diff --git a/.env.example b/.env.example index 61d7e03..6dd8311 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,11 @@ DATABASE_URL="file:./prisma/dev.db" # Optional: custom API base URL (proxy or local model server) # ANTHROPIC_BASE_URL= +# ── X (Twitter) OAuth 2.0 ────────────────────────────────────────── +# Get these from the X Developer Portal: https://developer.x.com +# X_OAUTH_CLIENT_ID= +# X_OAUTH_CLIENT_SECRET= + # ── Access control (optional) ──────────────────────────────────────── # Set BOTH to enable HTTP Basic Auth on the entire app. diff --git a/app/api/import/x-oauth/authorize/route.ts b/app/api/import/x-oauth/authorize/route.ts new file mode 100644 index 0000000..f2c7cbe --- /dev/null +++ b/app/api/import/x-oauth/authorize/route.ts @@ -0,0 +1,59 @@ +import { NextResponse } from 'next/server' +import { randomBytes, createHash } from 'crypto' +import prisma from '@/lib/db' + +export async function GET() { + try { + // Resolve client ID from DB or env + const dbClientId = await prisma.setting.findUnique({ where: { key: 'x_oauth_client_id' } }) + const clientId = dbClientId?.value || process.env.X_OAUTH_CLIENT_ID || '' + + if (!clientId) { + return NextResponse.json({ error: 'X OAuth Client ID not configured' }, { status: 400 }) + } + + // Generate PKCE code verifier + challenge + const codeVerifier = randomBytes(32).toString('base64url') + const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url') + + // Generate state for CSRF protection + const state = randomBytes(16).toString('hex') + + // Store verifier + state in DB so callback can use them + await Promise.all([ + prisma.setting.upsert({ + where: { key: 'x_oauth_code_verifier' }, + update: { value: codeVerifier }, + create: { key: 'x_oauth_code_verifier', value: codeVerifier }, + }), + prisma.setting.upsert({ + where: { key: 'x_oauth_state' }, + update: { value: state }, + create: { key: 'x_oauth_state', value: state }, + }), + ]) + + const redirectUri = `${process.env.X_OAUTH_REDIRECT_BASE || 'http://127.0.0.1:3000'}/api/import/x-oauth/callback` + const scopes = 'bookmark.read tweet.read users.read offline.access' + + const params = new URLSearchParams({ + response_type: 'code', + client_id: clientId, + redirect_uri: redirectUri, + scope: scopes, + state, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + }) + + const authUrl = `https://x.com/i/oauth2/authorize?${params.toString()}` + + return NextResponse.json({ authUrl }) + } catch (err) { + console.error('X OAuth authorize error:', err) + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Failed to start OAuth' }, + { status: 500 }, + ) + } +} diff --git a/app/api/import/x-oauth/callback/route.ts b/app/api/import/x-oauth/callback/route.ts new file mode 100644 index 0000000..7d724f7 --- /dev/null +++ b/app/api/import/x-oauth/callback/route.ts @@ -0,0 +1,122 @@ +import { NextRequest, NextResponse } from 'next/server' +import prisma from '@/lib/db' + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url) + const code = searchParams.get('code') + const state = searchParams.get('state') + const error = searchParams.get('error') + + const redirectBase = '/import' + + if (error) { + return NextResponse.redirect(new URL(`${redirectBase}?x_error=${encodeURIComponent(error)}`, request.url)) + } + + if (!code || !state) { + return NextResponse.redirect(new URL(`${redirectBase}?x_error=missing_params`, request.url)) + } + + try { + // Verify state + const savedState = await prisma.setting.findUnique({ where: { key: 'x_oauth_state' } }) + if (!savedState?.value || savedState.value !== state) { + return NextResponse.redirect(new URL(`${redirectBase}?x_error=invalid_state`, request.url)) + } + + // Get stored code verifier + const verifierSetting = await prisma.setting.findUnique({ where: { key: 'x_oauth_code_verifier' } }) + if (!verifierSetting?.value) { + return NextResponse.redirect(new URL(`${redirectBase}?x_error=missing_verifier`, request.url)) + } + + // Resolve client credentials + const [dbClientId, dbClientSecret] = await Promise.all([ + prisma.setting.findUnique({ where: { key: 'x_oauth_client_id' } }), + prisma.setting.findUnique({ where: { key: 'x_oauth_client_secret' } }), + ]) + const clientId = dbClientId?.value || process.env.X_OAUTH_CLIENT_ID || '' + const clientSecret = dbClientSecret?.value || process.env.X_OAUTH_CLIENT_SECRET || '' + + const redirectUri = `${process.env.X_OAUTH_REDIRECT_BASE || 'http://127.0.0.1:3000'}/api/import/x-oauth/callback` + + // Exchange code for tokens + const tokenBody = new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri, + code_verifier: verifierSetting.value, + client_id: clientId, + }) + + const headers: Record = { + 'Content-Type': 'application/x-www-form-urlencoded', + } + + // Use Basic auth if client secret is available (confidential client) + if (clientSecret) { + headers['Authorization'] = `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}` + } + + const tokenRes = await fetch('https://api.x.com/2/oauth2/token', { + method: 'POST', + headers, + body: tokenBody.toString(), + }) + + const tokenData = await tokenRes.json() + + if (!tokenRes.ok) { + console.error('X token exchange error:', tokenData) + return NextResponse.redirect( + new URL(`${redirectBase}?x_error=${encodeURIComponent(tokenData.error_description || tokenData.error || 'token_exchange_failed')}`, request.url), + ) + } + + // Fetch user info + let user: { id?: string; name?: string; username?: string } | null = null + try { + const userRes = await fetch('https://api.x.com/2/users/me', { + headers: { Authorization: `Bearer ${tokenData.access_token}` }, + }) + if (userRes.ok) { + const userData = await userRes.json() + user = userData.data ?? null + } + } catch { + // Non-fatal — we can still store the tokens + } + + // Store tokens in DB + const toStore = [ + { key: 'x_oauth_access_token', value: tokenData.access_token }, + { key: 'x_oauth_token_type', value: tokenData.token_type ?? 'bearer' }, + ...(tokenData.refresh_token ? [{ key: 'x_oauth_refresh_token', value: tokenData.refresh_token }] : []), + ...(tokenData.expires_in ? [{ key: 'x_oauth_expires_at', value: String(Date.now() + tokenData.expires_in * 1000) }] : []), + ...(user ? [{ key: 'x_oauth_user', value: JSON.stringify(user) }] : []), + ] + + await Promise.all( + toStore.map(({ key, value }) => + prisma.setting.upsert({ + where: { key }, + update: { value }, + create: { key, value }, + }), + ), + ) + + // Clean up verifier + state + await Promise.all([ + prisma.setting.deleteMany({ where: { key: 'x_oauth_code_verifier' } }), + prisma.setting.deleteMany({ where: { key: 'x_oauth_state' } }), + ]) + + return NextResponse.redirect(new URL(`${redirectBase}?x_connected=true`, request.url)) + } catch (err) { + console.error('X OAuth callback error:', err) + return NextResponse.redirect( + new URL(`${redirectBase}?x_error=${encodeURIComponent(err instanceof Error ? err.message : 'callback_failed')}`, request.url), + ) + } +} diff --git a/app/api/import/x-oauth/disconnect/route.ts b/app/api/import/x-oauth/disconnect/route.ts new file mode 100644 index 0000000..2bf6db1 --- /dev/null +++ b/app/api/import/x-oauth/disconnect/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from 'next/server' +import prisma from '@/lib/db' + +export async function POST() { + try { + // Optionally revoke the token with X + const accessToken = await prisma.setting.findUnique({ where: { key: 'x_oauth_access_token' } }) + if (accessToken?.value) { + const dbClientId = await prisma.setting.findUnique({ where: { key: 'x_oauth_client_id' } }) + const clientId = dbClientId?.value || process.env.X_OAUTH_CLIENT_ID || '' + + if (clientId) { + try { + await fetch('https://api.x.com/2/oauth2/revoke', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ token: accessToken.value, client_id: clientId }), + }) + } catch { + // Non-fatal — still clear local tokens + } + } + } + + // Clear all OAuth tokens from DB + await prisma.setting.deleteMany({ + where: { + key: { + in: [ + 'x_oauth_access_token', + 'x_oauth_refresh_token', + 'x_oauth_token_type', + 'x_oauth_expires_at', + 'x_oauth_user', + ], + }, + }, + }) + + return NextResponse.json({ ok: true }) + } catch (err) { + console.error('X OAuth disconnect error:', err) + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Failed to disconnect' }, + { status: 500 }, + ) + } +} diff --git a/app/api/import/x-oauth/fetch/route.ts b/app/api/import/x-oauth/fetch/route.ts new file mode 100644 index 0000000..69eeab7 --- /dev/null +++ b/app/api/import/x-oauth/fetch/route.ts @@ -0,0 +1,239 @@ +import { NextRequest, NextResponse } from 'next/server' +import prisma from '@/lib/db' + +interface TweetData { + id: string + text: string + created_at?: string + author_id?: string + attachments?: { media_keys?: string[] } +} + +interface UserData { + id: string + name: string + username: string +} + +interface MediaData { + media_key: string + type: string + url?: string + preview_image_url?: string +} + +async function refreshAccessToken(): Promise { + const [refreshToken, dbClientId, dbClientSecret] = await Promise.all([ + prisma.setting.findUnique({ where: { key: 'x_oauth_refresh_token' } }), + prisma.setting.findUnique({ where: { key: 'x_oauth_client_id' } }), + prisma.setting.findUnique({ where: { key: 'x_oauth_client_secret' } }), + ]) + + if (!refreshToken?.value) return null + + const clientId = dbClientId?.value || process.env.X_OAUTH_CLIENT_ID || '' + const clientSecret = dbClientSecret?.value || process.env.X_OAUTH_CLIENT_SECRET || '' + + const headers: Record = { + 'Content-Type': 'application/x-www-form-urlencoded', + } + + if (clientSecret) { + headers['Authorization'] = `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}` + } + + const res = await fetch('https://api.x.com/2/oauth2/token', { + method: 'POST', + headers, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshToken.value, + client_id: clientId, + }), + }) + + if (!res.ok) return null + + const data = await res.json() + + // Store new tokens + const toStore = [ + { key: 'x_oauth_access_token', value: data.access_token }, + ...(data.refresh_token ? [{ key: 'x_oauth_refresh_token', value: data.refresh_token }] : []), + ...(data.expires_in ? [{ key: 'x_oauth_expires_at', value: String(Date.now() + data.expires_in * 1000) }] : []), + ] + + await Promise.all( + toStore.map(({ key, value }) => + prisma.setting.upsert({ + where: { key }, + update: { value }, + create: { key, value }, + }), + ), + ) + + return data.access_token +} + +async function getAccessToken(): Promise { + const [tokenSetting, expiresAt] = await Promise.all([ + prisma.setting.findUnique({ where: { key: 'x_oauth_access_token' } }), + prisma.setting.findUnique({ where: { key: 'x_oauth_expires_at' } }), + ]) + + if (!tokenSetting?.value) return null + + // Try refresh if expired + if (expiresAt?.value && Date.now() > Number(expiresAt.value)) { + const refreshed = await refreshAccessToken() + if (refreshed) return refreshed + } + + return tokenSetting.value +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json().catch(() => ({})) + const maxPages = Math.min(body.maxPages ?? 5, 20) + + const accessToken = await getAccessToken() + if (!accessToken) { + return NextResponse.json({ error: 'Not connected to X. Please authorize first.' }, { status: 401 }) + } + + // Fetch the authenticated user's ID + const meRes = await fetch('https://api.x.com/2/users/me', { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + if (!meRes.ok) { + const err = await meRes.json().catch(() => ({})) + return NextResponse.json( + { error: `Failed to get user info: ${err.detail || err.title || meRes.statusText}` }, + { status: meRes.status }, + ) + } + const meData = await meRes.json() + const userId = meData.data?.id + + if (!userId) { + return NextResponse.json({ error: 'Could not determine user ID' }, { status: 500 }) + } + + // Paginate through bookmarks + let paginationToken: string | undefined + let totalFetched = 0 + let importedCount = 0 + let skippedCount = 0 + const allUsers = new Map() + const allMedia = new Map() + + for (let page = 0; page < maxPages; page++) { + const params = new URLSearchParams({ + 'tweet.fields': 'created_at,author_id,attachments', + 'user.fields': 'name,username', + 'media.fields': 'type,url,preview_image_url', + expansions: 'author_id,attachments.media_keys', + max_results: '100', + }) + if (paginationToken) params.set('pagination_token', paginationToken) + + const bmRes = await fetch( + `https://api.x.com/2/users/${userId}/bookmarks?${params.toString()}`, + { headers: { Authorization: `Bearer ${accessToken}` } }, + ) + + if (!bmRes.ok) { + const err = await bmRes.json().catch(() => ({})) + if (totalFetched === 0) { + return NextResponse.json( + { error: `X API error: ${err.detail || err.title || bmRes.statusText}` }, + { status: bmRes.status }, + ) + } + break // Return partial results + } + + const bmData = await bmRes.json() + const tweets: TweetData[] = bmData.data ?? [] + + if (tweets.length === 0) break + + // Index includes + for (const u of (bmData.includes?.users ?? []) as UserData[]) { + allUsers.set(u.id, u) + } + for (const m of (bmData.includes?.media ?? []) as MediaData[]) { + allMedia.set(m.media_key, m) + } + + // Import each tweet + for (const tweet of tweets) { + totalFetched++ + try { + const existing = await prisma.bookmark.findUnique({ + where: { tweetId: tweet.id }, + select: { id: true }, + }) + + if (existing) { + skippedCount++ + continue + } + + const author = tweet.author_id ? allUsers.get(tweet.author_id) : undefined + + const created = await prisma.bookmark.create({ + data: { + tweetId: tweet.id, + text: tweet.text, + authorHandle: author?.username ?? null, + authorName: author?.name ?? null, + tweetCreatedAt: tweet.created_at ? new Date(tweet.created_at) : null, + rawJson: JSON.stringify(tweet), + source: 'bookmark', + }, + }) + + // Import media + const mediaKeys = tweet.attachments?.media_keys ?? [] + const mediaItems = mediaKeys + .map((mk) => allMedia.get(mk)) + .filter((m): m is MediaData => !!m) + + if (mediaItems.length > 0) { + await prisma.mediaItem.createMany({ + data: mediaItems.map((m) => ({ + bookmarkId: created.id, + type: m.type === 'photo' ? 'image' : m.type, + url: m.url ?? m.preview_image_url ?? '', + thumbnailUrl: m.preview_image_url ?? null, + })), + }) + } + + importedCount++ + } catch (err) { + console.error(`Failed to import tweet ${tweet.id}:`, err) + skippedCount++ + } + } + + paginationToken = bmData.meta?.next_token + if (!paginationToken) break + } + + return NextResponse.json({ + imported: importedCount, + skipped: skippedCount, + total: totalFetched, + }) + } catch (err) { + console.error('X OAuth fetch error:', err) + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Fetch failed' }, + { status: 500 }, + ) + } +} diff --git a/app/api/import/x-oauth/status/route.ts b/app/api/import/x-oauth/status/route.ts new file mode 100644 index 0000000..372343e --- /dev/null +++ b/app/api/import/x-oauth/status/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from 'next/server' +import prisma from '@/lib/db' + +export async function GET() { + try { + const [clientId, accessToken, expiresAt, userSetting] = await Promise.all([ + prisma.setting.findUnique({ where: { key: 'x_oauth_client_id' } }), + prisma.setting.findUnique({ where: { key: 'x_oauth_access_token' } }), + prisma.setting.findUnique({ where: { key: 'x_oauth_expires_at' } }), + prisma.setting.findUnique({ where: { key: 'x_oauth_user' } }), + ]) + + const resolvedClientId = clientId?.value || process.env.X_OAUTH_CLIENT_ID || '' + const configured = resolvedClientId.length > 0 + const connected = !!accessToken?.value + + let tokenExpired = false + if (expiresAt?.value) { + tokenExpired = Date.now() > Number(expiresAt.value) + } + + let user = null + if (userSetting?.value) { + try { user = JSON.parse(userSetting.value) } catch {} + } + + return NextResponse.json({ configured, connected, tokenExpired, user }) + } catch (err) { + console.error('X OAuth status error:', err) + return NextResponse.json({ configured: false, connected: false }, { status: 500 }) + } +} diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index f06373e..5e6191f 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -42,9 +42,9 @@ export async function GET(): Promise { openaiApiKey: maskKey(openai?.value ?? null), hasOpenaiKey: openai !== null, openaiModel: openaiModel?.value ?? 'gpt-4.1-mini', - xOAuthClientId: maskKey(xClientId?.value ?? null), - xOAuthClientSecret: maskKey(xClientSecret?.value ?? null), - hasXOAuth: !!xClientId?.value, + xOAuthClientId: maskKey(xClientId?.value ?? process.env.X_OAUTH_CLIENT_ID ?? null), + xOAuthClientSecret: maskKey(xClientSecret?.value ?? process.env.X_OAUTH_CLIENT_SECRET ?? null), + hasXOAuth: !!(xClientId?.value || process.env.X_OAUTH_CLIENT_ID), }) } catch (err) { console.error('Settings GET error:', err)