mirror of
https://github.com/docmost/docmost.git
synced 2026-05-21 01:04:39 +08:00
Merge branch 'main' into confluence
This commit is contained in:
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
Global,
|
||||
Logger,
|
||||
Module,
|
||||
OnModuleDestroy,
|
||||
OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import { Logger, Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { AuthenticationExtension } from './extensions/authentication.extension';
|
||||
import { PersistenceExtension } from './extensions/persistence.extension';
|
||||
import { CollaborationGateway } from './collaboration.gateway';
|
||||
@@ -18,6 +12,10 @@ import { LoggerExtension } from './extensions/logger.extension';
|
||||
import { CollaborationHandler } from './collaboration.handler';
|
||||
import { CollabHistoryService } from './services/collab-history.service';
|
||||
import { WatcherModule } from '../core/watcher/watcher.module';
|
||||
import { TransclusionService } from '../core/page/transclusion/transclusion.service';
|
||||
import { TransclusionModule } from '../core/page/transclusion/transclusion.module';
|
||||
import { StorageModule } from '../integrations/storage/storage.module';
|
||||
import { EnvironmentModule } from '../integrations/environment/environment.module';
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
@@ -28,9 +26,17 @@ import { WatcherModule } from '../core/watcher/watcher.module';
|
||||
HistoryProcessor,
|
||||
CollabHistoryService,
|
||||
CollaborationHandler,
|
||||
TransclusionService,
|
||||
],
|
||||
exports: [CollaborationGateway],
|
||||
imports: [TokenModule, WatcherModule],
|
||||
imports: [
|
||||
TokenModule,
|
||||
WatcherModule,
|
||||
StorageModule.forRootAsync({
|
||||
imports: [EnvironmentModule],
|
||||
}),
|
||||
TransclusionModule,
|
||||
],
|
||||
})
|
||||
export class CollaborationModule implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(CollaborationModule.name);
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
TiptapVideo,
|
||||
TiptapAudio,
|
||||
TiptapPdf,
|
||||
PageBreak,
|
||||
TrailingNode,
|
||||
Attachment,
|
||||
Drawio,
|
||||
@@ -34,12 +35,15 @@ import {
|
||||
Mention,
|
||||
Subpages,
|
||||
Highlight,
|
||||
Indent,
|
||||
UniqueID,
|
||||
Columns,
|
||||
Column,
|
||||
Status,
|
||||
addUniqueIdsToDoc,
|
||||
htmlToMarkdown,
|
||||
TransclusionSource,
|
||||
TransclusionReference,
|
||||
} from '@docmost/editor-ext';
|
||||
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
||||
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
|
||||
@@ -60,10 +64,11 @@ export const tiptapExtensions = [
|
||||
}),
|
||||
Heading,
|
||||
UniqueID.configure({
|
||||
types: ['heading', 'paragraph'],
|
||||
types: ['heading', 'paragraph', 'transclusionSource'],
|
||||
}),
|
||||
Comment,
|
||||
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
||||
Indent,
|
||||
TaskList,
|
||||
TaskItem.configure({
|
||||
nested: true,
|
||||
@@ -90,6 +95,7 @@ export const tiptapExtensions = [
|
||||
TiptapVideo,
|
||||
TiptapAudio,
|
||||
TiptapPdf,
|
||||
PageBreak,
|
||||
Callout,
|
||||
Attachment,
|
||||
CustomCodeBlock,
|
||||
@@ -101,6 +107,8 @@ export const tiptapExtensions = [
|
||||
Columns,
|
||||
Column,
|
||||
Status,
|
||||
TransclusionSource,
|
||||
TransclusionReference,
|
||||
] as any;
|
||||
|
||||
export function jsonToHtml(tiptapJson: any) {
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
HISTORY_FAST_THRESHOLD,
|
||||
HISTORY_INTERVAL,
|
||||
} from '../constants';
|
||||
import { TransclusionService } from '../../core/page/transclusion/transclusion.service';
|
||||
|
||||
@Injectable()
|
||||
export class PersistenceExtension implements Extension {
|
||||
@@ -45,6 +46,7 @@ export class PersistenceExtension implements Extension {
|
||||
@InjectQueue(QueueName.HISTORY_QUEUE) private historyQueue: Queue,
|
||||
@InjectQueue(QueueName.NOTIFICATION_QUEUE) private notificationQueue: Queue,
|
||||
private readonly collabHistory: CollabHistoryService,
|
||||
private readonly transclusionService: TransclusionService,
|
||||
) {}
|
||||
|
||||
async onLoadDocument(data: onLoadDocumentPayload) {
|
||||
@@ -134,7 +136,11 @@ export class PersistenceExtension implements Extension {
|
||||
try {
|
||||
const existingContributors = page.contributorIds || [];
|
||||
contributorIds = Array.from(
|
||||
new Set([...existingContributors, ...editingUserIds, page.creatorId]),
|
||||
new Set([
|
||||
...existingContributors,
|
||||
...editingUserIds,
|
||||
page.creatorId,
|
||||
]),
|
||||
);
|
||||
} catch (err) {
|
||||
//this.logger.debug('Contributors error:' + err?.['message']);
|
||||
@@ -158,6 +164,10 @@ export class PersistenceExtension implements Extension {
|
||||
this.logger.error(`Failed to update page ${pageId}`, err);
|
||||
}
|
||||
|
||||
if (page) {
|
||||
await this.syncTransclusion(pageId, page.workspaceId, tiptapJson);
|
||||
}
|
||||
|
||||
if (page) {
|
||||
await this.collabHistory.addContributors(pageId, editingUserIds);
|
||||
|
||||
@@ -165,7 +175,9 @@ export class PersistenceExtension implements Extension {
|
||||
|
||||
const userMentions = extractUserMentions(mentions);
|
||||
const oldMentions = page.content ? extractMentions(page.content) : [];
|
||||
const oldMentionedUserIds = extractUserMentions(oldMentions).map((m) => m.entityId);
|
||||
const oldMentionedUserIds = extractUserMentions(oldMentions).map(
|
||||
(m) => m.entityId,
|
||||
);
|
||||
|
||||
if (userMentions.length > 0) {
|
||||
await this.notificationQueue.add(QueueJob.PAGE_MENTION_NOTIFICATION, {
|
||||
@@ -229,4 +241,41 @@ export class PersistenceExtension implements Extension {
|
||||
{ jobId: page.id, delay },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh `page_transclusions` and `page_transclusion_references` to match
|
||||
* the page's current content. Runs outside the page-write transaction and
|
||||
* isolates each call so a failure here cannot affect the page save itself.
|
||||
* The diff is idempotent — the next save converges if a round drops anything.
|
||||
*/
|
||||
private async syncTransclusion(
|
||||
pageId: string,
|
||||
workspaceId: string,
|
||||
tiptapJson: unknown,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.transclusionService.syncPageTransclusions(
|
||||
pageId,
|
||||
workspaceId,
|
||||
tiptapJson,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
{ err, pageId },
|
||||
'Failed to sync transclusions for page',
|
||||
);
|
||||
}
|
||||
try {
|
||||
await this.transclusionService.syncPageReferences(
|
||||
pageId,
|
||||
workspaceId,
|
||||
tiptapJson,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
{ err, pageId },
|
||||
'Failed to sync transclusion references for page',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
||||
import { AppController } from '../../app.controller';
|
||||
import { AppService } from '../../app.service';
|
||||
import { EnvironmentModule } from '../../integrations/environment/environment.module';
|
||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||
import { CollaborationModule } from '../collaboration.module';
|
||||
import { DatabaseModule } from '@docmost/db/database.module';
|
||||
import { QueueModule } from '../../integrations/queue/queue.module';
|
||||
@@ -12,6 +13,8 @@ import { LoggerModule } from '../../common/logger/logger.module';
|
||||
import { RedisModule } from '@nestjs-labs/nestjs-ioredis';
|
||||
import { RedisConfigService } from '../../integrations/redis/redis-config.service';
|
||||
import { CaslModule } from '../../core/casl/casl.module';
|
||||
import { CacheModule } from '@nestjs/cache-manager';
|
||||
import KeyvRedis from '@keyv/redis';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -26,6 +29,18 @@ import { CaslModule } from '../../core/casl/casl.module';
|
||||
RedisModule.forRootAsync({
|
||||
useClass: RedisConfigService,
|
||||
}),
|
||||
CacheModule.registerAsync({
|
||||
isGlobal: true,
|
||||
useFactory: async (environmentService: EnvironmentService) => {
|
||||
const redisUrl = environmentService.getRedisUrl();
|
||||
|
||||
return {
|
||||
ttl: 5 * 1000,
|
||||
stores: [new KeyvRedis(redisUrl)],
|
||||
};
|
||||
},
|
||||
inject: [EnvironmentService],
|
||||
}),
|
||||
],
|
||||
controllers: [
|
||||
AppController,
|
||||
|
||||
@@ -62,14 +62,14 @@ function applyMarkToYFragment(
|
||||
) {
|
||||
let pos = 0;
|
||||
|
||||
const processItem = (item: any): boolean => {
|
||||
const processItem = (item: any, parentNodeName?: string): boolean => {
|
||||
if (pos >= to) return false;
|
||||
|
||||
if (item instanceof Y.XmlText) {
|
||||
const textLength = item.length;
|
||||
const itemEnd = pos + textLength;
|
||||
|
||||
if (itemEnd > from && pos < to) {
|
||||
if (itemEnd > from && pos < to && parentNodeName !== 'codeBlock') {
|
||||
const formatFrom = Math.max(0, from - pos);
|
||||
const formatTo = Math.min(textLength, to - pos);
|
||||
const formatLength = formatTo - formatFrom;
|
||||
@@ -82,7 +82,7 @@ function applyMarkToYFragment(
|
||||
} else if (item instanceof Y.XmlElement) {
|
||||
pos++; // Opening tag
|
||||
for (let i = 0; i < item.length; i++) {
|
||||
if (!processItem(item.get(i))) return false;
|
||||
if (!processItem(item.get(i), item.nodeName)) return false;
|
||||
}
|
||||
pos++; // Closing tag
|
||||
}
|
||||
|
||||
@@ -23,6 +23,11 @@ export const AuditEvent = {
|
||||
API_KEY_UPDATED: 'api_key.updated',
|
||||
API_KEY_DELETED: 'api_key.deleted',
|
||||
|
||||
// SCIM Tokens
|
||||
SCIM_TOKEN_CREATED: 'scim_token.created',
|
||||
SCIM_TOKEN_UPDATED: 'scim_token.updated',
|
||||
SCIM_TOKEN_DELETED: 'scim_token.deleted',
|
||||
|
||||
// Space
|
||||
SPACE_CREATED: 'space.created',
|
||||
SPACE_UPDATED: 'space.updated',
|
||||
@@ -119,6 +124,7 @@ export const AuditResource = {
|
||||
COMMENT: 'comment',
|
||||
SHARE: 'share',
|
||||
API_KEY: 'api_key',
|
||||
SCIM_TOKEN: 'scim_token',
|
||||
SSO_PROVIDER: 'sso_provider',
|
||||
WORKSPACE_INVITATION: 'workspace_invitation',
|
||||
ATTACHMENT: 'attachment',
|
||||
|
||||
@@ -8,6 +8,7 @@ export const Feature = {
|
||||
AI: 'ai',
|
||||
CONFLUENCE_IMPORT: 'import:confluence',
|
||||
DOCX_IMPORT: 'import:docx',
|
||||
PDF_IMPORT: 'import:pdf',
|
||||
ATTACHMENT_INDEXING: 'attachment:indexing',
|
||||
SECURITY_SETTINGS: 'security:settings',
|
||||
MCP: 'mcp',
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
export const CacheKey = {
|
||||
LICENSE_VALID: (workspaceId: string) => `license:valid:${workspaceId}`,
|
||||
SPACE_ROLES: (userId: string, spaceId: string) =>
|
||||
`perm:space-roles:${userId}:${spaceId}`,
|
||||
PAGE_CAN_EDIT: (userId: string, pageId: string) =>
|
||||
`perm:can-edit:${userId}:${pageId}`,
|
||||
};
|
||||
|
||||
// Permission caches dedupe repeated checks within and across short request bursts.
|
||||
// 5s keeps staleness on revocations bounded.
|
||||
export const PERMISSION_CACHE_TTL_MS = 5_000;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
const ATTACHMENT_NODE_TYPES = [
|
||||
'attachment',
|
||||
'image',
|
||||
'video',
|
||||
'audio',
|
||||
'pdf',
|
||||
'excalidraw',
|
||||
'drawio',
|
||||
];
|
||||
|
||||
export function isAttachmentNode(nodeType: string): boolean {
|
||||
return ATTACHMENT_NODE_TYPES.includes(nodeType);
|
||||
}
|
||||
@@ -1,2 +1,2 @@
|
||||
export * from './generateHTML.js';
|
||||
export * from './generateJSON.js';
|
||||
export * from './generateHTML';
|
||||
export * from './generateJSON';
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { htmlToJson, jsonToHtml } from '../../../collaboration/collaboration.util';
|
||||
|
||||
const findFirstChild = (
|
||||
json: any,
|
||||
type: string,
|
||||
): any | undefined => {
|
||||
if (!json || typeof json !== 'object') return undefined;
|
||||
if (json.type === type) return json;
|
||||
if (Array.isArray(json.content)) {
|
||||
for (const child of json.content) {
|
||||
const found = findFirstChild(child, type);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
describe('indent attribute round-trip', () => {
|
||||
it('parses data-indent on a paragraph into the indent attribute', () => {
|
||||
const html = '<p data-indent="3">Hello</p>';
|
||||
const json = htmlToJson(html);
|
||||
const paragraph = findFirstChild(json, 'paragraph');
|
||||
expect(paragraph).toBeDefined();
|
||||
expect(paragraph.attrs.indent).toBe(3);
|
||||
});
|
||||
|
||||
it('parses data-indent on a heading into the indent attribute', () => {
|
||||
const html = '<h2 data-indent="2">Heading</h2>';
|
||||
const json = htmlToJson(html);
|
||||
const heading = findFirstChild(json, 'heading');
|
||||
expect(heading).toBeDefined();
|
||||
expect(heading.attrs.indent).toBe(2);
|
||||
expect(heading.attrs.level).toBe(2);
|
||||
});
|
||||
|
||||
it('clamps out-of-range data-indent values', () => {
|
||||
const html = '<p data-indent="42">Too deep</p>';
|
||||
const json = htmlToJson(html);
|
||||
const paragraph = findFirstChild(json, 'paragraph');
|
||||
expect(paragraph.attrs.indent).toBe(8);
|
||||
});
|
||||
|
||||
it('renders nonzero indent back to data-indent on HTML serialization', () => {
|
||||
const html = '<p data-indent="4">Round-trip</p>';
|
||||
const json = htmlToJson(html);
|
||||
const out = jsonToHtml(json);
|
||||
expect(out).toContain('data-indent="4"');
|
||||
});
|
||||
|
||||
it('omits data-indent for indent zero', () => {
|
||||
const html = '<p>No indent</p>';
|
||||
const json = htmlToJson(html);
|
||||
const out = jsonToHtml(json);
|
||||
expect(out).not.toContain('data-indent');
|
||||
});
|
||||
|
||||
it('preserves indent through HTML → JSON → HTML', () => {
|
||||
const original = '<p data-indent="5">Five deep</p>';
|
||||
const json = htmlToJson(original);
|
||||
const final = jsonToHtml(json);
|
||||
expect(final).toContain('data-indent="5"');
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
INTERNAL_LINK_REGEX,
|
||||
extractPageSlugId,
|
||||
} from '../../../integrations/export/utils';
|
||||
import { isAttachmentNode } from './attachment-node-types';
|
||||
|
||||
export interface MentionNode {
|
||||
id: string;
|
||||
@@ -122,18 +123,7 @@ export function getProsemirrorContent(content: any) {
|
||||
);
|
||||
}
|
||||
|
||||
export function isAttachmentNode(nodeType: string) {
|
||||
const attachmentNodeTypes = [
|
||||
'attachment',
|
||||
'image',
|
||||
'video',
|
||||
'audio',
|
||||
'pdf',
|
||||
'excalidraw',
|
||||
'drawio',
|
||||
];
|
||||
return attachmentNodeTypes.includes(nodeType);
|
||||
}
|
||||
export { isAttachmentNode };
|
||||
|
||||
export function getAttachmentIds(prosemirrorJson: any) {
|
||||
const doc = jsonToNode(prosemirrorJson);
|
||||
|
||||
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
import * as path from 'path';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { sanitize } from 'sanitize-filename-ts';
|
||||
import sanitize = require('sanitize-filename');
|
||||
import { FastifyRequest } from 'fastify';
|
||||
import { Readable, Transform } from 'stream';
|
||||
|
||||
@@ -72,11 +72,33 @@ export function extractDateFromUuid7(uuid7: string) {
|
||||
return new Date(timestamp);
|
||||
}
|
||||
|
||||
export function sanitizeFileName(fileName: string): string {
|
||||
const sanitizedFilename = sanitize(fileName)
|
||||
.replace(/ /g, '_')
|
||||
.replace(/#/g, '_');
|
||||
return sanitizedFilename.slice(0, 255);
|
||||
export type SanitizeFileNameOptions = {
|
||||
/** Keep spaces and `#` instead of replacing them with `_`. Useful for
|
||||
* download filenames where readability matters. Defaults to false. */
|
||||
preserveSpaces?: boolean;
|
||||
};
|
||||
|
||||
export function sanitizeFileName(
|
||||
fileName: string,
|
||||
options: SanitizeFileNameOptions = {},
|
||||
): string {
|
||||
// Decode percent-encoded sequences so that bypasses like "..%2F" reach
|
||||
// sanitize() as literal "../" and get stripped. sanitize-filename only
|
||||
// strips literal characters and won't catch encoded path separators
|
||||
// on its own.
|
||||
const decoded = fileName.replace(/%[0-9a-fA-F]{2}/g, (m) => {
|
||||
try {
|
||||
return decodeURIComponent(m);
|
||||
} catch {
|
||||
return m;
|
||||
}
|
||||
});
|
||||
|
||||
const sanitized = sanitize(decoded);
|
||||
if (options.preserveSpaces) {
|
||||
return sanitized;
|
||||
}
|
||||
return sanitized.replace(/ /g, '_').replace(/#/g, '_');
|
||||
}
|
||||
|
||||
export function removeAccent(str: string): string {
|
||||
@@ -88,7 +110,7 @@ export function extractBearerTokenFromHeader(
|
||||
request: FastifyRequest,
|
||||
): string | undefined {
|
||||
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||
return type === 'Bearer' ? token : undefined;
|
||||
return type?.toLowerCase() === 'bearer' ? token : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Cache } from 'cache-manager';
|
||||
|
||||
export async function withCache<T>(
|
||||
cacheManager: Cache,
|
||||
key: string,
|
||||
ttlMs: number,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
try {
|
||||
const cached = await cacheManager.get<{ v: T }>(key);
|
||||
if (cached !== undefined && cached !== null) {
|
||||
return cached.v;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[withCache] get failed for "${key}", falling back to source`, err);
|
||||
}
|
||||
|
||||
const value = await fn();
|
||||
|
||||
try {
|
||||
await cacheManager.set(key, { v: value }, ttlMs);
|
||||
} catch (err) {
|
||||
console.warn(`[withCache] set failed for "${key}"`, err);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
@@ -356,9 +356,19 @@ export class AttachmentController {
|
||||
throw new BadRequestException('Invalid image attachment type');
|
||||
}
|
||||
|
||||
const filenameWithoutExt = path.basename(fileName, path.extname(fileName));
|
||||
if (!isValidUUID(filenameWithoutExt)) {
|
||||
throw new BadRequestException('Invalid file id');
|
||||
if (!fileName) {
|
||||
throw new BadRequestException('Invalid file name');
|
||||
}
|
||||
|
||||
const ext = path.extname(fileName);
|
||||
const filenameWithoutExt = path.basename(fileName, ext);
|
||||
|
||||
if (
|
||||
!ext ||
|
||||
!isValidUUID(filenameWithoutExt) ||
|
||||
`${filenameWithoutExt}${ext}` !== fileName
|
||||
) {
|
||||
throw new BadRequestException('Invalid file name');
|
||||
}
|
||||
|
||||
const filePath = `${getAttachmentFolderPath(attachmentType, workspace.id)}/${fileName}`;
|
||||
|
||||
@@ -18,6 +18,7 @@ import { PageAccessModule } from './page/page-access/page-access.module';
|
||||
import { DomainMiddleware } from '../common/middlewares/domain.middleware';
|
||||
import { AuditContextMiddleware } from '../common/middlewares/audit-context.middleware';
|
||||
import { ShareModule } from './share/share.module';
|
||||
import { LabelModule } from './label/label.module';
|
||||
import { NotificationModule } from './notification/notification.module';
|
||||
import { WatcherModule } from './watcher/watcher.module';
|
||||
import { FavoriteModule } from './favorite/favorite.module';
|
||||
@@ -39,6 +40,7 @@ import { ClsMiddleware } from 'nestjs-cls';
|
||||
CaslModule,
|
||||
PageAccessModule,
|
||||
ShareModule,
|
||||
LabelModule,
|
||||
NotificationModule,
|
||||
WatcherModule,
|
||||
SessionModule,
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { GroupService } from './group.service';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
|
||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
AUDIT_SERVICE,
|
||||
IAuditService,
|
||||
} from '../../../integrations/audit/audit.service';
|
||||
import { dbOrTx } from '@docmost/db/utils';
|
||||
|
||||
@Injectable()
|
||||
export class GroupUserService {
|
||||
@@ -54,17 +55,23 @@ export class GroupUserService {
|
||||
userIds: string[],
|
||||
groupId: string,
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
await this.groupService.findAndValidateGroup(groupId, workspaceId);
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await this.groupService.findAndValidateGroup(groupId, workspaceId, trx);
|
||||
|
||||
if (userIds.length === 0) return;
|
||||
|
||||
// make sure we have valid workspace users
|
||||
const validUsers = await this.db
|
||||
const validUsers = await db
|
||||
.selectFrom('users')
|
||||
.select(['id', 'name'])
|
||||
.where('users.id', 'in', userIds)
|
||||
.where('users.workspaceId', '=', workspaceId)
|
||||
.execute();
|
||||
|
||||
if (validUsers.length === 0) return;
|
||||
|
||||
// prepare users to add to group
|
||||
const groupUsersToInsert = [];
|
||||
for (const user of validUsers) {
|
||||
@@ -75,7 +82,7 @@ export class GroupUserService {
|
||||
}
|
||||
|
||||
// batch insert new group users
|
||||
await this.db
|
||||
await db
|
||||
.insertInto('groupUsers')
|
||||
.values(groupUsersToInsert)
|
||||
.onConflict((oc) => oc.columns(['userId', 'groupId']).doNothing())
|
||||
|
||||
@@ -216,8 +216,11 @@ export class GroupService {
|
||||
async findAndValidateGroup(
|
||||
groupId: string,
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<Group> {
|
||||
const group = await this.groupRepo.findById(groupId, workspaceId);
|
||||
const group = await this.groupRepo.findById(groupId, workspaceId, {
|
||||
trx,
|
||||
});
|
||||
if (!group) {
|
||||
throw new NotFoundException('Group not found');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Matches,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { LabelType } from '@docmost/db/repos/label/label.repo';
|
||||
import { PageIdDto } from '../../page/dto/page.dto';
|
||||
import { normalizeLabelName } from '../utils';
|
||||
|
||||
//TODO: We may support SPACE/TEMPLATE labels in the future
|
||||
const SUPPORTED_LABEL_TYPES: LabelType[] = [LabelType.PAGE];
|
||||
|
||||
export class AddLabelsDto extends PageIdDto {
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(25)
|
||||
@IsString({ each: true })
|
||||
@IsNotEmpty({ each: true })
|
||||
@Transform(({ value }) =>
|
||||
Array.isArray(value) ? value.map(normalizeLabelName) : value,
|
||||
)
|
||||
@MaxLength(100, { each: true })
|
||||
@Matches(/^[a-z0-9_-][a-z0-9_~-]*$/, {
|
||||
each: true,
|
||||
message:
|
||||
'Label names can only contain letters, numbers, hyphens, underscores, and tildes, and cannot start with a tilde',
|
||||
})
|
||||
names: string[];
|
||||
}
|
||||
|
||||
export class RemoveLabelDto extends PageIdDto {
|
||||
@IsUUID()
|
||||
labelId: string;
|
||||
}
|
||||
|
||||
export class FindPagesByLabelDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
labelId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Transform(({ value }) =>
|
||||
typeof value === 'string' ? normalizeLabelName(value) : value,
|
||||
)
|
||||
@MaxLength(100)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
spaceId?: string;
|
||||
}
|
||||
|
||||
export class LabelInfoDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Transform(({ value }) =>
|
||||
typeof value === 'string' ? normalizeLabelName(value) : value,
|
||||
)
|
||||
@MaxLength(100)
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
@IsIn(SUPPORTED_LABEL_TYPES)
|
||||
type: LabelType;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
spaceId?: string;
|
||||
}
|
||||
|
||||
export class ListLabelsDto {
|
||||
@IsString()
|
||||
@IsIn(SUPPORTED_LABEL_TYPES)
|
||||
type: LabelType;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { LabelService } from './label.service';
|
||||
import {
|
||||
FindPagesByLabelDto,
|
||||
LabelInfoDto,
|
||||
ListLabelsDto,
|
||||
} from './dto/label.dto';
|
||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import { LabelRepo, LabelType } from '@docmost/db/repos/label/label.repo';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { emptyCursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
|
||||
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
|
||||
import {
|
||||
SpaceCaslAction,
|
||||
SpaceCaslSubject,
|
||||
} from '../casl/interfaces/space-ability.type';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('labels')
|
||||
export class LabelController {
|
||||
constructor(
|
||||
private readonly labelService: LabelService,
|
||||
private readonly labelRepo: LabelRepo,
|
||||
private readonly spaceAbility: SpaceAbilityFactory,
|
||||
) {}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('/')
|
||||
async getLabels(
|
||||
@Body() dto: ListLabelsDto,
|
||||
@Body() pagination: PaginationOptions,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return this.labelService.getLabels(
|
||||
workspace.id,
|
||||
user.id,
|
||||
dto.type,
|
||||
pagination,
|
||||
);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('pages')
|
||||
async findPagesByLabel(
|
||||
@Body() dto: FindPagesByLabelDto,
|
||||
@Body() pagination: PaginationOptions,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
if (dto.spaceId) {
|
||||
await this.assertCanReadSpace(user, dto.spaceId);
|
||||
}
|
||||
|
||||
let labelId = dto.labelId;
|
||||
if (!labelId) {
|
||||
if (!dto.name) {
|
||||
throw new BadRequestException('labelId or name is required');
|
||||
}
|
||||
const label = await this.labelRepo.findByNameAndWorkspace(
|
||||
dto.name,
|
||||
workspace.id,
|
||||
LabelType.PAGE,
|
||||
);
|
||||
if (!label) {
|
||||
return emptyCursorPaginationResult(pagination.limit);
|
||||
}
|
||||
labelId = label.id;
|
||||
} else {
|
||||
const label = await this.labelRepo.findById(labelId);
|
||||
if (!label) {
|
||||
throw new NotFoundException('Label not found');
|
||||
}
|
||||
}
|
||||
|
||||
return this.labelService.findPagesByLabel(labelId, user.id, {
|
||||
spaceId: dto.spaceId,
|
||||
query: pagination.query,
|
||||
pagination,
|
||||
});
|
||||
}
|
||||
|
||||
// @HttpCode(HttpStatus.OK)
|
||||
// @Post('info')
|
||||
// async getLabelInfo(
|
||||
// @Body() dto: LabelInfoDto,
|
||||
// @AuthUser() user: User,
|
||||
// @AuthWorkspace() workspace: Workspace,
|
||||
// ) {
|
||||
// if (dto.spaceId) {
|
||||
// await this.assertCanReadSpace(user, dto.spaceId);
|
||||
// }
|
||||
//
|
||||
// return this.labelService.getLabelInfo(
|
||||
// dto.name,
|
||||
// dto.type,
|
||||
// workspace.id,
|
||||
// user.id,
|
||||
// dto.spaceId,
|
||||
// );
|
||||
// }
|
||||
|
||||
private async assertCanReadSpace(user: User, spaceId: string) {
|
||||
const ability = await this.spaceAbility.createForUser(user, spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { LabelController } from './label.controller';
|
||||
import { LabelService } from './label.service';
|
||||
|
||||
@Module({
|
||||
controllers: [LabelController],
|
||||
providers: [LabelService],
|
||||
exports: [LabelService],
|
||||
})
|
||||
export class LabelModule {}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Label } from '@docmost/db/types/entity.types';
|
||||
import { LabelRepo, LabelType } from '@docmost/db/repos/label/label.repo';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||
import { normalizeLabelName } from './utils';
|
||||
|
||||
@Injectable()
|
||||
export class LabelService {
|
||||
constructor(
|
||||
private readonly labelRepo: LabelRepo,
|
||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
) {}
|
||||
|
||||
async addLabelsToPage(
|
||||
pageId: string,
|
||||
names: string[],
|
||||
workspaceId: string,
|
||||
): Promise<Label[]> {
|
||||
const attached: Label[] = [];
|
||||
await executeTx(this.db, async (trx) => {
|
||||
for (const name of names) {
|
||||
const label = await this.labelRepo.findOrCreate(
|
||||
name.trim(),
|
||||
workspaceId,
|
||||
LabelType.PAGE,
|
||||
trx,
|
||||
);
|
||||
await this.labelRepo.addLabelToPage(pageId, label.id, trx);
|
||||
attached.push(label);
|
||||
}
|
||||
});
|
||||
return attached;
|
||||
}
|
||||
|
||||
async removeLabelFromPage(
|
||||
pageId: string,
|
||||
labelId: string,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
await executeTx(this.db, async (trx) => {
|
||||
const label = await this.labelRepo.findById(labelId, trx);
|
||||
if (!label || label.workspaceId !== workspaceId) {
|
||||
throw new NotFoundException('Label not found');
|
||||
}
|
||||
|
||||
await this.labelRepo.removeLabelFromPage(
|
||||
pageId,
|
||||
labelId,
|
||||
workspaceId,
|
||||
trx,
|
||||
);
|
||||
|
||||
const count = await this.labelRepo.getLabelPageCount(
|
||||
labelId,
|
||||
workspaceId,
|
||||
trx,
|
||||
);
|
||||
if (count === 0) {
|
||||
await this.labelRepo.deleteLabel(labelId, workspaceId, trx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getPageLabels(pageId: string, pagination: PaginationOptions) {
|
||||
return this.labelRepo.findLabelsByPageId(pageId, pagination);
|
||||
}
|
||||
|
||||
async getLabels(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
type: LabelType,
|
||||
pagination: PaginationOptions,
|
||||
) {
|
||||
return this.labelRepo.findLabels(
|
||||
workspaceId,
|
||||
userId,
|
||||
type,
|
||||
pagination,
|
||||
);
|
||||
}
|
||||
|
||||
async findPagesByLabel(
|
||||
labelId: string,
|
||||
userId: string,
|
||||
opts: {
|
||||
spaceId?: string;
|
||||
query?: string;
|
||||
pagination: PaginationOptions;
|
||||
},
|
||||
) {
|
||||
const result = await this.labelRepo.findPagesByLabelId(labelId, userId, opts);
|
||||
if (result.items.length === 0) return result;
|
||||
|
||||
const accessibleIds = await this.pagePermissionRepo.filterAccessiblePageIds({
|
||||
pageIds: result.items.map((p) => p.id),
|
||||
userId,
|
||||
spaceId: opts.spaceId,
|
||||
});
|
||||
const accessible = new Set(accessibleIds);
|
||||
return {
|
||||
items: result.items.filter((p) => accessible.has(p.id)),
|
||||
meta: result.meta,
|
||||
};
|
||||
}
|
||||
|
||||
async getLabelInfo(
|
||||
name: string,
|
||||
type: LabelType,
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
spaceId?: string,
|
||||
) {
|
||||
const normalized = normalizeLabelName(name);
|
||||
const label = await this.labelRepo.findByNameAndWorkspace(
|
||||
normalized,
|
||||
workspaceId,
|
||||
type,
|
||||
);
|
||||
|
||||
// Uniform response shape.
|
||||
// We don't want to expose whether the label row exists
|
||||
const usageCount = label
|
||||
? await this.labelRepo.getLabelPageCountForUser(
|
||||
label.id,
|
||||
userId,
|
||||
spaceId,
|
||||
)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
name: normalized,
|
||||
usageCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function normalizeLabelName(name: string): string {
|
||||
return name.trim().replace(/\s+/g, '-').toLowerCase();
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { IsIn, IsNotEmpty, IsString } from 'class-validator';
|
||||
import { PageIdDto } from './page.dto';
|
||||
|
||||
export type BacklinkDirection = 'incoming' | 'outgoing';
|
||||
|
||||
export class BacklinksListDto extends PageIdDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@IsIn(['incoming', 'outgoing'])
|
||||
direction: BacklinkDirection;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { PageService } from './services/page.service';
|
||||
import { BacklinkService } from './services/backlink.service';
|
||||
import { PageAccessService } from './page-access/page-access.service';
|
||||
import { CreatePageDto } from './dto/create-page.dto';
|
||||
import { UpdatePageDto } from './dto/update-page.dto';
|
||||
@@ -38,6 +39,9 @@ import { RecentPageDto } from './dto/recent-page.dto';
|
||||
import { CreatedByUserDto } from './dto/created-by-user.dto';
|
||||
import { DuplicatePageDto } from './dto/duplicate-page.dto';
|
||||
import { DeletedPageDto } from './dto/deleted-page.dto';
|
||||
import { BacklinksListDto } from './dto/backlink.dto';
|
||||
import { LabelService } from '../label/label.service';
|
||||
import { AddLabelsDto, RemoveLabelDto } from '../label/dto/label.dto';
|
||||
import {
|
||||
jsonToHtml,
|
||||
jsonToMarkdown,
|
||||
@@ -58,6 +62,8 @@ export class PageController {
|
||||
private readonly pageHistoryService: PageHistoryService,
|
||||
private readonly spaceAbility: SpaceAbilityFactory,
|
||||
private readonly pageAccessService: PageAccessService,
|
||||
private readonly backlinkService: BacklinkService,
|
||||
private readonly labelService: LabelService,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
|
||||
@@ -70,6 +76,7 @@ export class PageController {
|
||||
includeCreator: true,
|
||||
includeLastUpdatedBy: true,
|
||||
includeContributors: true,
|
||||
includeDeletedBy: true,
|
||||
});
|
||||
|
||||
if (!page) {
|
||||
@@ -96,6 +103,100 @@ export class PageController {
|
||||
return { ...page, permissions };
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('labels')
|
||||
async getPageLabels(
|
||||
@Body() dto: PageIdDto,
|
||||
@Body() pagination: PaginationOptions,
|
||||
@AuthUser() user: User,
|
||||
) {
|
||||
const page = await this.pageRepo.findById(dto.pageId);
|
||||
if (!page) {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
await this.pageAccessService.validateCanView(page, user);
|
||||
|
||||
return this.labelService.getPageLabels(page.id, pagination);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('labels/add')
|
||||
async addPageLabels(
|
||||
@Body() dto: AddLabelsDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const page = await this.pageRepo.findById(dto.pageId);
|
||||
if (!page || page.deletedAt) {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
await this.pageAccessService.validateCanEdit(page, user);
|
||||
|
||||
return this.labelService.addLabelsToPage(
|
||||
page.id,
|
||||
dto.names,
|
||||
workspace.id,
|
||||
);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('labels/remove')
|
||||
async removePageLabel(
|
||||
@Body() dto: RemoveLabelDto,
|
||||
@AuthUser() user: User,
|
||||
) {
|
||||
const page = await this.pageRepo.findById(dto.pageId);
|
||||
if (!page || page.deletedAt) {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
await this.pageAccessService.validateCanEdit(page, user);
|
||||
|
||||
await this.labelService.removeLabelFromPage(
|
||||
page.id,
|
||||
dto.labelId,
|
||||
page.workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('backlinks-count')
|
||||
async getBacklinksCount(
|
||||
@Body() dto: PageIdDto,
|
||||
@AuthUser() user: User,
|
||||
): Promise<{ incoming: number; outgoing: number }> {
|
||||
const page = await this.pageRepo.findById(dto.pageId);
|
||||
if (!page) {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
await this.pageAccessService.validateCanView(page, user);
|
||||
|
||||
return this.backlinkService.countByPageId(page.id, user.id);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('backlinks')
|
||||
async getBacklinks(
|
||||
@Body() dto: BacklinksListDto,
|
||||
@Body() pagination: PaginationOptions,
|
||||
@AuthUser() user: User,
|
||||
) {
|
||||
const page = await this.pageRepo.findById(dto.pageId);
|
||||
if (!page) {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
await this.pageAccessService.validateCanView(page, user);
|
||||
|
||||
return this.backlinkService.findByPageId(
|
||||
page.id,
|
||||
dto.direction,
|
||||
user.id,
|
||||
pagination,
|
||||
);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('create')
|
||||
async create(
|
||||
|
||||
@@ -3,14 +3,28 @@ import { PageService } from './services/page.service';
|
||||
import { PageController } from './page.controller';
|
||||
import { PageHistoryService } from './services/page-history.service';
|
||||
import { TrashCleanupService } from './services/trash-cleanup.service';
|
||||
import { BacklinkService } from './services/backlink.service';
|
||||
import { StorageModule } from '../../integrations/storage/storage.module';
|
||||
import { CollaborationModule } from '../../collaboration/collaboration.module';
|
||||
import { WatcherModule } from '../watcher/watcher.module';
|
||||
import { TransclusionModule } from './transclusion/transclusion.module';
|
||||
import { LabelModule } from '../label/label.module';
|
||||
|
||||
@Module({
|
||||
controllers: [PageController],
|
||||
providers: [PageService, PageHistoryService, TrashCleanupService],
|
||||
providers: [
|
||||
PageService,
|
||||
PageHistoryService,
|
||||
TrashCleanupService,
|
||||
BacklinkService,
|
||||
],
|
||||
exports: [PageService, PageHistoryService],
|
||||
imports: [StorageModule, CollaborationModule, WatcherModule],
|
||||
imports: [
|
||||
StorageModule,
|
||||
CollaborationModule,
|
||||
WatcherModule,
|
||||
TransclusionModule,
|
||||
LabelModule,
|
||||
],
|
||||
})
|
||||
export class PageModule {}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { BacklinkService } from './backlink.service';
|
||||
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
|
||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||
|
||||
describe('BacklinkService.countByPageId', () => {
|
||||
let service: BacklinkService;
|
||||
let backlinkRepo: jest.Mocked<BacklinkRepo>;
|
||||
let permissionRepo: jest.Mocked<PagePermissionRepo>;
|
||||
|
||||
const pageId = '00000000-0000-0000-0000-000000000001';
|
||||
const userId = '00000000-0000-0000-0000-000000000099';
|
||||
|
||||
beforeEach(async () => {
|
||||
const backlinkRepoMock: jest.Mocked<Partial<BacklinkRepo>> = {
|
||||
findRelatedPageIds: jest.fn(),
|
||||
findPagesByIdsPaginated: jest.fn(),
|
||||
};
|
||||
const permissionRepoMock: jest.Mocked<Partial<PagePermissionRepo>> = {
|
||||
filterAccessiblePageIds: jest.fn(),
|
||||
};
|
||||
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [
|
||||
BacklinkService,
|
||||
{ provide: BacklinkRepo, useValue: backlinkRepoMock },
|
||||
{ provide: PagePermissionRepo, useValue: permissionRepoMock },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get(BacklinkService);
|
||||
backlinkRepo = module.get(BacklinkRepo) as jest.Mocked<BacklinkRepo>;
|
||||
permissionRepo = module.get(
|
||||
PagePermissionRepo,
|
||||
) as jest.Mocked<PagePermissionRepo>;
|
||||
});
|
||||
|
||||
it('returns post-filter counts for both directions', async () => {
|
||||
backlinkRepo.findRelatedPageIds.mockImplementation(async (_id, dir) =>
|
||||
dir === 'incoming' ? ['a', 'b', 'c'] : ['x', 'y'],
|
||||
);
|
||||
permissionRepo.filterAccessiblePageIds.mockImplementation(
|
||||
async ({ pageIds }) =>
|
||||
pageIds.filter((id) => id !== 'b' && id !== 'y'),
|
||||
);
|
||||
|
||||
const result = await service.countByPageId(pageId, userId);
|
||||
|
||||
expect(result).toEqual({ incoming: 2, outgoing: 1 });
|
||||
expect(permissionRepo.filterAccessiblePageIds).toHaveBeenCalledWith({
|
||||
pageIds: ['a', 'b', 'c'],
|
||||
userId,
|
||||
});
|
||||
expect(permissionRepo.filterAccessiblePageIds).toHaveBeenCalledWith({
|
||||
pageIds: ['x', 'y'],
|
||||
userId,
|
||||
});
|
||||
});
|
||||
|
||||
it('skips the permission filter when there are no candidates', async () => {
|
||||
backlinkRepo.findRelatedPageIds.mockResolvedValue([]);
|
||||
permissionRepo.filterAccessiblePageIds.mockResolvedValue([]);
|
||||
|
||||
const result = await service.countByPageId(pageId, userId);
|
||||
|
||||
expect(result).toEqual({ incoming: 0, outgoing: 0 });
|
||||
expect(permissionRepo.filterAccessiblePageIds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes the userId to findRelatedPageIds so the repo can apply space membership filtering', async () => {
|
||||
backlinkRepo.findRelatedPageIds.mockResolvedValue([]);
|
||||
|
||||
await service.countByPageId(pageId, userId);
|
||||
|
||||
expect(backlinkRepo.findRelatedPageIds).toHaveBeenCalledWith(
|
||||
pageId,
|
||||
'incoming',
|
||||
userId,
|
||||
);
|
||||
expect(backlinkRepo.findRelatedPageIds).toHaveBeenCalledWith(
|
||||
pageId,
|
||||
'outgoing',
|
||||
userId,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BacklinkService.findByPageId', () => {
|
||||
let service: BacklinkService;
|
||||
let backlinkRepo: jest.Mocked<BacklinkRepo>;
|
||||
let permissionRepo: jest.Mocked<PagePermissionRepo>;
|
||||
|
||||
const pageId = '00000000-0000-0000-0000-000000000001';
|
||||
const userId = '00000000-0000-0000-0000-000000000099';
|
||||
|
||||
beforeEach(async () => {
|
||||
const backlinkRepoMock: jest.Mocked<Partial<BacklinkRepo>> = {
|
||||
findRelatedPageIds: jest.fn(),
|
||||
findPagesByIdsPaginated: jest.fn(),
|
||||
};
|
||||
const permissionRepoMock: jest.Mocked<Partial<PagePermissionRepo>> = {
|
||||
filterAccessiblePageIds: jest.fn(),
|
||||
};
|
||||
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [
|
||||
BacklinkService,
|
||||
{ provide: BacklinkRepo, useValue: backlinkRepoMock },
|
||||
{ provide: PagePermissionRepo, useValue: permissionRepoMock },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get(BacklinkService);
|
||||
backlinkRepo = module.get(BacklinkRepo) as jest.Mocked<BacklinkRepo>;
|
||||
permissionRepo = module.get(
|
||||
PagePermissionRepo,
|
||||
) as jest.Mocked<PagePermissionRepo>;
|
||||
});
|
||||
|
||||
it('passes filtered ids through to the paginated repo call', async () => {
|
||||
backlinkRepo.findRelatedPageIds.mockResolvedValue(['a', 'b']);
|
||||
permissionRepo.filterAccessiblePageIds.mockResolvedValue(['a']);
|
||||
backlinkRepo.findPagesByIdsPaginated.mockResolvedValue({
|
||||
items: [],
|
||||
meta: {
|
||||
limit: 20,
|
||||
hasNextPage: false,
|
||||
hasPrevPage: false,
|
||||
nextCursor: null,
|
||||
prevCursor: null,
|
||||
},
|
||||
} as any);
|
||||
|
||||
await service.findByPageId(pageId, 'incoming', userId, { limit: 20 } as any);
|
||||
|
||||
expect(backlinkRepo.findPagesByIdsPaginated).toHaveBeenCalledWith(
|
||||
['a'],
|
||||
expect.objectContaining({ limit: 20 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('hands an empty list to the repo when there are no accessible ids', async () => {
|
||||
backlinkRepo.findRelatedPageIds.mockResolvedValue([]);
|
||||
backlinkRepo.findPagesByIdsPaginated.mockResolvedValue({
|
||||
items: [],
|
||||
meta: {
|
||||
limit: 20,
|
||||
hasNextPage: false,
|
||||
hasPrevPage: false,
|
||||
nextCursor: null,
|
||||
prevCursor: null,
|
||||
},
|
||||
} as any);
|
||||
|
||||
await service.findByPageId(pageId, 'incoming', userId, { limit: 20 } as any);
|
||||
|
||||
expect(backlinkRepo.findPagesByIdsPaginated).toHaveBeenCalledWith(
|
||||
[],
|
||||
expect.objectContaining({ limit: 20 }),
|
||||
);
|
||||
expect(permissionRepo.filterAccessiblePageIds).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
|
||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
|
||||
export type BacklinkDirection = 'incoming' | 'outgoing';
|
||||
|
||||
@Injectable()
|
||||
export class BacklinkService {
|
||||
constructor(
|
||||
private readonly backlinkRepo: BacklinkRepo,
|
||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||
) {}
|
||||
|
||||
async countByPageId(
|
||||
pageId: string,
|
||||
userId: string,
|
||||
): Promise<{ incoming: number; outgoing: number }> {
|
||||
const [incomingIds, outgoingIds] = await Promise.all([
|
||||
this.accessibleRelatedIds(pageId, 'incoming', userId),
|
||||
this.accessibleRelatedIds(pageId, 'outgoing', userId),
|
||||
]);
|
||||
return { incoming: incomingIds.length, outgoing: outgoingIds.length };
|
||||
}
|
||||
|
||||
async findByPageId(
|
||||
pageId: string,
|
||||
direction: BacklinkDirection,
|
||||
userId: string,
|
||||
pagination: PaginationOptions,
|
||||
) {
|
||||
const accessibleIds = await this.accessibleRelatedIds(
|
||||
pageId,
|
||||
direction,
|
||||
userId,
|
||||
);
|
||||
return this.backlinkRepo.findPagesByIdsPaginated(accessibleIds, pagination);
|
||||
}
|
||||
|
||||
private async accessibleRelatedIds(
|
||||
pageId: string,
|
||||
direction: BacklinkDirection,
|
||||
userId: string,
|
||||
): Promise<string[]> {
|
||||
const candidateIds = await this.backlinkRepo.findRelatedPageIds(
|
||||
pageId,
|
||||
direction,
|
||||
userId,
|
||||
);
|
||||
if (candidateIds.length === 0) return [];
|
||||
return this.pagePermissionRepo.filterAccessiblePageIds({
|
||||
pageIds: candidateIds,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
import { markdownToHtml } from '@docmost/editor-ext';
|
||||
import { WatcherService } from '../../watcher/watcher.service';
|
||||
import { sql } from 'kysely';
|
||||
import { TransclusionService } from '../transclusion/transclusion.service';
|
||||
|
||||
@Injectable()
|
||||
export class PageService {
|
||||
@@ -71,6 +72,7 @@ export class PageService {
|
||||
private eventEmitter: EventEmitter2,
|
||||
private collaborationGateway: CollaborationGateway,
|
||||
private readonly watcherService: WatcherService,
|
||||
private readonly transclusionService: TransclusionService,
|
||||
) {}
|
||||
|
||||
async findById(
|
||||
@@ -423,11 +425,7 @@ export class PageService {
|
||||
|
||||
if (pageIdsToMove.length > 1) {
|
||||
// Update sub pages (all accessible pages except root)
|
||||
await this.pageRepo.updatePages(
|
||||
{ spaceId },
|
||||
childPageIds,
|
||||
trx,
|
||||
);
|
||||
await this.pageRepo.updatePages({ spaceId }, childPageIds, trx);
|
||||
}
|
||||
|
||||
if (pageIdsToMove.length > 0) {
|
||||
@@ -474,9 +472,13 @@ export class PageService {
|
||||
);
|
||||
|
||||
// Update watchers and remove those without access to new space
|
||||
await this.watcherService.movePageWatchersToSpace(pageIdsToMove, spaceId, {
|
||||
trx,
|
||||
});
|
||||
await this.watcherService.movePageWatchersToSpace(
|
||||
pageIdsToMove,
|
||||
spaceId,
|
||||
{
|
||||
trx,
|
||||
},
|
||||
);
|
||||
|
||||
await this.aiQueue.add(QueueJob.PAGE_MOVED_TO_SPACE, {
|
||||
pageId: pageIdsToMove,
|
||||
@@ -600,6 +602,17 @@ export class PageService {
|
||||
}
|
||||
}
|
||||
|
||||
// Remap transclusion-reference source pages to their copies when
|
||||
// the source page is also being duplicated in the same operation.
|
||||
if (node.type.name === 'transclusionReference') {
|
||||
const sourcePageId = node.attrs.sourcePageId;
|
||||
if (sourcePageId && pageMap.has(sourcePageId)) {
|
||||
const mappedPage = pageMap.get(sourcePageId);
|
||||
//@ts-ignore
|
||||
node.attrs.sourcePageId = mappedPage.newPageId;
|
||||
}
|
||||
}
|
||||
|
||||
// Update internal page links in link marks
|
||||
for (const mark of node.marks) {
|
||||
if (
|
||||
@@ -659,6 +672,39 @@ export class PageService {
|
||||
|
||||
await this.db.insertInto('pages').values(insertablePages).execute();
|
||||
|
||||
// Extract transclusions from every duplicated page and persist them in
|
||||
// one statement. Duplication bypasses Yjs onStoreDocument; brand-new
|
||||
// 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,
|
||||
workspaceId: p.workspaceId,
|
||||
content: p.content,
|
||||
})),
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
'Failed to insert transclusions for duplicated pages',
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.transclusionService.insertReferencesForPages(
|
||||
insertablePages.map((p) => ({
|
||||
id: p.id,
|
||||
workspaceId: p.workspaceId,
|
||||
content: p.content,
|
||||
})),
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
'Failed to insert transclusion references for duplicated pages',
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
||||
const insertedPageIds = insertablePages.map((page) => page.id);
|
||||
this.eventEmitter.emit(EventName.PAGE_CREATED, {
|
||||
pageIds: insertedPageIds,
|
||||
@@ -812,13 +858,15 @@ export class PageService {
|
||||
.selectFrom('page_ancestors')
|
||||
.selectAll('page_ancestors')
|
||||
.select((eb) =>
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('pages as child')
|
||||
.select(sql`1`.as('one'))
|
||||
.whereRef('child.parentPageId', '=', 'page_ancestors.id')
|
||||
.where('child.deletedAt', 'is', null),
|
||||
).as('hasChildren'),
|
||||
eb
|
||||
.exists(
|
||||
eb
|
||||
.selectFrom('pages as child')
|
||||
.select(sql`1`.as('one'))
|
||||
.whereRef('child.parentPageId', '=', 'page_ancestors.id')
|
||||
.where('child.deletedAt', 'is', null),
|
||||
)
|
||||
.as('hasChildren'),
|
||||
)
|
||||
.execute();
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
export class LookupReferenceDto {
|
||||
@IsUUID()
|
||||
sourcePageId!: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(36)
|
||||
transclusionId!: string;
|
||||
}
|
||||
|
||||
export class LookupDto {
|
||||
@IsArray()
|
||||
@ArrayMaxSize(50)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => LookupReferenceDto)
|
||||
references!: LookupReferenceDto[];
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { IsString, IsUUID } from 'class-validator';
|
||||
|
||||
export class ReferencesDto {
|
||||
@IsUUID()
|
||||
sourcePageId!: string;
|
||||
|
||||
@IsString()
|
||||
transclusionId!: string;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { IsString, IsUUID } from 'class-validator';
|
||||
|
||||
export class UnsyncReferenceDto {
|
||||
@IsUUID()
|
||||
referencePageId!: string;
|
||||
|
||||
@IsUUID()
|
||||
sourcePageId!: string;
|
||||
|
||||
@IsString()
|
||||
transclusionId!: string;
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import {
|
||||
collectReferencesFromPmJson,
|
||||
collectTransclusionsFromPmJson,
|
||||
} from '../utils/transclusion-prosemirror.util';
|
||||
|
||||
describe('collectTransclusionsFromPmJson', () => {
|
||||
it('returns [] for null/undefined doc', () => {
|
||||
expect(collectTransclusionsFromPmJson(null)).toEqual([]);
|
||||
expect(collectTransclusionsFromPmJson(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns [] for a doc with no transclusion nodes', () => {
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hi' }] }],
|
||||
};
|
||||
expect(collectTransclusionsFromPmJson(doc)).toEqual([]);
|
||||
});
|
||||
|
||||
it('extracts a top-level transclusion with id and content', () => {
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'transclusionSource',
|
||||
attrs: { id: 'abc123' },
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Body' }] }],
|
||||
},
|
||||
],
|
||||
};
|
||||
const got = collectTransclusionsFromPmJson(doc);
|
||||
expect(got).toHaveLength(1);
|
||||
expect(got[0].transclusionId).toBe('abc123');
|
||||
expect(got[0].content).toEqual({
|
||||
type: 'doc',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Body' }] }],
|
||||
});
|
||||
});
|
||||
|
||||
it('skips transclusion nodes with no id (transient before UniqueID assigns one)', () => {
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'transclusionSource', attrs: {}, content: [{ type: 'paragraph' }] },
|
||||
],
|
||||
};
|
||||
expect(collectTransclusionsFromPmJson(doc)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns multiple top-level transclusions', () => {
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'transclusionSource', attrs: { id: 'a' }, content: [{ type: 'paragraph' }] },
|
||||
{ type: 'transclusionSource', attrs: { id: 'b' }, content: [{ type: 'paragraph' }] },
|
||||
],
|
||||
};
|
||||
const got = collectTransclusionsFromPmJson(doc);
|
||||
expect(got.map((e) => e.transclusionId)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('does not recurse into a nested transclusion (transclusion cannot contain transclusion per schema, but be defensive)', () => {
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'transclusionSource',
|
||||
attrs: { id: 'outer' },
|
||||
content: [
|
||||
{
|
||||
type: 'transclusionSource',
|
||||
attrs: { id: 'inner' },
|
||||
content: [{ type: 'paragraph' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const got = collectTransclusionsFromPmJson(doc);
|
||||
expect(got.map((e) => e.transclusionId)).toEqual(['outer']);
|
||||
});
|
||||
|
||||
it('finds transclusions nested inside other block containers (e.g. column)', () => {
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'column',
|
||||
content: [
|
||||
{ type: 'transclusionSource', attrs: { id: 'inCol' }, content: [{ type: 'paragraph' }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(collectTransclusionsFromPmJson(doc).map((e) => e.transclusionId)).toEqual([
|
||||
'inCol',
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the last id when duplicate ids appear (later wins, deterministic)', () => {
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'transclusionSource',
|
||||
attrs: { id: 'dup' },
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'first' }] }],
|
||||
},
|
||||
{
|
||||
type: 'transclusionSource',
|
||||
attrs: { id: 'dup' },
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'second' }] }],
|
||||
},
|
||||
],
|
||||
};
|
||||
const got = collectTransclusionsFromPmJson(doc);
|
||||
expect(got).toHaveLength(1);
|
||||
expect(got[0].content).toEqual({
|
||||
type: 'doc',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'second' }] }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectReferencesFromPmJson', () => {
|
||||
it('returns [] for null/undefined doc', () => {
|
||||
expect(collectReferencesFromPmJson(null)).toEqual([]);
|
||||
expect(collectReferencesFromPmJson(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns [] for a doc with no transclusionReference nodes', () => {
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'hi' }] },
|
||||
],
|
||||
};
|
||||
expect(collectReferencesFromPmJson(doc)).toEqual([]);
|
||||
});
|
||||
|
||||
it('extracts a top-level reference', () => {
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'transclusionReference',
|
||||
attrs: { sourcePageId: 'p1', transclusionId: 'e1' },
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(collectReferencesFromPmJson(doc)).toEqual([
|
||||
{ sourcePageId: 'p1', transclusionId: 'e1' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips references missing sourcePageId or transclusionId', () => {
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'transclusionReference', attrs: { transclusionId: 'e1' } },
|
||||
{ type: 'transclusionReference', attrs: { sourcePageId: 'p1' } },
|
||||
{ type: 'transclusionReference', attrs: {} },
|
||||
],
|
||||
};
|
||||
expect(collectReferencesFromPmJson(doc)).toEqual([]);
|
||||
});
|
||||
|
||||
it('finds references nested in other block containers (column, callout, etc.)', () => {
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'column',
|
||||
content: [
|
||||
{
|
||||
type: 'transclusionReference',
|
||||
attrs: { sourcePageId: 'p1', transclusionId: 'e1' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'callout',
|
||||
content: [
|
||||
{
|
||||
type: 'transclusionReference',
|
||||
attrs: { sourcePageId: 'p2', transclusionId: 'e2' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(collectReferencesFromPmJson(doc)).toEqual([
|
||||
{ sourcePageId: 'p1', transclusionId: 'e1' },
|
||||
{ sourcePageId: 'p2', transclusionId: 'e2' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not recurse into a transclusion source (schema forbids references inside)', () => {
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'transclusionSource',
|
||||
attrs: { id: 'src1' },
|
||||
content: [
|
||||
{
|
||||
type: 'transclusionReference',
|
||||
attrs: { sourcePageId: 'p1', transclusionId: 'e1' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(collectReferencesFromPmJson(doc)).toEqual([]);
|
||||
});
|
||||
|
||||
it('dedupes identical (sourcePageId, transclusionId) pairs', () => {
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'transclusionReference',
|
||||
attrs: { sourcePageId: 'p1', transclusionId: 'e1' },
|
||||
},
|
||||
{
|
||||
type: 'transclusionReference',
|
||||
attrs: { sourcePageId: 'p1', transclusionId: 'e1' },
|
||||
},
|
||||
{
|
||||
type: 'transclusionReference',
|
||||
attrs: { sourcePageId: 'p2', transclusionId: 'e2' },
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(collectReferencesFromPmJson(doc)).toEqual([
|
||||
{ sourcePageId: 'p1', transclusionId: 'e1' },
|
||||
{ sourcePageId: 'p2', transclusionId: 'e2' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import {
|
||||
rewriteAttachmentsForUnsync,
|
||||
type AttachmentRewritePlan,
|
||||
} from '../utils/transclusion-unsync.util';
|
||||
|
||||
describe('rewriteAttachmentsForUnsync', () => {
|
||||
const fixedIds = (() => {
|
||||
let i = 0;
|
||||
return () => `new-${++i}`;
|
||||
});
|
||||
|
||||
it('returns content unchanged when no attachment nodes are present', () => {
|
||||
const content = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'hello' }] },
|
||||
],
|
||||
};
|
||||
const r = rewriteAttachmentsForUnsync(content, fixedIds());
|
||||
expect(r.content).toEqual(content);
|
||||
expect(r.copies).toEqual([]);
|
||||
});
|
||||
|
||||
it('rewrites attachmentId and src on a single image node', () => {
|
||||
const oldId = '11111111-1111-1111-1111-111111111111';
|
||||
const content = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'image',
|
||||
attrs: {
|
||||
attachmentId: oldId,
|
||||
src: `/api/files/${oldId}/cat.png`,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const gen = fixedIds();
|
||||
const r = rewriteAttachmentsForUnsync(content, gen);
|
||||
|
||||
expect(r.copies).toHaveLength(1);
|
||||
const plan: AttachmentRewritePlan = r.copies[0];
|
||||
expect(plan.oldAttachmentId).toBe(oldId);
|
||||
expect(plan.newAttachmentId).toBe('new-1');
|
||||
|
||||
const img = (r.content as any).content[0];
|
||||
expect(img.attrs.attachmentId).toBe('new-1');
|
||||
expect(img.attrs.src).toBe('/api/files/new-1/cat.png');
|
||||
});
|
||||
|
||||
it('rewrites every attachment node type (image, video, audio, attachment, drawio, excalidraw, pdf)', () => {
|
||||
const types = [
|
||||
'image',
|
||||
'video',
|
||||
'audio',
|
||||
'attachment',
|
||||
'drawio',
|
||||
'excalidraw',
|
||||
'pdf',
|
||||
] as const;
|
||||
const content = {
|
||||
type: 'doc',
|
||||
content: types.map((t, i) => ({
|
||||
type: t,
|
||||
attrs: {
|
||||
attachmentId: `old-${i}`,
|
||||
src: `/api/files/old-${i}/file`,
|
||||
},
|
||||
})),
|
||||
};
|
||||
const r = rewriteAttachmentsForUnsync(content, fixedIds());
|
||||
expect(r.copies).toHaveLength(types.length);
|
||||
expect((r.content as any).content.map((n: any) => n.attrs.attachmentId)).toEqual(
|
||||
Array.from({ length: types.length }, (_, i) => `new-${i + 1}`),
|
||||
);
|
||||
});
|
||||
|
||||
it('reuses one new id per old attachmentId across nodes (dedupe)', () => {
|
||||
const shared = 'shared-old';
|
||||
const content = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'image',
|
||||
attrs: {
|
||||
attachmentId: shared,
|
||||
src: `/api/files/${shared}/a.png`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
attrs: {
|
||||
attachmentId: shared,
|
||||
src: `/api/files/${shared}/a.png`,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const r = rewriteAttachmentsForUnsync(content, fixedIds());
|
||||
expect(r.copies).toHaveLength(1);
|
||||
expect(r.copies[0].oldAttachmentId).toBe(shared);
|
||||
const newId = r.copies[0].newAttachmentId;
|
||||
expect((r.content as any).content[0].attrs.attachmentId).toBe(newId);
|
||||
expect((r.content as any).content[1].attrs.attachmentId).toBe(newId);
|
||||
});
|
||||
|
||||
it('does not mutate the input content object', () => {
|
||||
const content = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'image',
|
||||
attrs: { attachmentId: 'old-x', src: '/api/files/old-x/x.png' },
|
||||
},
|
||||
],
|
||||
};
|
||||
const snapshot = JSON.parse(JSON.stringify(content));
|
||||
rewriteAttachmentsForUnsync(content, fixedIds());
|
||||
expect(content).toEqual(snapshot);
|
||||
});
|
||||
|
||||
it('skips nodes whose attachmentId is missing or not a uuid-shaped string', () => {
|
||||
const content = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'image', attrs: {} },
|
||||
{ type: 'image', attrs: { attachmentId: '' } },
|
||||
],
|
||||
};
|
||||
const r = rewriteAttachmentsForUnsync(content, fixedIds());
|
||||
expect(r.copies).toEqual([]);
|
||||
expect(r.content).toEqual(content);
|
||||
});
|
||||
|
||||
it('recurses into nested containers (column, callout)', () => {
|
||||
const oldId = 'old-nested';
|
||||
const content = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'callout',
|
||||
content: [
|
||||
{
|
||||
type: 'image',
|
||||
attrs: {
|
||||
attachmentId: oldId,
|
||||
src: `/api/files/${oldId}/x.png`,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const r = rewriteAttachmentsForUnsync(content, fixedIds());
|
||||
expect(r.copies).toHaveLength(1);
|
||||
const newId = r.copies[0].newAttachmentId;
|
||||
const inner = (r.content as any).content[0].content[0];
|
||||
expect(inner.attrs.attachmentId).toBe(newId);
|
||||
expect(inner.attrs.src).toBe(`/api/files/${newId}/x.png`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { TransclusionController } from '../transclusion.controller';
|
||||
import { TransclusionService } from '../transclusion.service';
|
||||
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
||||
|
||||
describe('TransclusionController.lookup', () => {
|
||||
let controller: TransclusionController;
|
||||
let service: jest.Mocked<TransclusionService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
service = {
|
||||
lookup: jest.fn(),
|
||||
listReferences: jest.fn(),
|
||||
unsyncReference: jest.fn(),
|
||||
} as any;
|
||||
|
||||
const module = await Test.createTestingModule({
|
||||
controllers: [TransclusionController],
|
||||
providers: [{ provide: TransclusionService, useValue: service }],
|
||||
})
|
||||
.overrideGuard(JwtAuthGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.compile();
|
||||
|
||||
controller = module.get(TransclusionController);
|
||||
});
|
||||
|
||||
const user = { id: 'u1', workspaceId: 'w1' } as any;
|
||||
const ref = { sourcePageId: 'p1', transclusionId: 'e1' };
|
||||
|
||||
it('passes the references, viewer id and workspace id through to the service and returns its result', async () => {
|
||||
service.lookup.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
sourcePageId: 'p1',
|
||||
transclusionId: 'e1',
|
||||
content: { type: 'doc' },
|
||||
sourceUpdatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
} as any);
|
||||
|
||||
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', 'w1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,320 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { TransclusionService } from '../transclusion.service';
|
||||
import { PageTransclusionsRepo } from '@docmost/db/repos/page-transclusions/page-transclusions.repo';
|
||||
import { PageTransclusionReferencesRepo } from '@docmost/db/repos/page-transclusions/page-transclusion-references.repo';
|
||||
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;
|
||||
let repo: jest.Mocked<PageTransclusionsRepo>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockRepo: jest.Mocked<Partial<PageTransclusionsRepo>> = {
|
||||
findByPageId: jest.fn(),
|
||||
insert: jest.fn(),
|
||||
update: jest.fn(),
|
||||
deleteByPageAndTransclusionIds: jest.fn(),
|
||||
};
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [
|
||||
TransclusionService,
|
||||
{ provide: PageTransclusionsRepo, useValue: mockRepo },
|
||||
{ provide: PageTransclusionReferencesRepo, useValue: {} },
|
||||
{ provide: PageRepo, useValue: {} },
|
||||
{ provide: PagePermissionRepo, useValue: {} },
|
||||
{ provide: AttachmentRepo, useValue: {} },
|
||||
{ provide: StorageService, useValue: {} },
|
||||
{ provide: PageAccessService, useValue: {} },
|
||||
],
|
||||
}).compile();
|
||||
service = module.get(TransclusionService);
|
||||
repo = module.get(PageTransclusionsRepo);
|
||||
});
|
||||
|
||||
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([]);
|
||||
const pm = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'transclusionSource',
|
||||
attrs: { id: 'a' },
|
||||
content: [{ type: 'paragraph' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = await service.syncPageTransclusions(pageId, workspaceId, pm);
|
||||
|
||||
expect(result).toEqual({ inserted: 1, updated: 0, deleted: 0 });
|
||||
expect(repo.insert).toHaveBeenCalledTimes(1);
|
||||
expect(repo.insert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
pageId,
|
||||
transclusionId: 'a',
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
expect(repo.update).not.toHaveBeenCalled();
|
||||
expect(repo.deleteByPageAndTransclusionIds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates transclusions whose content changed', async () => {
|
||||
repo.findByPageId.mockResolvedValue([
|
||||
{
|
||||
id: 'row1',
|
||||
pageId,
|
||||
transclusionId: 'a',
|
||||
content: { type: 'doc', content: [{ type: 'paragraph' }] },
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as any,
|
||||
]);
|
||||
const newContent = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'X' }] },
|
||||
],
|
||||
};
|
||||
const pm = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'transclusionSource',
|
||||
attrs: { id: 'a' },
|
||||
content: newContent.content,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = await service.syncPageTransclusions(pageId, workspaceId, pm);
|
||||
|
||||
expect(result).toEqual({ inserted: 0, updated: 1, deleted: 0 });
|
||||
expect(repo.update).toHaveBeenCalledWith(
|
||||
pageId,
|
||||
'a',
|
||||
expect.objectContaining({ content: newContent }),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('skips update when content is unchanged', async () => {
|
||||
const sameContent = {
|
||||
type: 'doc',
|
||||
content: [{ type: 'paragraph' }],
|
||||
};
|
||||
repo.findByPageId.mockResolvedValue([
|
||||
{
|
||||
id: 'row1',
|
||||
pageId,
|
||||
transclusionId: 'a',
|
||||
content: sameContent,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as any,
|
||||
]);
|
||||
const pm = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'transclusionSource',
|
||||
attrs: { id: 'a' },
|
||||
content: sameContent.content,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = await service.syncPageTransclusions(pageId, workspaceId, pm);
|
||||
|
||||
expect(result).toEqual({ inserted: 0, updated: 0, deleted: 0 });
|
||||
expect(repo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes transclusions that no longer appear in the doc', async () => {
|
||||
repo.findByPageId.mockResolvedValue([
|
||||
{
|
||||
id: 'r',
|
||||
pageId,
|
||||
transclusionId: 'gone',
|
||||
content: { type: 'doc', content: [] },
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as any,
|
||||
]);
|
||||
const pm = { type: 'doc', content: [{ type: 'paragraph' }] };
|
||||
|
||||
const result = await service.syncPageTransclusions(pageId, workspaceId, pm);
|
||||
|
||||
expect(result).toEqual({ inserted: 0, updated: 0, deleted: 1 });
|
||||
expect(repo.deleteByPageAndTransclusionIds).toHaveBeenCalledWith(
|
||||
pageId,
|
||||
['gone'],
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('handles empty doc → noop', async () => {
|
||||
repo.findByPageId.mockResolvedValue([]);
|
||||
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();
|
||||
expect(repo.deleteByPageAndTransclusionIds).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TransclusionService.syncPageReferences', () => {
|
||||
let service: TransclusionService;
|
||||
let refRepo: jest.Mocked<PageTransclusionReferencesRepo>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockTransclusionsRepo: Partial<PageTransclusionsRepo> = {};
|
||||
const mockRefRepo: jest.Mocked<Partial<PageTransclusionReferencesRepo>> = {
|
||||
findByReferencePageId: jest.fn(),
|
||||
insertMany: jest.fn(),
|
||||
deleteByReferenceAndKeys: jest.fn(),
|
||||
};
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [
|
||||
TransclusionService,
|
||||
{ provide: PageTransclusionsRepo, useValue: mockTransclusionsRepo },
|
||||
{ provide: PageTransclusionReferencesRepo, useValue: mockRefRepo },
|
||||
{ provide: PageRepo, useValue: {} },
|
||||
{ provide: PagePermissionRepo, useValue: {} },
|
||||
{ provide: AttachmentRepo, useValue: {} },
|
||||
{ provide: StorageService, useValue: {} },
|
||||
{ provide: PageAccessService, useValue: {} },
|
||||
],
|
||||
}).compile();
|
||||
service = module.get(TransclusionService);
|
||||
refRepo = module.get(PageTransclusionReferencesRepo);
|
||||
});
|
||||
|
||||
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([]);
|
||||
const pm = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'transclusionReference',
|
||||
attrs: { sourcePageId: 'p1', transclusionId: 'e1' },
|
||||
},
|
||||
{
|
||||
type: 'transclusionReference',
|
||||
attrs: { sourcePageId: 'p2', transclusionId: 'e2' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
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',
|
||||
},
|
||||
],
|
||||
undefined,
|
||||
);
|
||||
expect(refRepo.deleteByReferenceAndKeys).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores references nested inside a source (schema-forbidden)', async () => {
|
||||
refRepo.findByReferencePageId.mockResolvedValue([]);
|
||||
const pm = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'transclusionSource',
|
||||
attrs: { id: 's1' },
|
||||
content: [
|
||||
{
|
||||
type: 'transclusionReference',
|
||||
attrs: { sourcePageId: 'p2', transclusionId: 'e2' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = await service.syncPageReferences(referencePageId, workspaceId, pm);
|
||||
|
||||
expect(result).toEqual({ inserted: 0, deleted: 0 });
|
||||
expect(refRepo.insertMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes references that no longer appear', async () => {
|
||||
refRepo.findByReferencePageId.mockResolvedValue([
|
||||
{
|
||||
id: 'r1',
|
||||
referencePageId,
|
||||
sourcePageId: 'p1',
|
||||
transclusionId: 'e1',
|
||||
createdAt: new Date(),
|
||||
} as any,
|
||||
]);
|
||||
const pm = { type: 'doc', content: [{ type: 'paragraph' }] };
|
||||
|
||||
const result = await service.syncPageReferences(referencePageId, workspaceId, pm);
|
||||
|
||||
expect(result).toEqual({ inserted: 0, deleted: 1 });
|
||||
expect(refRepo.deleteByReferenceAndKeys).toHaveBeenCalledWith(
|
||||
referencePageId,
|
||||
[
|
||||
{
|
||||
sourcePageId: 'p1',
|
||||
transclusionId: 'e1',
|
||||
},
|
||||
],
|
||||
undefined,
|
||||
);
|
||||
expect(refRepo.insertMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is a no-op when desired matches existing exactly', async () => {
|
||||
refRepo.findByReferencePageId.mockResolvedValue([
|
||||
{
|
||||
id: 'r',
|
||||
referencePageId,
|
||||
sourcePageId: 'p1',
|
||||
transclusionId: 'e1',
|
||||
createdAt: new Date(),
|
||||
} as any,
|
||||
]);
|
||||
const pm = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'transclusionReference',
|
||||
attrs: { sourcePageId: 'p1', transclusionId: 'e1' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = await service.syncPageReferences(referencePageId, workspaceId, pm);
|
||||
|
||||
expect(result).toEqual({ inserted: 0, deleted: 0 });
|
||||
expect(refRepo.insertMany).not.toHaveBeenCalled();
|
||||
expect(refRepo.deleteByReferenceAndKeys).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
|
||||
import { AuthUser } from '../../../common/decorators/auth-user.decorator';
|
||||
import { User } from '@docmost/db/types/entity.types';
|
||||
import { TransclusionService } from './transclusion.service';
|
||||
import { LookupDto } from './dto/lookup.dto';
|
||||
import { ReferencesDto } from './dto/references.dto';
|
||||
import { UnsyncReferenceDto } from './dto/unsync-reference.dto';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('pages/transclusion')
|
||||
export class TransclusionController {
|
||||
constructor(private readonly transclusionService: TransclusionService) {}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('lookup')
|
||||
async lookup(@Body() dto: LookupDto, @AuthUser() user: User) {
|
||||
return this.transclusionService.lookup(
|
||||
dto.references,
|
||||
user.id,
|
||||
user.workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('references')
|
||||
async references(
|
||||
@Body() dto: ReferencesDto,
|
||||
@AuthUser() user: User,
|
||||
) {
|
||||
return this.transclusionService.listReferences({
|
||||
sourcePageId: dto.sourcePageId,
|
||||
transclusionId: dto.transclusionId,
|
||||
viewerUserId: user.id,
|
||||
workspaceId: user.workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('unsync-reference')
|
||||
async unsyncReference(
|
||||
@Body() dto: UnsyncReferenceDto,
|
||||
@AuthUser() user: User,
|
||||
) {
|
||||
return this.transclusionService.unsyncReference(
|
||||
dto.referencePageId,
|
||||
dto.sourcePageId,
|
||||
dto.transclusionId,
|
||||
user,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TransclusionController } from './transclusion.controller';
|
||||
import { TransclusionService } from './transclusion.service';
|
||||
|
||||
@Module({
|
||||
controllers: [TransclusionController],
|
||||
providers: [TransclusionService],
|
||||
exports: [TransclusionService],
|
||||
})
|
||||
export class TransclusionModule {}
|
||||
@@ -0,0 +1,478 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
import { v7 as uuid7 } from 'uuid';
|
||||
import { KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||
import { PageTransclusionsRepo } from '@docmost/db/repos/page-transclusions/page-transclusions.repo';
|
||||
import { PageTransclusionReferencesRepo } from '@docmost/db/repos/page-transclusions/page-transclusion-references.repo';
|
||||
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 {
|
||||
collectReferencesFromPmJson,
|
||||
collectTransclusionsFromPmJson,
|
||||
} from './utils/transclusion-prosemirror.util';
|
||||
import { rewriteAttachmentsForUnsync } from './utils/transclusion-unsync.util';
|
||||
import { TransclusionLookup } from './transclusion.types';
|
||||
import { Page, User } from '@docmost/db/types/entity.types';
|
||||
import { PageAccessService } from '../page-access/page-access.service';
|
||||
|
||||
type ReferencingPageInfo = {
|
||||
id: string;
|
||||
slugId: string;
|
||||
title: string | null;
|
||||
icon: string | null;
|
||||
spaceId: string;
|
||||
spaceSlug: string | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class TransclusionService {
|
||||
private readonly logger = new Logger(TransclusionService.name);
|
||||
|
||||
constructor(
|
||||
private readonly pageTransclusionsRepo: PageTransclusionsRepo,
|
||||
private readonly pageTransclusionReferencesRepo: PageTransclusionReferencesRepo,
|
||||
private readonly pageRepo: PageRepo,
|
||||
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 }> {
|
||||
const desired = collectTransclusionsFromPmJson(pmJson);
|
||||
const desiredById = new Map(desired.map((d) => [d.transclusionId, d]));
|
||||
|
||||
const existing = await this.pageTransclusionsRepo.findByPageId(pageId, trx);
|
||||
const existingById = new Map(existing.map((e) => [e.transclusionId, e]));
|
||||
|
||||
let inserted = 0;
|
||||
let updated = 0;
|
||||
let deleted = 0;
|
||||
|
||||
for (const d of desired) {
|
||||
const prev = existingById.get(d.transclusionId);
|
||||
if (!prev) {
|
||||
await this.pageTransclusionsRepo.insert(
|
||||
{
|
||||
workspaceId,
|
||||
pageId,
|
||||
transclusionId: d.transclusionId,
|
||||
content: d.content as any,
|
||||
},
|
||||
trx,
|
||||
);
|
||||
inserted += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const contentChanged = !isDeepStrictEqual(prev.content, d.content);
|
||||
if (contentChanged) {
|
||||
await this.pageTransclusionsRepo.update(
|
||||
pageId,
|
||||
d.transclusionId,
|
||||
{ content: d.content as any },
|
||||
trx,
|
||||
);
|
||||
updated += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const removedIds = existing
|
||||
.filter((e) => !desiredById.has(e.transclusionId))
|
||||
.map((e) => e.transclusionId);
|
||||
if (removedIds.length > 0) {
|
||||
await this.pageTransclusionsRepo.deleteByPageAndTransclusionIds(
|
||||
pageId,
|
||||
removedIds,
|
||||
trx,
|
||||
);
|
||||
deleted = removedIds.length;
|
||||
}
|
||||
|
||||
return { inserted, updated, deleted };
|
||||
}
|
||||
|
||||
async syncPageReferences(
|
||||
referencePageId: string,
|
||||
workspaceId: string,
|
||||
pmJson: unknown,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<{ inserted: number; deleted: number }> {
|
||||
const desired = collectReferencesFromPmJson(pmJson);
|
||||
const keyOf = (s: {
|
||||
sourcePageId: string;
|
||||
transclusionId: string;
|
||||
}) => `${s.sourcePageId}::${s.transclusionId}`;
|
||||
const desiredKeys = new Set(desired.map(keyOf));
|
||||
|
||||
const existing = await this.pageTransclusionReferencesRepo.findByReferencePageId(
|
||||
referencePageId,
|
||||
trx,
|
||||
);
|
||||
const existingKeys = new Set(existing.map(keyOf));
|
||||
|
||||
const toInsert = desired
|
||||
.filter((d) => !existingKeys.has(keyOf(d)))
|
||||
.map((d) => ({
|
||||
workspaceId,
|
||||
referencePageId,
|
||||
sourcePageId: d.sourcePageId,
|
||||
transclusionId: d.transclusionId,
|
||||
}));
|
||||
|
||||
const toDelete = existing
|
||||
.filter((e) => !desiredKeys.has(keyOf(e)))
|
||||
.map((e) => ({
|
||||
sourcePageId: e.sourcePageId,
|
||||
transclusionId: e.transclusionId,
|
||||
}));
|
||||
|
||||
if (toInsert.length > 0) {
|
||||
await this.pageTransclusionReferencesRepo.insertMany(toInsert, trx);
|
||||
}
|
||||
if (toDelete.length > 0) {
|
||||
await this.pageTransclusionReferencesRepo.deleteByReferenceAndKeys(
|
||||
referencePageId,
|
||||
toDelete,
|
||||
trx,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
inserted: toInsert.length,
|
||||
deleted: toDelete.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract transclusions from each page's PM JSON and bulk-insert into
|
||||
* `page_transclusions` in a single statement. Intended for brand-new pages
|
||||
* (e.g. duplication, import) where there is nothing to diff against.
|
||||
*/
|
||||
async insertTransclusionsForPages(
|
||||
pages: Array<{ id: string; workspaceId: string; content: unknown }>,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<{ inserted: number }> {
|
||||
const rows: Parameters<PageTransclusionsRepo['insertMany']>[0] = [];
|
||||
for (const page of pages) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (rows.length === 0) return { inserted: 0 };
|
||||
await this.pageTransclusionsRepo.insertMany(rows, trx);
|
||||
return { inserted: rows.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk each page's PM JSON for `transclusionReference` nodes and bulk-insert
|
||||
* one row per `(referencePage, source, target)`. For brand-new pages
|
||||
* (duplication, import) where there is nothing to diff against.
|
||||
*/
|
||||
async insertReferencesForPages(
|
||||
pages: Array<{ id: string; workspaceId: string; content: unknown }>,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<{ inserted: number }> {
|
||||
const rows: Array<{
|
||||
workspaceId: string;
|
||||
referencePageId: string;
|
||||
sourcePageId: string;
|
||||
transclusionId: string;
|
||||
}> = [];
|
||||
for (const page of pages) {
|
||||
const refs = collectReferencesFromPmJson(page.content);
|
||||
for (const r of refs) {
|
||||
rows.push({
|
||||
workspaceId: page.workspaceId,
|
||||
referencePageId: page.id,
|
||||
sourcePageId: r.sourcePageId,
|
||||
transclusionId: r.transclusionId,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (rows.length === 0) return { inserted: 0 };
|
||||
await this.pageTransclusionReferencesRepo.insertMany(rows, trx);
|
||||
return { inserted: rows.length };
|
||||
}
|
||||
|
||||
async lookup(
|
||||
references: Array<{ sourcePageId: string; transclusionId: string }>,
|
||||
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 = new Set(
|
||||
await this.pagePermissionRepo.filterAccessiblePageIds({
|
||||
pageIds: candidatePageIds,
|
||||
userId: viewerUserId,
|
||||
}),
|
||||
);
|
||||
|
||||
return this.lookupWithAccessSet(references, accessibleSet, workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve transclusion content for the given references using a caller-supplied
|
||||
* `accessibleSet` of source page ids. Source pages absent from the set return
|
||||
* `no_access`. Used by the share-scoped lookup path, where access is gated by
|
||||
* the share graph rather than the viewer's personal permissions.
|
||||
*/
|
||||
async lookupWithAccessSet(
|
||||
references: Array<{ sourcePageId: string; transclusionId: string }>,
|
||||
accessibleSet: Set<string>,
|
||||
workspaceId: string,
|
||||
): Promise<{ items: TransclusionLookup[] }> {
|
||||
if (references.length === 0) return { items: [] };
|
||||
|
||||
const items: TransclusionLookup[] = new Array(references.length).fill(null);
|
||||
const pendingIdx = references.map((_, i) => i);
|
||||
|
||||
const accessiblePending = pendingIdx.filter((i) =>
|
||||
accessibleSet.has(references[i].sourcePageId),
|
||||
);
|
||||
const rows = await this.pageTransclusionsRepo.findManyByPageAndTransclusion(
|
||||
accessiblePending.map((i) => ({
|
||||
pageId: references[i].sourcePageId,
|
||||
transclusionId: references[i].transclusionId,
|
||||
})),
|
||||
workspaceId,
|
||||
);
|
||||
const rowKey = (r: { pageId: string; transclusionId: string }) =>
|
||||
`${r.pageId}::${r.transclusionId}`;
|
||||
const rowMap = new Map(rows.map((r) => [rowKey(r), r]));
|
||||
|
||||
const accessiblePageIds = Array.from(
|
||||
new Set(accessiblePending.map((i) => references[i].sourcePageId)),
|
||||
);
|
||||
const pages = await this.pageRepo.findManyByIds(accessiblePageIds, {
|
||||
workspaceId,
|
||||
});
|
||||
const pageMeta = new Map<string, Date>();
|
||||
for (const p of pages) {
|
||||
pageMeta.set(p.id, p.updatedAt);
|
||||
}
|
||||
|
||||
for (const i of pendingIdx) {
|
||||
const ref = references[i];
|
||||
if (!accessibleSet.has(ref.sourcePageId)) {
|
||||
items[i] = {
|
||||
sourcePageId: ref.sourcePageId,
|
||||
transclusionId: ref.transclusionId,
|
||||
status: 'no_access',
|
||||
};
|
||||
continue;
|
||||
}
|
||||
const updatedAt = pageMeta.get(ref.sourcePageId);
|
||||
if (!updatedAt) {
|
||||
items[i] = {
|
||||
sourcePageId: ref.sourcePageId,
|
||||
transclusionId: ref.transclusionId,
|
||||
status: 'not_found',
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
const row = rowMap.get(`${ref.sourcePageId}::${ref.transclusionId}`);
|
||||
if (!row) {
|
||||
items[i] = {
|
||||
sourcePageId: ref.sourcePageId,
|
||||
transclusionId: ref.transclusionId,
|
||||
status: 'not_found',
|
||||
};
|
||||
continue;
|
||||
}
|
||||
items[i] = {
|
||||
sourcePageId: ref.sourcePageId,
|
||||
transclusionId: ref.transclusionId,
|
||||
content: row.content,
|
||||
sourceUpdatedAt: updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
async listReferences(opts: {
|
||||
sourcePageId: string;
|
||||
transclusionId: string;
|
||||
viewerUserId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<{
|
||||
source: ReferencingPageInfo | null;
|
||||
references: ReferencingPageInfo[];
|
||||
}> {
|
||||
const { sourcePageId, transclusionId, viewerUserId, workspaceId } = opts;
|
||||
|
||||
const referencePageIds =
|
||||
await this.pageTransclusionReferencesRepo.findReferencePageIdsByTransclusion(
|
||||
sourcePageId,
|
||||
transclusionId,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
const candidatePageIds = Array.from(
|
||||
new Set([sourcePageId, ...referencePageIds]),
|
||||
);
|
||||
const accessibleSet = new Set(
|
||||
await this.pagePermissionRepo.filterAccessiblePageIds({
|
||||
pageIds: candidatePageIds,
|
||||
userId: viewerUserId,
|
||||
}),
|
||||
);
|
||||
|
||||
const accessibleIds = candidatePageIds.filter((id) =>
|
||||
accessibleSet.has(id),
|
||||
);
|
||||
if (accessibleIds.length === 0) {
|
||||
return { source: null, references: [] };
|
||||
}
|
||||
|
||||
const rows = await Promise.all(
|
||||
accessibleIds.map((id) =>
|
||||
this.pageRepo.findById(id, { includeSpace: true }),
|
||||
),
|
||||
);
|
||||
const byId = new Map<string, ReferencingPageInfo>();
|
||||
for (const p of rows) {
|
||||
if (!p || p.deletedAt || p.workspaceId !== workspaceId) continue;
|
||||
const space = (p as Page & { space?: { slug?: string } }).space;
|
||||
byId.set(p.id, {
|
||||
id: p.id,
|
||||
slugId: p.slugId,
|
||||
title: p.title ?? null,
|
||||
icon: p.icon ?? null,
|
||||
spaceId: p.spaceId,
|
||||
spaceSlug: space?.slug ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
const source = byId.get(sourcePageId) ?? null;
|
||||
const references = referencePageIds
|
||||
.map((id) => byId.get(id))
|
||||
.filter((p): p is ReferencingPageInfo => Boolean(p));
|
||||
|
||||
return { source, references };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a `transclusionReference` into a self-contained copy on the
|
||||
* reference page: load source content, generate fresh attachment ids, copy storage
|
||||
* files, insert new attachment rows, return rewritten content. The caller
|
||||
* (controller) returns the content blob to the client which then performs
|
||||
* `editor.commands.insertContentAt(range, content)` to replace the
|
||||
* reference node. The next Yjs save naturally cleans up the
|
||||
* page_transclusion_references row, but we also delete it eagerly here so a
|
||||
* crash between server response and client save doesn't leave a stale row.
|
||||
*/
|
||||
async unsyncReference(
|
||||
referencePageId: string,
|
||||
sourcePageId: string,
|
||||
transclusionId: string,
|
||||
user: User,
|
||||
): Promise<{ content: unknown }> {
|
||||
const referencePage = await this.pageRepo.findById(referencePageId);
|
||||
if (!referencePage || referencePage.deletedAt) {
|
||||
throw new NotFoundException('Reference page not found');
|
||||
}
|
||||
|
||||
const sourcePage = await this.pageRepo.findById(sourcePageId);
|
||||
if (!sourcePage || sourcePage.deletedAt) {
|
||||
throw new NotFoundException('Source page not found');
|
||||
}
|
||||
|
||||
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,
|
||||
transclusionId,
|
||||
);
|
||||
if (!transclusion) {
|
||||
throw new NotFoundException('Sync block not found');
|
||||
}
|
||||
|
||||
const { content, copies } = rewriteAttachmentsForUnsync(
|
||||
transclusion.content,
|
||||
() => uuid7(),
|
||||
);
|
||||
|
||||
if (copies.length > 0) {
|
||||
const oldIds = copies.map((c) => c.oldAttachmentId);
|
||||
const oldRows = await this.attachmentRepo.findByIds(oldIds);
|
||||
const byOldId = new Map(
|
||||
oldRows
|
||||
.filter((a) => a.pageId === sourcePageId)
|
||||
.map((a) => [a.id, a]),
|
||||
);
|
||||
|
||||
for (const plan of copies) {
|
||||
const old = byOldId.get(plan.oldAttachmentId);
|
||||
if (!old) continue;
|
||||
|
||||
const newFilePath = old.filePath
|
||||
.split(plan.oldAttachmentId)
|
||||
.join(plan.newAttachmentId);
|
||||
try {
|
||||
await this.storageService.copy(old.filePath, newFilePath);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`unsync: failed to copy attachment ${old.id}`,
|
||||
err as Error,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
await this.attachmentRepo.insertAttachment({
|
||||
id: plan.newAttachmentId,
|
||||
type: old.type,
|
||||
filePath: newFilePath,
|
||||
fileName: old.fileName,
|
||||
fileSize: old.fileSize,
|
||||
mimeType: old.mimeType,
|
||||
fileExt: old.fileExt,
|
||||
creatorId: user.id,
|
||||
workspaceId: referencePage.workspaceId,
|
||||
pageId: referencePageId,
|
||||
spaceId: referencePage.spaceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await this.pageTransclusionReferencesRepo.deleteOne(
|
||||
referencePageId,
|
||||
sourcePageId,
|
||||
transclusionId,
|
||||
);
|
||||
|
||||
return { content };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export type TransclusionLookup =
|
||||
| {
|
||||
sourcePageId: string;
|
||||
transclusionId: string;
|
||||
content: unknown;
|
||||
sourceUpdatedAt: Date;
|
||||
}
|
||||
| { sourcePageId: string; transclusionId: string; status: 'not_found' }
|
||||
| { sourcePageId: string; transclusionId: string; status: 'no_access' };
|
||||
|
||||
export type TransclusionNodeSnapshot = {
|
||||
transclusionId: string;
|
||||
content: unknown;
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
import { TransclusionNodeSnapshot } from '../transclusion.types';
|
||||
|
||||
const TRANSCLUSION_TYPE = 'transclusionSource';
|
||||
const REFERENCE_TYPE = 'transclusionReference';
|
||||
|
||||
export type TransclusionReferenceSnapshot = {
|
||||
sourcePageId: string;
|
||||
transclusionId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Walks a ProseMirror JSON document and returns one snapshot per top-level
|
||||
* `transclusion` node. Does not recurse into transclusions (schema disallows
|
||||
* nesting). Skips transclusion nodes without an id (transient state). When
|
||||
* duplicate ids are encountered, the later occurrence wins so the result is
|
||||
* deterministic.
|
||||
*/
|
||||
export function collectTransclusionsFromPmJson(
|
||||
doc: unknown,
|
||||
): TransclusionNodeSnapshot[] {
|
||||
if (!doc || typeof doc !== 'object') return [];
|
||||
|
||||
const byId = new Map<string, TransclusionNodeSnapshot>();
|
||||
|
||||
const visit = (node: any): void => {
|
||||
if (!node || typeof node !== 'object') return;
|
||||
|
||||
if (node.type === TRANSCLUSION_TYPE) {
|
||||
const id = node.attrs?.id;
|
||||
if (typeof id === 'string' && id.length > 0) {
|
||||
byId.set(id, {
|
||||
transclusionId: id,
|
||||
content: { type: 'doc', content: node.content ?? [] },
|
||||
});
|
||||
}
|
||||
return; // do not recurse into transclusion children
|
||||
}
|
||||
|
||||
if (Array.isArray(node.content)) {
|
||||
for (const child of node.content) visit(child);
|
||||
}
|
||||
};
|
||||
|
||||
visit(doc);
|
||||
return Array.from(byId.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks a ProseMirror JSON document and returns one snapshot per unique
|
||||
* `(sourcePageId, transclusionId)` pair found on `transclusionReference`
|
||||
* nodes. The schema forbids references inside a `transclusionSource` so this
|
||||
* walk stops at source boundaries — references can only appear at page level.
|
||||
* Order preserved by first-seen.
|
||||
*/
|
||||
export function collectReferencesFromPmJson(
|
||||
doc: unknown,
|
||||
): TransclusionReferenceSnapshot[] {
|
||||
if (!doc || typeof doc !== 'object') return [];
|
||||
|
||||
const seen = new Set<string>();
|
||||
const out: TransclusionReferenceSnapshot[] = [];
|
||||
|
||||
const visit = (node: any): void => {
|
||||
if (!node || typeof node !== 'object') return;
|
||||
|
||||
if (node.type === REFERENCE_TYPE) {
|
||||
const sourcePageId = node.attrs?.sourcePageId;
|
||||
const transclusionId = node.attrs?.transclusionId;
|
||||
if (
|
||||
typeof sourcePageId === 'string' &&
|
||||
sourcePageId.length > 0 &&
|
||||
typeof transclusionId === 'string' &&
|
||||
transclusionId.length > 0
|
||||
) {
|
||||
const key = `${sourcePageId}::${transclusionId}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
out.push({ sourcePageId, transclusionId });
|
||||
}
|
||||
}
|
||||
return; // atom node - no children
|
||||
}
|
||||
|
||||
// References cannot live inside a source (schema-enforced); skip recursing
|
||||
// so a malformed inbound doc can't sneak in a nested reference here.
|
||||
if (node.type === TRANSCLUSION_TYPE) return;
|
||||
|
||||
if (Array.isArray(node.content)) {
|
||||
for (const child of node.content) visit(child);
|
||||
}
|
||||
};
|
||||
|
||||
visit(doc);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { isAttachmentNode } from '../../../../common/helpers/prosemirror/attachment-node-types';
|
||||
|
||||
export type AttachmentRewritePlan = {
|
||||
oldAttachmentId: string;
|
||||
newAttachmentId: string;
|
||||
};
|
||||
|
||||
export type RewriteResult = {
|
||||
content: unknown;
|
||||
copies: AttachmentRewritePlan[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Walk a ProseMirror JSON tree, rewrite every attachment-like node so its
|
||||
* `attachmentId` (and any `src` substring matching that id) point at a fresh
|
||||
* id. Each unique old id maps to exactly one new id; the caller is responsible
|
||||
* for actually copying the underlying storage file.
|
||||
*
|
||||
* Pure: does not mutate the input. Returns a deep clone.
|
||||
*/
|
||||
export function rewriteAttachmentsForUnsync(
|
||||
content: unknown,
|
||||
generateId: () => string,
|
||||
): RewriteResult {
|
||||
const cloned = content ? JSON.parse(JSON.stringify(content)) : content;
|
||||
const idMap = new Map<string, string>();
|
||||
|
||||
const visit = (node: any): void => {
|
||||
if (!node || typeof node !== 'object') return;
|
||||
|
||||
if (
|
||||
typeof node.type === 'string' &&
|
||||
isAttachmentNode(node.type) &&
|
||||
node.attrs
|
||||
) {
|
||||
const oldId = node.attrs.attachmentId;
|
||||
if (typeof oldId === 'string' && oldId.length > 0) {
|
||||
let newId = idMap.get(oldId);
|
||||
if (!newId) {
|
||||
newId = generateId();
|
||||
idMap.set(oldId, newId);
|
||||
}
|
||||
node.attrs.attachmentId = newId;
|
||||
if (typeof node.attrs.src === 'string' && node.attrs.src.includes(oldId)) {
|
||||
node.attrs.src = node.attrs.src.split(oldId).join(newId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(node.content)) {
|
||||
for (const child of node.content) visit(child);
|
||||
}
|
||||
};
|
||||
|
||||
visit(cloned);
|
||||
|
||||
const copies: AttachmentRewritePlan[] = Array.from(idMap.entries()).map(
|
||||
([oldAttachmentId, newAttachmentId]) => ({
|
||||
oldAttachmentId,
|
||||
newAttachmentId,
|
||||
}),
|
||||
);
|
||||
|
||||
return { content: cloned, copies };
|
||||
}
|
||||
@@ -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,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,10 +13,6 @@ import { CreateUserDto } from '../../auth/dto/create-user.dto';
|
||||
export class UpdateUserDto extends PartialType(
|
||||
OmitType(CreateUserDto, ['password'] as const),
|
||||
) {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
avatarUrl: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
fullPageWidth: boolean;
|
||||
@@ -26,6 +22,10 @@ export class UpdateUserDto extends PartialType(
|
||||
@IsIn(['read', 'edit'])
|
||||
pageEditMode: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
editorToolbar: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
locale: string;
|
||||
|
||||
@@ -61,6 +61,14 @@ export class UserService {
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof updateUserDto.editorToolbar !== 'undefined') {
|
||||
return this.userRepo.updatePreference(
|
||||
userId,
|
||||
'editorToolbar',
|
||||
updateUserDto.editorToolbar,
|
||||
);
|
||||
}
|
||||
|
||||
const notificationSettings: Record<string, NotificationSettingKey> = {
|
||||
notificationPageUpdates: 'page.updated',
|
||||
notificationPageUserMention: 'page.userMention',
|
||||
@@ -110,10 +118,6 @@ export class UserService {
|
||||
user.email = updateUserDto.email;
|
||||
}
|
||||
|
||||
if (updateUserDto.avatarUrl) {
|
||||
user.avatarUrl = updateUserDto.avatarUrl;
|
||||
}
|
||||
|
||||
if (updateUserDto.locale) {
|
||||
user.locale = updateUserDto.locale;
|
||||
}
|
||||
|
||||
@@ -5,15 +5,10 @@ import {
|
||||
IsBoolean,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
logo: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
emailDomains: string[];
|
||||
@@ -46,6 +41,10 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
|
||||
@IsBoolean()
|
||||
mcpEnabled: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isScimEnabled: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
aiChat: boolean;
|
||||
|
||||
@@ -331,7 +331,8 @@ export class WorkspaceService {
|
||||
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.mcpEnabled !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.allowMemberTemplates !== 'undefined'
|
||||
typeof updateWorkspaceDto.allowMemberTemplates !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.isScimEnabled !== 'undefined'
|
||||
) {
|
||||
const ws = await this.db
|
||||
.selectFrom('workspaces')
|
||||
@@ -351,6 +352,14 @@ export class WorkspaceService {
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof updateWorkspaceDto.isScimEnabled !== 'undefined') {
|
||||
if (!this.licenseCheckService.hasFeature(ws.licenseKey, Feature.SCIM, ws.plan)) {
|
||||
throw new ForbiddenException(
|
||||
'This feature requires a valid license',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
typeof updateWorkspaceDto.disablePublicSharing !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' ||
|
||||
@@ -535,6 +544,7 @@ export class WorkspaceService {
|
||||
'enforceSso',
|
||||
'enforceMfa',
|
||||
'emailDomains',
|
||||
'isScimEnabled',
|
||||
],
|
||||
updateWorkspaceDto,
|
||||
workspaceBefore,
|
||||
|
||||
@@ -11,6 +11,8 @@ import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
import { PageRepo } from './repos/page/page.repo';
|
||||
import { PagePermissionRepo } from './repos/page/page-permission.repo';
|
||||
import { CommentRepo } from './repos/comment/comment.repo';
|
||||
import { PageTransclusionsRepo } from './repos/page-transclusions/page-transclusions.repo';
|
||||
import { PageTransclusionReferencesRepo } from './repos/page-transclusions/page-transclusion-references.repo';
|
||||
import { PageHistoryRepo } from './repos/page/page-history.repo';
|
||||
import { AttachmentRepo } from './repos/attachment/attachment.repo';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
@@ -22,6 +24,7 @@ import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
|
||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||
import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo';
|
||||
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
|
||||
import { LabelRepo } from '@docmost/db/repos/label/label.repo';
|
||||
import { FavoriteRepo } from '@docmost/db/repos/favorite/favorite.repo';
|
||||
import { TemplateRepo } from '@docmost/db/repos/template/template.repo';
|
||||
import { PageListener } from '@docmost/db/listeners/page.listener';
|
||||
@@ -75,6 +78,8 @@ import { normalizePostgresUrl } from '../common/helpers';
|
||||
SpaceMemberRepo,
|
||||
PageRepo,
|
||||
PagePermissionRepo,
|
||||
PageTransclusionsRepo,
|
||||
PageTransclusionReferencesRepo,
|
||||
PageHistoryRepo,
|
||||
CommentRepo,
|
||||
FavoriteRepo,
|
||||
@@ -85,6 +90,7 @@ import { normalizePostgresUrl } from '../common/helpers';
|
||||
ShareRepo,
|
||||
NotificationRepo,
|
||||
WatcherRepo,
|
||||
LabelRepo,
|
||||
TemplateRepo,
|
||||
PageListener,
|
||||
],
|
||||
@@ -97,6 +103,8 @@ import { normalizePostgresUrl } from '../common/helpers';
|
||||
SpaceMemberRepo,
|
||||
PageRepo,
|
||||
PagePermissionRepo,
|
||||
PageTransclusionsRepo,
|
||||
PageTransclusionReferencesRepo,
|
||||
PageHistoryRepo,
|
||||
CommentRepo,
|
||||
FavoriteRepo,
|
||||
@@ -107,6 +115,7 @@ import { normalizePostgresUrl } from '../common/helpers';
|
||||
ShareRepo,
|
||||
NotificationRepo,
|
||||
WatcherRepo,
|
||||
LabelRepo,
|
||||
TemplateRepo,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.createTable('scim_tokens')
|
||||
.addColumn('id', 'uuid', (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||
)
|
||||
.addColumn('name', 'varchar', (col) => col.notNull())
|
||||
.addColumn('token_hash', 'varchar', (col) => col.notNull())
|
||||
.addColumn('token_last_four', 'varchar(4)', (col) => col.notNull())
|
||||
.addColumn('last_used_at', 'timestamptz')
|
||||
.addColumn('is_enabled', 'boolean', (col) => col.notNull().defaultTo(true))
|
||||
.addColumn('creator_id', 'uuid', (col) =>
|
||||
col.references('users.id').onDelete('set null'),
|
||||
)
|
||||
.addColumn('workspace_id', 'uuid', (col) =>
|
||||
col.references('workspaces.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('created_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('updated_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('deleted_at', 'timestamptz')
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_scim_tokens_token_hash')
|
||||
.ifNotExists()
|
||||
.on('scim_tokens')
|
||||
.column('token_hash')
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_scim_tokens_workspace_id')
|
||||
.ifNotExists()
|
||||
.on('scim_tokens')
|
||||
.column('workspace_id')
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.alterTable('users')
|
||||
.addColumn('scim_external_id', 'text')
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_users_workspace_scim_external_id')
|
||||
.ifNotExists()
|
||||
.on('users')
|
||||
.columns(['workspace_id', 'scim_external_id'])
|
||||
.where('scim_external_id', 'is not', null)
|
||||
.unique()
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.alterTable('groups')
|
||||
.addColumn('scim_external_id', 'text')
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_groups_workspace_scim_external_id')
|
||||
.ifNotExists()
|
||||
.on('groups')
|
||||
.columns(['workspace_id', 'scim_external_id'])
|
||||
.where('scim_external_id', 'is not', null)
|
||||
.unique()
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.alterTable('groups')
|
||||
.addColumn('is_external', 'boolean', (col) =>
|
||||
col.notNull().defaultTo(false),
|
||||
)
|
||||
.execute();
|
||||
|
||||
// Backfill: mark all non-default groups as external in workspaces with SSO group sync enabled
|
||||
await sql`
|
||||
UPDATE groups SET is_external = true
|
||||
WHERE is_default = false
|
||||
AND workspace_id IN (
|
||||
SELECT workspace_id FROM auth_providers WHERE group_sync = true
|
||||
)
|
||||
`.execute(db);
|
||||
|
||||
await db.schema
|
||||
.alterTable('workspaces')
|
||||
.addColumn('is_scim_enabled', 'boolean', (col) =>
|
||||
col.notNull().defaultTo(false),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.dropTable('scim_tokens').execute();
|
||||
|
||||
await db.schema.dropIndex('idx_users_workspace_scim_external_id').execute();
|
||||
await db.schema.alterTable('users').dropColumn('scim_external_id').execute();
|
||||
|
||||
await db.schema.dropIndex('idx_groups_workspace_scim_external_id').execute();
|
||||
await db.schema.alterTable('groups').dropColumn('scim_external_id').execute();
|
||||
|
||||
await db.schema.alterTable('groups').dropColumn('is_external').execute();
|
||||
|
||||
await db.schema
|
||||
.alterTable('workspaces')
|
||||
.dropColumn('is_scim_enabled')
|
||||
.execute();
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { type Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.createTable('page_transclusions')
|
||||
.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'),
|
||||
)
|
||||
.addColumn('transclusion_id', 'varchar', (col) => col.notNull())
|
||||
.addColumn('content', 'jsonb', (col) => col.notNull())
|
||||
.addColumn('created_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('updated_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addUniqueConstraint('page_transclusions_page_transclusion_unique', [
|
||||
'page_id',
|
||||
'transclusion_id',
|
||||
])
|
||||
.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'),
|
||||
)
|
||||
.addColumn('source_page_id', 'uuid', (col) =>
|
||||
col.notNull().references('pages.id').onDelete('cascade'),
|
||||
)
|
||||
.addColumn('transclusion_id', 'varchar', (col) => col.notNull())
|
||||
.addColumn('created_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addUniqueConstraint('page_transclusion_references_unique', [
|
||||
'reference_page_id',
|
||||
'source_page_id',
|
||||
'transclusion_id',
|
||||
])
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_page_transclusion_references_source')
|
||||
.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> {
|
||||
await db.schema.dropTable('page_transclusion_references').execute();
|
||||
await db.schema.dropTable('page_transclusions').execute();
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { type Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.createTable('labels')
|
||||
.addColumn('id', 'uuid', (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||
)
|
||||
.addColumn('name', 'varchar', (col) => col.notNull())
|
||||
.addColumn('type', 'varchar', (col) => col.notNull().defaultTo('page'))
|
||||
.addColumn('workspace_id', 'uuid', (col) =>
|
||||
col.references('workspaces.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('created_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('updated_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('labels_workspace_id_type_name_unique')
|
||||
.on('labels')
|
||||
.columns(['workspace_id', 'type', 'name'])
|
||||
.unique()
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createTable('page_labels')
|
||||
.addColumn('id', 'uuid', (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||
)
|
||||
.addColumn('page_id', 'uuid', (col) =>
|
||||
col.references('pages.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('label_id', 'uuid', (col) =>
|
||||
col.references('labels.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('created_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addUniqueConstraint('page_labels_page_id_label_id_unique', [
|
||||
'page_id',
|
||||
'label_id',
|
||||
])
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('page_labels_label_id_idx')
|
||||
.on('page_labels')
|
||||
.column('label_id')
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.dropTable('page_labels').execute();
|
||||
await db.schema.dropTable('labels').execute();
|
||||
}
|
||||
@@ -89,6 +89,22 @@ export class AttachmentRepo {
|
||||
.execute();
|
||||
}
|
||||
|
||||
async findByIds(
|
||||
ids: string[],
|
||||
opts?: {
|
||||
trx?: KyselyTransaction;
|
||||
},
|
||||
): Promise<Attachment[]> {
|
||||
if (ids.length === 0) return [];
|
||||
const db = dbOrTx(this.db, opts?.trx);
|
||||
|
||||
return db
|
||||
.selectFrom('attachments')
|
||||
.select(this.baseFields)
|
||||
.where('id', 'in', ids)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async findByAiChatId(
|
||||
aiChatId: string,
|
||||
opts?: {
|
||||
|
||||
@@ -7,10 +7,20 @@ import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||
import { dbOrTx } from '@docmost/db/utils';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import {
|
||||
executeWithCursorPagination,
|
||||
emptyCursorPaginationResult,
|
||||
} from '@docmost/db/pagination/cursor-pagination';
|
||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
|
||||
@Injectable()
|
||||
export class BacklinkRepo {
|
||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||
constructor(
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private readonly spaceMemberRepo: SpaceMemberRepo,
|
||||
) {}
|
||||
|
||||
async findById(
|
||||
backlinkId: string,
|
||||
@@ -69,4 +79,84 @@ export class BacklinkRepo {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db.deleteFrom('backlinks').where('id', '=', backlinkId).execute();
|
||||
}
|
||||
|
||||
async findRelatedPageIds(
|
||||
pageId: string,
|
||||
direction: 'incoming' | 'outgoing',
|
||||
userId: string,
|
||||
): Promise<string[]> {
|
||||
const userSpaceIds = this.spaceMemberRepo.getUserSpaceIdsQuery(userId);
|
||||
|
||||
if (direction === 'incoming') {
|
||||
const rows = await this.db
|
||||
.selectFrom('backlinks')
|
||||
.innerJoin('pages', 'pages.id', 'backlinks.sourcePageId')
|
||||
.select('backlinks.sourcePageId as relatedId')
|
||||
.where('backlinks.targetPageId', '=', pageId)
|
||||
.where('pages.deletedAt', 'is', null)
|
||||
.where('pages.spaceId', 'in', userSpaceIds)
|
||||
.execute();
|
||||
return rows.map((r) => r.relatedId);
|
||||
}
|
||||
|
||||
const rows = await this.db
|
||||
.selectFrom('backlinks')
|
||||
.innerJoin('pages', 'pages.id', 'backlinks.targetPageId')
|
||||
.select('backlinks.targetPageId as relatedId')
|
||||
.where('backlinks.sourcePageId', '=', pageId)
|
||||
.where('pages.deletedAt', 'is', null)
|
||||
.where('pages.spaceId', 'in', userSpaceIds)
|
||||
.execute();
|
||||
return rows.map((r) => r.relatedId);
|
||||
}
|
||||
|
||||
async findPagesByIdsPaginated(
|
||||
pageIds: string[],
|
||||
pagination: PaginationOptions,
|
||||
) {
|
||||
if (pageIds.length === 0) {
|
||||
return emptyCursorPaginationResult<{
|
||||
id: string;
|
||||
slugId: string;
|
||||
title: string | null;
|
||||
icon: string | null;
|
||||
spaceId: string;
|
||||
updatedAt: Date;
|
||||
space: { id: string; slug: string; name: string } | null;
|
||||
}>(pagination.limit);
|
||||
}
|
||||
|
||||
const query = this.db
|
||||
.selectFrom('pages')
|
||||
.select((eb) => [
|
||||
'pages.id',
|
||||
'pages.slugId',
|
||||
'pages.title',
|
||||
'pages.icon',
|
||||
'pages.spaceId',
|
||||
'pages.updatedAt',
|
||||
jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom('spaces')
|
||||
.select(['spaces.id', 'spaces.slug', 'spaces.name'])
|
||||
.whereRef('spaces.id', '=', 'pages.spaceId'),
|
||||
).as('space'),
|
||||
])
|
||||
.where('pages.deletedAt', 'is', null)
|
||||
.where('pages.id', 'in', pageIds);
|
||||
|
||||
return executeWithCursorPagination(query, {
|
||||
perPage: pagination.limit,
|
||||
cursor: pagination.cursor,
|
||||
beforeCursor: pagination.beforeCursor,
|
||||
fields: [
|
||||
{ expression: 'pages.updatedAt', direction: 'desc', key: 'updatedAt' },
|
||||
{ expression: 'pages.id', direction: 'desc', key: 'id' },
|
||||
],
|
||||
parseCursor: (cursor) => ({
|
||||
updatedAt: new Date(cursor.updatedAt),
|
||||
id: cursor.id,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from '@docmost/db/types/entity.types';
|
||||
import { ExpressionBuilder, sql } from 'kysely';
|
||||
import { PaginationOptions } from '../../pagination/pagination-options';
|
||||
import { DB } from '@docmost/db/types/db';
|
||||
import { DB, Groups } from '@docmost/db/types/db';
|
||||
import { DefaultGroup } from '../../../core/group/dto/create-group.dto';
|
||||
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
||||
|
||||
@@ -17,16 +17,34 @@ import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagin
|
||||
export class GroupRepo {
|
||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||
|
||||
private baseFields: Array<keyof Groups> = [
|
||||
'id',
|
||||
'name',
|
||||
'description',
|
||||
'isDefault',
|
||||
'isExternal',
|
||||
'creatorId',
|
||||
'workspaceId',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'deletedAt',
|
||||
];
|
||||
|
||||
async findById(
|
||||
groupId: string,
|
||||
workspaceId: string,
|
||||
opts?: { includeMemberCount?: boolean; trx?: KyselyTransaction },
|
||||
opts?: {
|
||||
includeMemberCount?: boolean;
|
||||
includeScimExternalId?: boolean;
|
||||
trx?: KyselyTransaction;
|
||||
},
|
||||
): Promise<Group> {
|
||||
const db = dbOrTx(this.db, opts?.trx);
|
||||
return db
|
||||
.selectFrom('groups')
|
||||
.selectAll('groups')
|
||||
.select(this.baseFields)
|
||||
.$if(opts?.includeMemberCount, (qb) => qb.select(this.withMemberCount))
|
||||
.$if(opts?.includeScimExternalId, (qb) => qb.select('scimExternalId'))
|
||||
.where('id', '=', groupId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.executeTakeFirst();
|
||||
@@ -35,13 +53,18 @@ export class GroupRepo {
|
||||
async findByName(
|
||||
groupName: string,
|
||||
workspaceId: string,
|
||||
opts?: { includeMemberCount?: boolean; trx?: KyselyTransaction },
|
||||
opts?: {
|
||||
includeMemberCount?: boolean;
|
||||
includeScimExternalId?: boolean;
|
||||
trx?: KyselyTransaction;
|
||||
},
|
||||
): Promise<Group> {
|
||||
const db = dbOrTx(this.db, opts?.trx);
|
||||
return db
|
||||
.selectFrom('groups')
|
||||
.selectAll('groups')
|
||||
.select(this.baseFields)
|
||||
.$if(opts?.includeMemberCount, (qb) => qb.select(this.withMemberCount))
|
||||
.$if(opts?.includeScimExternalId, (qb) => qb.select('scimExternalId'))
|
||||
.where(sql`LOWER(name)`, '=', sql`LOWER(${groupName})`)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.executeTakeFirst();
|
||||
@@ -51,8 +74,11 @@ export class GroupRepo {
|
||||
updatableGroup: UpdatableGroup,
|
||||
groupId: string,
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
await this.db
|
||||
const db = dbOrTx(this.db, trx);
|
||||
|
||||
await db
|
||||
.updateTable('groups')
|
||||
.set({ ...updatableGroup, updatedAt: new Date() })
|
||||
.where('id', '=', groupId)
|
||||
@@ -68,7 +94,7 @@ export class GroupRepo {
|
||||
return db
|
||||
.insertInto('groups')
|
||||
.values(insertableGroup)
|
||||
.returningAll()
|
||||
.returning(this.baseFields)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@@ -80,7 +106,7 @@ export class GroupRepo {
|
||||
return (
|
||||
db
|
||||
.selectFrom('groups')
|
||||
.selectAll()
|
||||
.select(this.baseFields)
|
||||
// .select((eb) => this.withMemberCount(eb))
|
||||
.where('isDefault', '=', true)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
@@ -106,7 +132,7 @@ export class GroupRepo {
|
||||
async getGroupsPaginated(workspaceId: string, pagination: PaginationOptions) {
|
||||
let baseQuery = this.db
|
||||
.selectFrom('groups')
|
||||
.selectAll('groups')
|
||||
.select(this.baseFields)
|
||||
.select((eb) => this.withMemberCount(eb))
|
||||
.where('workspaceId', '=', workspaceId);
|
||||
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
|
||||
import { Label } from '@docmost/db/types/entity.types';
|
||||
import { dbOrTx } from '@docmost/db/utils';
|
||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
||||
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import { normalizeLabelName } from '../../../core/label/utils';
|
||||
|
||||
export const LabelType = {
|
||||
PAGE: 'page',
|
||||
SPACE: 'space',
|
||||
} as const;
|
||||
|
||||
export type LabelType = (typeof LabelType)[keyof typeof LabelType];
|
||||
|
||||
@Injectable()
|
||||
export class LabelRepo {
|
||||
constructor(
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private readonly spaceMemberRepo: SpaceMemberRepo,
|
||||
) {}
|
||||
|
||||
async findById(
|
||||
labelId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<Label | undefined> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.selectFrom('labels')
|
||||
.selectAll()
|
||||
.where('id', '=', labelId)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async findByNameAndWorkspace(
|
||||
name: string,
|
||||
workspaceId: string,
|
||||
type: LabelType,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<Label | undefined> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.selectFrom('labels')
|
||||
.selectAll()
|
||||
.where('name', '=', normalizeLabelName(name))
|
||||
.where('type', '=', type)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async findOrCreate(
|
||||
name: string,
|
||||
workspaceId: string,
|
||||
type: LabelType,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<Label> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
const normalizedName = normalizeLabelName(name);
|
||||
|
||||
// DO UPDATE (rather than DO NOTHING) so RETURNING always emits a row,
|
||||
// even on conflict. Avoids a race where a follow-up SELECT could miss a
|
||||
// row inserted by a concurrent transaction. The set is a no-op write.
|
||||
return db
|
||||
.insertInto('labels')
|
||||
.values({ name: normalizedName, type, workspaceId })
|
||||
.onConflict((oc) =>
|
||||
oc
|
||||
.columns(['name', 'type', 'workspaceId'])
|
||||
.doUpdateSet({ name: normalizedName }),
|
||||
)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
async findLabelsByPageId(pageId: string, pagination: PaginationOptions) {
|
||||
const query = this.db
|
||||
.selectFrom('labels')
|
||||
.innerJoin('pageLabels', 'pageLabels.labelId', 'labels.id')
|
||||
.select([
|
||||
'labels.id',
|
||||
'labels.name',
|
||||
'labels.type',
|
||||
'labels.createdAt',
|
||||
'labels.updatedAt',
|
||||
'labels.workspaceId',
|
||||
'pageLabels.id as joinId',
|
||||
])
|
||||
.where('pageLabels.pageId', '=', pageId)
|
||||
.where('labels.type', '=', LabelType.PAGE);
|
||||
|
||||
const result = await executeWithCursorPagination(query, {
|
||||
perPage: pagination.limit,
|
||||
cursor: pagination.cursor,
|
||||
beforeCursor: pagination.beforeCursor,
|
||||
fields: [
|
||||
{ expression: 'pageLabels.id', direction: 'asc', key: 'joinId' },
|
||||
],
|
||||
parseCursor: (cursor) => ({
|
||||
joinId: cursor.joinId,
|
||||
}),
|
||||
});
|
||||
|
||||
// joinId is an internal pagination cursor; don't leak it to callers.
|
||||
return {
|
||||
...result,
|
||||
items: result.items.map(({ joinId: _joinId, ...rest }) => rest),
|
||||
};
|
||||
}
|
||||
|
||||
async findLabels(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
type: LabelType,
|
||||
pagination: PaginationOptions,
|
||||
) {
|
||||
// Label visibility is scoped to space membership: a label surfaces if it
|
||||
// is attached to any non-deleted page in a space the user belongs to.
|
||||
// Per-page permission restrictions intentionally do not narrow this
|
||||
// further — labels are a space-level concept, not a page-level one.
|
||||
let query = this.db
|
||||
.selectFrom('labels')
|
||||
.select(['id', 'name', 'type', 'createdAt', 'updatedAt', 'workspaceId'])
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where('type', '=', type)
|
||||
.where(
|
||||
'id',
|
||||
'in',
|
||||
this.db
|
||||
.selectFrom('pageLabels')
|
||||
.innerJoin('pages', 'pages.id', 'pageLabels.pageId')
|
||||
.select('pageLabels.labelId')
|
||||
.where('pages.deletedAt', 'is', null)
|
||||
.where(
|
||||
'pages.spaceId',
|
||||
'in',
|
||||
this.spaceMemberRepo.getUserSpaceIdsQuery(userId),
|
||||
),
|
||||
);
|
||||
|
||||
if (pagination.query) {
|
||||
query = query.where(
|
||||
'name',
|
||||
'like',
|
||||
`%${pagination.query.toLowerCase()}%`,
|
||||
);
|
||||
}
|
||||
|
||||
return executeWithCursorPagination(query, {
|
||||
perPage: pagination.limit,
|
||||
cursor: pagination.cursor,
|
||||
beforeCursor: pagination.beforeCursor,
|
||||
fields: [
|
||||
{ expression: 'name', direction: 'asc' },
|
||||
{ expression: 'id', direction: 'asc' },
|
||||
],
|
||||
parseCursor: (cursor) => ({
|
||||
name: cursor.name,
|
||||
id: cursor.id,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async addLabelToPage(
|
||||
pageId: string,
|
||||
labelId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
.insertInto('pageLabels')
|
||||
.values({ pageId, labelId })
|
||||
.onConflict((oc) => oc.doNothing())
|
||||
.execute();
|
||||
}
|
||||
|
||||
async removeLabelFromPage(
|
||||
pageId: string,
|
||||
labelId: string,
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
.deleteFrom('pageLabels')
|
||||
.where('pageId', '=', pageId)
|
||||
.where('labelId', '=', labelId)
|
||||
.where((eb) =>
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('labels')
|
||||
.select('id')
|
||||
.whereRef('labels.id', '=', 'pageLabels.labelId')
|
||||
.where('labels.workspaceId', '=', workspaceId),
|
||||
),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async getPageLabelCount(
|
||||
pageId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<number> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
const result = await db
|
||||
.selectFrom('pageLabels')
|
||||
.select((eb) => eb.fn.count('id').as('count'))
|
||||
.where('pageId', '=', pageId)
|
||||
.executeTakeFirst();
|
||||
|
||||
return Number(result?.count ?? 0);
|
||||
}
|
||||
|
||||
async getLabelPageCount(
|
||||
labelId: string,
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<number> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
const result = await db
|
||||
.selectFrom('pageLabels')
|
||||
.innerJoin('labels', 'labels.id', 'pageLabels.labelId')
|
||||
.select((eb) => eb.fn.count('pageLabels.id').as('count'))
|
||||
.where('pageLabels.labelId', '=', labelId)
|
||||
.where('labels.workspaceId', '=', workspaceId)
|
||||
.executeTakeFirst();
|
||||
|
||||
return Number(result?.count ?? 0);
|
||||
}
|
||||
|
||||
async deleteLabel(
|
||||
labelId: string,
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
.deleteFrom('labels')
|
||||
.where('id', '=', labelId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async findPagesByLabelId(
|
||||
labelId: string,
|
||||
userId: string,
|
||||
opts: {
|
||||
spaceId?: string;
|
||||
query?: string;
|
||||
pagination: PaginationOptions;
|
||||
},
|
||||
) {
|
||||
let query = this.db
|
||||
.selectFrom('pages')
|
||||
.innerJoin('pageLabels', 'pageLabels.pageId', 'pages.id')
|
||||
.select((eb) => [
|
||||
'pages.id',
|
||||
'pages.slugId',
|
||||
'pages.title',
|
||||
'pages.icon',
|
||||
'pages.spaceId',
|
||||
'pages.createdAt',
|
||||
'pages.updatedAt',
|
||||
jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom('spaces')
|
||||
.select(['spaces.id', 'spaces.name', 'spaces.slug', 'spaces.logo'])
|
||||
.whereRef('spaces.id', '=', 'pages.spaceId'),
|
||||
).as('space'),
|
||||
jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom('users')
|
||||
.select(['users.id', 'users.name', 'users.avatarUrl'])
|
||||
.whereRef('users.id', '=', 'pages.creatorId'),
|
||||
).as('creator'),
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom('labels')
|
||||
.innerJoin('pageLabels as pl', 'pl.labelId', 'labels.id')
|
||||
.select(['labels.id', 'labels.name'])
|
||||
.whereRef('pl.pageId', '=', 'pages.id')
|
||||
.where('labels.type', '=', LabelType.PAGE)
|
||||
.orderBy('pl.id', 'asc'),
|
||||
).as('labels'),
|
||||
])
|
||||
.where('pageLabels.labelId', '=', labelId)
|
||||
.where('pages.deletedAt', 'is', null);
|
||||
|
||||
if (opts.spaceId) {
|
||||
query = query.where('pages.spaceId', '=', opts.spaceId);
|
||||
} else {
|
||||
query = query.where(
|
||||
'pages.spaceId',
|
||||
'in',
|
||||
this.spaceMemberRepo.getUserSpaceIdsQuery(userId),
|
||||
);
|
||||
}
|
||||
|
||||
if (opts.query) {
|
||||
query = query.where('pages.title', 'ilike', `%${opts.query}%`);
|
||||
}
|
||||
|
||||
return executeWithCursorPagination(query, {
|
||||
perPage: opts.pagination.limit,
|
||||
cursor: opts.pagination.cursor,
|
||||
beforeCursor: opts.pagination.beforeCursor,
|
||||
fields: [
|
||||
{ expression: 'pages.updatedAt', direction: 'desc', key: 'updatedAt' },
|
||||
{ expression: 'pages.id', direction: 'desc', key: 'id' },
|
||||
],
|
||||
parseCursor: (cursor) => ({
|
||||
updatedAt: new Date(cursor.updatedAt),
|
||||
id: cursor.id,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async getLabelPageCountForUser(
|
||||
labelId: string,
|
||||
userId: string,
|
||||
spaceId?: string,
|
||||
): Promise<number> {
|
||||
let query = this.db
|
||||
.selectFrom('pageLabels')
|
||||
.innerJoin('pages', 'pages.id', 'pageLabels.pageId')
|
||||
.select((eb) => eb.fn.count('pageLabels.id').as('count'))
|
||||
.where('pageLabels.labelId', '=', labelId)
|
||||
.where('pages.deletedAt', 'is', null);
|
||||
|
||||
if (spaceId) {
|
||||
query = query.where('pages.spaceId', '=', spaceId);
|
||||
} else {
|
||||
query = query.where(
|
||||
'pages.spaceId',
|
||||
'in',
|
||||
this.spaceMemberRepo.getUserSpaceIdsQuery(userId),
|
||||
);
|
||||
}
|
||||
|
||||
const result = await query.executeTakeFirst();
|
||||
return Number(result?.count ?? 0);
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||
import { dbOrTx } from '@docmost/db/utils';
|
||||
import {
|
||||
InsertablePageTransclusionReference,
|
||||
PageTransclusionReference,
|
||||
} from '@docmost/db/types/entity.types';
|
||||
|
||||
export type TransclusionReferenceKey = {
|
||||
sourcePageId: string;
|
||||
transclusionId: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class PageTransclusionReferencesRepo {
|
||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||
|
||||
async findByReferencePageId(
|
||||
referencePageId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<PageTransclusionReference[]> {
|
||||
return dbOrTx(this.db, trx)
|
||||
.selectFrom('pageTransclusionReferences')
|
||||
.selectAll()
|
||||
.where('referencePageId', '=', referencePageId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
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();
|
||||
return rows.map((r) => r.referencePageId);
|
||||
}
|
||||
|
||||
async insertMany(
|
||||
rows: InsertablePageTransclusionReference[],
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
if (rows.length === 0) return;
|
||||
await dbOrTx(this.db, trx)
|
||||
.insertInto('pageTransclusionReferences')
|
||||
.values(rows)
|
||||
.onConflict((oc) =>
|
||||
oc
|
||||
.columns(['referencePageId', 'sourcePageId', 'transclusionId'])
|
||||
.doNothing(),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async deleteByReferenceAndKeys(
|
||||
referencePageId: string,
|
||||
keys: TransclusionReferenceKey[],
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
if (keys.length === 0) return;
|
||||
await dbOrTx(this.db, trx)
|
||||
.deleteFrom('pageTransclusionReferences')
|
||||
.where('referencePageId', '=', referencePageId)
|
||||
.where((eb) =>
|
||||
eb.or(
|
||||
keys.map((k) =>
|
||||
eb.and([
|
||||
eb('sourcePageId', '=', k.sourcePageId),
|
||||
eb('transclusionId', '=', k.transclusionId),
|
||||
]),
|
||||
),
|
||||
),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async deleteOne(
|
||||
referencePageId: string,
|
||||
sourcePageId: string,
|
||||
transclusionId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
await dbOrTx(this.db, trx)
|
||||
.deleteFrom('pageTransclusionReferences')
|
||||
.where('referencePageId', '=', referencePageId)
|
||||
.where('sourcePageId', '=', sourcePageId)
|
||||
.where('transclusionId', '=', transclusionId)
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||
import { dbOrTx } from '@docmost/db/utils';
|
||||
import {
|
||||
InsertablePageTransclusion,
|
||||
PageTransclusion,
|
||||
UpdatablePageTransclusion,
|
||||
} from '@docmost/db/types/entity.types';
|
||||
|
||||
@Injectable()
|
||||
export class PageTransclusionsRepo {
|
||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||
|
||||
async findByPageId(
|
||||
pageId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<PageTransclusion[]> {
|
||||
return dbOrTx(this.db, trx)
|
||||
.selectFrom('pageTransclusions')
|
||||
.selectAll()
|
||||
.where('pageId', '=', pageId)
|
||||
.orderBy('createdAt', 'asc')
|
||||
.execute();
|
||||
}
|
||||
|
||||
async findByPageAndTransclusion(
|
||||
pageId: string,
|
||||
transclusionId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<PageTransclusion | undefined> {
|
||||
return dbOrTx(this.db, trx)
|
||||
.selectFrom('pageTransclusions')
|
||||
.selectAll()
|
||||
.where('pageId', '=', pageId)
|
||||
.where('transclusionId', '=', transclusionId)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
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) =>
|
||||
eb.and([
|
||||
eb('pageId', '=', k.pageId),
|
||||
eb('transclusionId', '=', k.transclusionId),
|
||||
]),
|
||||
),
|
||||
),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async insert(
|
||||
data: InsertablePageTransclusion,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<PageTransclusion> {
|
||||
return dbOrTx(this.db, trx)
|
||||
.insertInto('pageTransclusions')
|
||||
.values(data)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
async insertMany(
|
||||
data: InsertablePageTransclusion[],
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
if (data.length === 0) return;
|
||||
await dbOrTx(this.db, trx)
|
||||
.insertInto('pageTransclusions')
|
||||
.values(data)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async update(
|
||||
pageId: string,
|
||||
transclusionId: string,
|
||||
data: UpdatablePageTransclusion,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
await dbOrTx(this.db, trx)
|
||||
.updateTable('pageTransclusions')
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where('pageId', '=', pageId)
|
||||
.where('transclusionId', '=', transclusionId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async deleteByPageAndTransclusionIds(
|
||||
pageId: string,
|
||||
transclusionIds: string[],
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
if (transclusionIds.length === 0) return;
|
||||
await dbOrTx(this.db, trx)
|
||||
.deleteFrom('pageTransclusions')
|
||||
.where('pageId', '=', pageId)
|
||||
.where('transclusionId', 'in', transclusionIds)
|
||||
.execute();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||
import { Cache } from 'cache-manager';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||
import { dbOrTx } from '@docmost/db/utils';
|
||||
@@ -17,6 +19,11 @@ import {
|
||||
executeWithCursorPagination,
|
||||
} from '@docmost/db/pagination/cursor-pagination';
|
||||
import { PagePermissionMember } from './types/page-permission.types';
|
||||
import { withCache } from '../../../common/helpers/with-cache';
|
||||
import {
|
||||
CacheKey,
|
||||
PERMISSION_CACHE_TTL_MS,
|
||||
} from '../../../common/helpers/cache-keys';
|
||||
|
||||
export { PagePermissionMember } from './types/page-permission.types';
|
||||
|
||||
@@ -25,6 +32,7 @@ export class PagePermissionRepo {
|
||||
constructor(
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private readonly groupRepo: GroupRepo,
|
||||
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
|
||||
) {}
|
||||
|
||||
async findPageAccessByPageId(
|
||||
@@ -361,40 +369,8 @@ export class PagePermissionRepo {
|
||||
* Check if user can access a page by verifying they have permission on ALL restricted ancestors.
|
||||
*/
|
||||
async canUserAccessPage(userId: string, pageId: string): Promise<boolean> {
|
||||
const deniedAncestor = await this.db
|
||||
.withRecursive('ancestors', (qb) =>
|
||||
qb
|
||||
.selectFrom('pages')
|
||||
.select(['pages.id as ancestorId', 'pages.parentPageId'])
|
||||
.where('pages.id', '=', pageId)
|
||||
.unionAll((eb) =>
|
||||
eb
|
||||
.selectFrom('pages')
|
||||
.innerJoin('ancestors', 'ancestors.parentPageId', 'pages.id')
|
||||
.select(['pages.id as ancestorId', 'pages.parentPageId']),
|
||||
),
|
||||
)
|
||||
.selectFrom('ancestors')
|
||||
.innerJoin('pageAccess', 'pageAccess.pageId', 'ancestors.ancestorId')
|
||||
.leftJoin('pagePermissions', (join) =>
|
||||
join
|
||||
.onRef('pagePermissions.pageAccessId', '=', 'pageAccess.id')
|
||||
.on((eb) =>
|
||||
eb.or([
|
||||
eb('pagePermissions.userId', '=', userId),
|
||||
eb(
|
||||
'pagePermissions.groupId',
|
||||
'in',
|
||||
this.userGroupIdsSubquery(eb, userId),
|
||||
),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.select('pageAccess.pageId')
|
||||
.where('pagePermissions.id', 'is', null)
|
||||
.executeTakeFirst();
|
||||
|
||||
return !deniedAncestor;
|
||||
const { canAccess } = await this.canUserEditPage(userId, pageId);
|
||||
return canAccess;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -412,43 +388,50 @@ export class PagePermissionRepo {
|
||||
canAccess: boolean;
|
||||
canEdit: boolean;
|
||||
}> {
|
||||
const result = await sql<{
|
||||
canAccess: boolean | null;
|
||||
canEdit: boolean | null;
|
||||
}>`
|
||||
WITH RECURSIVE ancestors AS (
|
||||
SELECT id AS ancestor_id, parent_page_id, 0 AS depth
|
||||
FROM pages
|
||||
WHERE id = ${pageId}::uuid
|
||||
UNION ALL
|
||||
SELECT p.id, p.parent_page_id, a.depth + 1
|
||||
FROM pages p
|
||||
JOIN ancestors a ON a.parent_page_id = p.id
|
||||
)
|
||||
SELECT
|
||||
bool_and(pp.id IS NOT NULL) AS "canAccess",
|
||||
-- nearest restricted ancestor's highest role wins (DESC: 'writer' > 'reader', NULLS LAST: no-permission after real roles)
|
||||
(array_agg(pp.role ORDER BY a.depth ASC, pp.role DESC NULLS LAST))[1] = 'writer' AS "canEdit"
|
||||
FROM ancestors a
|
||||
JOIN page_access pa ON pa.page_id = a.ancestor_id
|
||||
LEFT JOIN page_permissions pp ON pp.page_access_id = pa.id
|
||||
AND (
|
||||
pp.user_id = ${userId}::uuid
|
||||
OR pp.group_id IN (
|
||||
SELECT gu.group_id FROM group_users gu WHERE gu.user_id = ${userId}::uuid
|
||||
return withCache(
|
||||
this.cacheManager,
|
||||
CacheKey.PAGE_CAN_EDIT(userId, pageId),
|
||||
PERMISSION_CACHE_TTL_MS,
|
||||
async () => {
|
||||
const result = await sql<{
|
||||
canAccess: boolean | null;
|
||||
canEdit: boolean | null;
|
||||
}>`
|
||||
WITH RECURSIVE ancestors AS (
|
||||
SELECT id AS ancestor_id, parent_page_id, 0 AS depth
|
||||
FROM pages
|
||||
WHERE id = ${pageId}::uuid
|
||||
UNION ALL
|
||||
SELECT p.id, p.parent_page_id, a.depth + 1
|
||||
FROM pages p
|
||||
JOIN ancestors a ON a.parent_page_id = p.id
|
||||
)
|
||||
)
|
||||
`.execute(this.db);
|
||||
SELECT
|
||||
bool_and(pp.id IS NOT NULL) AS "canAccess",
|
||||
-- nearest restricted ancestor's highest role wins (DESC: 'writer' > 'reader', NULLS LAST: no-permission after real roles)
|
||||
(array_agg(pp.role ORDER BY a.depth ASC, pp.role DESC NULLS LAST))[1] = 'writer' AS "canEdit"
|
||||
FROM ancestors a
|
||||
JOIN page_access pa ON pa.page_id = a.ancestor_id
|
||||
LEFT JOIN page_permissions pp ON pp.page_access_id = pa.id
|
||||
AND (
|
||||
pp.user_id = ${userId}::uuid
|
||||
OR pp.group_id IN (
|
||||
SELECT gu.group_id FROM group_users gu WHERE gu.user_id = ${userId}::uuid
|
||||
)
|
||||
)
|
||||
`.execute(this.db);
|
||||
|
||||
const row = result.rows[0];
|
||||
if (!row || row.canAccess === null) {
|
||||
return { hasAnyRestriction: false, canAccess: true, canEdit: true };
|
||||
}
|
||||
return {
|
||||
hasAnyRestriction: true,
|
||||
canAccess: row.canAccess,
|
||||
canEdit: row.canAccess && (row.canEdit ?? false),
|
||||
};
|
||||
const row = result.rows[0];
|
||||
if (!row || row.canAccess === null) {
|
||||
return { hasAnyRestriction: false, canAccess: true, canEdit: true };
|
||||
}
|
||||
return {
|
||||
hasAnyRestriction: true,
|
||||
canAccess: row.canAccess,
|
||||
canEdit: row.canAccess && (row.canEdit ?? false),
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -54,6 +54,7 @@ export class PageRepo {
|
||||
includeCreator?: boolean;
|
||||
includeLastUpdatedBy?: boolean;
|
||||
includeContributors?: boolean;
|
||||
includeDeletedBy?: boolean;
|
||||
includeHasChildren?: boolean;
|
||||
withLock?: boolean;
|
||||
trx?: KyselyTransaction;
|
||||
@@ -83,6 +84,10 @@ export class PageRepo {
|
||||
query = query.select((eb) => this.withContributors(eb));
|
||||
}
|
||||
|
||||
if (opts?.includeDeletedBy) {
|
||||
query = query.select((eb) => this.withDeletedBy(eb));
|
||||
}
|
||||
|
||||
if (opts?.includeSpace) {
|
||||
query = query.select((eb) => this.withSpace(eb));
|
||||
}
|
||||
@@ -100,6 +105,30 @@ export class PageRepo {
|
||||
return query.executeTakeFirst();
|
||||
}
|
||||
|
||||
async findManyByIds(
|
||||
pageIds: string[],
|
||||
opts?: {
|
||||
trx?: KyselyTransaction;
|
||||
workspaceId?: string;
|
||||
},
|
||||
): Promise<Page[]> {
|
||||
if (pageIds.length === 0) return [];
|
||||
const db = dbOrTx(this.db, opts?.trx);
|
||||
|
||||
let query = db
|
||||
.selectFrom('pages')
|
||||
.select(this.baseFields)
|
||||
.where('id', 'in', pageIds);
|
||||
|
||||
if (opts?.workspaceId) {
|
||||
query = query
|
||||
.where('workspaceId', '=', opts.workspaceId)
|
||||
.where('deletedAt', 'is', null);
|
||||
}
|
||||
|
||||
return query.execute();
|
||||
}
|
||||
|
||||
async updatePage(
|
||||
updatablePage: UpdatablePage,
|
||||
pageId: string,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||
import { Cache } from 'cache-manager';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||
import { dbOrTx } from '@docmost/db/utils';
|
||||
@@ -13,6 +15,11 @@ import { MemberInfo, UserSpaceRole } from './types';
|
||||
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
||||
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
|
||||
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
|
||||
import { withCache } from '../../../common/helpers/with-cache';
|
||||
import {
|
||||
CacheKey,
|
||||
PERMISSION_CACHE_TTL_MS,
|
||||
} from '../../../common/helpers/cache-keys';
|
||||
|
||||
@Injectable()
|
||||
export class SpaceMemberRepo {
|
||||
@@ -20,6 +27,7 @@ export class SpaceMemberRepo {
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private readonly groupRepo: GroupRepo,
|
||||
private readonly spaceRepo: SpaceRepo,
|
||||
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
|
||||
) {}
|
||||
|
||||
async insertSpaceMember(
|
||||
@@ -214,25 +222,36 @@ export class SpaceMemberRepo {
|
||||
userId: string,
|
||||
spaceId: string,
|
||||
): Promise<UserSpaceRole[]> {
|
||||
const roles = await this.db
|
||||
.selectFrom('spaceMembers')
|
||||
.select(['userId', 'role'])
|
||||
.where('userId', '=', userId)
|
||||
.where('spaceId', '=', spaceId)
|
||||
.unionAll(
|
||||
this.db
|
||||
return withCache(
|
||||
this.cacheManager,
|
||||
CacheKey.SPACE_ROLES(userId, spaceId),
|
||||
PERMISSION_CACHE_TTL_MS,
|
||||
async () => {
|
||||
const roles = await this.db
|
||||
.selectFrom('spaceMembers')
|
||||
.innerJoin('groupUsers', 'groupUsers.groupId', 'spaceMembers.groupId')
|
||||
.select(['groupUsers.userId', 'spaceMembers.role'])
|
||||
.where('groupUsers.userId', '=', userId)
|
||||
.where('spaceMembers.spaceId', '=', spaceId),
|
||||
)
|
||||
.execute();
|
||||
.select(['userId', 'role'])
|
||||
.where('userId', '=', userId)
|
||||
.where('spaceId', '=', spaceId)
|
||||
.unionAll(
|
||||
this.db
|
||||
.selectFrom('spaceMembers')
|
||||
.innerJoin(
|
||||
'groupUsers',
|
||||
'groupUsers.groupId',
|
||||
'spaceMembers.groupId',
|
||||
)
|
||||
.select(['groupUsers.userId', 'spaceMembers.role'])
|
||||
.where('groupUsers.userId', '=', userId)
|
||||
.where('spaceMembers.spaceId', '=', spaceId),
|
||||
)
|
||||
.execute();
|
||||
|
||||
if (!roles || roles.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return roles;
|
||||
if (!roles || roles.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return roles;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async getUserIdsWithSpaceAccess(
|
||||
|
||||
@@ -44,6 +44,7 @@ export class UserRepo {
|
||||
opts?: {
|
||||
includePassword?: boolean;
|
||||
includeUserMfa?: boolean;
|
||||
includeScimExternalId?: boolean;
|
||||
trx?: KyselyTransaction;
|
||||
},
|
||||
): Promise<User> {
|
||||
@@ -53,6 +54,7 @@ export class UserRepo {
|
||||
.select(this.baseFields)
|
||||
.$if(opts?.includePassword, (qb) => qb.select('password'))
|
||||
.$if(opts?.includeUserMfa, (qb) => qb.select(this.withUserMfa))
|
||||
.$if(opts?.includeScimExternalId, (qb) => qb.select('scimExternalId'))
|
||||
.where('id', '=', userId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.executeTakeFirst();
|
||||
@@ -64,6 +66,7 @@ export class UserRepo {
|
||||
opts?: {
|
||||
includePassword?: boolean;
|
||||
includeUserMfa?: boolean;
|
||||
includeScimExternalId?: boolean;
|
||||
trx?: KyselyTransaction;
|
||||
},
|
||||
): Promise<User> {
|
||||
@@ -73,6 +76,7 @@ export class UserRepo {
|
||||
.select(this.baseFields)
|
||||
.$if(opts?.includePassword, (qb) => qb.select('password'))
|
||||
.$if(opts?.includeUserMfa, (qb) => qb.select(this.withUserMfa))
|
||||
.$if(opts?.includeScimExternalId, (qb) => qb.select('scimExternalId'))
|
||||
.where(sql`LOWER(email)`, '=', sql`LOWER(${email})`)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.executeTakeFirst();
|
||||
|
||||
@@ -34,6 +34,7 @@ export class WorkspaceRepo {
|
||||
'plan',
|
||||
'enforceMfa',
|
||||
'trashRetentionDays',
|
||||
'isScimEnabled',
|
||||
];
|
||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||
|
||||
|
||||
+58
@@ -213,7 +213,9 @@ export interface Groups {
|
||||
description: string | null;
|
||||
id: Generated<string>;
|
||||
isDefault: boolean;
|
||||
isExternal: Generated<boolean>;
|
||||
name: string;
|
||||
scimExternalId: string | null;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
workspaceId: string;
|
||||
}
|
||||
@@ -226,6 +228,25 @@ export interface GroupUsers {
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface PageTransclusionReferences {
|
||||
createdAt: Generated<Timestamp>;
|
||||
transclusionId: string;
|
||||
referencePageId: string;
|
||||
id: Generated<string>;
|
||||
sourcePageId: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface PageTransclusions {
|
||||
content: Json;
|
||||
createdAt: Generated<Timestamp>;
|
||||
transclusionId: string;
|
||||
id: Generated<string>;
|
||||
pageId: string;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface PageHistory {
|
||||
content: Json | null;
|
||||
contributorIds: Generated<string[] | null>;
|
||||
@@ -338,6 +359,7 @@ export interface Users {
|
||||
name: string | null;
|
||||
password: string | null;
|
||||
role: string | null;
|
||||
scimExternalId: string | null;
|
||||
settings: Json | null;
|
||||
timezone: string | null;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
@@ -381,6 +403,7 @@ export interface Workspaces {
|
||||
enforceMfa: Generated<boolean | null>;
|
||||
enforceSso: Generated<boolean>;
|
||||
hostname: string | null;
|
||||
isScimEnabled: Generated<boolean>;
|
||||
id: Generated<string>;
|
||||
licenseKey: string | null;
|
||||
logo: string | null;
|
||||
@@ -410,6 +433,20 @@ export interface Notifications {
|
||||
createdAt: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
export interface ScimTokens {
|
||||
createdAt: Generated<Timestamp>;
|
||||
deletedAt: Timestamp | null;
|
||||
id: Generated<string>;
|
||||
isEnabled: Generated<boolean>;
|
||||
lastUsedAt: Timestamp | null;
|
||||
name: string;
|
||||
tokenHash: string;
|
||||
tokenLastFour: string;
|
||||
creatorId: string | null;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface Watchers {
|
||||
id: Generated<string>;
|
||||
userId: string;
|
||||
@@ -422,6 +459,15 @@ export interface Watchers {
|
||||
createdAt: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
export interface Labels {
|
||||
id: Generated<string>;
|
||||
name: string;
|
||||
type: Generated<string>;
|
||||
workspaceId: string;
|
||||
createdAt: Generated<Timestamp>;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
export interface PageAccess {
|
||||
id: Generated<string>;
|
||||
pageId: string;
|
||||
@@ -433,6 +479,13 @@ export interface PageAccess {
|
||||
updatedAt: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
export interface PageLabels {
|
||||
id: Generated<string>;
|
||||
pageId: string;
|
||||
labelId: string;
|
||||
createdAt: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
export interface PagePermissions {
|
||||
id: Generated<string>;
|
||||
pageAccessId: string;
|
||||
@@ -551,13 +604,18 @@ export interface DB {
|
||||
fileTasks: FileTasks;
|
||||
groups: Groups;
|
||||
groupUsers: GroupUsers;
|
||||
labels: Labels;
|
||||
notifications: Notifications;
|
||||
pageAccess: PageAccess;
|
||||
pageTransclusionReferences: PageTransclusionReferences;
|
||||
pageTransclusions: PageTransclusions;
|
||||
pagePermissions: PagePermissions;
|
||||
pageHistory: PageHistory;
|
||||
pageLabels: PageLabels;
|
||||
pageVerifications: PageVerifications;
|
||||
pageVerifiers: PageVerifiers;
|
||||
pages: Pages;
|
||||
scimTokens: ScimTokens;
|
||||
shares: Shares;
|
||||
spaceMembers: SpaceMembers;
|
||||
spaces: Spaces;
|
||||
|
||||
@@ -5,8 +5,12 @@ import {
|
||||
Attachments,
|
||||
Comments,
|
||||
Groups,
|
||||
Labels,
|
||||
Notifications,
|
||||
PageLabels,
|
||||
PageAccess as _PageAccess,
|
||||
PageTransclusions,
|
||||
PageTransclusionReferences,
|
||||
PagePermissions as _PagePermissions,
|
||||
PageVerifications as _PageVerifications,
|
||||
PageVerifiers as _PageVerifiers,
|
||||
@@ -29,6 +33,7 @@ import {
|
||||
UserMfa as _UserMFA,
|
||||
UserSessions,
|
||||
ApiKeys,
|
||||
ScimTokens,
|
||||
Watchers,
|
||||
Audit as _Audit,
|
||||
Templates,
|
||||
@@ -144,6 +149,18 @@ export type Favorite = Selectable<Favorites>;
|
||||
export type InsertableFavorite = Insertable<Favorites>;
|
||||
export type UpdatableFavorite = Updateable<Omit<Favorites, 'id'>>;
|
||||
|
||||
// Page Transclusion
|
||||
export type PageTransclusion = Selectable<PageTransclusions>;
|
||||
export type InsertablePageTransclusion = Insertable<PageTransclusions>;
|
||||
export type UpdatablePageTransclusion = Updateable<Omit<PageTransclusions, 'id'>>;
|
||||
|
||||
// Page Transclusion Reference
|
||||
export type PageTransclusionReference = Selectable<PageTransclusionReferences>;
|
||||
export type InsertablePageTransclusionReference = Insertable<PageTransclusionReferences>;
|
||||
export type UpdatablePageTransclusionReference = Updateable<
|
||||
Omit<PageTransclusionReferences, 'id'>
|
||||
>;
|
||||
|
||||
// File Task
|
||||
export type FileTask = Selectable<FileTasks>;
|
||||
export type InsertableFileTask = Insertable<FileTasks>;
|
||||
@@ -159,6 +176,11 @@ export type ApiKey = Selectable<ApiKeys>;
|
||||
export type InsertableApiKey = Insertable<ApiKeys>;
|
||||
export type UpdatableApiKey = Updateable<Omit<ApiKeys, 'id'>>;
|
||||
|
||||
// Scim Tokens
|
||||
export type ScimToken = Selectable<ScimTokens>;
|
||||
export type InsertableScimToken = Insertable<ScimTokens>;
|
||||
export type UpdatableScimToken = Updateable<Omit<ScimTokens, 'id'>>;
|
||||
|
||||
// Page Embedding
|
||||
export type PageEmbedding = Selectable<PageEmbeddings>;
|
||||
export type InsertablePageEmbedding = Insertable<PageEmbeddings>;
|
||||
@@ -174,6 +196,15 @@ export type Watcher = Selectable<Watchers>;
|
||||
export type InsertableWatcher = Insertable<Watchers>;
|
||||
export type UpdatableWatcher = Updateable<Omit<Watchers, 'id'>>;
|
||||
|
||||
// Label
|
||||
export type Label = Selectable<Labels>;
|
||||
export type InsertableLabel = Insertable<Labels>;
|
||||
export type UpdatableLabel = Updateable<Omit<Labels, 'id'>>;
|
||||
|
||||
// PageLabel
|
||||
export type PageLabel = Selectable<PageLabels>;
|
||||
export type InsertablePageLabel = Insertable<PageLabels>;
|
||||
|
||||
// Page Access
|
||||
export type PageAccess = Selectable<_PageAccess>;
|
||||
export type InsertablePageAccess = Insertable<_PageAccess>;
|
||||
|
||||
+1
-1
Submodule apps/server/src/ee updated: ae15159b8d...6ebd5205d9
@@ -112,7 +112,10 @@ export class EnvironmentService {
|
||||
}
|
||||
|
||||
getAwsS3ForcePathStyle(): boolean {
|
||||
return this.configService.get<boolean>('AWS_S3_FORCE_PATH_STYLE');
|
||||
const forcePathStyle = this.configService
|
||||
.get<string>('AWS_S3_FORCE_PATH_STYLE', 'false')
|
||||
.toLowerCase();
|
||||
return forcePathStyle === 'true';
|
||||
}
|
||||
|
||||
getAwsS3Url(): string {
|
||||
@@ -131,6 +134,17 @@ export class EnvironmentService {
|
||||
return this.configService.get<string>('MAIL_FROM_NAME', 'Docmost');
|
||||
}
|
||||
|
||||
getMailBlockedRecipientDomains(): string[] {
|
||||
const raw = this.configService.get<string>(
|
||||
'MAIL_BLOCKED_RECIPIENT_DOMAINS',
|
||||
'',
|
||||
);
|
||||
return raw
|
||||
.split(',')
|
||||
.map((d) => d.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
getSmtpHost(): string {
|
||||
return this.configService.get<string>('SMTP_HOST');
|
||||
}
|
||||
@@ -304,4 +318,11 @@ export class EnvironmentService {
|
||||
getClickHouseUrl(): string {
|
||||
return this.configService.get<string>('CLICKHOUSE_URL');
|
||||
}
|
||||
|
||||
getSamlDisableRequestedAuthnContext(): boolean {
|
||||
const disabled = this.configService
|
||||
.get<string>('SAML_DISABLE_REQUESTED_AUTHN_CONTEXT', 'false')
|
||||
.toLowerCase();
|
||||
return disabled === 'true';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,9 +23,12 @@ import {
|
||||
SpaceCaslSubject,
|
||||
} from '../../core/casl/interfaces/space-ability.type';
|
||||
import { FastifyReply } from 'fastify';
|
||||
import { sanitize } from 'sanitize-filename-ts';
|
||||
import { getExportExtension } from './utils';
|
||||
import { getMimeType, getPageTitle } from '../../common/helpers';
|
||||
import {
|
||||
getMimeType,
|
||||
getPageTitle,
|
||||
sanitizeFileName,
|
||||
} from '../../common/helpers';
|
||||
import * as path from 'path';
|
||||
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
|
||||
import {
|
||||
@@ -85,7 +88,9 @@ export class ExportController {
|
||||
|
||||
if (result.type === 'file') {
|
||||
const ext = getExportExtension(dto.format);
|
||||
const fileName = sanitize(page.title || 'untitled') + ext;
|
||||
const fileName =
|
||||
sanitizeFileName(page.title || 'untitled', { preserveSpaces: true }) +
|
||||
ext;
|
||||
const contentType = getMimeType(path.extname(fileName));
|
||||
|
||||
res.headers({
|
||||
@@ -96,7 +101,9 @@ export class ExportController {
|
||||
|
||||
res.send(result.content);
|
||||
} else {
|
||||
const fileName = sanitize(page.title || 'untitled') + '.zip';
|
||||
const fileName =
|
||||
sanitizeFileName(page.title || 'untitled', { preserveSpaces: true }) +
|
||||
'.zip';
|
||||
|
||||
res.headers({
|
||||
'Content-Type': 'application/zip',
|
||||
@@ -144,7 +151,9 @@ export class ExportController {
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Disposition':
|
||||
'attachment; filename="' +
|
||||
encodeURIComponent(sanitize(exportFile.fileName)) +
|
||||
encodeURIComponent(
|
||||
sanitizeFileName(exportFile.fileName, { preserveSpaces: true }),
|
||||
) +
|
||||
'"',
|
||||
});
|
||||
|
||||
|
||||
@@ -39,6 +39,8 @@ import {
|
||||
} from '../../common/helpers/prosemirror/utils';
|
||||
import { htmlToMarkdown } from '@docmost/editor-ext';
|
||||
|
||||
type AllowedAttachment = { id: string; fileName: string; filePath: string };
|
||||
|
||||
@Injectable()
|
||||
export class ExportService {
|
||||
private readonly logger = new Logger(ExportService.name);
|
||||
@@ -272,6 +274,12 @@ export class ExportService {
|
||||
|
||||
computeLocalPath(tree, format, null, '', slugIdToPath);
|
||||
|
||||
// Batch resolve attachments once for the whole export so we only run the
|
||||
// owning-page view check a single time, regardless of page count.
|
||||
const allowedAttachments = includeAttachments
|
||||
? await this.resolveAccessibleAttachments(tree, userId, ignorePermissions)
|
||||
: new Map<string, AllowedAttachment>();
|
||||
|
||||
const stack: { folder: JSZip; parentPageId: string | null }[] = [
|
||||
{ folder: zip, parentPageId: null },
|
||||
];
|
||||
@@ -301,7 +309,7 @@ export class ExportService {
|
||||
);
|
||||
|
||||
if (includeAttachments) {
|
||||
await this.zipAttachments(updatedJsonContent, page.spaceId, folder);
|
||||
await this.zipAttachments(updatedJsonContent, folder, allowedAttachments);
|
||||
updatedJsonContent =
|
||||
updateAttachmentUrlsToLocalPaths(updatedJsonContent);
|
||||
}
|
||||
@@ -347,31 +355,80 @@ export class ExportService {
|
||||
zip.file('docmost-metadata.json', JSON.stringify(metadata, null, 2));
|
||||
}
|
||||
|
||||
async zipAttachments(prosemirrorJson: any, spaceId: string, zip: JSZip) {
|
||||
async zipAttachments(
|
||||
prosemirrorJson: any,
|
||||
zip: JSZip,
|
||||
allowed: Map<string, AllowedAttachment>,
|
||||
) {
|
||||
const attachmentIds = getAttachmentIds(prosemirrorJson);
|
||||
|
||||
if (attachmentIds.length > 0) {
|
||||
const attachments = await this.db
|
||||
.selectFrom('attachments')
|
||||
.select(['id', 'fileName', 'filePath'])
|
||||
.where('id', 'in', attachmentIds)
|
||||
.where('spaceId', '=', spaceId)
|
||||
.execute();
|
||||
await Promise.all(
|
||||
attachmentIds.map(async (id) => {
|
||||
const attachment = allowed.get(id);
|
||||
if (!attachment) return;
|
||||
try {
|
||||
const fileBuffer = await this.storageService.read(
|
||||
attachment.filePath,
|
||||
);
|
||||
const filePath = `/files/${attachment.id}/${attachment.fileName}`;
|
||||
zip.file(filePath, fileBuffer);
|
||||
} catch (err) {
|
||||
this.logger.debug(`Attachment export error ${attachment.id}`, err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
attachments.map(async (attachment) => {
|
||||
try {
|
||||
const fileBuffer = await this.storageService.read(
|
||||
attachment.filePath,
|
||||
);
|
||||
const filePath = `/files/${attachment.id}/${attachment.fileName}`;
|
||||
zip.file(filePath, fileBuffer);
|
||||
} catch (err) {
|
||||
this.logger.debug(`Attachment export error ${attachment.id}`, err);
|
||||
}
|
||||
}),
|
||||
private async resolveAccessibleAttachments(
|
||||
tree: PageExportTree,
|
||||
userId: string | undefined,
|
||||
ignorePermissions: boolean,
|
||||
): Promise<Map<string, AllowedAttachment>> {
|
||||
const allAttachmentIds = new Set<string>();
|
||||
let spaceId: string | undefined;
|
||||
for (const siblings of Object.values(tree)) {
|
||||
for (const page of siblings) {
|
||||
if (!spaceId) spaceId = page.spaceId;
|
||||
for (const id of getAttachmentIds(getProsemirrorContent(page.content))) {
|
||||
allAttachmentIds.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (allAttachmentIds.size === 0 || !spaceId) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const attachments = await this.db
|
||||
.selectFrom('attachments')
|
||||
.select(['id', 'fileName', 'filePath', 'pageId'])
|
||||
.where('id', 'in', [...allAttachmentIds])
|
||||
.where('spaceId', '=', spaceId)
|
||||
.execute();
|
||||
|
||||
let visible = attachments;
|
||||
if (!ignorePermissions && userId) {
|
||||
const ownerPageIds = [
|
||||
...new Set(
|
||||
attachments
|
||||
.map((a) => a.pageId)
|
||||
.filter((id): id is string => !!id),
|
||||
),
|
||||
];
|
||||
const accessible = ownerPageIds.length
|
||||
? await this.pagePermissionRepo.filterAccessiblePageIds({
|
||||
pageIds: ownerPageIds,
|
||||
userId,
|
||||
spaceId,
|
||||
})
|
||||
: [];
|
||||
const accessibleSet = new Set(accessible);
|
||||
visible = attachments.filter(
|
||||
(a) => a.pageId && accessibleSet.has(a.pageId),
|
||||
);
|
||||
}
|
||||
|
||||
return new Map(visible.map((a) => [a.id, a]));
|
||||
}
|
||||
|
||||
async turnPageMentionsToLinks(
|
||||
|
||||
@@ -51,9 +51,9 @@ export class ImportController {
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const validFileExtensions = ['.md', '.html', '.docx'];
|
||||
const validFileExtensions = ['.md', '.html', '.docx', '.pdf'];
|
||||
|
||||
const maxFileSize = bytes('20mb');
|
||||
const maxFileSize = bytes('30mb');
|
||||
|
||||
let file = null;
|
||||
try {
|
||||
@@ -102,6 +102,7 @@ export class ImportController {
|
||||
'.md': 'markdown',
|
||||
'.html': 'html',
|
||||
'.docx': 'docx',
|
||||
'.pdf': 'pdf',
|
||||
};
|
||||
|
||||
if (createdPage) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { MultipartFile } from '@fastify/multipart';
|
||||
import { sanitize } from 'sanitize-filename-ts';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
htmlToJson,
|
||||
@@ -55,8 +54,8 @@ export class ImportService {
|
||||
const file = await filePromise;
|
||||
const fileBuffer = await file.toBuffer();
|
||||
const fileExtension = path.extname(file.filename).toLowerCase();
|
||||
const fileName = sanitize(
|
||||
path.basename(file.filename, fileExtension).slice(0, 255),
|
||||
const fileName = sanitizeFileName(
|
||||
path.basename(file.filename, fileExtension),
|
||||
);
|
||||
const fileContent = fileBuffer.toString();
|
||||
|
||||
@@ -64,7 +63,10 @@ export class ImportService {
|
||||
let createdPage = null;
|
||||
|
||||
// For DOCX, we need the page ID upfront so images can reference it
|
||||
const pageId = fileExtension === '.docx' ? uuid7() : undefined;
|
||||
const pageId =
|
||||
fileExtension === '.docx' || fileExtension === '.pdf'
|
||||
? uuid7()
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
if (fileExtension.endsWith('.md')) {
|
||||
@@ -79,6 +81,14 @@ export class ImportService {
|
||||
pageId,
|
||||
userId,
|
||||
);
|
||||
} else if (fileExtension.endsWith('.pdf')) {
|
||||
prosemirrorState = await this.processPdf(
|
||||
fileBuffer,
|
||||
workspaceId,
|
||||
spaceId,
|
||||
pageId,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = 'Error processing file content';
|
||||
@@ -157,7 +167,7 @@ export class ImportService {
|
||||
let DocxImportModule: any;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
DocxImportModule = require('./../../../ee/docx-import/docx-import.service');
|
||||
DocxImportModule = require('./../../../ee/document-import/docx-import.service');
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
'DOCX import requested but EE module not bundled in this build',
|
||||
@@ -183,6 +193,42 @@ export class ImportService {
|
||||
return this.processHTML(html);
|
||||
}
|
||||
|
||||
async processPdf(
|
||||
fileBuffer: Buffer,
|
||||
workspaceId: string,
|
||||
spaceId: string,
|
||||
pageId: string,
|
||||
userId: string,
|
||||
): Promise<any> {
|
||||
let PdfImportModule: any;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
PdfImportModule = require('./../../../ee/document-import/pdf-import.service');
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
'PDF import requested but EE module not bundled in this build',
|
||||
);
|
||||
throw new BadRequestException(
|
||||
'This feature requires a valid enterprise license.',
|
||||
);
|
||||
}
|
||||
|
||||
const pdfImportService = this.moduleRef.get(
|
||||
PdfImportModule.PdfImportService,
|
||||
{ strict: false },
|
||||
);
|
||||
|
||||
const html = await pdfImportService.convertPdfToHtml(
|
||||
fileBuffer,
|
||||
workspaceId,
|
||||
spaceId,
|
||||
pageId,
|
||||
userId,
|
||||
);
|
||||
|
||||
return this.processHTML(html);
|
||||
}
|
||||
|
||||
async createYdoc(prosemirrorJson: any): Promise<Buffer | null> {
|
||||
if (prosemirrorJson) {
|
||||
// this.logger.debug(`Converting prosemirror json state to ydoc`);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { CheerioAPI, Cheerio } from 'cheerio';
|
||||
|
||||
const DEFAULT_IMPORT_COL_WIDTH_PX = 150;
|
||||
|
||||
/**
|
||||
* Extracts a pixel-integer width from either the `width` attribute or
|
||||
* `style="width: Npx"` on a <col>/<td>/<th>. Returns null when absent,
|
||||
@@ -70,12 +72,23 @@ export function normalizeTableColumnWidths(
|
||||
): void {
|
||||
$root.find('table').each(function () {
|
||||
const table = $(this);
|
||||
const colWidths = deriveColumnWidths($, table);
|
||||
if (!colWidths) return;
|
||||
|
||||
const firstRow = table.find('> tbody > tr, > thead > tr, > tr').first();
|
||||
if (!firstRow.length) return;
|
||||
|
||||
let colWidths = deriveColumnWidths($, table);
|
||||
if (!colWidths) {
|
||||
// No widths anywhere (e.g. markdown-sourced tables). Apply a default
|
||||
// per-column width so the table's intrinsic width can exceed the
|
||||
// editor container, letting .tableWrapper's overflow-x: auto scroll
|
||||
// instead of cramming columns into the available width.
|
||||
let count = 0;
|
||||
firstRow.children('td, th').each(function () {
|
||||
count += parseInt($(this).attr('colspan') || '1', 10) || 1;
|
||||
});
|
||||
if (count === 0) return;
|
||||
colWidths = new Array(count).fill(DEFAULT_IMPORT_COL_WIDTH_PX);
|
||||
}
|
||||
|
||||
let col = 0;
|
||||
firstRow.children('td, th').each(function () {
|
||||
const cell = $(this);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { EnvironmentService } from '../environment/environment.service';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { QueueName, QueueJob } from '../queue/constants';
|
||||
import { Queue } from 'bullmq';
|
||||
import { render } from '@react-email/render';
|
||||
import { render } from 'react-email';
|
||||
|
||||
@Injectable()
|
||||
export class MailService {
|
||||
@@ -17,6 +17,10 @@ export class MailService {
|
||||
) {}
|
||||
|
||||
async sendEmail(message: MailMessage): Promise<void> {
|
||||
if (this.isRecipientBlocked(message.to)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.template) {
|
||||
// in case this method is used directly. we do not send the tsx template from queue
|
||||
message.html = await render(message.template, {
|
||||
@@ -35,6 +39,10 @@ export class MailService {
|
||||
}
|
||||
|
||||
async sendToQueue(message: MailMessage): Promise<void> {
|
||||
if (this.isRecipientBlocked(message.to)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.template) {
|
||||
// transform the React object because it gets lost when sent via the queue
|
||||
message.html = await render(message.template, {
|
||||
@@ -47,4 +55,11 @@ export class MailService {
|
||||
}
|
||||
await this.emailQueue.add(QueueJob.SEND_EMAIL, message);
|
||||
}
|
||||
|
||||
private isRecipientBlocked(to: string): boolean {
|
||||
const blocked = this.environmentService.getMailBlockedRecipientDomains();
|
||||
if (blocked.length === 0) return false;
|
||||
const domain = to?.split('@')[1]?.toLowerCase();
|
||||
return !!domain && blocked.includes(domain);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { resolve, sep } from 'path';
|
||||
import { LocalDriver } from './local.driver';
|
||||
|
||||
type FullPath = (filePath: string) => string;
|
||||
|
||||
describe('LocalDriver._fullPath', () => {
|
||||
const ROOT = resolve('/data/storage');
|
||||
const driver = new LocalDriver({ storagePath: ROOT });
|
||||
const fullPath = ((driver as any)._fullPath as FullPath).bind(driver);
|
||||
|
||||
describe('legitimate inputs (behavior preserved)', () => {
|
||||
it.each([
|
||||
['workspace-id/avatars/uuid.png', `${ROOT}${sep}workspace-id${sep}avatars${sep}uuid.png`],
|
||||
['workspace-id/files/uuid/file.pdf', `${ROOT}${sep}workspace-id${sep}files${sep}uuid${sep}file.pdf`],
|
||||
['a/b/c/d/e.bin', `${ROOT}${sep}a${sep}b${sep}c${sep}d${sep}e.bin`],
|
||||
['', ROOT],
|
||||
['.', ROOT],
|
||||
['./x/y.png', `${ROOT}${sep}x${sep}y.png`],
|
||||
['a//b', `${ROOT}${sep}a${sep}b`],
|
||||
['a/b/../c', `${ROOT}${sep}a${sep}c`],
|
||||
])('resolves %j to %j', (input, expected) => {
|
||||
expect(fullPath(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('traversal rejected', () => {
|
||||
it.each([
|
||||
'../etc/passwd',
|
||||
'../../../etc/passwd',
|
||||
'workspace/../../../etc/passwd',
|
||||
'..',
|
||||
'../..',
|
||||
'a/../../..',
|
||||
])('throws for %j', (input) => {
|
||||
expect(() => fullPath(input)).toThrow('Invalid file path');
|
||||
});
|
||||
});
|
||||
|
||||
describe('absolute path rejected', () => {
|
||||
it.each([
|
||||
'/etc/passwd',
|
||||
'/root/.ssh/id_rsa',
|
||||
sep + 'absolute',
|
||||
])('throws for %j', (input) => {
|
||||
expect(() => fullPath(input)).toThrow('Invalid file path');
|
||||
});
|
||||
});
|
||||
|
||||
describe('prefix-confusion rejected', () => {
|
||||
it('rejects a sibling directory whose name starts with the storage root', () => {
|
||||
const siblingDriver = new LocalDriver({ storagePath: '/data/storage' });
|
||||
const siblingFullPath = ((siblingDriver as any)._fullPath as FullPath).bind(siblingDriver);
|
||||
// Attempt to reach /data/storage-evil/secret by traversal:
|
||||
// resolve('/data/storage', '../storage-evil/secret') === '/data/storage-evil/secret'
|
||||
// Without the `+ sep` guard, a startsWith check would match.
|
||||
expect(() => siblingFullPath('../storage-evil/secret')).toThrow('Invalid file path');
|
||||
});
|
||||
});
|
||||
|
||||
describe('storage root itself', () => {
|
||||
it('accepts the root when input resolves to it', () => {
|
||||
expect(fullPath('')).toBe(ROOT);
|
||||
expect(fullPath('.')).toBe(ROOT);
|
||||
expect(fullPath('a/..')).toBe(ROOT);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
LocalStorageConfig,
|
||||
StorageOption,
|
||||
} from '../interfaces';
|
||||
import { join, dirname } from 'path';
|
||||
import { dirname, resolve, sep } from 'path';
|
||||
import * as fs from 'fs-extra';
|
||||
import { Readable } from 'stream';
|
||||
import { createReadStream, createWriteStream } from 'node:fs';
|
||||
@@ -17,7 +17,12 @@ export class LocalDriver implements StorageDriver {
|
||||
}
|
||||
|
||||
private _fullPath(filePath: string): string {
|
||||
return join(this.config.storagePath, filePath);
|
||||
const storageRoot = resolve(this.config.storagePath);
|
||||
const fullPath = resolve(storageRoot, filePath);
|
||||
if (fullPath !== storageRoot && !fullPath.startsWith(storageRoot + sep)) {
|
||||
throw new Error('Invalid file path');
|
||||
}
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
async upload(filePath: string, file: Buffer | Readable): Promise<void> {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Section, Text } from '@react-email/components';
|
||||
import { Section, Text } from 'react-email';
|
||||
import * as React from 'react';
|
||||
import { content, paragraph } from '../css/styles';
|
||||
import { EmailButton, MailBody } from '../partials/partials';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Section, Text } from '@react-email/components';
|
||||
import { Section, Text } from 'react-email';
|
||||
import * as React from 'react';
|
||||
import { content, paragraph } from '../css/styles';
|
||||
import { EmailButton, MailBody } from '../partials/partials';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Section, Text } from '@react-email/components';
|
||||
import { Section, Text } from 'react-email';
|
||||
import * as React from 'react';
|
||||
import { content, paragraph } from '../css/styles';
|
||||
import { MailBody } from '../partials/partials';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Section, Text } from '@react-email/components';
|
||||
import { Section, Text } from 'react-email';
|
||||
import * as React from 'react';
|
||||
import { content, paragraph } from '../css/styles';
|
||||
import { EmailButton, MailBody } from '../partials/partials';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Section, Text } from '@react-email/components';
|
||||
import { Section, Text } from 'react-email';
|
||||
import * as React from 'react';
|
||||
import { content, paragraph } from '../css/styles';
|
||||
import { EmailButton, MailBody } from '../partials/partials';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Section, Text } from '@react-email/components';
|
||||
import { Section, Text } from 'react-email';
|
||||
import * as React from 'react';
|
||||
import { content, paragraph } from '../css/styles';
|
||||
import { EmailButton, MailBody } from '../partials/partials';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button, Link, Section, Text } from '@react-email/components';
|
||||
import { Button, Link, Section, Text } from 'react-email';
|
||||
import * as React from 'react';
|
||||
import { button, content, paragraph } from '../css/styles';
|
||||
import { MailBody } from '../partials/partials';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Section, Text } from '@react-email/components';
|
||||
import { Section, Text } from 'react-email';
|
||||
import * as React from 'react';
|
||||
import { content, paragraph } from '../css/styles';
|
||||
import { MailBody } from '../partials/partials';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Section, Text } from '@react-email/components';
|
||||
import { Section, Text } from 'react-email';
|
||||
import * as React from 'react';
|
||||
import { content, paragraph } from '../css/styles';
|
||||
import { EmailButton, MailBody } from '../partials/partials';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Section, Text } from '@react-email/components';
|
||||
import { Section, Text } from 'react-email';
|
||||
import * as React from 'react';
|
||||
import { content, paragraph } from '../css/styles';
|
||||
import { EmailButton, MailBody } from '../partials/partials';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Link, Section, Text } from '@react-email/components';
|
||||
import { Link, Section, Text } from 'react-email';
|
||||
import * as React from 'react';
|
||||
import { content, link, paragraph } from '../css/styles';
|
||||
import { getGreetingName, MailBody } from '../partials/partials';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Link, Section, Text } from '@react-email/components';
|
||||
import { Link, Section, Text } from 'react-email';
|
||||
import * as React from 'react';
|
||||
import { content, link, paragraph } from '../css/styles';
|
||||
import { EmailButton, getGreetingName, MailBody } from '../partials/partials';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Section, Text } from '@react-email/components';
|
||||
import { Section, Text } from 'react-email';
|
||||
import * as React from 'react';
|
||||
import { content, paragraph } from '../css/styles';
|
||||
import { EmailButton, MailBody } from '../partials/partials';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Section, Text } from '@react-email/components';
|
||||
import { Section, Text } from 'react-email';
|
||||
import * as React from 'react';
|
||||
import { content, paragraph } from '../css/styles';
|
||||
import { EmailButton, MailBody } from '../partials/partials';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Section, Text } from '@react-email/components';
|
||||
import { Section, Text } from 'react-email';
|
||||
import * as React from 'react';
|
||||
import { content, paragraph } from '../css/styles';
|
||||
import { EmailButton, MailBody } from '../partials/partials';
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
Row,
|
||||
Section,
|
||||
Text,
|
||||
} from '@react-email/components';
|
||||
} from 'react-email';
|
||||
import * as React from 'react';
|
||||
|
||||
interface MailBodyProps {
|
||||
|
||||
@@ -50,6 +50,30 @@ async function bootstrap() {
|
||||
await app.register(fastifyMultipart);
|
||||
await app.register(fastifyCookie);
|
||||
|
||||
app
|
||||
.getHttpAdapter()
|
||||
.getInstance()
|
||||
.addHook('onRequest', (request, _reply, done) => {
|
||||
(request.raw as any).ip = request.ip;
|
||||
done();
|
||||
});
|
||||
|
||||
app
|
||||
.getHttpAdapter()
|
||||
.getInstance()
|
||||
.addContentTypeParser(
|
||||
'application/scim+json',
|
||||
{ parseAs: 'string' },
|
||||
(_, body, done) => {
|
||||
try {
|
||||
const json = JSON.parse(body.toString());
|
||||
done(null, json);
|
||||
} catch (err: any) {
|
||||
done(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app
|
||||
.getHttpAdapter()
|
||||
.getInstance()
|
||||
|
||||
@@ -54,7 +54,7 @@ export class WsService {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.broadcastToAuthorizedUsers(room, client.data.userId, pageId, data);
|
||||
await this.broadcastToAuthorizedUsers(room, client.id, pageId, data);
|
||||
}
|
||||
|
||||
async invalidateSpaceRestrictionCache(spaceId: string): Promise<void> {
|
||||
@@ -115,14 +115,17 @@ export class WsService {
|
||||
|
||||
private async broadcastToAuthorizedUsers(
|
||||
room: string,
|
||||
excludeUserId: string | null,
|
||||
excludeSocketId: string | null,
|
||||
pageId: string,
|
||||
data: any,
|
||||
): Promise<void> {
|
||||
const sockets = await this.server.in(room).fetchSockets();
|
||||
|
||||
const otherSockets = excludeUserId
|
||||
? sockets.filter((s) => s.data.userId !== excludeUserId)
|
||||
// Exclude only the originating socket, not every socket of the originating
|
||||
// user. Excluding by userId silently dropped the originator's other tabs
|
||||
// from receiving restricted-space tree events.
|
||||
const otherSockets = excludeSocketId
|
||||
? sockets.filter((s) => s.id !== excludeSocketId)
|
||||
: sockets;
|
||||
if (otherSockets.length === 0) return;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user