Skip to content

feat(folders): public sharing of a folder via a signed link#1890

Open
alexis-morain wants to merge 2 commits into
CapSoftware:mainfrom
alexis-morain:feat/public-folder-sharing
Open

feat(folders): public sharing of a folder via a signed link#1890
alexis-morain wants to merge 2 commits into
CapSoftware:mainfrom
alexis-morain:feat/public-folder-sharing

Conversation

@alexis-morain

@alexis-morain alexis-morain commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a per-folder Share folder action that produces a public URL anyone with the link can open. The visitor sees a clean index of the folder's videos (thumbnails, names, durations, dates) and clicks through to the existing /s/<videoId> player — no account, no signup.

I built this for a self-hosted instance where I needed to hand a client all the videos in one project folder without giving them a Cap account. Felt generally useful so I'm upstreaming it.

Architecture

  • Schema — single new column folders.publicShared boolean default false. No new table; the slug is derived, not stored.
  • Slugbase64url(folderId) + \".\" + hmac(folderId, NEXTAUTH_SECRET). Stateless. Rotating NEXTAUTH_SECRET revokes every outstanding folder-share link.
  • ActionenableFolderSharing(folderId) flips folders.publicShared = true AND videos.public = true for every video in the folder so the existing share page keeps working unauthenticated. disableFolderSharing reverts both.
  • Public route/share/f/[slug]/page.tsx (Server Component): verifies the slug, refuses if publicShared = false, resolves S3 signed thumbnail URLs server-side (the existing /api/thumbnail returns JSON, not the image), renders a responsive grid linking each card to /s/<videoId>.
  • Proxy/share/ added to the self-hosted whitelist next to /embed/ so the route is reachable without auth.
  • UI — new "Share folder" item in the existing FoldersDropdown kebab menu (hidden for folders owned by a Space — out of scope for this PR). New ShareFolderDialog with state hydration, Enable / Disable buttons, copy-to-clipboard.

Test plan

  • On /dashboard/caps, kebab on any folder → Share folder → dialog opens, shows "Enable sharing".
  • Click Enable sharing → toast confirms, public link appears in the dialog and is copied to clipboard.
  • Open the link in an incognito window → folder index renders with thumbnails / dates / durations, no auth prompt.
  • Click a video → /s/<videoId> plays without login.
  • Re-open the dialog on the original folder → still shows the same link.
  • Click Disable sharing → toast confirms, opening the public link returns 404; opening any /s/<videoId> from that folder requires auth again (videos flipped back to private).
  • Repeat after rotating NEXTAUTH_SECRET → previously-generated links return 404. Generating a new link produces a different slug.

Notes for reviewers

  • Migration not regenerated. No MySQL on the dev machine that pushed this. Please run pnpm db:generate to emit the migration file and journal entry; the schema change is just the new boolean column.
  • "Set every video to public" trade-off. This is the simplest path to making /s/<videoId> work for guests without touching the share page. Downside: someone who guesses a video id while a folder is shared can hit it directly. A follow-up could swap this for a signed query param /s/<videoId>?fs=<slug> checked against the folder slug, keeping the videos technically private. Kept out of this PR to limit the blast radius.
  • Spaces folders. Skipped intentionally — the kebab passes setShareDialogOpen={spaceId ? undefined : ...}. The Space sharing model is a separate question and I didn't want to overload it here.
  • No password / no expiry. Conscious v1 scope. Both are clean additions on top (extra column for password hash, extra column for expiresAt) without architecture changes.

Greptile Summary

This PR adds stateless public folder sharing: a signed slug (base64url(folderId) + \".\" + truncated-HMAC) is generated from NEXTAUTH_SECRET, and a new /share/f/[slug] page renders the folder's video grid for unauthenticated visitors.

  • disableFolderSharing is destructive: it unconditionally bulk-sets all videos in the folder to public = false, which permanently makes private any video that was already individually shared before folder sharing was ever enabled. Only videos whose public flag was flipped by enableFolderSharing should be reverted.
  • Timing-unsafe HMAC verification: verifyFolderShareSlug uses !== to compare the expected and received HMAC signatures; crypto.timingSafeEqual should be used instead.
  • The thumbnail resolver in the share page issues 2 sequential S3 API calls (listObjects + getSignedObjectUrl) for every video; for large folders this causes meaningful latency and could be reduced by constructing the key directly.

Confidence Score: 3/5

Not safe to merge as-is: disabling folder sharing will silently revoke individually-shared videos, and the HMAC verification is not constant-time.

Two issues need resolution before merge. The disableFolderSharing action bulk-sets every video in the folder to private without checking whether those videos were already public before folder sharing was enabled — any video a user had independently shared will silently lose its public access. The HMAC verification in folder-share.ts uses a plain string comparison instead of timingSafeEqual, introducing a timing oracle on a security-critical path.

apps/web/actions/folders/share-folder.ts (destructive video visibility reset) and apps/web/lib/folder-share.ts (non-constant-time signature check) both need fixes before this lands.

Security Review

  • Timing-unsafe HMAC comparison (apps/web/lib/folder-share.ts line 37): sign(folderId) !== sig uses a short-circuit string comparison, leaking byte-position timing information. Because the HMAC check runs before any database query, an attacker can probe it freely without needing a real folder ID. Replacing it with crypto.timingSafeEqual eliminates the timing oracle.

Important Files Changed

Filename Overview
apps/web/lib/folder-share.ts New HMAC-based slug signing/verification module; uses non-constant-time string comparison in verifyFolderShareSlug, which should use timingSafeEqual instead.
apps/web/actions/folders/share-folder.ts Server actions for enabling/disabling folder sharing; disableFolderSharing unconditionally sets all folder videos to private, destroying the pre-existing public state of videos that were already shared individually before folder sharing was enabled.
apps/web/app/share/f/[slug]/page.tsx New public folder share page; verifies slug and renders video grid, but issues 2 S3 API calls per video for thumbnail resolution and uses as any casts to work around Drizzle branded-type mismatches.
apps/web/app/(org)/dashboard/caps/components/ShareFolderDialog.tsx New client dialog for managing folder sharing state; hydration pattern and clipboard handling look correct; minor: clipboard failure is silently swallowed in handleEnable.
apps/web/app/(org)/dashboard/caps/components/FoldersDropdown.tsx Adds optional "Share folder" menu item; correctly hidden for space-owned folders via undefined prop guard.
packages/database/schema.ts Adds publicShared boolean NOT NULL DEFAULT false column to folders table; schema change is straightforward, migration not yet generated (noted in PR).
apps/web/proxy.ts Adds /share/ to the self-hosted auth bypass whitelist alongside /embed/; minimal and correct change.
apps/web/app/(org)/dashboard/caps/components/Folder.tsx Wires ShareFolderDialog into the folder card; correctly gates the share action behind !spaceId.
Prompt To Fix All With AI
Fix the following 5 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 5
apps/web/actions/folders/share-folder.ts:59-62
**`disableFolderSharing` permanently silences pre-existing public videos**

`disableFolderSharing` unconditionally sets `public = false` on every video in the folder, regardless of whether those videos were already public before folder sharing was enabled. A user who individually shared a video via `/s/<videoId>` before ever touching the folder-share feature will silently find that video made private the moment they disable folder sharing — with no way to know it happened.

`enableFolderSharing` should record which videos it flipped (e.g., by filtering on `public = false` before updating, or by storing a flag like `sharedViaFolder`), so `disableFolderSharing` can revert only those videos rather than all of them.

### Issue 2 of 5
apps/web/lib/folder-share.ts:1
The `timingSafeEqual` import is missing when switching to constant-time comparison.

```suggestion
import { createHmac, timingSafeEqual } from "node:crypto";
```

### Issue 3 of 5
apps/web/lib/folder-share.ts:37
**Non-constant-time HMAC verification is susceptible to timing attacks**

`sign(folderId) !== sig` is a standard string comparison that short-circuits on the first differing byte, leaking timing information. Since the HMAC check runs before any DB lookup, an attacker can probe it freely. The fix is to use `timingSafeEqual` from `node:crypto`, which always runs in constant time regardless of where the strings differ.

```suggestion
	const expected = Buffer.from(sign(folderId));
	const actual = Buffer.from(sig);
	if (expected.length !== actual.length || !timingSafeEqual(expected, actual))
		return null;
```

### Issue 4 of 5
apps/web/app/share/f/[slug]/page.tsx:47-71
**N+1 S3 API calls on page load**

`resolveThumbnailUrl` makes two sequential S3 API calls per video (`listObjects` then `getSignedObjectUrl`). For a folder with 30 videos, the page issues 60 S3 calls concurrently via `Promise.all` — each of which fans out to the cloud. This will cause noticeably slow page loads and may hit S3 rate limits on large folders. If the thumbnail key pattern is stable (e.g., `{ownerId}/{videoId}/screen-capture.jpg`), the `listObjects` call can be skipped and the signed URL generated directly from the known key.

### Issue 5 of 5
apps/web/app/share/f/[slug]/page.tsx:24
**`as any` casts suppress Drizzle type safety**

Both WHERE clauses cast `folderId` to `any` (lines 24, 88, 105). This suggests a branded-type mismatch between the decoded string returned by `verifyFolderShareSlug` and the column's declared type (`Folder.FolderId`). The right fix is to cast the return value of `verifyFolderShareSlug` to the correct branded type once, or to type the function return as `Folder.FolderId | null`, instead of silencing the type checker at every call site.

Reviews (1): Last reviewed commit: "feat(folders): public sharing of a folde..." | Re-trigger Greptile

Greptile also left 5 inline comments on this PR.

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/<videoId>
  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/<videoId> 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/<videoId> 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.
Comment on lines +59 to +62
await db()
.update(videos)
.set({ public: false })
.where(eq(videos.folderId, folderId));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 disableFolderSharing permanently silences pre-existing public videos

disableFolderSharing unconditionally sets public = false on every video in the folder, regardless of whether those videos were already public before folder sharing was enabled. A user who individually shared a video via /s/<videoId> before ever touching the folder-share feature will silently find that video made private the moment they disable folder sharing — with no way to know it happened.

enableFolderSharing should record which videos it flipped (e.g., by filtering on public = false before updating, or by storing a flag like sharedViaFolder), so disableFolderSharing can revert only those videos rather than all of them.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/actions/folders/share-folder.ts
Line: 59-62

Comment:
**`disableFolderSharing` permanently silences pre-existing public videos**

`disableFolderSharing` unconditionally sets `public = false` on every video in the folder, regardless of whether those videos were already public before folder sharing was enabled. A user who individually shared a video via `/s/<videoId>` before ever touching the folder-share feature will silently find that video made private the moment they disable folder sharing — with no way to know it happened.

`enableFolderSharing` should record which videos it flipped (e.g., by filtering on `public = false` before updating, or by storing a flag like `sharedViaFolder`), so `disableFolderSharing` can revert only those videos rather than all of them.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment thread apps/web/lib/folder-share.ts Outdated
@@ -0,0 +1,39 @@
import { createHmac } from "node:crypto";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 The timingSafeEqual import is missing when switching to constant-time comparison.

Suggested change
import { createHmac } from "node:crypto";
import { createHmac, timingSafeEqual } from "node:crypto";
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/lib/folder-share.ts
Line: 1

Comment:
The `timingSafeEqual` import is missing when switching to constant-time comparison.

```suggestion
import { createHmac, timingSafeEqual } from "node:crypto";
```

How can I resolve this? If you propose a fix, please make it concise.

Comment thread apps/web/lib/folder-share.ts Outdated
} catch {
return null;
}
if (sign(folderId) !== sig) return null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 security Non-constant-time HMAC verification is susceptible to timing attacks

sign(folderId) !== sig is a standard string comparison that short-circuits on the first differing byte, leaking timing information. Since the HMAC check runs before any DB lookup, an attacker can probe it freely. The fix is to use timingSafeEqual from node:crypto, which always runs in constant time regardless of where the strings differ.

Suggested change
if (sign(folderId) !== sig) return null;
const expected = Buffer.from(sign(folderId));
const actual = Buffer.from(sig);
if (expected.length !== actual.length || !timingSafeEqual(expected, actual))
return null;
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/lib/folder-share.ts
Line: 37

Comment:
**Non-constant-time HMAC verification is susceptible to timing attacks**

`sign(folderId) !== sig` is a standard string comparison that short-circuits on the first differing byte, leaking timing information. Since the HMAC check runs before any DB lookup, an attacker can probe it freely. The fix is to use `timingSafeEqual` from `node:crypto`, which always runs in constant time regardless of where the strings differ.

```suggestion
	const expected = Buffer.from(sign(folderId));
	const actual = Buffer.from(sig);
	if (expected.length !== actual.length || !timingSafeEqual(expected, actual))
		return null;
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +47 to +71
async function resolveThumbnailUrl(
videoId: string,
ownerId: string,
bucketId: string | null,
): Promise<string | null> {
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;
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 N+1 S3 API calls on page load

resolveThumbnailUrl makes two sequential S3 API calls per video (listObjects then getSignedObjectUrl). For a folder with 30 videos, the page issues 60 S3 calls concurrently via Promise.all — each of which fans out to the cloud. This will cause noticeably slow page loads and may hit S3 rate limits on large folders. If the thumbnail key pattern is stable (e.g., {ownerId}/{videoId}/screen-capture.jpg), the listObjects call can be skipped and the signed URL generated directly from the known key.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/app/share/f/[slug]/page.tsx
Line: 47-71

Comment:
**N+1 S3 API calls on page load**

`resolveThumbnailUrl` makes two sequential S3 API calls per video (`listObjects` then `getSignedObjectUrl`). For a folder with 30 videos, the page issues 60 S3 calls concurrently via `Promise.all` — each of which fans out to the cloud. This will cause noticeably slow page loads and may hit S3 rate limits on large folders. If the thumbnail key pattern is stable (e.g., `{ownerId}/{videoId}/screen-capture.jpg`), the `listObjects` call can be skipped and the signed URL generated directly from the known key.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment thread apps/web/app/share/f/[slug]/page.tsx Outdated
const [folder] = await db()
.select({ name: folders.name, publicShared: folders.publicShared })
.from(folders)
.where(eq(folders.id, folderId as any));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 as any casts suppress Drizzle type safety

Both WHERE clauses cast folderId to any (lines 24, 88, 105). This suggests a branded-type mismatch between the decoded string returned by verifyFolderShareSlug and the column's declared type (Folder.FolderId). The right fix is to cast the return value of verifyFolderShareSlug to the correct branded type once, or to type the function return as Folder.FolderId | null, instead of silencing the type checker at every call site.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/app/share/f/[slug]/page.tsx
Line: 24

Comment:
**`as any` casts suppress Drizzle type safety**

Both WHERE clauses cast `folderId` to `any` (lines 24, 88, 105). This suggests a branded-type mismatch between the decoded string returned by `verifyFolderShareSlug` and the column's declared type (`Folder.FolderId`). The right fix is to cast the return value of `verifyFolderShareSlug` to the correct branded type once, or to type the function return as `Folder.FolderId | null`, instead of silencing the type checker at every call site.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

disable preserves pre-existing public videos, skip listObjects)

- 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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant