fix: various fixes to sentry-reported errors and more
This commit is contained in:
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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'});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user