refactor: standardise S3 URL generation (#362)
* refactor: replace presigned URL uploads with backend upload endpoints * feat: update avatar upload to use new endpoint * refactor: use createS3Client in auth hooks * feat: generate presigned URLs for avatars * fix: show avatar image in user menu * fix: hide tooltip if content is empty * fix: support external avatar URLs in generateAvatarUrl * fix: remove content type restriction on attachments
This commit is contained in:
@@ -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:^",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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<ReturnType<typeof workspaceRepo.getByPublicIdWithMembers>>;
|
||||
}
|
||||
|
||||
return result;
|
||||
return {
|
||||
...result,
|
||||
members: membersWithAvatarUrls,
|
||||
};
|
||||
}),
|
||||
bySlug: publicProcedure
|
||||
.meta({
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import {
|
||||
DeleteObjectCommand,
|
||||
GetObjectCommand,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
|
||||
export function createS3Client() {
|
||||
const credentials =
|
||||
process.env.S3_ACCESS_KEY_ID && process.env.S3_SECRET_ACCESS_KEY
|
||||
? {
|
||||
accessKeyId: process.env.S3_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return new S3Client({
|
||||
region: process.env.S3_REGION ?? "",
|
||||
endpoint: process.env.S3_ENDPOINT ?? "",
|
||||
forcePathStyle: process.env.S3_FORCE_PATH_STYLE === "true",
|
||||
credentials,
|
||||
});
|
||||
}
|
||||
|
||||
export async function generateUploadUrl(
|
||||
bucket: string,
|
||||
key: string,
|
||||
contentType: string,
|
||||
expiresIn = 3600,
|
||||
) {
|
||||
const client = createS3Client();
|
||||
return getSignedUrl(
|
||||
client,
|
||||
new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
ContentType: contentType,
|
||||
// Don't set ACL for private files
|
||||
}),
|
||||
{ expiresIn },
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateDownloadUrl(
|
||||
bucket: string,
|
||||
key: string,
|
||||
expiresIn = 3600,
|
||||
) {
|
||||
const client = createS3Client();
|
||||
return getSignedUrl(
|
||||
client,
|
||||
new GetObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
}),
|
||||
{ expiresIn },
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteObject(bucket: string, key: string) {
|
||||
const client = createS3Client();
|
||||
await client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
}),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user