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() : '';
|
||||
|
||||
159
server/lib/tiktok-assets.js
Normal file
159
server/lib/tiktok-assets.js
Normal 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,
|
||||
};
|
||||
97
server/test/tiktok-assets.test.js
Normal file
97
server/test/tiktok-assets.test.js
Normal 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/,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user