From 7c210524a938996f04138be8d1cd25a4dc13aa9e Mon Sep 17 00:00:00 2001 From: Timo Date: Mon, 13 Jul 2026 11:58:12 +0200 Subject: [PATCH] Support authenticated GreenLens TikTok video uploads --- server/index.js | 89 ++++++++++------- server/lib/tiktok-assets.js | 159 ++++++++++++++++++++++++++++++ server/test/tiktok-assets.test.js | 97 ++++++++++++++++++ 3 files changed, 308 insertions(+), 37 deletions(-) create mode 100644 server/lib/tiktok-assets.js create mode 100644 server/test/tiktok-assets.test.js diff --git a/server/index.js b/server/index.js index 763b7f8..f1fa1e9 100644 --- a/server/index.js +++ b/server/index.js @@ -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() : ''; diff --git a/server/lib/tiktok-assets.js b/server/lib/tiktok-assets.js new file mode 100644 index 0000000..c910d2a --- /dev/null +++ b/server/lib/tiktok-assets.js @@ -0,0 +1,159 @@ +const sharp = require('sharp'); + +const MAX_TIKTOK_PHOTO_BYTES = 20 * 1024 * 1024; + +const createInputError = (message) => { + const error = new Error(message); + error.status = 400; + error.code = 'BAD_REQUEST'; + return error; +}; + +const normalizePublicBaseUrl = (value) => String(value || '').trim().replace(/\/$/, ''); + +const isAllowedR2MediaUrl = (value) => { + try { + const candidate = new URL(value); + const configuredHosts = String(process.env.TIKTOK_MEDIA_SOURCE_HOSTS || '') + .split(',') + .map((host) => host.trim().toLowerCase()) + .filter(Boolean); + const hostname = candidate.hostname.toLowerCase(); + return candidate.protocol === 'https:' + && !candidate.username + && !candidate.password + && !candidate.port + && (hostname.endsWith('.r2.dev') || configuredHosts.includes(hostname)); + } catch { + return false; + } +}; + +const isOwnedTiktokMediaUrl = (value, publicBaseUrl) => { + try { + const candidate = new URL(value); + const base = new URL(normalizePublicBaseUrl(publicBaseUrl)); + const basePath = base.pathname.replace(/\/$/, ''); + return candidate.protocol === 'https:' + && !candidate.username + && !candidate.password + && candidate.origin === base.origin + && (candidate.pathname === basePath || candidate.pathname.startsWith(`${basePath}/`)); + } catch { + return false; + } +}; + +const readBase64Photo = (photo) => { + if (Buffer.isBuffer(photo)) return photo; + if (Buffer.isBuffer(photo?.buffer)) return photo.buffer; + + let encoded = photo?.imageBase64 || photo?.dataBase64 || photo?.base64; + if (typeof encoded !== 'string' || !encoded.trim()) return null; + encoded = encoded.trim(); + + const dataUrlMatch = encoded.match(/^data:image\/[a-z0-9.+-]+;base64,(.+)$/is); + if (dataUrlMatch) encoded = dataUrlMatch[1]; + + if (!/^[a-z0-9+/\s]+={0,2}$/i.test(encoded)) { + throw createInputError('Each photo must contain valid base64 image data.'); + } + + const buffer = Buffer.from(encoded.replace(/\s/g, ''), 'base64'); + if (!buffer.length) throw createInputError('Each photo must contain image data.'); + return buffer; +}; + +const normalizeTiktokPhoto = async (buffer) => { + if (buffer.length > MAX_TIKTOK_PHOTO_BYTES) { + throw createInputError('Each TikTok photo must be 20 MB or smaller.'); + } + + try { + return await sharp(buffer) + .rotate() + .resize({ width: 1080, height: 1080, fit: 'inside', withoutEnlargement: true }) + .jpeg({ quality: 92, mozjpeg: true }) + .toBuffer(); + } catch { + throw createInputError('Each photo must be a valid image.'); + } +}; + +const downloadRemotePhoto = async (sourceUrl) => { + if (!isAllowedR2MediaUrl(sourceUrl)) { + throw createInputError('External photo URL host is not approved for GreenLens imports.'); + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 15_000); + try { + const response = await fetch(sourceUrl, { redirect: 'error', signal: controller.signal }); + if (!response.ok) throw createInputError('GreenLens could not download an external photo.'); + + const contentType = String(response.headers.get('content-type') || '').split(';')[0].trim().toLowerCase(); + if (!['image/jpeg', 'image/png', 'image/webp'].includes(contentType)) { + throw createInputError('External photo must be JPEG, PNG, or WebP.'); + } + + const declaredSize = Number(response.headers.get('content-length') || 0); + if (declaredSize > MAX_TIKTOK_PHOTO_BYTES) { + throw createInputError('Each TikTok photo must be 20 MB or smaller.'); + } + + const chunks = []; + let totalBytes = 0; + for await (const chunk of response.body) { + totalBytes += chunk.length; + if (totalBytes > MAX_TIKTOK_PHOTO_BYTES) { + controller.abort(); + throw createInputError('Each TikTok photo must be 20 MB or smaller.'); + } + chunks.push(Buffer.from(chunk)); + } + return Buffer.concat(chunks, totalBytes); + } catch (error) { + if (error?.status) throw error; + throw createInputError('GreenLens could not securely download the external photo.'); + } finally { + clearTimeout(timeout); + } +}; + +const prepareTiktokPhotoUrls = async (photos, options) => { + const publicBaseUrl = normalizePublicBaseUrl(options.publicBaseUrl); + if (!publicBaseUrl) throw new Error('MINIO_PUBLIC_URL is not configured.'); + + const urls = []; + for (const photo of photos) { + const sourceUrl = typeof photo === 'string' ? photo.trim() : String(photo?.url || '').trim(); + if (sourceUrl && isOwnedTiktokMediaUrl(sourceUrl, publicBaseUrl)) { + urls.push(sourceUrl); + continue; + } + + let inputBuffer = readBase64Photo(photo); + if (!inputBuffer && sourceUrl) { + inputBuffer = await (options.downloadPhoto || downloadRemotePhoto)(sourceUrl); + } + if (!inputBuffer) { + throw createInputError('Each photo must include a GreenLens storage URL or imageBase64.'); + } + + const jpegBuffer = await (options.normalizePhoto || normalizeTiktokPhoto)(inputBuffer); + const uploaded = await options.uploadImage(jpegBuffer.toString('base64'), 'image/jpeg'); + if (!isOwnedTiktokMediaUrl(uploaded?.url, publicBaseUrl)) { + throw new Error('GreenLens image storage returned an unverified public URL.'); + } + urls.push(uploaded.url); + } + + return urls; +}; + +module.exports = { + MAX_TIKTOK_PHOTO_BYTES, + isAllowedR2MediaUrl, + isOwnedTiktokMediaUrl, + prepareTiktokPhotoUrls, +}; diff --git a/server/test/tiktok-assets.test.js b/server/test/tiktok-assets.test.js new file mode 100644 index 0000000..fe373b3 --- /dev/null +++ b/server/test/tiktok-assets.test.js @@ -0,0 +1,97 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { isAllowedR2MediaUrl, isOwnedTiktokMediaUrl, prepareTiktokPhotoUrls } = require('../lib/tiktok-assets'); + +const publicBaseUrl = 'https://greenlenspro.com/storage'; + +test('accepts only HTTPS media URLs under the configured GreenLens storage path', () => { + assert.equal(isOwnedTiktokMediaUrl('https://greenlenspro.com/storage/plant-images/slide.jpg', publicBaseUrl), true); + assert.equal(isOwnedTiktokMediaUrl('https://greenlenspro.com.evil.test/storage/slide.jpg', publicBaseUrl), false); + assert.equal(isOwnedTiktokMediaUrl('http://greenlenspro.com/storage/slide.jpg', publicBaseUrl), false); + assert.equal(isOwnedTiktokMediaUrl('https://greenlenspro.com/other/slide.jpg', publicBaseUrl), false); +}); + +test('keeps existing GreenLens storage URLs without uploading them again', async () => { + let uploads = 0; + const url = 'https://greenlenspro.com/storage/plant-images/slide.jpg'; + const result = await prepareTiktokPhotoUrls([url], { + publicBaseUrl, + uploadImage: async () => { uploads += 1; }, + }); + + assert.deepEqual(result, [url]); + assert.equal(uploads, 0); +}); + +test('hosts base64 photos on the verified GreenLens domain', async () => { + const uploaded = []; + const result = await prepareTiktokPhotoUrls([{ imageBase64: Buffer.from('source').toString('base64') }], { + publicBaseUrl, + normalizePhoto: async (buffer) => { + assert.equal(buffer.toString(), 'source'); + return Buffer.from('jpeg'); + }, + uploadImage: async (base64, contentType) => { + uploaded.push({ base64, contentType }); + return { url: 'https://greenlenspro.com/storage/plant-images/normalized.jpg' }; + }, + }); + + assert.deepEqual(result, ['https://greenlenspro.com/storage/plant-images/normalized.jpg']); + assert.deepEqual(uploaded, [{ base64: Buffer.from('jpeg').toString('base64'), contentType: 'image/jpeg' }]); +}); + +test('allows only HTTPS R2 source hosts without credentials or custom ports', () => { + assert.equal(isAllowedR2MediaUrl('https://pub-example.r2.dev/slide.jpg'), true); + assert.equal(isAllowedR2MediaUrl('http://pub-example.r2.dev/slide.jpg'), false); + assert.equal(isAllowedR2MediaUrl('https://user:pass@pub-example.r2.dev/slide.jpg'), false); + assert.equal(isAllowedR2MediaUrl('https://pub-example.r2.dev:8443/slide.jpg'), false); + assert.equal(isAllowedR2MediaUrl('https://r2.dev.evil.test/slide.jpg'), false); +}); + +test('rehosts an external R2 photo instead of leaking its URL into the TikTok payload', async () => { + const sourceUrl = 'https://pub-example.r2.dev/slide.png'; + const result = await prepareTiktokPhotoUrls([sourceUrl], { + publicBaseUrl, + downloadPhoto: async (url) => { + assert.equal(url, sourceUrl); + return Buffer.from('png'); + }, + normalizePhoto: async () => Buffer.from('jpeg'), + uploadImage: async () => ({ url: 'https://greenlenspro.com/storage/plant-images/slide.jpg' }), + }); + + assert.deepEqual(result, ['https://greenlenspro.com/storage/plant-images/slide.jpg']); + assert.equal(result.includes(sourceUrl), false); +}); + +test('prefers supplied base64 over an external legacy URL', async () => { + let downloaded = false; + const result = await prepareTiktokPhotoUrls([{ + url: 'https://pub-example.r2.dev/slide.png', + imageBase64: Buffer.from('local').toString('base64'), + }], { + publicBaseUrl, + downloadPhoto: async () => { downloaded = true; }, + normalizePhoto: async (buffer) => { + assert.equal(buffer.toString(), 'local'); + return Buffer.from('jpeg'); + }, + uploadImage: async () => ({ url: 'https://greenlenspro.com/storage/plant-images/slide.jpg' }), + }); + + assert.deepEqual(result, ['https://greenlenspro.com/storage/plant-images/slide.jpg']); + assert.equal(downloaded, false); +}); + +test('rejects a storage result outside the verified GreenLens domain', async () => { + await assert.rejects( + prepareTiktokPhotoUrls([{ imageBase64: Buffer.from('source').toString('base64') }], { + publicBaseUrl, + normalizePhoto: async () => Buffer.from('jpeg'), + uploadImage: async () => ({ url: 'https://pub-example.r2.dev/slide.jpg' }), + }), + /unverified public URL/, + ); +});