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() : '';

159
server/lib/tiktok-assets.js Normal file
View File

@@ -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,
};

View File

@@ -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/,
);
});