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:
@@ -75,6 +75,12 @@ import {
|
||||
setSubscriptionGroupMembers,
|
||||
setSubscriptionGroups,
|
||||
listSubscriptionGroupMembersByUser,
|
||||
upsertOAuthConnection,
|
||||
listOAuthConnections,
|
||||
getOAuthConnection,
|
||||
updateOAuthTokens,
|
||||
deleteOAuthConnection,
|
||||
upsertChannelRow,
|
||||
insertDownloadJob,
|
||||
getDownloadJob,
|
||||
listDownloadJobs,
|
||||
@@ -93,6 +99,11 @@ import {
|
||||
} from './db.mjs';
|
||||
import { getChannelAdapter, setTwitchTokenProvider } from './providers/channel-registry.mjs';
|
||||
import { fetchChannelContent } from './providers/channel-content.mjs';
|
||||
import {
|
||||
oauthStatus, buildAuthUrl, createOAuthState, consumeOAuthState, exchangeCode,
|
||||
refreshAccessToken, redirectUriFor, fetchGoogleProfile, fetchGoogleSubscriptions,
|
||||
fetchGoogleLiked, fetchTwitchProfile, fetchTwitchFollows,
|
||||
} from './oauth.mjs';
|
||||
import { getSearchMode, getYtDlpBin, hasCookiesFile, metricsSnapshot } from './providers/youtube-common.mjs';
|
||||
import { ytScrapeCacheStats } from './providers/youtube.mjs';
|
||||
|
||||
@@ -438,6 +449,191 @@ r.put('/subscriptions/:subscriptionId/groups', authMiddlewareCookieAware, subscr
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------- OAuth Google / Twitch (import favoris + abonnements) --------------------
|
||||
|
||||
const oauthLimiter = rateLimit({ windowMs: 60 * 1000, max: 30, standardHeaders: true, legacyHeaders: false });
|
||||
|
||||
function requireOAuthProvider(value) {
|
||||
const p = String(value || '').toLowerCase();
|
||||
if (p !== 'google' && p !== 'twitch') throw Object.assign(new Error('invalid_oauth_provider'), { status: 400 });
|
||||
return p;
|
||||
}
|
||||
|
||||
/** Token frais (refresh si expiré dans < 60 s et refresh_token dispo). */
|
||||
async function freshOAuthToken(userId, provider) {
|
||||
const conn = getOAuthConnection(userId, provider, true);
|
||||
if (!conn?.accessToken) throw Object.assign(new Error('oauth_not_connected'), { status: 404 });
|
||||
const expired = typeof conn.expiresAt === 'number' && conn.expiresAt - Date.now() < 60_000;
|
||||
if (!expired || !conn.refreshToken) return conn;
|
||||
const refreshed = await refreshAccessToken(provider, conn.refreshToken);
|
||||
updateOAuthTokens(userId, provider, refreshed);
|
||||
return { ...conn, accessToken: refreshed.accessToken, refreshToken: refreshed.refreshToken, expiresAt: refreshed.expiresAt };
|
||||
}
|
||||
|
||||
// Public : l'UI affiche "Connecter" seulement si configuré côté serveur.
|
||||
r.get('/oauth/status', (req, res) => res.json(oauthStatus()));
|
||||
|
||||
// URL d'autorisation (connecté requis : le state mémorise l'utilisateur).
|
||||
r.get('/oauth/:provider/url', authMiddlewareCookieAware, oauthLimiter, (req, res) => {
|
||||
try {
|
||||
const provider = requireOAuthProvider(req.params.provider);
|
||||
const status = oauthStatus()[provider];
|
||||
if (!status?.configured) return res.status(503).json({ error: `${provider}_oauth_not_configured`, missing: status?.missing || [] });
|
||||
const state = createOAuthState(req.user.id, provider);
|
||||
return res.json({ url: buildAuthUrl(provider, state, req) });
|
||||
} catch (error) {
|
||||
return res.status(error?.status || 500).json({ error: error?.message || 'oauth_url_failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Callback OAuth (public : retrouvé via le state). Redirige vers le front.
|
||||
r.get('/oauth/:provider/callback', oauthLimiter, async (req, res) => {
|
||||
const provider = String(req.params.provider || '').toLowerCase();
|
||||
const frontBase = (() => {
|
||||
try {
|
||||
const explicit = String(process.env.OAUTH_APP_BASE_URL || '').trim().replace(/\/+$/, '');
|
||||
if (explicit) return explicit;
|
||||
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 || '').trim();
|
||||
// En prod Docker le callback arrive sur :4000 mais l'UI est sur :4200 (hôte).
|
||||
// Si l'hôte pointe le port API interne, on rebascule vers le port public.
|
||||
const publicPort = String(process.env.OAUTH_PUBLIC_PORT || process.env.HOST_PORT || '').trim();
|
||||
if (host && publicPort) {
|
||||
const bare = host.split(':')[0];
|
||||
return `${proto}://${bare}:${publicPort}`;
|
||||
}
|
||||
if (host) return `${proto}://${host}`;
|
||||
} catch {}
|
||||
return 'http://localhost:4200';
|
||||
})();
|
||||
const fail = (code) => res.redirect(302, `${frontBase}/library/import?provider=${provider}&error=${encodeURIComponent(code)}`);
|
||||
try {
|
||||
if (provider !== 'google' && provider !== 'twitch') return fail('invalid_oauth_provider');
|
||||
if (req.query?.error) return fail(String(req.query.error_description || req.query.error));
|
||||
const { code, state } = req.query || {};
|
||||
if (!code || !state) return fail('oauth_missing_code_or_state');
|
||||
const entry = consumeOAuthState(String(state));
|
||||
if (!entry || entry.provider !== provider) return fail('oauth_invalid_state');
|
||||
const tokens = await exchangeCode(provider, String(code), req);
|
||||
if (!tokens?.accessToken) return fail('oauth_token_failed');
|
||||
let profile = { id: '', displayName: provider, avatarUrl: '' };
|
||||
try {
|
||||
profile = provider === 'google' ? await fetchGoogleProfile(tokens.accessToken) : await fetchTwitchProfile(tokens.accessToken);
|
||||
} catch {}
|
||||
upsertOAuthConnection({
|
||||
userId: entry.userId, provider, externalUserId: profile.id || null,
|
||||
displayName: profile.displayName || null, avatarUrl: profile.avatarUrl || null,
|
||||
accessToken: tokens.accessToken, refreshToken: tokens.refreshToken,
|
||||
expiresAt: tokens.expiresAt, scopes: tokens.scopes,
|
||||
});
|
||||
return res.redirect(302, `${frontBase}/library/import?provider=${provider}&connected=1`);
|
||||
} catch (error) {
|
||||
return fail(error?.message || 'oauth_callback_failed');
|
||||
}
|
||||
});
|
||||
|
||||
r.get('/oauth/connections', authMiddlewareCookieAware, oauthLimiter, (req, res) => {
|
||||
try {
|
||||
return res.json({ connections: listOAuthConnections(req.user.id), status: oauthStatus() });
|
||||
} catch {
|
||||
return res.status(500).json({ error: 'oauth_connections_failed' });
|
||||
}
|
||||
});
|
||||
|
||||
r.delete('/oauth/:provider', authMiddlewareCookieAware, oauthLimiter, (req, res) => {
|
||||
try {
|
||||
const provider = requireOAuthProvider(req.params.provider);
|
||||
deleteOAuthConnection(req.user.id, provider);
|
||||
return res.status(204).end();
|
||||
} catch (error) {
|
||||
return res.status(error?.status || 500).json({ error: error?.message || 'oauth_disconnect_failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Aperçu distant (sans rien importer) : abonnements + favoris.
|
||||
r.get('/oauth/:provider/preview', authMiddlewareCookieAware, oauthLimiter, async (req, res) => {
|
||||
try {
|
||||
const provider = requireOAuthProvider(req.params.provider);
|
||||
const conn = await freshOAuthToken(req.user.id, provider);
|
||||
if (provider === 'google') {
|
||||
const [subscriptions, likes] = await Promise.all([
|
||||
fetchGoogleSubscriptions(conn.accessToken, 50).catch(() => []),
|
||||
fetchGoogleLiked(conn.accessToken, 25).catch(() => []),
|
||||
]);
|
||||
return res.json({ provider, subscriptions, likes, subscriptionCount: subscriptions.length, likeCount: likes.length });
|
||||
}
|
||||
const profile = await fetchTwitchProfile(conn.accessToken).catch(() => ({ id: conn.externalUserId || '' }));
|
||||
const follows = await fetchTwitchFollows(conn.accessToken, profile?.id || conn.externalUserId || '', 100);
|
||||
return res.json({ provider, subscriptions: follows, likes: [], subscriptionCount: follows.length, likeCount: 0 });
|
||||
} catch (error) {
|
||||
const status = error?.status || 502;
|
||||
return res.status(status).json({ error: error?.message || 'oauth_preview_failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Import : { types: ['subscriptions','likes'] } (likes = Google uniquement).
|
||||
r.post('/oauth/:provider/import', authMiddlewareCookieAware, oauthLimiter, async (req, res) => {
|
||||
try {
|
||||
const provider = requireOAuthProvider(req.params.provider);
|
||||
const rawTypes = Array.isArray(req.body?.types) ? req.body.types : ['subscriptions', 'likes'];
|
||||
const wantSubs = rawTypes.includes('subscriptions');
|
||||
const wantLikes = rawTypes.includes('likes');
|
||||
const conn = await freshOAuthToken(req.user.id, provider);
|
||||
let importedSubscriptions = 0;
|
||||
let importedLikes = 0;
|
||||
let skippedSubscriptions = 0;
|
||||
let skippedLikes = 0;
|
||||
|
||||
if (provider === 'google') {
|
||||
if (wantSubs) {
|
||||
const subs = await fetchGoogleSubscriptions(conn.accessToken, 50);
|
||||
for (const s of subs) {
|
||||
try {
|
||||
const row = upsertChannelRow({
|
||||
provider: 'youtube', externalId: s.externalId, title: s.title,
|
||||
handle: s.handle || null, avatarUrl: s.avatarUrl || null, url: s.url || null,
|
||||
lastRefreshedAt: Date.now(),
|
||||
});
|
||||
subscribeChannel({ userId: req.user.id, provider: 'youtube', externalId: s.externalId, channelId: row?.id });
|
||||
importedSubscriptions++;
|
||||
} catch { skippedSubscriptions++; }
|
||||
}
|
||||
}
|
||||
if (wantLikes) {
|
||||
const likes = await fetchGoogleLiked(conn.accessToken, 25);
|
||||
for (const v of likes) {
|
||||
try {
|
||||
likeVideo({ userId: req.user.id, provider: 'youtube', videoId: v.videoId, title: v.title, thumbnail: v.thumbnail });
|
||||
importedLikes++;
|
||||
} catch { skippedLikes++; }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (wantSubs) {
|
||||
const profile = await fetchTwitchProfile(conn.accessToken).catch(() => ({ id: conn.externalUserId || '' }));
|
||||
const follows = await fetchTwitchFollows(conn.accessToken, profile?.id || conn.externalUserId || '', 100);
|
||||
for (const f of follows) {
|
||||
try {
|
||||
const row = upsertChannelRow({
|
||||
provider: 'twitch', externalId: f.externalId, title: f.title,
|
||||
handle: f.handle || null, avatarUrl: f.avatarUrl || null, url: f.url || null,
|
||||
lastRefreshedAt: Date.now(),
|
||||
});
|
||||
subscribeChannel({ userId: req.user.id, provider: 'twitch', externalId: f.externalId, channelId: row?.id });
|
||||
importedSubscriptions++;
|
||||
} catch { skippedSubscriptions++; }
|
||||
}
|
||||
}
|
||||
// Twitch : pas de likes vidéo → on l'indique au lieu d'échouer silencieusement.
|
||||
if (wantLikes) skippedLikes = 0;
|
||||
}
|
||||
return res.json({ provider, importedSubscriptions, importedLikes, skippedSubscriptions, skippedLikes });
|
||||
} catch (error) {
|
||||
const status = error?.status || 502;
|
||||
return res.status(status).json({ error: error?.message || 'oauth_import_failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Public: view a playlist if allowed (owner or public). Authorization header is optional.
|
||||
r.get('/playlists/:id/view', (req, res) => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user