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:
2026-09-26 18:47:02 -04:00
parent 21f2296c41
commit df53445efe
13 changed files with 971 additions and 1 deletions
+99
View File
@@ -1361,3 +1361,102 @@ export function getYoutubeMetricsToday() {
export function countYoutubeCacheRows() {
try { return db.prepare(`SELECT COUNT(1) AS n FROM youtube_search_cache`).get()?.n || 0; } catch { return 0; }
}
// -------------------- OAuth : connexions Google / Twitch (import favoris/abos) --------------------
function ensureOAuthTables() {
try {
db.exec(`CREATE TABLE IF NOT EXISTS oauth_connections (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
external_user_id TEXT,
display_name TEXT,
avatar_url TEXT,
access_token TEXT,
refresh_token TEXT,
expires_at INTEGER,
scopes TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (user_id, provider)
);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_oauth_connections_user ON oauth_connections(user_id);`);
} catch {}
}
ensureOAuthTables();
function oauthRowToDto(row, withTokens = false) {
if (!row) return null;
const dto = {
provider: row.provider,
externalUserId: row.external_user_id || null,
displayName: row.display_name || null,
avatarUrl: row.avatar_url || null,
scopes: row.scopes || null,
expiresAt: row.expires_at ?? null,
connectedAt: row.created_at ?? null,
updatedAt: row.updated_at ?? null,
};
if (withTokens) {
dto.accessToken = row.access_token || null;
dto.refreshToken = row.refresh_token || null;
}
return dto;
}
export function upsertOAuthConnection({ userId, provider, externalUserId, displayName, avatarUrl, accessToken, refreshToken, expiresAt, scopes }) {
ensureOAuthTables();
const now = Date.now();
const existing = db.prepare(`SELECT * FROM oauth_connections WHERE user_id = ? AND provider = ?`).get(userId, provider);
if (existing) {
db.prepare(`UPDATE oauth_connections SET external_user_id = ?, display_name = ?, avatar_url = ?,
access_token = COALESCE(?, access_token), refresh_token = COALESCE(?, refresh_token),
expires_at = COALESCE(?, expires_at), scopes = COALESCE(?, scopes), updated_at = ?
WHERE user_id = ? AND provider = ?`)
.run(
externalUserId ?? existing.external_user_id, displayName ?? existing.display_name,
avatarUrl ?? existing.avatar_url, accessToken ?? null, refreshToken ?? null,
expiresAt ?? null, scopes ?? null, now, userId, provider,
);
} else {
db.prepare(`INSERT INTO oauth_connections (user_id, provider, external_user_id, display_name, avatar_url,
access_token, refresh_token, expires_at, scopes, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
.run(userId, provider, externalUserId || null, displayName || null, avatarUrl || null,
accessToken || null, refreshToken || null, expiresAt ?? null, scopes || null, now, now);
}
return oauthRowToDto(db.prepare(`SELECT * FROM oauth_connections WHERE user_id = ? AND provider = ?`).get(userId, provider));
}
export function listOAuthConnections(userId) {
try {
ensureOAuthTables();
return db.prepare(`SELECT * FROM oauth_connections WHERE user_id = ?`).all(userId).map((r) => oauthRowToDto(r));
} catch { return []; }
}
export function getOAuthConnection(userId, provider, withTokens = false) {
try {
ensureOAuthTables();
return oauthRowToDto(
db.prepare(`SELECT * FROM oauth_connections WHERE user_id = ? AND provider = ?`).get(userId, provider),
withTokens,
);
} catch { return null; }
}
export function updateOAuthTokens(userId, provider, { accessToken, refreshToken, expiresAt }) {
try {
ensureOAuthTables();
db.prepare(`UPDATE oauth_connections SET access_token = COALESCE(?, access_token),
refresh_token = COALESCE(?, refresh_token), expires_at = COALESCE(?, expires_at), updated_at = ?
WHERE user_id = ? AND provider = ?`)
.run(accessToken ?? null, refreshToken ?? null, expiresAt ?? null, Date.now(), userId, provider);
} catch {}
}
export function deleteOAuthConnection(userId, provider) {
try {
ensureOAuthTables();
return db.prepare(`DELETE FROM oauth_connections WHERE user_id = ? AND provider = ?`).run(userId, provider);
} catch { return { changes: 0 }; }
}