feat: generate presigned URLs for avatars

This commit is contained in:
Henry
2026-02-04 21:50:37 +00:00
parent ceac4c0d81
commit c8517ed4c2
15 changed files with 211 additions and 73 deletions

View File

@@ -9,7 +9,7 @@ import { generateUID } from "@kan/shared/utils";
import { env } from "~/env"; import { env } from "~/env";
import { withRateLimit } from "@kan/api/utils/rateLimit"; 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"; import { assertPermission } from "@kan/api/utils/permissions";
const MAX_SIZE_BYTES = 50 * 1024 * 1024; // 50MB const MAX_SIZE_BYTES = 50 * 1024 * 1024; // 50MB

View File

@@ -6,7 +6,7 @@ import * as userRepo from "@kan/db/repository/user.repo";
import { env } from "~/env"; import { env } from "~/env";
import { withRateLimit } from "@kan/api/utils/rateLimit"; 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 MAX_SIZE_BYTES = 2 * 1024 * 1024; // 2MB
const allowedContentTypes = ["image/jpeg", "image/png", "image/webp"]; const allowedContentTypes = ["image/jpeg", "image/png", "image/webp"];

View File

@@ -1,5 +1,3 @@
import { env } from "next-runtime-env";
export const formatToArray = ( export const formatToArray = (
value: string | string[] | undefined, value: string | string[] | undefined,
): string[] => { ): string[] => {
@@ -52,14 +50,5 @@ export const getAvatarUrl = (imageOrKey: string | null) => {
return imageOrKey; return imageOrKey;
} }
const bucket = env("NEXT_PUBLIC_AVATAR_BUCKET_NAME"); return "";
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}`;
}; };

View File

@@ -28,10 +28,6 @@
"types": "./dist/utils/rateLimit.d.ts", "types": "./dist/utils/rateLimit.d.ts",
"default": "./src/utils/rateLimit.ts" "default": "./src/utils/rateLimit.ts"
}, },
"./utils/s3": {
"types": "./dist/utils/s3.d.ts",
"default": "./src/utils/s3.ts"
},
"./utils/permissions": { "./utils/permissions": {
"types": "./dist/utils/permissions.d.ts", "types": "./dist/utils/permissions.d.ts",
"default": "./src/utils/permissions.ts" "default": "./src/utils/permissions.ts"
@@ -47,8 +43,6 @@
"typecheck": "tsc --noEmit --emitDeclarationOnly false" "typecheck": "tsc --noEmit --emitDeclarationOnly false"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.802.0",
"@aws-sdk/s3-request-presigner": "^3.812.0",
"@kan/auth": "workspace:*", "@kan/auth": "workspace:*",
"@kan/db": "workspace:*", "@kan/db": "workspace:*",
"@kan/email": "workspace:^", "@kan/email": "workspace:^",

View File

@@ -9,7 +9,7 @@ import { generateUID } from "@kan/shared/utils";
import { createTRPCRouter, protectedProcedure } from "../trpc"; import { createTRPCRouter, protectedProcedure } from "../trpc";
import { assertPermission } from "../utils/permissions"; import { assertPermission } from "../utils/permissions";
import { deleteObject, generateUploadUrl } from "../utils/s3"; import { deleteObject, generateUploadUrl } from "@kan/shared/utils";
export const attachmentRouter = createTRPCRouter({ export const attachmentRouter = createTRPCRouter({
generateUploadUrl: protectedProcedure generateUploadUrl: protectedProcedure

View File

@@ -10,6 +10,7 @@ import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { colours } from "@kan/shared/constants"; import { colours } from "@kan/shared/constants";
import { import {
convertDueDateFiltersToRanges, convertDueDateFiltersToRanges,
generateAvatarUrl,
generateSlug, generateSlug,
generateUID, generateUID,
} from "@kan/shared/utils"; } 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 bySlug: publicProcedure
.meta({ .meta({

View File

@@ -11,7 +11,7 @@ import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import { mergeActivities } from "../utils/activities"; import { mergeActivities } from "../utils/activities";
import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions"; import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
import { generateDownloadUrl } from "../utils/s3"; import { generateAttachmentUrl, generateAvatarUrl } from "@kan/shared/utils";
export const cardRouter = createTRPCRouter({ export const cardRouter = createTRPCRouter({
create: protectedProcedure create: protectedProcedure
@@ -631,45 +631,54 @@ export const cardRouter = createTRPCRouter({
}); });
// Generate URLs for all attachments // Generate URLs for all attachments
const bucket = process.env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME; const attachmentsWithUrls = await Promise.all(
if (result.attachments && Array.isArray(result.attachments)) { result.attachments.map(async (attachment) => {
const attachments = result.attachments as { const url = await generateAttachmentUrl(attachment.s3Key);
publicId: string; return {
contentType: string; publicId: attachment.publicId,
s3Key: string; contentType: attachment.contentType,
originalFilename: string | null; s3Key: attachment.s3Key,
size?: number | null; originalFilename: attachment.originalFilename,
}[]; size: attachment.size,
url,
};
}),
);
const attachmentsWithUrls = await Promise.all( // Generate presigned URLs for workspace member avatars
attachments.map(async (attachment) => { const workspaceWithAvatarUrls = result.list.board.workspace
const base = { ? {
publicId: attachment.publicId, ...result.list.board.workspace,
contentType: attachment.contentType, members: await Promise.all(
s3Key: attachment.s3Key, result.list.board.workspace.members.map(async (member) => {
originalFilename: attachment.originalFilename, if (!member.user?.image) {
size: attachment.size, return member;
}; }
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 };
}
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 getActivities: publicProcedure
.meta({ .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 { return {
activities: mergedActivities, activities: mergedActivities,

View File

@@ -24,7 +24,7 @@ import {
createTRPCRouter, createTRPCRouter,
publicProcedure, publicProcedure,
} from "../trpc"; } from "../trpc";
import { createS3Client } from "../utils/s3"; import { createS3Client } from "@kan/shared/utils";
const checkDatabaseConnection = async (db: dbClient) => { const checkDatabaseConnection = async (db: dbClient) => {
try { try {

View File

@@ -4,6 +4,7 @@ import { z } from "zod";
import * as userRepo from "@kan/db/repository/user.repo"; import * as userRepo from "@kan/db/repository/user.repo";
import { createTRPCRouter, protectedProcedure } from "../trpc"; import { createTRPCRouter, protectedProcedure } from "../trpc";
import { generateAvatarUrl } from "@kan/shared/utils";
export const userRouter = createTRPCRouter({ export const userRouter = createTRPCRouter({
getUser: protectedProcedure getUser: protectedProcedure
@@ -55,8 +56,12 @@ export const userRouter = createTRPCRouter({
const apiKey = result.apiKeys[0]; const apiKey = result.apiKeys[0];
// Generate presigned URL for avatar
const imageUrl = await generateAvatarUrl(result.image);
return { return {
...result, ...result,
image: imageUrl,
apiKey: apiKey ?? null, 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,
};
}), }),
}); });

View File

@@ -8,6 +8,7 @@ import { generateUID } from "@kan/shared/utils";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import { assertPermission } from "../utils/permissions"; import { assertPermission } from "../utils/permissions";
import { generateAvatarUrl } from "@kan/shared/utils";
export const workspaceRouter = createTRPCRouter({ export const workspaceRouter = createTRPCRouter({
all: protectedProcedure all: protectedProcedure
@@ -86,9 +87,27 @@ export const workspaceRouter = createTRPCRouter({
const shouldShowEmails = const shouldShowEmails =
isAdmin || result.showEmailsToMembers === true; 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 emails should be hidden, filter them out
if (!shouldShowEmails) { if (!shouldShowEmails) {
const sanitizedMembers = result.members.map((member) => { const sanitizedMembers = membersWithAvatarUrls.map((member) => {
// If user doesn't have a display name, use anonymous identifier // If user doesn't have a display name, use anonymous identifier
const displayName = const displayName =
member.user?.name?.trim() ?? `anonymous_${member.publicId}`; member.user?.name?.trim() ?? `anonymous_${member.publicId}`;
@@ -120,7 +139,10 @@ export const workspaceRouter = createTRPCRouter({
} as Awaited<ReturnType<typeof workspaceRepo.getByPublicIdWithMembers>>; } as Awaited<ReturnType<typeof workspaceRepo.getByPublicIdWithMembers>>;
} }
return result; return {
...result,
members: membersWithAvatarUrls,
};
}), }),
bySlug: publicProcedure bySlug: publicProcedure
.meta({ .meta({

View File

@@ -7,8 +7,7 @@ import type { dbClient } from "@kan/db/client";
import * as memberRepo from "@kan/db/repository/member.repo"; import * as memberRepo from "@kan/db/repository/member.repo";
import * as userRepo from "@kan/db/repository/user.repo"; import * as userRepo from "@kan/db/repository/user.repo";
import { notificationClient } from "@kan/email"; import { notificationClient } from "@kan/email";
import { createEmailUnsubscribeLink } from "@kan/shared"; import { createEmailUnsubscribeLink, createS3Client } from "@kan/shared";
import { createS3Client } from "@kan/api/utils/s3";
import { downloadImage } from "./utils"; import { downloadImage } from "./utils";

View File

@@ -36,6 +36,8 @@
}, },
"prettier": "@kan/prettier-config", "prettier": "@kan/prettier-config",
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.802.0",
"@aws-sdk/s3-request-presigner": "^3.812.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"jose": "^6.1.2", "jose": "^6.1.2",
"nanoid": "^5.0.9", "nanoid": "^5.0.9",

View File

@@ -3,3 +3,4 @@ export * from "./generateSlug";
export * from "./subscriptions"; export * from "./subscriptions";
export * from "./email"; export * from "./email";
export * from "./dueDateFilters"; export * from "./dueDateFilters";
export * from "./s3";

View File

@@ -5,6 +5,7 @@ import {
S3Client, S3Client,
} from "@aws-sdk/client-s3"; } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { env } from "next-runtime-env";
export function createS3Client() { export function createS3Client() {
const credentials = 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<string | null> {
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<string | null> {
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;
}
}

12
pnpm-lock.yaml generated
View File

@@ -293,12 +293,6 @@ importers:
packages/api: packages/api:
dependencies: 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': '@kan/auth':
specifier: workspace:* specifier: workspace:*
version: link:../auth version: link:../auth
@@ -483,6 +477,12 @@ importers:
packages/shared: packages/shared:
dependencies: 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: date-fns:
specifier: ^4.1.0 specifier: ^4.1.0
version: 4.1.0 version: 4.1.0