Support authenticated GreenLens TikTok video uploads
This commit is contained in:
@@ -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() : '';
|
||||
|
||||
Reference in New Issue
Block a user