TikTok V5 + Security
This commit is contained in:
@@ -30,7 +30,8 @@ CRON_SECRET=
|
|||||||
# Production example: https://qrmaster.net/api/tiktok/callback
|
# Production example: https://qrmaster.net/api/tiktok/callback
|
||||||
# Local dev example: http://localhost:3000/api/tiktok/callback
|
# Local dev example: http://localhost:3000/api/tiktok/callback
|
||||||
# Tokens are saved in the DB after the OAuth callback; do not store access tokens here.
|
# Tokens are saved in the DB after the OAuth callback; do not store access tokens here.
|
||||||
TIKTOK_CLIENT_KEY=aw2l7czcin3uk426
|
TIKTOK_CLIENT_KEY=
|
||||||
TIKTOK_CLIENT_SECRET=LapujWkqpZfj1jgWBeCzFhw2nuhMPMBA
|
TIKTOK_CLIENT_SECRET=
|
||||||
TIKTOK_REDIRECT_URI=https://qrmaster.net/api/tiktok/callback
|
TIKTOK_REDIRECT_URI=https://qrmaster.net/api/tiktok/callback
|
||||||
TIKTOK_ADMIN_KEY=yV0CzlXyw00kFc7hnnJZv_eoH5WaqGsD5l8_GH7Aj2s
|
TIKTOK_ADMIN_KEY=
|
||||||
|
TIKTOK_EXPECTED_OPEN_ID=
|
||||||
|
|||||||
@@ -56,8 +56,9 @@ services:
|
|||||||
INTERNAL_API_SECRET: ${INTERNAL_API_SECRET}
|
INTERNAL_API_SECRET: ${INTERNAL_API_SECRET}
|
||||||
TIKTOK_CLIENT_KEY: ${TIKTOK_CLIENT_KEY:-}
|
TIKTOK_CLIENT_KEY: ${TIKTOK_CLIENT_KEY:-}
|
||||||
TIKTOK_CLIENT_SECRET: ${TIKTOK_CLIENT_SECRET:-}
|
TIKTOK_CLIENT_SECRET: ${TIKTOK_CLIENT_SECRET:-}
|
||||||
TIKTOK_REDIRECT_URI: ${TIKTOK_REDIRECT_URI:-https://qrmaster.net/api/tiktok/callback}
|
TIKTOK_REDIRECT_URI: ${TIKTOK_REDIRECT_URI:-https://qrmaster.net/api/tiktok/callback}
|
||||||
TIKTOK_ADMIN_KEY: ${TIKTOK_ADMIN_KEY:-}
|
TIKTOK_ADMIN_KEY: ${TIKTOK_ADMIN_KEY:-}
|
||||||
|
TIKTOK_EXPECTED_OPEN_ID: ${TIKTOK_EXPECTED_OPEN_ID:-}
|
||||||
IP_SALT: ${IP_SALT:-your-salt-change-in-production}
|
IP_SALT: ${IP_SALT:-your-salt-change-in-production}
|
||||||
ENABLE_DEMO: ${ENABLE_DEMO:-false}
|
ENABLE_DEMO: ${ENABLE_DEMO:-false}
|
||||||
NEXT_PUBLIC_INDEXABLE: ${NEXT_PUBLIC_INDEXABLE:-true}
|
NEXT_PUBLIC_INDEXABLE: ${NEXT_PUBLIC_INDEXABLE:-true}
|
||||||
|
|||||||
@@ -39,6 +39,8 @@
|
|||||||
- Optional helper: `TIKTOK_ADMIN_KEY` for guarding the `/api/tiktok/connect` start route.
|
- Optional helper: `TIKTOK_ADMIN_KEY` for guarding the `/api/tiktok/connect` start route.
|
||||||
- OAuth result: access/refresh tokens are stored in the database after `/api/tiktok/connect` and `/api/tiktok/callback`.
|
- OAuth result: access/refresh tokens are stored in the database after `/api/tiktok/connect` and `/api/tiktok/callback`.
|
||||||
- Do not put TikTok access tokens in `.env`; the cron job reads them from the DB through the app flow.
|
- Do not put TikTok access tokens in `.env`; the cron job reads them from the DB through the app flow.
|
||||||
|
- Configure `TIKTOK_EXPECTED_OPEN_ID` for QRMaster before deploying the hardened routes. The callback and every API call fail closed when it is missing or belongs to another account.
|
||||||
|
- `GET /api/tiktok/token` is status-only and never returns access or refresh tokens. Automations must call the server upload route instead of caching credentials locally.
|
||||||
- For cron posting, use the same QRMaster server environment that already contains `CRON_SECRET` / `INTERNAL_API_SECRET` for internal APIs.
|
- For cron posting, use the same QRMaster server environment that already contains `CRON_SECRET` / `INTERNAL_API_SECRET` for internal APIs.
|
||||||
|
|
||||||
## TikTok Connection Status + Credential Locations
|
## TikTok Connection Status + Credential Locations
|
||||||
|
|||||||
@@ -54,4 +54,5 @@ TIKTOK_CLIENT_KEY=
|
|||||||
TIKTOK_CLIENT_SECRET=
|
TIKTOK_CLIENT_SECRET=
|
||||||
TIKTOK_REDIRECT_URI=https://qrmaster.net/api/tiktok/callback
|
TIKTOK_REDIRECT_URI=https://qrmaster.net/api/tiktok/callback
|
||||||
# Optional: protects /api/tiktok/connect from being triggered by strangers
|
# Optional: protects /api/tiktok/connect from being triggered by strangers
|
||||||
TIKTOK_ADMIN_KEY=
|
TIKTOK_ADMIN_KEY=
|
||||||
|
TIKTOK_EXPECTED_OPEN_ID=
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { cookies } from 'next/headers';
|
import { getSessionUserId } from '@/lib/session';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||||
import { TrendData } from '@/types/analytics';
|
import { TrendData } from '@/types/analytics';
|
||||||
@@ -41,7 +41,7 @@ function calculateTrend(current: number, previous: number): TrendData {
|
|||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const userId = cookies().get('userId')?.value;
|
const userId = getSessionUserId();
|
||||||
|
|
||||||
// Rate Limiting (user-based)
|
// Rate Limiting (user-based)
|
||||||
const clientId = userId || getClientIdentifier(request);
|
const clientId = userId || getClientIdentifier(request);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { csrfProtection } from '@/lib/csrf';
|
import { csrfProtection } from '@/lib/csrf';
|
||||||
|
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||||
import { sendPasswordResetEmail } from '@/lib/email';
|
import { sendPasswordResetEmail } from '@/lib/email';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
|
|
||||||
@@ -15,6 +16,26 @@ export async function POST(req: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rate Limiting
|
||||||
|
const clientId = getClientIdentifier(req);
|
||||||
|
const rateLimitResult = rateLimit(clientId, RateLimits.PASSWORD_RESET);
|
||||||
|
if (!rateLimitResult.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Too many password reset requests. Please try again later.',
|
||||||
|
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 429,
|
||||||
|
headers: {
|
||||||
|
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
|
||||||
|
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
|
||||||
|
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const { email } = body;
|
const { email } = body;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { getAuthCookieOptions } from '@/lib/cookieConfig';
|
import { getAuthCookieOptions } from '@/lib/cookieConfig';
|
||||||
|
import { signUserId } from '@/lib/session';
|
||||||
import {
|
import {
|
||||||
appendRedirectParam,
|
appendRedirectParam,
|
||||||
GOOGLE_OAUTH_STATE_COOKIE_NAME,
|
GOOGLE_OAUTH_STATE_COOKIE_NAME,
|
||||||
@@ -27,14 +28,14 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
// If no code, redirect to Google OAuth
|
// If no code, redirect to Google OAuth
|
||||||
if (!code) {
|
if (!code) {
|
||||||
const googleClientId = process.env.GOOGLE_CLIENT_ID;
|
const googleClientId = process.env.GOOGLE_CLIENT_ID;
|
||||||
|
|
||||||
if (!googleClientId) {
|
if (!googleClientId) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Google Client ID not configured' },
|
{ error: 'Google Client ID not configured' },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/google`;
|
const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/google`;
|
||||||
const scope = 'openid email profile';
|
const scope = 'openid email profile';
|
||||||
@@ -85,58 +86,58 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
const googleClientId = process.env.GOOGLE_CLIENT_ID;
|
const googleClientId = process.env.GOOGLE_CLIENT_ID;
|
||||||
const googleClientSecret = process.env.GOOGLE_CLIENT_SECRET;
|
const googleClientSecret = process.env.GOOGLE_CLIENT_SECRET;
|
||||||
|
|
||||||
if (!googleClientId || !googleClientSecret) {
|
if (!googleClientId || !googleClientSecret) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Google OAuth not configured' },
|
{ error: 'Google OAuth not configured' },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/google`;
|
const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/google`;
|
||||||
|
|
||||||
// Exchange code for tokens
|
// Exchange code for tokens
|
||||||
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
|
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
},
|
},
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
code,
|
code,
|
||||||
client_id: googleClientId,
|
client_id: googleClientId,
|
||||||
client_secret: googleClientSecret,
|
client_secret: googleClientSecret,
|
||||||
redirect_uri: redirectUri,
|
redirect_uri: redirectUri,
|
||||||
grant_type: 'authorization_code',
|
grant_type: 'authorization_code',
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!tokenResponse.ok) {
|
if (!tokenResponse.ok) {
|
||||||
throw new Error('Failed to exchange code for tokens');
|
throw new Error('Failed to exchange code for tokens');
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokens = await tokenResponse.json();
|
const tokens = await tokenResponse.json();
|
||||||
|
|
||||||
// Get user info from Google
|
// Get user info from Google
|
||||||
const userInfoResponse = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
|
const userInfoResponse = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${tokens.access_token}`,
|
Authorization: `Bearer ${tokens.access_token}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!userInfoResponse.ok) {
|
if (!userInfoResponse.ok) {
|
||||||
throw new Error('Failed to get user info');
|
throw new Error('Failed to get user info');
|
||||||
}
|
}
|
||||||
|
|
||||||
const userInfo = await userInfoResponse.json();
|
const userInfo = await userInfoResponse.json();
|
||||||
|
|
||||||
// Check if user exists in database
|
// Check if user exists in database
|
||||||
let user = await db.user.findUnique({
|
let user = await db.user.findUnique({
|
||||||
where: { email: userInfo.email },
|
where: { email: userInfo.email },
|
||||||
});
|
});
|
||||||
|
|
||||||
const isNewUser = !user;
|
const isNewUser = !user;
|
||||||
|
|
||||||
// Create user if they don't exist
|
// Create user if they don't exist
|
||||||
if (!user) {
|
if (!user) {
|
||||||
const onboardingStartedAt = new Date();
|
const onboardingStartedAt = new Date();
|
||||||
user = await db.user.create({
|
user = await db.user.create({
|
||||||
@@ -158,58 +159,58 @@ export async function GET(request: NextRequest) {
|
|||||||
signupFirstSeenAt: firstTouch?.signupFirstSeenAt ? new Date(firstTouch.signupFirstSeenAt) : onboardingStartedAt,
|
signupFirstSeenAt: firstTouch?.signupFirstSeenAt ? new Date(firstTouch.signupFirstSeenAt) : onboardingStartedAt,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create Account entry for the OAuth provider
|
// Create Account entry for the OAuth provider
|
||||||
await db.account.create({
|
await db.account.create({
|
||||||
data: {
|
data: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
type: 'oauth',
|
type: 'oauth',
|
||||||
provider: 'google',
|
provider: 'google',
|
||||||
providerAccountId: userInfo.sub || userInfo.id,
|
providerAccountId: userInfo.sub || userInfo.id,
|
||||||
access_token: tokens.access_token,
|
access_token: tokens.access_token,
|
||||||
refresh_token: tokens.refresh_token,
|
refresh_token: tokens.refresh_token,
|
||||||
expires_at: tokens.expires_in ? Math.floor(Date.now() / 1000) + tokens.expires_in : null,
|
expires_at: tokens.expires_in ? Math.floor(Date.now() / 1000) + tokens.expires_in : null,
|
||||||
token_type: tokens.token_type,
|
token_type: tokens.token_type,
|
||||||
scope: tokens.scope,
|
scope: tokens.scope,
|
||||||
id_token: tokens.id_token,
|
id_token: tokens.id_token,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Update existing account tokens
|
// Update existing account tokens
|
||||||
const existingAccount = await db.account.findUnique({
|
const existingAccount = await db.account.findUnique({
|
||||||
where: {
|
where: {
|
||||||
provider_providerAccountId: {
|
provider_providerAccountId: {
|
||||||
provider: 'google',
|
provider: 'google',
|
||||||
providerAccountId: userInfo.sub || userInfo.id,
|
providerAccountId: userInfo.sub || userInfo.id,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existingAccount) {
|
if (existingAccount) {
|
||||||
await db.account.update({
|
await db.account.update({
|
||||||
where: { id: existingAccount.id },
|
where: { id: existingAccount.id },
|
||||||
data: {
|
data: {
|
||||||
access_token: tokens.access_token,
|
access_token: tokens.access_token,
|
||||||
refresh_token: tokens.refresh_token,
|
refresh_token: tokens.refresh_token,
|
||||||
expires_at: tokens.expires_in ? Math.floor(Date.now() / 1000) + tokens.expires_in : null,
|
expires_at: tokens.expires_in ? Math.floor(Date.now() / 1000) + tokens.expires_in : null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Create Account entry if it doesn't exist
|
// Create Account entry if it doesn't exist
|
||||||
await db.account.create({
|
await db.account.create({
|
||||||
data: {
|
data: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
type: 'oauth',
|
type: 'oauth',
|
||||||
provider: 'google',
|
provider: 'google',
|
||||||
providerAccountId: userInfo.sub || userInfo.id,
|
providerAccountId: userInfo.sub || userInfo.id,
|
||||||
access_token: tokens.access_token,
|
access_token: tokens.access_token,
|
||||||
refresh_token: tokens.refresh_token,
|
refresh_token: tokens.refresh_token,
|
||||||
expires_at: tokens.expires_in ? Math.floor(Date.now() / 1000) + tokens.expires_in : null,
|
expires_at: tokens.expires_in ? Math.floor(Date.now() / 1000) + tokens.expires_in : null,
|
||||||
token_type: tokens.token_type,
|
token_type: tokens.token_type,
|
||||||
scope: tokens.scope,
|
scope: tokens.scope,
|
||||||
id_token: tokens.id_token,
|
id_token: tokens.id_token,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,7 +228,7 @@ export async function GET(request: NextRequest) {
|
|||||||
const redirectUrl = new URL(`${process.env.NEXT_PUBLIC_APP_URL}${onboardingTarget}`);
|
const redirectUrl = new URL(`${process.env.NEXT_PUBLIC_APP_URL}${onboardingTarget}`);
|
||||||
|
|
||||||
const response = NextResponse.redirect(redirectUrl.toString());
|
const response = NextResponse.redirect(redirectUrl.toString());
|
||||||
response.cookies.set('userId', user.id, getAuthCookieOptions());
|
response.cookies.set('userId', signUserId(user.id), getAuthCookieOptions());
|
||||||
response.cookies.delete(GOOGLE_OAUTH_STATE_COOKIE_NAME);
|
response.cookies.delete(GOOGLE_OAUTH_STATE_COOKIE_NAME);
|
||||||
response.cookies.delete(POST_AUTH_REDIRECT_COOKIE_NAME);
|
response.cookies.delete(POST_AUTH_REDIRECT_COOKIE_NAME);
|
||||||
response.cookies.delete(ATTRIBUTION_COOKIE_NAME);
|
response.cookies.delete(ATTRIBUTION_COOKIE_NAME);
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Hash the new password
|
// Hash the new password
|
||||||
const hashedPassword = await bcrypt.hash(password, 10);
|
const hashedPassword = await bcrypt.hash(password, 12);
|
||||||
|
|
||||||
// Update user's password and clear reset token
|
// Update user's password and clear reset token
|
||||||
await db.user.update({
|
await db.user.update({
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { csrfProtection } from '@/lib/csrf';
|
import { csrfProtection } from '@/lib/csrf';
|
||||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||||
import { getAuthCookieOptions } from '@/lib/cookieConfig';
|
import { getAuthCookieOptions } from '@/lib/cookieConfig';
|
||||||
import { signupSchema, validateRequest } from '@/lib/validationSchemas';
|
import { signUserId } from '@/lib/session';
|
||||||
import { sendWelcomeEmail } from '@/lib/email';
|
import { signupSchema, validateRequest } from '@/lib/validationSchemas';
|
||||||
import { sendConversionEvent } from '@/lib/metaConversions';
|
import { sendWelcomeEmail } from '@/lib/email';
|
||||||
import {
|
import { sendConversionEvent } from '@/lib/metaConversions';
|
||||||
ATTRIBUTION_COOKIE_NAME,
|
import {
|
||||||
getEmailDomain,
|
ATTRIBUTION_COOKIE_NAME,
|
||||||
parseAttributionCookie,
|
getEmailDomain,
|
||||||
} from '@/lib/revops';
|
parseAttributionCookie,
|
||||||
import { triggerLifecycleScoring } from '@/lib/revops-server';
|
} from '@/lib/revops';
|
||||||
|
import { triggerLifecycleScoring } from '@/lib/revops-server';
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -72,29 +73,29 @@ export async function POST(request: NextRequest) {
|
|||||||
// Hash password
|
// Hash password
|
||||||
const hashedPassword = await bcrypt.hash(password, 12);
|
const hashedPassword = await bcrypt.hash(password, 12);
|
||||||
|
|
||||||
const firstTouch = parseAttributionCookie(request.cookies.get(ATTRIBUTION_COOKIE_NAME)?.value);
|
const firstTouch = parseAttributionCookie(request.cookies.get(ATTRIBUTION_COOKIE_NAME)?.value);
|
||||||
const onboardingStartedAt = new Date();
|
const onboardingStartedAt = new Date();
|
||||||
|
|
||||||
// Create user
|
// Create user
|
||||||
const user = await db.user.create({
|
const user = await db.user.create({
|
||||||
data: {
|
data: {
|
||||||
name,
|
name,
|
||||||
email,
|
email,
|
||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
onboardingStartedAt,
|
onboardingStartedAt,
|
||||||
emailDomain: getEmailDomain(email),
|
emailDomain: getEmailDomain(email),
|
||||||
signupSource: firstTouch?.signupSource || null,
|
signupSource: firstTouch?.signupSource || null,
|
||||||
signupMedium: firstTouch?.signupMedium || null,
|
signupMedium: firstTouch?.signupMedium || null,
|
||||||
signupCampaign: firstTouch?.signupCampaign || null,
|
signupCampaign: firstTouch?.signupCampaign || null,
|
||||||
signupContent: firstTouch?.signupContent || null,
|
signupContent: firstTouch?.signupContent || null,
|
||||||
signupTerm: firstTouch?.signupTerm || null,
|
signupTerm: firstTouch?.signupTerm || null,
|
||||||
signupReferrer: firstTouch?.signupReferrer || null,
|
signupReferrer: firstTouch?.signupReferrer || null,
|
||||||
signupLandingPath: firstTouch?.signupLandingPath || '/signup',
|
signupLandingPath: firstTouch?.signupLandingPath || '/signup',
|
||||||
signupFirstSeenAt: firstTouch?.signupFirstSeenAt ? new Date(firstTouch.signupFirstSeenAt) : onboardingStartedAt,
|
signupFirstSeenAt: firstTouch?.signupFirstSeenAt ? new Date(firstTouch.signupFirstSeenAt) : onboardingStartedAt,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
triggerLifecycleScoring(user.id, 'signup');
|
triggerLifecycleScoring(user.id, 'signup');
|
||||||
|
|
||||||
// Send welcome email (fire-and-forget — never block signup)
|
// Send welcome email (fire-and-forget — never block signup)
|
||||||
try {
|
try {
|
||||||
@@ -117,22 +118,22 @@ export async function POST(request: NextRequest) {
|
|||||||
}).catch(console.error);
|
}).catch(console.error);
|
||||||
|
|
||||||
// Create response
|
// Create response
|
||||||
const response = NextResponse.json({
|
const response = NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
needsOnboarding: true,
|
needsOnboarding: true,
|
||||||
user: {
|
user: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
plan: 'FREE',
|
plan: 'FREE',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set cookie for auto-login after signup
|
// Set cookie for auto-login after signup
|
||||||
response.cookies.set('userId', user.id, getAuthCookieOptions());
|
response.cookies.set('userId', signUserId(user.id), getAuthCookieOptions());
|
||||||
response.cookies.delete(ATTRIBUTION_COOKIE_NAME);
|
response.cookies.delete(ATTRIBUTION_COOKIE_NAME);
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof z.ZodError) {
|
if (error instanceof z.ZodError) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -147,4 +148,4 @@ export async function POST(request: NextRequest) {
|
|||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import { cookies } from 'next/headers';
|
import { csrfProtection } from '@/lib/csrf';
|
||||||
import { csrfProtection } from '@/lib/csrf';
|
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
import { setSessionCookie } from '@/lib/session';
|
||||||
import { getAuthCookieOptions } from '@/lib/cookieConfig';
|
import { loginSchema, validateRequest } from '@/lib/validationSchemas';
|
||||||
import { loginSchema, validateRequest } from '@/lib/validationSchemas';
|
import { shouldResumeOnboarding } from '@/lib/revops';
|
||||||
import { shouldResumeOnboarding } from '@/lib/revops';
|
|
||||||
|
// A fixed bcrypt hash used to equalize timing when no user is found, so the
|
||||||
|
// response time does not reveal whether an email is registered.
|
||||||
|
const DUMMY_BCRYPT_HASH = '$2a$12$R9h/cIPz0gi.URNNX3kh2OPST9/PgBkqquzi.Ss7KIUgO2t0jWMUW';
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -51,28 +54,30 @@ export async function POST(request: NextRequest) {
|
|||||||
const { email, password } = validation.data;
|
const { email, password } = validation.data;
|
||||||
|
|
||||||
// Find user
|
// Find user
|
||||||
const user = await db.user.findUnique({
|
const user = await db.user.findUnique({
|
||||||
where: { email },
|
where: { email },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
email: true,
|
email: true,
|
||||||
name: true,
|
name: true,
|
||||||
plan: true,
|
plan: true,
|
||||||
password: true,
|
password: true,
|
||||||
onboardingStartedAt: true,
|
onboardingStartedAt: true,
|
||||||
onboardingCompletedAt: true,
|
onboardingCompletedAt: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
// Equalize response time to prevent user enumeration via timing.
|
||||||
|
await bcrypt.compare(password, DUMMY_BCRYPT_HASH);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Invalid email or password' },
|
{ error: 'Invalid email or password' },
|
||||||
{ status: 401 }
|
{ status: 401 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify password
|
// Verify password (dummy-compare when no hash exists, e.g. OAuth-only accounts)
|
||||||
const isValid = await bcrypt.compare(password, user.password || '');
|
const isValid = await bcrypt.compare(password, user.password || DUMMY_BCRYPT_HASH);
|
||||||
|
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -81,16 +86,16 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set cookie
|
// Set signed session cookie
|
||||||
cookies().set('userId', user.id, getAuthCookieOptions());
|
setSessionCookie(user.id);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
needsOnboarding: shouldResumeOnboarding(user),
|
needsOnboarding: shouldResumeOnboarding(user),
|
||||||
user: { id: user.id, email: user.email, name: user.name, plan: user.plan || 'FREE' }
|
user: { id: user.id, email: user.email, name: user.name, plan: user.plan || 'FREE' }
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Login error:', error);
|
console.error('Login error:', error);
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { cookies } from 'next/headers';
|
import { getSessionUserId } from '@/lib/session';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { csrfProtection } from '@/lib/csrf';
|
import { csrfProtection } from '@/lib/csrf';
|
||||||
import { getClientIdentifier, rateLimit, RateLimits } from '@/lib/rateLimit';
|
import { getClientIdentifier, rateLimit, RateLimits } from '@/lib/rateLimit';
|
||||||
@@ -10,7 +10,7 @@ export const dynamic = 'force-dynamic';
|
|||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const userId = cookies().get('userId')?.value;
|
const userId = getSessionUserId();
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
@@ -36,7 +36,7 @@ export async function PATCH(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: csrfCheck.error }, { status: 403 });
|
return NextResponse.json({ error: csrfCheck.error }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const userId = cookies().get('userId')?.value;
|
const userId = getSessionUserId();
|
||||||
const clientId = userId || getClientIdentifier(request);
|
const clientId = userId || getClientIdentifier(request);
|
||||||
const rateLimitResult = rateLimit(clientId, RateLimits.PROFILE_UPDATE);
|
const rateLimitResult = rateLimit(clientId, RateLimits.PROFILE_UPDATE);
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { getServerSession } from 'next-auth';
|
import { getServerSession } from 'next-auth';
|
||||||
import { authOptions } from '@/lib/auth';
|
import { authOptions } from '@/lib/auth';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { cookies } from 'next/headers';
|
import { getSessionUserId } from '@/lib/session';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
@@ -16,9 +16,8 @@ export async function GET(
|
|||||||
if (session?.user?.id) {
|
if (session?.user?.id) {
|
||||||
userId = session.user.id;
|
userId = session.user.id;
|
||||||
} else {
|
} else {
|
||||||
// Fallback: Check raw userId cookie (like /api/user does)
|
// Fallback: verified signed userId cookie (like /api/user does)
|
||||||
const cookieStore = await cookies();
|
userId = getSessionUserId() ?? undefined;
|
||||||
userId = cookieStore.get('userId')?.value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { cookies } from 'next/headers';
|
import { getSessionUserId } from '@/lib/session';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { csrfProtection } from '@/lib/csrf';
|
import { csrfProtection } from '@/lib/csrf';
|
||||||
@@ -18,7 +18,7 @@ export async function GET(
|
|||||||
{ params }: { params: { id: string } }
|
{ params }: { params: { id: string } }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const userId = cookies().get('userId')?.value;
|
const userId = getSessionUserId();
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
@@ -62,7 +62,7 @@ export async function PATCH(
|
|||||||
return NextResponse.json({ error: csrfCheck.error }, { status: 403 });
|
return NextResponse.json({ error: csrfCheck.error }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const userId = cookies().get('userId')?.value;
|
const userId = getSessionUserId();
|
||||||
|
|
||||||
// Rate Limiting (user-based)
|
// Rate Limiting (user-based)
|
||||||
const clientId = userId || getClientIdentifier(request);
|
const clientId = userId || getClientIdentifier(request);
|
||||||
@@ -151,7 +151,7 @@ export async function DELETE(
|
|||||||
return NextResponse.json({ error: csrfCheck.error }, { status: 403 });
|
return NextResponse.json({ error: csrfCheck.error }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const userId = cookies().get('userId')?.value;
|
const userId = getSessionUserId();
|
||||||
|
|
||||||
// Rate Limiting (user-based)
|
// Rate Limiting (user-based)
|
||||||
const clientId = userId || getClientIdentifier(request);
|
const clientId = userId || getClientIdentifier(request);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { cookies } from 'next/headers';
|
import { getSessionUserId } from '@/lib/session';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { csrfProtection } from '@/lib/csrf';
|
import { csrfProtection } from '@/lib/csrf';
|
||||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||||
@@ -15,7 +15,7 @@ export async function DELETE(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const userId = cookies().get('userId')?.value;
|
const userId = getSessionUserId();
|
||||||
|
|
||||||
// Rate Limiting (user-based)
|
// Rate Limiting (user-based)
|
||||||
const clientId = userId || getClientIdentifier(request);
|
const clientId = userId || getClientIdentifier(request);
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { cookies } from 'next/headers';
|
import { getSessionUserId } from '@/lib/session';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { generateSlug } from '@/lib/hash';
|
import { generateSlug } from '@/lib/hash';
|
||||||
import { createQRSchema, validateRequest } from '@/lib/validationSchemas';
|
import { createQRSchema, validateRequest } from '@/lib/validationSchemas';
|
||||||
import { csrfProtection } from '@/lib/csrf';
|
import { csrfProtection } from '@/lib/csrf';
|
||||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||||
import { DYNAMIC_QR_LIMITS } from '@/lib/plans';
|
import { DYNAMIC_QR_LIMITS } from '@/lib/plans';
|
||||||
import { triggerLifecycleScoring } from '@/lib/revops-server';
|
import { triggerLifecycleScoring } from '@/lib/revops-server';
|
||||||
|
|
||||||
// GET /api/qrs - List user's QR codes
|
// GET /api/qrs - List user's QR codes
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const userId = cookies().get('userId')?.value;
|
const userId = getSessionUserId();
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
@@ -49,7 +49,7 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Plan limits
|
// Plan limits
|
||||||
const PLAN_LIMITS = DYNAMIC_QR_LIMITS;
|
const PLAN_LIMITS = DYNAMIC_QR_LIMITS;
|
||||||
|
|
||||||
// POST /api/qrs - Create a new QR code
|
// POST /api/qrs - Create a new QR code
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
@@ -60,7 +60,7 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: csrfCheck.error }, { status: 403 });
|
return NextResponse.json({ error: csrfCheck.error }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const userId = cookies().get('userId')?.value;
|
const userId = getSessionUserId();
|
||||||
|
|
||||||
// Rate Limiting (user-based)
|
// Rate Limiting (user-based)
|
||||||
const clientId = userId || getClientIdentifier(request);
|
const clientId = userId || getClientIdentifier(request);
|
||||||
@@ -205,9 +205,9 @@ END:VCARD`;
|
|||||||
const slug = generateSlug(body.title);
|
const slug = generateSlug(body.title);
|
||||||
|
|
||||||
// Create QR code
|
// Create QR code
|
||||||
const qrCode = await db.qRCode.create({
|
const qrCode = await db.qRCode.create({
|
||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
title: body.title,
|
title: body.title,
|
||||||
type: isStatic ? 'STATIC' : 'DYNAMIC',
|
type: isStatic ? 'STATIC' : 'DYNAMIC',
|
||||||
contentType: body.contentType,
|
contentType: body.contentType,
|
||||||
@@ -221,12 +221,12 @@ END:VCARD`;
|
|||||||
},
|
},
|
||||||
slug,
|
slug,
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
triggerLifecycleScoring(userId, 'qr_created');
|
triggerLifecycleScoring(userId, 'qr_created');
|
||||||
|
|
||||||
return NextResponse.json(qrCode);
|
return NextResponse.json(qrCode);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating QR code:', error);
|
console.error('Error creating QR code:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -234,4 +234,4 @@ END:VCARD`;
|
|||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { cookies } from 'next/headers';
|
import { getSessionUserId } from '@/lib/session';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { generateSlug } from '@/lib/hash';
|
import { generateSlug } from '@/lib/hash';
|
||||||
|
|
||||||
// POST /api/qrs/static - Create a STATIC QR code that contains the direct URL
|
// POST /api/qrs/static - Create a STATIC QR code that contains the direct URL
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const userId = cookies().get('userId')?.value;
|
const userId = getSessionUserId();
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { cookies } from 'next/headers';
|
import { getSessionUserId } from '@/lib/session';
|
||||||
import { stripe } from '@/lib/stripe';
|
import { stripe } from '@/lib/stripe';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||||
import { scoreUserLifecycle } from '@/lib/revops-server';
|
import { scoreUserLifecycle } from '@/lib/revops-server';
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const userId = cookies().get('userId')?.value;
|
const userId = getSessionUserId();
|
||||||
|
|
||||||
// Rate Limiting (user-based)
|
// Rate Limiting (user-based)
|
||||||
const clientId = userId || getClientIdentifier(request);
|
const clientId = userId || getClientIdentifier(request);
|
||||||
@@ -34,14 +34,14 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get user with subscription info
|
// Get user with subscription info
|
||||||
const user = await db.user.findUnique({
|
const user = await db.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
select: {
|
select: {
|
||||||
stripeSubscriptionId: true,
|
stripeSubscriptionId: true,
|
||||||
stripeCurrentPeriodEnd: true,
|
stripeCurrentPeriodEnd: true,
|
||||||
plan: true,
|
plan: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
@@ -55,42 +55,42 @@ export async function POST(request: NextRequest) {
|
|||||||
// No active subscription
|
// No active subscription
|
||||||
if (!user.stripeSubscriptionId) {
|
if (!user.stripeSubscriptionId) {
|
||||||
// Just update plan to FREE if somehow plan is not FREE but no subscription
|
// Just update plan to FREE if somehow plan is not FREE but no subscription
|
||||||
await db.user.update({
|
await db.user.update({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
data: {
|
data: {
|
||||||
plan: 'FREE',
|
plan: 'FREE',
|
||||||
stripePriceId: null,
|
stripePriceId: null,
|
||||||
stripeCurrentPeriodEnd: null,
|
stripeCurrentPeriodEnd: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await scoreUserLifecycle(userId, 'subscription_deleted');
|
await scoreUserLifecycle(userId, 'subscription_deleted');
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Schedule cancellation at the end of the paid period so paid features stay active.
|
// Schedule cancellation at the end of the paid period so paid features stay active.
|
||||||
const subscription: any = await stripe.subscriptions.update(user.stripeSubscriptionId, {
|
const subscription: any = await stripe.subscriptions.update(user.stripeSubscriptionId, {
|
||||||
cancel_at_period_end: true,
|
cancel_at_period_end: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const periodEndTimestamp = subscription.current_period_end
|
const periodEndTimestamp = subscription.current_period_end
|
||||||
|| subscription.currentPeriodEnd
|
|| subscription.currentPeriodEnd
|
||||||
|| subscription.billing_cycle_anchor;
|
|| subscription.billing_cycle_anchor;
|
||||||
|
|
||||||
const currentPeriodEnd = periodEndTimestamp
|
const currentPeriodEnd = periodEndTimestamp
|
||||||
? new Date(periodEndTimestamp * 1000)
|
? new Date(periodEndTimestamp * 1000)
|
||||||
: user.stripeCurrentPeriodEnd;
|
: user.stripeCurrentPeriodEnd;
|
||||||
|
|
||||||
// Keep the paid plan locally until Stripe sends customer.subscription.deleted.
|
// Keep the paid plan locally until Stripe sends customer.subscription.deleted.
|
||||||
await db.user.update({
|
await db.user.update({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
data: {
|
data: {
|
||||||
stripeCurrentPeriodEnd: currentPeriodEnd,
|
stripeCurrentPeriodEnd: currentPeriodEnd,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await scoreUserLifecycle(userId, 'subscription_canceled_at_period_end');
|
await scoreUserLifecycle(userId, 'subscription_canceled_at_period_end');
|
||||||
|
|
||||||
return NextResponse.json({ success: true, currentPeriodEnd });
|
return NextResponse.json({ success: true, currentPeriodEnd });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error canceling subscription:', error);
|
console.error('Error canceling subscription:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { stripe, STRIPE_PLANS } from '@/lib/stripe';
|
import { stripe, STRIPE_PLANS } from '@/lib/stripe';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { cookies } from 'next/headers';
|
import { getSessionUserId } from '@/lib/session';
|
||||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
// Get user from cookie (using userId like other routes)
|
// Get user from verified signed session cookie
|
||||||
const cookieStore = await cookies();
|
const userId = getSessionUserId();
|
||||||
const userId = cookieStore.get('userId')?.value;
|
|
||||||
|
|
||||||
// Rate Limiting (user-based)
|
// Rate Limiting (user-based)
|
||||||
const clientId = userId || getClientIdentifier(request);
|
const clientId = userId || getClientIdentifier(request);
|
||||||
@@ -65,29 +64,29 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create or get Stripe customer
|
// Create or get Stripe customer
|
||||||
let customerId = user.stripeCustomerId;
|
let customerId = user.stripeCustomerId;
|
||||||
|
|
||||||
if (customerId) {
|
if (customerId) {
|
||||||
try {
|
try {
|
||||||
const existingCustomer = await stripe.customers.retrieve(customerId);
|
const existingCustomer = await stripe.customers.retrieve(customerId);
|
||||||
|
|
||||||
if ('deleted' in existingCustomer && existingCustomer.deleted) {
|
if ('deleted' in existingCustomer && existingCustomer.deleted) {
|
||||||
customerId = null;
|
customerId = null;
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error?.code === 'resource_missing' || error?.type === 'StripeInvalidRequestError') {
|
if (error?.code === 'resource_missing' || error?.type === 'StripeInvalidRequestError') {
|
||||||
customerId = null;
|
customerId = null;
|
||||||
} else {
|
} else {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!customerId) {
|
if (!customerId) {
|
||||||
const customer = await stripe.customers.create({
|
const customer = await stripe.customers.create({
|
||||||
email: user.email,
|
email: user.email,
|
||||||
metadata: {
|
metadata: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -95,34 +94,34 @@ export async function POST(request: NextRequest) {
|
|||||||
customerId = customer.id;
|
customerId = customer.id;
|
||||||
|
|
||||||
// Update user with Stripe customer ID
|
// Update user with Stripe customer ID
|
||||||
await db.user.update({
|
await db.user.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: { stripeCustomerId: customerId },
|
data: { stripeCustomerId: customerId },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL || request.nextUrl.origin;
|
const appUrl = process.env.NEXT_PUBLIC_APP_URL || request.nextUrl.origin;
|
||||||
|
|
||||||
// Create Stripe Checkout Session
|
// Create Stripe Checkout Session
|
||||||
const checkoutSession = await stripe.checkout.sessions.create({
|
const checkoutSession = await stripe.checkout.sessions.create({
|
||||||
customer: customerId,
|
customer: customerId,
|
||||||
mode: 'subscription',
|
mode: 'subscription',
|
||||||
payment_method_types: ['card'],
|
payment_method_types: ['card'],
|
||||||
allow_promotion_codes: true,
|
allow_promotion_codes: true,
|
||||||
line_items: [
|
line_items: [
|
||||||
{
|
{
|
||||||
price: priceId,
|
price: priceId,
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
success_url: `${appUrl}/dashboard?success=true&session_id={CHECKOUT_SESSION_ID}`,
|
success_url: `${appUrl}/dashboard?success=true&session_id={CHECKOUT_SESSION_ID}`,
|
||||||
cancel_url: `${appUrl}/pricing?canceled=true`,
|
cancel_url: `${appUrl}/pricing?canceled=true`,
|
||||||
metadata: {
|
metadata: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
plan,
|
plan,
|
||||||
billingInterval,
|
billingInterval,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ url: checkoutSession.url });
|
return NextResponse.json({ url: checkoutSession.url });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { cookies } from 'next/headers';
|
import { getSessionUserId } from '@/lib/session';
|
||||||
import { stripe } from '@/lib/stripe';
|
import { stripe } from '@/lib/stripe';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const userId = cookies().get('userId')?.value;
|
const userId = getSessionUserId();
|
||||||
|
|
||||||
// Rate Limiting (user-based)
|
// Rate Limiting (user-based)
|
||||||
const clientId = userId || getClientIdentifier(request);
|
const clientId = userId || getClientIdentifier(request);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { cookies } from 'next/headers';
|
import { getSessionUserId } from '@/lib/session';
|
||||||
import { stripe } from '@/lib/stripe';
|
import { stripe } from '@/lib/stripe';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { scoreUserLifecycle } from '@/lib/revops-server';
|
import { scoreUserLifecycle } from '@/lib/revops-server';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manual sync endpoint to update user subscription from Stripe
|
* Manual sync endpoint to update user subscription from Stripe
|
||||||
@@ -11,7 +11,7 @@ import { scoreUserLifecycle } from '@/lib/revops-server';
|
|||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
// Use cookie-based auth
|
// Use cookie-based auth
|
||||||
const userId = cookies().get('userId')?.value;
|
const userId = getSessionUserId();
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
@@ -38,19 +38,19 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
if (subscriptions.data.length === 0) {
|
if (subscriptions.data.length === 0) {
|
||||||
// No active subscription - set to FREE
|
// No active subscription - set to FREE
|
||||||
await db.user.update({
|
await db.user.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: {
|
data: {
|
||||||
stripeSubscriptionId: null,
|
stripeSubscriptionId: null,
|
||||||
stripePriceId: null,
|
stripePriceId: null,
|
||||||
stripeCurrentPeriodEnd: null,
|
stripeCurrentPeriodEnd: null,
|
||||||
plan: 'FREE',
|
plan: 'FREE',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await scoreUserLifecycle(user.id, 'subscription_deleted');
|
await scoreUserLifecycle(user.id, 'subscription_deleted');
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
plan: 'FREE',
|
plan: 'FREE',
|
||||||
message: 'No active subscription found, set to FREE plan',
|
message: 'No active subscription found, set to FREE plan',
|
||||||
@@ -90,20 +90,20 @@ export async function POST(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Update user in database
|
// Update user in database
|
||||||
await db.user.update({
|
await db.user.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: {
|
data: {
|
||||||
stripeSubscriptionId: subscription.id,
|
stripeSubscriptionId: subscription.id,
|
||||||
stripePriceId: priceId,
|
stripePriceId: priceId,
|
||||||
stripeCurrentPeriodEnd: currentPeriodEnd,
|
stripeCurrentPeriodEnd: currentPeriodEnd,
|
||||||
plan: plan as any,
|
plan: plan as any,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await scoreUserLifecycle(user.id, 'subscription_synced');
|
await scoreUserLifecycle(user.id, 'subscription_synced');
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
plan,
|
plan,
|
||||||
subscriptionId: subscription.id,
|
subscriptionId: subscription.id,
|
||||||
currentPeriodEnd,
|
currentPeriodEnd,
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { cookies } from 'next/headers';
|
import { getSessionUserId } from '@/lib/session';
|
||||||
import { stripe } from '@/lib/stripe';
|
import { stripe } from '@/lib/stripe';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { scoreUserLifecycle } from '@/lib/revops-server';
|
import { scoreUserLifecycle } from '@/lib/revops-server';
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
// Use cookie-based auth instead of NextAuth
|
// Use cookie-based auth instead of NextAuth
|
||||||
const userId = cookies().get('userId')?.value;
|
const userId = getSessionUserId();
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await db.user.findUnique({
|
const user = await db.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user.stripeCustomerId) {
|
if (!user.stripeCustomerId) {
|
||||||
return NextResponse.json({ error: 'No Stripe customer ID' }, { status: 400 });
|
return NextResponse.json({ error: 'No Stripe customer ID' }, { status: 400 });
|
||||||
}
|
}
|
||||||
@@ -45,31 +45,31 @@ export async function POST(request: NextRequest) {
|
|||||||
if (checkoutSession.payment_status === 'paid' && checkoutSession.subscription) {
|
if (checkoutSession.payment_status === 'paid' && checkoutSession.subscription) {
|
||||||
const subscriptionId = typeof checkoutSession.subscription === 'string'
|
const subscriptionId = typeof checkoutSession.subscription === 'string'
|
||||||
? checkoutSession.subscription
|
? checkoutSession.subscription
|
||||||
: checkoutSession.subscription.id;
|
: checkoutSession.subscription.id;
|
||||||
|
|
||||||
// Retrieve the full subscription object
|
// Retrieve the full subscription object
|
||||||
const subscription: any = await stripe.subscriptions.retrieve(subscriptionId);
|
const subscription: any = await stripe.subscriptions.retrieve(subscriptionId);
|
||||||
|
|
||||||
// Determine plan from metadata or price ID
|
// Determine plan from metadata or price ID
|
||||||
const plan = checkoutSession.metadata?.plan || 'PRO';
|
const plan = checkoutSession.metadata?.plan || 'PRO';
|
||||||
|
|
||||||
// Get current_period_end - Stripe returns it as a Unix timestamp
|
// Get current_period_end - Stripe returns it as a Unix timestamp
|
||||||
const periodEndTimestamp = subscription.current_period_end
|
const periodEndTimestamp = subscription.current_period_end
|
||||||
|| subscription.currentPeriodEnd
|
|| subscription.currentPeriodEnd
|
||||||
|| subscription.billing_cycle_anchor;
|
|| subscription.billing_cycle_anchor;
|
||||||
|
|
||||||
const currentPeriodEnd = periodEndTimestamp
|
const currentPeriodEnd = periodEndTimestamp
|
||||||
? new Date(periodEndTimestamp * 1000)
|
? new Date(periodEndTimestamp * 1000)
|
||||||
: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // Default to 30 days from now
|
: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // Default to 30 days from now
|
||||||
|
|
||||||
// Update user in database
|
// Update user in database
|
||||||
await db.user.update({
|
await db.user.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: {
|
data: {
|
||||||
stripeSubscriptionId: subscription.id,
|
stripeSubscriptionId: subscription.id,
|
||||||
stripePriceId: subscription.items.data[0].price.id,
|
stripePriceId: subscription.items.data[0].price.id,
|
||||||
stripeCurrentPeriodEnd: currentPeriodEnd,
|
stripeCurrentPeriodEnd: currentPeriodEnd,
|
||||||
plan: plan as any,
|
plan: plan as any,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -79,15 +79,15 @@ export async function POST(request: NextRequest) {
|
|||||||
success: true,
|
success: true,
|
||||||
plan,
|
plan,
|
||||||
subscriptionId: subscription.id,
|
subscriptionId: subscription.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ error: 'Payment not completed' }, { status: 400 });
|
return NextResponse.json({ error: 'Payment not completed' }, { status: 400 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error verifying session:', error);
|
console.error('Error verifying session:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Internal server error' },
|
{ error: 'Internal server error' },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { TIKTOK_ACCOUNT_KEY, TIKTOK_OAUTH_STATE_COOKIE_NAME } from '@/lib/tiktok';
|
import {
|
||||||
|
assertExpectedTiktokAccount,
|
||||||
|
TIKTOK_ACCOUNT_KEY,
|
||||||
|
TIKTOK_OAUTH_STATE_COOKIE_NAME,
|
||||||
|
} from '@/lib/tiktok';
|
||||||
|
|
||||||
const textResponse = (body: string, status: number) => {
|
const textResponse = (body: string, status: number) => {
|
||||||
const response = new NextResponse(body, {
|
const response = new NextResponse(body, {
|
||||||
@@ -56,6 +60,9 @@ export async function GET(request: NextRequest) {
|
|||||||
throw new Error(tokens.error_description || tokens.error || 'TikTok token exchange failed');
|
throw new Error(tokens.error_description || tokens.error || 'TikTok token exchange failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reject the wrong browser account before it can replace the valid QRMaster connection.
|
||||||
|
assertExpectedTiktokAccount(tokens.open_id);
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const accessTokenExpiresAt = new Date(now + Number(tokens.expires_in || 0) * 1000);
|
const accessTokenExpiresAt = new Date(now + Number(tokens.expires_in || 0) * 1000);
|
||||||
const refreshTokenExpiresAt = tokens.refresh_expires_in
|
const refreshTokenExpiresAt = tokens.refresh_expires_in
|
||||||
@@ -87,6 +94,8 @@ export async function GET(request: NextRequest) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('TikTok callback error:', err);
|
console.error('TikTok callback error:', err);
|
||||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||||
return textResponse(`Failed to connect TikTok account: ${message}`, 502);
|
const status =
|
||||||
|
err instanceof Error && 'status' in err ? (err as { status?: number }).status : 502;
|
||||||
|
return textResponse(`Failed to connect TikTok account: ${message}`, status || 502);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getValidTiktokTokens } from '@/lib/tiktok';
|
import { createHash } from 'crypto';
|
||||||
|
import { TIKTOK_BRAND, getValidTiktokTokens } from '@/lib/tiktok';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const adminKey = process.env.TIKTOK_ADMIN_KEY;
|
const adminKey = process.env.TIKTOK_ADMIN_KEY;
|
||||||
@@ -7,7 +8,7 @@ export async function GET(request: NextRequest) {
|
|||||||
// Unlike /connect, this endpoint hands out live credentials — never expose
|
// Unlike /connect, this endpoint hands out live credentials — never expose
|
||||||
// it without a configured key.
|
// it without a configured key.
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'TIKTOK_ADMIN_KEY must be configured to expose tokens' },
|
{ error: 'TIKTOK_ADMIN_KEY must be configured to expose TikTok status' },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -27,15 +28,28 @@ export async function GET(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const refreshExpired = Boolean(
|
||||||
|
tokens.refreshTokenExpiresAt && tokens.refreshTokenExpiresAt.getTime() <= now
|
||||||
|
);
|
||||||
|
|
||||||
|
// This endpoint is status-only. Access and refresh tokens remain server-side.
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
access_token: tokens.accessToken,
|
connected: true,
|
||||||
open_id: tokens.openId,
|
accountMatch: true,
|
||||||
|
brand: TIKTOK_BRAND,
|
||||||
|
openIdFingerprint: createHash('sha256').update(tokens.openId).digest('hex').slice(0, 12),
|
||||||
scope: tokens.scope,
|
scope: tokens.scope,
|
||||||
expires_at: tokens.accessTokenExpiresAt.toISOString(),
|
accessTokenExpiresAt: tokens.accessTokenExpiresAt.toISOString(),
|
||||||
|
refreshTokenExpiresAt: tokens.refreshTokenExpiresAt?.toISOString() || null,
|
||||||
|
requiresReconnect: refreshExpired,
|
||||||
|
uploadMode: 'draft',
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('TikTok token endpoint error:', err);
|
console.error('TikTok token endpoint error:', err);
|
||||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||||
return NextResponse.json({ error: message }, { status: 502 });
|
const status =
|
||||||
|
err instanceof Error && 'status' in err ? (err as { status?: number }).status : 502;
|
||||||
|
return NextResponse.json({ error: message }, { status: status || 502 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { authOptions } from '@/lib/auth';
|
|||||||
import { uploadFileToR2 } from '@/lib/r2';
|
import { uploadFileToR2 } from '@/lib/r2';
|
||||||
import { env } from '@/lib/env';
|
import { env } from '@/lib/env';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
import { getSessionUserId } from '@/lib/session';
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -13,7 +14,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
// Fallback: Check for simple-login cookie if no NextAuth session
|
// Fallback: Check for simple-login cookie if no NextAuth session
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
const cookieUserId = request.cookies.get('userId')?.value;
|
const cookieUserId = getSessionUserId();
|
||||||
if (cookieUserId) {
|
if (cookieUserId) {
|
||||||
// Verify user exists
|
// Verify user exists
|
||||||
const user = await db.user.findUnique({
|
const user = await db.user.findUnique({
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { cookies } from 'next/headers';
|
import { getSessionUserId } from '@/lib/session';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const userId = cookies().get('userId')?.value;
|
// Debug endpoint must never be reachable in production
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = getSessionUserId();
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
|
import { getSessionUserId } from '@/lib/session';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { stripe } from '@/lib/stripe';
|
import { stripe } from '@/lib/stripe';
|
||||||
import { csrfProtection } from '@/lib/csrf';
|
import { csrfProtection } from '@/lib/csrf';
|
||||||
@@ -16,7 +17,7 @@ export async function DELETE(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const userId = cookies().get('userId')?.value;
|
const userId = getSessionUserId();
|
||||||
|
|
||||||
// Rate Limiting (user-based)
|
// Rate Limiting (user-based)
|
||||||
const clientId = userId || getClientIdentifier(request);
|
const clientId = userId || getClientIdentifier(request);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { cookies } from 'next/headers';
|
import { getSessionUserId } from '@/lib/session';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import { csrfProtection } from '@/lib/csrf';
|
import { csrfProtection } from '@/lib/csrf';
|
||||||
@@ -17,7 +17,7 @@ export async function PATCH(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const userId = cookies().get('userId')?.value;
|
const userId = getSessionUserId();
|
||||||
|
|
||||||
// Rate Limiting (user-based)
|
// Rate Limiting (user-based)
|
||||||
const clientId = userId || getClientIdentifier(request);
|
const clientId = userId || getClientIdentifier(request);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { cookies } from 'next/headers';
|
import { getSessionUserId } from '@/lib/session';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { STRIPE_PLANS } from '@/lib/stripe';
|
import { STRIPE_PLANS } from '@/lib/stripe';
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@ export const dynamic = 'force-dynamic';
|
|||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
// Use cookie-based auth instead of NextAuth
|
// Use cookie-based auth instead of NextAuth
|
||||||
const userId = cookies().get('userId')?.value;
|
const userId = getSessionUserId();
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { cookies } from 'next/headers';
|
import { getSessionUserId } from '@/lib/session';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { csrfProtection } from '@/lib/csrf';
|
import { csrfProtection } from '@/lib/csrf';
|
||||||
import { updateProfileSchema, validateRequest } from '@/lib/validationSchemas';
|
import { updateProfileSchema, validateRequest } from '@/lib/validationSchemas';
|
||||||
@@ -13,7 +13,7 @@ export async function PATCH(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: csrfCheck.error }, { status: 403 });
|
return NextResponse.json({ error: csrfCheck.error }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const userId = cookies().get('userId')?.value;
|
const userId = getSessionUserId();
|
||||||
|
|
||||||
// Rate Limiting (user-based)
|
// Rate Limiting (user-based)
|
||||||
const clientId = userId || getClientIdentifier(request);
|
const clientId = userId || getClientIdentifier(request);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { cookies } from 'next/headers';
|
import { getSessionUserId } from '@/lib/session';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
// Force dynamic rendering (required for cookies)
|
// Force dynamic rendering (required for cookies)
|
||||||
@@ -11,7 +11,7 @@ export const dynamic = 'force-dynamic';
|
|||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const userId = cookies().get('userId')?.value;
|
const userId = getSessionUserId();
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { cookies } from 'next/headers';
|
import { getSessionUserId } from '@/lib/session';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const userId = cookies().get('userId')?.value;
|
const userId = getSessionUserId();
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import crypto from 'crypto';
|
||||||
import { getCsrfCookieOptions } from './cookieConfig';
|
import { getCsrfCookieOptions } from './cookieConfig';
|
||||||
|
|
||||||
const CSRF_TOKEN_COOKIE = 'csrf_token';
|
const CSRF_TOKEN_COOKIE = 'csrf_token';
|
||||||
@@ -38,7 +39,12 @@ export function validateCsrfToken(headerToken: string | null): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Constant-time comparison to prevent timing attacks
|
// Constant-time comparison to prevent timing attacks
|
||||||
return cookieToken === headerToken;
|
const cookieBuf = Buffer.from(cookieToken);
|
||||||
|
const headerBuf = Buffer.from(headerToken);
|
||||||
|
if (cookieBuf.length !== headerBuf.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return crypto.timingSafeEqual(cookieBuf, headerBuf);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
62
src/lib/session-edge.ts
Normal file
62
src/lib/session-edge.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* Edge-runtime variant of the signed-session verification used by middleware.
|
||||||
|
*
|
||||||
|
* Next.js middleware runs on the Edge runtime, where Node's `crypto` module is
|
||||||
|
* unavailable, so we verify the HMAC signature with Web Crypto (SubtleCrypto).
|
||||||
|
* Keep this in sync with `src/lib/session.ts` (same secret, same algorithm).
|
||||||
|
*/
|
||||||
|
|
||||||
|
function base64url(bytes: ArrayBuffer): string {
|
||||||
|
const view = new Uint8Array(bytes);
|
||||||
|
let bin = '';
|
||||||
|
for (let i = 0; i < view.length; i++) {
|
||||||
|
bin += String.fromCharCode(view[i]);
|
||||||
|
}
|
||||||
|
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function timingSafeEqual(a: string, b: string): boolean {
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
let diff = 0;
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return diff === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a signed cookie value in the Edge runtime.
|
||||||
|
* Returns the user id when the signature is valid, otherwise null.
|
||||||
|
*/
|
||||||
|
export async function verifySignedUserIdEdge(
|
||||||
|
value: string | undefined | null
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (!value) return null;
|
||||||
|
|
||||||
|
const secret = process.env.NEXTAUTH_SECRET;
|
||||||
|
if (!secret) return null;
|
||||||
|
|
||||||
|
const separator = value.lastIndexOf('.');
|
||||||
|
if (separator <= 0 || separator === value.length - 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = value.slice(0, separator);
|
||||||
|
const providedSig = value.slice(separator + 1);
|
||||||
|
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
new TextEncoder().encode(secret),
|
||||||
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
|
false,
|
||||||
|
['sign']
|
||||||
|
);
|
||||||
|
const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(userId));
|
||||||
|
const expectedSig = base64url(signature);
|
||||||
|
|
||||||
|
if (!timingSafeEqual(providedSig, expectedSig)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
79
src/lib/session.ts
Normal file
79
src/lib/session.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import 'server-only';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { getAuthCookieOptions } from './cookieConfig';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signed session cookie.
|
||||||
|
*
|
||||||
|
* The auth cookie holds the user id, but it MUST NOT be a bare, forgeable value.
|
||||||
|
* We attach an HMAC-SHA256 signature keyed with NEXTAUTH_SECRET so the server can
|
||||||
|
* detect a tampered/forged cookie and reject it. Format: `<userId>.<signature>`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const AUTH_COOKIE_NAME = 'userId';
|
||||||
|
|
||||||
|
function getSecret(): string {
|
||||||
|
const secret = process.env.NEXTAUTH_SECRET;
|
||||||
|
if (!secret) {
|
||||||
|
throw new Error('NEXTAUTH_SECRET is not set — cannot sign or verify session cookies');
|
||||||
|
}
|
||||||
|
return secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeSignature(userId: string): string {
|
||||||
|
return crypto.createHmac('sha256', getSecret()).update(userId).digest('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Produce the signed cookie value for a user id.
|
||||||
|
*/
|
||||||
|
export function signUserId(userId: string): string {
|
||||||
|
return `${userId}.${computeSignature(userId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a signed cookie value. Returns the user id if the signature is valid,
|
||||||
|
* otherwise null. Uses a constant-time comparison to avoid signature timing leaks.
|
||||||
|
*/
|
||||||
|
export function verifySignedUserId(value: string | undefined | null): string | null {
|
||||||
|
if (!value) return null;
|
||||||
|
|
||||||
|
const separator = value.lastIndexOf('.');
|
||||||
|
if (separator <= 0 || separator === value.length - 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = value.slice(0, separator);
|
||||||
|
const providedSig = value.slice(separator + 1);
|
||||||
|
const expectedSig = computeSignature(userId);
|
||||||
|
|
||||||
|
const providedBuf = Buffer.from(providedSig);
|
||||||
|
const expectedBuf = Buffer.from(expectedSig);
|
||||||
|
|
||||||
|
if (providedBuf.length !== expectedBuf.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!crypto.timingSafeEqual(providedBuf, expectedBuf)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read and verify the authenticated user id from the request cookies.
|
||||||
|
* Returns null when no valid, correctly-signed session cookie is present.
|
||||||
|
*
|
||||||
|
* Use this in route handlers instead of reading the `userId` cookie directly.
|
||||||
|
*/
|
||||||
|
export function getSessionUserId(): string | null {
|
||||||
|
return verifySignedUserId(cookies().get(AUTH_COOKIE_NAME)?.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the signed auth cookie for the given user id (server component / route handler context).
|
||||||
|
*/
|
||||||
|
export function setSessionCookie(userId: string): void {
|
||||||
|
cookies().set(AUTH_COOKIE_NAME, signUserId(userId), getAuthCookieOptions());
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
export const TIKTOK_OAUTH_STATE_COOKIE_NAME = 'tiktok_oauth_state';
|
export const TIKTOK_OAUTH_STATE_COOKIE_NAME = 'tiktok_oauth_state';
|
||||||
export const TIKTOK_ACCOUNT_KEY = 'hermes-agent';
|
export const TIKTOK_ACCOUNT_KEY = 'qrmaster';
|
||||||
|
const LEGACY_TIKTOK_ACCOUNT_KEY = 'hermes-agent';
|
||||||
|
export const TIKTOK_BRAND = 'qrmaster';
|
||||||
|
|
||||||
export class TiktokApiError extends Error {
|
export class TiktokApiError extends Error {
|
||||||
status: number;
|
status: number;
|
||||||
@@ -17,24 +19,57 @@ export class TiktokApiError extends Error {
|
|||||||
// Refresh when the access token expires within this window, so Hermes never
|
// Refresh when the access token expires within this window, so Hermes never
|
||||||
// receives a token that dies mid-upload.
|
// receives a token that dies mid-upload.
|
||||||
const REFRESH_BUFFER_MS = 5 * 60 * 1000;
|
const REFRESH_BUFFER_MS = 5 * 60 * 1000;
|
||||||
|
let refreshInFlight: Promise<Awaited<ReturnType<typeof findTiktokIntegration>>> | null = null;
|
||||||
|
|
||||||
export async function getValidTiktokTokens() {
|
const expectedOpenId = () => process.env.TIKTOK_EXPECTED_OPEN_ID?.trim();
|
||||||
const integration = await db.tiktokIntegration.findUnique({
|
|
||||||
|
export function assertExpectedTiktokAccount(openId: string | null | undefined) {
|
||||||
|
const expected = expectedOpenId();
|
||||||
|
if (!expected) {
|
||||||
|
throw new TiktokApiError('TIKTOK_EXPECTED_OPEN_ID is not configured for QRMaster.', 500);
|
||||||
|
}
|
||||||
|
if (!openId || openId !== expected) {
|
||||||
|
throw new TiktokApiError('Connected TikTok account does not match the QRMaster account.', 409);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findTiktokIntegration() {
|
||||||
|
const current = await db.tiktokIntegration.findUnique({
|
||||||
where: { accountKey: TIKTOK_ACCOUNT_KEY },
|
where: { accountKey: TIKTOK_ACCOUNT_KEY },
|
||||||
});
|
});
|
||||||
|
if (current) return current;
|
||||||
|
|
||||||
if (!integration) {
|
const legacy = await db.tiktokIntegration.findUnique({
|
||||||
return null;
|
where: { accountKey: LEGACY_TIKTOK_ACCOUNT_KEY },
|
||||||
}
|
});
|
||||||
|
if (!legacy) return null;
|
||||||
|
|
||||||
if (integration.accessTokenExpiresAt.getTime() - Date.now() > REFRESH_BUFFER_MS) {
|
// Preserve an existing installation while moving it to the product-specific key.
|
||||||
return integration;
|
return db.tiktokIntegration.update({
|
||||||
|
where: { accountKey: LEGACY_TIKTOK_ACCOUNT_KEY },
|
||||||
|
data: { accountKey: TIKTOK_ACCOUNT_KEY },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshTiktokTokens() {
|
||||||
|
const integration = await findTiktokIntegration();
|
||||||
|
if (!integration) return null;
|
||||||
|
assertExpectedTiktokAccount(integration.openId);
|
||||||
|
|
||||||
|
if (
|
||||||
|
integration.refreshTokenExpiresAt &&
|
||||||
|
integration.refreshTokenExpiresAt.getTime() <= Date.now()
|
||||||
|
) {
|
||||||
|
throw new TiktokApiError(
|
||||||
|
'TikTok authorization has expired. Reconnect the QRMaster TikTok account.',
|
||||||
|
401
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const clientKey = process.env.TIKTOK_CLIENT_KEY;
|
const clientKey = process.env.TIKTOK_CLIENT_KEY;
|
||||||
const clientSecret = process.env.TIKTOK_CLIENT_SECRET;
|
const clientSecret = process.env.TIKTOK_CLIENT_SECRET;
|
||||||
if (!clientKey || !clientSecret) {
|
if (!clientKey || !clientSecret) {
|
||||||
throw new Error('TikTok client credentials are not configured.');
|
throw new TiktokApiError('TikTok client credentials are not configured.', 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch('https://open.tiktokapis.com/v2/oauth/token/', {
|
const response = await fetch('https://open.tiktokapis.com/v2/oauth/token/', {
|
||||||
@@ -50,41 +85,63 @@ export async function getValidTiktokTokens() {
|
|||||||
|
|
||||||
const tokens = await response.json();
|
const tokens = await response.json();
|
||||||
if (!response.ok || tokens.error) {
|
if (!response.ok || tokens.error) {
|
||||||
throw new Error(tokens.error_description || tokens.error || 'TikTok token refresh failed');
|
const reconnectRequired =
|
||||||
|
response.status === 401 || tokens.error === 'invalid_grant';
|
||||||
|
throw new TiktokApiError(
|
||||||
|
reconnectRequired
|
||||||
|
? 'TikTok authorization is no longer valid. Reconnect the QRMaster TikTok account.'
|
||||||
|
: tokens.error_description || tokens.error || 'TikTok token refresh failed',
|
||||||
|
reconnectRequired ? 401 : 502,
|
||||||
|
tokens
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
const refreshedOpenId = tokens.open_id || integration.openId;
|
||||||
|
assertExpectedTiktokAccount(refreshedOpenId);
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
return db.tiktokIntegration.update({
|
return db.tiktokIntegration.update({
|
||||||
where: { accountKey: TIKTOK_ACCOUNT_KEY },
|
where: { accountKey: TIKTOK_ACCOUNT_KEY },
|
||||||
data: {
|
data: {
|
||||||
openId: tokens.open_id,
|
openId: refreshedOpenId,
|
||||||
accessToken: tokens.access_token,
|
accessToken: tokens.access_token,
|
||||||
refreshToken: tokens.refresh_token,
|
refreshToken: tokens.refresh_token || integration.refreshToken,
|
||||||
scope: tokens.scope || null,
|
scope: tokens.scope || integration.scope,
|
||||||
accessTokenExpiresAt: new Date(now + Number(tokens.expires_in || 0) * 1000),
|
accessTokenExpiresAt: new Date(now + Number(tokens.expires_in || 0) * 1000),
|
||||||
refreshTokenExpiresAt: tokens.refresh_expires_in
|
refreshTokenExpiresAt: tokens.refresh_expires_in
|
||||||
? new Date(now + Number(tokens.refresh_expires_in || 0) * 1000)
|
? new Date(now + Number(tokens.refresh_expires_in || 0) * 1000)
|
||||||
: null,
|
: integration.refreshTokenExpiresAt,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getLiveTiktokAccessToken() {
|
async function refreshTiktokTokensOnce() {
|
||||||
const integration = await db.tiktokIntegration.findUnique({
|
if (!refreshInFlight) {
|
||||||
where: { accountKey: TIKTOK_ACCOUNT_KEY },
|
refreshInFlight = refreshTiktokTokens().finally(() => {
|
||||||
});
|
refreshInFlight = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return refreshInFlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getValidTiktokTokens(forceRefresh = false) {
|
||||||
|
const integration = await findTiktokIntegration();
|
||||||
|
|
||||||
if (!integration) {
|
if (!integration) {
|
||||||
throw new TiktokApiError('No TikTok account connected.', 404);
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (integration.accessTokenExpiresAt.getTime() - Date.now() > REFRESH_BUFFER_MS) {
|
assertExpectedTiktokAccount(integration.openId);
|
||||||
|
|
||||||
|
if (!forceRefresh && integration.accessTokenExpiresAt.getTime() - Date.now() > REFRESH_BUFFER_MS) {
|
||||||
return integration;
|
return integration;
|
||||||
}
|
}
|
||||||
|
return refreshTiktokTokensOnce();
|
||||||
|
}
|
||||||
|
|
||||||
const clientKey = process.env.TIKTOK_CLIENT_KEY;
|
export async function getLiveTiktokAccessToken() {
|
||||||
const clientSecret = process.env.TIKTOK_CLIENT_SECRET;
|
const integration = await findTiktokIntegration();
|
||||||
if (!clientKey || !clientSecret) {
|
if (!integration) {
|
||||||
throw new TiktokApiError('TikTok client credentials are not configured.', 500);
|
throw new TiktokApiError('No TikTok account connected.', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const refreshed = await getValidTiktokTokens();
|
const refreshed = await getValidTiktokTokens();
|
||||||
@@ -95,20 +152,20 @@ export async function getLiveTiktokAccessToken() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function tiktokApi(url: string, options: RequestInit = {}) {
|
export async function tiktokApi(url: string, options: RequestInit = {}) {
|
||||||
const tokens = await getLiveTiktokAccessToken();
|
const request = async (accessToken: string) => {
|
||||||
const accessToken = tokens.accessToken;
|
const res = await fetch(url, {
|
||||||
|
...options,
|
||||||
const fetchOptions: RequestInit = {
|
headers: {
|
||||||
...options,
|
...(options.headers as Record<string, string> | undefined),
|
||||||
headers: {
|
Authorization: `Bearer ${accessToken}`,
|
||||||
...(options.headers as Record<string, string> | undefined),
|
'Content-Type': 'application/json; charset=UTF-8',
|
||||||
Authorization: `Bearer ${accessToken}`,
|
},
|
||||||
'Content-Type': 'application/json; charset=UTF-8',
|
});
|
||||||
},
|
return { res, text: await res.text() };
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = await fetch(url, fetchOptions);
|
let tokens = await getLiveTiktokAccessToken();
|
||||||
const text = await res.text();
|
let { res, text } = await request(tokens.accessToken);
|
||||||
let data: Record<string, unknown>;
|
let data: Record<string, unknown>;
|
||||||
try {
|
try {
|
||||||
data = JSON.parse(text) as Record<string, unknown>;
|
data = JSON.parse(text) as Record<string, unknown>;
|
||||||
@@ -116,6 +173,17 @@ export async function tiktokApi(url: string, options: RequestInit = {}) {
|
|||||||
data = { raw: text };
|
data = { raw: text };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const errorCode = (data?.error as Record<string, string> | undefined)?.code;
|
||||||
|
if (errorCode === 'access_token_invalid') {
|
||||||
|
tokens = await getValidTiktokTokens(true) as NonNullable<typeof tokens>;
|
||||||
|
({ res, text } = await request(tokens.accessToken));
|
||||||
|
try {
|
||||||
|
data = JSON.parse(text) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
data = { raw: text };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!res.ok || (data?.error as Record<string, string> | undefined)?.code !== 'ok') {
|
if (!res.ok || (data?.error as Record<string, string> | undefined)?.code !== 'ok') {
|
||||||
const message = (data?.error as Record<string, string> | undefined)?.message || (typeof data?.raw === 'string' ? data.raw : '') || `TikTok API error: ${res.status}`;
|
const message = (data?.error as Record<string, string> | undefined)?.message || (typeof data?.raw === 'string' ? data.raw : '') || `TikTok API error: ${res.status}`;
|
||||||
throw new TiktokApiError(message, res.status, data);
|
throw new TiktokApiError(message, res.status, data);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
buildAttributionSnapshot,
|
buildAttributionSnapshot,
|
||||||
serializeAttributionCookie,
|
serializeAttributionCookie,
|
||||||
} from '@/lib/revops';
|
} from '@/lib/revops';
|
||||||
|
import { verifySignedUserIdEdge } from '@/lib/session-edge';
|
||||||
|
|
||||||
const isProduction = process.env.NODE_ENV === 'production';
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
@@ -41,7 +42,7 @@ function attachAttributionCookie(req: NextRequest, response: NextResponse) {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function middleware(req: NextRequest) {
|
export async function middleware(req: NextRequest) {
|
||||||
const path = req.nextUrl.pathname;
|
const path = req.nextUrl.pathname;
|
||||||
const hostname = req.headers.get('host')?.split(':')[0] || req.nextUrl.hostname;
|
const hostname = req.headers.get('host')?.split(':')[0] || req.nextUrl.hostname;
|
||||||
|
|
||||||
@@ -157,8 +158,8 @@ export function middleware(req: NextRequest) {
|
|||||||
return attachAttributionCookie(req, NextResponse.next());
|
return attachAttributionCookie(req, NextResponse.next());
|
||||||
}
|
}
|
||||||
|
|
||||||
// For protected routes, check for userId cookie
|
// For protected routes, require a validly signed userId cookie
|
||||||
const userId = req.cookies.get('userId')?.value;
|
const userId = await verifySignedUserIdEdge(req.cookies.get('userId')?.value);
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
// Not authenticated - redirect to signup
|
// Not authenticated - redirect to signup
|
||||||
|
|||||||
Reference in New Issue
Block a user