Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
59 changes: 59 additions & 0 deletions app/api/import/x-oauth/authorize/route.ts
Original file line number Diff line number Diff line change
@@ -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 },
)
}
}
122 changes: 122 additions & 0 deletions app/api/import/x-oauth/callback/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
'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),
)
}
}
48 changes: 48 additions & 0 deletions app/api/import/x-oauth/disconnect/route.ts
Original file line number Diff line number Diff line change
@@ -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 },
)
}
}
Loading