fix: various fixes to sentry-reported errors and more

This commit is contained in:
Hampus Kraft
2026-02-18 15:38:51 +00:00
parent 302c0d2a0c
commit 0517a966a3
357 changed files with 25420 additions and 16281 deletions

View File

@@ -23,6 +23,7 @@ import {HeadObjectCommand, type S3Client} from '@aws-sdk/client-s3';
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
import {toBodyData, toWebReadableStream} from '@fluxer/media_proxy/src/lib/BinaryUtils';
import {parseRange, setHeaders} from '@fluxer/media_proxy/src/lib/HttpUtils';
import {ImageProcessingError} from '@fluxer/media_proxy/src/lib/ImageProcessing';
import type {InMemoryCoalescer} from '@fluxer/media_proxy/src/lib/InMemoryCoalescer';
import type {MediaTransformService} from '@fluxer/media_proxy/src/lib/MediaTransformService';
import {SUPPORTED_MIME_TYPES} from '@fluxer/media_proxy/src/lib/MediaTypes';
@@ -124,14 +125,22 @@ export function createAttachmentsHandler(deps: AttachmentsControllerDeps) {
if (!mediaType) throw new HTTPException(400, {message: 'Invalid media type'});
if (mediaType === 'image') {
return transformImage(data, {
width,
height,
format: normalizedFormat || undefined,
quality,
animated,
fallbackContentType: contentType,
});
try {
return await transformImage(data, {
width,
height,
format: normalizedFormat || undefined,
quality,
animated,
fallbackContentType: contentType,
});
} catch (error) {
if (error instanceof ImageProcessingError) {
logger.warn({error}, 'Image transformation failed, serving original');
return {data, contentType};
}
throw error;
}
}
if (mediaType === 'video' && format && mimeType) {
@@ -145,7 +154,12 @@ export function createAttachmentsHandler(deps: AttachmentsControllerDeps) {
throw new HTTPException(400, {message: 'Only images can be transformed via this endpoint'});
} catch (error) {
logger.error({error}, 'Failed to process attachment media');
if (error instanceof HTTPException) throw error;
if (error instanceof Error && 'isExpected' in error) {
logger.warn({error}, 'Failed to process attachment media');
} else {
logger.error({error}, 'Failed to process attachment media');
}
throw new HTTPException(400);
}
});

View File

@@ -21,6 +21,7 @@ import type {HttpClient} from '@fluxer/http_client/src/HttpClientTypes';
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
import {toBodyData} from '@fluxer/media_proxy/src/lib/BinaryUtils';
import {parseRange, setHeaders} from '@fluxer/media_proxy/src/lib/HttpUtils';
import {ImageProcessingError} from '@fluxer/media_proxy/src/lib/ImageProcessing';
import type {InMemoryCoalescer} from '@fluxer/media_proxy/src/lib/InMemoryCoalescer';
import type {MediaTransformService} from '@fluxer/media_proxy/src/lib/MediaTransformService';
import type {MediaValidator} from '@fluxer/media_proxy/src/lib/MediaValidation';
@@ -141,14 +142,22 @@ export function createExternalMediaHandler(deps: ExternalMediaControllerDeps) {
if (mediaType === 'image') {
tracing?.addSpanEvent('image.process.start', {mimeType});
return transformImage(buffer, {
width,
height,
format: normalizedFormat || undefined,
quality,
animated,
fallbackContentType: mimeType,
});
try {
return await transformImage(buffer, {
width,
height,
format: normalizedFormat || undefined,
quality,
animated,
fallbackContentType: mimeType,
});
} catch (error) {
if (error instanceof ImageProcessingError) {
logger.warn({error}, 'Image transformation failed, serving original');
return {data: buffer, contentType: mimeType};
}
throw error;
}
}
if (mediaType === 'video' && format) {
@@ -164,7 +173,11 @@ export function createExternalMediaHandler(deps: ExternalMediaControllerDeps) {
return {data: buffer, contentType: mimeType};
} catch (error) {
if (error instanceof HTTPException) throw error;
logger.error({error}, 'Failed to process external media');
if (error instanceof Error && 'isExpected' in error) {
logger.warn({error}, 'Failed to process external media');
} else {
logger.error({error}, 'Failed to process external media');
}
throw new HTTPException(400, {message: 'Failed to process media'});
}
};

View File

@@ -22,6 +22,22 @@ import type {TracingInterface} from '@fluxer/media_proxy/src/types/Tracing';
import sharp from 'sharp';
import {rgbaToThumbHash} from 'thumbhash';
export class ImageProcessingError extends Error {
readonly isExpected = true;
constructor(message: string, cause?: unknown) {
super(message);
this.name = 'ImageProcessingError';
this.cause = cause;
}
}
const VIPS_ERROR_PATTERN = /^Vips|^Input |unexpected end of|load_buffer: |save_buffer: /i;
function isCorruptImageError(error: unknown): error is Error {
return error instanceof Error && VIPS_ERROR_PATTERN.test(error.message);
}
export async function generatePlaceholder(imageBuffer: Buffer): Promise<string> {
try {
const metadata = await sharp(imageBuffer).metadata();
@@ -75,45 +91,61 @@ export function createImageProcessor(options?: {
animated: boolean;
}): Promise<Buffer> => {
const fn = async () => {
const startTime = Date.now();
const metadata = await sharp(opts.buffer).metadata();
try {
const startTime = Date.now();
const metadata = await sharp(opts.buffer).metadata();
const resizeWidth = Math.min(opts.width, metadata.width || 0);
const resizeHeight = Math.min(opts.height, metadata.height || 0);
const targetFormat = opts.format.toLowerCase();
if (!targetFormat) {
throw new Error('Target image format is required');
const resizeWidth = Math.min(opts.width, metadata.width || 0);
const resizeHeight = Math.min(opts.height, metadata.height || 0);
const targetFormat = opts.format.toLowerCase();
if (!targetFormat) {
throw new Error('Target image format is required');
}
const supportsAnimation = ANIMATED_OUTPUT_FORMATS.has(targetFormat);
const shouldAnimate = opts.animated && supportsAnimation;
const buildPipeline = (animated: boolean) =>
sharp(opts.buffer, {animated})
.resize(resizeWidth, resizeHeight, {
fit: 'cover',
withoutEnlargement: true,
})
.toFormat(targetFormat as keyof sharp.FormatEnum, {
quality: opts.quality === 'high' ? 80 : opts.quality === 'low' ? 20 : 100,
})
.toBuffer();
let result: Buffer;
try {
result = await buildPipeline(shouldAnimate);
} catch (error) {
if (!shouldAnimate || !isCorruptImageError(error)) {
throw error;
}
result = await buildPipeline(false);
}
const duration = Date.now() - startTime;
metrics?.histogram({
name: 'media_proxy.transform.latency',
dimensions: {format: targetFormat, quality: opts.quality},
valueMs: duration,
});
metrics?.counter({
name: 'media_proxy.transform.bytes',
dimensions: {format: targetFormat, quality: opts.quality},
value: result.length,
});
return result;
} catch (error) {
if (isCorruptImageError(error)) {
throw new ImageProcessingError(error.message, error);
}
throw error;
}
const supportsAnimation = ANIMATED_OUTPUT_FORMATS.has(targetFormat);
const shouldAnimate = opts.animated && supportsAnimation;
const result = await sharp(opts.buffer, {
animated: shouldAnimate,
})
.resize(resizeWidth, resizeHeight, {
fit: 'cover',
withoutEnlargement: true,
})
.toFormat(targetFormat as keyof sharp.FormatEnum, {
quality: opts.quality === 'high' ? 80 : opts.quality === 'low' ? 20 : 100,
})
.toBuffer();
const duration = Date.now() - startTime;
metrics?.histogram({
name: 'media_proxy.transform.latency',
dimensions: {format: targetFormat, quality: opts.quality},
valueMs: duration,
});
metrics?.counter({
name: 'media_proxy.transform.bytes',
dimensions: {format: targetFormat, quality: opts.quality},
value: result.length,
});
return result;
};
if (tracing) {

View File

@@ -17,13 +17,19 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {execFile} from 'node:child_process';
import fs from 'node:fs/promises';
import {promisify} from 'node:util';
import {DEFAULT_THUMBNAIL_TIMEOUT_MS} from '@fluxer/constants/src/Timeouts';
import type {FFmpegUtilsType} from '@fluxer/media_proxy/src/lib/FFmpegUtils';
import type {ImageProcessor} from '@fluxer/media_proxy/src/lib/ImageProcessing';
import {ImageProcessingError} from '@fluxer/media_proxy/src/lib/ImageProcessing';
import type {MimeTypeUtils} from '@fluxer/media_proxy/src/lib/MimeTypeUtils';
import sharp from 'sharp';
import {temporaryFile} from 'tempy';
const execFileAsync = promisify(execFile);
interface TransformResult {
data: Buffer;
contentType: string;
@@ -51,6 +57,22 @@ interface MediaTransformServiceDeps {
mimeTypeUtils: MimeTypeUtils;
}
async function decodeWithFFmpeg(buffer: Buffer): Promise<Buffer> {
const inputPath = temporaryFile({extension: 'bin'});
const outputPath = temporaryFile({extension: 'png'});
try {
await fs.writeFile(inputPath, buffer);
await execFileAsync(
'ffmpeg',
['-hide_banner', '-loglevel', 'error', '-i', inputPath, '-frames:v', '1', '-y', outputPath],
{timeout: DEFAULT_THUMBNAIL_TIMEOUT_MS},
);
return await fs.readFile(outputPath);
} finally {
await Promise.all([fs.unlink(inputPath).catch(() => {}), fs.unlink(outputPath).catch(() => {})]);
}
}
export function createMediaTransformService(deps: MediaTransformServiceDeps) {
const {imageProcessor, ffmpegUtils, mimeTypeUtils} = deps;
const {processImage} = imageProcessor;
@@ -58,13 +80,27 @@ export function createMediaTransformService(deps: MediaTransformServiceDeps) {
const {getMimeType} = mimeTypeUtils;
async function transformImage(buffer: Buffer, options: ImageTransformOptions): Promise<TransformResult> {
const metadata = await sharp(buffer).metadata();
let metadata: sharp.Metadata;
let processBuffer = buffer;
try {
metadata = await sharp(buffer).metadata();
} catch {
try {
processBuffer = await decodeWithFFmpeg(buffer);
metadata = await sharp(processBuffer).metadata();
} catch (ffmpegError) {
throw new ImageProcessingError(
ffmpegError instanceof Error ? ffmpegError.message : 'Failed to read image metadata',
ffmpegError,
);
}
}
const targetWidth = options.width ? Math.min(options.width, metadata.width || 0) : metadata.width || 0;
const targetHeight = options.height ? Math.min(options.height, metadata.height || 0) : metadata.height || 0;
const outputFormat = (options.format || metadata.format?.toLowerCase() || '').toLowerCase();
const image = await processImage({
buffer,
buffer: processBuffer,
width: targetWidth,
height: targetHeight,
format: outputFormat,