Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion apps/web/actions/account/remove-profile-image.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
"use server";

import path from "node:path";
import { db } from "@cap/database";
import { getCurrentUser } from "@cap/database/auth/session";
import { users } from "@cap/database/schema";
import { S3Buckets } from "@cap/web-backend";
import { eq } from "drizzle-orm";
import { Effect, Option } from "effect";
import { revalidatePath } from "next/cache";
import { runPromise } from "@/lib/server";

export async function removeProfileImage() {
const user = await getCurrentUser();
Expand All @@ -13,10 +17,50 @@ export async function removeProfileImage() {
throw new Error("Unauthorized");
}

const image = user.image;

// Delete the profile image from S3 if it exists
if (image) {
try {
// Extract the S3 key - handle both old URL format and new key format
let s3Key = image;
if (image.startsWith("http://") || image.startsWith("https://")) {
const url = new URL(image);
// Only extract key from URLs with amazonaws.com hostname
if (
url.hostname.endsWith(".amazonaws.com") ||
url.hostname === "amazonaws.com"
) {
const raw = url.pathname.startsWith("/")
? url.pathname.slice(1)
: url.pathname;
const decoded = decodeURIComponent(raw);
const normalized = path.posix.normalize(decoded);
if (normalized.includes("..")) {
throw new Error("Invalid S3 key path");
}
s3Key = normalized;
} else {
// Not an S3 URL, skip deletion of S3 object; continue with DB update below
}
}

// Only delete if it looks like a user profile image key
if (s3Key.startsWith("users/")) {
await Effect.gen(function* () {
const [bucket] = yield* S3Buckets.getBucketAccess(Option.none());
yield* bucket.deleteObject(s3Key);
}).pipe(runPromise);
}
} catch (error) {
console.error("Error deleting profile image from S3:", error);
// Continue with database update even if S3 deletion fails
}
}

await db().update(users).set({ image: null }).where(eq(users.id, user.id));

revalidatePath("/dashboard/settings/account");
revalidatePath("/dashboard", "layout");

return { success: true } as const;
}
59 changes: 42 additions & 17 deletions apps/web/actions/account/upload-profile-image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { randomUUID } from "node:crypto";
import { db } from "@cap/database";
import { getCurrentUser } from "@cap/database/auth/session";
import { users } from "@cap/database/schema";
import { serverEnv } from "@cap/env";
import { S3Buckets } from "@cap/web-backend";
import { eq } from "drizzle-orm";
import { Effect, Option } from "effect";
Expand Down Expand Up @@ -43,15 +42,50 @@ export async function uploadProfileImage(formData: FormData) {
throw new Error("File size must be 3MB or less");
}

// Get the old profile image to delete it later
const oldImageUrlOrKey = user.image;

const fileKey = `users/${user.id}/profile-${Date.now()}-${randomUUID()}.${fileExtension}`;

try {
const sanitizedFile = await sanitizeFile(file);
let imageUrl: string | undefined;
let image: string | null = null;

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

// Delete old profile image if it exists
if (oldImageUrlOrKey) {
try {
// Extract the S3 key - handle both old URL format and new key format
let oldS3Key = oldImageUrlOrKey;
if (
oldImageUrlOrKey.startsWith("http://") ||
oldImageUrlOrKey.startsWith("https://")
) {
const url = new URL(oldImageUrlOrKey);
// Only extract key from URLs with amazonaws.com hostname
if (
url.hostname.endsWith(".amazonaws.com") ||
url.hostname === "amazonaws.com"
) {
oldS3Key = url.pathname.substring(1); // Remove leading slash
} else {
// Not an S3 URL, skip deletion
return;
}
}

// Only delete if it looks like a user profile image key
if (oldS3Key.startsWith("users/")) {
yield* bucket.deleteObject(oldS3Key);
}
} catch (error) {
console.error("Error deleting old profile image from S3:", error);
// Continue with upload even if deletion fails
}
}

const bodyBytes = yield* Effect.promise(async () => {
const buf = await sanitizedFile.arrayBuffer();
return new Uint8Array(buf);
Expand All @@ -61,32 +95,23 @@ export async function uploadProfileImage(formData: FormData) {
contentType: file.type,
});

if (serverEnv().CAP_AWS_BUCKET_URL) {
imageUrl = `${serverEnv().CAP_AWS_BUCKET_URL}/${fileKey}`;
} else if (serverEnv().CAP_AWS_ENDPOINT) {
imageUrl = `${serverEnv().CAP_AWS_ENDPOINT}/${bucket.bucketName}/${fileKey}`;
} else {
imageUrl = `https://${bucket.bucketName}.s3.${
serverEnv().CAP_AWS_REGION || "us-east-1"
}.amazonaws.com/${fileKey}`;
}
image = fileKey;
}).pipe(runPromise);

if (typeof imageUrl !== "string" || imageUrl.length === 0) {
throw new Error("Failed to resolve uploaded profile image URL");
if (!image) {
throw new Error("Failed to resolve uploaded profile image key");
}

const finalImageUrl = imageUrl;
const finalImageUrlOrKey = image;

await db()
.update(users)
.set({ image: finalImageUrl })
.set({ image: finalImageUrlOrKey })
.where(eq(users.id, user.id));

revalidatePath("/dashboard/settings/account");
revalidatePath("/dashboard", "layout");

return { success: true, imageUrl: finalImageUrl } as const;
return { success: true, image: finalImageUrlOrKey } as const;
} catch (error) {
console.error("Error uploading profile image:", error);
throw new Error(error instanceof Error ? error.message : "Upload failed");
Expand Down
37 changes: 10 additions & 27 deletions apps/web/actions/organization/create-space.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { db } from "@cap/database";
import { getCurrentUser } from "@cap/database/auth/session";
import { nanoId, nanoIdLength } from "@cap/database/helpers";
import { spaceMembers, spaces, users } from "@cap/database/schema";
import { serverEnv } from "@cap/env";
import { S3Buckets } from "@cap/web-backend";
import { Space } from "@cap/web-domain";
import { and, eq, inArray } from "drizzle-orm";
Expand Down Expand Up @@ -100,20 +99,7 @@ export async function createSpace(
yield* Effect.promise(() => iconFile.bytes()),
{ contentType: iconFile.type },
);

// Construct the icon URL
if (serverEnv().CAP_AWS_BUCKET_URL) {
// If a custom bucket URL is defined, use it
iconUrl = `${serverEnv().CAP_AWS_BUCKET_URL}/${fileKey}`;
} else if (serverEnv().CAP_AWS_ENDPOINT) {
// For custom endpoints like MinIO
iconUrl = `${serverEnv().CAP_AWS_ENDPOINT}/${bucket.bucketName}/${fileKey}`;
} else {
// Default AWS S3 URL format
iconUrl = `https://${bucket.bucketName}.s3.${
serverEnv().CAP_AWS_REGION || "us-east-1"
}.amazonaws.com/${fileKey}`;
}
iconUrl = fileKey;
}).pipe(runPromise);
} catch (error) {
console.error("Error uploading space icon:", error);
Expand All @@ -124,18 +110,15 @@ export async function createSpace(
}
}

await db()
.insert(spaces)
.values({
id: spaceId,
name,
organizationId: user.activeOrganizationId,
createdById: user.id,
iconUrl,
description: iconUrl ? `Space with custom icon: ${iconUrl}` : null,
createdAt: new Date(),
updatedAt: new Date(),
});
await db().insert(spaces).values({
id: spaceId,
name,
organizationId: user.activeOrganizationId,
createdById: user.id,
iconUrl,
createdAt: new Date(),
updatedAt: new Date(),
});

// --- Member Management Logic ---
// Collect member emails from formData
Expand Down
36 changes: 36 additions & 0 deletions apps/web/actions/organization/remove-icon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@
import { db } from "@cap/database";
import { getCurrentUser } from "@cap/database/auth/session";
import { organizations } from "@cap/database/schema";
import { S3Buckets } from "@cap/web-backend";
import type { Organisation } from "@cap/web-domain";
import { eq } from "drizzle-orm";
import { Effect, Option } from "effect";
import { revalidatePath } from "next/cache";
import { runPromise } from "@/lib/server";

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

const iconUrl = organization[0]?.iconUrl;

// Delete the icon from S3 if it exists
if (iconUrl) {
try {
// Extract the S3 key - handle both old URL format and new key format
let s3Key = iconUrl;
if (iconUrl.startsWith("http://") || iconUrl.startsWith("https://")) {
const url = new URL(iconUrl);
// Only extract key from URLs with amazonaws.com hostname
if (
url.hostname.endsWith(".amazonaws.com") ||
url.hostname === "amazonaws.com"
) {
s3Key = url.pathname.substring(1); // Remove leading slash
} else {
s3Key = "";
}
}

// Only delete if it looks like an organization icon key
if (s3Key.startsWith("organizations/")) {
await Effect.gen(function* () {
const [bucket] = yield* S3Buckets.getBucketAccess(Option.none());
yield* bucket.deleteObject(s3Key);
}).pipe(runPromise);
}
} catch (error) {
console.error("Error deleting organization icon from S3:", error);
// Continue with database update even if S3 deletion fails
}
}

// Update organization to remove icon URL
await db()
.update(organizations)
Expand Down
5 changes: 4 additions & 1 deletion apps/web/actions/organization/update-space.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@ export async function updateSpace(formData: FormData) {
const spaceArr = await db().select().from(spaces).where(eq(spaces.id, id));
const space = spaceArr[0];
if (space?.iconUrl) {
const key = space.iconUrl.match(/organizations\/.+/)?.[0];
// Extract the S3 key (it might already be a key or could be a legacy URL)
const key = space.iconUrl.startsWith("organizations/")
? space.iconUrl
: space.iconUrl.match(/organizations\/.+/)?.[0];

if (key) {
try {
Expand Down
52 changes: 36 additions & 16 deletions apps/web/actions/organization/upload-organization-icon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import { db } from "@cap/database";
import { getCurrentUser } from "@cap/database/auth/session";
import { organizations } from "@cap/database/schema";
import { serverEnv } from "@cap/env";
import { S3Buckets } from "@cap/web-backend";
import type { Organisation } from "@cap/web-domain";
import { eq } from "drizzle-orm";
Expand Down Expand Up @@ -52,39 +51,60 @@ export async function uploadOrganizationIcon(
throw new Error("File size must be less than 1MB");
}

// Get the old icon to delete it later
const oldIconUrlOrKey = organization[0]?.iconUrl;

// Create a unique file key
const fileExtension = file.name.split(".").pop();
const fileKey = `organizations/${organizationId}/icon-${Date.now()}.${fileExtension}`;

try {
const sanitizedFile = await sanitizeFile(file);
let iconUrl: string | undefined;

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

// Delete old icon if it exists
if (oldIconUrlOrKey) {
try {
// Extract the S3 key - handle both old URL format and new key format
let oldS3Key = oldIconUrlOrKey;
if (
oldIconUrlOrKey.startsWith("http://") ||
oldIconUrlOrKey.startsWith("https://")
) {
const url = new URL(oldIconUrlOrKey);
// Only extract key from URLs with amazonaws.com hostname
if (
url.hostname.endsWith(".amazonaws.com") ||
url.hostname === "amazonaws.com"
) {
oldS3Key = url.pathname.substring(1); // Remove leading slash
} else {
return;
}
}

// Only delete if it looks like an organization icon key
if (oldS3Key.startsWith("organizations/")) {
yield* bucket.deleteObject(oldS3Key);
}
} catch (error) {
console.error("Error deleting old organization icon from S3:", error);
// Continue with upload even if deletion fails
}
}

const bodyBytes = yield* Effect.promise(async () => {
const buf = await sanitizedFile.arrayBuffer();
return new Uint8Array(buf);
});

yield* bucket.putObject(fileKey, bodyBytes, { contentType: file.type });
// Construct the icon URL
if (serverEnv().CAP_AWS_BUCKET_URL) {
// If a custom bucket URL is defined, use it
iconUrl = `${serverEnv().CAP_AWS_BUCKET_URL}/${fileKey}`;
} else if (serverEnv().CAP_AWS_ENDPOINT) {
// For custom endpoints like MinIO
iconUrl = `${serverEnv().CAP_AWS_ENDPOINT}/${bucket.bucketName}/${fileKey}`;
} else {
// Default AWS S3 URL format
iconUrl = `https://${bucket.bucketName}.s3.${
serverEnv().CAP_AWS_REGION || "us-east-1"
}.amazonaws.com/${fileKey}`;
}
}).pipe(runPromise);

// Update organization with new icon URL
const iconUrl = fileKey;

await db()
.update(organizations)
.set({ iconUrl })
Expand Down
Loading