mirror of
https://github.com/docmost/docmost.git
synced 2026-07-25 05:24:43 +10:00
feat: synced blocks (transclusion)
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 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,
|
||||
);
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user