Files
svemagie 3dda28d3dc feat: add YouTube liked videos sync via OAuth 2.0
Adds OAuth 2.0 flow to connect a YouTube account and sync liked
videos as "like" posts on the blog. Includes:
- OAuth authorize/callback/disconnect flow with token persistence
- getLikedVideos() method using videos.list?myRating=like
- Background periodic sync + manual sync trigger
- Dashboard UI for connection status and sync controls
- Public JSON API for querying synced likes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 20:53:38 +01:00

165 lines
4.8 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* YouTube OAuth 2.0 helpers
*
* Uses Google's OAuth 2.0 endpoints to obtain a user token with
* `youtube.readonly` scope so we can read the authenticated user's
* likedvideos list.
*
* Tokens (access + refresh) are persisted in a MongoDB collection
* (`youtubeMeta`) so the user only has to authorize once.
*/
const GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
const SCOPES = "https://www.googleapis.com/auth/youtube.readonly";
/**
* Build the Google OAuth authorization URL.
* @param {object} opts
* @param {string} opts.clientId
* @param {string} opts.redirectUri
* @param {string} [opts.state]
* @returns {string}
*/
export function buildAuthUrl({ clientId, redirectUri, state }) {
const url = new URL(GOOGLE_AUTH_URL);
url.searchParams.set("client_id", clientId);
url.searchParams.set("redirect_uri", redirectUri);
url.searchParams.set("response_type", "code");
url.searchParams.set("scope", SCOPES);
url.searchParams.set("access_type", "offline");
url.searchParams.set("prompt", "consent");
if (state) url.searchParams.set("state", state);
return url.toString();
}
/**
* Exchange an authorization code for tokens.
* @param {object} opts
* @param {string} opts.code
* @param {string} opts.clientId
* @param {string} opts.clientSecret
* @param {string} opts.redirectUri
* @returns {Promise<{access_token: string, refresh_token?: string, expires_in: number}>}
*/
export async function exchangeCode({ code, clientId, clientSecret, redirectUri }) {
const response = await fetch(GOOGLE_TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
code,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: redirectUri,
grant_type: "authorization_code",
}),
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(`Token exchange failed: ${err.error_description || response.statusText}`);
}
return response.json();
}
/**
* Refresh an access token using a refresh token.
* @param {object} opts
* @param {string} opts.refreshToken
* @param {string} opts.clientId
* @param {string} opts.clientSecret
* @returns {Promise<{access_token: string, expires_in: number}>}
*/
export async function refreshAccessToken({ refreshToken, clientId, clientSecret }) {
const response = await fetch(GOOGLE_TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
refresh_token: refreshToken,
client_id: clientId,
client_secret: clientSecret,
grant_type: "refresh_token",
}),
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(`Token refresh failed: ${err.error_description || response.statusText}`);
}
return response.json();
}
/**
* Persist tokens to MongoDB.
* @param {import("mongodb").Db} db
* @param {object} tokens - { access_token, refresh_token?, expires_in }
*/
export async function saveTokens(db, tokens) {
const expiresAt = new Date(Date.now() + (tokens.expires_in || 3600) * 1000);
const update = {
$set: {
key: "oauth_tokens",
accessToken: tokens.access_token,
expiresAt,
updatedAt: new Date(),
},
};
// Only overwrite refresh_token when a new one is provided
if (tokens.refresh_token) {
update.$set.refreshToken = tokens.refresh_token;
}
await db.collection("youtubeMeta").updateOne(
{ key: "oauth_tokens" },
update,
{ upsert: true },
);
}
/**
* Load tokens from MongoDB.
* @param {import("mongodb").Db} db
* @returns {Promise<{accessToken: string, refreshToken: string, expiresAt: Date}|null>}
*/
export async function loadTokens(db) {
return db.collection("youtubeMeta").findOne({ key: "oauth_tokens" });
}
/**
* Get a valid access token, refreshing if needed.
* @param {import("mongodb").Db} db
* @param {object} opts - { clientId, clientSecret }
* @returns {Promise<string|null>}
*/
export async function getValidAccessToken(db, { clientId, clientSecret }) {
const stored = await loadTokens(db);
if (!stored?.refreshToken) return null;
// If the access token hasn't expired yet (with 60s buffer), use it
if (stored.accessToken && stored.expiresAt && new Date(stored.expiresAt) > new Date(Date.now() + 60_000)) {
return stored.accessToken;
}
// Refresh
const fresh = await refreshAccessToken({
refreshToken: stored.refreshToken,
clientId,
clientSecret,
});
await saveTokens(db, fresh);
return fresh.access_token;
}
/**
* Delete stored tokens (disconnect).
* @param {import("mongodb").Db} db
*/
export async function deleteTokens(db) {
await db.collection("youtubeMeta").deleteOne({ key: "oauth_tokens" });
}