Skip to content

Commit bc21c3f

Browse files
authored
web: use icon keys instead of urls (#1259)
* handle icon urls with keys instead * move to rpc * adjust user profile placeholder * refactor some code + rollback name changes of columns * Update _journal.json * use the signed image url component in other places and address pr issues * ts * fix comment stamp clipping at 0:00 * Update media-player.tsx
1 parent 5355d72 commit bc21c3f

49 files changed

Lines changed: 596 additions & 397 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
"use server";
22

3+
import path from "node:path";
34
import { db } from "@cap/database";
45
import { getCurrentUser } from "@cap/database/auth/session";
56
import { users } from "@cap/database/schema";
7+
import { S3Buckets } from "@cap/web-backend";
68
import { eq } from "drizzle-orm";
9+
import { Effect, Option } from "effect";
710
import { revalidatePath } from "next/cache";
11+
import { runPromise } from "@/lib/server";
812

913
export async function removeProfileImage() {
1014
const user = await getCurrentUser();
@@ -13,10 +17,50 @@ export async function removeProfileImage() {
1317
throw new Error("Unauthorized");
1418
}
1519

20+
const image = user.image;
21+
22+
// Delete the profile image from S3 if it exists
23+
if (image) {
24+
try {
25+
// Extract the S3 key - handle both old URL format and new key format
26+
let s3Key = image;
27+
if (image.startsWith("http://") || image.startsWith("https://")) {
28+
const url = new URL(image);
29+
// Only extract key from URLs with amazonaws.com hostname
30+
if (
31+
url.hostname.endsWith(".amazonaws.com") ||
32+
url.hostname === "amazonaws.com"
33+
) {
34+
const raw = url.pathname.startsWith("/")
35+
? url.pathname.slice(1)
36+
: url.pathname;
37+
const decoded = decodeURIComponent(raw);
38+
const normalized = path.posix.normalize(decoded);
39+
if (normalized.includes("..")) {
40+
throw new Error("Invalid S3 key path");
41+
}
42+
s3Key = normalized;
43+
} else {
44+
// Not an S3 URL, skip deletion of S3 object; continue with DB update below
45+
}
46+
}
47+
48+
// Only delete if it looks like a user profile image key
49+
if (s3Key.startsWith("users/")) {
50+
await Effect.gen(function* () {
51+
const [bucket] = yield* S3Buckets.getBucketAccess(Option.none());
52+
yield* bucket.deleteObject(s3Key);
53+
}).pipe(runPromise);
54+
}
55+
} catch (error) {
56+
console.error("Error deleting profile image from S3:", error);
57+
// Continue with database update even if S3 deletion fails
58+
}
59+
}
60+
1661
await db().update(users).set({ image: null }).where(eq(users.id, user.id));
1762

1863
revalidatePath("/dashboard/settings/account");
19-
revalidatePath("/dashboard", "layout");
2064

2165
return { success: true } as const;
2266
}

apps/web/actions/account/upload-profile-image.ts

Lines changed: 42 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { randomUUID } from "node:crypto";
44
import { db } from "@cap/database";
55
import { getCurrentUser } from "@cap/database/auth/session";
66
import { users } from "@cap/database/schema";
7-
import { serverEnv } from "@cap/env";
87
import { S3Buckets } from "@cap/web-backend";
98
import { eq } from "drizzle-orm";
109
import { Effect, Option } from "effect";
@@ -43,15 +42,50 @@ export async function uploadProfileImage(formData: FormData) {
4342
throw new Error("File size must be 3MB or less");
4443
}
4544

45+
// Get the old profile image to delete it later
46+
const oldImageUrlOrKey = user.image;
47+
4648
const fileKey = `users/${user.id}/profile-${Date.now()}-${randomUUID()}.${fileExtension}`;
4749

4850
try {
4951
const sanitizedFile = await sanitizeFile(file);
50-
let imageUrl: string | undefined;
52+
let image: string | null = null;
5153

5254
await Effect.gen(function* () {
5355
const [bucket] = yield* S3Buckets.getBucketAccess(Option.none());
5456

57+
// Delete old profile image if it exists
58+
if (oldImageUrlOrKey) {
59+
try {
60+
// Extract the S3 key - handle both old URL format and new key format
61+
let oldS3Key = oldImageUrlOrKey;
62+
if (
63+
oldImageUrlOrKey.startsWith("http://") ||
64+
oldImageUrlOrKey.startsWith("https://")
65+
) {
66+
const url = new URL(oldImageUrlOrKey);
67+
// Only extract key from URLs with amazonaws.com hostname
68+
if (
69+
url.hostname.endsWith(".amazonaws.com") ||
70+
url.hostname === "amazonaws.com"
71+
) {
72+
oldS3Key = url.pathname.substring(1); // Remove leading slash
73+
} else {
74+
// Not an S3 URL, skip deletion
75+
return;
76+
}
77+
}
78+
79+
// Only delete if it looks like a user profile image key
80+
if (oldS3Key.startsWith("users/")) {
81+
yield* bucket.deleteObject(oldS3Key);
82+
}
83+
} catch (error) {
84+
console.error("Error deleting old profile image from S3:", error);
85+
// Continue with upload even if deletion fails
86+
}
87+
}
88+
5589
const bodyBytes = yield* Effect.promise(async () => {
5690
const buf = await sanitizedFile.arrayBuffer();
5791
return new Uint8Array(buf);
@@ -61,32 +95,23 @@ export async function uploadProfileImage(formData: FormData) {
6195
contentType: file.type,
6296
});
6397

64-
if (serverEnv().CAP_AWS_BUCKET_URL) {
65-
imageUrl = `${serverEnv().CAP_AWS_BUCKET_URL}/${fileKey}`;
66-
} else if (serverEnv().CAP_AWS_ENDPOINT) {
67-
imageUrl = `${serverEnv().CAP_AWS_ENDPOINT}/${bucket.bucketName}/${fileKey}`;
68-
} else {
69-
imageUrl = `https://${bucket.bucketName}.s3.${
70-
serverEnv().CAP_AWS_REGION || "us-east-1"
71-
}.amazonaws.com/${fileKey}`;
72-
}
98+
image = fileKey;
7399
}).pipe(runPromise);
74100

75-
if (typeof imageUrl !== "string" || imageUrl.length === 0) {
76-
throw new Error("Failed to resolve uploaded profile image URL");
101+
if (!image) {
102+
throw new Error("Failed to resolve uploaded profile image key");
77103
}
78104

79-
const finalImageUrl = imageUrl;
105+
const finalImageUrlOrKey = image;
80106

81107
await db()
82108
.update(users)
83-
.set({ image: finalImageUrl })
109+
.set({ image: finalImageUrlOrKey })
84110
.where(eq(users.id, user.id));
85111

86112
revalidatePath("/dashboard/settings/account");
87-
revalidatePath("/dashboard", "layout");
88113

89-
return { success: true, imageUrl: finalImageUrl } as const;
114+
return { success: true, image: finalImageUrlOrKey } as const;
90115
} catch (error) {
91116
console.error("Error uploading profile image:", error);
92117
throw new Error(error instanceof Error ? error.message : "Upload failed");

apps/web/actions/organization/create-space.ts

Lines changed: 10 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { db } from "@cap/database";
44
import { getCurrentUser } from "@cap/database/auth/session";
55
import { nanoId, nanoIdLength } from "@cap/database/helpers";
66
import { spaceMembers, spaces, users } from "@cap/database/schema";
7-
import { serverEnv } from "@cap/env";
87
import { S3Buckets } from "@cap/web-backend";
98
import { Space } from "@cap/web-domain";
109
import { and, eq, inArray } from "drizzle-orm";
@@ -100,20 +99,7 @@ export async function createSpace(
10099
yield* Effect.promise(() => iconFile.bytes()),
101100
{ contentType: iconFile.type },
102101
);
103-
104-
// Construct the icon URL
105-
if (serverEnv().CAP_AWS_BUCKET_URL) {
106-
// If a custom bucket URL is defined, use it
107-
iconUrl = `${serverEnv().CAP_AWS_BUCKET_URL}/${fileKey}`;
108-
} else if (serverEnv().CAP_AWS_ENDPOINT) {
109-
// For custom endpoints like MinIO
110-
iconUrl = `${serverEnv().CAP_AWS_ENDPOINT}/${bucket.bucketName}/${fileKey}`;
111-
} else {
112-
// Default AWS S3 URL format
113-
iconUrl = `https://${bucket.bucketName}.s3.${
114-
serverEnv().CAP_AWS_REGION || "us-east-1"
115-
}.amazonaws.com/${fileKey}`;
116-
}
102+
iconUrl = fileKey;
117103
}).pipe(runPromise);
118104
} catch (error) {
119105
console.error("Error uploading space icon:", error);
@@ -124,18 +110,15 @@ export async function createSpace(
124110
}
125111
}
126112

127-
await db()
128-
.insert(spaces)
129-
.values({
130-
id: spaceId,
131-
name,
132-
organizationId: user.activeOrganizationId,
133-
createdById: user.id,
134-
iconUrl,
135-
description: iconUrl ? `Space with custom icon: ${iconUrl}` : null,
136-
createdAt: new Date(),
137-
updatedAt: new Date(),
138-
});
113+
await db().insert(spaces).values({
114+
id: spaceId,
115+
name,
116+
organizationId: user.activeOrganizationId,
117+
createdById: user.id,
118+
iconUrl,
119+
createdAt: new Date(),
120+
updatedAt: new Date(),
121+
});
139122

140123
// --- Member Management Logic ---
141124
// Collect member emails from formData

apps/web/actions/organization/remove-icon.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@
33
import { db } from "@cap/database";
44
import { getCurrentUser } from "@cap/database/auth/session";
55
import { organizations } from "@cap/database/schema";
6+
import { S3Buckets } from "@cap/web-backend";
67
import type { Organisation } from "@cap/web-domain";
78
import { eq } from "drizzle-orm";
9+
import { Effect, Option } from "effect";
810
import { revalidatePath } from "next/cache";
11+
import { runPromise } from "@/lib/server";
912

1013
export async function removeOrganizationIcon(
1114
organizationId: Organisation.OrganisationId,
@@ -29,6 +32,39 @@ export async function removeOrganizationIcon(
2932
throw new Error("Only the owner can remove the organization icon");
3033
}
3134

35+
const iconUrl = organization[0]?.iconUrl;
36+
37+
// Delete the icon from S3 if it exists
38+
if (iconUrl) {
39+
try {
40+
// Extract the S3 key - handle both old URL format and new key format
41+
let s3Key = iconUrl;
42+
if (iconUrl.startsWith("http://") || iconUrl.startsWith("https://")) {
43+
const url = new URL(iconUrl);
44+
// Only extract key from URLs with amazonaws.com hostname
45+
if (
46+
url.hostname.endsWith(".amazonaws.com") ||
47+
url.hostname === "amazonaws.com"
48+
) {
49+
s3Key = url.pathname.substring(1); // Remove leading slash
50+
} else {
51+
s3Key = "";
52+
}
53+
}
54+
55+
// Only delete if it looks like an organization icon key
56+
if (s3Key.startsWith("organizations/")) {
57+
await Effect.gen(function* () {
58+
const [bucket] = yield* S3Buckets.getBucketAccess(Option.none());
59+
yield* bucket.deleteObject(s3Key);
60+
}).pipe(runPromise);
61+
}
62+
} catch (error) {
63+
console.error("Error deleting organization icon from S3:", error);
64+
// Continue with database update even if S3 deletion fails
65+
}
66+
}
67+
3268
// Update organization to remove icon URL
3369
await db()
3470
.update(organizations)

apps/web/actions/organization/update-space.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,10 @@ export async function updateSpace(formData: FormData) {
5252
const spaceArr = await db().select().from(spaces).where(eq(spaces.id, id));
5353
const space = spaceArr[0];
5454
if (space?.iconUrl) {
55-
const key = space.iconUrl.match(/organizations\/.+/)?.[0];
55+
// Extract the S3 key (it might already be a key or could be a legacy URL)
56+
const key = space.iconUrl.startsWith("organizations/")
57+
? space.iconUrl
58+
: space.iconUrl.match(/organizations\/.+/)?.[0];
5659

5760
if (key) {
5861
try {

apps/web/actions/organization/upload-organization-icon.ts

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import { db } from "@cap/database";
44
import { getCurrentUser } from "@cap/database/auth/session";
55
import { organizations } from "@cap/database/schema";
6-
import { serverEnv } from "@cap/env";
76
import { S3Buckets } from "@cap/web-backend";
87
import type { Organisation } from "@cap/web-domain";
98
import { eq } from "drizzle-orm";
@@ -52,39 +51,60 @@ export async function uploadOrganizationIcon(
5251
throw new Error("File size must be less than 1MB");
5352
}
5453

54+
// Get the old icon to delete it later
55+
const oldIconUrlOrKey = organization[0]?.iconUrl;
56+
5557
// Create a unique file key
5658
const fileExtension = file.name.split(".").pop();
5759
const fileKey = `organizations/${organizationId}/icon-${Date.now()}.${fileExtension}`;
5860

5961
try {
6062
const sanitizedFile = await sanitizeFile(file);
61-
let iconUrl: string | undefined;
6263

6364
await Effect.gen(function* () {
6465
const [bucket] = yield* S3Buckets.getBucketAccess(Option.none());
6566

67+
// Delete old icon if it exists
68+
if (oldIconUrlOrKey) {
69+
try {
70+
// Extract the S3 key - handle both old URL format and new key format
71+
let oldS3Key = oldIconUrlOrKey;
72+
if (
73+
oldIconUrlOrKey.startsWith("http://") ||
74+
oldIconUrlOrKey.startsWith("https://")
75+
) {
76+
const url = new URL(oldIconUrlOrKey);
77+
// Only extract key from URLs with amazonaws.com hostname
78+
if (
79+
url.hostname.endsWith(".amazonaws.com") ||
80+
url.hostname === "amazonaws.com"
81+
) {
82+
oldS3Key = url.pathname.substring(1); // Remove leading slash
83+
} else {
84+
return;
85+
}
86+
}
87+
88+
// Only delete if it looks like an organization icon key
89+
if (oldS3Key.startsWith("organizations/")) {
90+
yield* bucket.deleteObject(oldS3Key);
91+
}
92+
} catch (error) {
93+
console.error("Error deleting old organization icon from S3:", error);
94+
// Continue with upload even if deletion fails
95+
}
96+
}
97+
6698
const bodyBytes = yield* Effect.promise(async () => {
6799
const buf = await sanitizedFile.arrayBuffer();
68100
return new Uint8Array(buf);
69101
});
70102

71103
yield* bucket.putObject(fileKey, bodyBytes, { contentType: file.type });
72-
// Construct the icon URL
73-
if (serverEnv().CAP_AWS_BUCKET_URL) {
74-
// If a custom bucket URL is defined, use it
75-
iconUrl = `${serverEnv().CAP_AWS_BUCKET_URL}/${fileKey}`;
76-
} else if (serverEnv().CAP_AWS_ENDPOINT) {
77-
// For custom endpoints like MinIO
78-
iconUrl = `${serverEnv().CAP_AWS_ENDPOINT}/${bucket.bucketName}/${fileKey}`;
79-
} else {
80-
// Default AWS S3 URL format
81-
iconUrl = `https://${bucket.bucketName}.s3.${
82-
serverEnv().CAP_AWS_REGION || "us-east-1"
83-
}.amazonaws.com/${fileKey}`;
84-
}
85104
}).pipe(runPromise);
86105

87-
// Update organization with new icon URL
106+
const iconUrl = fileKey;
107+
88108
await db()
89109
.update(organizations)
90110
.set({ iconUrl })

0 commit comments

Comments
 (0)