diff --git a/apps/web/actions/folders/share-folder.ts b/apps/web/actions/folders/share-folder.ts new file mode 100644 index 00000000000..4ff9f6f3693 --- /dev/null +++ b/apps/web/actions/folders/share-folder.ts @@ -0,0 +1,72 @@ +"use server"; + +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { folders, videos } from "@cap/database/schema"; +import type { Folder } from "@cap/web-domain"; +import { and, eq } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { signFolderShareSlug } from "@/lib/folder-share"; + +const requireOwnedFolder = async (folderId: Folder.FolderId) => { + const user = await getCurrentUser(); + if (!user || !user.activeOrganizationId) + throw new Error("Unauthorized or no active organization"); + + const [folder] = await db() + .select() + .from(folders) + .where( + and( + eq(folders.id, folderId), + eq(folders.organizationId, user.activeOrganizationId), + ), + ); + + if (!folder) throw new Error("Folder not found"); + return folder; +}; + +export async function getFolderShareState(folderId: Folder.FolderId) { + const folder = await requireOwnedFolder(folderId); + return { + isShared: folder.publicShared === true, + slug: folder.publicShared ? signFolderShareSlug(folderId) : null, + }; +} + +export async function enableFolderSharing(folderId: Folder.FolderId) { + await requireOwnedFolder(folderId); + await db() + .update(folders) + .set({ publicShared: true }) + .where(eq(folders.id, folderId)); + await db() + .update(videos) + .set({ public: true, publicSourcedFromFolderShare: true }) + .where( + and(eq(videos.folderId, folderId), eq(videos.public, false)), + ); + revalidatePath(`/dashboard/folder/${folderId}`); + revalidatePath(`/dashboard/caps`); + return { slug: signFolderShareSlug(folderId) }; +} + +export async function disableFolderSharing(folderId: Folder.FolderId) { + await requireOwnedFolder(folderId); + await db() + .update(folders) + .set({ publicShared: false }) + .where(eq(folders.id, folderId)); + await db() + .update(videos) + .set({ public: false, publicSourcedFromFolderShare: false }) + .where( + and( + eq(videos.folderId, folderId), + eq(videos.publicSourcedFromFolderShare, true), + ), + ); + revalidatePath(`/dashboard/folder/${folderId}`); + revalidatePath(`/dashboard/caps`); +} diff --git a/apps/web/app/(org)/dashboard/caps/components/Folder.tsx b/apps/web/app/(org)/dashboard/caps/components/Folder.tsx index 6bc22a260d0..0c2e8473031 100644 --- a/apps/web/app/(org)/dashboard/caps/components/Folder.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/Folder.tsx @@ -14,6 +14,7 @@ import { ConfirmationDialog } from "../../_components/ConfirmationDialog"; import { useDashboardContext, useTheme } from "../../Contexts"; import { registerDropTarget } from "../../folder/[id]/components/ClientCapCard"; import { FoldersDropdown } from "./FoldersDropdown"; +import { ShareFolderDialog } from "./ShareFolderDialog"; export type FolderDataType = { name: string; @@ -35,6 +36,7 @@ const FolderCard = ({ const router = useRouter(); const { theme } = useTheme(); const [confirmDeleteFolderOpen, setConfirmDeleteFolderOpen] = useState(false); + const [shareDialogOpen, setShareDialogOpen] = useState(false); const [isRenaming, setIsRenaming] = useState(false); const [updateName, setUpdateName] = useState(name); const nameRef = useRef(null); @@ -402,8 +404,14 @@ const FolderCard = ({ parentId={parentId} setIsRenaming={setIsRenaming} setConfirmDeleteFolderOpen={setConfirmDeleteFolderOpen} + setShareDialogOpen={spaceId ? undefined : setShareDialogOpen} nameRef={nameRef} /> + ); diff --git a/apps/web/app/(org)/dashboard/caps/components/FoldersDropdown.tsx b/apps/web/app/(org)/dashboard/caps/components/FoldersDropdown.tsx index d224034b016..cc3568bad55 100644 --- a/apps/web/app/(org)/dashboard/caps/components/FoldersDropdown.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/FoldersDropdown.tsx @@ -7,6 +7,7 @@ import { import { faEllipsis, faPencil, + faShareNodes, faTrash, } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; @@ -16,6 +17,7 @@ interface FoldersDropdownProps { id: string; setIsRenaming: (isRenaming: boolean) => void; setConfirmDeleteFolderOpen: (open: boolean) => void; + setShareDialogOpen?: (open: boolean) => void; nameRef: RefObject; parentId?: string | null; } @@ -23,6 +25,7 @@ interface FoldersDropdownProps { export const FoldersDropdown = ({ setIsRenaming, setConfirmDeleteFolderOpen, + setShareDialogOpen, nameRef, }: FoldersDropdownProps) => { return ( @@ -62,6 +65,15 @@ export const FoldersDropdown = ({ }, 0); }, }, + ...(setShareDialogOpen + ? [ + { + label: "Share folder", + icon: faShareNodes, + onClick: () => setShareDialogOpen(true), + } satisfies FolderDropdownItem, + ] + : []), // Only show Duplicate if there is NO active space // ...(!activeSpace // ? [ diff --git a/apps/web/app/(org)/dashboard/caps/components/ShareFolderDialog.tsx b/apps/web/app/(org)/dashboard/caps/components/ShareFolderDialog.tsx new file mode 100644 index 00000000000..4dc0e68c133 --- /dev/null +++ b/apps/web/app/(org)/dashboard/caps/components/ShareFolderDialog.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + Input, +} from "@cap/ui"; +import type { Folder } from "@cap/web-domain"; +import { + faCopy, + faShareNodes, +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { + disableFolderSharing, + enableFolderSharing, + getFolderShareState, +} from "@/actions/folders/share-folder"; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + folderId: Folder.FolderId; +} + +export const ShareFolderDialog: React.FC = ({ + open, + onOpenChange, + folderId, +}) => { + const [isShared, setIsShared] = useState(false); + const [slug, setSlug] = useState(null); + const [loading, setLoading] = useState(false); + const [hydrated, setHydrated] = useState(false); + + useEffect(() => { + if (!open) return; + setHydrated(false); + getFolderShareState(folderId) + .then((state) => { + setIsShared(state.isShared); + setSlug(state.slug); + }) + .catch(() => toast.error("Failed to load share state")) + .finally(() => setHydrated(true)); + }, [open, folderId]); + + const baseUrl = + typeof window !== "undefined" ? window.location.origin : ""; + const shareUrl = slug ? `${baseUrl}/share/f/${slug}` : ""; + + const handleEnable = async () => { + setLoading(true); + try { + const res = await enableFolderSharing(folderId); + setSlug(res.slug); + setIsShared(true); + toast.success("Folder shared. Link copied to clipboard."); + try { + await navigator.clipboard.writeText(`${baseUrl}/share/f/${res.slug}`); + } catch {} + } catch { + toast.error("Failed to enable sharing"); + } finally { + setLoading(false); + } + }; + + const handleDisable = async () => { + setLoading(true); + try { + await disableFolderSharing(folderId); + setIsShared(false); + setSlug(null); + toast.success("Sharing disabled"); + } catch { + toast.error("Failed to disable sharing"); + } finally { + setLoading(false); + } + }; + + const handleCopy = async () => { + if (!shareUrl) return; + try { + await navigator.clipboard.writeText(shareUrl); + toast.success("Link copied"); + } catch { + toast.error("Failed to copy"); + } + }; + + return ( + + + } + > + Share folder + +
+

+ Anyone with the link can view every video in this folder in + read-only mode. No account required. Enabling will also flip + every video in the folder to public; disabling reverts them. +

+ {hydrated && isShared && shareUrl ? ( +
+ +
+ + +
+
+ ) : null} +
+ + + {hydrated && isShared ? ( + + ) : ( + + )} + +
+
+ ); +}; diff --git a/apps/web/app/embed/[videoId]/page.tsx b/apps/web/app/embed/[videoId]/page.tsx index 5a8ca764faf..3f2184fd771 100644 --- a/apps/web/app/embed/[videoId]/page.tsx +++ b/apps/web/app/embed/[videoId]/page.tsx @@ -163,6 +163,7 @@ export default async function EmbedVideoPage( transcriptionStatus: videos.transcriptionStatus, source: videos.source, folderId: videos.folderId, + publicSourcedFromFolderShare: videos.publicSourcedFromFolderShare, width: videos.width, height: videos.height, duration: videos.duration, diff --git a/apps/web/app/s/[videoId]/page.tsx b/apps/web/app/s/[videoId]/page.tsx index f6b288fcf97..3920677b0fa 100644 --- a/apps/web/app/s/[videoId]/page.tsx +++ b/apps/web/app/s/[videoId]/page.tsx @@ -425,6 +425,7 @@ export default async function ShareVideoPage(props: PageProps<"/s/[videoId]">) { storageIntegrationId: videos.storageIntegrationId, metadata: videos.metadata, public: videos.public, + publicSourcedFromFolderShare: videos.publicSourcedFromFolderShare, videoStartTime: videos.videoStartTime, audioStartTime: videos.audioStartTime, awsRegion: videos.awsRegion, diff --git a/apps/web/app/share/f/[slug]/page.tsx b/apps/web/app/share/f/[slug]/page.tsx new file mode 100644 index 00000000000..22c23db0ccf --- /dev/null +++ b/apps/web/app/share/f/[slug]/page.tsx @@ -0,0 +1,185 @@ +import { db } from "@cap/database"; +import { folders, s3Buckets, videos } from "@cap/database/schema"; +import { Logo } from "@cap/ui"; +import { S3Buckets } from "@cap/web-backend"; +import type { S3Bucket } from "@cap/web-domain"; +import { eq } from "drizzle-orm"; +import { Option } from "effect"; +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { verifyFolderShareSlug } from "@/lib/folder-share"; +import { runPromise } from "@/lib/server"; + +export const dynamic = "force-dynamic"; + +export async function generateMetadata(props: { + params: Promise<{ slug: string }>; +}): Promise { + const { slug } = await props.params; + const folderId = verifyFolderShareSlug(slug); + if (!folderId) return { title: "Cap" }; + const [folder] = await db() + .select({ name: folders.name, publicShared: folders.publicShared }) + .from(folders) + .where(eq(folders.id, folderId)); + if (!folder || !folder.publicShared) return { title: "Cap" }; + return { + title: `${folder.name} | Cap`, + description: `Shared folder: ${folder.name}`, + robots: "noindex, nofollow", + }; +} + +const colorTints: Record = { + normal: "#9ca3af", + blue: "#3b82f6", + red: "#ef4444", + yellow: "#eab308", +}; + +const formatDuration = (s: number | null) => { + if (s == null || Number.isNaN(s)) return ""; + const m = Math.floor(s / 60); + const sec = Math.floor(s % 60); + return `${m}:${sec.toString().padStart(2, "0")}`; +}; + +async function resolveThumbnailUrl( + videoId: string, + ownerId: string, + bucketId: S3Bucket.S3BucketId | null, +): Promise { + try { + const [bucket] = await S3Buckets.getBucketAccess( + Option.fromNullable(bucketId), + ).pipe(runPromise); + const thumbnailKey = `${ownerId}/${videoId}/screenshot/screen-capture.jpg`; + const url = await bucket.getSignedObjectUrl(thumbnailKey).pipe(runPromise); + return url; + } catch { + return null; + } +} + +export default async function SharedFolderPage(props: { + params: Promise<{ slug: string }>; +}) { + const { slug } = await props.params; + const folderId = verifyFolderShareSlug(slug); + if (!folderId) return notFound(); + + const [folder] = await db() + .select({ + id: folders.id, + name: folders.name, + color: folders.color, + publicShared: folders.publicShared, + }) + .from(folders) + .where(eq(folders.id, folderId)); + + if (!folder || !folder.publicShared) return notFound(); + + const folderVideos = await db() + .select({ + id: videos.id, + name: videos.name, + createdAt: videos.createdAt, + duration: videos.duration, + width: videos.width, + height: videos.height, + ownerId: videos.ownerId, + bucketId: videos.bucket, + }) + .from(videos) + .leftJoin(s3Buckets, eq(videos.bucket, s3Buckets.id)) + .where(eq(videos.folderId, folderId)); + + const videosWithThumbs = await Promise.all( + folderVideos.map(async (v) => ({ + ...v, + thumbnailUrl: await resolveThumbnailUrl(v.id, v.ownerId, v.bucketId), + })), + ); + + return ( +
+
+
+ + + + Powered by Cap +
+
+
+
+
+

{folder.name}

+
+

+ {folderVideos.length} {folderVideos.length === 1 ? "video" : "videos"} +

+ + {folderVideos.length === 0 ? ( +
+ No videos in this folder yet. +
+ ) : ( +
+ {videosWithThumbs.map((v) => { + const aspect = v.width && v.height ? v.width / v.height : 16 / 9; + return ( + +
+ {v.thumbnailUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {v.name} + ) : ( +
+ No preview +
+ )} + {v.duration ? ( +
+ {formatDuration(Number(v.duration))} +
+ ) : null} +
+
+

+ {v.name} +

+

+ {new Date(v.createdAt).toLocaleDateString(undefined, { + day: "numeric", + month: "long", + year: "numeric", + })} +

+
+ + ); + })} +
+ )} +
+
+ ); +} diff --git a/apps/web/lib/folder-share.ts b/apps/web/lib/folder-share.ts new file mode 100644 index 00000000000..aa13a4aacf7 --- /dev/null +++ b/apps/web/lib/folder-share.ts @@ -0,0 +1,45 @@ +import type { Folder } from "@cap/web-domain"; +import { createHmac, timingSafeEqual } from "node:crypto"; + +const getSecret = () => { + const s = process.env.NEXTAUTH_SECRET; + if (!s) throw new Error("NEXTAUTH_SECRET is required for folder share signing"); + return s; +}; + +const sign = (folderId: string) => { + return createHmac("sha256", getSecret()) + .update(`folder-share:${folderId}`) + .digest("base64url") + .slice(0, 16); +}; + +const toBase64Url = (s: string) => + Buffer.from(s, "utf8").toString("base64url"); + +const fromBase64Url = (s: string) => + Buffer.from(s, "base64url").toString("utf8"); + +export const signFolderShareSlug = (folderId: Folder.FolderId): string => { + return `${toBase64Url(folderId)}.${sign(folderId)}`; +}; + +export const verifyFolderShareSlug = ( + slug: string, +): Folder.FolderId | null => { + const parts = slug.split("."); + if (parts.length !== 2) return null; + const [encodedId, sig] = parts; + if (!encodedId || !sig) return null; + let folderId: string; + try { + folderId = fromBase64Url(encodedId); + } catch { + return null; + } + const expected = Buffer.from(sign(folderId)); + const actual = Buffer.from(sig); + if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) + return null; + return folderId as Folder.FolderId; +}; diff --git a/apps/web/proxy.ts b/apps/web/proxy.ts index 36ee15452bb..65b88597fb5 100644 --- a/apps/web/proxy.ts +++ b/apps/web/proxy.ts @@ -54,7 +54,8 @@ export async function proxy(request: NextRequest) { path.startsWith("/self-hosting") || path.startsWith("/download") || path.startsWith("/terms") || - path.startsWith("/verify-otp") + path.startsWith("/verify-otp") || + path.startsWith("/share/") ) && process.env.NODE_ENV !== "development" ) diff --git a/packages/database/schema.ts b/packages/database/schema.ts index dfcb724c63f..fc63aff6bcb 100644 --- a/packages/database/schema.ts +++ b/packages/database/schema.ts @@ -298,6 +298,7 @@ export const folders = mysqlTable( spaceId: nanoIdNullable("spaceId").$type(), createdAt: timestamp("createdAt").notNull().defaultNow(), updatedAt: timestamp("updatedAt").notNull().defaultNow().onUpdateNow(), + publicShared: boolean("publicShared").notNull().default(false), }, (table) => ({ organizationIdIndex: index("organization_id_idx").on(table.organizationId), @@ -348,6 +349,9 @@ export const videos = mysqlTable( .notNull() .default({ type: "MediaConvert" }), folderId: nanoIdNullable("folderId").$type(), + publicSourcedFromFolderShare: boolean("publicSourcedFromFolderShare") + .notNull() + .default(false), createdAt: timestamp("createdAt").notNull().defaultNow(), effectiveCreatedAt: datetime("effectiveCreatedAt").generatedAlwaysAs( sql.raw(