feat(server): add image resize support (#14588)

fix #13842 


#### PR Dependency Tree


* **PR #14588** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Images are now processed natively and converted to WebP for smaller,
optimized files; Copilot and avatar attachments use the processed WebP
output.
* Avatar uploads accept BMP, GIF, JPEG, PNG, WebP (5MB max) and are
downscaled to a standard edge.

* **Error Messages / i18n**
  * Added localized error "Image format not supported: {format}".

* **Tests**
* Added end-to-end and unit tests for conversion, EXIF preservation, and
upload limits.

* **Chores**
  * Added native image-processing dependencies.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-03-07 04:42:12 +08:00
committed by GitHub
parent f34e25e122
commit 86d65b2f64
20 changed files with 743 additions and 29 deletions
@@ -17,6 +17,8 @@ import { isNil, omitBy } from 'lodash-es';
import {
CannotDeleteOwnAccount,
type FileUpload,
ImageFormatNotSupported,
OneMB,
readBufferWithLimit,
sniffMime,
Throttle,
@@ -28,6 +30,7 @@ import {
UserFeatureName,
UserSettingsSchema,
} from '../../models';
import { processImage } from '../../native';
import { Public } from '../auth/guard';
import { sessionUser } from '../auth/service';
import { CurrentUser } from '../auth/session';
@@ -115,16 +118,26 @@ export class UserResolver {
throw new UserNotFound();
}
const avatarBuffer = await readBufferWithLimit(avatar.createReadStream());
const contentType = sniffMime(avatarBuffer, avatar.mimetype);
const avatarBuffer = await readBufferWithLimit(
avatar.createReadStream(),
5 * OneMB
);
const contentType = sniffMime(avatarBuffer, avatar.mimetype)?.toLowerCase();
if (!contentType || !contentType.startsWith('image/')) {
throw new Error(`Invalid file type: ${contentType || 'unknown'}`);
throw new ImageFormatNotSupported({ format: contentType || 'unknown' });
}
let processedAvatarBuffer: Buffer;
try {
processedAvatarBuffer = await processImage(avatarBuffer, 512, false);
} catch {
throw new ImageFormatNotSupported({ format: contentType });
}
const avatarUrl = await this.storage.put(
`${user.id}-avatar-${Date.now()}`,
avatarBuffer,
{ contentType }
processedAvatarBuffer,
{ contentType: 'image/webp' }
);
if (user.avatarUrl) {