diff --git a/packages/backend/src/db/queries/organisations.ts b/packages/backend/src/db/queries/organisations.ts index 81d54ac..e90bf4b 100644 --- a/packages/backend/src/db/queries/organisations.ts +++ b/packages/backend/src/db/queries/organisations.ts @@ -85,6 +85,7 @@ export async function updateOrganisation( name?: string; description?: string; slug?: string; + iconURL?: string | null; statuses?: Record; }, ) { diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 1057fba..846043a 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -50,6 +50,7 @@ const main = async () => { "/organisation/by-id": withCors(withAuth(routes.organisationById)), "/organisation/update": withCors(withAuth(withCSRF(routes.organisationUpdate))), "/organisation/delete": withCors(withAuth(withCSRF(routes.organisationDelete))), + "/organisation/upload-icon": withCors(withAuth(withCSRF(routes.organisationUploadIcon))), "/organisation/add-member": withCors(withAuth(withCSRF(routes.organisationAddMember))), "/organisation/members": withCors(withAuth(routes.organisationMembers)), "/organisation/remove-member": withCors(withAuth(withCSRF(routes.organisationRemoveMember))), diff --git a/packages/backend/src/routes/index.ts b/packages/backend/src/routes/index.ts index 0efe957..00b450e 100644 --- a/packages/backend/src/routes/index.ts +++ b/packages/backend/src/routes/index.ts @@ -18,6 +18,7 @@ import organisationMembers from "./organisation/members"; import organisationRemoveMember from "./organisation/remove-member"; import organisationUpdate from "./organisation/update"; import organisationUpdateMemberRole from "./organisation/update-member-role"; +import organisationUploadIcon from "./organisation/upload-icon"; import projectsAll from "./project/all"; import projectsByCreator from "./project/by-creator"; import projectsByOrganisation from "./project/by-organisation"; @@ -66,6 +67,7 @@ export const routes = { organisationMembers, organisationRemoveMember, organisationUpdateMemberRole, + organisationUploadIcon, organisationsByUser, diff --git a/packages/backend/src/routes/organisation/update.ts b/packages/backend/src/routes/organisation/update.ts index 35b8639..8601de4 100644 --- a/packages/backend/src/routes/organisation/update.ts +++ b/packages/backend/src/routes/organisation/update.ts @@ -7,7 +7,7 @@ export default async function organisationUpdate(req: AuthedRequest) { const parsed = await parseJsonBody(req, OrgUpdateRequestSchema); if ("error" in parsed) return parsed.error; - const { id, name, description, slug, statuses } = parsed.data; + const { id, name, description, slug, iconURL, statuses } = parsed.data; const existingOrganisation = await getOrganisationById(id); if (!existingOrganisation) { @@ -22,9 +22,9 @@ export default async function organisationUpdate(req: AuthedRequest) { return errorResponse("only owners and admins can edit organisations", "PERMISSION_DENIED", 403); } - if (!name && !description && !slug && !statuses) { + if (!name && !description && !slug && !statuses && iconURL === undefined) { return errorResponse( - "at least one of name, description, slug, or statuses must be provided", + "at least one of name, description, slug, iconURL, or statuses must be provided", "NO_UPDATES", 400, ); @@ -34,6 +34,7 @@ export default async function organisationUpdate(req: AuthedRequest) { name, description, slug, + iconURL, statuses, }); diff --git a/packages/backend/src/routes/organisation/upload-icon.ts b/packages/backend/src/routes/organisation/upload-icon.ts new file mode 100644 index 0000000..5dbc1c7 --- /dev/null +++ b/packages/backend/src/routes/organisation/upload-icon.ts @@ -0,0 +1,96 @@ +import { randomUUID } from "node:crypto"; +import sharp from "sharp"; +import type { AuthedRequest } from "../../auth/middleware"; +import { getOrganisationById, getOrganisationMemberRole, updateOrganisation } from "../../db/queries"; +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 TARGET_SIZE = 256; + +export default async function uploadOrganisationIcon(req: AuthedRequest) { + if (req.method !== "POST") { + return new Response("method not allowed", { status: 405 }); + } + + const formData = await req.formData(); + const file = formData.get("file") as File | null; + const organisationIdStr = formData.get("organisationId") as string | null; + + if (!file) { + return new Response("file is required", { status: 400 }); + } + + if (!organisationIdStr) { + return new Response("organisationId is required", { status: 400 }); + } + + const organisationId = Number.parseInt(organisationIdStr, 10); + if (Number.isNaN(organisationId) || organisationId <= 0) { + return new Response("organisationId must be a positive integer", { status: 400 }); + } + + const organisation = await getOrganisationById(organisationId); + if (!organisation) { + return new Response("organisation not found", { status: 404 }); + } + + const member = await getOrganisationMemberRole(organisationId, req.userId); + if (!member) { + return new Response("you are not a member of this organisation", { status: 403 }); + } + if (member.role !== "owner" && member.role !== "admin") { + return new Response("only owners and admins can upload organisation icons", { status: 403 }); + } + + if (file.size > MAX_FILE_SIZE) { + return new Response("file size exceeds 5MB limit", { status: 400 }); + } + + if (!ALLOWED_TYPES.includes(file.type)) { + return new Response("invalid file type. Allowed types: png, jpg, jpeg, webp, gif", { + 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 = `org-icons/${uuid}.${outputExtension}`; + const publicUrlBase = s3PublicUrl || s3Endpoint; + const publicUrl = `${publicUrlBase}/${key}`; + + try { + const s3File = s3Client.file(key); + 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 }); + } + + await updateOrganisation(organisationId, { iconURL: publicUrl }); + + return Response.json({ iconURL: publicUrl }); +} diff --git a/packages/frontend/src/components/upload-org-icon.tsx b/packages/frontend/src/components/upload-org-icon.tsx new file mode 100644 index 0000000..d309d56 --- /dev/null +++ b/packages/frontend/src/components/upload-org-icon.tsx @@ -0,0 +1,92 @@ +import { useRef, useState } from "react"; +import { toast } from "sonner"; +import OrgIcon from "@/components/org-icon"; +import { Button } from "@/components/ui/button"; +import Icon from "@/components/ui/icon"; +import { Label } from "@/components/ui/label"; +import { useUploadOrganisationIcon } from "@/lib/query/hooks"; +import { parseError } from "@/lib/server"; +import { cn } from "@/lib/utils"; + +export function UploadOrgIcon({ + name, + slug, + iconURL, + organisationId, + onIconUploaded, + className, +}: { + name: string; + slug: string; + iconURL?: string | null; + organisationId: number; + onIconUploaded: (iconURL: string) => void; + className?: string; +}) { + const [uploading, setUploading] = useState(false); + const [error, setError] = useState(null); + const fileInputRef = useRef(null); + const [showEdit, setShowEdit] = useState(false); + const uploadIcon = useUploadOrganisationIcon(); + + const handleFileChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + setUploading(true); + setError(null); + + try { + const url = await uploadIcon.mutateAsync({ file, organisationId }); + onIconUploaded(url); + setUploading(false); + + toast.success( +
+ Organisation icon + Organisation icon uploaded successfully +
, + { + dismissible: false, + }, + ); + } catch (err) { + const message = parseError(err as Error); + setError(message); + setUploading(false); + + toast.error(`Error uploading icon: ${message}`, { + dismissible: false, + }); + } + }; + + return ( +
+ + + {error && } +
+ ); +} diff --git a/packages/frontend/src/lib/query/hooks/organisations.ts b/packages/frontend/src/lib/query/hooks/organisations.ts index 412d3bb..5468ed6 100644 --- a/packages/frontend/src/lib/query/hooks/organisations.ts +++ b/packages/frontend/src/lib/query/hooks/organisations.ts @@ -99,3 +99,15 @@ export function useUpdateOrganisationMemberRole() { }, }); } + +export function useUploadOrganisationIcon() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: ["organisations", "upload-icon"], + mutationFn: ({ file, organisationId }) => organisation.uploadIcon(file, organisationId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.organisations.byUser() }); + }, + }); +} diff --git a/packages/frontend/src/lib/server/organisation/uploadIcon.ts b/packages/frontend/src/lib/server/organisation/uploadIcon.ts new file mode 100644 index 0000000..eeda919 --- /dev/null +++ b/packages/frontend/src/lib/server/organisation/uploadIcon.ts @@ -0,0 +1,42 @@ +import { getCsrfToken, getServerURL } from "@/lib/utils"; +import { getErrorMessage } from ".."; + +export async function uploadIcon(file: File, organisationId: number): Promise { + const MAX_FILE_SIZE = 5 * 1024 * 1024; + const ALLOWED_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"]; + + if (file.size > MAX_FILE_SIZE) { + throw new Error("File size exceeds 5MB limit"); + } + + if (!ALLOWED_TYPES.includes(file.type)) { + throw new Error("Invalid file type. Allowed types: png, jpg, jpeg, webp, gif"); + } + + const formData = new FormData(); + formData.append("file", file); + formData.append("organisationId", organisationId.toString()); + + const csrfToken = getCsrfToken(); + const headers: HeadersInit = {}; + if (csrfToken) headers["X-CSRF-Token"] = csrfToken; + + const res = await fetch(`${getServerURL()}/organisation/upload-icon`, { + method: "POST", + headers, + body: formData, + credentials: "include", + }); + + if (!res.ok) { + const message = await getErrorMessage(res, `Failed to upload icon (${res.status})`); + throw new Error(message); + } + + const data = await res.json(); + if (data.iconURL) { + return data.iconURL; + } + + throw new Error("Failed to upload icon"); +}