diff --git a/server/index.js b/server/index.js index c6f65bd..c98f208 100644 --- a/server/index.js +++ b/server/index.js @@ -1223,7 +1223,7 @@ app.get('/api/tiktok/connect', (request, response) => { const authUrl = new URL('https://www.tiktok.com/v2/auth/authorize/'); authUrl.searchParams.set('client_key', clientKey); - authUrl.searchParams.set('scope', 'user.info.basic,user.info.stats,video.list,video.upload,video.publish'); + authUrl.searchParams.set('scope', 'user.info.basic,video.publish,video.upload'); authUrl.searchParams.set('response_type', 'code'); authUrl.searchParams.set('redirect_uri', redirectUri); authUrl.searchParams.set('state', oauthState); @@ -1468,7 +1468,20 @@ app.post('/api/tiktok/upload/photo', async (request, response) => { return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid or missing admin key.' }); } - const files = Array.isArray(request.body?.files) ? request.body.files : []; + let files = Array.isArray(request.body?.files) + ? request.body.files + : Array.isArray(request.body?.photos) + ? request.body.photos.map((photo) => { + if (Buffer.isBuffer(photo?.buffer)) { + return { buffer: photo.buffer, contentType: photo.mimeType || photo.contentType || 'image/jpeg' }; + } + if (typeof photo?.url === 'string' && /^https?:\/\//i.test(photo.url)) { + return { buffer: Buffer.from(photo.url), contentType: photo.mimeType || photo.contentType || 'image/jpeg', url: photo.url }; + } + return null; + }).filter(Boolean) + : []; + if (!files.length) { return response.status(400).json({ code: 'BAD_REQUEST', message: 'files must be a non-empty array.' }); } @@ -1477,28 +1490,43 @@ app.post('/api/tiktok/upload/photo', async (request, response) => { return response.status(400).json({ code: 'BAD_REQUEST', message: 'TikTok allows up to 35 photos per carousel post.' }); } - const fileInfos = []; + const photoUrls = []; for (const file of files) { + // Photos given as public URLs are passed to TikTok directly; buffers + // are first uploaded to MinIO so TikTok can pull them by URL. + if (typeof file.url === 'string' && /^https?:\/\//i.test(file.url)) { + photoUrls.push(file.url); + continue; + } + 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.' }); + return response.status(400).json({ code: 'BAD_REQUEST', message: 'Each file must include a binary buffer or a public url.' }); } const uploadRes = await uploadImage(buffer.toString('base64'), mimeType); - fileInfos.push({ - url: uploadRes.url, - mime_type: mimeType, - }); + photoUrls.push(uploadRes.url); } + const title = typeof request.body?.title === 'string' ? request.body.title.trim() : ''; + const description = typeof request.body?.description === 'string' ? request.body.description.trim() : ''; + 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', + // MEDIA_UPLOAD = draft in the creator's TikTok inbox (posting policy is + // upload/draft only) and only needs the video.upload scope. + post_mode: 'MEDIA_UPLOAD', + post_info: { + ...(title ? { title } : {}), + ...(description ? { description } : {}), + }, + source_info: { + source: 'PULL_FROM_URL', + photo_cover_index: 0, + photo_images: photoUrls, + }, }; const initResult = await tiktokApi('https://open.tiktokapis.com/v2/post/publish/content/init/', { diff --git a/server/package-lock.json b/server/package-lock.json index 2ed103d..988c6d5 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1248,6 +1248,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0",