Public sharing (#218): - Bind public-share content to the requested shareId. getSharedPage now enforces dto.shareId (forwarded from /share/:shareId/p/:slug): the page must be reachable THROUGH that exact share (its own share, or an includeSubPages ancestor that contains it). A forged/mismatched shareId 404s instead of rendering off the slug alone and no longer leaks the real canonical key via redirect. A request with no shareId keeps the legacy slug-capability path. - Trim /shares/page-info: drop internal metadata (creatorId, spaceId, workspaceId, contributorIds, lastUpdated*, parent/position, lock/template flags, timestamps) from the anonymous payload. - Default share-to-web includeSubPages to false (opt-in), so enabling a share no longer silently exposes the whole sub-tree (#216). Editor (#218): - Harden the new-page pre-sync window: the body editor is kept read-only until the collab provider is Connected and synced, so early keystrokes can't land only in local ProseMirror and then be clobbered by the server's empty doc. - Surface a "Connecting… (read-only)" affordance during the static phase so input isn't silently swallowed. Other: - Breadcrumb: resolve from the page's own ancestor data (/pages/breadcrumbs) instead of waiting for the lazily-built sidebar tree, so deep pages don't render a blank breadcrumb for seconds. - Pasting GitHub `> [!type]` callouts now converts to a callout node instead of a literal blockquote (new marked extension wired into markdownToHtml). Tests: editor-sync-state gate (client), getSharedPage share-binding (server), github-callout markdown conversion (editor-ext). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
161
apps/server/src/core/share/share-get-shared-page-binding.spec.ts
Normal file
161
apps/server/src/core/share/share-get-shared-page-binding.spec.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { ShareService } from './share.service';
|
||||
|
||||
/**
|
||||
* Regression for issue #218: public-share content must be bound to the requested
|
||||
* shareId. `getSharedPage` resolves the page off its slug, but when the caller
|
||||
* supplies a shareId it must be reachable THROUGH that exact share — a forged or
|
||||
* mismatched shareId 404s instead of rendering the page off its slug alone. A
|
||||
* request with no shareId keeps the legacy slug-capability behavior.
|
||||
*/
|
||||
const WS = 'ws-1';
|
||||
const PAGE_ID = 'page-uuid-1';
|
||||
const OWN_SHARE_ID = 'share-own';
|
||||
const OWN_SHARE_KEY = 'ownkey';
|
||||
|
||||
function buildService(over: {
|
||||
resolvedShare?: any;
|
||||
ancestorShare?: any; // returned by shareRepo.findById(requestedShareId)
|
||||
ancestorFound?: boolean; // getShareAncestorPage result
|
||||
} = {}) {
|
||||
const resolvedShare = over.resolvedShare ?? {
|
||||
id: OWN_SHARE_ID,
|
||||
key: OWN_SHARE_KEY,
|
||||
includeSubPages: false,
|
||||
spaceId: 'space-1',
|
||||
workspaceId: WS,
|
||||
};
|
||||
const page = { id: PAGE_ID, deletedAt: null, content: { type: 'doc' } };
|
||||
|
||||
const shareRepo = {
|
||||
findById: jest.fn(async () => over.ancestorShare ?? null),
|
||||
};
|
||||
|
||||
const service = new ShareService(
|
||||
shareRepo as any,
|
||||
{} as any, // pageRepo (resolveReadableSharePage is spied)
|
||||
{} as any, // pagePermissionRepo
|
||||
{} as any, // db
|
||||
{} as any, // tokenService
|
||||
{} as any, // transclusionService
|
||||
{} as any, // workspaceRepo
|
||||
);
|
||||
|
||||
jest
|
||||
.spyOn(service, 'resolveReadableSharePage')
|
||||
.mockResolvedValue({ share: resolvedShare, page } as any);
|
||||
jest
|
||||
.spyOn(service, 'updatePublicAttachments')
|
||||
.mockResolvedValue(page.content as any);
|
||||
jest
|
||||
.spyOn(service, 'getShareAncestorPage')
|
||||
.mockResolvedValue(over.ancestorFound ? { id: 'anc' } : null);
|
||||
|
||||
return { service, shareRepo, page, resolvedShare };
|
||||
}
|
||||
|
||||
describe('ShareService.getSharedPage — share binding (#218)', () => {
|
||||
it('returns the page when no shareId is supplied (legacy slug path)', async () => {
|
||||
const { service } = buildService();
|
||||
const out = await service.getSharedPage({ pageId: PAGE_ID } as any, WS);
|
||||
expect(out.page.id).toBe(PAGE_ID);
|
||||
});
|
||||
|
||||
it('returns the page when the shareId matches the resolved share key', async () => {
|
||||
const { service } = buildService();
|
||||
const out = await service.getSharedPage(
|
||||
{ pageId: PAGE_ID, shareId: OWN_SHARE_KEY } as any,
|
||||
WS,
|
||||
);
|
||||
expect(out.page.id).toBe(PAGE_ID);
|
||||
});
|
||||
|
||||
it('returns the page when the shareId matches the resolved share id (case-insensitive key)', async () => {
|
||||
const { service } = buildService();
|
||||
const out = await service.getSharedPage(
|
||||
{ pageId: PAGE_ID, shareId: OWN_SHARE_KEY.toUpperCase() } as any,
|
||||
WS,
|
||||
);
|
||||
expect(out.page.id).toBe(PAGE_ID);
|
||||
});
|
||||
|
||||
it('404s for a forged shareId that resolves to nothing', async () => {
|
||||
const { service } = buildService({ ancestorShare: null });
|
||||
await expect(
|
||||
service.getSharedPage(
|
||||
{ pageId: PAGE_ID, shareId: 'doesnotexist99' } as any,
|
||||
WS,
|
||||
),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('allows an includeSubPages ANCESTOR share that contains the page', async () => {
|
||||
const { service } = buildService({
|
||||
ancestorShare: {
|
||||
id: 'ancestor-share',
|
||||
pageId: 'ancestor-page',
|
||||
includeSubPages: true,
|
||||
workspaceId: WS,
|
||||
},
|
||||
ancestorFound: true,
|
||||
});
|
||||
const out = await service.getSharedPage(
|
||||
{ pageId: PAGE_ID, shareId: 'ancestorkey' } as any,
|
||||
WS,
|
||||
);
|
||||
expect(out.page.id).toBe(PAGE_ID);
|
||||
});
|
||||
|
||||
it('404s for a different share WITHOUT includeSubPages', async () => {
|
||||
const { service } = buildService({
|
||||
ancestorShare: {
|
||||
id: 'other-share',
|
||||
pageId: 'other-page',
|
||||
includeSubPages: false,
|
||||
workspaceId: WS,
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
service.getSharedPage(
|
||||
{ pageId: PAGE_ID, shareId: 'otherkey' } as any,
|
||||
WS,
|
||||
),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('404s for an includeSubPages share that does NOT contain the page', async () => {
|
||||
const { service } = buildService({
|
||||
ancestorShare: {
|
||||
id: 'unrelated-share',
|
||||
pageId: 'unrelated-page',
|
||||
includeSubPages: true,
|
||||
workspaceId: WS,
|
||||
},
|
||||
ancestorFound: false,
|
||||
});
|
||||
await expect(
|
||||
service.getSharedPage(
|
||||
{ pageId: PAGE_ID, shareId: 'unrelatedkey' } as any,
|
||||
WS,
|
||||
),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('404s for a share in a different workspace', async () => {
|
||||
const { service } = buildService({
|
||||
ancestorShare: {
|
||||
id: 'foreign-share',
|
||||
pageId: 'foreign-page',
|
||||
includeSubPages: true,
|
||||
workspaceId: 'other-ws',
|
||||
},
|
||||
ancestorFound: true,
|
||||
});
|
||||
await expect(
|
||||
service.getSharedPage(
|
||||
{ pageId: PAGE_ID, shareId: 'foreignkey' } as any,
|
||||
WS,
|
||||
),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
@@ -93,8 +93,30 @@ export class ShareController {
|
||||
? await this.aiSettings.resolvePublicShareAssistantName(workspace.id)
|
||||
: null;
|
||||
|
||||
// Trim the public payload to what the anonymous renderer actually needs
|
||||
// (#218). Internal metadata — creatorId/lastUpdatedById/contributorIds,
|
||||
// spaceId/workspaceId, AI/source bookkeeping, lock/template flags,
|
||||
// parent/position, raw timestamps — must not leak to anonymous viewers.
|
||||
const { page, share } = shareData;
|
||||
const publicPage = {
|
||||
id: page.id,
|
||||
slugId: page.slugId,
|
||||
title: page.title,
|
||||
icon: page.icon,
|
||||
content: page.content,
|
||||
};
|
||||
const publicShare = {
|
||||
id: share.id,
|
||||
key: share.key,
|
||||
includeSubPages: share.includeSubPages,
|
||||
searchIndexing: share.searchIndexing,
|
||||
level: share.level,
|
||||
sharedPage: share.sharedPage,
|
||||
};
|
||||
|
||||
return {
|
||||
...shareData,
|
||||
page: publicPage,
|
||||
share: publicShare,
|
||||
aiAssistant,
|
||||
aiAssistantName,
|
||||
features: this.licenseCheckService.resolveFeatures(
|
||||
|
||||
@@ -189,9 +189,9 @@ export class ShareService {
|
||||
}
|
||||
|
||||
async getSharedPage(dto: ShareInfoDto, workspaceId: string) {
|
||||
// Resolve via the single canonical boundary. There is no independent
|
||||
// requested shareId here (the share is resolved FROM the page), so no
|
||||
// share-id match is performed.
|
||||
// Resolve via the single canonical boundary. The share is resolved FROM the
|
||||
// page (the request carries the page slug), so the boundary itself performs
|
||||
// no share-id match here.
|
||||
const resolved = await this.resolveReadableSharePage(
|
||||
null,
|
||||
dto.pageId,
|
||||
@@ -205,11 +205,64 @@ export class ShareService {
|
||||
|
||||
const { share, page } = resolved;
|
||||
|
||||
// Bind content to the requested share (#218). When the caller supplies a
|
||||
// shareId/key (the `/share/:shareId/p/:slug` route now forwards it), the
|
||||
// page must be reachable THROUGH that exact share — a forged or mismatched
|
||||
// shareId must 404 instead of rendering the page off its slug alone, and it
|
||||
// must not be answerable with the page's real (canonical) share key. A
|
||||
// request with no shareId keeps the legacy slug-capability behavior (the
|
||||
// `/share/p/:slug` route + internal title look-ups); the slug nanoid stays
|
||||
// the access secret there — an inherited Docmost design we don't widen.
|
||||
if (dto.shareId) {
|
||||
const reachable = await this.isPageReachableThroughShare(
|
||||
dto.shareId,
|
||||
share,
|
||||
page.id,
|
||||
workspaceId,
|
||||
);
|
||||
if (!reachable) {
|
||||
throw new NotFoundException('Shared page not found');
|
||||
}
|
||||
}
|
||||
|
||||
page.content = await this.updatePublicAttachments(page);
|
||||
|
||||
return { page, share };
|
||||
}
|
||||
|
||||
/**
|
||||
* Does `requestedShareId` (a share id OR key) legitimately grant access to
|
||||
* `pageId`? True when it names the page's own resolved share, or an ancestor
|
||||
* share with `includeSubPages` that contains the page. Any other value
|
||||
* (unknown key, wrong workspace, a sibling share that doesn't cover the page)
|
||||
* is false, so a guessed slug paired with a forged shareId can't render.
|
||||
*/
|
||||
private async isPageReachableThroughShare(
|
||||
requestedShareId: string,
|
||||
resolvedShare: NonNullable<
|
||||
Awaited<ReturnType<ShareService['getShareForPage']>>
|
||||
>,
|
||||
pageId: string,
|
||||
workspaceId: string,
|
||||
): Promise<boolean> {
|
||||
// Fast path: the request names the page's own resolved share.
|
||||
if (
|
||||
requestedShareId === resolvedShare.id ||
|
||||
requestedShareId.toLowerCase() === resolvedShare.key?.toLowerCase()
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Otherwise it may name an includeSubPages ANCESTOR share: the page has its
|
||||
// own closer share but is also served under the ancestor's public tree.
|
||||
const requested = await this.shareRepo.findById(requestedShareId);
|
||||
if (!requested || requested.workspaceId !== workspaceId) return false;
|
||||
if (!requested.includeSubPages) return false;
|
||||
|
||||
const ancestor = await this.getShareAncestorPage(requested.pageId, pageId);
|
||||
return !!ancestor;
|
||||
}
|
||||
|
||||
async getShareForPage(pageId: string, workspaceId: string) {
|
||||
// here we try to check if a page was shared directly or if it inherits the share from its closest shared ancestor
|
||||
const share = await this.db
|
||||
|
||||
Reference in New Issue
Block a user