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 <noreply@anthropic.com>
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
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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)