diff --git a/apps/web/src/components/Avatar.tsx b/apps/web/src/components/Avatar.tsx index 0abcbb17..a04ede04 100644 --- a/apps/web/src/components/Avatar.tsx +++ b/apps/web/src/components/Avatar.tsx @@ -25,7 +25,7 @@ const Avatar = ({ icon?: React.ReactNode; isLoading?: boolean; }) => { - const initials = name + const initials = name?.trim() ? getInitialsFromName(name) : inferInitialsFromEmail(email); diff --git a/apps/web/src/components/Dashboard.tsx b/apps/web/src/components/Dashboard.tsx index b35b12b0..e541d402 100644 --- a/apps/web/src/components/Dashboard.tsx +++ b/apps/web/src/components/Dashboard.tsx @@ -12,6 +12,7 @@ import { authClient } from "@kan/auth/client"; import { useClickOutside } from "~/hooks/useClickOutside"; import { useModal } from "~/providers/modal"; import { useWorkspace, WorkspaceProvider } from "~/providers/workspace"; +import { api } from "~/utils/api"; import SideNavigation from "./SideNavigation"; interface DashboardProps { @@ -44,6 +45,12 @@ export default function Dashboard({ const { availableWorkspaces, hasLoaded } = useWorkspace(); const { data: session, isPending: sessionLoading } = authClient.useSession(); + const { data: user, isLoading: userLoading } = api.user.getUser.useQuery( + undefined, + { + enabled: !!session?.user, + }, + ); const [isSideNavOpen, setIsSideNavOpen] = useState(false); const [isRightPanelOpen, setIsRightPanelOpen] = useState(false); @@ -155,8 +162,12 @@ export default function Dashboard({ className={`fixed top-12 z-40 h-[calc(100dvh-3rem)] w-[calc(100vw-1.5rem)] transform transition-transform duration-300 ease-in-out md:relative md:top-0 md:h-full md:w-auto md:translate-x-0 ${isSideNavOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"} `} > diff --git a/apps/web/src/components/Tooltip.tsx b/apps/web/src/components/Tooltip.tsx index 115ee44a..e8eff111 100644 --- a/apps/web/src/components/Tooltip.tsx +++ b/apps/web/src/components/Tooltip.tsx @@ -7,7 +7,7 @@ import tippy from "tippy.js"; interface TooltipProps { children: ReactNode; - content: ReactNode; + content?: ReactNode; placement?: Placement; delay?: number | [number, number]; } @@ -24,6 +24,8 @@ export function Tooltip({ useEffect(() => { if (!triggerRef.current) return; + if (!content) return; + const container = document.createElement("div"); const root = createRoot(container); rootRef.current = root; diff --git a/apps/web/src/pages/api/upload/attachment.ts b/apps/web/src/pages/api/upload/attachment.ts new file mode 100644 index 00000000..8d22703d --- /dev/null +++ b/apps/web/src/pages/api/upload/attachment.ts @@ -0,0 +1,128 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { PutObjectCommand } from "@aws-sdk/client-s3"; + +import { createNextApiContext } from "@kan/api/trpc"; +import * as cardRepo from "@kan/db/repository/card.repo"; +import * as cardActivityRepo from "@kan/db/repository/cardActivity.repo"; +import * as cardAttachmentRepo from "@kan/db/repository/cardAttachment.repo"; +import { generateUID } from "@kan/shared/utils"; + +import { env } from "~/env"; +import { withRateLimit } from "@kan/api/utils/rateLimit"; +import { createS3Client } from "@kan/shared/utils"; +import { assertPermission } from "@kan/api/utils/permissions"; + +const MAX_SIZE_BYTES = 50 * 1024 * 1024; // 50MB + +export const config = { + api: { + bodyParser: false, + }, +}; + +export default withRateLimit( + { points: 100, duration: 60 }, + async (req: NextApiRequest, res: NextApiResponse) => { + if (req.method !== "POST") { + return res.status(405).json({ error: "Method not allowed" }); + } + + try { + const { user, db } = await createNextApiContext(req); + + if (!user) { + return res.status(401).json({ error: "Unauthorized" }); + } + + const bucket = env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME; + if (!bucket) { + return res.status(500).json({ error: "Attachments bucket not configured" }); + } + + const cardPublicId = req.query.cardPublicId; + if (typeof cardPublicId !== "string" || cardPublicId.length < 12) { + return res.status(400).json({ error: "Invalid cardPublicId" }); + } + + const contentType = req.headers["content-type"]; + const contentLengthHeader = req.headers["content-length"]; + const contentLength = contentLengthHeader + ? Number.parseInt(contentLengthHeader, 10) + : NaN; + + if (typeof contentType !== "string") { + return res.status(400).json({ error: "Missing content type" }); + } + + if (!Number.isFinite(contentLength) || contentLength <= 0) { + return res.status(400).json({ error: "Missing or invalid content length" }); + } + + if (contentLength > MAX_SIZE_BYTES) { + return res.status(400).json({ error: "File too large" }); + } + + const originalFilenameHeader = + (req.headers["x-original-filename"] as string | undefined) ?? "file"; + + const sanitizedFilename = originalFilenameHeader + .replace(/[^a-zA-Z0-9._-]/g, "_") + .substring(0, 200); + + // Get card and check permissions + const card = await cardRepo.getWorkspaceAndCardIdByCardPublicId( + db, + cardPublicId, + ); + + if (!card) { + return res.status(404).json({ error: "Card not found" }); + } + + // Check if user has permission to edit the card + try { + await assertPermission(db, user.id, card.workspaceId, "card:edit"); + } catch { + return res.status(403).json({ error: "Permission denied" }); + } + + const s3Key = `${card.workspaceId}/${cardPublicId}/${generateUID()}-${sanitizedFilename}`; + + const client = createS3Client(); + + // Upload the file to S3 + await client.send( + new PutObjectCommand({ + Bucket: bucket, + Key: s3Key, + Body: req, + ContentType: contentType, + ContentLength: contentLength, + }), + ); + + // Create attachment record and log activity + const attachment = await cardAttachmentRepo.create(db, { + cardId: card.id, + filename: sanitizedFilename, + originalFilename: originalFilenameHeader, + contentType, + size: contentLength, + s3Key, + createdBy: user.id, + }); + + await cardActivityRepo.create(db, { + type: "card.updated.attachment.added", + cardId: card.id, + createdBy: user.id, + }); + + return res.status(200).json({ attachment }); + } catch (error) { + console.error("Attachment upload failed", error); + return res.status(500).json({ error: "Internal server error" }); + } + }, +); + diff --git a/apps/web/src/pages/api/upload/avatar.ts b/apps/web/src/pages/api/upload/avatar.ts new file mode 100644 index 00000000..de3508bf --- /dev/null +++ b/apps/web/src/pages/api/upload/avatar.ts @@ -0,0 +1,101 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { PutObjectCommand } from "@aws-sdk/client-s3"; + +import { createNextApiContext } from "@kan/api/trpc"; +import * as userRepo from "@kan/db/repository/user.repo"; + +import { env } from "~/env"; +import { withRateLimit } from "@kan/api/utils/rateLimit"; +import { createS3Client } from "@kan/shared/utils"; + +const MAX_SIZE_BYTES = 2 * 1024 * 1024; // 2MB +const allowedContentTypes = ["image/jpeg", "image/png", "image/webp"]; + +export const config = { + api: { + bodyParser: false, + }, +}; + +export default withRateLimit( + { points: 100, duration: 60 }, + async (req: NextApiRequest, res: NextApiResponse) => { + if (req.method !== "POST") { + return res.status(405).json({ error: "Method not allowed" }); + } + + try { + const { user, db } = await createNextApiContext(req); + + if (!user) { + return res.status(401).json({ error: "Unauthorized" }); + } + + const bucket = env.NEXT_PUBLIC_AVATAR_BUCKET_NAME; + if (!bucket) { + return res.status(500).json({ error: "Avatar bucket not configured" }); + } + + const contentType = req.headers["content-type"]; + const contentLengthHeader = req.headers["content-length"]; + const contentLength = contentLengthHeader + ? Number.parseInt(contentLengthHeader, 10) + : NaN; + + if (typeof contentType !== "string") { + return res.status(400).json({ error: "Missing content type" }); + } + + if (!allowedContentTypes.includes(contentType)) { + return res.status(400).json({ error: "Invalid content type" }); + } + + if (!Number.isFinite(contentLength) || contentLength <= 0) { + return res.status(400).json({ error: "Missing or invalid content length" }); + } + + if (contentLength > MAX_SIZE_BYTES) { + return res.status(400).json({ error: "File too large" }); + } + + const originalFilenameHeader = + (req.headers["x-original-filename"] as string | undefined) ?? "file"; + + const sanitizedFilename = originalFilenameHeader + .replace(/[^a-zA-Z0-9._-]/g, "_") + .substring(0, 200); + + const s3Key = `${user.id}/${sanitizedFilename}`; + + const client = createS3Client(); + + // Upload the file to S3 + await client.send( + new PutObjectCommand({ + Bucket: bucket, + Key: s3Key, + Body: req, + ContentType: contentType, + ContentLength: contentLength, + }), + ); + + // Update user image in database + const updatedUser = await userRepo.update(db, user.id, { + image: s3Key, + }); + + return res.status(200).json({ + key: s3Key, + filename: sanitizedFilename, + contentType, + size: contentLength, + user: updatedUser, + }); + } catch (error) { + console.error("Avatar upload failed", error); + return res.status(500).json({ error: "Internal server error" }); + } + }, +); + diff --git a/apps/web/src/pages/api/upload/image.ts b/apps/web/src/pages/api/upload/image.ts deleted file mode 100644 index 1ead915c..00000000 --- a/apps/web/src/pages/api/upload/image.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from "next"; -import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; -import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; -import { env as nextRuntimeEnv } from "next-runtime-env"; - -import { createNextApiContext } from "@kan/api/trpc"; - -import { env } from "~/env"; -import { withRateLimit } from "@kan/api/utils/rateLimit"; - -const allowedContentTypes = ["image/jpeg", "image/png"]; - -export default withRateLimit( - { points: 100, duration: 60 }, - async (req: NextApiRequest, res: NextApiResponse) => { - if (req.method !== "POST") { - return res.status(405).json({ error: "Method not allowed" }); - } - - try { - const { user } = await createNextApiContext(req); - - if (!user) { - return res.status(401).json({ error: "Unauthorized" }); - } - - const { filename, contentType } = req.body as { - filename: string; - contentType: string; - }; - - // Specific to avatar uploads for now - const filenameRegex = /^[a-f0-9\-]+\/[a-zA-Z0-9_\-]+(\.jpg|\.jpeg|\.png)$/; - - if (!filenameRegex.test(filename)) { - return res.status(400).json({ error: "Invalid filename" }); - } - - if ( - typeof contentType !== "string" || - !allowedContentTypes.includes(contentType) - ) { - return res.status(400).json({ error: "Invalid content type" }); - } - - const credentials = - env.S3_ACCESS_KEY_ID && env.S3_SECRET_ACCESS_KEY - ? { - accessKeyId: env.S3_ACCESS_KEY_ID, - secretAccessKey: env.S3_SECRET_ACCESS_KEY, - } - : undefined; - - const client = new S3Client({ - region: env.S3_REGION ?? "", - endpoint: env.S3_ENDPOINT ?? "", - forcePathStyle: env.S3_FORCE_PATH_STYLE === "true", - credentials, - }); - - const signedUrl = await getSignedUrl( - client, - new PutObjectCommand({ - Bucket: nextRuntimeEnv("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "", - Key: filename, - ACL: "public-read", - }), - ); - - return res.status(200).json({ url: signedUrl, key: filename }); - } catch (error) { - return res.status(500).json({ error: (error as Error).message }); - } - }, -); diff --git a/apps/web/src/utils/helpers.ts b/apps/web/src/utils/helpers.ts index 25defca5..648bc081 100644 --- a/apps/web/src/utils/helpers.ts +++ b/apps/web/src/utils/helpers.ts @@ -1,5 +1,3 @@ -import { env } from "next-runtime-env"; - export const formatToArray = ( value: string | string[] | undefined, ): string[] => { @@ -52,14 +50,5 @@ export const getAvatarUrl = (imageOrKey: string | null) => { return imageOrKey; } - const bucket = env("NEXT_PUBLIC_AVATAR_BUCKET_NAME"); - const useVirtualHostedUrls = env("NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS"); - const storageDomain = env("NEXT_PUBLIC_STORAGE_DOMAIN"); - - if (useVirtualHostedUrls === "true" && storageDomain) { - return `https://${bucket}.${storageDomain}/${imageOrKey}`; - } - - const storageUrl = env("NEXT_PUBLIC_STORAGE_URL"); - return `${storageUrl}/${bucket}/${imageOrKey}`; + return ""; }; diff --git a/apps/web/src/views/card/components/AttachmentUpload.tsx b/apps/web/src/views/card/components/AttachmentUpload.tsx index a0530a8b..3547bbc1 100644 --- a/apps/web/src/views/card/components/AttachmentUpload.tsx +++ b/apps/web/src/views/card/components/AttachmentUpload.tsx @@ -7,6 +7,7 @@ import { twMerge } from "tailwind-merge"; import Button from "~/components/Button"; import { useModal } from "~/providers/modal"; import { usePopup } from "~/providers/popup"; +import { env } from "next-runtime-env"; import { api } from "~/utils/api"; import { invalidateCard } from "~/utils/cardInvalidation"; @@ -18,62 +19,33 @@ export function AttachmentUpload({ cardPublicId }: { cardPublicId: string }) { const [isDragging, setIsDragging] = useState(false); const inputRef = useRef(null); - const generateUploadUrl = api.attachment.generateUploadUrl.useMutation(); - const confirmAttachment = api.attachment.confirm.useMutation({ - onSuccess: async () => { + const uploadFile = async (file: File) => { + setUploading(true); + + try { + const baseUrl = env("NEXT_PUBLIC_BASE_URL") ?? ""; + const response = await fetch( + `${baseUrl}/api/upload/attachment?cardPublicId=${encodeURIComponent(cardPublicId)}`, + { + method: "POST", + headers: { + "Content-Type": file.type, + "x-original-filename": file.name, + }, + body: file, + }, + ); + + if (!response.ok) { + throw new Error("Upload failed"); + } + await invalidateCard(utils, cardPublicId); showPopup({ header: t`Attachment uploaded`, message: t`Your file has been uploaded successfully.`, icon: "success", }); - }, - onError: () => { - showPopup({ - header: t`Upload failed`, - message: t`Failed to upload attachment. Please try again.`, - icon: "error", - }); - }, - onSettled: () => { - setUploading(false); - }, - }); - - const uploadFile = async (file: File) => { - setUploading(true); - - try { - // Generate presigned URL - const { url, key } = await generateUploadUrl.mutateAsync({ - cardPublicId, - filename: file.name, - contentType: file.type, - size: file.size, - }); - - // Upload file to S3 - const uploadResponse = await fetch(url, { - method: "PUT", - body: file, - headers: { - "Content-Type": file.type, - }, - }); - - if (!uploadResponse.ok) { - throw new Error("Upload failed"); - } - - // Confirm attachment in database - await confirmAttachment.mutateAsync({ - cardPublicId, - s3Key: key, - filename: file.name, - originalFilename: file.name, - contentType: file.type, - size: file.size, - }); } catch { showPopup({ header: t`Upload failed`, diff --git a/apps/web/src/views/members/index.tsx b/apps/web/src/views/members/index.tsx index 035be336..0b6d138b 100644 --- a/apps/web/src/views/members/index.tsx +++ b/apps/web/src/views/members/index.tsx @@ -126,7 +126,6 @@ export default function MembersPage() { name={memberName ?? ""} email={memberEmail ?? ""} imageUrl={memberImage ? getAvatarUrl(memberImage) : undefined} - icon={showPendingIcon ? "?" : undefined} /> )} diff --git a/apps/web/src/views/settings/components/Avatar.tsx b/apps/web/src/views/settings/components/Avatar.tsx index 3635f733..4e068eb8 100644 --- a/apps/web/src/views/settings/components/Avatar.tsx +++ b/apps/web/src/views/settings/components/Avatar.tsx @@ -58,28 +58,6 @@ export default function Avatar({ const [crop, setCrop] = useState(); const imgRef = useRef(null); - const updateUser = api.user.update.useMutation({ - onSuccess: async () => { - showPopup({ - header: t`Profile image updated`, - message: t`Your profile image has been updated.`, - icon: "success", - }); - try { - await utils.user.getUser.refetch(); - } catch (e) { - console.error(e); - throw e; - } - }, - onError: () => { - showPopup({ - header: t`Error updating profile image`, - message: t`Please try again later, or contact customer support.`, - icon: "error", - }); - }, - }); const avatarUrl = userImage ? getAvatarUrl(userImage) : undefined; @@ -187,29 +165,32 @@ export default function Avatar({ const originalExt = selectedFile.name.split(".").pop() ?? "jpg"; const fileName = `${userId}/avatar-${generateUID()}.${originalExt}`; + const baseUrl = env("NEXT_PUBLIC_BASE_URL") ?? ""; const response = await fetch( - env("NEXT_PUBLIC_BASE_URL") + "/api/upload/image", + `${baseUrl}/api/upload/avatar`, { method: "POST", headers: { - "Content-Type": "application/json", + "Content-Type": blob.type, + "x-original-filename": fileName, }, - body: JSON.stringify({ filename: fileName, contentType: blob.type }), + body: blob, }, ); - if (!response.ok) throw new Error("Failed to get pre-signed URL"); + if (!response.ok) { + throw new Error("Failed to upload profile image"); + } - const { url } = (await response.json()) as { url: string }; - - const uploadResponse = await fetch(url, { - method: "PUT", - body: blob, + // User image is updated in the backend, refresh user data + await utils.user.getUser.refetch(); + + showPopup({ + header: t`Profile image updated`, + message: t`Your profile image has been updated.`, + icon: "success", }); - - if (!uploadResponse.ok) throw new Error("Failed to upload profile image"); - - updateUser.mutate({ image: fileName }); + setCropDialogOpen(false); resetCropState(); } catch (error) { @@ -227,7 +208,7 @@ export default function Avatar({ resetCropState, selectedFile, showPopup, - updateUser, + utils.user.getUser, userId, ]); diff --git a/packages/api/package.json b/packages/api/package.json index 292fad98..30bdb7a1 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -27,6 +27,10 @@ "./utils/rateLimit": { "types": "./dist/utils/rateLimit.d.ts", "default": "./src/utils/rateLimit.ts" + }, + "./utils/permissions": { + "types": "./dist/utils/permissions.d.ts", + "default": "./src/utils/permissions.ts" } }, "license": "GPL-3.0", @@ -39,8 +43,6 @@ "typecheck": "tsc --noEmit --emitDeclarationOnly false" }, "dependencies": { - "@aws-sdk/client-s3": "^3.802.0", - "@aws-sdk/s3-request-presigner": "^3.812.0", "@kan/auth": "workspace:*", "@kan/db": "workspace:*", "@kan/email": "workspace:^", diff --git a/packages/api/src/routers/attachment.ts b/packages/api/src/routers/attachment.ts index 571670ae..b75ece81 100644 --- a/packages/api/src/routers/attachment.ts +++ b/packages/api/src/routers/attachment.ts @@ -9,7 +9,7 @@ import { generateUID } from "@kan/shared/utils"; import { createTRPCRouter, protectedProcedure } from "../trpc"; import { assertPermission } from "../utils/permissions"; -import { deleteObject, generateUploadUrl } from "../utils/s3"; +import { deleteObject, generateUploadUrl } from "@kan/shared/utils"; export const attachmentRouter = createTRPCRouter({ generateUploadUrl: protectedProcedure diff --git a/packages/api/src/routers/board.ts b/packages/api/src/routers/board.ts index 320d09e0..8180a7b7 100644 --- a/packages/api/src/routers/board.ts +++ b/packages/api/src/routers/board.ts @@ -10,6 +10,7 @@ import * as workspaceRepo from "@kan/db/repository/workspace.repo"; import { colours } from "@kan/shared/constants"; import { convertDueDateFiltersToRanges, + generateAvatarUrl, generateSlug, generateUID, } from "@kan/shared/utils"; @@ -142,7 +143,33 @@ export const boardRouter = createTRPCRouter({ }, ); - return result; + // Generate presigned URLs for workspace member avatars + const workspaceWithAvatarUrls = result.workspace + ? { + ...result.workspace, + members: await Promise.all( + result.workspace.members.map(async (member) => { + if (!member.user?.image) { + return member; + } + + const avatarUrl = await generateAvatarUrl(member.user.image); + return { + ...member, + user: { + ...member.user, + image: avatarUrl, + }, + }; + }), + ), + } + : result.workspace; + + return { + ...result, + workspace: workspaceWithAvatarUrls, + }; }), bySlug: publicProcedure .meta({ diff --git a/packages/api/src/routers/card.ts b/packages/api/src/routers/card.ts index e4dfa631..b39256c5 100644 --- a/packages/api/src/routers/card.ts +++ b/packages/api/src/routers/card.ts @@ -11,7 +11,7 @@ import * as workspaceRepo from "@kan/db/repository/workspace.repo"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; import { mergeActivities } from "../utils/activities"; import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions"; -import { generateDownloadUrl } from "../utils/s3"; +import { generateAttachmentUrl, generateAvatarUrl } from "@kan/shared/utils"; export const cardRouter = createTRPCRouter({ create: protectedProcedure @@ -631,45 +631,54 @@ export const cardRouter = createTRPCRouter({ }); // Generate URLs for all attachments - const bucket = process.env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME; - if (result.attachments && Array.isArray(result.attachments)) { - const attachments = result.attachments as { - publicId: string; - contentType: string; - s3Key: string; - originalFilename: string | null; - size?: number | null; - }[]; + const attachmentsWithUrls = await Promise.all( + result.attachments.map(async (attachment) => { + const url = await generateAttachmentUrl(attachment.s3Key); + return { + publicId: attachment.publicId, + contentType: attachment.contentType, + s3Key: attachment.s3Key, + originalFilename: attachment.originalFilename, + size: attachment.size, + url, + }; + }), + ); - const attachmentsWithUrls = await Promise.all( - attachments.map(async (attachment) => { - const base = { - publicId: attachment.publicId, - contentType: attachment.contentType, - s3Key: attachment.s3Key, - originalFilename: attachment.originalFilename, - size: attachment.size, - }; - if (!bucket || !attachment.s3Key) { - return { ...base, url: null }; - } - try { - const url = await generateDownloadUrl( - bucket, - attachment.s3Key, - 86400, // 24 hours expiration - ); - return { ...base, url }; - } catch { - // If URL generation fails, return attachment with url: null - return { ...base, url: null }; - } - }), - ); - return { ...result, attachments: attachmentsWithUrls }; - } + // Generate presigned URLs for workspace member avatars + const workspaceWithAvatarUrls = result.list.board.workspace + ? { + ...result.list.board.workspace, + members: await Promise.all( + result.list.board.workspace.members.map(async (member) => { + if (!member.user?.image) { + return member; + } - return { ...result, attachments: [] }; + const avatarUrl = await generateAvatarUrl(member.user.image); + return { + ...member, + user: { + ...member.user, + image: avatarUrl, + }, + }; + }), + ), + } + : result.list.board.workspace; + + return { + ...result, + attachments: attachmentsWithUrls, + list: { + ...result.list, + board: { + ...result.list.board, + workspace: workspaceWithAvatarUrls, + }, + }, + }; }), getActivities: publicProcedure .meta({ @@ -738,7 +747,39 @@ export const cardRouter = createTRPCRouter({ }, ); - const mergedActivities = mergeActivities(result.activities); + // Generate presigned URLs for user avatars in activities + const activitiesWithAvatarUrls = await Promise.all( + result.activities.map(async (activity) => { + const updatedActivity = { ...activity }; + + // Generate presigned URL for activity user avatar + if (activity.user?.image) { + const userAvatarUrl = await generateAvatarUrl(activity.user.image); + updatedActivity.user = { + ...activity.user, + image: userAvatarUrl, + }; + } + + // Generate presigned URL for member user avatar (if exists) + if (activity.member?.user?.image) { + const memberAvatarUrl = await generateAvatarUrl( + activity.member.user.image, + ); + updatedActivity.member = { + ...activity.member, + user: { + ...activity.member.user, + image: memberAvatarUrl, + }, + }; + } + + return updatedActivity; + }), + ); + + const mergedActivities = mergeActivities(activitiesWithAvatarUrls); return { activities: mergedActivities, diff --git a/packages/api/src/routers/health.ts b/packages/api/src/routers/health.ts index 1cb0d03f..b3082c5b 100644 --- a/packages/api/src/routers/health.ts +++ b/packages/api/src/routers/health.ts @@ -24,7 +24,7 @@ import { createTRPCRouter, publicProcedure, } from "../trpc"; -import { createS3Client } from "../utils/s3"; +import { createS3Client } from "@kan/shared/utils"; const checkDatabaseConnection = async (db: dbClient) => { try { diff --git a/packages/api/src/routers/user.ts b/packages/api/src/routers/user.ts index 88b4235c..262b5c96 100644 --- a/packages/api/src/routers/user.ts +++ b/packages/api/src/routers/user.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import * as userRepo from "@kan/db/repository/user.repo"; import { createTRPCRouter, protectedProcedure } from "../trpc"; +import { generateAvatarUrl } from "@kan/shared/utils"; export const userRouter = createTRPCRouter({ getUser: protectedProcedure @@ -55,8 +56,12 @@ export const userRouter = createTRPCRouter({ const apiKey = result.apiKeys[0]; + // Generate presigned URL for avatar + const imageUrl = await generateAvatarUrl(result.image); + return { ...result, + image: imageUrl, apiKey: apiKey ?? null, }; }), @@ -102,6 +107,12 @@ export const userRouter = createTRPCRouter({ }); } - return result; + // Generate presigned URL for avatar + const imageUrl = await generateAvatarUrl(result.image); + + return { + ...result, + image: imageUrl, + }; }), }); diff --git a/packages/api/src/routers/workspace.ts b/packages/api/src/routers/workspace.ts index 99102dd3..48c9d8cb 100644 --- a/packages/api/src/routers/workspace.ts +++ b/packages/api/src/routers/workspace.ts @@ -8,6 +8,7 @@ import { generateUID } from "@kan/shared/utils"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; import { assertPermission } from "../utils/permissions"; +import { generateAvatarUrl } from "@kan/shared/utils"; export const workspaceRouter = createTRPCRouter({ all: protectedProcedure @@ -86,9 +87,27 @@ export const workspaceRouter = createTRPCRouter({ const shouldShowEmails = isAdmin || result.showEmailsToMembers === true; + // Generate presigned URLs for member avatars + const membersWithAvatarUrls = await Promise.all( + result.members.map(async (member) => { + if (!member.user?.image) { + return member; + } + + const avatarUrl = await generateAvatarUrl(member.user.image); + return { + ...member, + user: { + ...member.user, + image: avatarUrl, + }, + }; + }), + ); + // If emails should be hidden, filter them out if (!shouldShowEmails) { - const sanitizedMembers = result.members.map((member) => { + const sanitizedMembers = membersWithAvatarUrls.map((member) => { // If user doesn't have a display name, use anonymous identifier const displayName = member.user?.name?.trim() ?? `anonymous_${member.publicId}`; @@ -120,7 +139,10 @@ export const workspaceRouter = createTRPCRouter({ } as Awaited>; } - return result; + return { + ...result, + members: membersWithAvatarUrls, + }; }), bySlug: publicProcedure .meta({ diff --git a/packages/auth/src/hooks.ts b/packages/auth/src/hooks.ts index c357681f..246fb1f8 100644 --- a/packages/auth/src/hooks.ts +++ b/packages/auth/src/hooks.ts @@ -1,4 +1,4 @@ -import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; +import { PutObjectCommand } from "@aws-sdk/client-s3"; import { ChatOrPushProviderEnum } from "@novu/api/models/components"; import { createAuthMiddleware } from "better-auth/api"; import { env } from "next-runtime-env"; @@ -7,7 +7,7 @@ import type { dbClient } from "@kan/db/client"; import * as memberRepo from "@kan/db/repository/member.repo"; import * as userRepo from "@kan/db/repository/user.repo"; import { notificationClient } from "@kan/email"; -import { createEmailUnsubscribeLink } from "@kan/shared"; +import { createEmailUnsubscribeLink, createS3Client } from "@kan/shared"; import { downloadImage } from "./utils"; @@ -61,20 +61,7 @@ export function createDatabaseHooks(db: dbClient) { !user.image.includes(storageDomain) ) { try { - const credentials = - env("S3_ACCESS_KEY_ID") && env("S3_SECRET_ACCESS_KEY") - ? { - accessKeyId: env("S3_ACCESS_KEY_ID")!, - secretAccessKey: env("S3_SECRET_ACCESS_KEY")!, - } - : undefined; - - const client = new S3Client({ - region: env("S3_REGION") ?? "", - endpoint: env("S3_ENDPOINT") ?? "", - forcePathStyle: env("S3_FORCE_PATH_STYLE") === "true", - credentials, - }); + const client = createS3Client(); const allowedFileExtensions = ["jpg", "jpeg", "png", "webp"]; diff --git a/packages/shared/package.json b/packages/shared/package.json index fa6f0012..0d5fdfce 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -36,6 +36,8 @@ }, "prettier": "@kan/prettier-config", "dependencies": { + "@aws-sdk/client-s3": "^3.802.0", + "@aws-sdk/s3-request-presigner": "^3.812.0", "date-fns": "^4.1.0", "jose": "^6.1.2", "nanoid": "^5.0.9", diff --git a/packages/shared/src/utils/index.ts b/packages/shared/src/utils/index.ts index f108497f..0e6ab7d1 100644 --- a/packages/shared/src/utils/index.ts +++ b/packages/shared/src/utils/index.ts @@ -3,3 +3,4 @@ export * from "./generateSlug"; export * from "./subscriptions"; export * from "./email"; export * from "./dueDateFilters"; +export * from "./s3"; diff --git a/packages/api/src/utils/s3.ts b/packages/shared/src/utils/s3.ts similarity index 50% rename from packages/api/src/utils/s3.ts rename to packages/shared/src/utils/s3.ts index 8d78d55c..a90fb885 100644 --- a/packages/api/src/utils/s3.ts +++ b/packages/shared/src/utils/s3.ts @@ -5,6 +5,7 @@ import { S3Client, } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; +import { env } from "next-runtime-env"; export function createS3Client() { const credentials = @@ -67,3 +68,60 @@ export async function deleteObject(bucket: string, key: string) { }), ); } + +/** + * Generate presigned URL for an avatar image + * Returns the URL as-is if it's already a full URL (external provider) + * Returns presigned URL if it's an S3 key + * Returns null if image key is missing, bucket is not configured, or URL generation fails + */ +export async function generateAvatarUrl( + imageKey: string | null | undefined, + expiresIn = 86400, // 24 hours +): Promise { + if (!imageKey) { + return null; + } + + if (imageKey.startsWith("http://") || imageKey.startsWith("https://")) { + return imageKey; + } + + const bucket = env("NEXT_PUBLIC_AVATAR_BUCKET_NAME"); + if (!bucket) { + return null; + } + + try { + return await generateDownloadUrl(bucket, imageKey, expiresIn); + } catch { + // If URL generation fails, return null + return null; + } +} + +/** + * Generate presigned URL for an attachment + * Returns null if attachment key is missing, bucket is not configured, or URL generation fails + */ +export async function generateAttachmentUrl( + attachmentKey: string | null | undefined, + expiresIn = 86400, // 24 hours +): Promise { + if (!attachmentKey) { + return null; + } + + const bucket = env("NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME"); + if (!bucket) { + return null; + } + + try { + return await generateDownloadUrl(bucket, attachmentKey, expiresIn); + } catch { + // If URL generation fails, return null + return null; + } +} + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e2f2c62..f89b05ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -293,12 +293,6 @@ importers: packages/api: dependencies: - '@aws-sdk/client-s3': - specifier: ^3.802.0 - version: 3.879.0 - '@aws-sdk/s3-request-presigner': - specifier: ^3.812.0 - version: 3.879.0 '@kan/auth': specifier: workspace:* version: link:../auth @@ -483,6 +477,12 @@ importers: packages/shared: dependencies: + '@aws-sdk/client-s3': + specifier: ^3.802.0 + version: 3.879.0 + '@aws-sdk/s3-request-presigner': + specifier: ^3.812.0 + version: 3.879.0 date-fns: specifier: ^4.1.0 version: 4.1.0