Bild Carousel
This commit is contained in:
205
server/index.js
205
server/index.js
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user