TikTok api
This commit is contained in:
@@ -48,3 +48,10 @@ NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
|
||||
# Analytics (Optional - PostHog)
|
||||
NEXT_PUBLIC_POSTHOG_KEY=
|
||||
NEXT_PUBLIC_POSTHOG_HOST=https://app.posthog.com
|
||||
|
||||
# TikTok Content Posting API (Hermes Agent automated posting)
|
||||
TIKTOK_CLIENT_KEY=
|
||||
TIKTOK_CLIENT_SECRET=
|
||||
TIKTOK_REDIRECT_URI=https://qrmaster.net/api/tiktok/callback
|
||||
# Optional: protects /api/tiktok/connect from being triggered by strangers
|
||||
TIKTOK_ADMIN_KEY=
|
||||
|
||||
@@ -201,6 +201,19 @@ model Integration {
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model TiktokIntegration {
|
||||
id String @id @default(cuid())
|
||||
accountKey String @unique
|
||||
openId String
|
||||
accessToken String
|
||||
refreshToken String
|
||||
scope String?
|
||||
accessTokenExpiresAt DateTime
|
||||
refreshTokenExpiresAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model UserLifecycleLog {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
|
||||
92
src/app/(main)/api/tiktok/callback/route.ts
Normal file
92
src/app/(main)/api/tiktok/callback/route.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { TIKTOK_ACCOUNT_KEY, TIKTOK_OAUTH_STATE_COOKIE_NAME } from '@/lib/tiktok';
|
||||
|
||||
const textResponse = (body: string, status: number) => {
|
||||
const response = new NextResponse(body, {
|
||||
status,
|
||||
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
||||
});
|
||||
response.cookies.delete(TIKTOK_OAUTH_STATE_COOKIE_NAME);
|
||||
return response;
|
||||
};
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const code = searchParams.get('code');
|
||||
const state = searchParams.get('state');
|
||||
const error = searchParams.get('error');
|
||||
const errorDescription = searchParams.get('error_description');
|
||||
const savedState = request.cookies.get(TIKTOK_OAUTH_STATE_COOKIE_NAME)?.value;
|
||||
|
||||
if (error) {
|
||||
return textResponse(`TikTok authorization failed: ${errorDescription || error}`, 400);
|
||||
}
|
||||
if (!code) {
|
||||
return textResponse('Missing authorization code.', 400);
|
||||
}
|
||||
if (!state || !savedState || state !== savedState) {
|
||||
return textResponse('Invalid OAuth state. Start over at /api/tiktok/connect.', 403);
|
||||
}
|
||||
|
||||
const clientKey = process.env.TIKTOK_CLIENT_KEY;
|
||||
const clientSecret = process.env.TIKTOK_CLIENT_SECRET;
|
||||
if (!clientKey || !clientSecret) {
|
||||
return textResponse('TikTok client credentials are not configured.', 500);
|
||||
}
|
||||
|
||||
const redirectUri =
|
||||
process.env.TIKTOK_REDIRECT_URI || `${process.env.NEXT_PUBLIC_APP_URL}/api/tiktok/callback`;
|
||||
|
||||
try {
|
||||
const tokenResponse = await fetch('https://open.tiktokapis.com/v2/oauth/token/', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
client_key: clientKey,
|
||||
client_secret: clientSecret,
|
||||
code,
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: redirectUri,
|
||||
}),
|
||||
});
|
||||
|
||||
const tokens = await tokenResponse.json();
|
||||
if (!tokenResponse.ok || tokens.error) {
|
||||
throw new Error(tokens.error_description || tokens.error || 'TikTok token exchange failed');
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const accessTokenExpiresAt = new Date(now + Number(tokens.expires_in || 0) * 1000);
|
||||
const refreshTokenExpiresAt = tokens.refresh_expires_in
|
||||
? new Date(now + Number(tokens.refresh_expires_in) * 1000)
|
||||
: null;
|
||||
|
||||
await db.tiktokIntegration.upsert({
|
||||
where: { accountKey: TIKTOK_ACCOUNT_KEY },
|
||||
create: {
|
||||
accountKey: TIKTOK_ACCOUNT_KEY,
|
||||
openId: tokens.open_id,
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
scope: tokens.scope || null,
|
||||
accessTokenExpiresAt,
|
||||
refreshTokenExpiresAt,
|
||||
},
|
||||
update: {
|
||||
openId: tokens.open_id,
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
scope: tokens.scope || null,
|
||||
accessTokenExpiresAt,
|
||||
refreshTokenExpiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
return textResponse('TikTok account connected. You can close this tab.', 200);
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
41
src/app/(main)/api/tiktok/connect/route.ts
Normal file
41
src/app/(main)/api/tiktok/connect/route.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { TIKTOK_OAUTH_STATE_COOKIE_NAME } from '@/lib/tiktok';
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const adminKey = process.env.TIKTOK_ADMIN_KEY;
|
||||
if (adminKey) {
|
||||
const provided = request.nextUrl.searchParams.get('key');
|
||||
if (provided !== adminKey) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
const clientKey = process.env.TIKTOK_CLIENT_KEY;
|
||||
if (!clientKey) {
|
||||
return NextResponse.json({ error: 'TIKTOK_CLIENT_KEY not configured' }, { status: 500 });
|
||||
}
|
||||
|
||||
const redirectUri =
|
||||
process.env.TIKTOK_REDIRECT_URI || `${process.env.NEXT_PUBLIC_APP_URL}/api/tiktok/callback`;
|
||||
|
||||
const oauthState = crypto.randomUUID();
|
||||
|
||||
const authUrl = new URL('https://www.tiktok.com/v2/auth/authorize/');
|
||||
authUrl.searchParams.set('client_key', clientKey);
|
||||
authUrl.searchParams.set('scope', 'user.info.basic,video.publish');
|
||||
authUrl.searchParams.set('response_type', 'code');
|
||||
authUrl.searchParams.set('redirect_uri', redirectUri);
|
||||
authUrl.searchParams.set('state', oauthState);
|
||||
|
||||
const response = NextResponse.redirect(authUrl);
|
||||
response.cookies.set(TIKTOK_OAUTH_STATE_COOKIE_NAME, oauthState, {
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: 60 * 10,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
41
src/app/(main)/api/tiktok/token/route.ts
Normal file
41
src/app/(main)/api/tiktok/token/route.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getValidTiktokTokens } from '@/lib/tiktok';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const adminKey = process.env.TIKTOK_ADMIN_KEY;
|
||||
if (!adminKey) {
|
||||
// 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' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const provided =
|
||||
request.headers.get('x-admin-key') || request.nextUrl.searchParams.get('key');
|
||||
if (provided !== adminKey) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const tokens = await getValidTiktokTokens();
|
||||
if (!tokens) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No TikTok account connected. Visit /api/tiktok/connect first.' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
access_token: tokens.accessToken,
|
||||
open_id: tokens.openId,
|
||||
scope: tokens.scope,
|
||||
expires_at: tokens.accessTokenExpiresAt.toISOString(),
|
||||
});
|
||||
} 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 });
|
||||
}
|
||||
}
|
||||
59
src/lib/tiktok.ts
Normal file
59
src/lib/tiktok.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export const TIKTOK_OAUTH_STATE_COOKIE_NAME = 'tiktok_oauth_state';
|
||||
export const TIKTOK_ACCOUNT_KEY = 'hermes-agent';
|
||||
|
||||
// Refresh when the access token expires within this window, so Hermes never
|
||||
// receives a token that dies mid-upload.
|
||||
const REFRESH_BUFFER_MS = 5 * 60 * 1000;
|
||||
|
||||
export async function getValidTiktokTokens() {
|
||||
const integration = await db.tiktokIntegration.findUnique({
|
||||
where: { accountKey: TIKTOK_ACCOUNT_KEY },
|
||||
});
|
||||
|
||||
if (!integration) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (integration.accessTokenExpiresAt.getTime() - Date.now() > REFRESH_BUFFER_MS) {
|
||||
return integration;
|
||||
}
|
||||
|
||||
const clientKey = process.env.TIKTOK_CLIENT_KEY;
|
||||
const clientSecret = process.env.TIKTOK_CLIENT_SECRET;
|
||||
if (!clientKey || !clientSecret) {
|
||||
throw new Error('TikTok client credentials are not configured.');
|
||||
}
|
||||
|
||||
const response = await fetch('https://open.tiktokapis.com/v2/oauth/token/', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
client_key: clientKey,
|
||||
client_secret: clientSecret,
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: integration.refreshToken,
|
||||
}),
|
||||
});
|
||||
|
||||
const tokens = await response.json();
|
||||
if (!response.ok || tokens.error) {
|
||||
throw new Error(tokens.error_description || tokens.error || 'TikTok token refresh failed');
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
return db.tiktokIntegration.update({
|
||||
where: { accountKey: TIKTOK_ACCOUNT_KEY },
|
||||
data: {
|
||||
openId: tokens.open_id,
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
scope: tokens.scope || null,
|
||||
accessTokenExpiresAt: new Date(now + Number(tokens.expires_in || 0) * 1000),
|
||||
refreshTokenExpiresAt: tokens.refresh_expires_in
|
||||
? new Date(now + Number(tokens.refresh_expires_in) * 1000)
|
||||
: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user