Support authenticated GreenLens TikTok video uploads

This commit is contained in:
Timo
2026-07-13 11:58:12 +02:00
parent eb6f5f2200
commit 7c210524a9
3 changed files with 308 additions and 37 deletions

View File

@@ -70,6 +70,7 @@ const {
const { applyCatalogGrounding, normalizeText } = require('./lib/scanGrounding');
const { decideReviewOutcome, reviewAgreesWithPrimary } = require('./lib/scanReview');
const { ensureStorageBucketWithRetry, uploadImage, isStorageConfigured } = require('./lib/storage');
const { prepareTiktokPhotoUrls } = require('./lib/tiktok-assets');
const { isPurchaseEventType, notifyPurchase, notifyNewUser, notifyDownload } = require('./lib/discord');
const {
exchangeCodeForTokens: exchangeTiktokCode,
@@ -556,6 +557,29 @@ app.post('/api/revenuecat/webhook', express.json({ limit: '1mb' }), async (reque
}
});
// TikTok carousels can contain several base64 slides. Authenticate before
// accepting the larger body; all other JSON endpoints keep the tighter limit.
app.use(
'/api/tiktok/upload/photo',
(request, response, next) => {
if (!isAuthorizedAdminNavigation(request)) {
return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid or missing admin key.' });
}
return next();
},
express.json({ limit: '30mb' }),
);
app.use(
'/api/tiktok/upload/video',
(request, response, next) => {
if (!isAuthorizedAdminNavigation(request)) {
return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid or missing admin key.' });
}
return next();
},
// 24 MB decoded video limit; base64 expands the JSON request to roughly 32 MB.
express.json({ limit: '32mb' }),
);
app.use(express.json({ limit: '10mb' }));
app.get('/', (_request, response) => {
@@ -1121,8 +1145,8 @@ app.post('/v1/upload/image', async (request, response) => {
// ─── Install ping ──────────────────────────────────────────────────────────
// Fired once by the app on first launch (unauthenticated — there is no account
// yet). Deduped per install id so retries and reinstalls with a persisted id
// don't ping the downloads channel twice.
// yet). Deduped per install id so request retries don't ping the downloads
// channel twice; a reinstall generates a fresh id and counts as a new download.
app.post('/v1/app-install', async (request, response) => {
try {
const installId = String(request.body?.installId || '').trim();
@@ -1447,12 +1471,23 @@ app.post('/api/tiktok/upload/video', async (request, response) => {
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 { videoBuffer, videoBase64, mimeType = 'video/mp4' } = request.body || {};
let uploadBuffer = Buffer.isBuffer(videoBuffer) ? videoBuffer : null;
if (!uploadBuffer && typeof videoBase64 === 'string') {
const encoded = videoBase64.replace(/^data:video\/[^;]+;base64,/i, '').replace(/\s/g, '');
if (!encoded || !/^[A-Za-z0-9+/]+={0,2}$/.test(encoded)) {
return response.status(400).json({ code: 'BAD_REQUEST', message: 'videoBase64 must be valid base64 video data.' });
}
uploadBuffer = Buffer.from(encoded, 'base64');
}
if (!uploadBuffer) {
return response.status(400).json({ code: 'BAD_REQUEST', message: 'videoBase64 is required.' });
}
if (uploadBuffer.length > 24 * 1024 * 1024) {
return response.status(413).json({ code: 'PAYLOAD_TOO_LARGE', message: 'TikTok video must be 24 MB or smaller.' });
}
const videoSize = videoBuffer.length;
const videoSize = uploadBuffer.length;
const initBody = {
source_info: {
source: 'FILE_UPLOAD',
@@ -1473,7 +1508,7 @@ app.post('/api/tiktok/upload/video', async (request, response) => {
return response.status(502).json({ code: 'PROVIDER_ERROR', message: 'Missing upload_url or publish_id from TikTok.' });
}
await uploadBinaryToTiktok(uploadUrl, videoBuffer, mimeType || 'video/mp4');
await uploadBinaryToTiktok(uploadUrl, uploadBuffer, mimeType || 'video/mp4');
response.status(200).json({
publish_id: publishId,
@@ -1494,19 +1529,11 @@ app.post('/api/tiktok/upload/photo', async (request, response) => {
return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid or missing admin key.' });
}
let files = Array.isArray(request.body?.files)
const 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)
: [];
? request.body.photos
: [];
if (!files.length) {
return response.status(400).json({ code: 'BAD_REQUEST', message: 'files must be a non-empty array.' });
@@ -1516,25 +1543,13 @@ 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 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 or a public url.' });
}
const uploadRes = await uploadImage(buffer.toString('base64'), mimeType);
photoUrls.push(uploadRes.url);
}
// TikTok only accepts PULL_FROM_URL media from a domain verified for this
// app. Base64 inputs are normalized to JPEG and hosted under
// greenlenspro.com/storage before the TikTok request is created.
const photoUrls = await prepareTiktokPhotoUrls(files, {
uploadImage,
publicBaseUrl: process.env.MINIO_PUBLIC_URL || 'https://greenlenspro.com/storage',
});
const title = typeof request.body?.title === 'string' ? request.body.title.trim() : '';
const description = typeof request.body?.description === 'string' ? request.body.description.trim() : '';