feat: synced blocks (transclusion)

This commit is contained in:
Philipinho
2026-05-07 22:20:29 +01:00
parent 17f3158a3b
commit 94ecc98617
60 changed files with 4556 additions and 96 deletions
@@ -0,0 +1,8 @@
import { IsNotEmpty, IsString } from 'class-validator';
import { LookupDto } from '../../page/transclusion/dto/lookup.dto';
export class ShareTransclusionLookupDto extends LookupDto {
@IsString()
@IsNotEmpty()
shareId!: string;
}
@@ -21,6 +21,7 @@ import {
SharePageIdDto,
UpdateShareDto,
} from './dto/share.dto';
import { ShareTransclusionLookupDto } from './dto/share-transclusion-lookup.dto';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { PageAccessService } from '../page/page-access/page-access.service';
@@ -110,6 +111,20 @@ export class ShareController {
return share;
}
@Public()
@HttpCode(HttpStatus.OK)
@Post('/transclusion/lookup')
async transclusionLookup(
@Body() dto: ShareTransclusionLookupDto,
@AuthWorkspace() workspace: Workspace,
) {
return this.shareService.lookupTransclusionForShare(
dto.shareId,
dto.references,
workspace.id,
);
}
@HttpCode(HttpStatus.OK)
@Post('/for-page')
async getShareForPage(
+2 -1
View File
@@ -3,9 +3,10 @@ import { ShareController } from './share.controller';
import { ShareService } from './share.service';
import { TokenModule } from '../auth/token.module';
import { ShareSeoController } from './share-seo.controller';
import { TransclusionModule } from '../page/transclusion/transclusion.module';
@Module({
imports: [TokenModule],
imports: [TokenModule, TransclusionModule],
controllers: [ShareController, ShareSeoController],
providers: [ShareService],
exports: [ShareService],
+152 -14
View File
@@ -24,6 +24,8 @@ import { updateAttachmentAttr } from './share.util';
import { Page } from '@docmost/db/types/entity.types';
import { validate as isValidUUID } from 'uuid';
import { sql } from 'kysely';
import { TransclusionService } from '../page/transclusion/transclusion.service';
import { TransclusionLookup } from '../page/transclusion/transclusion.types';
@Injectable()
export class ShareService {
@@ -35,6 +37,7 @@ export class ShareService {
private readonly pagePermissionRepo: PagePermissionRepo,
@InjectKysely() private readonly db: KyselyDB,
private readonly tokenService: TokenService,
private readonly transclusionService: TransclusionService,
) {}
async getShareTree(shareId: string, workspaceId: string) {
@@ -281,6 +284,112 @@ export class ShareService {
return ancestor;
}
/**
* Resolve transclusion content for a public share viewer. Each requested
* source page must itself be reachable via the share graph (its own share
* or a shared ancestor with `includeSubPages`), in the same workspace as
* the requesting share, with sharing allowed and no restricted ancestors.
* Sources that don't qualify come back as `no_access` so the editor renders
* the existing placeholder. The viewer's personal permissions are
* intentionally ignored — share-served content is gated only by the share
* graph.
*/
async lookupTransclusionForShare(
shareId: string,
references: Array<{ sourcePageId: string; transclusionId: string }>,
workspaceId: string,
): Promise<{ items: TransclusionLookup[] }> {
const share = await this.shareRepo.findById(shareId);
if (!share || share.workspaceId !== workspaceId) {
throw new NotFoundException('Share not found');
}
const sharingAllowed = await this.isSharingAllowed(
workspaceId,
share.spaceId,
);
if (!sharingAllowed) {
throw new NotFoundException('Share not found');
}
const candidatePageIds = Array.from(
new Set(references.map((r) => r.sourcePageId)),
);
// TODO: Reduce DB round trips at scale by replacing the per-page chain
// with bulk repo methods that take all candidate pageIds at once:
// - shareRepo.getSharesForPages(pageIds, workspaceId): Map<pageId, share>
// - pagePermissionRepo.filterRestrictedPageIds(pageIds): Set<pageId>
// - isSharingAllowed for the distinct spaceIds in one query
// Brings per-request trip count from ~2N+1 (parallel) to 3 (constant)
// for N unique candidate pages. Worth doing if profiling ever flags it.
// Most candidates will share the host share's space, so cache by spaceId
// and seed with the host space we just verified. Stores in-flight
// promises so concurrent chains de-dupe at the request boundary.
const sharingAllowedCache = new Map<string, Promise<boolean>>();
sharingAllowedCache.set(share.spaceId, Promise.resolve(true));
const isSharingAllowedFor = (spaceId: string) => {
const cached = sharingAllowedCache.get(spaceId);
if (cached) return cached;
const p = this.isSharingAllowed(workspaceId, spaceId);
sharingAllowedCache.set(spaceId, p);
return p;
};
// Per-page chains run in parallel; wall time is the slowest chain, not
// the sum. Each chain still does its 23 queries sequentially because
// each step gates the next.
const accessibleResults = await Promise.all(
candidatePageIds.map(async (pageId) => {
const sourceShare = await this.getShareForPage(pageId, workspaceId);
if (!sourceShare) return null;
if (!(await isSharingAllowedFor(sourceShare.spaceId))) return null;
const restricted =
await this.pagePermissionRepo.hasRestrictedAncestor(pageId);
if (restricted) return null;
return pageId;
}),
);
const accessibleSet = new Set<string>(
accessibleResults.filter((id): id is string => id !== null),
);
const { items } = await this.transclusionService.lookupWithAccessSet(
references,
accessibleSet,
);
// Sanitize each item's content for public delivery
// generate per-attachment tokens scoped to the source page
// and strip comment marks.
const tokenized = await Promise.all(
items.map(async (item) => {
if ('status' in item) return item;
const doc = await this.prepareContentForShare(
item.content,
item.sourcePageId,
workspaceId,
);
return { ...item, content: doc?.toJSON() ?? item.content };
}),
);
// Collapse `not_found` to `no_access` for share viewers so the response
// can't be used to tell "page is shared but transclusion id doesn't
// match" from "page isn't shared at all".
const sanitized = tokenized.map((item) =>
'status' in item && item.status === 'not_found'
? {
sourcePageId: item.sourcePageId,
transclusionId: item.transclusionId,
status: 'no_access' as const,
}
: item,
);
return { items: sanitized };
}
async isSharingAllowed(
workspaceId: string,
spaceId: string,
@@ -307,35 +416,64 @@ export class ShareService {
}
async updatePublicAttachments(page: Page): Promise<any> {
const prosemirrorJson = getProsemirrorContent(page.content);
const attachmentIds = getAttachmentIds(prosemirrorJson);
const attachmentMap = new Map<string, string>();
const doc = await this.prepareContentForShare(
page.content,
page.id,
page.workspaceId,
);
return doc?.toJSON() ?? page.content;
}
/**
* Prepare a ProseMirror JSON doc for delivery to a public share viewer.
* Performs the two transforms required by the share threat model:
*
* 1. Mint a per-attachment public token scoped to `attachmentOwnerPageId`
* and rewrite each attachment node's `src`/`url` to the public form
* (`/files/public/...?jwt=`). The receiver enforces
* `attachment.pageId === token.pageId`, which is why the owner page id
* has to be passed in explicitly: the host page for direct shared
* content, the source page for transcluded source-block content
* (attachments in a sync block were uploaded onto the source page).
*
* 2. Strip `comment` marks. Comments are internal-team metadata and must
* not leak structure (existence, location, count, resolved state, or
* comment ids) to public viewers.
*
* Both share-content paths — the host page (`updatePublicAttachments`) and
* the share-scoped transclusion lookup (`lookupTransclusionForShare`) —
* call into this single helper so the two paths can never drift on
* sanitization rules.
*/
private async prepareContentForShare(
content: unknown,
attachmentOwnerPageId: string,
workspaceId: string,
): Promise<Node | null> {
const pmJson = getProsemirrorContent(content);
const attachmentIds = getAttachmentIds(pmJson);
const tokenMap = new Map<string, string>();
await Promise.all(
attachmentIds.map(async (attachmentId: string) => {
const token = await this.tokenService.generateAttachmentToken({
attachmentId,
pageId: page.id,
workspaceId: page.workspaceId,
pageId: attachmentOwnerPageId,
workspaceId,
});
attachmentMap.set(attachmentId, token);
tokenMap.set(attachmentId, token);
}),
);
const doc = jsonToNode(prosemirrorJson);
const doc = jsonToNode(pmJson);
doc?.descendants((node: Node) => {
if (!isAttachmentNode(node.type.name)) return;
const attachmentId = node.attrs.attachmentId;
const token = attachmentMap.get(attachmentId);
const token = tokenMap.get(node.attrs.attachmentId);
if (!token) return;
updateAttachmentAttr(node, 'src', token);
updateAttachmentAttr(node, 'url', token);
});
const removeCommentMarks = removeMarkTypeFromDoc(doc, 'comment');
return removeCommentMarks.toJSON();
return doc ? removeMarkTypeFromDoc(doc, 'comment') : null;
}
}