From dc50df15cbb89655305413ea87da4c49ba5522fb Mon Sep 17 00:00:00 2001 From: Oliver Bryan <04oliverbryan@gmail.com> Date: Fri, 9 Jan 2026 03:59:34 +0000 Subject: [PATCH] gif avatar support all resizing is done server-side with sharp --- bun.lock | 55 +++++++++++++++++++ packages/backend/package.json | 3 +- .../backend/src/routes/user/upload-avatar.ts | 32 +++++++++-- .../src/lib/server/user/uploadAvatar.ts | 13 +---- packages/frontend/src/lib/utils.ts | 42 -------------- todo.md | 3 - 6 files changed, 85 insertions(+), 63 deletions(-) diff --git a/bun.lock b/bun.lock index 7285ad0..688140d 100644 --- a/bun.lock +++ b/bun.lock @@ -19,6 +19,7 @@ "drizzle-orm": "^0.45.0", "jsonwebtoken": "^9.0.3", "pg": "^8.16.3", + "sharp": "^0.34.5", }, "devDependencies": { "@types/bcrypt": "^6.0.0", @@ -120,6 +121,8 @@ "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], + "@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], + "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "0.18.20", "source-map-support": "0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "3.3.2", "get-tsconfig": "4.13.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="], @@ -184,6 +187,56 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], + "@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + "@issue/backend": ["@issue/backend@workspace:packages/backend"], "@issue/frontend": ["@issue/frontend@workspace:packages/frontend"], @@ -614,6 +667,8 @@ "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], + "sharp": ["sharp@0.34.5", "", { "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=="], + "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="], "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], diff --git a/packages/backend/package.json b/packages/backend/package.json index 0d224ce..d09df43 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -32,6 +32,7 @@ "dotenv": "^17.2.3", "drizzle-orm": "^0.45.0", "jsonwebtoken": "^9.0.3", - "pg": "^8.16.3" + "pg": "^8.16.3", + "sharp": "^0.34.5" } } diff --git a/packages/backend/src/routes/user/upload-avatar.ts b/packages/backend/src/routes/user/upload-avatar.ts index c9a0713..21792d1 100644 --- a/packages/backend/src/routes/user/upload-avatar.ts +++ b/packages/backend/src/routes/user/upload-avatar.ts @@ -1,10 +1,11 @@ import { randomUUID } from "node:crypto"; import type { BunRequest } from "bun"; +import sharp from "sharp"; import { s3Client, s3Endpoint, s3PublicUrl } from "../../s3"; const MAX_FILE_SIZE = 5 * 1024 * 1024; const ALLOWED_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"]; -const ALLOWED_EXTENSIONS = ["png", "jpg", "jpeg", "webp", "gif"]; +const TARGET_SIZE = 256; export default async function uploadAvatar(req: BunRequest) { if (req.method !== "POST") { @@ -28,19 +29,38 @@ export default async function uploadAvatar(req: BunRequest) { }); } - const fileExtension = file.name.split(".").pop()?.toLowerCase(); - if (!fileExtension || !ALLOWED_EXTENSIONS.includes(fileExtension)) { - return new Response("invalid file extension", { status: 400 }); + const isGIF = file.type === "image/gif"; + const outputExtension = isGIF ? "gif" : "png"; + const outputMimeType = isGIF ? "image/gif" : "image/png"; + + let resizedBuffer: Buffer; + try { + const inputBuffer = Buffer.from(await file.arrayBuffer()); + + if (isGIF) { + resizedBuffer = await sharp(inputBuffer, { animated: true }) + .resize(TARGET_SIZE, TARGET_SIZE, { fit: "cover" }) + .gif() + .toBuffer(); + } else { + resizedBuffer = await sharp(inputBuffer) + .resize(TARGET_SIZE, TARGET_SIZE, { fit: "cover" }) + .png() + .toBuffer(); + } + } catch (error) { + console.error("failed to resize image:", error); + return new Response("failed to process image", { status: 500 }); } const uuid = randomUUID(); - const key = `avatars/${uuid}.${fileExtension}`; + const key = `avatars/${uuid}.${outputExtension}`; const publicUrlBase = s3PublicUrl || s3Endpoint; const publicUrl = `${publicUrlBase}/${key}`; try { const s3File = s3Client.file(key); - await s3File.write(file, { type: file.type }); + await s3File.write(resizedBuffer, { type: outputMimeType }); } catch (error) { console.error("failed to upload to S3:", error); return new Response("failed to upload image", { status: 500 }); diff --git a/packages/frontend/src/lib/server/user/uploadAvatar.ts b/packages/frontend/src/lib/server/user/uploadAvatar.ts index c9e278c..a75b079 100644 --- a/packages/frontend/src/lib/server/user/uploadAvatar.ts +++ b/packages/frontend/src/lib/server/user/uploadAvatar.ts @@ -1,4 +1,4 @@ -import { getAuthHeaders, getServerURL, resizeImageToSquare } from "@/lib/utils"; +import { getAuthHeaders, getServerURL } from "@/lib/utils"; import type { ServerQueryInput } from ".."; export async function uploadAvatar({ @@ -21,17 +21,8 @@ export async function uploadAvatar({ return; } - let resizedFile: File; - try { - const blob = await resizeImageToSquare(file, 256); - resizedFile = new File([blob], "avatar.png", { type: "image/png" }); - } catch (_error) { - onError?.("Failed to resize image"); - return; - } - const formData = new FormData(); - formData.append("file", resizedFile); + formData.append("file", file); const res = await fetch(`${getServerURL()}/user/upload-avatar`, { method: "POST", diff --git a/packages/frontend/src/lib/utils.ts b/packages/frontend/src/lib/utils.ts index 19c1f63..666043e 100644 --- a/packages/frontend/src/lib/utils.ts +++ b/packages/frontend/src/lib/utils.ts @@ -31,45 +31,3 @@ export function getServerURL() { } return serverURL; } - -export async function resizeImageToSquare(file: File, targetSize: number = 256): Promise { - return new Promise((resolve, reject) => { - const img = new Image(); - const canvas = document.createElement("canvas"); - const ctx = canvas.getContext("2d"); - - if (!ctx) { - reject(new Error("Could not get canvas context")); - return; - } - - img.onload = () => { - canvas.width = targetSize; - canvas.height = targetSize; - - const minDimension = Math.min(img.width, img.height); - const startX = (img.width - minDimension) / 2; - const startY = (img.height - minDimension) / 2; - - ctx.drawImage(img, startX, startY, minDimension, minDimension, 0, 0, targetSize, targetSize); - - canvas.toBlob( - (blob) => { - if (blob) { - resolve(blob); - } else { - reject(new Error("Failed to create blob")); - } - }, - "image/png", - 0.9, - ); - }; - - img.onerror = () => { - reject(new Error("Failed to load image")); - }; - - img.src = URL.createObjectURL(file); - }); -} diff --git a/todo.md b/todo.md index 4ff2ca2..74a3862 100644 --- a/todo.md +++ b/todo.md @@ -1,6 +1,4 @@ - landing/marketing page - - does your team need a snappy project management tool? - - build your next project with Issue (might need a new name...) - add loading state to landing CTAs during auth verification - dedicated /register route (currently login/register are combined on /login) - real logo @@ -16,5 +14,4 @@ - time tracking (linked to issues or standalone) - user preferences - "assign to me by default" option for new issues -- add support for gif avatars (if its a gif, just check size, reject if too big and skip the canvas step otherwise) - org member role promotion/demotion