Skip to content

Commit 9ec18e9

Browse files
committed
feat: improve image processor
* allow all image types and more image resolutions * use buns image processor instead of sharp
1 parent 9405438 commit 9ec18e9

5 files changed

Lines changed: 79 additions & 59 deletions

File tree

src/libs/image-processor.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { snakeCase } from "change-case";
2+
3+
export async function processUploadedImage(image: Bun.Image, maxResolution: number, target: Bun.BunFile): Promise<number> {
4+
try {
5+
const metadata = await image.metadata();
6+
const side = Math.min(metadata.width, metadata.height, maxResolution);
7+
8+
return await image.resize(side, side).png().write(target);
9+
} catch(err) {
10+
return Promise.reject(err);
11+
}
12+
}
13+
14+
const KNOWN_ERRORS = [
15+
'ERR_IMAGE_DECODE_FAILED',
16+
'ERR_IMAGE_UNKNOWN_FORMAT',
17+
'ERR_IMAGE_FORMAT_UNSUPPORTED',
18+
'ERR_IMAGE_TOO_MANY_PIXELS'
19+
];
20+
21+
export function imageErrorTranslation(error: any): string {
22+
if(!error || !error.code || !KNOWN_ERRORS.includes(error.code)) return '$.error.icon_upload.unknown';
23+
return `$.error.icon_upload.${snakeCase(error.code.slice(10))}`; // remove 'ERR_IMAGE_' prefix and convert to snake_case
24+
}

src/libs/models.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export namespace tRequestBody {
4848
};
4949

5050
export const tUploadIcon = (description: string) => t.Object({
51-
image: t.File({ type: 'image/png', description: 'A png image file' })
51+
image: t.File({ description: 'A png image file' })
5252
}, { description: description, ...options });
5353
export const UploadCustomIcon = tUploadIcon('A custom icon upload object');
5454
export const UploadRoleIcon = tUploadIcon('A role icon upload object');

src/routes/partners.ts

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ import { DocumentationCategory } from "../types/DocumentationCategory";
66
import { Permission } from "../types/Permission";
77
import { formatUUID, GameProfile, stripUUID, uuidRegex } from "../libs/game-profiles";
88
import { partnerIconFile } from "../libs/data-accessor";
9-
import sharp from "sharp";
109
import Logger from "../libs/Logger";
1110
import { config } from "../libs/config";
11+
import { imageErrorTranslation, processUploadedImage } from "../libs/image-processor";
1212

1313
export default (app: ElysiaApp) => app.get('/', async () =>
1414
Promise.all((await Partner.find().sort({ joinedAt: 1 }).lean()).map(async partner => ({
@@ -190,25 +190,23 @@ export default (app: ElysiaApp) => app.get('/', async () =>
190190
},
191191
params: t.Object({ uuid: t.String({ description: 'The partner UUID' }) }),
192192
headers: tHeaders
193-
}).post('/', async ({ session, params, body: { image }, i18n, status }) => { // Set role icon
193+
}).post('/', async ({ session, params, body, i18n, status }) => { // Set role icon
194194
if(!session?.selfOrHasPermission(Permission.ManagePartners)) return status(403, { error: i18n('$.error.notAllowed') });
195195

196196
const partner = await Partner.findOne({ uuid: stripUUID(params.uuid) });
197197
if(!partner) return status(404, { error: i18n('$.partners.not_found') });
198198

199-
const metadata = await sharp(await image.arrayBuffer()).metadata().catch((err: Error) => {
200-
Logger.error('Failed to read image metadata:', err.message);
201-
return null;
202-
});
203-
204-
if(!metadata) return status(422, { error: i18n('$.partners.icon.upload.invalidMetadata') });
205-
if(metadata.format != 'png') return status(422, { error: i18n('$.partners.icon.upload.wrongFormat')});
206-
if(!metadata.height || metadata.height != metadata.width) return status(422, { error: i18n('$.partners.icon.upload.wrongResolution')});
207-
if(metadata.height > config.validation.icon.maxResolution) return status(422, { error: i18n('$.partners.icon.upload.exceedsMaxResolution').replaceAll('<max>', config.validation.icon.maxResolution.toString()) });
208-
209-
await Bun.write(partnerIconFile(partner.uuid), await image.arrayBuffer(), { createPath: true });
210-
211-
return { message: i18n('$.partners.icon.upload.success') };
199+
try {
200+
await processUploadedImage(
201+
new Bun.Image(await body.image.arrayBuffer()),
202+
config.validation.icon.maxResolution,
203+
partnerIconFile(partner.uuid)
204+
);
205+
206+
return { message: i18n('$.partners.icon.upload.success') };
207+
} catch(err) {
208+
return status(422, { error: i18n(imageErrorTranslation(err)) });
209+
}
212210
}, {
213211
detail: {
214212
tags: [DocumentationCategory.Partners],

src/routes/players/[uuid]/icons.ts

Lines changed: 19 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@ import { GlobalIcon, icons } from "../../../types/GlobalIcon";
55
import { stripUUID } from "../../../libs/game-profiles";
66
import { ElysiaApp } from "../../..";
77
import { sendCustomIconUploadMessage } from "../../../libs/discord-notifier";
8-
import sharp from "sharp";
98
import Logger from "../../../libs/Logger";
109
import { generateSecureCode } from "../../../libs/crypto";
1110
import { Player } from "../../../libs/database/schemas/Player";
1211
import { tResponseBody, tHeaders, tParams, tRequestBody } from "../../../libs/models";
1312
import { DocumentationCategory } from "../../../types/DocumentationCategory";
1413
import { customIconFile, customIconPath } from "../../../libs/data-accessor";
1514
import { readdirSync } from "fs";
15+
import { imageErrorTranslation, processUploadedImage } from "../../../libs/image-processor";
1616

1717
export function getCustomIconUrl(uuid: string, hash: string) {
1818
return `${config.baseUrl}/players/${uuid}/icon/${hash}`;
@@ -37,7 +37,7 @@ export default (app: ElysiaApp) => app.get('/', async ({ session, params: { uuid
3737
params: tParams.uuid
3838
}).get('/:hash', async ({ params: { uuid, hash }, i18n, status }) => { // Get custom icon
3939
const player = await Player.findOne({ uuid: stripUUID(uuid) });
40-
if(!player) return status(404, { error: i18n('$.error.noTag') });
40+
if(!player) return status(404, { error: i18n('$.error.playerNotFound') });
4141
if(player.isBanned()) return status(403, { error: i18n('$.error.playerBanned') });
4242

4343
const file = customIconFile(player.uuid, hash);
@@ -55,36 +55,35 @@ export default (app: ElysiaApp) => app.get('/', async ({ session, params: { uuid
5555
404: tResponseBody.Error
5656
},
5757
params: tParams.uuidAndIconHash
58-
}).post('/', async ({ session, body: { image }, params, i18n, status }) => { // Upload custom icon
58+
}).post('/', async ({ session, body, params, i18n, status }) => { // Upload custom icon
5959
if(!session || !session.self) return status(403, { error: i18n('$.error.notAllowed') });
6060

6161
const player = await Player.findOne({ uuid: stripUUID(params.uuid) });
6262
if(!player) return status(404, { error: i18n('$.error.noTag') });
6363
if(player.isBanned()) return status(403, { error: i18n('$.error.banned') });
6464
if(!player.hasPermission(Permission.CustomIcon)) return status(403, { error: i18n('$.icon.upload.notAllowed') });
6565

66-
const metadata = await sharp(await image.arrayBuffer()).metadata().catch((err: Error) => {
67-
Logger.error('Failed to read image metadata:', err.message);
68-
return null;
69-
});
70-
71-
if(!metadata) return status(422, { error: i18n('$.icon.upload.invalidMetadata') });
72-
if(metadata.format != 'png') return status(422, { error: i18n('$.icon.upload.wrongFormat')});
73-
if(!metadata.height || metadata.height != metadata.width) return status(422, { error: i18n('$.icon.upload.wrongResolution')});
74-
if(metadata.height > config.validation.icon.maxResolution) return status(422, { error: i18n('$.icon.upload.exceedsMaxResolution').replaceAll('<max>', config.validation.icon.maxResolution.toString()) });
75-
7666
player.icon.type = GlobalIcon.Custom;
7767
player.icon.hash = generateSecureCode(32);
7868
player.markModified('icon');
79-
await player.save();
80-
await Bun.write(customIconFile(player.uuid, player.icon.hash), await image.arrayBuffer(), { createPath: true });
8169

82-
if(!player.hasPermission(Permission.BypassValidation)) sendCustomIconUploadMessage(
83-
await player.getGameProfile(),
84-
player.icon.hash
85-
);
70+
try {
71+
await processUploadedImage(
72+
new Bun.Image(await body.image.arrayBuffer()),
73+
config.validation.icon.maxResolution,
74+
customIconFile(player.uuid, player.icon.hash)
75+
);
76+
await player.save();
77+
78+
if(!player.hasPermission(Permission.BypassValidation)) sendCustomIconUploadMessage(
79+
await player.getGameProfile(),
80+
player.icon.hash
81+
);
8682

87-
return { message: i18n('$.icon.upload.success'), hash: player.icon.hash };
83+
return { message: i18n('$.icon.upload.success'), hash: player.icon.hash };
84+
} catch(err) {
85+
return status(422, { error: i18n(imageErrorTranslation(err)) });
86+
}
8887
}, {
8988
detail: {
9089
tags: [DocumentationCategory.Tags],

src/routes/roles.ts

Lines changed: 22 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ import { tHeaders, tParams, tRequestBody, tResponseBody, tSchema } from "../libs
77
import { DocumentationCategory } from "../types/DocumentationCategory";
88
import { snakeCase } from "change-case";
99
import Logger from "../libs/Logger";
10-
import sharp from "sharp";
1110
import { config } from "../libs/config";
1211
import { roleIconFile } from "../libs/data-accessor";
12+
import { imageErrorTranslation, processUploadedImage } from "../libs/image-processor";
1313

1414
export default (app: ElysiaApp) => app.get('/', async ({ session, i18n, status }) => { // Get roles
1515
if(!session?.player?.hasPermission(Permission.ViewRoles)) return status(403, { error: i18n('$.error.notAllowed') });
@@ -126,36 +126,35 @@ export default (app: ElysiaApp) => app.get('/', async ({ session, i18n, status }
126126
422: tResponseBody.Error
127127
},
128128
headers: tHeaders
129-
}).post('/:id/icon', async ({ session, params, body: { image }, i18n, status }) => { // Set role icon
129+
}).post('/:id/icon', async ({ session, params, body, i18n, status }) => { // Set role icon
130130
if(!session?.player?.hasPermission(Permission.EditRoles)) return status(403, { error: i18n('$.error.notAllowed') });
131131

132132
const role = await Role.findOne({ id: params.id });
133133
if(!role) return status(404, { error: i18n('$.roles.not_found') });
134134

135-
const metadata = await sharp(await image.arrayBuffer()).metadata().catch((err: Error) => {
136-
Logger.error('Failed to read image metadata:', err.message);
137-
return null;
138-
});
139-
140-
if(!metadata) return status(422, { error: i18n('$.icon.upload.invalidMetadata') });
141-
if(metadata.format != 'png') return status(422, { error: i18n('$.icon.upload.wrongFormat')});
142-
if(!metadata.height || metadata.height != metadata.width) return status(422, { error: i18n('$.icon.upload.wrongResolution')});
143-
if(metadata.height > config.validation.icon.maxResolution) return status(422, { error: i18n('$.icon.upload.exceedsMaxResolution').replaceAll('<max>', config.validation.icon.maxResolution.toString()) });
144-
145-
await Bun.write(roleIconFile(role.id), await image.arrayBuffer(), { createPath: true });
146135
role.hasIcon = true;
147136
role.markModified('hasIcon');
148-
await role.save();
149-
updateRoleCache();
150137

151-
return {
152-
id: role.id,
153-
name: role.name,
154-
position: role.position,
155-
color: role.color || null,
156-
hasIcon: role.hasIcon,
157-
permissions: role.permissions
158-
};
138+
try {
139+
await processUploadedImage(
140+
new Bun.Image(await body.image.arrayBuffer()),
141+
config.validation.icon.maxResolution,
142+
roleIconFile(role.id)
143+
);
144+
await role.save();
145+
updateRoleCache();
146+
147+
return {
148+
id: role.id,
149+
name: role.name,
150+
position: role.position,
151+
color: role.color || null,
152+
hasIcon: role.hasIcon,
153+
permissions: role.permissions
154+
};
155+
} catch(err) {
156+
return status(422, { error: i18n(imageErrorTranslation(err)) });
157+
}
159158
}, {
160159
detail: {
161160
tags: [DocumentationCategory.Roles],

0 commit comments

Comments
 (0)