Bild Carousel
This commit is contained in:
@@ -24,7 +24,7 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
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.upload');
|
||||
authUrl.searchParams.set('scope', 'user.info.basic,video.upload,video.publish');
|
||||
authUrl.searchParams.set('response_type', 'code');
|
||||
authUrl.searchParams.set('redirect_uri', redirectUri);
|
||||
authUrl.searchParams.set('state', oauthState);
|
||||
|
||||
145
src/app/(main)/api/tiktok/upload/route.ts
Normal file
145
src/app/(main)/api/tiktok/upload/route.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getValidTiktokTokens, getLiveTiktokAccessToken, tiktokApi, uploadBinaryToTiktok } from '@/lib/tiktok';
|
||||
|
||||
const isAdminRequest = (request: NextRequest) => {
|
||||
const adminKey = process.env.TIKTOK_ADMIN_KEY;
|
||||
if (!adminKey) return false;
|
||||
const provided =
|
||||
request.headers.get('x-admin-key') || request.nextUrl.searchParams.get('key');
|
||||
return provided === adminKey;
|
||||
};
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
if (!isAdminRequest(request)) {
|
||||
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 }
|
||||
);
|
||||
}
|
||||
|
||||
const contentType = request.headers.get('content-type') || '';
|
||||
let publishId: string | undefined;
|
||||
let status = 'INITIATED';
|
||||
|
||||
if (contentType.includes('application/json')) {
|
||||
const json = await request.json().catch(() => ({}));
|
||||
|
||||
if (typeof json?.videoBufferBase64 === 'string') {
|
||||
const videoBuffer = Buffer.from(json.videoBufferBase64, 'base64');
|
||||
const videoSize = videoBuffer.length;
|
||||
const initBody = {
|
||||
source_info: {
|
||||
source: 'FILE_UPLOAD',
|
||||
video_size: videoSize,
|
||||
chunk_size: videoSize,
|
||||
total_chunk_count: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const initResult = await tiktokApi(
|
||||
'https://open.tiktokapis.com/v2/post/publish/inbox/video/init/',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(initBody),
|
||||
}
|
||||
);
|
||||
|
||||
const uploadUrl = initResult?.data?.upload_url as string | undefined;
|
||||
publishId = initResult?.data?.publish_id as string | undefined;
|
||||
|
||||
if (!uploadUrl || !publishId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing upload_url or publish_id from TikTok.' },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
await uploadBinaryToTiktok(uploadUrl, videoBuffer, 'video/mp4');
|
||||
} else if (Array.isArray(json?.photos)) {
|
||||
const photos = json.photos as Array<{ url?: string } | string>;
|
||||
if (!photos.length) {
|
||||
return NextResponse.json(
|
||||
{ error: 'photos must be a non-empty array for photo uploads.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (photos.length > 35) {
|
||||
return NextResponse.json(
|
||||
{ error: 'TikTok allows up to 35 photos per carousel post.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const photoUrls = photos
|
||||
.map((photo) => {
|
||||
if (typeof photo === 'string') return photo.trim();
|
||||
return String(photo?.url || '').trim();
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
if (!photoUrls.length) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Each photo entry must include a public url.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const initBody = {
|
||||
media_type: 'PHOTO',
|
||||
photo_cover_index: 0,
|
||||
file_paths: photoUrls,
|
||||
file_extensions: photoUrls.map((url) => {
|
||||
const fileName = String(new URL(url).pathname).split('/').pop() || 'photo.jpg';
|
||||
const extension = fileName.split('.').pop() || 'jpg';
|
||||
return extension.startsWith('.') ? extension.slice(1) : extension;
|
||||
}),
|
||||
post_mode: 'DIRECT_POST',
|
||||
};
|
||||
|
||||
const initResult = await tiktokApi(
|
||||
'https://open.tiktokapis.com/v2/post/publish/content/init/',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(initBody),
|
||||
}
|
||||
);
|
||||
|
||||
publishId = initResult?.data?.publish_id as string | undefined;
|
||||
status = (initResult?.data?.status as string) || 'INITIATED';
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unsupported JSON payload. Use videoBufferBase64 or photos.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unsupported content type. Use application/json for TikTok uploads.' },
|
||||
{ status: 415 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!publishId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing publish_id from TikTok.' },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
publish_id: publishId,
|
||||
status,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
const status = err instanceof Error && 'status' in err ? (err as { status?: number }).status : 502;
|
||||
return NextResponse.json({ error: message }, { status: status || 502 });
|
||||
}
|
||||
}
|
||||
22
src/app/(main)/api/tiktok/upload/status/route.ts
Normal file
22
src/app/(main)/api/tiktok/upload/status/route.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { tiktokApi } from '@/lib/tiktok';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const publishId = String(request.nextUrl.searchParams.get('publish_id') || '').trim();
|
||||
if (!publishId) {
|
||||
return NextResponse.json({ error: 'publish_id is required.' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await tiktokApi('https://open.tiktokapis.com/v2/post/publish/status/fetch/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ publish_id: publishId }),
|
||||
});
|
||||
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
const status = err instanceof Error && 'status' in err ? (err as { status?: number }).status : 502;
|
||||
return NextResponse.json({ error: message }, { status: status || 502 });
|
||||
}
|
||||
}
|
||||
@@ -52,8 +52,88 @@ export async function getValidTiktokTokens() {
|
||||
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)
|
||||
? new Date(now + Number(tokens.refresh_expires_in || 0) * 1000)
|
||||
: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const REFRESH_BUFFER_MS = 5 * 60 * 1000;
|
||||
|
||||
export async function getLiveTiktokAccessToken() {
|
||||
const integration = await db.tiktokIntegration.findUnique({
|
||||
where: { accountKey: TIKTOK_ACCOUNT_KEY },
|
||||
});
|
||||
if (!integration) {
|
||||
const error = new Error('No TikTok account connected.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
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) {
|
||||
const error = new Error('TikTok client credentials are not configured.');
|
||||
error.status = 500;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return getValidTiktokTokens();
|
||||
}
|
||||
|
||||
export async function tiktokApi(url: string, options: RequestInit = {}) {
|
||||
const tokens = await getLiveTiktokAccessToken();
|
||||
const accessToken = tokens.accessToken;
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
...options,
|
||||
headers: {
|
||||
...(options.headers as Record<string, string> | undefined),
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json; charset=UTF-8',
|
||||
},
|
||||
};
|
||||
|
||||
const res = await fetch(url, fetchOptions);
|
||||
const text = await res.text();
|
||||
let data: Record<string, unknown>;
|
||||
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') {
|
||||
const message = (data?.error as Record<string, string> | undefined)?.message || (typeof data?.raw === 'string' ? data.raw : '') || `TikTok API error: ${res.status}`;
|
||||
const error = new Error(message);
|
||||
error.status = res.status;
|
||||
error.body = data;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function uploadBinaryToTiktok(uploadUrl: string, buffer: Buffer, mimeType = 'video/mp4') {
|
||||
const res = await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': mimeType,
|
||||
'Content-Length': String(buffer.length),
|
||||
},
|
||||
body: buffer,
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
if (!res.ok && res.status !== 201) {
|
||||
const error = new Error(`TikTok binary upload failed: ${res.status} ${text}`);
|
||||
error.status = res.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return { status: res.status, body: text };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user