Skip to content

Commit cf6242b

Browse files
aster-voidclaude
andcommitted
modules/storage: add image validation and WebP compression
- Add MIME type validation for uploads (jpeg, png, webp, avif, heic, gif, tiff, svg, bmp) - Add folder path validation (allowlist) - Add server-side WebP compression using sharp - Preserve SVG and animated GIF as-is - Update client hint text to show supported formats 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent a6d1cc0 commit cf6242b

File tree

6 files changed

+241
-14
lines changed

6 files changed

+241
-14
lines changed

bun.lock

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"lucide-svelte": "^0.561.0",
1212
"marked": "^17.0.1",
1313
"minio": "^8.0.6",
14+
"sharp": "^0.34.5",
1415
"valibot": "^1.2.0",
1516
},
1617
"devDependencies": {
@@ -77,6 +78,8 @@
7778

7879
"@drizzle-team/brocli": ["@drizzle-team/[email protected]", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
7980

81+
"@emnapi/runtime": ["@emnapi/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="],
82+
8083
"@esbuild-kit/core-utils": ["@esbuild-kit/[email protected]", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
8184

8285
"@esbuild-kit/esm-loader": ["@esbuild-kit/[email protected]", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="],
@@ -161,6 +164,56 @@
161164

162165
"@humanwhocodes/retry": ["@humanwhocodes/[email protected]", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
163166

167+
"@img/colour": ["@img/[email protected]", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="],
168+
169+
"@img/sharp-darwin-arm64": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
170+
171+
"@img/sharp-darwin-x64": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
172+
173+
"@img/sharp-libvips-darwin-arm64": ["@img/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
174+
175+
"@img/sharp-libvips-darwin-x64": ["@img/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
176+
177+
"@img/sharp-libvips-linux-arm": ["@img/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
178+
179+
"@img/sharp-libvips-linux-arm64": ["@img/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
180+
181+
"@img/sharp-libvips-linux-ppc64": ["@img/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
182+
183+
"@img/sharp-libvips-linux-riscv64": ["@img/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
184+
185+
"@img/sharp-libvips-linux-s390x": ["@img/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
186+
187+
"@img/sharp-libvips-linux-x64": ["@img/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
188+
189+
"@img/sharp-libvips-linuxmusl-arm64": ["@img/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
190+
191+
"@img/sharp-libvips-linuxmusl-x64": ["@img/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
192+
193+
"@img/sharp-linux-arm": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
194+
195+
"@img/sharp-linux-arm64": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
196+
197+
"@img/sharp-linux-ppc64": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
198+
199+
"@img/sharp-linux-riscv64": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
200+
201+
"@img/sharp-linux-s390x": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
202+
203+
"@img/sharp-linux-x64": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
204+
205+
"@img/sharp-linuxmusl-arm64": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
206+
207+
"@img/sharp-linuxmusl-x64": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
208+
209+
"@img/sharp-wasm32": ["@img/[email protected]", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
210+
211+
"@img/sharp-win32-arm64": ["@img/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
212+
213+
"@img/sharp-win32-ia32": ["@img/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
214+
215+
"@img/sharp-win32-x64": ["@img/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
216+
164217
"@jridgewell/gen-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
165218

166219
"@jridgewell/remapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
@@ -769,6 +822,8 @@
769822

770823
"set-function-length": ["[email protected]", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
771824

825+
"sharp": ["[email protected]", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
826+
772827
"shebang-command": ["[email protected]", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
773828

774829
"shebang-regex": ["[email protected]", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
@@ -827,6 +882,8 @@
827882

828883
"ts-api-utils": ["[email protected]", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="],
829884

885+
"tslib": ["[email protected]", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
886+
830887
"type-check": ["[email protected]", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
831888

832889
"typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
"lucide-svelte": "^0.561.0",
6464
"marked": "^17.0.1",
6565
"minio": "^8.0.6",
66+
"sharp": "^0.34.5",
6667
"valibot": "^1.2.0"
6768
}
6869
}

src/lib/components/image-upload.svelte

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,22 @@
11
<script lang="ts">
22
import { upload } from "$lib/data/private/storage.remote";
33
import { Loader2, Upload, AlertCircle, X } from "lucide-svelte";
4+
import {
5+
isAcceptedImageType,
6+
isAllowedFolder,
7+
type AllowedFolder,
8+
} from "$lib/shared/logic/image";
49
510
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
611
712
let {
813
value = $bindable(""),
9-
folder = "images",
14+
folder = "images" as AllowedFolder,
1015
label = "Image",
1116
aspect = "5/3",
1217
}: {
1318
value?: string;
14-
folder?: string;
19+
folder?: AllowedFolder;
1520
label?: string;
1621
aspect?: "5/3" | "1/1" | "16/9" | "4/3";
1722
} = $props();
@@ -93,8 +98,14 @@
9398
error = null;
9499
95100
// Validate file type
96-
if (!file.type.startsWith("image/")) {
97-
error = "Please select an image file";
101+
if (!isAcceptedImageType(file.type)) {
102+
error = "Unsupported image format";
103+
return;
104+
}
105+
106+
// Validate folder
107+
if (!isAllowedFolder(folder)) {
108+
error = "Invalid upload folder";
98109
return;
99110
}
100111
@@ -120,9 +131,13 @@
120131
try {
121132
const arrayBuffer = await processedFile.arrayBuffer();
122133
const base64 = arrayBufferToBase64(arrayBuffer);
134+
// Use the validated original type - server will re-compress to WebP anyway
135+
const uploadType = isAcceptedImageType(processedFile.type)
136+
? processedFile.type
137+
: file.type;
123138
const result = await upload({
124139
data: base64,
125-
type: processedFile.type,
140+
type: uploadType,
126141
name: processedFile.name,
127142
folder,
128143
});
@@ -265,7 +280,7 @@
265280
Drop, click, or paste (Ctrl+V)
266281
{/if}
267282
</span>
268-
<span class="mt-1 text-xs text-zinc-400">PNG, JPG, GIF up to 10MB</span>
283+
<span class="mt-1 text-xs text-zinc-400">JPG, PNG, WebP, AVIF, HEIC up to 10MB</span>
269284
{/if}
270285
<input
271286
type="file"

src/lib/data/private/storage.remote.ts

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,46 @@ import { command } from "$app/server";
22
import * as v from "valibot";
33
import { requireUtCodeMember } from "$lib/server/database/auth.server";
44
import { uploadBuffer, deleteFile } from "$lib/server/database/storage.server";
5+
import { compressImage } from "$lib/server/database/image.server";
56
import { S3KeySchema } from "$lib/shared/logic/storage";
7+
import { ACCEPTED_IMAGE_TYPES, ALLOWED_FOLDERS } from "$lib/shared/logic/image";
8+
9+
/** Allowed folder paths for uploads */
10+
const FolderSchema = v.optional(v.picklist([...ALLOWED_FOLDERS]));
11+
12+
/** Max file size: 10MB (base64 encoded ~13.7MB) */
13+
const MAX_BASE64_SIZE = Math.ceil(10 * 1024 * 1024 * 1.37);
14+
15+
const UploadSchema = v.object({
16+
data: v.pipe(
17+
v.string(),
18+
v.maxLength(MAX_BASE64_SIZE, "File too large (max 10MB)"),
19+
),
20+
type: v.picklist([...ACCEPTED_IMAGE_TYPES], "Unsupported image format"),
21+
name: v.pipe(v.string(), v.maxLength(255)),
22+
folder: FolderSchema,
23+
});
624

725
export const upload = command(
8-
v.object({
9-
data: v.string(), // base64
10-
type: v.string(), // mime type
11-
name: v.string(), // file name
12-
folder: v.optional(v.string()),
13-
}),
26+
UploadSchema,
1427
async ({ data, type, name, folder }) => {
1528
await requireUtCodeMember();
29+
1630
const path = folder ?? "uploads";
17-
const buffer = Buffer.from(data, "base64");
18-
return await uploadBuffer(buffer, type, name, path);
31+
const inputBuffer = Buffer.from(data, "base64");
32+
33+
// Compress and convert to WebP
34+
const {
35+
buffer,
36+
type: outputType,
37+
extension,
38+
} = await compressImage(inputBuffer, type);
39+
40+
// Replace original extension with output extension
41+
const baseName = name.replace(/\.[^.]+$/, "");
42+
const outputName = `${baseName}.${extension}`;
43+
44+
return await uploadBuffer(buffer, outputType, outputName, path);
1945
},
2046
);
2147

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import sharp from "sharp";
2+
3+
// Re-export shared types
4+
export {
5+
ACCEPTED_IMAGE_TYPES,
6+
isAcceptedImageType,
7+
type AcceptedImageType,
8+
} from "$lib/shared/logic/image";
9+
10+
/**
11+
* Image compression options
12+
*/
13+
interface CompressOptions {
14+
/** Max width/height in pixels (default: 1920) */
15+
maxSize?: number;
16+
/** WebP quality 1-100 (default: 85) */
17+
quality?: number;
18+
}
19+
20+
/**
21+
* Compress an image buffer to WebP format
22+
* - Resizes if larger than maxSize
23+
* - Converts to WebP for optimal compression
24+
* - Preserves aspect ratio
25+
* - Handles animated GIFs by keeping them as-is
26+
*
27+
* @returns Compressed buffer and the output MIME type
28+
*/
29+
export async function compressImage(
30+
buffer: Buffer,
31+
inputType: string,
32+
options: CompressOptions = {},
33+
): Promise<{ buffer: Buffer; type: string; extension: string }> {
34+
const { maxSize = 1920, quality = 85 } = options;
35+
36+
// Pass through SVG as-is (vector format, no compression needed)
37+
if (inputType === "image/svg+xml") {
38+
return { buffer, type: "image/svg+xml", extension: "svg" };
39+
}
40+
41+
// Check if it's an animated GIF
42+
if (inputType === "image/gif") {
43+
const metadata = await sharp(buffer).metadata();
44+
if (metadata.pages && metadata.pages > 1) {
45+
// Animated GIF - pass through as-is
46+
return { buffer, type: "image/gif", extension: "gif" };
47+
}
48+
}
49+
50+
// Process with sharp
51+
let image = sharp(buffer, {
52+
// Enable HEIF/HEIC support
53+
failOnError: false,
54+
});
55+
56+
const metadata = await image.metadata();
57+
58+
// Resize if needed (preserve aspect ratio)
59+
if (metadata.width && metadata.height) {
60+
if (metadata.width > maxSize || metadata.height > maxSize) {
61+
image = image.resize(maxSize, maxSize, {
62+
fit: "inside",
63+
withoutEnlargement: true,
64+
});
65+
}
66+
}
67+
68+
// Convert to WebP
69+
const outputBuffer = await image
70+
.webp({
71+
quality,
72+
effort: 4, // Balance between speed and compression (0-6)
73+
})
74+
.toBuffer();
75+
76+
return {
77+
buffer: outputBuffer,
78+
type: "image/webp",
79+
extension: "webp",
80+
};
81+
}

src/lib/shared/logic/image.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**
2+
* Accepted image MIME types for upload
3+
* Shared between client and server
4+
*/
5+
export const ACCEPTED_IMAGE_TYPES = [
6+
"image/jpeg",
7+
"image/png",
8+
"image/webp",
9+
"image/avif",
10+
"image/heic",
11+
"image/heif",
12+
"image/gif",
13+
"image/tiff",
14+
"image/svg+xml",
15+
"image/bmp",
16+
] as const;
17+
18+
export type AcceptedImageType = (typeof ACCEPTED_IMAGE_TYPES)[number];
19+
20+
/**
21+
* Check if a MIME type is an accepted image type
22+
*/
23+
export function isAcceptedImageType(type: string): type is AcceptedImageType {
24+
return (ACCEPTED_IMAGE_TYPES as readonly string[]).includes(type);
25+
}
26+
27+
/**
28+
* Allowed folder paths for uploads
29+
*/
30+
export const ALLOWED_FOLDERS = [
31+
"images",
32+
"uploads",
33+
"covers",
34+
"avatars",
35+
"articles",
36+
"members",
37+
"projects",
38+
] as const;
39+
40+
export type AllowedFolder = (typeof ALLOWED_FOLDERS)[number];
41+
42+
/**
43+
* Check if a folder is allowed
44+
*/
45+
export function isAllowedFolder(folder: string): folder is AllowedFolder {
46+
return (ALLOWED_FOLDERS as readonly string[]).includes(folder);
47+
}

0 commit comments

Comments
 (0)