fix: scope synced blocks to workspace, gate unsync on edit permission

This commit is contained in:
Philipinho
2026-05-08 01:15:13 +01:00
parent 09f2b84988
commit 159920bc84
13 changed files with 122 additions and 49 deletions
@@ -165,7 +165,7 @@ export class PersistenceExtension implements Extension {
}
if (page) {
await this.syncTransclusion(pageId, tiptapJson);
await this.syncTransclusion(pageId, page.workspaceId, tiptapJson);
}
if (page) {
@@ -250,10 +250,15 @@ export class PersistenceExtension implements Extension {
*/
private async syncTransclusion(
pageId: string,
workspaceId: string,
tiptapJson: unknown,
): Promise<void> {
try {
await this.transclusionService.syncPageTransclusions(pageId, tiptapJson);
await this.transclusionService.syncPageTransclusions(
pageId,
workspaceId,
tiptapJson,
);
} catch (err) {
this.logger.error(
{ err, pageId },
@@ -261,7 +266,11 @@ export class PersistenceExtension implements Extension {
);
}
try {
await this.transclusionService.syncPageReferences(pageId, tiptapJson);
await this.transclusionService.syncPageReferences(
pageId,
workspaceId,
tiptapJson,
);
} catch (err) {
this.logger.error(
{ err, pageId },
@@ -677,7 +677,11 @@ export class PageService {
// pages never have prior rows so we can skip the diff and just bulk-insert.
try {
await this.transclusionService.insertTransclusionsForPages(
insertablePages.map((p) => ({ id: p.id, content: p.content })),
insertablePages.map((p) => ({
id: p.id,
workspaceId: p.workspaceId,
content: p.content,
})),
);
} catch (err) {
this.logger.error(
@@ -688,7 +692,11 @@ export class PageService {
try {
await this.transclusionService.insertReferencesForPages(
insertablePages.map((p) => ({ id: p.id, content: p.content })),
insertablePages.map((p) => ({
id: p.id,
workspaceId: p.workspaceId,
content: p.content,
})),
);
} catch (err) {
this.logger.error(
@@ -25,10 +25,10 @@ describe('TransclusionController.lookup', () => {
controller = module.get(TransclusionController);
});
const user = { id: 'u1' } as any;
const user = { id: 'u1', workspaceId: 'w1' } as any;
const ref = { sourcePageId: 'p1', transclusionId: 'e1' };
it('passes the references and viewer id through to the service and returns its result', async () => {
it('passes the references, viewer id and workspace id through to the service and returns its result', async () => {
service.lookup.mockResolvedValue({
items: [
{
@@ -43,6 +43,6 @@ describe('TransclusionController.lookup', () => {
const out = await controller.lookup({ references: [ref] } as any, user);
expect(out.items[0]).not.toHaveProperty('status');
expect((out.items[0] as any).content).toEqual({ type: 'doc' });
expect(service.lookup).toHaveBeenCalledWith([ref], 'u1');
expect(service.lookup).toHaveBeenCalledWith([ref], 'u1', 'w1');
});
});
@@ -6,6 +6,7 @@ import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
import { StorageService } from '../../../../integrations/storage/storage.service';
import { PageAccessService } from '../../page-access/page-access.service';
describe('TransclusionService.syncPageTransclusions', () => {
let service: TransclusionService;
@@ -27,6 +28,7 @@ describe('TransclusionService.syncPageTransclusions', () => {
{ provide: PagePermissionRepo, useValue: {} },
{ provide: AttachmentRepo, useValue: {} },
{ provide: StorageService, useValue: {} },
{ provide: PageAccessService, useValue: {} },
],
}).compile();
service = module.get(TransclusionService);
@@ -34,6 +36,7 @@ describe('TransclusionService.syncPageTransclusions', () => {
});
const pageId = '00000000-0000-0000-0000-000000000001';
const workspaceId = '00000000-0000-0000-0000-000000000099';
it('inserts new transclusions that did not exist before', async () => {
repo.findByPageId.mockResolvedValue([]);
@@ -48,7 +51,7 @@ describe('TransclusionService.syncPageTransclusions', () => {
],
};
const result = await service.syncPageTransclusions(pageId, pm);
const result = await service.syncPageTransclusions(pageId, workspaceId, pm);
expect(result).toEqual({ inserted: 1, updated: 0, deleted: 0 });
expect(repo.insert).toHaveBeenCalledTimes(1);
@@ -91,7 +94,7 @@ describe('TransclusionService.syncPageTransclusions', () => {
],
};
const result = await service.syncPageTransclusions(pageId, pm);
const result = await service.syncPageTransclusions(pageId, workspaceId, pm);
expect(result).toEqual({ inserted: 0, updated: 1, deleted: 0 });
expect(repo.update).toHaveBeenCalledWith(
@@ -128,7 +131,7 @@ describe('TransclusionService.syncPageTransclusions', () => {
],
};
const result = await service.syncPageTransclusions(pageId, pm);
const result = await service.syncPageTransclusions(pageId, workspaceId, pm);
expect(result).toEqual({ inserted: 0, updated: 0, deleted: 0 });
expect(repo.update).not.toHaveBeenCalled();
@@ -147,7 +150,7 @@ describe('TransclusionService.syncPageTransclusions', () => {
]);
const pm = { type: 'doc', content: [{ type: 'paragraph' }] };
const result = await service.syncPageTransclusions(pageId, pm);
const result = await service.syncPageTransclusions(pageId, workspaceId, pm);
expect(result).toEqual({ inserted: 0, updated: 0, deleted: 1 });
expect(repo.deleteByPageAndTransclusionIds).toHaveBeenCalledWith(
@@ -159,7 +162,7 @@ describe('TransclusionService.syncPageTransclusions', () => {
it('handles empty doc → noop', async () => {
repo.findByPageId.mockResolvedValue([]);
const result = await service.syncPageTransclusions(pageId, null);
const result = await service.syncPageTransclusions(pageId, workspaceId, null);
expect(result).toEqual({ inserted: 0, updated: 0, deleted: 0 });
expect(repo.insert).not.toHaveBeenCalled();
expect(repo.update).not.toHaveBeenCalled();
@@ -187,6 +190,7 @@ describe('TransclusionService.syncPageReferences', () => {
{ provide: PagePermissionRepo, useValue: {} },
{ provide: AttachmentRepo, useValue: {} },
{ provide: StorageService, useValue: {} },
{ provide: PageAccessService, useValue: {} },
],
}).compile();
service = module.get(TransclusionService);
@@ -194,6 +198,7 @@ describe('TransclusionService.syncPageReferences', () => {
});
const referencePageId = '00000000-0000-0000-0000-000000000001';
const workspaceId = '00000000-0000-0000-0000-000000000099';
it('inserts new loose references, no deletes when none existed', async () => {
refRepo.findByReferencePageId.mockResolvedValue([]);
@@ -211,17 +216,19 @@ describe('TransclusionService.syncPageReferences', () => {
],
};
const result = await service.syncPageReferences(referencePageId, pm);
const result = await service.syncPageReferences(referencePageId, workspaceId, pm);
expect(result).toEqual({ inserted: 2, deleted: 0 });
expect(refRepo.insertMany).toHaveBeenCalledWith(
[
{
workspaceId,
referencePageId,
sourcePageId: 'p1',
transclusionId: 'e1',
},
{
workspaceId,
referencePageId,
sourcePageId: 'p2',
transclusionId: 'e2',
@@ -250,7 +257,7 @@ describe('TransclusionService.syncPageReferences', () => {
],
};
const result = await service.syncPageReferences(referencePageId, pm);
const result = await service.syncPageReferences(referencePageId, workspaceId, pm);
expect(result).toEqual({ inserted: 0, deleted: 0 });
expect(refRepo.insertMany).not.toHaveBeenCalled();
@@ -268,7 +275,7 @@ describe('TransclusionService.syncPageReferences', () => {
]);
const pm = { type: 'doc', content: [{ type: 'paragraph' }] };
const result = await service.syncPageReferences(referencePageId, pm);
const result = await service.syncPageReferences(referencePageId, workspaceId, pm);
expect(result).toEqual({ inserted: 0, deleted: 1 });
expect(refRepo.deleteByReferenceAndKeys).toHaveBeenCalledWith(
@@ -304,7 +311,7 @@ describe('TransclusionService.syncPageReferences', () => {
],
};
const result = await service.syncPageReferences(referencePageId, pm);
const result = await service.syncPageReferences(referencePageId, workspaceId, pm);
expect(result).toEqual({ inserted: 0, deleted: 0 });
expect(refRepo.insertMany).not.toHaveBeenCalled();
@@ -24,7 +24,8 @@ export class TransclusionController {
async lookup(@Body() dto: LookupDto, @AuthUser() user: User) {
return this.transclusionService.lookup(
dto.references,
user?.id ?? null,
user.id,
user.workspaceId,
);
}
@@ -38,6 +39,7 @@ export class TransclusionController {
sourcePageId: dto.sourcePageId,
transclusionId: dto.transclusionId,
viewerUserId: user.id,
workspaceId: user.workspaceId,
});
}
@@ -51,7 +53,7 @@ export class TransclusionController {
dto.referencePageId,
dto.sourcePageId,
dto.transclusionId,
user.id,
user,
);
}
}
@@ -19,7 +19,8 @@ import {
} from './utils/transclusion-prosemirror.util';
import { rewriteAttachmentsForUnsync } from './utils/transclusion-unsync.util';
import { TransclusionLookup } from './transclusion.types';
import { Page } from '@docmost/db/types/entity.types';
import { Page, User } from '@docmost/db/types/entity.types';
import { PageAccessService } from '../page-access/page-access.service';
type ReferencingPageInfo = {
id: string;
@@ -41,10 +42,12 @@ export class TransclusionService {
private readonly pagePermissionRepo: PagePermissionRepo,
private readonly attachmentRepo: AttachmentRepo,
private readonly storageService: StorageService,
private readonly pageAccessService: PageAccessService,
) {}
async syncPageTransclusions(
pageId: string,
workspaceId: string,
pmJson: unknown,
trx?: KyselyTransaction,
): Promise<{ inserted: number; updated: number; deleted: number }> {
@@ -63,6 +66,7 @@ export class TransclusionService {
if (!prev) {
await this.pageTransclusionsRepo.insert(
{
workspaceId,
pageId,
transclusionId: d.transclusionId,
content: d.content as any,
@@ -102,6 +106,7 @@ export class TransclusionService {
async syncPageReferences(
referencePageId: string,
workspaceId: string,
pmJson: unknown,
trx?: KyselyTransaction,
): Promise<{ inserted: number; deleted: number }> {
@@ -121,6 +126,7 @@ export class TransclusionService {
const toInsert = desired
.filter((d) => !existingKeys.has(keyOf(d)))
.map((d) => ({
workspaceId,
referencePageId,
sourcePageId: d.sourcePageId,
transclusionId: d.transclusionId,
@@ -156,7 +162,7 @@ export class TransclusionService {
* (e.g. duplication, import) where there is nothing to diff against.
*/
async insertTransclusionsForPages(
pages: Array<{ id: string; content: unknown }>,
pages: Array<{ id: string; workspaceId: string; content: unknown }>,
trx?: KyselyTransaction,
): Promise<{ inserted: number }> {
const rows: Parameters<PageTransclusionsRepo['insertMany']>[0] = [];
@@ -164,6 +170,7 @@ export class TransclusionService {
const snapshots = collectTransclusionsFromPmJson(page.content);
for (const s of snapshots) {
rows.push({
workspaceId: page.workspaceId,
pageId: page.id,
transclusionId: s.transclusionId,
content: s.content as any,
@@ -181,10 +188,11 @@ export class TransclusionService {
* (duplication, import) where there is nothing to diff against.
*/
async insertReferencesForPages(
pages: Array<{ id: string; content: unknown }>,
pages: Array<{ id: string; workspaceId: string; content: unknown }>,
trx?: KyselyTransaction,
): Promise<{ inserted: number }> {
const rows: Array<{
workspaceId: string;
referencePageId: string;
sourcePageId: string;
transclusionId: string;
@@ -193,6 +201,7 @@ export class TransclusionService {
const refs = collectReferencesFromPmJson(page.content);
for (const r of refs) {
rows.push({
workspaceId: page.workspaceId,
referencePageId: page.id,
sourcePageId: r.sourcePageId,
transclusionId: r.transclusionId,
@@ -206,23 +215,22 @@ export class TransclusionService {
async lookup(
references: Array<{ sourcePageId: string; transclusionId: string }>,
viewerUserId: string | null,
viewerUserId: string,
workspaceId: string,
): Promise<{ items: TransclusionLookup[] }> {
if (references.length === 0) return { items: [] };
const candidatePageIds = Array.from(
new Set(references.map((r) => r.sourcePageId)),
);
const accessibleSet = viewerUserId
? new Set(
await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds: candidatePageIds,
userId: viewerUserId,
}),
)
: new Set<string>();
const accessibleSet = new Set(
await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds: candidatePageIds,
userId: viewerUserId,
}),
);
return this.lookupWithAccessSet(references, accessibleSet);
return this.lookupWithAccessSet(references, accessibleSet, workspaceId);
}
/**
@@ -234,6 +242,7 @@ export class TransclusionService {
async lookupWithAccessSet(
references: Array<{ sourcePageId: string; transclusionId: string }>,
accessibleSet: Set<string>,
workspaceId: string,
): Promise<{ items: TransclusionLookup[] }> {
if (references.length === 0) return { items: [] };
@@ -248,6 +257,7 @@ export class TransclusionService {
pageId: references[i].sourcePageId,
transclusionId: references[i].transclusionId,
})),
workspaceId,
);
const rowKey = (r: { pageId: string; transclusionId: string }) =>
`${r.pageId}::${r.transclusionId}`;
@@ -256,10 +266,12 @@ export class TransclusionService {
const accessiblePageIds = Array.from(
new Set(accessiblePending.map((i) => references[i].sourcePageId)),
);
const pages = await this.pageRepo.findManyByIds(accessiblePageIds);
const pages = await this.pageRepo.findManyByIds(accessiblePageIds, {
workspaceId,
});
const pageMeta = new Map<string, Date>();
for (const p of pages) {
if (!p.deletedAt) pageMeta.set(p.id, p.updatedAt);
pageMeta.set(p.id, p.updatedAt);
}
for (const i of pendingIdx) {
@@ -306,16 +318,18 @@ export class TransclusionService {
sourcePageId: string;
transclusionId: string;
viewerUserId: string;
workspaceId: string;
}): Promise<{
source: ReferencingPageInfo | null;
references: ReferencingPageInfo[];
}> {
const { sourcePageId, transclusionId, viewerUserId } = opts;
const { sourcePageId, transclusionId, viewerUserId, workspaceId } = opts;
const referencePageIds =
await this.pageTransclusionReferencesRepo.findReferencePageIdsByTransclusion(
sourcePageId,
transclusionId,
workspaceId,
);
const candidatePageIds = Array.from(
@@ -342,7 +356,7 @@ export class TransclusionService {
);
const byId = new Map<string, ReferencingPageInfo>();
for (const p of rows) {
if (!p || p.deletedAt) continue;
if (!p || p.deletedAt || p.workspaceId !== workspaceId) continue;
const space = (p as Page & { space?: { slug?: string } }).space;
byId.set(p.id, {
id: p.id,
@@ -376,7 +390,7 @@ export class TransclusionService {
referencePageId: string,
sourcePageId: string,
transclusionId: string,
viewerUserId: string,
user: User,
): Promise<{ content: unknown }> {
const referencePage = await this.pageRepo.findById(referencePageId);
if (!referencePage || referencePage.deletedAt) {
@@ -388,16 +402,16 @@ export class TransclusionService {
throw new NotFoundException('Source page not found');
}
const accessible = new Set(
await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds: [referencePageId, sourcePageId],
userId: viewerUserId,
}),
);
if (!accessible.has(referencePageId) || !accessible.has(sourcePageId)) {
if (
referencePage.workspaceId !== user.workspaceId ||
sourcePage.workspaceId !== user.workspaceId
) {
throw new ForbiddenException();
}
await this.pageAccessService.validateCanEdit(referencePage, user);
await this.pageAccessService.validateCanView(sourcePage, user);
const transclusion =
await this.pageTransclusionsRepo.findByPageAndTransclusion(
sourcePageId,
@@ -445,7 +459,7 @@ export class TransclusionService {
fileSize: old.fileSize,
mimeType: old.mimeType,
fileExt: old.fileExt,
creatorId: viewerUserId,
creatorId: user.id,
workspaceId: referencePage.workspaceId,
pageId: referencePageId,
spaceId: referencePage.spaceId,
@@ -357,6 +357,7 @@ export class ShareService {
const { items } = await this.transclusionService.lookupWithAccessSet(
references,
accessibleSet,
workspaceId,
);
// Sanitize each item's content for public delivery
@@ -6,6 +6,9 @@ export async function up(db: Kysely<any>): Promise<void> {
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('workspace_id', 'uuid', (col) =>
col.notNull().references('workspaces.id').onDelete('cascade'),
)
.addColumn('page_id', 'uuid', (col) =>
col.notNull().references('pages.id').onDelete('cascade'),
)
@@ -23,11 +26,20 @@ export async function up(db: Kysely<any>): Promise<void> {
])
.execute();
await db.schema
.createIndex('idx_page_transclusions_workspace')
.on('page_transclusions')
.column('workspace_id')
.execute();
await db.schema
.createTable('page_transclusion_references')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('workspace_id', 'uuid', (col) =>
col.notNull().references('workspaces.id').onDelete('cascade'),
)
.addColumn('reference_page_id', 'uuid', (col) =>
col.notNull().references('pages.id').onDelete('cascade'),
)
@@ -50,6 +62,12 @@ export async function up(db: Kysely<any>): Promise<void> {
.on('page_transclusion_references')
.columns(['source_page_id', 'transclusion_id'])
.execute();
await db.schema
.createIndex('idx_page_transclusion_references_workspace')
.on('page_transclusion_references')
.column('workspace_id')
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
@@ -30,12 +30,14 @@ export class PageTransclusionReferencesRepo {
async findReferencePageIdsByTransclusion(
sourcePageId: string,
transclusionId: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<string[]> {
const rows = await dbOrTx(this.db, trx)
.selectFrom('pageTransclusionReferences')
.select('referencePageId')
.distinct()
.where('workspaceId', '=', workspaceId)
.where('sourcePageId', '=', sourcePageId)
.where('transclusionId', '=', transclusionId)
.execute();
@@ -39,12 +39,14 @@ export class PageTransclusionsRepo {
async findManyByPageAndTransclusion(
keys: Array<{ pageId: string; transclusionId: string }>,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<PageTransclusion[]> {
if (keys.length === 0) return [];
return dbOrTx(this.db, trx)
.selectFrom('pageTransclusions')
.selectAll()
.where('workspaceId', '=', workspaceId)
.where((eb) =>
eb.or(
keys.map((k) =>
@@ -104,16 +104,24 @@ export class PageRepo {
pageIds: string[],
opts?: {
trx?: KyselyTransaction;
workspaceId?: string;
},
): Promise<Page[]> {
if (pageIds.length === 0) return [];
const db = dbOrTx(this.db, opts?.trx);
return db
let query = db
.selectFrom('pages')
.select(this.baseFields)
.where('id', 'in', pageIds)
.execute();
.where('id', 'in', pageIds);
if (opts?.workspaceId) {
query = query
.where('workspaceId', '=', opts.workspaceId)
.where('deletedAt', 'is', null);
}
return query.execute();
}
async updatePage(
+2
View File
@@ -234,6 +234,7 @@ export interface PageTransclusionReferences {
referencePageId: string;
id: Generated<string>;
sourcePageId: string;
workspaceId: string;
}
export interface PageTransclusions {
@@ -243,6 +244,7 @@ export interface PageTransclusions {
id: Generated<string>;
pageId: string;
updatedAt: Generated<Timestamp>;
workspaceId: string;
}
export interface PageHistory {