Skip to content

Public links for spaces and folders#1906

Merged
richiemcilroy merged 37 commits into
mainfrom
space-folder-sharing
Jun 10, 2026
Merged

Public links for spaces and folders#1906
richiemcilroy merged 37 commits into
mainfrom
space-folder-sharing

Conversation

@richiemcilroy

@richiemcilroy richiemcilroy commented Jun 9, 2026

Copy link
Copy Markdown
Member

Adds public collection pages at /c/[id] for spaces and folders. Org members can publish a space or folder, share a link, and visitors get a branded grid of caps with optional password protection and page customization.

Greptile Summary

This PR adds public collection pages at /c/[id] for spaces and folders, allowing org members to publish a space or folder with an optional password, email-domain restriction, custom branding, and a configurable grid/list layout. The feature is gated on the org owner's Pro plan.

  • New public page (/c/[id]): Parallel DB lookups for folder and space candidates, server-side access control (password / email restriction), paginated video grid, and branded header; previously flagged sequential DB queries are now resolved with Promise.all.
  • Password cookie refactored: password-cookie.ts now stores an encrypted JSON array of hashes (up to 10) instead of a single hash, eliminating the cross-resource collision issue raised in the prior review.
  • Authorization and Pro-gating: Three new server actions (visibility, password, logo) and backend Folders.update extension all enforce manager-level access and Pro checks before writing; un-publishing is always allowed without a Pro check.

Confidence Score: 5/5

The change is safe to merge; authorization is enforced server-side on all write paths and the previously flagged issues have been addressed.

All three issues from the prior review (password cookie collision, sequential DB queries, console.error on bad passwords) are resolved. Server-side Pro and permission gates cover every write path, settings patches are schema-validated, updates are atomic via JSON_MERGE_PATCH, and password verification is rate-limited. The only new findings are style-level concerns.

Folder.tsx and FoldersDropdown.tsx — the Make public dropdown item is shown to all users who can see a folder card, not just those who can manage it.

Important Files Changed

Filename Overview
apps/web/lib/public-collections.ts New server module: parallel DB lookups for folder/space, correct password inheritance, public-video-only queries, and proper tombstone gating. Previous sequential-query concern is resolved with Promise.all.
apps/web/lib/public-collections-policy.ts Access-control logic for public collections; email restriction and password gating are correctly ordered. parsePublicCollectionPage has no upper-bound check on the page number (flagged).
apps/web/lib/password-cookie.ts Refactored from a single-hash cookie to an encrypted JSON array (up to 10 hashes), resolving the prior cross-resource collision. Legacy single-hash cookies are handled gracefully.
apps/web/actions/collections/password.ts New action for collection password verification with per-IP rate limiting, PBKDF2 verification, and correct separation of expected vs. unexpected failure paths.
apps/web/actions/collections/visibility.ts New server action for toggling space public status; schema-validates the settings patch, Pro-gates enabling/customizing, and uses atomic JSON_MERGE_PATCH to avoid concurrent-write races.
apps/web/actions/collections/logo.ts New server action for collection logo upload/removal; validates file type, size, SVG sanitization, and applies proper authorization for both space and folder contexts with Pro gate.
apps/web/app/c/[id]/page.tsx New public collection page; correctly gates content behind password/email-restriction states and renders metadata, branding, hero, folders, and paginated videos.
packages/database/schema.ts Adds public boolean and settings JSON to both folders and spaces; adds composite indexes that support the new public-collection queries.
apps/web/app/(org)/dashboard/caps/components/Folder.tsx Reworked to a fieldset container to allow interactive children without anchor nesting; adds public indicator and Make public dropdown toggle, but the toggle is shown to all users regardless of manage permission (flagged).
packages/web-backend/src/Folders/index.ts Adds public and publicPage fields to FolderCreate and FolderUpdate; Pro-gates enabling or customizing the public page; uses JSON_MERGE_PATCH for atomic settings updates.
packages/web-domain/src/PublicCollection.ts New domain types and Effect schemas for public collection page settings; PublicPageSettingsUpdate correctly omits logoUrl so only the logo action owns that field.
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
apps/web/app/(org)/dashboard/caps/components/Folder.tsx:289-430
**"Make public" shown to non-managers**

`FolderCard` receives no `canManage` prop, so every user who sees a folder card — including read-only space members — sees "Make public" (or "Make private") in the `FoldersDropdown`. Clicking it calls `updateFolder.mutate({ id, public: nextPublic })`, which the server correctly rejects, but the error that surfaces is a generic `"Failed to update folder"` rather than something actionable. The client-side Pro check (`ownerIsPro`) only guards the upgrade modal path, not the permission path.

Consider adding a `canManage` boolean to `FolderDataType` and threading it down to `FoldersDropdown` so the public-toggle items are only shown (or enabled) when the viewer actually has permission to make the change.

### Issue 2 of 2
apps/web/lib/public-collections-policy.ts:25-27
No upper bound is enforced on the page number. Passing a very large value (e.g. `page=9999999`) results in a huge SQL `OFFSET`, which MySQL must still evaluate and skip before returning zero rows. Capping at a reasonable ceiling costs nothing for normal usage and avoids wasted work on adversarial requests.

```suggestion
	if (!Number.isInteger(parsed) || parsed < 1 || parsed > 10_000) return 1;

	return parsed;
```

Reviews (2): Last reviewed commit: "fix(web): address review feedback on pub..." | Re-trigger Greptile

@polarityinc

polarityinc Bot commented Jun 9, 2026

Copy link
Copy Markdown

Paragon Review Skipped

Hi @richiemcilroy! Your Polarity credit balance is insufficient to complete this review.

Please visit https://app.paragon.run to finish your review.

@superagent-security

Copy link
Copy Markdown

Superagent didn't find any vulnerabilities or security issues in this PR.

[],
);

const url = `${webUrl}/c/${collectionId ?? ""}`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Consider encoding the id when building the canonical link (keeps URLs valid if the id ever includes reserved chars, and matches the revalidatePath usage elsewhere).

Suggested change
const url = `${webUrl}/c/${collectionId ?? ""}`;
const url = `${webUrl}/c/${collectionId ? encodeURIComponent(collectionId) : ""}`;


try {
const headersList = await headers();
const request = new Request("https://cap.so/api/collection-password", {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Minor: the URL here is only used to construct a Request object for checkRateLimit, but hard-coding cap.so is a bit confusing for self-hosted deployments.

Suggested change
const request = new Request("https://cap.so/api/collection-password", {
const request = new Request("https://example.invalid/api/collection-password", {

Comment on lines +11 to +18
export async function setVerifiedPasswordCookie(passwordHash: string) {
(await cookies()).set("x-cap-password", await encrypt(passwordHash), {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
});
}

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 Single x-cap-password cookie shared across all resources

The cookie is set on path: "/" and holds exactly one encrypted hash. Verifying a collection password overwrites any previously stored video-password hash, and vice versa. A user who unlocks a password-protected video share (/s/) and then verifies a collection password (/c/) will silently lose access to the video and need to re-enter its password. The same happens in reverse. The cookie value is also read by videoPasswordPredicate in public-collections.ts, so the cross-resource collision is live today. Consider scoping cookies by resource ID (e.g. x-cap-password-${id}) or using a Map-style cookie value.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/lib/password-cookie.ts
Line: 11-18

Comment:
**Single `x-cap-password` cookie shared across all resources**

The cookie is set on `path: "/"` and holds exactly one encrypted hash. Verifying a collection password overwrites any previously stored video-password hash, and vice versa. A user who unlocks a password-protected video share (`/s/`) and then verifies a collection password (`/c/`) will silently lose access to the video and need to re-enter its password. The same happens in reverse. The cookie value is also read by `videoPasswordPredicate` in `public-collections.ts`, so the cross-resource collision is live today. Consider scoping cookies by resource ID (e.g. `x-cap-password-${id}`) or using a `Map`-style cookie value.

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

Comment on lines +275 to +399
const resolvePublicCollection = cache(
async (collectionId: string): Promise<PublicCollection | null> => {
// Fetched by primary key, then gated in `resolvePublicCollectionCandidate`
// so the public/tombstone policy lives in one tested place.
const [folderRow] = await db()
.select({
id: folders.id,
name: folders.name,
color: folders.color,
public: folders.public,
settings: folders.settings,
spaceId: folders.spaceId,
organizationId: folders.organizationId,
organizationName: organizations.name,
organizationTombstoneAt: organizations.tombstoneAt,
allowedEmailDomain: organizations.allowedEmailDomain,
organizationIconUrl: organizations.iconUrl,
ownerStripeSubscriptionStatus: users.stripeSubscriptionStatus,
ownerThirdPartyStripeSubscriptionId:
users.thirdPartyStripeSubscriptionId,
passwordHash: spaces.password,
})
.from(folders)
.innerJoin(organizations, eq(folders.organizationId, organizations.id))
.innerJoin(users, eq(organizations.ownerId, users.id))
.leftJoin(spaces, eq(folders.spaceId, spaces.id))
.where(eq(folders.id, collectionId as Folder.FolderId))
.limit(1);

const folder = resolvePublicCollectionCandidate(
folderRow ? { kind: "folder" as const, ...folderRow } : null,
null,
);

if (folder) {
const publicPage = resolveEffectivePublicPage(
userIsPro({
stripeSubscriptionStatus: folder.ownerStripeSubscriptionStatus,
thirdPartyStripeSubscriptionId:
folder.ownerThirdPartyStripeSubscriptionId,
}),
folder.settings?.publicPage,
);
const icons = await resolveIconUrls({
...publicPageIconKeys(publicPage, {
organizationIconUrl: folder.organizationIconUrl,
}),
});

return {
id: folder.id,
kind: "folder",
name: folder.name,
color: folder.color,
description: null,
spaceId: folder.spaceId,
organizationId: folder.organizationId,
organizationName: folder.organizationName,
allowedEmailDomain: folder.allowedEmailDomain,
passwordHash: folder.passwordHash,
publicPage,
...icons,
};
}

const [spaceRow] = await db()
.select({
id: spaces.id,
name: spaces.name,
description: spaces.description,
public: spaces.public,
settings: spaces.settings,
organizationId: spaces.organizationId,
organizationName: organizations.name,
organizationTombstoneAt: organizations.tombstoneAt,
allowedEmailDomain: organizations.allowedEmailDomain,
organizationIconUrl: organizations.iconUrl,
ownerStripeSubscriptionStatus: users.stripeSubscriptionStatus,
ownerThirdPartyStripeSubscriptionId:
users.thirdPartyStripeSubscriptionId,
passwordHash: spaces.password,
})
.from(spaces)
.innerJoin(organizations, eq(spaces.organizationId, organizations.id))
.innerJoin(users, eq(organizations.ownerId, users.id))
.where(eq(spaces.id, collectionId as Space.SpaceIdOrOrganisationId))
.limit(1);

const space = resolvePublicCollectionCandidate(
null,
spaceRow ? { kind: "space" as const, ...spaceRow } : null,
);

if (!space) return null;

const publicPage = resolveEffectivePublicPage(
userIsPro({
stripeSubscriptionStatus: space.ownerStripeSubscriptionStatus,
thirdPartyStripeSubscriptionId:
space.ownerThirdPartyStripeSubscriptionId,
}),
space.settings?.publicPage,
);
const icons = await resolveIconUrls({
...publicPageIconKeys(publicPage, {
organizationIconUrl: space.organizationIconUrl,
}),
});

return {
id: space.id,
kind: "space",
name: space.name,
color: null,
description: space.description,
spaceId: space.id,
organizationId: space.organizationId,
organizationName: space.organizationName,
allowedEmailDomain: space.allowedEmailDomain,
passwordHash: space.passwordHash,
publicPage,
...icons,
};
},
);

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 Sequential DB round-trips for space collection IDs

resolvePublicCollection always queries folders first, then—only when the folder lookup returns nothing or a private row—queries spaces. For every space collection page render, the folder lookup is wasted, adding a serial DB round-trip. The function is memoized with React cache() so within one request it only fires once, but the first call still pays the double-query cost. Consider running both lookups in parallel with Promise.all and returning whichever resolves to a public candidate, or at minimum add a union/single-query approach.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/lib/public-collections.ts
Line: 275-399

Comment:
**Sequential DB round-trips for space collection IDs**

`resolvePublicCollection` always queries `folders` first, then—only when the folder lookup returns nothing or a private row—queries `spaces`. For every space collection page render, the folder lookup is wasted, adding a serial DB round-trip. The function is memoized with React `cache()` so within one request it only fires once, but the first call still pays the double-query cost. Consider running both lookups in parallel with `Promise.all` and returning whichever resolves to a public candidate, or at minimum add a union/single-query approach.

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 on lines +64 to +67
} catch (error) {
console.error("Error verifying collection password:", error);
return { success: false, error: "Failed to verify password" };
}

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 console.error fires on every wrong-password attempt

All throws inside the try block—including the expected "Invalid password" path—flow through the catch, which calls console.error. Every failed password attempt (including normal typos) will produce a server error log entry, making this noise rather than signal. The expected failure paths (!passwordHash, !valid) should either be handled before the try/catch or logged at a lower level (e.g. console.info), while only truly unexpected errors hit console.error.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/actions/collections/password.ts
Line: 64-67

Comment:
**`console.error` fires on every wrong-password attempt**

All throws inside the `try` block—including the expected `"Invalid password"` path—flow through the `catch`, which calls `console.error`. Every failed password attempt (including normal typos) will produce a server error log entry, making this noise rather than signal. The expected failure paths (`!passwordHash`, `!valid`) should either be handled before the try/catch or logged at a lower level (e.g. `console.info`), while only truly unexpected errors hit `console.error`.

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

Comment on lines +53 to +63
const [[space], access] = await Promise.all([
db()
.select({
organizationId: spaces.organizationId,
public: spaces.public,
})
.from(spaces)
.where(eq(spaces.id, id))
.limit(1),
requireSpaceManager(user.id, id).catch(() => 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 Broad .catch(() => null) swallows unexpected errors as "Unauthorized"

Any error thrown by requireSpaceManager—including DB connection failures or unexpected runtime exceptions—is silently converted to null and then returned as { success: false, error: "Unauthorized" }. Real errors become invisible, which makes production debugging difficult. Narrowing the catch to only the expected denial error allows genuine failures to propagate.

Suggested change
const [[space], access] = await Promise.all([
db()
.select({
organizationId: spaces.organizationId,
public: spaces.public,
})
.from(spaces)
.where(eq(spaces.id, id))
.limit(1),
requireSpaceManager(user.id, id).catch(() => null),
]);
const [spaceRows, access] = await Promise.all([
db()
.select({
organizationId: spaces.organizationId,
public: spaces.public,
})
.from(spaces)
.where(eq(spaces.id, id))
.limit(1),
requireSpaceManager(user.id, id).catch((err) => {
if (err instanceof Error && err.name === "PolicyDeniedError") return null;
throw err;
}),
]);
const [space] = spaceRows;
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/actions/collections/visibility.ts
Line: 53-63

Comment:
**Broad `.catch(() => null)` swallows unexpected errors as "Unauthorized"**

Any error thrown by `requireSpaceManager`—including DB connection failures or unexpected runtime exceptions—is silently converted to `null` and then returned as `{ success: false, error: "Unauthorized" }`. Real errors become invisible, which makes production debugging difficult. Narrowing the catch to only the expected denial error allows genuine failures to propagate.

```suggestion
	const [spaceRows, access] = await Promise.all([
		db()
			.select({
				organizationId: spaces.organizationId,
				public: spaces.public,
			})
			.from(spaces)
			.where(eq(spaces.id, id))
			.limit(1),
		requireSpaceManager(user.id, id).catch((err) => {
			if (err instanceof Error && err.name === "PolicyDeniedError") return null;
			throw err;
		}),
	]);
	const [space] = spaceRows;
```

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

- Store every verified share password in the x-cap-password cookie
  (encrypted JSON array, legacy single-hash cookies still accepted) so
  unlocking a collection no longer evicts a verified video password and
  vice versa; VideoPasswordAttachment now carries all verified hashes
- Run the folder and space lookups in resolvePublicCollection in
  parallel instead of sequentially
- Return expected password failures (typos, removed passwords) without
  logging so console.error stays reserved for genuine errors
- Replace requireSpaceManager(...).catch(() => null) with getSpaceAccess
  so unexpected failures propagate instead of reading as "Unauthorized"
@richiemcilroy

Copy link
Copy Markdown
Member Author

please re-review the pr @greptileai

@richiemcilroy richiemcilroy merged commit a04cfa7 into main Jun 10, 2026
20 of 21 checks passed
password: string,
) {
try {
if (!collectionId || typeof password !== "string") {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Small hardening: since server actions are callable with untrusted input, it might be worth bounding password length to avoid worst-case PBKDF2 CPU/memory on a single request.

Suggested change
if (!collectionId || typeof password !== "string") {
if (!collectionId || typeof password !== "string" || password.length > 1024) {
return { success: false, error: "Failed to verify password" };
}

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