Skip to content

Commit ef0d53d

Browse files
authored
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
1 parent 78b9de8 commit ef0d53d

File tree

22 files changed

+503
-244
lines changed

22 files changed

+503
-244
lines changed

apps/web/src/components/Avatar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ const Avatar = ({
2525
icon?: React.ReactNode;
2626
isLoading?: boolean;
2727
}) => {
28-
const initials = name
28+
const initials = name?.trim()
2929
? getInitialsFromName(name)
3030
: inferInitialsFromEmail(email);
3131

apps/web/src/components/Dashboard.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { authClient } from "@kan/auth/client";
1212
import { useClickOutside } from "~/hooks/useClickOutside";
1313
import { useModal } from "~/providers/modal";
1414
import { useWorkspace, WorkspaceProvider } from "~/providers/workspace";
15+
import { api } from "~/utils/api";
1516
import SideNavigation from "./SideNavigation";
1617

1718
interface DashboardProps {
@@ -44,6 +45,12 @@ export default function Dashboard({
4445
const { availableWorkspaces, hasLoaded } = useWorkspace();
4546

4647
const { data: session, isPending: sessionLoading } = authClient.useSession();
48+
const { data: user, isLoading: userLoading } = api.user.getUser.useQuery(
49+
undefined,
50+
{
51+
enabled: !!session?.user,
52+
},
53+
);
4754

4855
const [isSideNavOpen, setIsSideNavOpen] = useState(false);
4956
const [isRightPanelOpen, setIsRightPanelOpen] = useState(false);
@@ -155,8 +162,12 @@ export default function Dashboard({
155162
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"} `}
156163
>
157164
<SideNavigation
158-
user={{ displayName: session?.user.name, email: session?.user.email, image: session?.user.image }}
159-
isLoading={sessionLoading}
165+
user={{
166+
displayName: user?.name ?? session?.user.name,
167+
email: user?.email ?? session?.user.email ?? "",
168+
image: user?.image ?? undefined,
169+
}}
170+
isLoading={sessionLoading || userLoading}
160171
onCloseSideNav={closeSideNav}
161172
/>
162173
</div>

apps/web/src/components/Tooltip.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import tippy from "tippy.js";
77

88
interface TooltipProps {
99
children: ReactNode;
10-
content: ReactNode;
10+
content?: ReactNode;
1111
placement?: Placement;
1212
delay?: number | [number, number];
1313
}
@@ -24,6 +24,8 @@ export function Tooltip({
2424
useEffect(() => {
2525
if (!triggerRef.current) return;
2626

27+
if (!content) return;
28+
2729
const container = document.createElement("div");
2830
const root = createRoot(container);
2931
rootRef.current = root;
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import type { NextApiRequest, NextApiResponse } from "next";
2+
import { PutObjectCommand } from "@aws-sdk/client-s3";
3+
4+
import { createNextApiContext } from "@kan/api/trpc";
5+
import * as cardRepo from "@kan/db/repository/card.repo";
6+
import * as cardActivityRepo from "@kan/db/repository/cardActivity.repo";
7+
import * as cardAttachmentRepo from "@kan/db/repository/cardAttachment.repo";
8+
import { generateUID } from "@kan/shared/utils";
9+
10+
import { env } from "~/env";
11+
import { withRateLimit } from "@kan/api/utils/rateLimit";
12+
import { createS3Client } from "@kan/shared/utils";
13+
import { assertPermission } from "@kan/api/utils/permissions";
14+
15+
const MAX_SIZE_BYTES = 50 * 1024 * 1024; // 50MB
16+
17+
export const config = {
18+
api: {
19+
bodyParser: false,
20+
},
21+
};
22+
23+
export default withRateLimit(
24+
{ points: 100, duration: 60 },
25+
async (req: NextApiRequest, res: NextApiResponse) => {
26+
if (req.method !== "POST") {
27+
return res.status(405).json({ error: "Method not allowed" });
28+
}
29+
30+
try {
31+
const { user, db } = await createNextApiContext(req);
32+
33+
if (!user) {
34+
return res.status(401).json({ error: "Unauthorized" });
35+
}
36+
37+
const bucket = env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME;
38+
if (!bucket) {
39+
return res.status(500).json({ error: "Attachments bucket not configured" });
40+
}
41+
42+
const cardPublicId = req.query.cardPublicId;
43+
if (typeof cardPublicId !== "string" || cardPublicId.length < 12) {
44+
return res.status(400).json({ error: "Invalid cardPublicId" });
45+
}
46+
47+
const contentType = req.headers["content-type"];
48+
const contentLengthHeader = req.headers["content-length"];
49+
const contentLength = contentLengthHeader
50+
? Number.parseInt(contentLengthHeader, 10)
51+
: NaN;
52+
53+
if (typeof contentType !== "string") {
54+
return res.status(400).json({ error: "Missing content type" });
55+
}
56+
57+
if (!Number.isFinite(contentLength) || contentLength <= 0) {
58+
return res.status(400).json({ error: "Missing or invalid content length" });
59+
}
60+
61+
if (contentLength > MAX_SIZE_BYTES) {
62+
return res.status(400).json({ error: "File too large" });
63+
}
64+
65+
const originalFilenameHeader =
66+
(req.headers["x-original-filename"] as string | undefined) ?? "file";
67+
68+
const sanitizedFilename = originalFilenameHeader
69+
.replace(/[^a-zA-Z0-9._-]/g, "_")
70+
.substring(0, 200);
71+
72+
// Get card and check permissions
73+
const card = await cardRepo.getWorkspaceAndCardIdByCardPublicId(
74+
db,
75+
cardPublicId,
76+
);
77+
78+
if (!card) {
79+
return res.status(404).json({ error: "Card not found" });
80+
}
81+
82+
// Check if user has permission to edit the card
83+
try {
84+
await assertPermission(db, user.id, card.workspaceId, "card:edit");
85+
} catch {
86+
return res.status(403).json({ error: "Permission denied" });
87+
}
88+
89+
const s3Key = `${card.workspaceId}/${cardPublicId}/${generateUID()}-${sanitizedFilename}`;
90+
91+
const client = createS3Client();
92+
93+
// Upload the file to S3
94+
await client.send(
95+
new PutObjectCommand({
96+
Bucket: bucket,
97+
Key: s3Key,
98+
Body: req,
99+
ContentType: contentType,
100+
ContentLength: contentLength,
101+
}),
102+
);
103+
104+
// Create attachment record and log activity
105+
const attachment = await cardAttachmentRepo.create(db, {
106+
cardId: card.id,
107+
filename: sanitizedFilename,
108+
originalFilename: originalFilenameHeader,
109+
contentType,
110+
size: contentLength,
111+
s3Key,
112+
createdBy: user.id,
113+
});
114+
115+
await cardActivityRepo.create(db, {
116+
type: "card.updated.attachment.added",
117+
cardId: card.id,
118+
createdBy: user.id,
119+
});
120+
121+
return res.status(200).json({ attachment });
122+
} catch (error) {
123+
console.error("Attachment upload failed", error);
124+
return res.status(500).json({ error: "Internal server error" });
125+
}
126+
},
127+
);
128+
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import type { NextApiRequest, NextApiResponse } from "next";
2+
import { PutObjectCommand } from "@aws-sdk/client-s3";
3+
4+
import { createNextApiContext } from "@kan/api/trpc";
5+
import * as userRepo from "@kan/db/repository/user.repo";
6+
7+
import { env } from "~/env";
8+
import { withRateLimit } from "@kan/api/utils/rateLimit";
9+
import { createS3Client } from "@kan/shared/utils";
10+
11+
const MAX_SIZE_BYTES = 2 * 1024 * 1024; // 2MB
12+
const allowedContentTypes = ["image/jpeg", "image/png", "image/webp"];
13+
14+
export const config = {
15+
api: {
16+
bodyParser: false,
17+
},
18+
};
19+
20+
export default withRateLimit(
21+
{ points: 100, duration: 60 },
22+
async (req: NextApiRequest, res: NextApiResponse) => {
23+
if (req.method !== "POST") {
24+
return res.status(405).json({ error: "Method not allowed" });
25+
}
26+
27+
try {
28+
const { user, db } = await createNextApiContext(req);
29+
30+
if (!user) {
31+
return res.status(401).json({ error: "Unauthorized" });
32+
}
33+
34+
const bucket = env.NEXT_PUBLIC_AVATAR_BUCKET_NAME;
35+
if (!bucket) {
36+
return res.status(500).json({ error: "Avatar bucket not configured" });
37+
}
38+
39+
const contentType = req.headers["content-type"];
40+
const contentLengthHeader = req.headers["content-length"];
41+
const contentLength = contentLengthHeader
42+
? Number.parseInt(contentLengthHeader, 10)
43+
: NaN;
44+
45+
if (typeof contentType !== "string") {
46+
return res.status(400).json({ error: "Missing content type" });
47+
}
48+
49+
if (!allowedContentTypes.includes(contentType)) {
50+
return res.status(400).json({ error: "Invalid content type" });
51+
}
52+
53+
if (!Number.isFinite(contentLength) || contentLength <= 0) {
54+
return res.status(400).json({ error: "Missing or invalid content length" });
55+
}
56+
57+
if (contentLength > MAX_SIZE_BYTES) {
58+
return res.status(400).json({ error: "File too large" });
59+
}
60+
61+
const originalFilenameHeader =
62+
(req.headers["x-original-filename"] as string | undefined) ?? "file";
63+
64+
const sanitizedFilename = originalFilenameHeader
65+
.replace(/[^a-zA-Z0-9._-]/g, "_")
66+
.substring(0, 200);
67+
68+
const s3Key = `${user.id}/${sanitizedFilename}`;
69+
70+
const client = createS3Client();
71+
72+
// Upload the file to S3
73+
await client.send(
74+
new PutObjectCommand({
75+
Bucket: bucket,
76+
Key: s3Key,
77+
Body: req,
78+
ContentType: contentType,
79+
ContentLength: contentLength,
80+
}),
81+
);
82+
83+
// Update user image in database
84+
const updatedUser = await userRepo.update(db, user.id, {
85+
image: s3Key,
86+
});
87+
88+
return res.status(200).json({
89+
key: s3Key,
90+
filename: sanitizedFilename,
91+
contentType,
92+
size: contentLength,
93+
user: updatedUser,
94+
});
95+
} catch (error) {
96+
console.error("Avatar upload failed", error);
97+
return res.status(500).json({ error: "Internal server error" });
98+
}
99+
},
100+
);
101+

apps/web/src/pages/api/upload/image.ts

Lines changed: 0 additions & 75 deletions
This file was deleted.

apps/web/src/utils/helpers.ts

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import { env } from "next-runtime-env";
2-
31
export const formatToArray = (
42
value: string | string[] | undefined,
53
): string[] => {
@@ -52,14 +50,5 @@ export const getAvatarUrl = (imageOrKey: string | null) => {
5250
return imageOrKey;
5351
}
5452

55-
const bucket = env("NEXT_PUBLIC_AVATAR_BUCKET_NAME");
56-
const useVirtualHostedUrls = env("NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS");
57-
const storageDomain = env("NEXT_PUBLIC_STORAGE_DOMAIN");
58-
59-
if (useVirtualHostedUrls === "true" && storageDomain) {
60-
return `https://${bucket}.${storageDomain}/${imageOrKey}`;
61-
}
62-
63-
const storageUrl = env("NEXT_PUBLIC_STORAGE_URL");
64-
return `${storageUrl}/${bucket}/${imageOrKey}`;
53+
return "";
6554
};

0 commit comments

Comments
 (0)