diff --git a/apps/web/actions/folders/add-videos.ts b/apps/web/actions/folders/add-videos.ts index 817da61914d..2bd4b2b6d41 100644 --- a/apps/web/actions/folders/add-videos.ts +++ b/apps/web/actions/folders/add-videos.ts @@ -12,6 +12,7 @@ import { import type { Folder, Space, Video } from "@cap/web-domain"; import { and, eq, inArray } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { getSpaceAccess } from "@/actions/organization/space-authorization"; export async function addVideosToFolder( folderId: Folder.FolderId, @@ -30,7 +31,11 @@ export async function addVideosToFolder( } const [folder] = await db() - .select({ id: folders.id, spaceId: folders.spaceId }) + .select({ + id: folders.id, + spaceId: folders.spaceId, + createdById: folders.createdById, + }) .from(folders) .where(eq(folders.id, folderId)); @@ -38,6 +43,20 @@ export async function addVideosToFolder( throw new Error("Folder not found"); } + // Verify the caller can edit the destination folder. Mirrors + // FoldersPolicy.canEdit: personal folders (no space) are creator-only, + // space folders require space-admin or org admin/owner access. + if (folder.spaceId === null) { + if (folder.createdById !== user.id) { + throw new Error("Folder not found"); + } + } else { + const access = await getSpaceAccess(user.id, folder.spaceId); + if (!access?.canManage) { + throw new Error("Folder not found"); + } + } + const userVideos = await db() .select({ id: videos.id }) .from(videos) diff --git a/apps/web/actions/folders/get-folder-videos.ts b/apps/web/actions/folders/get-folder-videos.ts index 704f6aeb224..ad7881147a2 100644 --- a/apps/web/actions/folders/get-folder-videos.ts +++ b/apps/web/actions/folders/get-folder-videos.ts @@ -2,9 +2,10 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; -import { sharedVideos, spaceVideos } from "@cap/database/schema"; +import { folders, sharedVideos, spaceVideos } from "@cap/database/schema"; import type { Folder, Space, Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; +import { getSpaceAccess } from "@/actions/organization/space-authorization"; export async function getFolderVideoIds( folderId: Folder.FolderId, @@ -21,6 +22,40 @@ export async function getFolderVideoIds( throw new Error("Folder ID is required"); } + // Ensure the caller can see this folder before disclosing its contents. + const [folder] = await db() + .select({ + spaceId: folders.spaceId, + organizationId: folders.organizationId, + createdById: folders.createdById, + }) + .from(folders) + .where(eq(folders.id, folderId)); + + if (!folder) { + throw new Error("Folder not found"); + } + + if (folder.spaceId === null) { + // Personal folders are creator-only (mirrors FoldersPolicy.canEdit and + // add-videos.ts); org membership must NOT grant access to another user's + // personal folder. + if (folder.createdById !== user.id) { + throw new Error("Folder not found"); + } + } else { + // getSpaceAccess returns a non-null object even for non-members (with + // both roles null), so a bare `!access` check would NOT block them. + // Require an actual org or space role to view the folder's contents. + const access = await getSpaceAccess(user.id, folder.spaceId); + if ( + !access || + (access.organizationRole === null && access.spaceRole === null) + ) { + throw new Error("Folder not found"); + } + } + const isAllSpacesEntry = user.activeOrganizationId === spaceId; const rows = isAllSpacesEntry diff --git a/apps/web/actions/folders/moveVideoToFolder.ts b/apps/web/actions/folders/moveVideoToFolder.ts index 59d8de51d05..861f407520d 100644 --- a/apps/web/actions/folders/moveVideoToFolder.ts +++ b/apps/web/actions/folders/moveVideoToFolder.ts @@ -11,6 +11,9 @@ import { import type { Folder, Space, Video } from "@cap/web-domain"; import { and, eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { requireOrganizationSettingsManager } from "@/actions/organization/authorization"; +import { getSpaceAccess } from "@/actions/organization/space-authorization"; + export async function moveVideoToFolder({ videoId, folderId, @@ -36,10 +39,18 @@ export async function moveVideoToFolder({ const isAllSpacesEntry = spaceId === user.activeOrganizationId; - // If folderId is provided, verify it exists and belongs to the same organization + // If a destination folder is provided, load it once (scoped to the caller's + // org) so each branch can also verify the caller may WRITE to that specific + // folder — not just that the source space/folder is manageable. + let destinationFolder: + | { spaceId: string | null; createdById: string } + | undefined; if (folderId) { - const [folder] = await db() - .select() + [destinationFolder] = await db() + .select({ + spaceId: folders.spaceId, + createdById: folders.createdById, + }) .from(folders) .where( and( @@ -48,12 +59,22 @@ export async function moveVideoToFolder({ ), ); - if (!folder) { + if (!destinationFolder) { throw new Error("Folder not found or not accessible"); } } if (spaceId && !isAllSpacesEntry) { + const access = await getSpaceAccess(user.id, spaceId); + if (!access?.canManage) { + throw new Error("You don't have permission to manage this space"); + } + + // The destination folder must belong to the same space being managed. + if (destinationFolder && destinationFolder.spaceId !== spaceId) { + throw new Error("Folder not found or not accessible"); + } + await db() .update(spaceVideos) .set({ @@ -63,6 +84,16 @@ export async function moveVideoToFolder({ and(eq(spaceVideos.videoId, videoId), eq(spaceVideos.spaceId, spaceId)), ); } else if (spaceId && isAllSpacesEntry) { + await requireOrganizationSettingsManager( + user.id, + user.activeOrganizationId, + ); + + // The destination must be an org-level (non-space) folder. + if (destinationFolder && destinationFolder.spaceId !== null) { + throw new Error("Folder not found or not accessible"); + } + await db() .update(sharedVideos) .set({ @@ -75,12 +106,21 @@ export async function moveVideoToFolder({ ), ); } else { + // Personal move: the destination must be the caller's own personal folder. + if ( + destinationFolder && + (destinationFolder.spaceId !== null || + destinationFolder.createdById !== user.id) + ) { + throw new Error("Folder not found or not accessible"); + } + await db() .update(videos) .set({ folderId: folderId === null ? null : folderId, }) - .where(eq(videos.id, videoId)); + .where(and(eq(videos.id, videoId), eq(videos.ownerId, user.id))); } // Always revalidate the main caps page diff --git a/apps/web/actions/organizations/get-organization-videos.ts b/apps/web/actions/organizations/get-organization-videos.ts index 2e584388f4c..39ead8c4b2c 100644 --- a/apps/web/actions/organizations/get-organization-videos.ts +++ b/apps/web/actions/organizations/get-organization-videos.ts @@ -5,6 +5,7 @@ import { getCurrentUser } from "@cap/database/auth/session"; import { sharedVideos } from "@cap/database/schema"; import type { Organisation } from "@cap/web-domain"; import { and, eq, isNull } from "drizzle-orm"; +import { getOrganizationAccess } from "@/actions/organization/authorization"; export async function getOrganizationVideoIds( organizationId: Organisation.OrganisationId, @@ -20,6 +21,12 @@ export async function getOrganizationVideoIds( throw new Error("Organization ID is required"); } + // Only members/owner of the organization may see its shared videos. + const access = await getOrganizationAccess(user.id, organizationId); + if (!access) { + throw new Error("Organization not found"); + } + const videoIds = await db() .select({ videoId: sharedVideos.videoId, diff --git a/apps/web/actions/spaces/get-space-videos.ts b/apps/web/actions/spaces/get-space-videos.ts index ddbcc42b823..5c3581907e1 100644 --- a/apps/web/actions/spaces/get-space-videos.ts +++ b/apps/web/actions/spaces/get-space-videos.ts @@ -5,6 +5,8 @@ import { getCurrentUser } from "@cap/database/auth/session"; import { sharedVideos, spaceVideos } from "@cap/database/schema"; import type { Space } from "@cap/web-domain"; import { and, eq, isNull } from "drizzle-orm"; +import { getOrganizationAccess } from "@/actions/organization/authorization"; +import { getSpaceAccess } from "@/actions/organization/space-authorization"; export async function getSpaceVideoIds(spaceId: Space.SpaceIdOrOrganisationId) { try { @@ -20,6 +22,25 @@ export async function getSpaceVideoIds(spaceId: Space.SpaceIdOrOrganisationId) { const isAllSpacesEntry = user.activeOrganizationId === spaceId; + // Only members/owner of the space (or organization) may see its videos. + if (isAllSpacesEntry) { + const access = await getOrganizationAccess(user.id, spaceId); + if (!access) { + throw new Error("Space not found"); + } + } else { + // getSpaceAccess returns a non-null object even for non-members (with + // both roles null), so a bare `!access` check would NOT block them. + // Require an actual org or space role to view the space's videos. + const access = await getSpaceAccess(user.id, spaceId); + if ( + !access || + (access.organizationRole === null && access.spaceRole === null) + ) { + throw new Error("Space not found"); + } + } + const videoIds = isAllSpacesEntry ? await db() .select({ diff --git a/apps/web/app/(org)/dashboard/folder/[id]/page.tsx b/apps/web/app/(org)/dashboard/folder/[id]/page.tsx index 2495073044f..5b8d4265537 100644 --- a/apps/web/app/(org)/dashboard/folder/[id]/page.tsx +++ b/apps/web/app/(org)/dashboard/folder/[id]/page.tsx @@ -4,6 +4,7 @@ import { makeCurrentUserLayer } from "@cap/web-backend"; import { Folder } from "@cap/web-domain"; import { Effect } from "effect"; import { notFound } from "next/navigation"; +import { getSpaceAccess } from "@/actions/organization/space-authorization"; import { getChildFolders, getFolderBreadcrumb, @@ -31,6 +32,33 @@ const FolderPage = async (props: PageProps<"/dashboard/folder/[id]">) => { const user = await getCurrentUser(); if (!user || !user.activeOrganizationId) return notFound(); + // Ensure the folder belongs to a space the caller can access before + // disclosing its contents (mirrors FoldersPolicy: personal folders are + // creator-only, space folders require space/org membership). A missing folder + // surfaces as notFound() rather than an unhandled 500. + const folderForAccess = await getFolderById(folderId).pipe( + Effect.provide(makeCurrentUserLayer(user)), + Effect.catchAll(() => Effect.succeed(null)), + runPromise, + ); + + if (!folderForAccess) return notFound(); + + if (folderForAccess.spaceId === null) { + if (folderForAccess.createdById !== user.id) return notFound(); + } else { + // getSpaceAccess returns a non-null object even for non-members (both roles + // null), so check for an actual role. Using space access (not org-only) + // keeps legitimate space members who aren't org members from being blocked. + const access = await getSpaceAccess(user.id, folderForAccess.spaceId); + if ( + !access || + (access.organizationRole === null && access.spaceRole === null) + ) { + return notFound(); + } + } + return Effect.gen(function* () { const [childFolders, breadcrumb, videosData, share] = yield* Effect.all( [