Bild Carousel

This commit is contained in:
2026-07-09 16:45:02 +02:00
parent 9be9b2987b
commit 1a7ebdc3b6
15 changed files with 347 additions and 0 deletions

View File

@@ -1332,6 +1332,211 @@ app.get('/api/tiktok/status', async (request, response) => {
});
});
// ─── TikTok Upload Helpers ─────────────────────────────────────────────────
const getLiveTiktokAccessToken = async () => {
const tokens = await getTiktokTokens(db);
if (!tokens) {
const error = new Error('No TikTok account connected.');
error.status = 404;
throw error;
}
const expiresAt = new Date(tokens.access_token_expires_at).getTime();
if (expiresAt - Date.now() < TIKTOK_REFRESH_BUFFER_MS) {
const { clientKey, clientSecret } = getTiktokEnv();
if (!clientKey || !clientSecret) {
const error = new Error('TikTok client credentials are not configured.');
error.status = 500;
throw error;
}
return refreshTiktokTokens(db, { clientKey, clientSecret });
}
return tokens;
};
const tiktokApi = async (url, options = {}) => {
const tokens = await getLiveTiktokAccessToken();
const accessToken = tokens.access_token;
const fetchOptions = {
...options,
headers: {
...(options.headers || {}),
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json; charset=UTF-8',
},
};
const res = await fetch(url, fetchOptions);
const text = await res.text();
let data;
try {
data = JSON.parse(text);
} catch {
data = { raw: text };
}
if (!res.ok || data?.error?.code !== 'ok') {
const message = data?.error?.message || data?.raw || `TikTok API error: ${res.status}`;
const error = new Error(message);
error.status = res.status;
error.body = data;
throw error;
}
return data;
};
const uploadBinaryToTiktok = async (uploadUrl, 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 };
};
// ─── TikTok Video Upload ────────────────────────────────────────────────────
app.post('/api/tiktok/upload/video', async (request, response) => {
try {
if (!isAuthorizedAdminNavigation(request)) {
return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid or missing admin key.' });
}
const { videoBuffer, mimeType = 'video/mp4' } = request.body || {};
if (!Buffer.isBuffer(videoBuffer)) {
return response.status(400).json({ code: 'BAD_REQUEST', message: 'videoBuffer must be a binary buffer.' });
}
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: Buffer.from(JSON.stringify(initBody)),
});
const uploadUrl = initResult?.data?.upload_url;
const publishId = initResult?.data?.publish_id;
if (!uploadUrl || !publishId) {
return response.status(502).json({ code: 'PROVIDER_ERROR', message: 'Missing upload_url or publish_id from TikTok.' });
}
await uploadBinaryToTiktok(uploadUrl, videoBuffer, mimeType || 'video/mp4');
response.status(200).json({
publish_id: publishId,
upload_url: uploadUrl,
status: 'INITIATED',
});
} catch (error) {
const payload = toApiErrorPayload(error);
response.status(payload.status).json(payload.body);
}
});
// ─── TikTok Photo/Carousel Upload ───────────────────────────────────────────
app.post('/api/tiktok/upload/photo', async (request, response) => {
try {
if (!isAuthorizedAdminNavigation(request)) {
return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid or missing admin key.' });
}
const files = Array.isArray(request.body?.files) ? request.body.files : [];
if (!files.length) {
return response.status(400).json({ code: 'BAD_REQUEST', message: 'files must be a non-empty array.' });
}
if (files.length > 35) {
return response.status(400).json({ code: 'BAD_REQUEST', message: 'TikTok allows up to 35 photos per carousel post.' });
}
const fileInfos = [];
for (const file of files) {
const buffer = file.buffer || file.data;
const mimeType = file.contentType || file.mimeType || 'image/jpeg';
if (!Buffer.isBuffer(buffer)) {
return response.status(400).json({ code: 'BAD_REQUEST', message: 'Each file must include a binary buffer.' });
}
const uploadRes = await uploadImage(buffer.toString('base64'), mimeType);
fileInfos.push({
url: uploadRes.url,
mime_type: mimeType,
});
}
const initBody = {
media_type: 'PHOTO',
photo_cover_index: 0,
file_paths: fileInfos.map((info) => info.url),
file_extensions: fileInfos.map((info) => (info.mime_type || 'image/jpeg').split('/').pop()),
post_mode: 'DIRECT_POST',
};
const initResult = await tiktokApi('https://open.tiktokapis.com/v2/post/publish/content/init/', {
method: 'POST',
body: Buffer.from(JSON.stringify(initBody)),
});
response.status(200).json({
publish_id: initResult?.data?.publish_id,
status: initResult?.data?.status || 'INITIATED',
});
} catch (error) {
const payload = toApiErrorPayload(error);
response.status(payload.status).json(payload.body);
}
});
// ─── TikTok Upload Status ───────────────────────────────────────────────────
app.get('/api/tiktok/upload/status', async (request, response) => {
try {
if (!isAuthorizedAdminNavigation(request)) {
return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid or missing admin key.' });
}
const publishId = String(request.query.publish_id || '').trim();
if (!publishId) {
return response.status(400).json({ code: 'BAD_REQUEST', message: 'publish_id is required.' });
}
const result = await tiktokApi('https://open.tiktokapis.com/v2/post/publish/status/fetch/', {
method: 'POST',
body: Buffer.from(JSON.stringify({ publish_id: publishId })),
});
response.status(200).json(result);
} catch (error) {
const payload = toApiErrorPayload(error);
response.status(payload.status).json(payload.body);
}
});
// ─── Startup ───────────────────────────────────────────────────────────────
app.delete('/auth/account', async (request, response) => {