Compare commits

..

4 Commits

23 changed files with 248 additions and 524 deletions

View File

@@ -47,17 +47,7 @@ See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features.
## Self Hosting 🐳 ## Self Hosting 🐳
### One-click Deployments The easiest way to self-host Kan is with Docker Compose. This will set up everything for you including your postgres database.
The easiest way to deploy Kan is through Railway. We've partnered with Railway to maintain an official template that supports the development of the project.
<a href="https://railway.com/deploy/kan?referralCode=bZPsr2&utm_medium=integration&utm_source=template&utm_campaign=generic">
<img src="https://railway.app/button.svg" alt="Deploy on Railway" height="40" />
</a>
### Docker Compose
Alternatively, you can self-host Kan with Docker Compose. This will set up everything for you including your postgres database.
1. Create a new file called `docker-compose.yml` and paste the following configuration: 1. Create a new file called `docker-compose.yml` and paste the following configuration:

View File

@@ -25,7 +25,7 @@ const Avatar = ({
icon?: React.ReactNode; icon?: React.ReactNode;
isLoading?: boolean; isLoading?: boolean;
}) => { }) => {
const initials = name?.trim() const initials = name
? getInitialsFromName(name) ? getInitialsFromName(name)
: inferInitialsFromEmail(email); : inferInitialsFromEmail(email);

View File

@@ -12,7 +12,6 @@ import { authClient } from "@kan/auth/client";
import { useClickOutside } from "~/hooks/useClickOutside"; import { useClickOutside } from "~/hooks/useClickOutside";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
import { useWorkspace, WorkspaceProvider } from "~/providers/workspace"; import { useWorkspace, WorkspaceProvider } from "~/providers/workspace";
import { api } from "~/utils/api";
import SideNavigation from "./SideNavigation"; import SideNavigation from "./SideNavigation";
interface DashboardProps { interface DashboardProps {
@@ -45,12 +44,6 @@ export default function Dashboard({
const { availableWorkspaces, hasLoaded } = useWorkspace(); const { availableWorkspaces, hasLoaded } = useWorkspace();
const { data: session, isPending: sessionLoading } = authClient.useSession(); 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 [isSideNavOpen, setIsSideNavOpen] = useState(false);
const [isRightPanelOpen, setIsRightPanelOpen] = useState(false); const [isRightPanelOpen, setIsRightPanelOpen] = useState(false);
@@ -162,12 +155,8 @@ 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"} `} 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"} `}
> >
<SideNavigation <SideNavigation
user={{ user={{ displayName: session?.user.name, email: session?.user.email, image: session?.user.image }}
displayName: user?.name ?? session?.user.name, isLoading={sessionLoading}
email: user?.email ?? session?.user.email ?? "",
image: user?.image ?? undefined,
}}
isLoading={sessionLoading || userLoading}
onCloseSideNav={closeSideNav} onCloseSideNav={closeSideNav}
/> />
</div> </div>

View File

@@ -7,7 +7,7 @@ import tippy from "tippy.js";
interface TooltipProps { interface TooltipProps {
children: ReactNode; children: ReactNode;
content?: ReactNode; content: ReactNode;
placement?: Placement; placement?: Placement;
delay?: number | [number, number]; delay?: number | [number, number];
} }
@@ -24,8 +24,6 @@ export function Tooltip({
useEffect(() => { useEffect(() => {
if (!triggerRef.current) return; if (!triggerRef.current) return;
if (!content) return;
const container = document.createElement("div"); const container = document.createElement("div");
const root = createRoot(container); const root = createRoot(container);
rootRef.current = root; rootRef.current = root;

View File

@@ -1,128 +0,0 @@
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" });
}
},
);

View File

@@ -1,101 +0,0 @@
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" });
}
},
);

View File

@@ -0,0 +1,75 @@
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 });
}
},
);

View File

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

View File

@@ -7,7 +7,6 @@ import { twMerge } from "tailwind-merge";
import Button from "~/components/Button"; import Button from "~/components/Button";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup"; import { usePopup } from "~/providers/popup";
import { env } from "next-runtime-env";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation"; import { invalidateCard } from "~/utils/cardInvalidation";
@@ -19,33 +18,62 @@ export function AttachmentUpload({ cardPublicId }: { cardPublicId: string }) {
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
const uploadFile = async (file: File) => { const generateUploadUrl = api.attachment.generateUploadUrl.useMutation();
setUploading(true); const confirmAttachment = api.attachment.confirm.useMutation({
onSuccess: async () => {
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); await invalidateCard(utils, cardPublicId);
showPopup({ showPopup({
header: t`Attachment uploaded`, header: t`Attachment uploaded`,
message: t`Your file has been uploaded successfully.`, message: t`Your file has been uploaded successfully.`,
icon: "success", 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 { } catch {
showPopup({ showPopup({
header: t`Upload failed`, header: t`Upload failed`,

View File

@@ -126,6 +126,7 @@ export default function MembersPage() {
name={memberName ?? ""} name={memberName ?? ""}
email={memberEmail ?? ""} email={memberEmail ?? ""}
imageUrl={memberImage ? getAvatarUrl(memberImage) : undefined} imageUrl={memberImage ? getAvatarUrl(memberImage) : undefined}
icon={showPendingIcon ? "?" : undefined}
/> />
)} )}
</div> </div>

View File

@@ -58,6 +58,28 @@ export default function Avatar({
const [crop, setCrop] = useState<PercentCrop>(); const [crop, setCrop] = useState<PercentCrop>();
const imgRef = useRef<HTMLImageElement | null>(null); const imgRef = useRef<HTMLImageElement | null>(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; const avatarUrl = userImage ? getAvatarUrl(userImage) : undefined;
@@ -165,32 +187,29 @@ export default function Avatar({
const originalExt = selectedFile.name.split(".").pop() ?? "jpg"; const originalExt = selectedFile.name.split(".").pop() ?? "jpg";
const fileName = `${userId}/avatar-${generateUID()}.${originalExt}`; const fileName = `${userId}/avatar-${generateUID()}.${originalExt}`;
const baseUrl = env("NEXT_PUBLIC_BASE_URL") ?? "";
const response = await fetch( const response = await fetch(
`${baseUrl}/api/upload/avatar`, env("NEXT_PUBLIC_BASE_URL") + "/api/upload/image",
{ {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": blob.type, "Content-Type": "application/json",
"x-original-filename": fileName,
}, },
body: blob, body: JSON.stringify({ filename: fileName, contentType: blob.type }),
}, },
); );
if (!response.ok) { if (!response.ok) throw new Error("Failed to get pre-signed URL");
throw new Error("Failed to upload profile image");
}
// User image is updated in the backend, refresh user data const { url } = (await response.json()) as { url: string };
await utils.user.getUser.refetch();
const uploadResponse = await fetch(url, {
showPopup({ method: "PUT",
header: t`Profile image updated`, body: blob,
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); setCropDialogOpen(false);
resetCropState(); resetCropState();
} catch (error) { } catch (error) {
@@ -208,7 +227,7 @@ export default function Avatar({
resetCropState, resetCropState,
selectedFile, selectedFile,
showPopup, showPopup,
utils.user.getUser, updateUser,
userId, userId,
]); ]);

View File

@@ -27,10 +27,6 @@
"./utils/rateLimit": { "./utils/rateLimit": {
"types": "./dist/utils/rateLimit.d.ts", "types": "./dist/utils/rateLimit.d.ts",
"default": "./src/utils/rateLimit.ts" "default": "./src/utils/rateLimit.ts"
},
"./utils/permissions": {
"types": "./dist/utils/permissions.d.ts",
"default": "./src/utils/permissions.ts"
} }
}, },
"license": "GPL-3.0", "license": "GPL-3.0",
@@ -43,6 +39,8 @@
"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 "@kan/shared/utils"; import { deleteObject, generateUploadUrl } from "../utils/s3";
export const attachmentRouter = createTRPCRouter({ export const attachmentRouter = createTRPCRouter({
generateUploadUrl: protectedProcedure generateUploadUrl: protectedProcedure

View File

@@ -10,7 +10,6 @@ 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";
@@ -143,40 +142,7 @@ export const boardRouter = createTRPCRouter({
}, },
); );
if (!result) { return result;
throw new TRPCError({
message: `Board with public ID ${input.boardPublicId} not found`,
code: "NOT_FOUND",
});
}
// 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 { generateAttachmentUrl, generateAvatarUrl } from "@kan/shared/utils"; import { generateDownloadUrl } from "../utils/s3";
export const cardRouter = createTRPCRouter({ export const cardRouter = createTRPCRouter({
create: protectedProcedure create: protectedProcedure
@@ -631,54 +631,45 @@ export const cardRouter = createTRPCRouter({
}); });
// Generate URLs for all attachments // Generate URLs for all attachments
const attachmentsWithUrls = await Promise.all( const bucket = process.env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME;
result.attachments.map(async (attachment) => { if (result.attachments && Array.isArray(result.attachments)) {
const url = await generateAttachmentUrl(attachment.s3Key); const attachments = result.attachments as {
return { publicId: string;
publicId: attachment.publicId, contentType: string;
contentType: attachment.contentType, s3Key: string;
s3Key: attachment.s3Key, originalFilename: string | null;
originalFilename: attachment.originalFilename, size?: number | null;
size: attachment.size, }[];
url,
};
}),
);
// Generate presigned URLs for workspace member avatars const attachmentsWithUrls = await Promise.all(
const workspaceWithAvatarUrls = result.list.board.workspace attachments.map(async (attachment) => {
? { const base = {
...result.list.board.workspace, publicId: attachment.publicId,
members: await Promise.all( contentType: attachment.contentType,
result.list.board.workspace.members.map(async (member) => { s3Key: attachment.s3Key,
if (!member.user?.image) { originalFilename: attachment.originalFilename,
return member; 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 };
}
const avatarUrl = await generateAvatarUrl(member.user.image); return { ...result, attachments: [] };
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({
@@ -747,39 +738,7 @@ export const cardRouter = createTRPCRouter({
}, },
); );
// Generate presigned URLs for user avatars in activities const mergedActivities = mergeActivities(result.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 "@kan/shared/utils"; import { createS3Client } from "../utils/s3";
const checkDatabaseConnection = async (db: dbClient) => { const checkDatabaseConnection = async (db: dbClient) => {
try { try {

View File

@@ -4,7 +4,6 @@ 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
@@ -56,12 +55,8 @@ 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,
}; };
}), }),
@@ -107,12 +102,6 @@ export const userRouter = createTRPCRouter({
}); });
} }
// Generate presigned URL for avatar return result;
const imageUrl = await generateAvatarUrl(result.image);
return {
...result,
image: imageUrl,
};
}), }),
}); });

View File

@@ -8,7 +8,6 @@ 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
@@ -87,27 +86,9 @@ 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 = membersWithAvatarUrls.map((member) => { const sanitizedMembers = result.members.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}`;
@@ -139,10 +120,7 @@ export const workspaceRouter = createTRPCRouter({
} as Awaited<ReturnType<typeof workspaceRepo.getByPublicIdWithMembers>>; } as Awaited<ReturnType<typeof workspaceRepo.getByPublicIdWithMembers>>;
} }
return { return result;
...result,
members: membersWithAvatarUrls,
};
}), }),
bySlug: publicProcedure bySlug: publicProcedure
.meta({ .meta({

View File

@@ -5,7 +5,6 @@ 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 =
@@ -68,60 +67,3 @@ 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<string | null> {
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<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;
}
}

View File

@@ -1,4 +1,4 @@
import { PutObjectCommand } from "@aws-sdk/client-s3"; import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { ChatOrPushProviderEnum } from "@novu/api/models/components"; import { ChatOrPushProviderEnum } from "@novu/api/models/components";
import { createAuthMiddleware } from "better-auth/api"; import { createAuthMiddleware } from "better-auth/api";
import { env } from "next-runtime-env"; 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 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, createS3Client } from "@kan/shared"; import { createEmailUnsubscribeLink } from "@kan/shared";
import { downloadImage } from "./utils"; import { downloadImage } from "./utils";
@@ -61,7 +61,20 @@ export function createDatabaseHooks(db: dbClient) {
!user.image.includes(storageDomain) !user.image.includes(storageDomain)
) { ) {
try { try {
const client = createS3Client(); 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 allowedFileExtensions = ["jpg", "jpeg", "png", "webp"]; const allowedFileExtensions = ["jpg", "jpeg", "png", "webp"];

View File

@@ -36,8 +36,6 @@
}, },
"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,4 +3,3 @@ export * from "./generateSlug";
export * from "./subscriptions"; export * from "./subscriptions";
export * from "./email"; export * from "./email";
export * from "./dueDateFilters"; export * from "./dueDateFilters";
export * from "./s3";

12
pnpm-lock.yaml generated
View File

@@ -293,6 +293,12 @@ 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
@@ -477,12 +483,6 @@ 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