Review follow-ups for the combined QA-UI fixes (#216/#206/#204/#218/#192): - export/utils: correct the misleading getInternalLinkPageName comment — a bare `v1.2` loses its last dot-segment (`v1`); dots survive only in multi-segment names like `v1.2.md` -> `v1.2`. - share: extract toPublicSharePayload(page, share): PublicSharePayload, an explicit allowlist type+mapper replacing the inline literal in the /shares/page-info anonymous path (#218). Add share.controller.spec.ts that stubs getSharedPage returning internal fields and asserts the response key set EXACTLY equals the whitelist (page + share), so any `...shareData` regression or new leaking field fails. Also key-tests the extracted mapper. - breadcrumb: extract pure resolveBreadcrumbNodes(treeData, ancestors, pageId) (tree-hit -> tree; tree-miss -> map ancestors via canonical pageToTreeNode, dropping the as-any casts; else null) and unit-test all three branches. - share-modal: RTL test asserting enabling a share calls mutateAsync with includeSubPages: false (#216 security default). - share.service: one-line note at getSharedPage on the deferred consolidation of the ancestor-aware match into resolveReadableSharePage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
70 lines
2.1 KiB
TypeScript
70 lines
2.1 KiB
TypeScript
import { Page } from '@docmost/db/types/entity.types';
|
|
|
|
/**
|
|
* The EXACT shape returned to anonymous public-share viewers by the
|
|
* `/shares/page-info` route — the only unauthenticated path that serializes the
|
|
* full {page, share} records. This is a security boundary (#218): the raw rows
|
|
* carry internal metadata — creatorId/lastUpdatedById/contributorIds,
|
|
* spaceId/workspaceId, AI/source bookkeeping, lock/template flags,
|
|
* parent/position and raw timestamps — none of which may leak to an
|
|
* unauthenticated viewer. Keeping the allowlist as an explicit TYPE plus a
|
|
* single mapper means a new leaking field cannot be returned without also
|
|
* widening this contract (and tripping its key-test in share.controller.spec.ts).
|
|
*/
|
|
export interface PublicSharePayload {
|
|
page: {
|
|
id: string;
|
|
slugId: string;
|
|
title: string | null;
|
|
icon: string | null;
|
|
content: unknown;
|
|
};
|
|
share: {
|
|
id: string;
|
|
key: string;
|
|
includeSubPages: boolean | null;
|
|
searchIndexing: boolean | null;
|
|
level: number;
|
|
sharedPage: unknown;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The subset of the resolved share read by the public payload. Declared
|
|
* structurally so the richer getShareForPage result (which adds `level` and
|
|
* `sharedPage` on top of the base Shares row) passes without a cast.
|
|
*/
|
|
interface PublicShareSource {
|
|
id: string;
|
|
key: string;
|
|
includeSubPages: boolean | null;
|
|
searchIndexing: boolean | null;
|
|
// `level` is derived via a SQL literal in getShareForPage, so it surfaces as
|
|
// `unknown` in the resolved share; it is a number at runtime.
|
|
level: unknown;
|
|
sharedPage: unknown;
|
|
}
|
|
|
|
export function toPublicSharePayload(
|
|
page: Page,
|
|
share: PublicShareSource,
|
|
): PublicSharePayload {
|
|
return {
|
|
page: {
|
|
id: page.id,
|
|
slugId: page.slugId,
|
|
title: page.title,
|
|
icon: page.icon,
|
|
content: page.content,
|
|
},
|
|
share: {
|
|
id: share.id,
|
|
key: share.key,
|
|
includeSubPages: share.includeSubPages,
|
|
searchIndexing: share.searchIndexing,
|
|
level: share.level as number,
|
|
sharedPage: share.sharedPage,
|
|
},
|
|
};
|
|
}
|