diff --git a/apps/web/actions/account/remove-profile-image.ts b/apps/web/actions/account/remove-profile-image.ts deleted file mode 100644 index 6689ab9dc9..0000000000 --- a/apps/web/actions/account/remove-profile-image.ts +++ /dev/null @@ -1,66 +0,0 @@ -"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(); - - if (!user) { - 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"); - - return { success: true } as const; -} diff --git a/apps/web/actions/account/upload-profile-image.ts b/apps/web/actions/account/upload-profile-image.ts deleted file mode 100644 index 74c2f6c603..0000000000 --- a/apps/web/actions/account/upload-profile-image.ts +++ /dev/null @@ -1,119 +0,0 @@ -"use server"; - -import { randomUUID } from "node:crypto"; -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 { sanitizeFile } from "@/lib/sanitizeFile"; -import { runPromise } from "@/lib/server"; - -const MAX_FILE_SIZE_BYTES = 3 * 1024 * 1024; // 3MB -const ALLOWED_IMAGE_TYPES = new Map([ - ["image/png", "png"], - ["image/jpeg", "jpg"], - ["image/jpg", "jpg"], -]); - -export async function uploadProfileImage(formData: FormData) { - const user = await getCurrentUser(); - - if (!user) { - throw new Error("Unauthorized"); - } - - const file = formData.get("image") as File | null; - - if (!file) { - throw new Error("No file provided"); - } - - const normalizedType = file.type.toLowerCase(); - const fileExtension = ALLOWED_IMAGE_TYPES.get(normalizedType); - - if (!fileExtension) { - throw new Error("Only PNG or JPEG images are supported"); - } - - if (file.size > MAX_FILE_SIZE_BYTES) { - 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 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); - }); - - yield* bucket.putObject(fileKey, bodyBytes, { - contentType: file.type, - }); - - image = fileKey; - }).pipe(runPromise); - - if (!image) { - throw new Error("Failed to resolve uploaded profile image key"); - } - - const finalImageUrlOrKey = image; - - await db() - .update(users) - .set({ image: finalImageUrlOrKey }) - .where(eq(users.id, user.id)); - - revalidatePath("/dashboard/settings/account"); - - 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"); - } -} diff --git a/apps/web/actions/organization/remove-icon.ts b/apps/web/actions/organization/remove-icon.ts deleted file mode 100644 index b0c54b7423..0000000000 --- a/apps/web/actions/organization/remove-icon.ts +++ /dev/null @@ -1,79 +0,0 @@ -"use server"; - -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, -) { - const user = await getCurrentUser(); - - if (!user) { - throw new Error("Unauthorized"); - } - - const organization = await db() - .select() - .from(organizations) - .where(eq(organizations.id, organizationId)); - - if (!organization || organization.length === 0) { - throw new Error("Organization not found"); - } - - if (organization[0]?.ownerId !== user.id) { - 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) - .set({ - iconUrl: null, - }) - .where(eq(organizations.id, organizationId)); - - revalidatePath("/dashboard/settings/organization"); - - return { success: true }; -} diff --git a/apps/web/actions/organization/upload-organization-icon.ts b/apps/web/actions/organization/upload-organization-icon.ts deleted file mode 100644 index 77b4685176..0000000000 --- a/apps/web/actions/organization/upload-organization-icon.ts +++ /dev/null @@ -1,120 +0,0 @@ -"use server"; - -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 { sanitizeFile } from "@/lib/sanitizeFile"; -import { runPromise } from "@/lib/server"; - -const MAX_FILE_SIZE_BYTES = 1 * 1024 * 1024; // 1MB - -export async function uploadOrganizationIcon( - formData: FormData, - organizationId: Organisation.OrganisationId, -) { - const user = await getCurrentUser(); - - if (!user) { - throw new Error("Unauthorized"); - } - - const organization = await db() - .select() - .from(organizations) - .where(eq(organizations.id, organizationId)); - - if (!organization || organization.length === 0) { - throw new Error("Organization not found"); - } - - if (organization[0]?.ownerId !== user.id) { - throw new Error("Only the owner can update organization icon"); - } - - const file = formData.get("icon") as File | null; - - if (!file) { - throw new Error("No file provided"); - } - - // Validate file type - if (!file.type.startsWith("image/")) { - throw new Error("File must be an image"); - } - - if (file.size > MAX_FILE_SIZE_BYTES) { - 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); - - 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 }); - }).pipe(runPromise); - - const iconUrl = fileKey; - - await db() - .update(organizations) - .set({ iconUrl }) - .where(eq(organizations.id, organizationId)); - - revalidatePath("/dashboard/settings/organization"); - - return { success: true, iconUrl }; - } catch (error) { - console.error("Error uploading organization icon:", error); - throw new Error(error instanceof Error ? error.message : "Upload failed"); - } -} diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/SpaceDialog.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/SpaceDialog.tsx index a8fb79804c..2de90da958 100644 --- a/apps/web/app/(org)/dashboard/_components/Navbar/SpaceDialog.tsx +++ b/apps/web/app/(org)/dashboard/_components/Navbar/SpaceDialog.tsx @@ -18,13 +18,12 @@ import { faLayerGroup } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; import type React from "react"; -import { useEffect, useId, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import * as z from "zod"; import { updateSpace } from "@/actions/organization/update-space"; import { FileInput } from "@/components/FileInput"; -import { SignedImageUrl } from "@/components/SignedImageUrl"; import { useDashboardContext } from "../../Contexts"; import { MemberSelect } from "../../spaces/[spaceId]/components/MemberSelect"; import { createSpace } from "./server"; diff --git a/apps/web/app/(org)/dashboard/settings/account/Settings.tsx b/apps/web/app/(org)/dashboard/settings/account/Settings.tsx index 0de4164e29..96d61bc111 100644 --- a/apps/web/app/(org)/dashboard/settings/account/Settings.tsx +++ b/apps/web/app/(org)/dashboard/settings/account/Settings.tsx @@ -11,12 +11,13 @@ import { } from "@cap/ui"; import { Organisation } from "@cap/web-domain"; import { useMutation } from "@tanstack/react-query"; +import { Effect } from "effect"; import { useRouter } from "next/navigation"; import { useEffect, useId, useState } from "react"; import { toast } from "sonner"; -import { removeProfileImage } from "@/actions/account/remove-profile-image"; -import { uploadProfileImage } from "@/actions/account/upload-profile-image"; import { SignedImageUrl } from "@/components/SignedImageUrl"; +import { useEffectMutation } from "@/lib/EffectRuntime"; +import { withRpc } from "@/lib/Rpcs"; import { useDashboardContext } from "../../Contexts"; import { ProfileImage } from "./components/ProfileImage"; import { patchAccountSettings } from "./server"; @@ -90,46 +91,81 @@ export const Settings = ({ return () => window.removeEventListener("beforeunload", handleBeforeUnload); }, [hasChanges]); - const uploadProfileImageMutation = useMutation({ - mutationFn: async (file: File) => { - const formData = new FormData(); - formData.append("image", file); - return uploadProfileImage(formData); - }, - onSuccess: (result) => { - if (result.success) { - setProfileImageOverride(undefined); - toast.success("Profile image updated successfully"); - router.refresh(); + const uploadProfileImageMutation = useEffectMutation({ + mutationFn: (file: File) => { + if (!user?.id) { + return Effect.fail(new Error("User ID is required")); } - }, - onError: (error) => { - console.error("Error uploading profile image:", error); - setProfileImageOverride(undefined); - toast.error( - error instanceof Error - ? error.message - : "Failed to upload profile image", + + return Effect.promise(() => file.arrayBuffer()).pipe( + Effect.map((arrayBuffer) => new Uint8Array(arrayBuffer)), + Effect.flatMap((data) => + withRpc((rpc) => + rpc.UploadImage({ + data, + contentType: file.type, + fileName: file.name, + type: "user" as const, + entityId: user.id, + oldImageKey: user.image, + }), + ), + ), + Effect.tap(() => + Effect.sync(() => { + setProfileImageOverride(undefined); + toast.success("Profile image updated successfully"); + router.refresh(); + }), + ), + Effect.catchAll((error) => + Effect.sync(() => { + console.error("Error uploading profile image:", error); + setProfileImageOverride(undefined); + toast.error( + error instanceof Error + ? error.message + : "Failed to upload profile image", + ); + throw error; + }), + ), ); }, }); - const removeProfileImageMutation = useMutation({ - mutationFn: removeProfileImage, - onSuccess: (result) => { - if (result?.success) { - setProfileImageOverride(null); - toast.success("Profile image removed"); - router.refresh(); + const removeProfileImageMutation = useEffectMutation({ + mutationFn: () => { + if (!user?.id) { + return Effect.fail(new Error("User ID is required")); } - }, - onError: (error) => { - console.error("Error removing profile image:", error); - setProfileImageOverride(initialProfileImage); - toast.error( - error instanceof Error - ? error.message - : "Failed to remove profile image", + + return withRpc((rpc) => + rpc.RemoveImage({ + imageKey: user.image || "", + type: "user" as const, + entityId: user.id, + }), + ).pipe( + Effect.tap(() => + Effect.sync(() => { + setProfileImageOverride(null); + toast.success("Profile image removed"); + router.refresh(); + }), + ), + Effect.catchAll((error) => + Effect.sync(() => { + console.error("Error removing profile image:", error); + setProfileImageOverride(initialProfileImage); + toast.error( + error instanceof Error + ? error.message + : "Failed to remove profile image", + ); + throw error; + }), + ), ); }, }); diff --git a/apps/web/app/(org)/dashboard/settings/organization/components/OrganizationIcon.tsx b/apps/web/app/(org)/dashboard/settings/organization/components/OrganizationIcon.tsx index 7b7813aa83..32f7947eed 100644 --- a/apps/web/app/(org)/dashboard/settings/organization/components/OrganizationIcon.tsx +++ b/apps/web/app/(org)/dashboard/settings/organization/components/OrganizationIcon.tsx @@ -1,12 +1,13 @@ "use client"; import { CardDescription, Label } from "@cap/ui"; +import { Effect } from "effect"; import { useRouter } from "next/navigation"; import { useId, useState } from "react"; import { toast } from "sonner"; -import { removeOrganizationIcon } from "@/actions/organization/remove-icon"; -import { uploadOrganizationIcon } from "@/actions/organization/upload-organization-icon"; import { FileInput } from "@/components/FileInput"; +import * as EffectRuntime from "@/lib/EffectRuntime"; +import { withRpc } from "@/lib/Rpcs"; import { useDashboardContext } from "../../../Contexts"; export const OrganizationIcon = () => { @@ -25,14 +26,29 @@ export const OrganizationIcon = () => { // Upload the file to the server immediately try { setIsUploading(true); - const fd = new FormData(); - fd.append("icon", file); - const result = await uploadOrganizationIcon(fd, organizationId); - if (result.success) { - toast.success("Organization icon updated successfully"); - router.refresh(); - } + const arrayBuffer = await file.arrayBuffer(); + const data = new Uint8Array(arrayBuffer); + + await EffectRuntime.EffectRuntime.runPromise( + withRpc((rpc) => + rpc.UploadImage({ + data, + contentType: file.type, + fileName: file.name, + type: "organization" as const, + entityId: organizationId, + oldImageKey: existingIconUrl, + }), + ).pipe( + Effect.tap(() => + Effect.sync(() => { + toast.success("Organization icon updated successfully"); + router.refresh(); + }), + ), + ), + ); } catch (error) { toast.error( error instanceof Error ? error.message : "Failed to upload icon", @@ -46,12 +62,22 @@ export const OrganizationIcon = () => { if (!organizationId) return; try { - const result = await removeOrganizationIcon(organizationId); - - if (result?.success) { - toast.success("Organization icon removed successfully"); - router.refresh(); - } + await EffectRuntime.EffectRuntime.runPromise( + withRpc((rpc) => + rpc.RemoveImage({ + imageKey: existingIconUrl || "", + type: "organization" as const, + entityId: organizationId, + }), + ).pipe( + Effect.tap(() => + Effect.sync(() => { + toast.success("Organization icon removed successfully"); + router.refresh(); + }), + ), + ), + ); } catch (error) { console.error("Error removing organization icon:", error); toast.error( diff --git a/apps/web/components/FileInput.tsx b/apps/web/components/FileInput.tsx index 0c0c1b0e25..25cdfe55d6 100644 --- a/apps/web/components/FileInput.tsx +++ b/apps/web/components/FileInput.tsx @@ -1,6 +1,6 @@ "use client"; -import { Button, Input } from "@cap/ui"; +import { Button, Input, LoadingSpinner } from "@cap/ui"; import { faCloudUpload, faSpinner, @@ -12,6 +12,7 @@ import type React from "react"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { useSignedImageUrl } from "@/lib/use-signed-image-url"; +import { SignedImageUrl } from "./SignedImageUrl"; import { Tooltip } from "./Tooltip"; const ALLOWED_IMAGE_TYPES = new Set(["image/jpeg", "image/png"]); @@ -61,13 +62,6 @@ export const FileInput: React.FC = ({ // Get signed URL for S3 keys const { data: signedUrl } = useSignedImageUrl(previewUrl, type); - function isS3Key(imageKeyOrUrl: string | null | undefined): boolean { - if (!imageKeyOrUrl) return false; - return ( - imageKeyOrUrl.startsWith("users/") || - imageKeyOrUrl.startsWith("organizations/") - ); - } const previousPreviewRef = useRef<{ url: string | null; isLocal: boolean; @@ -235,7 +229,12 @@ export const FileInput: React.FC = ({ }} > {/* Fixed height container to prevent resizing */} - {previewUrl ? ( + {isLoading ? ( +
+ +

Uploading...

+
+ ) : previewUrl ? (
@@ -250,19 +249,13 @@ export const FileInput: React.FC = ({ }} className="flex overflow-hidden relative flex-shrink-0 justify-center items-center rounded-full" > - {previewUrl && ( - File preview - )} +
diff --git a/packages/ui/src/components/LoadingSpinner.tsx b/packages/ui/src/components/LoadingSpinner.tsx index 4b03bae671..0168f1af66 100644 --- a/packages/ui/src/components/LoadingSpinner.tsx +++ b/packages/ui/src/components/LoadingSpinner.tsx @@ -2,6 +2,7 @@ export const LoadingSpinner = ({ size = 36, color = "white", borderColor = "rgba(255, 255, 255, 0.2)", + themeColors = false, thickness = 3, speed = 1.5, className, @@ -9,17 +10,20 @@ export const LoadingSpinner = ({ size?: number; color?: string; borderColor?: string; + themeColors?: boolean; thickness?: number; speed?: number; className?: string; }) => { + const borderColorValue = themeColors ? "var(--gray-4)" : borderColor; + const colorValue = themeColors ? "var(--gray-12)" : color; const spinnerStyle = { width: `${size}px`, minWidth: `${size}px`, height: `${size}px`, minHeight: `${size}px`, - border: `${thickness}px solid ${borderColor}`, - borderTop: `${thickness}px solid ${color}`, + border: `${thickness}px solid ${borderColorValue}`, + borderTop: `${thickness}px solid ${colorValue}`, borderRadius: "50%", animation: `spin ${1 / speed}s linear infinite`, }; diff --git a/packages/web-backend/src/Users/UsersRpcs.ts b/packages/web-backend/src/Users/UsersRpcs.ts index 41a6d80540..f041827183 100644 --- a/packages/web-backend/src/Users/UsersRpcs.ts +++ b/packages/web-backend/src/Users/UsersRpcs.ts @@ -1,12 +1,17 @@ -import { InternalError, User } from "@cap/web-domain"; +import * as Db from "@cap/database/schema"; +import { InternalError, Organisation, User } from "@cap/web-domain"; +import * as Dz from "drizzle-orm"; import { Effect, Layer, Option } from "effect"; +import { Database } from "../Database"; import { S3Buckets } from "../S3Buckets"; +import { parseImageKey } from "./helpers"; import { UsersOnboarding } from "./UsersOnboarding"; export const UsersRpcsLive = User.UserRpcs.toLayer( Effect.gen(function* () { const onboarding = yield* UsersOnboarding; const s3Buckets = yield* S3Buckets; + const db = yield* Database; return { UserCompleteOnboardingStep: (payload) => Effect.gen(function* () { @@ -55,6 +60,105 @@ export const UsersRpcsLive = User.UserRpcs.toLayer( ), Effect.catchAll(() => new InternalError({ type: "unknown" })), ), + UploadImage: (payload) => + Effect.gen(function* () { + const oldS3KeyOption = yield* parseImageKey( + payload.oldImageKey, + payload.type, + ); + const [bucket] = yield* s3Buckets.getBucketAccess(Option.none()); + + // Delete old image if it exists and is valid + if (Option.isSome(oldS3KeyOption)) { + yield* bucket.deleteObject(oldS3KeyOption.value); + } + + // Generate new S3 key + const timestamp = Date.now(); + const fileExtension = payload.fileName.split(".").pop() || "jpg"; + const s3Key = `${payload.type}s/${payload.entityId}/${timestamp}.${fileExtension}`; + + // Upload new image + const buffer = Buffer.from(payload.data); + yield* bucket.putObject(s3Key, buffer, { + contentType: payload.contentType, + }); + + // Update database + if (payload.type === "user") { + yield* db.use((db) => + db + .update(Db.users) + .set({ image: s3Key }) + .where(Dz.eq(Db.users.id, User.UserId.make(payload.entityId))), + ); + } else { + yield* db.use((db) => + db + .update(Db.organizations) + .set({ iconUrl: s3Key }) + .where( + Dz.eq( + Db.organizations.id, + Organisation.OrganisationId.make(payload.entityId), + ), + ), + ); + } + + return { key: s3Key }; + }).pipe( + Effect.catchTag("S3Error", () => new InternalError({ type: "s3" })), + Effect.catchTag( + "DatabaseError", + () => new InternalError({ type: "database" }), + ), + Effect.catchAll(() => new InternalError({ type: "unknown" })), + ), + RemoveImage: (payload) => + Effect.gen(function* () { + const s3KeyOption = yield* parseImageKey( + payload.imageKey, + payload.type, + ); + const [bucket] = yield* s3Buckets.getBucketAccess(Option.none()); + + // Only delete if we have a valid S3 key + if (Option.isSome(s3KeyOption)) { + yield* bucket.deleteObject(s3KeyOption.value); + } + + // Update database + if (payload.type === "user") { + yield* db.use((db) => + db + .update(Db.users) + .set({ image: null }) + .where(Dz.eq(Db.users.id, User.UserId.make(payload.entityId))), + ); + } else { + yield* db.use((db) => + db + .update(Db.organizations) + .set({ iconUrl: null }) + .where( + Dz.eq( + Db.organizations.id, + Organisation.OrganisationId.make(payload.entityId), + ), + ), + ); + } + + return { success: true as const }; + }).pipe( + Effect.catchTag("S3Error", () => new InternalError({ type: "s3" })), + Effect.catchTag( + "DatabaseError", + () => new InternalError({ type: "database" }), + ), + Effect.catchAll(() => new InternalError({ type: "unknown" })), + ), }; }), -).pipe(Layer.provide(UsersOnboarding.Default)); +).pipe(Layer.provide([UsersOnboarding.Default, Database.Default])); diff --git a/packages/web-backend/src/Users/helpers.ts b/packages/web-backend/src/Users/helpers.ts new file mode 100644 index 0000000000..4bb71240cf --- /dev/null +++ b/packages/web-backend/src/Users/helpers.ts @@ -0,0 +1,36 @@ +import { InternalError } from "@cap/web-domain"; +import { Effect, Option } from "effect"; +import * as path from "path"; + +export const parseImageKey = ( + imageKey: string | null | undefined, + expectedType: "user" | "organization", +): Effect.Effect, InternalError> => + Effect.gen(function* () { + // Return None if no image key provided + if (!imageKey || imageKey.trim() === "") { + return Option.none(); + } + + let s3Key = imageKey; + if (imageKey.startsWith("http://") || imageKey.startsWith("https://")) { + const url = new URL(imageKey); + const raw = url.pathname.startsWith("/") + ? url.pathname.slice(1) + : url.pathname; + const decoded = decodeURIComponent(raw); + const normalized = path.posix.normalize(decoded); + if (normalized.includes("..")) { + return yield* Effect.fail(new InternalError({ type: "unknown" })); + } + s3Key = normalized; + } + + const expectedPrefix = + expectedType === "user" ? "users/" : "organizations/"; + if (!s3Key.startsWith(expectedPrefix)) { + return Option.none(); + } + + return Option.some(s3Key); + }); diff --git a/packages/web-domain/src/User.ts b/packages/web-domain/src/User.ts index 7a0a1b2816..5b88c828ee 100644 --- a/packages/web-domain/src/User.ts +++ b/packages/web-domain/src/User.ts @@ -77,6 +77,29 @@ export const GetSignedImageUrlResult = Schema.Struct({ url: Schema.String, }); +export const UploadImagePayload = Schema.Struct({ + data: Schema.Uint8Array, + contentType: Schema.String, + fileName: Schema.String, + type: Schema.Literal("user", "organization"), + entityId: Schema.String, + oldImageKey: Schema.optional(Schema.NullOr(Schema.String)), +}); + +export const UploadImageResult = Schema.Struct({ + key: Schema.String, +}); + +export const RemoveImagePayload = Schema.Struct({ + imageKey: Schema.String, + type: Schema.Literal("user", "organization"), + entityId: Schema.String, +}); + +export const RemoveImageResult = Schema.Struct({ + success: Schema.Literal(true), +}); + export class UserRpcs extends RpcGroup.make( Rpc.make("UserCompleteOnboardingStep", { payload: OnboardingStepPayload, @@ -88,4 +111,14 @@ export class UserRpcs extends RpcGroup.make( success: GetSignedImageUrlResult, error: InternalError, }).middleware(RpcAuthMiddleware), + Rpc.make("UploadImage", { + payload: UploadImagePayload, + success: UploadImageResult, + error: InternalError, + }).middleware(RpcAuthMiddleware), + Rpc.make("RemoveImage", { + payload: RemoveImagePayload, + success: RemoveImageResult, + error: InternalError, + }).middleware(RpcAuthMiddleware), ) {} diff --git a/packages/web-domain/src/index.ts b/packages/web-domain/src/index.ts index 560c124a52..e3ca891547 100644 --- a/packages/web-domain/src/index.ts +++ b/packages/web-domain/src/index.ts @@ -14,4 +14,5 @@ export * as S3Bucket from "./S3Bucket.ts"; export { S3Error } from "./S3Bucket.ts"; export * as Space from "./Space.ts"; export * as User from "./User.ts"; +export { UserId } from "./User.ts"; export * as Video from "./Video.ts";