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