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
97 changes: 97 additions & 0 deletions apps/web/actions/folders/add-videos.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"use server";

import { db } from "@cap/database";
import { getCurrentUser } from "@cap/database/auth/session";
import {
folders,
sharedVideos,
spaceVideos,
videos,
} from "@cap/database/schema";
import type { Folder, Space, Video } from "@cap/web-domain";
import { and, eq, inArray } from "drizzle-orm";
import { revalidatePath } from "next/cache";

export async function addVideosToFolder(
folderId: Folder.FolderId,
videoIds: Video.VideoId[],
spaceId: Space.SpaceIdOrOrganisationId,
) {
try {
const user = await getCurrentUser();

if (!user || !user.id) {
throw new Error("Unauthorized");
}

if (!folderId || !videoIds || videoIds.length === 0) {
throw new Error("Missing required data");
}

const [folder] = await db()
.select({ id: folders.id, spaceId: folders.spaceId })
.from(folders)
.where(eq(folders.id, folderId));

if (!folder) {
throw new Error("Folder not found");
}

const userVideos = await db()
.select({ id: videos.id })
.from(videos)
.where(and(eq(videos.ownerId, user.id), inArray(videos.id, videoIds)));

const validVideoIds = userVideos.map((v) => v.id);

if (validVideoIds.length === 0) {
throw new Error("No valid videos found");
}

const isAllSpacesEntry = spaceId === user.activeOrganizationId;

//if video already exists in the space, then move it
if (isAllSpacesEntry) {
await db()
.update(sharedVideos)
.set({ folderId })
.where(
and(
eq(sharedVideos.organizationId, user.activeOrganizationId),
inArray(sharedVideos.videoId, validVideoIds),
),
);
} else {
await db()
.update(spaceVideos)
.set({ folderId })
.where(
and(
eq(spaceVideos.spaceId, spaceId),
inArray(spaceVideos.videoId, validVideoIds),
),
);
}

revalidatePath(`/dashboard/caps`);
revalidatePath(`/dashboard/folder/${folderId}`);
if (spaceId) {
revalidatePath(`/dashboard/spaces/${spaceId}/folder/${folderId}`);
}

return {
success: true,
message: `${validVideoIds.length} video${validVideoIds.length === 1 ? "" : "s"} added to folder`,
addedCount: validVideoIds.length,
};
} catch (error) {
console.error("Error adding videos to folder:", error);
return {
success: false,
error:
error instanceof Error
? error.message
: "Failed to add videos to folder",
};
}
}
50 changes: 50 additions & 0 deletions apps/web/actions/folders/get-folder-videos.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"use server";

import { db } from "@cap/database";
import { getCurrentUser } from "@cap/database/auth/session";
import { sharedVideos, spaceVideos } from "@cap/database/schema";
import type { Folder, Space, Video } from "@cap/web-domain";
import { eq } from "drizzle-orm";

export async function getFolderVideoIds(
folderId: Folder.FolderId,
spaceId: Space.SpaceIdOrOrganisationId,
) {
try {
const user = await getCurrentUser();

if (!user || !user.id) {
throw new Error("Unauthorized");
}

if (!folderId) {
throw new Error("Folder ID is required");
}

const isAllSpacesEntry = user.activeOrganizationId === spaceId;

const rows = isAllSpacesEntry
? await db()
.select({ id: sharedVideos.videoId })
.from(sharedVideos)
.where(eq(sharedVideos.folderId, folderId))
: await db()
.select({ id: spaceVideos.videoId })
.from(spaceVideos)
.where(eq(spaceVideos.folderId, folderId));

return {
success: true,
data: rows.map((r) => r.id as Video.VideoId),
};
} catch (error) {
console.error("Error fetching folder video IDs:", error);
return {
success: false,
error:
error instanceof Error
? error.message
: "Failed to fetch folder videos",
};
}
}
48 changes: 33 additions & 15 deletions apps/web/actions/folders/moveVideoToFolder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,23 @@

import { db } from "@cap/database";
import { getCurrentUser } from "@cap/database/auth/session";
import { folders, spaceVideos, videos } from "@cap/database/schema";
import type { Folder, Video } from "@cap/web-domain";
import {
folders,
sharedVideos,
spaceVideos,
videos,
} from "@cap/database/schema";
import type { Folder, Space, Video } from "@cap/web-domain";
import { and, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";

export async function moveVideoToFolder({
videoId,
folderId,
spaceId,
}: {
videoId: Video.VideoId;
folderId: Folder.FolderId | null;
spaceId?: string | null;
spaceId?: Space.SpaceIdOrOrganisationId | null;
}) {
const user = await getCurrentUser();
if (!user || !user.activeOrganizationId)
Expand All @@ -30,6 +34,8 @@ export async function moveVideoToFolder({

const originalFolderId = currentVideo?.folderId;

const isAllSpacesEntry = spaceId === user.activeOrganizationId;

// If folderId is provided, verify it exists and belongs to the same organization
if (folderId) {
const [folder] = await db()
Expand All @@ -47,24 +53,36 @@ export async function moveVideoToFolder({
}
}

if (spaceId) {
if (spaceId && !isAllSpacesEntry) {
await db()
.update(spaceVideos)
.set({
folderId: folderId === null ? null : folderId,
})
.where(eq(spaceVideos.videoId, videoId));
.where(
and(eq(spaceVideos.videoId, videoId), eq(spaceVideos.spaceId, spaceId)),
);
} else if (spaceId && isAllSpacesEntry) {
await db()
.update(sharedVideos)
.set({
folderId: folderId === null ? null : folderId,
})
.where(
and(
eq(sharedVideos.videoId, videoId),
eq(sharedVideos.organizationId, user.activeOrganizationId),
),
);
} else {
await db()
.update(videos)
.set({
folderId: folderId === null ? null : folderId,
})
.where(eq(videos.id, videoId));
}
Comment on lines +56 to 84
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Verify spaceId belongs to the user's organization.

The function branches between updating spaceVideos, sharedVideos, or videos based on the spaceId parameter, but it does not verify that the provided spaceId actually belongs to the user's active organization. If a caller passes a spaceId from a different organization, the function could update records in an unintended scope.

Add a verification step after line 25 to ensure the spaceId is valid:


🏁 Script executed:

#!/bin/bash
# Check if there's existing validation for spaceId in related actions

rg -nP --type=ts -C3 'spaceId.*activeOrganizationId' apps/web/actions/

Length of output: 1055


Add validation for spaceId ownership
Before branching (after the isAllSpacesEntry assignment), if spaceId is non-null and not equal to user.activeOrganizationId, query the spaces table for { id: spaceId, organizationId: user.activeOrganizationId } and throw NotFoundError if no match.

🤖 Prompt for AI Agents
In apps/web/actions/folders/moveVideoToFolder.ts around lines 56 to 84, after
the isAllSpacesEntry assignment add validation that if spaceId is non-null and
spaceId !== user.activeOrganizationId you must query the spaces table for a row
matching { id: spaceId, organizationId: user.activeOrganizationId } and throw
NotFoundError if no match is found; place this check before the existing
branching that updates spaceVideos/sharedVideos/videos so
unauthorized/mismatched spaceIds are rejected early.


// Update the video's folderId
await db()
.update(videos)
.set({
folderId,
updatedAt: new Date(),
})
.where(eq(videos.id, videoId));

// Always revalidate the main caps page
revalidatePath(`/dashboard/caps`);

Expand Down
112 changes: 112 additions & 0 deletions apps/web/actions/folders/remove-videos.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
"use server";

import { db } from "@cap/database";
import { getCurrentUser } from "@cap/database/auth/session";
import {
folders,
sharedVideos,
spaceVideos,
videos,
} from "@cap/database/schema";
import type { Folder, Space, Video } from "@cap/web-domain";
import { and, eq, inArray, sql } from "drizzle-orm";
import { revalidatePath } from "next/cache";

export async function removeVideosFromFolder(
folderId: Folder.FolderId,
videoIds: Video.VideoId[],
spaceId: Space.SpaceIdOrOrganisationId,
) {
try {
const user = await getCurrentUser();

if (!user || !user.id) {
throw new Error("Unauthorized");
}

const isAllSpacesEntry = user.activeOrganizationId === spaceId;

if (!folderId || !videoIds || videoIds.length === 0) {
throw new Error("Missing required data");
}

// Verify folder exists and is accessible
const [folder] = await db()
.select({ id: folders.id, spaceId: folders.spaceId })
.from(folders)
.where(eq(folders.id, folderId));

if (!folder) {
throw new Error("Folder not found");
}

// Only allow updating videos the user owns
const userVideos = await db()
.select({ id: videos.id })
.from(videos)
.where(and(eq(videos.ownerId, user.id), inArray(videos.id, videoIds)));

const validVideoIds = userVideos.map((v) => v.id);

if (validVideoIds.length === 0) {
throw new Error("No valid videos found");
}

// Clear the folderId on the videos
await db()
.update(videos)
.set({ folderId: null, updatedAt: new Date() })
.where(
and(inArray(videos.id, validVideoIds), eq(videos.folderId, folderId)),
);

// Clear the folderId in the appropriate table based on context
if (isAllSpacesEntry || !folder.spaceId) {
// Organization-level folder - clear folderId in sharedVideos
await db()
.update(sharedVideos)
.set({ folderId: null })
.where(
and(
eq(sharedVideos.organizationId, user.activeOrganizationId),
inArray(sharedVideos.videoId, validVideoIds),
eq(sharedVideos.folderId, folderId),
),
);
} else if (folder.spaceId) {
// Space-level folder - clear folderId in spaceVideos
await db()
.update(spaceVideos)
.set({ folderId: null })
.where(
and(
sql`${spaceVideos.spaceId} = ${folder.spaceId}`,
inArray(spaceVideos.videoId, validVideoIds),
eq(spaceVideos.folderId, folderId),
),
);
}

// Revalidate relevant paths
revalidatePath(`/dashboard/caps`);
revalidatePath(`/dashboard/folder/${folderId}`);
if (folder.spaceId) {
revalidatePath(`/dashboard/spaces/${folder.spaceId}/folder/${folderId}`);
}

return {
success: true,
message: `${validVideoIds.length} video${validVideoIds.length === 1 ? "" : "s"} removed from folder`,
removedCount: validVideoIds.length,
};
} catch (error) {
console.error("Error removing videos from folder:", error);
return {
success: false,
error:
error instanceof Error
? error.message
: "Failed to remove videos from folder",
};
}
}
Loading