From 0d18cfd06b65d8db0b14a7d3c2b0a7740f80af2d Mon Sep 17 00:00:00 2001 From: alexis-morain Date: Sat, 6 Jun 2026 11:35:15 +0200 Subject: [PATCH 1/3] feat(folders): public sharing of a folder via a signed link Adds a per-folder "Share" action that produces a public URL anyone with the link can open to browse every video in the folder. No client account required. How it works ------------ - New boolean column folders.publicShared (default false). - Slug is an HMAC of the folder id signed with NEXTAUTH_SECRET (no DB row per share, no extra table). Rotating NEXTAUTH_SECRET revokes all outstanding folder share links. - Enabling sharing flips folders.publicShared=true AND sets every video in that folder to videos.public=true so the existing /s/ share page keeps working unauthenticated. Disabling reverts both. - Public landing page lives at /share/f/[slug], rendered as a server component. It validates the slug, refuses if publicShared=false, resolves S3-signed thumbnail URLs server-side, and links each card to the existing /s/ player. - Proxy whitelist gains /share/ so the route is reachable on self-hosted instances (matches the existing /embed/ entry). UI -- - New Share folder item in the per-folder kebab dropdown (caps page only; suppressed for folders owned by a space). - ShareFolderDialog: state hydration, Enable / Disable buttons, read-only input + Copy button for the public URL. Notes for reviewers ------------------- - The folder share affects every video in the folder. The dialog copy states this explicitly. There is no per-video override yet. - I did not regenerate the Drizzle migration (no MySQL on the dev machine that pushed this branch); please run `pnpm db:generate` to produce the migration file and journal entry. - A follow-up could swap "set videos.public=true on share" for a signed query param the /s/ route checks against the folder slug; that lets shared videos stay private to direct guesses. Kept out of this PR to limit the blast radius. --- apps/web/actions/folders/share-folder.ts | 65 ++++++ .../dashboard/caps/components/Folder.tsx | 8 + .../caps/components/FoldersDropdown.tsx | 12 ++ .../caps/components/ShareFolderDialog.tsx | 167 +++++++++++++++ apps/web/app/share/f/[slug]/page.tsx | 195 ++++++++++++++++++ apps/web/lib/folder-share.ts | 39 ++++ apps/web/proxy.ts | 3 +- packages/database/schema.ts | 1 + 8 files changed, 489 insertions(+), 1 deletion(-) create mode 100644 apps/web/actions/folders/share-folder.ts create mode 100644 apps/web/app/(org)/dashboard/caps/components/ShareFolderDialog.tsx create mode 100644 apps/web/app/share/f/[slug]/page.tsx create mode 100644 apps/web/lib/folder-share.ts diff --git a/apps/web/actions/folders/share-folder.ts b/apps/web/actions/folders/share-folder.ts new file mode 100644 index 00000000000..2f794635125 --- /dev/null +++ b/apps/web/actions/folders/share-folder.ts @@ -0,0 +1,65 @@ +"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 }) + .where(eq(videos.folderId, folderId)); + 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 }) + .where(eq(videos.folderId, folderId)); + 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/share/f/[slug]/page.tsx b/apps/web/app/share/f/[slug]/page.tsx new file mode 100644 index 00000000000..10085c5717c --- /dev/null +++ b/apps/web/app/share/f/[slug]/page.tsx @@ -0,0 +1,195 @@ +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 { 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 as any)); + 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: string | null, +): Promise { + try { + const [bucket] = await S3Buckets.getBucketAccess( + Option.fromNullable(bucketId), + ).pipe(runPromise); + const listResponse = await bucket + .listObjects({ prefix: `${ownerId}/${videoId}/` }) + .pipe(runPromise); + const contents = listResponse.Contents || []; + const thumbnailKey = contents.find((item) => + item.Key?.endsWith("screen-capture.jpg"), + )?.Key; + if (!thumbnailKey) return null; + 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 as any)); + + 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 as any)); + + 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..f387c5c4a9a --- /dev/null +++ b/apps/web/lib/folder-share.ts @@ -0,0 +1,39 @@ +import { createHmac } 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: string): string => { + return `${toBase64Url(folderId)}.${sign(folderId)}`; +}; + +export const verifyFolderShareSlug = (slug: string): string | 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; + } + if (sign(folderId) !== sig) return null; + return 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..182dbf1b226 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), From 52f352a434ae1efa6173c09dbd24cf54f5cc5a2a Mon Sep 17 00:00:00 2001 From: alexis-morain Date: Sat, 6 Jun 2026 12:02:51 +0200 Subject: [PATCH 2/3] fixup: address greptile review (timing-safe HMAC, typed slug, disable preserves pre-existing public videos, skip listObjects) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - folder-share.ts: switch HMAC comparison to crypto.timingSafeEqual and return Folder.FolderId | null instead of string | null so the Drizzle WHERE clauses no longer need `as any`. - share-folder.ts: track which videos the share itself flipped via a new videos.publicSourcedFromFolderShare boolean. enableFolderSharing only flips videos that were public=false (and stamps the flag); disableFolderSharing only reverts videos with the flag, so a video the user had individually marked public stays public after the folder is unshared. - share/f/[slug]/page.tsx: drop the listObjects probe — the thumbnail key pattern is stable (ownerId/videoId/screenshot/screen-capture.jpg) so we can sign it directly. Halves the S3 round-trips per video. - packages/database/schema.ts: new boolean column videos.publicSourcedFromFolderShare (default false). --- apps/web/actions/folders/share-folder.ts | 15 +++++++++++---- apps/web/app/share/f/[slug]/page.tsx | 15 ++++----------- apps/web/lib/folder-share.ts | 16 +++++++++++----- packages/database/schema.ts | 3 +++ 4 files changed, 29 insertions(+), 20 deletions(-) diff --git a/apps/web/actions/folders/share-folder.ts b/apps/web/actions/folders/share-folder.ts index 2f794635125..4ff9f6f3693 100644 --- a/apps/web/actions/folders/share-folder.ts +++ b/apps/web/actions/folders/share-folder.ts @@ -43,8 +43,10 @@ export async function enableFolderSharing(folderId: Folder.FolderId) { .where(eq(folders.id, folderId)); await db() .update(videos) - .set({ public: true }) - .where(eq(videos.folderId, folderId)); + .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) }; @@ -58,8 +60,13 @@ export async function disableFolderSharing(folderId: Folder.FolderId) { .where(eq(folders.id, folderId)); await db() .update(videos) - .set({ public: false }) - .where(eq(videos.folderId, folderId)); + .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/share/f/[slug]/page.tsx b/apps/web/app/share/f/[slug]/page.tsx index 10085c5717c..8664086517d 100644 --- a/apps/web/app/share/f/[slug]/page.tsx +++ b/apps/web/app/share/f/[slug]/page.tsx @@ -21,7 +21,7 @@ export async function generateMetadata(props: { const [folder] = await db() .select({ name: folders.name, publicShared: folders.publicShared }) .from(folders) - .where(eq(folders.id, folderId as any)); + .where(eq(folders.id, folderId)); if (!folder || !folder.publicShared) return { title: "Cap" }; return { title: `${folder.name} | Cap`, @@ -53,14 +53,7 @@ async function resolveThumbnailUrl( const [bucket] = await S3Buckets.getBucketAccess( Option.fromNullable(bucketId), ).pipe(runPromise); - const listResponse = await bucket - .listObjects({ prefix: `${ownerId}/${videoId}/` }) - .pipe(runPromise); - const contents = listResponse.Contents || []; - const thumbnailKey = contents.find((item) => - item.Key?.endsWith("screen-capture.jpg"), - )?.Key; - if (!thumbnailKey) return null; + const thumbnailKey = `${ownerId}/${videoId}/screenshot/screen-capture.jpg`; const url = await bucket .getSignedObjectUrl(thumbnailKey) .pipe(runPromise); @@ -85,7 +78,7 @@ export default async function SharedFolderPage(props: { publicShared: folders.publicShared, }) .from(folders) - .where(eq(folders.id, folderId as any)); + .where(eq(folders.id, folderId)); if (!folder || !folder.publicShared) return notFound(); @@ -102,7 +95,7 @@ export default async function SharedFolderPage(props: { }) .from(videos) .leftJoin(s3Buckets, eq(videos.bucket, s3Buckets.id)) - .where(eq(videos.folderId, folderId as any)); + .where(eq(videos.folderId, folderId)); const videosWithThumbs = await Promise.all( folderVideos.map(async (v) => ({ diff --git a/apps/web/lib/folder-share.ts b/apps/web/lib/folder-share.ts index f387c5c4a9a..aa13a4aacf7 100644 --- a/apps/web/lib/folder-share.ts +++ b/apps/web/lib/folder-share.ts @@ -1,4 +1,5 @@ -import { createHmac } from "node:crypto"; +import type { Folder } from "@cap/web-domain"; +import { createHmac, timingSafeEqual } from "node:crypto"; const getSecret = () => { const s = process.env.NEXTAUTH_SECRET; @@ -19,11 +20,13 @@ const toBase64Url = (s: string) => const fromBase64Url = (s: string) => Buffer.from(s, "base64url").toString("utf8"); -export const signFolderShareSlug = (folderId: string): string => { +export const signFolderShareSlug = (folderId: Folder.FolderId): string => { return `${toBase64Url(folderId)}.${sign(folderId)}`; }; -export const verifyFolderShareSlug = (slug: string): string | null => { +export const verifyFolderShareSlug = ( + slug: string, +): Folder.FolderId | null => { const parts = slug.split("."); if (parts.length !== 2) return null; const [encodedId, sig] = parts; @@ -34,6 +37,9 @@ export const verifyFolderShareSlug = (slug: string): string | null => { } catch { return null; } - if (sign(folderId) !== sig) return null; - return folderId; + 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/packages/database/schema.ts b/packages/database/schema.ts index 182dbf1b226..fc63aff6bcb 100644 --- a/packages/database/schema.ts +++ b/packages/database/schema.ts @@ -349,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( From a25d6e6a49d4abbf65003def15a4a6e90d03d001 Mon Sep 17 00:00:00 2001 From: alexis-morain Date: Wed, 10 Jun 2026 13:20:20 +0200 Subject: [PATCH 3/3] chore: rebase on main and fix integration after schema change Rebased onto upstream/main. The new videos.publicSourcedFromFolderShare column makes it required in the inferred row type, so it must be added to the explicit select projections in the share and embed video pages. Also adapt the folder share page to the branded S3BucketId now expected by S3Buckets.getBucketAccess. Co-Authored-By: Claude Opus 4.8 --- apps/web/app/embed/[videoId]/page.tsx | 1 + apps/web/app/s/[videoId]/page.tsx | 1 + apps/web/app/share/f/[slug]/page.tsx | 13 +++++-------- 3 files changed, 7 insertions(+), 8 deletions(-) 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 index 8664086517d..22c23db0ccf 100644 --- a/apps/web/app/share/f/[slug]/page.tsx +++ b/apps/web/app/share/f/[slug]/page.tsx @@ -2,6 +2,7 @@ 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"; @@ -47,16 +48,14 @@ const formatDuration = (s: number | null) => { async function resolveThumbnailUrl( videoId: string, ownerId: string, - bucketId: string | null, + 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); + const url = await bucket.getSignedObjectUrl(thumbnailKey).pipe(runPromise); return url; } catch { return null; @@ -123,8 +122,7 @@ export default async function SharedFolderPage(props: {

{folder.name}

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

{folderVideos.length === 0 ? ( @@ -134,8 +132,7 @@ export default async function SharedFolderPage(props: { ) : (
{videosWithThumbs.map((v) => { - const aspect = - v.width && v.height ? v.width / v.height : 16 / 9; + const aspect = v.width && v.height ? v.width / v.height : 16 / 9; return (