3 Commits
Author SHA1 Message Date
Niklas 4cda9c8c0a fix(upload): store sniffed MIME instead of generic CDN content-type
Publish container image / build-and-push (push) Canceled after 0s
2026-09-05 13:02:50 +02:00
Niklas 3e3013045c fix(upload): sniff magic bytes when CDN serves generic binary/octet-stream
Publish container image / build-and-push (push) Canceled after 0s
2026-09-05 12:57:20 +02:00
Niklas 6587c3f865 fix(upload): send browser-like headers in from-url fetch (CDN 403s)
Publish container image / build-and-push (push) Canceled after 0s
2026-09-05 12:45:26 +02:00
+41 -3
View File
@@ -286,7 +286,14 @@ function downloadImage(imageUrl, maxRedirects = 5) {
const parsed = new URL(imageUrl); const parsed = new URL(imageUrl);
const client = parsed.protocol === 'https:' ? https : http; const client = parsed.protocol === 'https:' ? https : http;
client.get(imageUrl, { timeout: 30000 }, (response) => { // Browser-like headers: many CDNs (film.ai, frameset, etc.) reject
// requests without UA/Referer with 403.
const requestHeaders = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36",
"Accept": "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
"Referer": parsed.origin + "/",
};
client.get(imageUrl, { timeout: 30000, headers: requestHeaders }, (response) => {
// Follow redirects up to maxRedirects times // Follow redirects up to maxRedirects times
if ([301, 302, 303, 307, 308].includes(response.statusCode) && response.headers.location) { if ([301, 302, 303, 307, 308].includes(response.statusCode) && response.headers.location) {
if (maxRedirects <= 0) { if (maxRedirects <= 0) {
@@ -300,9 +307,11 @@ function downloadImage(imageUrl, maxRedirects = 5) {
} }
const contentType = (response.headers['content-type'] || '').split(';')[0].trim(); const contentType = (response.headers['content-type'] || '').split(';')[0].trim();
if (!ALLOWED_MIME_TYPES.includes(contentType)) { const GENERIC_TYPES = ['binary/octet-stream', 'application/octet-stream'];
if (!ALLOWED_MIME_TYPES.includes(contentType) && !GENERIC_TYPES.includes(contentType)) {
return reject(new Error(`Unsupported content type: ${contentType}`)); return reject(new Error(`Unsupported content type: ${contentType}`));
} }
const typeIsGeneric = GENERIC_TYPES.includes(contentType);
const chunks = []; const chunks = [];
let totalSize = 0; let totalSize = 0;
@@ -318,8 +327,17 @@ function downloadImage(imageUrl, maxRedirects = 5) {
response.on('end', () => { response.on('end', () => {
const buffer = Buffer.concat(chunks); const buffer = Buffer.concat(chunks);
let resolvedType = contentType;
if (typeIsGeneric) {
// Replace the generic CDN type with what the bytes actually are,
// so the correct MIME gets stored and served.
resolvedType = sniffMimeExact(buffer);
if (!resolvedType) {
return reject(new Error('Unsupported content type: binary/octet-stream (unknown magic bytes)'));
}
}
const filename = parsed.pathname.split('/').pop() || 'image'; const filename = parsed.pathname.split('/').pop() || 'image';
resolve({ buffer, mimeType: contentType, filename }); resolve({ buffer, mimeType: resolvedType, filename });
}); });
response.on('error', reject); response.on('error', reject);
@@ -418,4 +436,24 @@ router.use((err, req, res, next) => {
next(err); next(err);
}); });
// Sniff the real MIME type from magic bytes (for CDNs that serve everything
// as binary/octet-stream). Returns the exact MIME or null.
function sniffMimeExact(buf) {
if (!buf || buf.length < 12) return null;
const hex = buf.slice(0, 12).toString('hex');
const ascii = buf.slice(0, 12).toString('latin1');
if (hex.startsWith('89504e47')) return 'image/png';
if (hex.startsWith('ffd8ff')) return 'image/jpeg';
if (ascii.startsWith('GIF87a') || ascii.startsWith('GIF89a')) return 'image/gif';
if (ascii.startsWith('RIFF') && buf.slice(8, 12).toString('latin1') === 'WEBP') return 'image/webp';
if (ascii.startsWith('%PDF')) return 'application/pdf';
if (buf.slice(4, 8).toString('latin1') === 'ftyp') {
const brand = buf.slice(8, 12).toString('latin1');
return brand.startsWith('qt') ? 'video/quicktime' : 'video/mp4';
}
if (hex.startsWith('1a45dfa3')) return 'video/webm';
return null;
}
module.exports = router; module.exports = router;