diff --git a/apps/web/src/pages/api/upload/attachment.ts b/apps/web/src/pages/api/upload/attachment.ts index d97cf4ac..5d78a3cf 100644 --- a/apps/web/src/pages/api/upload/attachment.ts +++ b/apps/web/src/pages/api/upload/attachment.ts @@ -9,7 +9,7 @@ import { generateUID } from "@kan/shared/utils"; import { env } from "~/env"; import { withRateLimit } from "@kan/api/utils/rateLimit"; -import { createS3Client } from "@kan/api/utils/s3"; +import { createS3Client } from "@kan/shared/utils"; import { assertPermission } from "@kan/api/utils/permissions"; const MAX_SIZE_BYTES = 50 * 1024 * 1024; // 50MB diff --git a/apps/web/src/pages/api/upload/avatar.ts b/apps/web/src/pages/api/upload/avatar.ts index daff47e8..de3508bf 100644 --- a/apps/web/src/pages/api/upload/avatar.ts +++ b/apps/web/src/pages/api/upload/avatar.ts @@ -6,7 +6,7 @@ import * as userRepo from "@kan/db/repository/user.repo"; import { env } from "~/env"; import { withRateLimit } from "@kan/api/utils/rateLimit"; -import { createS3Client } from "@kan/api/utils/s3"; +import { createS3Client } from "@kan/shared/utils"; const MAX_SIZE_BYTES = 2 * 1024 * 1024; // 2MB const allowedContentTypes = ["image/jpeg", "image/png", "image/webp"]; 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/packages/api/package.json b/packages/api/package.json index b2a6613d..30bdb7a1 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -28,10 +28,6 @@ "types": "./dist/utils/rateLimit.d.ts", "default": "./src/utils/rateLimit.ts" }, - "./utils/s3": { - "types": "./dist/utils/s3.d.ts", - "default": "./src/utils/s3.ts" - }, "./utils/permissions": { "types": "./dist/utils/permissions.d.ts", "default": "./src/utils/permissions.ts" @@ -47,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 460a56cf..246fb1f8 100644 --- a/packages/auth/src/hooks.ts +++ b/packages/auth/src/hooks.ts @@ -7,8 +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 { createS3Client } from "@kan/api/utils/s3"; +import { createEmailUnsubscribeLink, createS3Client } from "@kan/shared"; import { downloadImage } from "./utils"; 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 55% rename from packages/api/src/utils/s3.ts rename to packages/shared/src/utils/s3.ts index 8d78d55c..6ae61f19 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,54 @@ export async function deleteObject(bucket: string, key: string) { }), ); } + +/** + * Generate presigned URL for an avatar image + * 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; + } + + 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