TikTok api
This commit is contained in:
@@ -48,3 +48,10 @@ NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
|
|||||||
# Analytics (Optional - PostHog)
|
# Analytics (Optional - PostHog)
|
||||||
NEXT_PUBLIC_POSTHOG_KEY=
|
NEXT_PUBLIC_POSTHOG_KEY=
|
||||||
NEXT_PUBLIC_POSTHOG_HOST=https://app.posthog.com
|
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)
|
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 {
|
model UserLifecycleLog {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String
|
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