mirror of
https://github.com/docmost/docmost.git
synced 2026-08-30 02:25:01 +08:00
feat: synced blocks (transclusion) (#2163)
* feat: synced blocks (transclusion) * fix:remove name * make placeholders smaller * feat: enforce strict transclusion schema * fix: scope synced blocks to workspace, gate unsync on edit permission * fix collab module error
This commit is contained in:
@@ -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,113 @@ 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 2–3 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,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
// 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 +417,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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user