TikTok api

This commit is contained in:
Timo Knuth
2026-07-02 13:06:50 +02:00
parent b0b70640ab
commit 0b9c8d2a8f
12 changed files with 1965 additions and 1712 deletions

View File

@@ -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=

View File

@@ -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

View 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);
}
}

View 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;
}

View 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
View 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,
},
});
}