@nextsparkjs/plugin-social-media-publisher 0.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,669 @@
1
+ /**
2
+ * OAuth Callback Handler for Social Media Publishing
3
+ *
4
+ * GET endpoint that receives the OAuth redirect from Facebook
5
+ * Accessible via: /api/v1/plugin/social-media-publisher/social/connect/callback
6
+ *
7
+ * Query params:
8
+ * - code: Authorization code from Facebook
9
+ * - state: CSRF protection token with clientId embedded (format: {randomState}&platform={platform}&clientId={clientId})
10
+ * - error: (optional) Error if user denied permission
11
+ * - error_description: (optional) Error description
12
+ *
13
+ * Architecture:
14
+ * - Uses child entity API (/api/v1/clients/{clientId}/social-platforms) instead of direct DB inserts
15
+ * - Social accounts belong to clients, not users
16
+ * - Redirects to /clients/{clientId}/social-platforms on success/error
17
+ */
18
+
19
+ import { NextRequest, NextResponse } from 'next/server'
20
+ import { authenticateRequest } from '@nextsparkjs/core/lib/api/auth/dual-auth'
21
+ import { TokenEncryption } from '@nextsparkjs/core/lib/oauth/encryption'
22
+ import { FacebookAPI } from '../../../../lib/providers/facebook'
23
+ import {
24
+ exchangeCodeForToken,
25
+ getOAuthConfig
26
+ } from '../../../../lib/oauth-helper'
27
+ import { mutateWithRLS } from '@nextsparkjs/core/lib/db'
28
+
29
+ export async function GET(request: NextRequest) {
30
+ try {
31
+ // 1. Parse state to extract clientId, platform, and mode
32
+ const { searchParams } = new URL(request.url)
33
+ const state = searchParams.get('state') || ''
34
+
35
+ // State format: "{randomState}&platform={platform}&clientId={clientId}&mode={mode}"
36
+ const stateParams = new URLSearchParams(state)
37
+ const clientId = stateParams.get('clientId')
38
+ const platform = stateParams.get('platform') || 'instagram_business'
39
+ const mode = stateParams.get('mode') // 'preview' = return data without saving, undefined = save to DB (default)
40
+
41
+ console.log('[oauth-callback] Received OAuth callback:', {
42
+ platform,
43
+ clientId,
44
+ mode: mode || 'save (default)',
45
+ hasCode: !!searchParams.get('code')
46
+ })
47
+
48
+ // 2. Check for OAuth errors (user denied, etc.)
49
+ const error = searchParams.get('error')
50
+ const errorDescription = searchParams.get('error_description')
51
+
52
+ if (error) {
53
+ console.error('[oauth-callback] OAuth error:', error, errorDescription)
54
+
55
+ // Map Meta OAuth errors to user-friendly error types
56
+ let errorType = error
57
+ let userMessage = errorDescription || 'Authentication failed'
58
+
59
+ if (error === 'access_denied') {
60
+ errorType = 'user_cancelled'
61
+ userMessage = 'You cancelled the authorization process'
62
+ } else if (error === 'unauthorized_client') {
63
+ errorType = 'app_not_authorized'
64
+ userMessage = 'This app is not authorized to access your Facebook account'
65
+ } else if (error === 'server_error' || error === 'temporarily_unavailable') {
66
+ errorType = 'meta_server_error'
67
+ userMessage = 'Facebook is temporarily unavailable. Please try again later'
68
+ }
69
+
70
+ // Return HTML page that sends error postMessage to opener window
71
+ const html = `
72
+ <!DOCTYPE html>
73
+ <html>
74
+ <head>
75
+ <title>OAuth Error</title>
76
+ <style>
77
+ body {
78
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
79
+ display: flex;
80
+ align-items: center;
81
+ justify-content: center;
82
+ height: 100vh;
83
+ margin: 0;
84
+ background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
85
+ color: white;
86
+ }
87
+ .container {
88
+ text-align: center;
89
+ padding: 2rem;
90
+ }
91
+ .error-icon {
92
+ font-size: 4rem;
93
+ margin-bottom: 1rem;
94
+ }
95
+ h1 {
96
+ font-size: 1.5rem;
97
+ margin-bottom: 0.5rem;
98
+ }
99
+ p {
100
+ opacity: 0.9;
101
+ }
102
+ </style>
103
+ </head>
104
+ <body>
105
+ <div class="container">
106
+ <div class="error-icon">❌</div>
107
+ <h1>Authentication Failed</h1>
108
+ <p>${userMessage.replace(/'/g, "\\'")}. This window will close automatically...</p>
109
+ </div>
110
+ <script>
111
+ // Send error message to parent window
112
+ if (window.opener) {
113
+ window.opener.postMessage({
114
+ type: 'oauth-error',
115
+ error: '${errorType}',
116
+ errorDescription: '${userMessage.replace(/'/g, "\\'")}'
117
+ }, window.location.origin);
118
+ }
119
+
120
+ // Close this popup after 3 seconds
121
+ setTimeout(() => {
122
+ window.close();
123
+ }, 3000);
124
+ </script>
125
+ </body>
126
+ </html>
127
+ `
128
+
129
+ return new NextResponse(html, {
130
+ headers: {
131
+ 'Content-Type': 'text/html',
132
+ },
133
+ })
134
+ }
135
+
136
+ // 3. Validate required parameters
137
+ if (!clientId) {
138
+ return NextResponse.redirect(
139
+ new URL(
140
+ `/dashboard?error=missing_client&message=Client ID not provided in OAuth flow`,
141
+ request.url
142
+ )
143
+ )
144
+ }
145
+
146
+ const code = searchParams.get('code')
147
+ if (!code) {
148
+ return NextResponse.redirect(
149
+ new URL(
150
+ `/clients/${clientId}/social-platforms?error=missing_code&message=Authorization code not provided`,
151
+ request.url
152
+ )
153
+ )
154
+ }
155
+
156
+ // 4. Authentication (user must be logged in)
157
+ const authResult = await authenticateRequest(request)
158
+ if (!authResult.success) {
159
+ return NextResponse.redirect(
160
+ new URL(
161
+ `/auth/login?error=authentication_required&message=You must be logged in to connect accounts&redirect=/clients/${clientId}/social-platforms`,
162
+ request.url
163
+ )
164
+ )
165
+ }
166
+
167
+ // 5. Verify user owns the client
168
+ const clientCheckResult = await mutateWithRLS<{ id: string }>(
169
+ `SELECT id FROM "clients" WHERE id = $1 AND "userId" = $2`,
170
+ [clientId, authResult.user!.id],
171
+ authResult.user!.id
172
+ )
173
+
174
+ if (clientCheckResult.rows.length === 0) {
175
+ return NextResponse.redirect(
176
+ new URL(
177
+ `/clients/${clientId}/social-platforms?error=unauthorized&message=You do not have access to this client`,
178
+ request.url
179
+ )
180
+ )
181
+ }
182
+
183
+ // 6. State validation (CSRF protection)
184
+ // TODO: Implement proper state validation with session storage
185
+ // For now, we'll log it and accept any state with a warning
186
+ if (state) {
187
+ console.log('[oauth-callback] Received state:', state)
188
+ console.warn('[oauth-callback] ⚠️ State validation not yet implemented - potential CSRF vulnerability')
189
+ }
190
+
191
+ // 5. Exchange authorization code for access token
192
+ const oauthConfig = getOAuthConfig()
193
+ const tokenData = await exchangeCodeForToken(
194
+ code,
195
+ oauthConfig,
196
+ platform as 'facebook_page' | 'instagram_business'
197
+ )
198
+
199
+ const userAccessToken = tokenData.accessToken
200
+ let expiresIn = tokenData.expiresIn
201
+
202
+ // 6. Get accounts based on platform
203
+ let accountsToConnect: Array<{
204
+ platformAccountId: string
205
+ username: string
206
+ accessToken: string
207
+ permissions: string[]
208
+ metadata: any
209
+ }> = []
210
+
211
+ if (platform === 'facebook_page') {
212
+ // Get Facebook Pages
213
+ const pages = await FacebookAPI.getUserPages(userAccessToken)
214
+
215
+ accountsToConnect = pages.map(page => ({
216
+ platformAccountId: page.id,
217
+ username: page.name,
218
+ accessToken: page.accessToken, // Use page token, not user token
219
+ permissions: page.tasks || [],
220
+ metadata: {
221
+ category: page.category,
222
+ pictureUrl: page.pictureUrl,
223
+ },
224
+ }))
225
+ } else if (platform === 'instagram_business') {
226
+ // Instagram Graph API (via Facebook Pages)
227
+ console.log('[oauth-callback] Using Instagram Graph API (via Facebook Pages)')
228
+
229
+ // Step 1: Get user's Facebook Pages
230
+ console.log('[oauth-callback] Fetching Facebook Pages...')
231
+ const pages = await FacebookAPI.getUserPages(userAccessToken)
232
+ console.log(`[oauth-callback] Found ${pages.length} Facebook Pages`)
233
+
234
+ // Step 2: For each Page, check if it has Instagram Business Account
235
+ for (const page of pages) {
236
+ console.log(`[oauth-callback] Checking Page "${page.name}" for Instagram...`)
237
+
238
+ try {
239
+ const igAccount = await FacebookAPI.getInstagramBusinessAccount(
240
+ page.id,
241
+ page.accessToken // Use Page token, not user token
242
+ )
243
+
244
+ if (igAccount) {
245
+ console.log(`[oauth-callback] ✅ Found Instagram @${igAccount.username} linked to Page "${page.name}"`)
246
+
247
+ accountsToConnect.push({
248
+ platformAccountId: igAccount.id,
249
+ username: igAccount.username,
250
+ accessToken: page.accessToken, // Use Page token for Instagram Graph API calls
251
+ permissions: [
252
+ 'instagram_basic',
253
+ 'instagram_content_publish',
254
+ 'instagram_manage_comments',
255
+ 'pages_show_list',
256
+ 'pages_read_engagement',
257
+ ],
258
+ metadata: {
259
+ // Instagram Graph API data (read-only, refreshable)
260
+ username: igAccount.username,
261
+ name: igAccount.name,
262
+ profilePictureUrl: igAccount.profilePictureUrl,
263
+ followersCount: igAccount.followersCount,
264
+ followsCount: igAccount.followsCount,
265
+ mediaCount: igAccount.mediaCount,
266
+ biography: igAccount.biography,
267
+ website: igAccount.website,
268
+ lastSyncedAt: new Date().toISOString(),
269
+
270
+ // Facebook Page info (for reference)
271
+ facebookPageId: page.id,
272
+ facebookPageName: page.name,
273
+ facebookPageCategory: page.category,
274
+
275
+ // User-editable fields (pre-filled, modifiable in form)
276
+ displayName: igAccount.name || igAccount.username, // Pre-fill with name or username
277
+ description: igAccount.biography || '', // Pre-fill with bio if available
278
+ tags: [], // Empty, user fills in form
279
+ },
280
+ })
281
+ } else {
282
+ console.log(`[oauth-callback] Page "${page.name}" has no Instagram Business Account linked`)
283
+ }
284
+ } catch (error) {
285
+ console.error(`[oauth-callback] Error checking Instagram for Page "${page.name}":`, error)
286
+ // Continue with next page instead of failing entire flow
287
+ }
288
+ }
289
+
290
+ // In preview mode, having 0 Instagram accounts is OK - user can still see their Pages
291
+ // In save mode, having 0 accounts is an error (nothing to save)
292
+ if (accountsToConnect.length === 0 && mode !== 'preview') {
293
+ console.warn('[oauth-callback] ⚠️ No Instagram Business Accounts found across all Facebook Pages (save mode)')
294
+
295
+ // Return HTML that sends a specific error message to parent window
296
+ const html = `
297
+ <!DOCTYPE html>
298
+ <html>
299
+ <head>
300
+ <title>No Instagram Accounts Found</title>
301
+ <style>
302
+ body {
303
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
304
+ display: flex;
305
+ align-items: center;
306
+ justify-content: center;
307
+ height: 100vh;
308
+ margin: 0;
309
+ background: linear-gradient(135deg, #ffa726 0%, #fb8c00 100%);
310
+ color: white;
311
+ }
312
+ .container {
313
+ text-align: center;
314
+ padding: 2rem;
315
+ max-width: 500px;
316
+ }
317
+ .warning-icon {
318
+ font-size: 4rem;
319
+ margin-bottom: 1rem;
320
+ }
321
+ h1 {
322
+ font-size: 1.5rem;
323
+ margin-bottom: 0.5rem;
324
+ }
325
+ p {
326
+ opacity: 0.9;
327
+ line-height: 1.5;
328
+ }
329
+ </style>
330
+ </head>
331
+ <body>
332
+ <div class="container">
333
+ <div class="warning-icon">⚠️</div>
334
+ <h1>No Instagram Accounts Found</h1>
335
+ <p>Although you authorized the app, we couldn't find any Instagram Business Accounts linked to your Facebook Pages.</p>
336
+ <p style="margin-top: 1rem; font-size: 0.9rem;">This window will close automatically...</p>
337
+ </div>
338
+ <script>
339
+ // Send specific error to parent window
340
+ if (window.opener) {
341
+ window.opener.postMessage({
342
+ type: 'oauth-error',
343
+ error: 'no_instagram_accounts',
344
+ errorDescription: 'No Instagram Business Accounts found linked to your Facebook Pages. Please link your Instagram account to a Facebook Page first.'
345
+ }, window.location.origin);
346
+ }
347
+
348
+ // Close this popup after 3 seconds
349
+ setTimeout(() => {
350
+ window.close();
351
+ }, 3000);
352
+ </script>
353
+ </body>
354
+ </html>
355
+ `
356
+
357
+ return new NextResponse(html, {
358
+ headers: {
359
+ 'Content-Type': 'text/html',
360
+ },
361
+ })
362
+ } else if (accountsToConnect.length === 0 && mode === 'preview') {
363
+ console.log('[oauth-callback] No Instagram accounts found, but in preview mode - continuing with empty array')
364
+ } else {
365
+ console.log(`[oauth-callback] ✅ Found ${accountsToConnect.length} Instagram Business Account(s)`)
366
+ }
367
+ }
368
+
369
+ // 7. Handle preview mode vs save mode
370
+ if (mode === 'preview') {
371
+ // PREVIEW MODE: Return data without saving to DB
372
+ // This allows the form to pre-fill with OAuth data before user clicks Save
373
+ console.log('[oauth-callback] Preview mode detected - returning data without saving to DB')
374
+
375
+ const previewData = accountsToConnect.map(account => ({
376
+ platform,
377
+ platformAccountId: account.platformAccountId,
378
+ username: account.username,
379
+ accessToken: account.accessToken, // Return unencrypted token (will be encrypted on save)
380
+ tokenExpiresAt: new Date(Date.now() + expiresIn * 1000).toISOString(),
381
+ permissions: account.permissions,
382
+ accountMetadata: account.metadata,
383
+ }))
384
+
385
+ const html = `
386
+ <!DOCTYPE html>
387
+ <html>
388
+ <head>
389
+ <title>OAuth Preview</title>
390
+ <style>
391
+ body {
392
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
393
+ display: flex;
394
+ align-items: center;
395
+ justify-content: center;
396
+ height: 100vh;
397
+ margin: 0;
398
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
399
+ color: white;
400
+ }
401
+ .container {
402
+ text-align: center;
403
+ padding: 2rem;
404
+ }
405
+ .loading-icon {
406
+ font-size: 4rem;
407
+ margin-bottom: 1rem;
408
+ animation: spin 2s linear infinite;
409
+ }
410
+ @keyframes spin {
411
+ 0% { transform: rotate(0deg); }
412
+ 100% { transform: rotate(360deg); }
413
+ }
414
+ h1 {
415
+ font-size: 1.5rem;
416
+ margin-bottom: 0.5rem;
417
+ }
418
+ p {
419
+ opacity: 0.9;
420
+ }
421
+ </style>
422
+ </head>
423
+ <body>
424
+ <div class="container">
425
+ <div class="loading-icon">🔄</div>
426
+ <h1>Loading Profile Data...</h1>
427
+ <p>Pre-filling your ${platform} information...</p>
428
+ </div>
429
+ <script>
430
+ // Send preview data to parent window
431
+ if (window.opener) {
432
+ window.opener.postMessage({
433
+ type: 'oauth-preview',
434
+ platform: '${platform}',
435
+ accounts: ${JSON.stringify(previewData)}
436
+ }, window.location.origin);
437
+
438
+ // Close this popup after sending data
439
+ setTimeout(() => {
440
+ window.close();
441
+ }, 1000);
442
+ }
443
+ </script>
444
+ </body>
445
+ </html>
446
+ `
447
+
448
+ return new NextResponse(html, {
449
+ status: 200,
450
+ headers: { 'Content-Type': 'text/html' }
451
+ })
452
+ }
453
+
454
+ // SAVE MODE (default): Encrypt tokens and save accounts to child entity
455
+ const savedCount = accountsToConnect.length
456
+
457
+ for (const account of accountsToConnect) {
458
+ // Encrypt access token
459
+ const encryptedToken = await TokenEncryption.encrypt(account.accessToken)
460
+
461
+ // Calculate expiration date
462
+ const now = new Date()
463
+ const expiresAt = new Date(now.getTime() + expiresIn * 1000)
464
+
465
+ // Insert social account into clients_social_platforms table (child entity)
466
+ const result = await mutateWithRLS<{
467
+ id: string
468
+ platform: string
469
+ username: string
470
+ }>(
471
+ `INSERT INTO "clients_social_platforms"
472
+ ("parentId", platform, "platformAccountId", "username", "accessToken",
473
+ "tokenExpiresAt", permissions, "accountMetadata", "isActive")
474
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
475
+ ON CONFLICT ("parentId", "platformAccountId")
476
+ WHERE "platformAccountId" IS NOT NULL
477
+ DO UPDATE SET
478
+ "accessToken" = EXCLUDED."accessToken",
479
+ "tokenExpiresAt" = EXCLUDED."tokenExpiresAt",
480
+ permissions = EXCLUDED.permissions,
481
+ "accountMetadata" = EXCLUDED."accountMetadata",
482
+ "isActive" = EXCLUDED."isActive",
483
+ "updatedAt" = CURRENT_TIMESTAMP
484
+ RETURNING *`,
485
+ [
486
+ clientId, // parentId (client that owns this account)
487
+ platform,
488
+ account.platformAccountId,
489
+ account.username,
490
+ `${encryptedToken.encrypted}:${encryptedToken.iv}:${encryptedToken.keyId}`,
491
+ expiresAt.toISOString(),
492
+ JSON.stringify(account.permissions),
493
+ JSON.stringify(account.metadata),
494
+ true // isActive
495
+ ],
496
+ authResult.user!.id
497
+ )
498
+
499
+ const savedAccount = result.rows[0]!
500
+ console.log('[oauth-callback] ✅ Account saved to child entity:', {
501
+ id: savedAccount.id,
502
+ clientId,
503
+ platform,
504
+ accountName: account.username
505
+ })
506
+
507
+ // Create audit log entry
508
+ await mutateWithRLS(
509
+ `INSERT INTO "audit_logs"
510
+ ("userId", "accountId", action, details, "ipAddress", "userAgent")
511
+ VALUES ($1, $2, $3, $4, $5, $6)`,
512
+ [
513
+ authResult.user!.id,
514
+ savedAccount.id,
515
+ 'account_connected',
516
+ JSON.stringify({
517
+ clientId,
518
+ platform,
519
+ accountName: account.username,
520
+ success: true,
521
+ connectedAt: new Date().toISOString()
522
+ }),
523
+ request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || null,
524
+ request.headers.get('user-agent') || null
525
+ ],
526
+ authResult.user!.id
527
+ )
528
+
529
+ console.log('[oauth-callback] ✅ Audit log created for account connection')
530
+ }
531
+
532
+ // 8. Return HTML page that sends postMessage to opener window
533
+ // This allows the OAuth popup to communicate back to the parent page
534
+ const html = `
535
+ <!DOCTYPE html>
536
+ <html>
537
+ <head>
538
+ <title>OAuth Success</title>
539
+ <style>
540
+ body {
541
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
542
+ display: flex;
543
+ align-items: center;
544
+ justify-content: center;
545
+ height: 100vh;
546
+ margin: 0;
547
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
548
+ color: white;
549
+ }
550
+ .container {
551
+ text-align: center;
552
+ padding: 2rem;
553
+ }
554
+ .success-icon {
555
+ font-size: 4rem;
556
+ margin-bottom: 1rem;
557
+ }
558
+ h1 {
559
+ font-size: 1.5rem;
560
+ margin-bottom: 0.5rem;
561
+ }
562
+ p {
563
+ opacity: 0.9;
564
+ }
565
+ </style>
566
+ </head>
567
+ <body>
568
+ <div class="container">
569
+ <div class="success-icon">✅</div>
570
+ <h1>Account Connected Successfully!</h1>
571
+ <p>Connected ${savedCount} ${platform} account(s). This window will close automatically...</p>
572
+ </div>
573
+ <script>
574
+ // Send success message to parent window
575
+ if (window.opener) {
576
+ window.opener.postMessage({
577
+ type: 'oauth-success',
578
+ platform: '${platform}',
579
+ connectedCount: ${savedCount}
580
+ }, window.location.origin);
581
+ }
582
+
583
+ // Close this popup after 2 seconds
584
+ setTimeout(() => {
585
+ window.close();
586
+ }, 2000);
587
+ </script>
588
+ </body>
589
+ </html>
590
+ `
591
+
592
+ return new NextResponse(html, {
593
+ headers: {
594
+ 'Content-Type': 'text/html',
595
+ },
596
+ })
597
+ } catch (error: unknown) {
598
+ console.error('❌ OAuth callback error:', error)
599
+
600
+ // Extract error message
601
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
602
+
603
+ // Return HTML page that sends error postMessage to opener window
604
+ // This is critical - if we redirect, the popup redirects and never communicates with parent
605
+ const html = `
606
+ <!DOCTYPE html>
607
+ <html>
608
+ <head>
609
+ <title>OAuth Error</title>
610
+ <style>
611
+ body {
612
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
613
+ display: flex;
614
+ align-items: center;
615
+ justify-content: center;
616
+ height: 100vh;
617
+ margin: 0;
618
+ background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
619
+ color: white;
620
+ }
621
+ .container {
622
+ text-align: center;
623
+ padding: 2rem;
624
+ }
625
+ .error-icon {
626
+ font-size: 4rem;
627
+ margin-bottom: 1rem;
628
+ }
629
+ h1 {
630
+ font-size: 1.5rem;
631
+ margin-bottom: 0.5rem;
632
+ }
633
+ p {
634
+ opacity: 0.9;
635
+ }
636
+ </style>
637
+ </head>
638
+ <body>
639
+ <div class="container">
640
+ <div class="error-icon">❌</div>
641
+ <h1>Connection Failed</h1>
642
+ <p>${errorMessage.replace(/'/g, "\\'")}. This window will close automatically...</p>
643
+ </div>
644
+ <script>
645
+ // Send error message to parent window
646
+ if (window.opener) {
647
+ window.opener.postMessage({
648
+ type: 'oauth-error',
649
+ error: 'callback_exception',
650
+ errorDescription: '${errorMessage.replace(/'/g, "\\'")}'
651
+ }, window.location.origin);
652
+ }
653
+
654
+ // Close this popup after 3 seconds
655
+ setTimeout(() => {
656
+ window.close();
657
+ }, 3000);
658
+ </script>
659
+ </body>
660
+ </html>
661
+ `
662
+
663
+ return new NextResponse(html, {
664
+ headers: {
665
+ 'Content-Type': 'text/html',
666
+ },
667
+ })
668
+ }
669
+ }