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