TikTok V5 + Security

This commit is contained in:
2026-07-11 22:09:10 +02:00
parent 671c1a1559
commit d542f849aa
36 changed files with 740 additions and 463 deletions

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { getSessionUserId } from '@/lib/session';
import { db } from '@/lib/db';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
import { TrendData } from '@/types/analytics';
@@ -41,7 +41,7 @@ function calculateTrend(current: number, previous: number): TrendData {
export async function GET(request: NextRequest) {
try {
const userId = cookies().get('userId')?.value;
const userId = getSessionUserId();
// Rate Limiting (user-based)
const clientId = userId || getClientIdentifier(request);

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { csrfProtection } from '@/lib/csrf';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
import { sendPasswordResetEmail } from '@/lib/email';
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 { email } = body;

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getAuthCookieOptions } from '@/lib/cookieConfig';
import { signUserId } from '@/lib/session';
import {
appendRedirectParam,
GOOGLE_OAUTH_STATE_COOKIE_NAME,
@@ -27,14 +28,14 @@ export async function GET(request: NextRequest) {
// If no code, redirect to Google OAuth
if (!code) {
const googleClientId = process.env.GOOGLE_CLIENT_ID;
if (!googleClientId) {
return NextResponse.json(
{ error: 'Google Client ID not configured' },
{ status: 500 }
);
}
const googleClientId = process.env.GOOGLE_CLIENT_ID;
if (!googleClientId) {
return NextResponse.json(
{ error: 'Google Client ID not configured' },
{ status: 500 }
);
}
const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/google`;
const scope = 'openid email profile';
@@ -85,58 +86,58 @@ export async function GET(request: NextRequest) {
const googleClientId = process.env.GOOGLE_CLIENT_ID;
const googleClientSecret = process.env.GOOGLE_CLIENT_SECRET;
if (!googleClientId || !googleClientSecret) {
return NextResponse.json(
{ error: 'Google OAuth not configured' },
{ status: 500 }
);
}
const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/google`;
// Exchange code for tokens
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
code,
client_id: googleClientId,
client_secret: googleClientSecret,
if (!googleClientId || !googleClientSecret) {
return NextResponse.json(
{ error: 'Google OAuth not configured' },
{ status: 500 }
);
}
const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/google`;
// Exchange code for tokens
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
code,
client_id: googleClientId,
client_secret: googleClientSecret,
redirect_uri: redirectUri,
grant_type: 'authorization_code',
}),
});
if (!tokenResponse.ok) {
throw new Error('Failed to exchange code for tokens');
}
const tokens = await tokenResponse.json();
// Get user info from Google
const userInfoResponse = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
headers: {
Authorization: `Bearer ${tokens.access_token}`,
},
});
if (!userInfoResponse.ok) {
throw new Error('Failed to get user info');
}
const userInfo = await userInfoResponse.json();
// Check if user exists in database
let user = await db.user.findUnique({
where: { email: userInfo.email },
});
const isNewUser = !user;
// Create user if they don't exist
});
if (!tokenResponse.ok) {
throw new Error('Failed to exchange code for tokens');
}
const tokens = await tokenResponse.json();
// Get user info from Google
const userInfoResponse = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
headers: {
Authorization: `Bearer ${tokens.access_token}`,
},
});
if (!userInfoResponse.ok) {
throw new Error('Failed to get user info');
}
const userInfo = await userInfoResponse.json();
// Check if user exists in database
let user = await db.user.findUnique({
where: { email: userInfo.email },
});
const isNewUser = !user;
// Create user if they don't exist
if (!user) {
const onboardingStartedAt = new Date();
user = await db.user.create({
@@ -158,58 +159,58 @@ export async function GET(request: NextRequest) {
signupFirstSeenAt: firstTouch?.signupFirstSeenAt ? new Date(firstTouch.signupFirstSeenAt) : onboardingStartedAt,
},
});
// Create Account entry for the OAuth provider
await db.account.create({
data: {
userId: user.id,
type: 'oauth',
provider: 'google',
providerAccountId: userInfo.sub || userInfo.id,
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expires_at: tokens.expires_in ? Math.floor(Date.now() / 1000) + tokens.expires_in : null,
token_type: tokens.token_type,
scope: tokens.scope,
id_token: tokens.id_token,
},
});
} else {
// Update existing account tokens
const existingAccount = await db.account.findUnique({
where: {
provider_providerAccountId: {
provider: 'google',
providerAccountId: userInfo.sub || userInfo.id,
},
},
});
if (existingAccount) {
await db.account.update({
where: { id: existingAccount.id },
data: {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expires_at: tokens.expires_in ? Math.floor(Date.now() / 1000) + tokens.expires_in : null,
},
});
} else {
// Create Account entry if it doesn't exist
await db.account.create({
data: {
userId: user.id,
type: 'oauth',
provider: 'google',
providerAccountId: userInfo.sub || userInfo.id,
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expires_at: tokens.expires_in ? Math.floor(Date.now() / 1000) + tokens.expires_in : null,
token_type: tokens.token_type,
scope: tokens.scope,
id_token: tokens.id_token,
},
});
// Create Account entry for the OAuth provider
await db.account.create({
data: {
userId: user.id,
type: 'oauth',
provider: 'google',
providerAccountId: userInfo.sub || userInfo.id,
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expires_at: tokens.expires_in ? Math.floor(Date.now() / 1000) + tokens.expires_in : null,
token_type: tokens.token_type,
scope: tokens.scope,
id_token: tokens.id_token,
},
});
} else {
// Update existing account tokens
const existingAccount = await db.account.findUnique({
where: {
provider_providerAccountId: {
provider: 'google',
providerAccountId: userInfo.sub || userInfo.id,
},
},
});
if (existingAccount) {
await db.account.update({
where: { id: existingAccount.id },
data: {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expires_at: tokens.expires_in ? Math.floor(Date.now() / 1000) + tokens.expires_in : null,
},
});
} else {
// Create Account entry if it doesn't exist
await db.account.create({
data: {
userId: user.id,
type: 'oauth',
provider: 'google',
providerAccountId: userInfo.sub || userInfo.id,
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expires_at: tokens.expires_in ? Math.floor(Date.now() / 1000) + tokens.expires_in : null,
token_type: tokens.token_type,
scope: tokens.scope,
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 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(POST_AUTH_REDIRECT_COOKIE_NAME);
response.cookies.delete(ATTRIBUTION_COOKIE_NAME);

View File

@@ -62,7 +62,7 @@ export async function POST(req: NextRequest) {
}
// 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
await db.user.update({

View File

@@ -1,19 +1,20 @@
import { NextRequest, NextResponse } from 'next/server';
import bcrypt from 'bcryptjs';
import { db } from '@/lib/db';
import { NextRequest, NextResponse } from 'next/server';
import bcrypt from 'bcryptjs';
import { db } from '@/lib/db';
import { z } from 'zod';
import { csrfProtection } from '@/lib/csrf';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
import { getAuthCookieOptions } from '@/lib/cookieConfig';
import { signupSchema, validateRequest } from '@/lib/validationSchemas';
import { sendWelcomeEmail } from '@/lib/email';
import { sendConversionEvent } from '@/lib/metaConversions';
import {
ATTRIBUTION_COOKIE_NAME,
getEmailDomain,
parseAttributionCookie,
} from '@/lib/revops';
import { triggerLifecycleScoring } from '@/lib/revops-server';
import { getAuthCookieOptions } from '@/lib/cookieConfig';
import { signUserId } from '@/lib/session';
import { signupSchema, validateRequest } from '@/lib/validationSchemas';
import { sendWelcomeEmail } from '@/lib/email';
import { sendConversionEvent } from '@/lib/metaConversions';
import {
ATTRIBUTION_COOKIE_NAME,
getEmailDomain,
parseAttributionCookie,
} from '@/lib/revops';
import { triggerLifecycleScoring } from '@/lib/revops-server';
export async function POST(request: NextRequest) {
try {
@@ -72,29 +73,29 @@ export async function POST(request: NextRequest) {
// Hash password
const hashedPassword = await bcrypt.hash(password, 12);
const firstTouch = parseAttributionCookie(request.cookies.get(ATTRIBUTION_COOKIE_NAME)?.value);
const onboardingStartedAt = new Date();
// Create user
const user = await db.user.create({
data: {
name,
email,
password: hashedPassword,
onboardingStartedAt,
emailDomain: getEmailDomain(email),
signupSource: firstTouch?.signupSource || null,
signupMedium: firstTouch?.signupMedium || null,
signupCampaign: firstTouch?.signupCampaign || null,
signupContent: firstTouch?.signupContent || null,
signupTerm: firstTouch?.signupTerm || null,
signupReferrer: firstTouch?.signupReferrer || null,
signupLandingPath: firstTouch?.signupLandingPath || '/signup',
signupFirstSeenAt: firstTouch?.signupFirstSeenAt ? new Date(firstTouch.signupFirstSeenAt) : onboardingStartedAt,
},
});
triggerLifecycleScoring(user.id, 'signup');
const firstTouch = parseAttributionCookie(request.cookies.get(ATTRIBUTION_COOKIE_NAME)?.value);
const onboardingStartedAt = new Date();
// Create user
const user = await db.user.create({
data: {
name,
email,
password: hashedPassword,
onboardingStartedAt,
emailDomain: getEmailDomain(email),
signupSource: firstTouch?.signupSource || null,
signupMedium: firstTouch?.signupMedium || null,
signupCampaign: firstTouch?.signupCampaign || null,
signupContent: firstTouch?.signupContent || null,
signupTerm: firstTouch?.signupTerm || null,
signupReferrer: firstTouch?.signupReferrer || null,
signupLandingPath: firstTouch?.signupLandingPath || '/signup',
signupFirstSeenAt: firstTouch?.signupFirstSeenAt ? new Date(firstTouch.signupFirstSeenAt) : onboardingStartedAt,
},
});
triggerLifecycleScoring(user.id, 'signup');
// Send welcome email (fire-and-forget — never block signup)
try {
@@ -117,22 +118,22 @@ export async function POST(request: NextRequest) {
}).catch(console.error);
// Create response
const response = NextResponse.json({
success: true,
needsOnboarding: true,
user: {
id: user.id,
name: user.name,
email: user.email,
plan: 'FREE',
const response = NextResponse.json({
success: true,
needsOnboarding: true,
user: {
id: user.id,
name: user.name,
email: user.email,
plan: 'FREE',
},
});
// Set cookie for auto-login after signup
response.cookies.set('userId', user.id, getAuthCookieOptions());
response.cookies.delete(ATTRIBUTION_COOKIE_NAME);
return response;
// Set cookie for auto-login after signup
response.cookies.set('userId', signUserId(user.id), getAuthCookieOptions());
response.cookies.delete(ATTRIBUTION_COOKIE_NAME);
return response;
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
@@ -147,4 +148,4 @@ export async function POST(request: NextRequest) {
{ status: 500 }
);
}
}
}

View File

@@ -1,12 +1,15 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import bcrypt from 'bcryptjs';
import { cookies } from 'next/headers';
import { csrfProtection } from '@/lib/csrf';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
import { getAuthCookieOptions } from '@/lib/cookieConfig';
import { loginSchema, validateRequest } from '@/lib/validationSchemas';
import { shouldResumeOnboarding } from '@/lib/revops';
import { csrfProtection } from '@/lib/csrf';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
import { setSessionCookie } from '@/lib/session';
import { loginSchema, validateRequest } from '@/lib/validationSchemas';
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) {
try {
@@ -51,28 +54,30 @@ export async function POST(request: NextRequest) {
const { email, password } = validation.data;
// Find user
const user = await db.user.findUnique({
where: { email },
select: {
id: true,
email: true,
name: true,
plan: true,
password: true,
onboardingStartedAt: true,
onboardingCompletedAt: true,
},
});
const user = await db.user.findUnique({
where: { email },
select: {
id: true,
email: true,
name: true,
plan: true,
password: true,
onboardingStartedAt: true,
onboardingCompletedAt: true,
},
});
if (!user) {
// Equalize response time to prevent user enumeration via timing.
await bcrypt.compare(password, DUMMY_BCRYPT_HASH);
return NextResponse.json(
{ error: 'Invalid email or password' },
{ status: 401 }
);
}
// Verify password
const isValid = await bcrypt.compare(password, user.password || '');
// Verify password (dummy-compare when no hash exists, e.g. OAuth-only accounts)
const isValid = await bcrypt.compare(password, user.password || DUMMY_BCRYPT_HASH);
if (!isValid) {
return NextResponse.json(
@@ -81,16 +86,16 @@ export async function POST(request: NextRequest) {
);
}
// Set cookie
cookies().set('userId', user.id, getAuthCookieOptions());
// Set signed session cookie
setSessionCookie(user.id);
return NextResponse.json({
success: true,
needsOnboarding: shouldResumeOnboarding(user),
user: { id: user.id, email: user.email, name: user.name, plan: user.plan || 'FREE' }
});
return NextResponse.json({
success: true,
needsOnboarding: shouldResumeOnboarding(user),
user: { id: user.id, email: user.email, name: user.name, plan: user.plan || 'FREE' }
});
} catch (error) {
console.error('Login error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
}

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { getSessionUserId } from '@/lib/session';
import { db } from '@/lib/db';
import { csrfProtection } from '@/lib/csrf';
import { getClientIdentifier, rateLimit, RateLimits } from '@/lib/rateLimit';
@@ -10,7 +10,7 @@ export const dynamic = 'force-dynamic';
export async function GET() {
try {
const userId = cookies().get('userId')?.value;
const userId = getSessionUserId();
if (!userId) {
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 });
}
const userId = cookies().get('userId')?.value;
const userId = getSessionUserId();
const clientId = userId || getClientIdentifier(request);
const rateLimitResult = rateLimit(clientId, RateLimits.PROFILE_UPDATE);

View File

@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { db } from '@/lib/db';
import { cookies } from 'next/headers';
import { getSessionUserId } from '@/lib/session';
export async function GET(
request: NextRequest,
@@ -16,9 +16,8 @@ export async function GET(
if (session?.user?.id) {
userId = session.user.id;
} else {
// Fallback: Check raw userId cookie (like /api/user does)
const cookieStore = await cookies();
userId = cookieStore.get('userId')?.value;
// Fallback: verified signed userId cookie (like /api/user does)
userId = getSessionUserId() ?? undefined;
}
if (!userId) {

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { getSessionUserId } from '@/lib/session';
import { db } from '@/lib/db';
import { z } from 'zod';
import { csrfProtection } from '@/lib/csrf';
@@ -18,7 +18,7 @@ export async function GET(
{ params }: { params: { id: string } }
) {
try {
const userId = cookies().get('userId')?.value;
const userId = getSessionUserId();
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
@@ -62,7 +62,7 @@ export async function PATCH(
return NextResponse.json({ error: csrfCheck.error }, { status: 403 });
}
const userId = cookies().get('userId')?.value;
const userId = getSessionUserId();
// Rate Limiting (user-based)
const clientId = userId || getClientIdentifier(request);
@@ -151,7 +151,7 @@ export async function DELETE(
return NextResponse.json({ error: csrfCheck.error }, { status: 403 });
}
const userId = cookies().get('userId')?.value;
const userId = getSessionUserId();
// Rate Limiting (user-based)
const clientId = userId || getClientIdentifier(request);

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { getSessionUserId } from '@/lib/session';
import { db } from '@/lib/db';
import { csrfProtection } from '@/lib/csrf';
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)
const clientId = userId || getClientIdentifier(request);

View File

@@ -1,17 +1,17 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { getSessionUserId } from '@/lib/session';
import { db } from '@/lib/db';
import { generateSlug } from '@/lib/hash';
import { createQRSchema, validateRequest } from '@/lib/validationSchemas';
import { csrfProtection } from '@/lib/csrf';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
import { DYNAMIC_QR_LIMITS } from '@/lib/plans';
import { triggerLifecycleScoring } from '@/lib/revops-server';
import { generateSlug } from '@/lib/hash';
import { createQRSchema, validateRequest } from '@/lib/validationSchemas';
import { csrfProtection } from '@/lib/csrf';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
import { DYNAMIC_QR_LIMITS } from '@/lib/plans';
import { triggerLifecycleScoring } from '@/lib/revops-server';
// GET /api/qrs - List user's QR codes
export async function GET(request: NextRequest) {
try {
const userId = cookies().get('userId')?.value;
const userId = getSessionUserId();
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
@@ -49,7 +49,7 @@ export async function GET(request: NextRequest) {
}
// Plan limits
const PLAN_LIMITS = DYNAMIC_QR_LIMITS;
const PLAN_LIMITS = DYNAMIC_QR_LIMITS;
// POST /api/qrs - Create a new QR code
export async function POST(request: NextRequest) {
@@ -60,7 +60,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: csrfCheck.error }, { status: 403 });
}
const userId = cookies().get('userId')?.value;
const userId = getSessionUserId();
// Rate Limiting (user-based)
const clientId = userId || getClientIdentifier(request);
@@ -205,9 +205,9 @@ END:VCARD`;
const slug = generateSlug(body.title);
// Create QR code
const qrCode = await db.qRCode.create({
data: {
userId,
const qrCode = await db.qRCode.create({
data: {
userId,
title: body.title,
type: isStatic ? 'STATIC' : 'DYNAMIC',
contentType: body.contentType,
@@ -221,12 +221,12 @@ END:VCARD`;
},
slug,
status: 'ACTIVE',
},
});
triggerLifecycleScoring(userId, 'qr_created');
return NextResponse.json(qrCode);
},
});
triggerLifecycleScoring(userId, 'qr_created');
return NextResponse.json(qrCode);
} catch (error) {
console.error('Error creating QR code:', error);
return NextResponse.json(
@@ -234,4 +234,4 @@ END:VCARD`;
{ status: 500 }
);
}
}
}

View File

@@ -1,12 +1,12 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { getSessionUserId } from '@/lib/session';
import { db } from '@/lib/db';
import { generateSlug } from '@/lib/hash';
// POST /api/qrs/static - Create a STATIC QR code that contains the direct URL
export async function POST(request: NextRequest) {
try {
const userId = cookies().get('userId')?.value;
const userId = getSessionUserId();
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

View File

@@ -1,13 +1,13 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { stripe } from '@/lib/stripe';
import { db } from '@/lib/db';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
import { scoreUserLifecycle } from '@/lib/revops-server';
import { getSessionUserId } from '@/lib/session';
import { stripe } from '@/lib/stripe';
import { db } from '@/lib/db';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
import { scoreUserLifecycle } from '@/lib/revops-server';
export async function POST(request: NextRequest) {
try {
const userId = cookies().get('userId')?.value;
const userId = getSessionUserId();
// Rate Limiting (user-based)
const clientId = userId || getClientIdentifier(request);
@@ -34,14 +34,14 @@ export async function POST(request: NextRequest) {
}
// Get user with subscription info
const user = await db.user.findUnique({
where: { id: userId },
select: {
stripeSubscriptionId: true,
stripeCurrentPeriodEnd: true,
plan: true,
},
});
const user = await db.user.findUnique({
where: { id: userId },
select: {
stripeSubscriptionId: true,
stripeCurrentPeriodEnd: true,
plan: true,
},
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
@@ -55,42 +55,42 @@ export async function POST(request: NextRequest) {
// No active subscription
if (!user.stripeSubscriptionId) {
// Just update plan to FREE if somehow plan is not FREE but no subscription
await db.user.update({
where: { id: userId },
data: {
await db.user.update({
where: { id: userId },
data: {
plan: 'FREE',
stripePriceId: null,
stripeCurrentPeriodEnd: null,
},
});
await scoreUserLifecycle(userId, 'subscription_deleted');
return NextResponse.json({ success: true });
},
});
await scoreUserLifecycle(userId, 'subscription_deleted');
return NextResponse.json({ success: true });
}
// Schedule cancellation at the end of the paid period so paid features stay active.
const subscription: any = await stripe.subscriptions.update(user.stripeSubscriptionId, {
cancel_at_period_end: true,
});
const periodEndTimestamp = subscription.current_period_end
|| subscription.currentPeriodEnd
|| subscription.billing_cycle_anchor;
const currentPeriodEnd = periodEndTimestamp
? new Date(periodEndTimestamp * 1000)
: user.stripeCurrentPeriodEnd;
// Keep the paid plan locally until Stripe sends customer.subscription.deleted.
await db.user.update({
where: { id: userId },
data: {
stripeCurrentPeriodEnd: currentPeriodEnd,
},
});
await scoreUserLifecycle(userId, 'subscription_canceled_at_period_end');
return NextResponse.json({ success: true, currentPeriodEnd });
// Schedule cancellation at the end of the paid period so paid features stay active.
const subscription: any = await stripe.subscriptions.update(user.stripeSubscriptionId, {
cancel_at_period_end: true,
});
const periodEndTimestamp = subscription.current_period_end
|| subscription.currentPeriodEnd
|| subscription.billing_cycle_anchor;
const currentPeriodEnd = periodEndTimestamp
? new Date(periodEndTimestamp * 1000)
: user.stripeCurrentPeriodEnd;
// Keep the paid plan locally until Stripe sends customer.subscription.deleted.
await db.user.update({
where: { id: userId },
data: {
stripeCurrentPeriodEnd: currentPeriodEnd,
},
});
await scoreUserLifecycle(userId, 'subscription_canceled_at_period_end');
return NextResponse.json({ success: true, currentPeriodEnd });
} catch (error) {
console.error('Error canceling subscription:', error);
return NextResponse.json(

View File

@@ -1,14 +1,13 @@
import { NextRequest, NextResponse } from 'next/server';
import { stripe, STRIPE_PLANS } from '@/lib/stripe';
import { db } from '@/lib/db';
import { cookies } from 'next/headers';
import { getSessionUserId } from '@/lib/session';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
export async function POST(request: NextRequest) {
try {
// Get user from cookie (using userId like other routes)
const cookieStore = await cookies();
const userId = cookieStore.get('userId')?.value;
// Get user from verified signed session cookie
const userId = getSessionUserId();
// Rate Limiting (user-based)
const clientId = userId || getClientIdentifier(request);
@@ -65,29 +64,29 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Create or get Stripe customer
let customerId = user.stripeCustomerId;
if (customerId) {
try {
const existingCustomer = await stripe.customers.retrieve(customerId);
if ('deleted' in existingCustomer && existingCustomer.deleted) {
customerId = null;
}
} catch (error: any) {
if (error?.code === 'resource_missing' || error?.type === 'StripeInvalidRequestError') {
customerId = null;
} else {
throw error;
}
}
}
if (!customerId) {
const customer = await stripe.customers.create({
email: user.email,
metadata: {
// Create or get Stripe customer
let customerId = user.stripeCustomerId;
if (customerId) {
try {
const existingCustomer = await stripe.customers.retrieve(customerId);
if ('deleted' in existingCustomer && existingCustomer.deleted) {
customerId = null;
}
} catch (error: any) {
if (error?.code === 'resource_missing' || error?.type === 'StripeInvalidRequestError') {
customerId = null;
} else {
throw error;
}
}
}
if (!customerId) {
const customer = await stripe.customers.create({
email: user.email,
metadata: {
userId: user.id,
},
});
@@ -95,34 +94,34 @@ export async function POST(request: NextRequest) {
customerId = customer.id;
// Update user with Stripe customer ID
await db.user.update({
where: { id: user.id },
data: { stripeCustomerId: customerId },
});
}
const appUrl = process.env.NEXT_PUBLIC_APP_URL || request.nextUrl.origin;
// Create Stripe Checkout Session
const checkoutSession = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
await db.user.update({
where: { id: user.id },
data: { stripeCustomerId: customerId },
});
}
const appUrl = process.env.NEXT_PUBLIC_APP_URL || request.nextUrl.origin;
// Create Stripe Checkout Session
const checkoutSession = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
payment_method_types: ['card'],
allow_promotion_codes: true,
allow_promotion_codes: true,
line_items: [
{
price: priceId,
quantity: 1,
},
],
success_url: `${appUrl}/dashboard?success=true&session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${appUrl}/pricing?canceled=true`,
metadata: {
userId: user.id,
plan,
billingInterval,
},
});
},
],
success_url: `${appUrl}/dashboard?success=true&session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${appUrl}/pricing?canceled=true`,
metadata: {
userId: user.id,
plan,
billingInterval,
},
});
return NextResponse.json({ url: checkoutSession.url });
} catch (error) {

View File

@@ -1,12 +1,12 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { getSessionUserId } from '@/lib/session';
import { stripe } from '@/lib/stripe';
import { db } from '@/lib/db';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
export async function POST(request: NextRequest) {
try {
const userId = cookies().get('userId')?.value;
const userId = getSessionUserId();
// Rate Limiting (user-based)
const clientId = userId || getClientIdentifier(request);

View File

@@ -1,8 +1,8 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { stripe } from '@/lib/stripe';
import { db } from '@/lib/db';
import { scoreUserLifecycle } from '@/lib/revops-server';
import { getSessionUserId } from '@/lib/session';
import { stripe } from '@/lib/stripe';
import { db } from '@/lib/db';
import { scoreUserLifecycle } from '@/lib/revops-server';
/**
* 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) {
try {
// Use cookie-based auth
const userId = cookies().get('userId')?.value;
const userId = getSessionUserId();
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
@@ -38,19 +38,19 @@ export async function POST(request: NextRequest) {
if (subscriptions.data.length === 0) {
// No active subscription - set to FREE
await db.user.update({
where: { id: user.id },
data: {
await db.user.update({
where: { id: user.id },
data: {
stripeSubscriptionId: null,
stripePriceId: null,
stripeCurrentPeriodEnd: null,
plan: 'FREE',
},
});
await scoreUserLifecycle(user.id, 'subscription_deleted');
return NextResponse.json({
},
});
await scoreUserLifecycle(user.id, 'subscription_deleted');
return NextResponse.json({
success: true,
plan: 'FREE',
message: 'No active subscription found, set to FREE plan',
@@ -90,20 +90,20 @@ export async function POST(request: NextRequest) {
});
// Update user in database
await db.user.update({
where: { id: user.id },
data: {
await db.user.update({
where: { id: user.id },
data: {
stripeSubscriptionId: subscription.id,
stripePriceId: priceId,
stripeCurrentPeriodEnd: currentPeriodEnd,
plan: plan as any,
},
});
await scoreUserLifecycle(user.id, 'subscription_synced');
return NextResponse.json({
success: true,
},
});
await scoreUserLifecycle(user.id, 'subscription_synced');
return NextResponse.json({
success: true,
plan,
subscriptionId: subscription.id,
currentPeriodEnd,

View File

@@ -1,26 +1,26 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
import { getSessionUserId } from '@/lib/session';
import { stripe } from '@/lib/stripe';
import { db } from '@/lib/db';
import { scoreUserLifecycle } from '@/lib/revops-server';
export async function POST(request: NextRequest) {
try {
// Use cookie-based auth instead of NextAuth
const userId = cookies().get('userId')?.value;
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const user = await db.user.findUnique({
where: { id: userId },
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
export async function POST(request: NextRequest) {
try {
// Use cookie-based auth instead of NextAuth
const userId = getSessionUserId();
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const user = await db.user.findUnique({
where: { id: userId },
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
if (!user.stripeCustomerId) {
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) {
const subscriptionId = typeof checkoutSession.subscription === 'string'
? checkoutSession.subscription
: checkoutSession.subscription.id;
// Retrieve the full subscription object
const subscription: any = await stripe.subscriptions.retrieve(subscriptionId);
// Determine plan from metadata or price ID
const plan = checkoutSession.metadata?.plan || 'PRO';
: checkoutSession.subscription.id;
// Retrieve the full subscription object
const subscription: any = await stripe.subscriptions.retrieve(subscriptionId);
// Determine plan from metadata or price ID
const plan = checkoutSession.metadata?.plan || 'PRO';
// Get current_period_end - Stripe returns it as a Unix timestamp
const periodEndTimestamp = subscription.current_period_end
|| subscription.currentPeriodEnd
|| subscription.billing_cycle_anchor;
const currentPeriodEnd = periodEndTimestamp
? new Date(periodEndTimestamp * 1000)
: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // Default to 30 days from now
const currentPeriodEnd = periodEndTimestamp
? new Date(periodEndTimestamp * 1000)
: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // Default to 30 days from now
// Update user in database
await db.user.update({
where: { id: user.id },
data: {
stripeSubscriptionId: subscription.id,
stripePriceId: subscription.items.data[0].price.id,
stripeCurrentPeriodEnd: currentPeriodEnd,
plan: plan as any,
stripeCurrentPeriodEnd: currentPeriodEnd,
plan: plan as any,
},
});
@@ -79,15 +79,15 @@ export async function POST(request: NextRequest) {
success: true,
plan,
subscriptionId: subscription.id,
});
}
return NextResponse.json({ error: 'Payment not completed' }, { status: 400 });
} catch (error) {
console.error('Error verifying session:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
});
}
return NextResponse.json({ error: 'Payment not completed' }, { status: 400 });
} catch (error) {
console.error('Error verifying session:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -1,6 +1,10 @@
import { NextRequest, NextResponse } from 'next/server';
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 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');
}
// Reject the wrong browser account before it can replace the valid QRMaster connection.
assertExpectedTiktokAccount(tokens.open_id);
const now = Date.now();
const accessTokenExpiresAt = new Date(now + Number(tokens.expires_in || 0) * 1000);
const refreshTokenExpiresAt = tokens.refresh_expires_in
@@ -87,6 +94,8 @@ export async function GET(request: NextRequest) {
} catch (err) {
console.error('TikTok callback error:', err);
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);
}
}

View File

@@ -1,5 +1,6 @@
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) {
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
// it without a configured key.
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 }
);
}
@@ -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({
access_token: tokens.accessToken,
open_id: tokens.openId,
connected: true,
accountMatch: true,
brand: TIKTOK_BRAND,
openIdFingerprint: createHash('sha256').update(tokens.openId).digest('hex').slice(0, 12),
scope: tokens.scope,
expires_at: tokens.accessTokenExpiresAt.toISOString(),
accessTokenExpiresAt: tokens.accessTokenExpiresAt.toISOString(),
refreshTokenExpiresAt: tokens.refreshTokenExpiresAt?.toISOString() || null,
requiresReconnect: refreshExpired,
uploadMode: 'draft',
});
} catch (err) {
console.error('TikTok token endpoint error:', err);
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 });
}
}

View File

@@ -4,6 +4,7 @@ import { authOptions } from '@/lib/auth';
import { uploadFileToR2 } from '@/lib/r2';
import { env } from '@/lib/env';
import { db } from '@/lib/db';
import { getSessionUserId } from '@/lib/session';
export async function POST(request: NextRequest) {
try {
@@ -13,7 +14,7 @@ export async function POST(request: NextRequest) {
// Fallback: Check for simple-login cookie if no NextAuth session
if (!userId) {
const cookieUserId = request.cookies.get('userId')?.value;
const cookieUserId = getSessionUserId();
if (cookieUserId) {
// Verify user exists
const user = await db.user.findUnique({

View File

@@ -1,12 +1,17 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { getSessionUserId } from '@/lib/session';
import { db } from '@/lib/db';
export const dynamic = 'force-dynamic';
export async function GET(request: NextRequest) {
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) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { getSessionUserId } from '@/lib/session';
import { db } from '@/lib/db';
import { stripe } from '@/lib/stripe';
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)
const clientId = userId || getClientIdentifier(request);

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { getSessionUserId } from '@/lib/session';
import { db } from '@/lib/db';
import bcrypt from 'bcryptjs';
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)
const clientId = userId || getClientIdentifier(request);

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { getSessionUserId } from '@/lib/session';
import { db } from '@/lib/db';
import { STRIPE_PLANS } from '@/lib/stripe';
@@ -8,7 +8,7 @@ export const dynamic = 'force-dynamic';
export async function GET(request: NextRequest) {
try {
// Use cookie-based auth instead of NextAuth
const userId = cookies().get('userId')?.value;
const userId = getSessionUserId();
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { getSessionUserId } from '@/lib/session';
import { db } from '@/lib/db';
import { csrfProtection } from '@/lib/csrf';
import { updateProfileSchema, validateRequest } from '@/lib/validationSchemas';
@@ -13,7 +13,7 @@ export async function PATCH(request: NextRequest) {
return NextResponse.json({ error: csrfCheck.error }, { status: 403 });
}
const userId = cookies().get('userId')?.value;
const userId = getSessionUserId();
// Rate Limiting (user-based)
const clientId = userId || getClientIdentifier(request);

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { getSessionUserId } from '@/lib/session';
import { db } from '@/lib/db';
// Force dynamic rendering (required for cookies)
@@ -11,7 +11,7 @@ export const dynamic = 'force-dynamic';
*/
export async function GET(request: NextRequest) {
try {
const userId = cookies().get('userId')?.value;
const userId = getSessionUserId();
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

View File

@@ -1,12 +1,12 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { getSessionUserId } from '@/lib/session';
import { db } from '@/lib/db';
export const dynamic = 'force-dynamic';
export async function GET(request: NextRequest) {
try {
const userId = cookies().get('userId')?.value;
const userId = getSessionUserId();
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}