Merge branch 'main' into feat/labels

This commit is contained in:
Philipinho
2026-05-09 17:11:37 +01:00
689 changed files with 54191 additions and 10255 deletions
+92 -70
View File
@@ -1,6 +1,6 @@
{
"name": "server",
"version": "0.25.3",
"version": "0.80.1",
"description": "",
"author": "",
"private": true,
@@ -30,116 +30,129 @@
"test:e2e": "jest --config test/jest-e2e.json"
},
"dependencies": {
"@ai-sdk/google": "^3.0.29",
"@ai-sdk/openai": "^3.0.29",
"@ai-sdk/openai-compatible": "^2.0.30",
"@aws-sdk/client-s3": "3.982.0",
"@aws-sdk/lib-storage": "3.982.0",
"@aws-sdk/s3-request-presigner": "3.982.0",
"@ai-sdk/google": "^3.0.52",
"@ai-sdk/openai": "^3.0.47",
"@ai-sdk/openai-compatible": "^2.0.37",
"@aws-sdk/client-s3": "3.1040.0",
"@aws-sdk/lib-storage": "3.1040.0",
"@aws-sdk/s3-request-presigner": "3.1040.0",
"@clickhouse/client": "^1.18.2",
"@docmost/pdf-inspector": "1.9.4",
"@fastify/cookie": "^11.0.2",
"@fastify/multipart": "^9.4.0",
"@fastify/static": "^9.0.0",
"@langchain/core": "1.1.18",
"@fastify/multipart": "^10.0.0",
"@fastify/static": "^9.1.3",
"@keyv/redis": "^5.1.6",
"@langchain/core": "1.1.39",
"@langchain/textsplitters": "1.0.1",
"@modelcontextprotocol/sdk": "^1.29.0",
"@nest-lab/throttler-storage-redis": "^1.2.0",
"@nestjs-labs/nestjs-ioredis": "^11.0.4",
"@nestjs/bullmq": "^11.0.4",
"@nestjs/common": "^11.1.11",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.1.13",
"@nestjs/cache-manager": "^3.1.0",
"@nestjs/common": "^11.1.19",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.1.19",
"@nestjs/event-emitter": "^3.0.1",
"@nestjs/jwt": "11.0.0",
"@nestjs/mapped-types": "^2.1.0",
"@nestjs/jwt": "11.0.2",
"@nestjs/mapped-types": "^2.1.1",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-fastify": "^11.1.13",
"@nestjs/platform-socket.io": "^11.1.13",
"@nestjs/schedule": "^6.1.0",
"@nestjs/terminus": "^11.0.0",
"@nestjs/websockets": "^11.1.13",
"@nestjs/platform-fastify": "^11.1.19",
"@nestjs/platform-socket.io": "^11.1.19",
"@nestjs/schedule": "^6.1.3",
"@nestjs/terminus": "^11.1.1",
"@nestjs/throttler": "^6.5.0",
"@nestjs/websockets": "^11.1.19",
"@node-saml/passport-saml": "^5.1.0",
"@react-email/components": "1.0.7",
"@react-email/render": "2.0.4",
"@socket.io/redis-adapter": "^8.3.0",
"ai": "^6.0.86",
"ai-sdk-ollama": "^3.7.0",
"ai": "^6.0.134",
"ai-sdk-ollama": "^3.8.1",
"bcrypt": "^6.0.0",
"bullmq": "^5.65.0",
"bowser": "^2.14.1",
"bullmq": "^5.76.0",
"cache-manager": "^7.2.8",
"cheerio": "^1.1.2",
"cheerio": "^1.2.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"class-validator": "^0.15.1",
"cookie": "^1.1.1",
"fs-extra": "^11.3.3",
"happy-dom": "20.1.0",
"ioredis": "^5.4.1",
"fast-bm25": "0.0.5",
"fastify-ip": "^2.0.0",
"fs-extra": "^11.3.4",
"happy-dom": "20.8.9",
"ioredis": "^5.10.1",
"js-tiktoken": "^1.0.21",
"jsonwebtoken": "^9.0.3",
"kysely": "^0.28.2",
"kysely": "^0.28.14",
"kysely-migration-cli": "^0.4.2",
"kysely-postgres-js": "^3.0.0",
"ldapts": "^7.4.0",
"ldapts": "^8.1.7",
"lib0": "^0.2.117",
"mammoth": "^1.11.0",
"mime-types": "^2.1.35",
"msgpackr": "^1.11.8",
"nanoid": "3.3.11",
"nestjs-kysely": "^1.2.0",
"nestjs-pino": "^4.5.0",
"nodemailer": "^7.0.12",
"openid-client": "^5.7.1",
"otpauth": "^9.4.1",
"p-limit": "^6.2.0",
"mammoth": "^1.12.0",
"mime-types": "^3.0.2",
"msgpackr": "^1.11.9",
"nanoid": "5.1.7",
"nestjs-cls": "^6.2.0",
"nestjs-kysely": "^3.1.2",
"nestjs-pino": "^4.6.1",
"nodemailer": "^8.0.5",
"openid-client": "^6.8.2",
"otpauth": "^9.5.0",
"p-limit": "^7.3.0",
"passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1",
"pdfjs-dist": "^5.4.394",
"pg-tsquery": "^8.4.2",
"pgvector": "^0.2.1",
"pino-http": "^11.0.0",
"pino-pretty": "^13.1.3",
"postgres": "^3.4.8",
"postmark": "^4.0.5",
"postmark": "^4.0.7",
"react": "^18.3.1",
"react-email": "6.0.8",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"sanitize-filename-ts": "1.0.2",
"sanitize-filename": "1.6.3",
"scimmy": "1.3.5",
"socket.io": "^4.8.3",
"stripe": "^17.5.0",
"stripe": "^17.7.0",
"tlds": "^1.261.0",
"tmp-promise": "^3.0.3",
"tseep": "^1.3.1",
"typesense": "^2.1.0",
"ws": "^8.19.0",
"yauzl": "^3.2.0"
"typesense": "^3.0.5",
"undici": "7.24.0",
"ws": "^8.20.0",
"yauzl": "^3.2.1",
"zod": "^4.3.6"
},
"devDependencies": {
"@eslint/js": "^9.20.0",
"@nestjs/cli": "^11.0.16",
"@nestjs/schematics": "^11.0.1",
"@nestjs/testing": "^11.0.10",
"@types/bcrypt": "^5.0.2",
"@eslint/js": "^9.28.0",
"@nestjs/cli": "^11.0.21",
"@nestjs/schematics": "^11.0.10",
"@nestjs/testing": "^11.1.19",
"@types/bcrypt": "^6.0.0",
"@types/debounce": "^1.2.4",
"@types/fs-extra": "^11.0.4",
"@types/jest": "^30.0.0",
"@types/mime-types": "^2.1.4",
"@types/node": "^22.13.4",
"@types/nodemailer": "^6.4.17",
"@types/passport-google-oauth20": "^2.0.16",
"@types/mime-types": "^3.0.1",
"@types/node": "^25.5.0",
"@types/nodemailer": "^7.0.11",
"@types/passport-google-oauth20": "^2.0.17",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.3",
"@types/ws": "^8.5.14",
"@types/ws": "^8.18.1",
"@types/yauzl": "^2.10.3",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.0.1",
"globals": "^15.15.0",
"jest": "^30.2.0",
"kysely-codegen": "^0.19.0",
"prettier": "^3.5.1",
"react-email": "5.2.8",
"eslint": "^9.28.0",
"eslint-config-prettier": "^10.1.8",
"globals": "^17.4.0",
"jest": "^30.3.0",
"kysely-codegen": "^0.20.0",
"prettier": "^3.8.1",
"source-map-support": "^0.5.21",
"supertest": "^7.2.2",
"ts-jest": "^29.4.6",
"ts-loader": "^9.5.4",
"ts-loader": "^9.5.7",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.24.1"
"typescript": "^5.9.3",
"typescript-eslint": "^8.57.1"
},
"jest": {
"moduleFileExtensions": [
@@ -150,12 +163,21 @@
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"happy-dom.+\\.js$": ["babel-jest", { "presets": [["@babel/preset-env", { "targets": { "node": "current" } }]] }],
"^.+\\.(t|j)s$": "ts-jest"
},
"transformIgnorePatterns": [
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom)(@|/))"
],
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
"testEnvironment": "node",
"moduleNameMapper": {
"^@docmost/db/(.*)$": "<rootDir>/database/$1",
"^@docmost/transactional/(.*)$": "<rootDir>/integrations/transactional/$1",
"^@docmost/ee/(.*)$": "<rootDir>/ee/$1"
}
}
}
+33 -1
View File
@@ -1,6 +1,9 @@
import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { EnvironmentService } from './integrations/environment/environment.service';
import { AuditActorInterceptor } from './common/interceptors/audit-actor.interceptor';
import { CoreModule } from './core/core.module';
import { EnvironmentModule } from './integrations/environment/environment.module';
import { CollaborationModule } from './collaboration/collaboration.module';
@@ -18,7 +21,12 @@ import { SecurityModule } from './integrations/security/security.module';
import { TelemetryModule } from './integrations/telemetry/telemetry.module';
import { RedisModule } from '@nestjs-labs/nestjs-ioredis';
import { RedisConfigService } from './integrations/redis/redis-config.service';
import { CacheModule } from '@nestjs/cache-manager';
import KeyvRedis from '@keyv/redis';
import { LoggerModule } from './common/logger/logger.module';
import { ClsModule } from 'nestjs-cls';
import { NoopAuditModule } from './integrations/audit/audit.module';
import { ThrottleModule } from './integrations/throttle/throttle.module';
const enterpriseModules = [];
try {
@@ -36,13 +44,30 @@ try {
@Module({
imports: [
ClsModule.forRoot({
global: true,
middleware: { mount: true },
}),
LoggerModule,
NoopAuditModule,
CoreModule,
DatabaseModule,
EnvironmentModule,
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],
}),
CollaborationModule,
WsModule,
QueueModule,
@@ -59,9 +84,16 @@ try {
EventEmitterModule.forRoot(),
SecurityModule,
TelemetryModule,
ThrottleModule,
...enterpriseModules,
],
controllers: [AppController],
providers: [AppService],
providers: [
AppService,
{
provide: APP_INTERCEPTOR,
useClass: AuditActorInterceptor,
},
],
})
export class AppModule {}
@@ -116,7 +116,7 @@ export class CollaborationGateway {
// Forward close events
client.on('close', (code: number, reason: Buffer) => {
this.redisSync!.onSocketClose(socketId, code, reason);
this.redisSync!.onSocketClose(socketId, code, reason.buffer as ArrayBuffer);
});
// Forward pong events for keepalive
@@ -5,6 +5,7 @@ import {
prosemirrorNodeToYElement,
tiptapExtensions,
} from './collaboration.util';
import { setYjsMark, updateYjsMarkAttribute, YjsSelection } from './yjs.util';
import * as Y from 'yjs';
import { User } from '@docmost/db/types/entity.types';
@@ -27,6 +28,53 @@ export class CollaborationHandler {
// const fragment = doc.getXmlFragment('default');
//});
},
setCommentMark: async (
documentName: string,
payload: {
yjsSelection: YjsSelection;
commentId: string;
resolved: boolean;
user: User;
},
) => {
const { yjsSelection, commentId, resolved, user } = payload;
await this.withYdocConnection(
hocuspocus,
documentName,
{ user },
(doc) => {
const fragment = doc.getXmlFragment('default');
setYjsMark(doc, fragment, yjsSelection, 'comment', {
commentId,
resolved,
});
},
);
},
resolveCommentMark: async (
documentName: string,
payload: {
commentId: string;
resolved: boolean;
user: User;
},
) => {
const { commentId, resolved, user } = payload;
await this.withYdocConnection(
hocuspocus,
documentName,
{ user },
(doc) => {
const fragment = doc.getXmlFragment('default');
updateYjsMarkAttribute(
fragment,
'comment',
{ name: 'commentId', value: commentId },
{ resolved },
);
},
);
},
updatePageContent: async (
documentName: string,
payload: {
@@ -58,8 +106,7 @@ export class CollaborationHandler {
} else {
const newContent = prosemirrorJson.content || [];
const yElements = newContent.map(prosemirrorNodeToYElement);
const position =
operation === 'prepend' ? 0 : fragment.length;
const position = operation === 'prepend' ? 0 : fragment.length;
fragment.insert(position, yElements);
}
},
@@ -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);
@@ -24,6 +24,8 @@ import {
CustomTable,
TiptapImage,
TiptapVideo,
TiptapAudio,
TiptapPdf,
TrailingNode,
Attachment,
Drawio,
@@ -32,9 +34,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';
@@ -55,10 +63,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,
@@ -83,6 +92,8 @@ export const tiptapExtensions = [
Youtube,
TiptapImage,
TiptapVideo,
TiptapAudio,
TiptapPdf,
Callout,
Attachment,
CustomCodeBlock,
@@ -91,6 +102,11 @@ export const tiptapExtensions = [
Embed,
Mention,
Subpages,
Columns,
Column,
Status,
TransclusionSource,
TransclusionReference,
] as any;
export function jsonToHtml(tiptapJson: any) {
@@ -133,6 +149,18 @@ export function getPageId(documentName: string) {
return documentName.split('.')[1];
}
export function isEmptyParagraphDoc(tiptapJson: JSONContent): boolean {
if (!tiptapJson || tiptapJson.type !== 'doc') return false;
const content = tiptapJson.content;
if (!Array.isArray(content) || content.length !== 1) return false;
const child = content[0];
if (!child || child.type !== 'paragraph') return false;
return (
!child.content ||
(Array.isArray(child.content) && child.content.length === 0)
);
}
function stripUnknownNodes(
json: JSONContent,
schema: Schema,
@@ -9,8 +9,10 @@ import { TokenService } from '../../core/auth/services/token.service';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { findHighestUserSpaceRole } from '@docmost/db/repos/space/utils';
import { SpaceRole } from '../../common/helpers/types/permission';
import { isUserDisabled } from '../../common/helpers';
import { getPageId } from '../collaboration.util';
import { JwtCollabPayload, JwtType } from '../../core/auth/dto/jwt-payload';
@@ -23,6 +25,7 @@ export class AuthenticationExtension implements Extension {
private userRepo: UserRepo,
private pageRepo: PageRepo,
private readonly spaceMemberRepo: SpaceMemberRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
) {}
async onAuthenticate(data: onAuthenticatePayload) {
@@ -46,13 +49,13 @@ export class AuthenticationExtension implements Extension {
throw new UnauthorizedException();
}
if (user.deactivatedAt || user.deletedAt) {
if (isUserDisabled(user)) {
throw new UnauthorizedException();
}
const page = await this.pageRepo.findById(pageId);
if (!page) {
this.logger.warn(`Page not found: ${pageId}`);
this.logger.debug(`Page not found: ${pageId}`);
throw new NotFoundException('Page not found');
}
@@ -68,9 +71,34 @@ export class AuthenticationExtension implements Extension {
throw new UnauthorizedException();
}
if (userSpaceRole === SpaceRole.READER) {
// Check page-level permissions
const { hasAnyRestriction, canAccess, canEdit } =
await this.pagePermissionRepo.canUserEditPage(user.id, page.id);
if (hasAnyRestriction) {
if (!canAccess) {
this.logger.warn(
`User ${user.id} denied page-level access to page: ${pageId}`,
);
throw new UnauthorizedException();
}
if (!canEdit) {
data.connectionConfig.readOnly = true;
this.logger.debug(
`User ${user.id} granted readonly access to restricted page: ${pageId}`,
);
}
} else {
// No restrictions - use space-level permissions
if (userSpaceRole === SpaceRole.READER) {
data.connectionConfig.readOnly = true;
this.logger.debug(`User granted readonly access to page: ${pageId}`);
}
}
if (page.deletedAt) {
data.connectionConfig.readOnly = true;
this.logger.debug(`User granted readonly access to page: ${pageId}`);
}
this.logger.debug(`Authenticated user ${user.id} on page ${pageId}`);
@@ -18,12 +18,10 @@ import { QueueJob, QueueName } from '../../integrations/queue/constants';
import { Queue } from 'bullmq';
import {
extractMentions,
extractPageMentions,
extractUserMentions,
} from '../../common/helpers/prosemirror/utils';
import { isDeepStrictEqual } from 'node:util';
import {
IPageBacklinkJob,
IPageHistoryJob,
IPageMentionNotificationJob,
} from '../../integrations/queue/constants/queue.interface';
@@ -34,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 {
@@ -43,11 +42,11 @@ export class PersistenceExtension implements Extension {
constructor(
private readonly pageRepo: PageRepo,
@InjectKysely() private readonly db: KyselyDB,
@InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue,
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
@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) {
@@ -137,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']);
@@ -161,21 +164,20 @@ 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);
const mentions = extractMentions(tiptapJson);
const pageMentions = extractPageMentions(mentions);
await this.generalQueue.add(QueueJob.PAGE_BACKLINKS, {
pageId: pageId,
workspaceId: page.workspaceId,
mentions: pageMentions,
} as IPageBacklinkJob);
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, {
@@ -239,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',
);
}
}
}
@@ -1,13 +1,24 @@
import { Logger, OnModuleDestroy } from '@nestjs/common';
import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq';
import { Job } from 'bullmq';
import { InjectQueue } from '@nestjs/bullmq';
import { Job, Queue } from 'bullmq';
import { QueueJob, QueueName } from '../../integrations/queue/constants';
import { IPageHistoryJob } from '../../integrations/queue/constants/queue.interface';
import {
IPageBacklinkJob,
IPageHistoryJob,
IPageUpdateNotificationJob,
} from '../../integrations/queue/constants/queue.interface';
import {
extractMentions,
extractPageMentions,
extractInternalLinkSlugIds,
} from '../../common/helpers/prosemirror/utils';
import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { isDeepStrictEqual } from 'node:util';
import { CollabHistoryService } from '../services/collab-history.service';
import { WatcherService } from '../../core/watcher/watcher.service';
import { isEmptyParagraphDoc } from '../collaboration.util';
@Processor(QueueName.HISTORY_QUEUE)
export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
@@ -18,6 +29,8 @@ export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
private readonly pageRepo: PageRepo,
private readonly collabHistory: CollabHistoryService,
private readonly watcherService: WatcherService,
@InjectQueue(QueueName.NOTIFICATION_QUEUE) private notificationQueue: Queue,
@InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue,
) {
super();
}
@@ -43,12 +56,19 @@ export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
{ includeContent: true },
);
if (!lastHistory && isEmptyParagraphDoc(page.content as any)) {
this.logger.debug(
`Skipping first history for page ${pageId}: empty content`,
);
await this.collabHistory.clearContributors(pageId);
return;
}
if (
!lastHistory ||
!isDeepStrictEqual(lastHistory.content, page.content)
) {
const contributorIds =
await this.collabHistory.popContributors(pageId);
const contributorIds = await this.collabHistory.popContributors(pageId);
try {
await this.watcherService.addPageWatchers(
@@ -61,12 +81,41 @@ export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
await this.pageHistoryRepo.saveHistory(page, { contributorIds });
this.logger.debug(`History created for page: ${pageId}`);
} catch (err) {
await this.collabHistory.addContributors(
pageId,
contributorIds,
);
await this.collabHistory.addContributors(pageId, contributorIds);
throw err;
}
const mentions = extractMentions(page.content);
const pageMentions = extractPageMentions(mentions);
const internalLinkSlugIds = extractInternalLinkSlugIds(page.content);
await this.generalQueue
.add(QueueJob.PAGE_BACKLINKS, {
pageId,
workspaceId: page.workspaceId,
mentions: pageMentions,
internalLinkSlugIds,
} as IPageBacklinkJob)
.catch((err) => {
this.logger.error(
`Failed to queue backlinks for ${pageId}: ${err.message}`,
);
});
if (contributorIds.length > 0 && lastHistory?.content) {
await this.notificationQueue
.add(QueueJob.PAGE_UPDATED, {
pageId,
spaceId: page.spaceId,
workspaceId: page.workspaceId,
actorIds: contributorIds,
} as IPageUpdateNotificationJob)
.catch((err) => {
this.logger.error(
`Failed to queue page update notification for ${pageId}: ${err.message}`,
);
});
}
}
} catch (err) {
throw err;
@@ -11,12 +11,14 @@ import { CollaborationController } from './collaboration.controller';
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';
@Module({
imports: [
LoggerModule,
DatabaseModule,
EnvironmentModule,
CaslModule,
CollaborationModule,
QueueModule,
HealthModule,
+4 -4
View File
@@ -1,7 +1,7 @@
import {
initProseMirrorDoc,
relativePositionToAbsolutePosition,
} from 'y-prosemirror';
} from '@tiptap/y-tiptap';
import * as Y from 'yjs';
import { Document } from '@hocuspocus/server';
import { getSchema } from '@tiptap/core';
@@ -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
}
@@ -0,0 +1,157 @@
export const AuditEvent = {
// Workspace
WORKSPACE_CREATED: 'workspace.created',
WORKSPACE_UPDATED: 'workspace.updated',
WORKSPACE_INVITE_CREATED: 'workspace.invite_created',
WORKSPACE_INVITE_RESENT: 'workspace.invite_resent',
WORKSPACE_INVITE_REVOKED: 'workspace.invite_revoked',
// User
USER_CREATED: 'user.created',
USER_DELETED: 'user.deleted',
USER_LOGIN: 'user.login',
USER_LOGOUT: 'user.logout',
USER_ROLE_CHANGED: 'user.role_changed',
USER_PASSWORD_CHANGED: 'user.password_changed',
USER_PASSWORD_RESET: 'user.password_reset',
USER_UPDATED: 'user.updated',
USER_DEACTIVATED: 'user.deactivated',
USER_ACTIVATED: 'user.activated',
// API Keys
API_KEY_CREATED: 'api_key.created',
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',
SPACE_DELETED: 'space.deleted',
SPACE_MEMBER_ADDED: 'space.member_added',
SPACE_MEMBER_REMOVED: 'space.member_removed',
SPACE_MEMBER_ROLE_CHANGED: 'space.member_role_changed',
// Group
GROUP_CREATED: 'group.created',
GROUP_UPDATED: 'group.updated',
GROUP_DELETED: 'group.deleted',
GROUP_MEMBER_ADDED: 'group.member_added',
GROUP_MEMBER_REMOVED: 'group.member_removed',
// Comment
COMMENT_CREATED: 'comment.created',
COMMENT_DELETED: 'comment.deleted',
// Comment updates / resolve
COMMENT_UPDATED: 'comment.updated',
COMMENT_RESOLVED: 'comment.resolved',
COMMENT_REOPENED: 'comment.reopened',
// Page
PAGE_CREATED: 'page.created',
PAGE_TRASHED: 'page.trashed',
PAGE_DELETED: 'page.deleted',
PAGE_RESTORED: 'page.restored',
PAGE_MOVED_TO_SPACE: 'page.moved_to_space',
PAGE_DUPLICATED: 'page.duplicated',
// Page permission
PAGE_RESTRICTED: 'page.restricted',
PAGE_RESTRICTION_REMOVED: 'page.restriction_removed',
PAGE_PERMISSION_ADDED: 'page.permission_added',
PAGE_PERMISSION_REMOVED: 'page.permission_removed',
// Page verification
PAGE_VERIFICATION_CREATED: 'page.verification_created',
PAGE_VERIFICATION_UPDATED: 'page.verification_updated',
PAGE_VERIFICATION_REMOVED: 'page.verification_removed',
PAGE_VERIFIED: 'page.verified',
PAGE_APPROVAL_REQUESTED: 'page.approval_requested',
PAGE_APPROVAL_REJECTED: 'page.approval_rejected',
PAGE_MARKED_OBSOLETE: 'page.marked_obsolete',
// Share
SHARE_CREATED: 'share.created',
SHARE_DELETED: 'share.deleted',
// Import / Export
PAGE_IMPORTED: 'page.imported',
PAGE_EXPORTED: 'page.exported',
SPACE_EXPORTED: 'space.exported',
// SSO provider management
SSO_PROVIDER_CREATED: 'sso.provider_created',
SSO_PROVIDER_UPDATED: 'sso.provider_updated',
SSO_PROVIDER_DELETED: 'sso.provider_deleted',
// MFA
USER_MFA_ENABLED: 'user.mfa_enabled',
USER_MFA_DISABLED: 'user.mfa_disabled',
USER_MFA_BACKUP_CODE_GENERATED: 'user.mfa_backup_code_generated',
// License
LICENSE_ACTIVATED: 'license.activated',
LICENSE_REMOVED: 'license.removed',
// Attachment
ATTACHMENT_UPLOADED: 'attachment.uploaded',
// ATTACHMENT_DELETED: 'attachment.deleted',
} as const;
export type AuditEventType = (typeof AuditEvent)[keyof typeof AuditEvent];
export const EXCLUDED_AUDIT_EVENTS: Set<string> = new Set([
AuditEvent.PAGE_CREATED,
AuditEvent.PAGE_MOVED_TO_SPACE,
AuditEvent.PAGE_DUPLICATED,
AuditEvent.COMMENT_CREATED,
AuditEvent.COMMENT_UPDATED,
AuditEvent.COMMENT_RESOLVED,
AuditEvent.COMMENT_REOPENED,
AuditEvent.ATTACHMENT_UPLOADED
]);
export const AuditResource = {
WORKSPACE: 'workspace',
USER: 'user',
PAGE: 'page',
SPACE: 'space',
SPACE_MEMBER: 'space_member',
GROUP: 'group',
COMMENT: 'comment',
SHARE: 'share',
API_KEY: 'api_key',
SCIM_TOKEN: 'scim_token',
SSO_PROVIDER: 'sso_provider',
WORKSPACE_INVITATION: 'workspace_invitation',
ATTACHMENT: 'attachment',
LICENSE: 'license',
} as const;
export type AuditResourceType =
(typeof AuditResource)[keyof typeof AuditResource];
export type ActorType = 'user' | 'system' | 'api_key';
export interface AuditLogPayload {
event: AuditEventType;
resourceType: AuditResourceType;
resourceId?: string;
spaceId?: string;
changes?: {
before?: Record<string, any>;
after?: Record<string, any>;
};
metadata?: Record<string, any>;
}
export interface AuditLogData extends AuditLogPayload {
workspaceId: string;
actorId?: string;
actorType: ActorType;
ipAddress?: string;
userAgent?: string;
}
+25
View File
@@ -0,0 +1,25 @@
export const Feature = {
SSO_CUSTOM: 'sso:custom',
SSO_GOOGLE: 'sso:google',
MFA: 'mfa',
API_KEYS: 'api:keys',
COMMENT_RESOLUTION: 'comment:resolution',
PAGE_PERMISSIONS: 'page:permissions',
AI: 'ai',
CONFLUENCE_IMPORT: 'import:confluence',
DOCX_IMPORT: 'import:docx',
PDF_IMPORT: 'import:pdf',
ATTACHMENT_INDEXING: 'attachment:indexing',
SECURITY_SETTINGS: 'security:settings',
MCP: 'mcp',
SCIM: 'scim',
PAGE_VERIFICATION: 'page:verification',
AUDIT_LOGS: 'audit:logs',
RETENTION: 'retention',
SHARING_CONTROLS: 'sharing:controls',
VIEWER_COMMENTS: 'comment:viewer',
TEMPLATES: 'templates',
PDF_EXPORT: 'export:pdf',
} as const;
export type FeatureKey = (typeof Feature)[keyof typeof Feature];
@@ -0,0 +1,3 @@
export const CacheKey = {
LICENSE_VALID: (workspaceId: string) => `license:valid:${workspaceId}`,
};
@@ -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"');
});
});
@@ -7,6 +7,11 @@ import { validate as isValidUUID } from 'uuid';
import { Transform } from '@tiptap/pm/transform';
import { TiptapTransformer } from '@hocuspocus/transformer';
import * as Y from 'yjs';
import {
INTERNAL_LINK_REGEX,
extractPageSlugId,
} from '../../../integrations/export/utils';
import { isAttachmentNode } from './attachment-node-types';
export interface MentionNode {
id: string;
@@ -64,6 +69,27 @@ export function extractPageMentions(mentionList: MentionNode[]): MentionNode[] {
return pageMentionList as MentionNode[];
}
export function extractInternalLinkSlugIds(prosemirrorJson: any): string[] {
const slugIds: string[] = [];
const doc = jsonToNode(prosemirrorJson);
doc.descendants((node: Node) => {
for (const mark of node.marks) {
if (mark.type.name === 'link' && mark.attrs.internal && mark.attrs.href) {
const match = mark.attrs.href.match(INTERNAL_LINK_REGEX);
if (match) {
const slugId = extractPageSlugId(match[5]);
if (slugId && !slugIds.includes(slugId)) {
slugIds.push(slugId);
}
}
}
}
});
return slugIds;
}
export function extractUserMentionIdsFromJson(json: any): string[] {
const userIds: string[] = [];
@@ -97,16 +123,7 @@ export function getProsemirrorContent(content: any) {
);
}
export function isAttachmentNode(nodeType: string) {
const attachmentNodeTypes = [
'attachment',
'image',
'video',
'excalidraw',
'drawio',
];
return attachmentNodeTypes.includes(nodeType);
}
export { isAttachmentNode };
export function getAttachmentIds(prosemirrorJson: any) {
const doc = jsonToNode(prosemirrorJson);
@@ -14,3 +14,12 @@ export enum SpaceVisibility {
OPEN = 'open', // any workspace member can see that it exists and join.
PRIVATE = 'private', // only added space users can see
}
export enum PageAccessLevel {
RESTRICTED = 'restricted', // only specific users/groups can view or edit
}
export enum PagePermissionRole {
READER = 'reader', // can only view content and descendants
WRITER = 'writer', // can edit content, descendants, and add new users to permission
}
Binary file not shown.
+72 -16
View File
@@ -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,16 +110,7 @@ export function extractBearerTokenFromHeader(
request: FastifyRequest,
): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
export function hasLicenseOrEE(opts: {
licenseKey: string;
plan: string;
isCloud: boolean;
}): boolean {
const { licenseKey, plan, isCloud } = opts;
return Boolean(licenseKey) || (isCloud && plan === 'business');
return type?.toLowerCase() === 'bearer' ? token : undefined;
}
/**
@@ -120,6 +133,49 @@ export function normalizePostgresUrl(url: string): string {
return parsed.toString();
}
export function diffAuditTrackedFields(
fields: readonly string[],
dto: Record<string, any>,
before: Record<string, any> | undefined | null,
after: Record<string, any> | undefined | null,
): { before: Record<string, any>; after: Record<string, any> } | null {
const beforeDiff: Record<string, any> = {};
const afterDiff: Record<string, any> = {};
let hasChanges = false;
for (const field of fields) {
if (typeof dto[field] === 'undefined') continue;
const oldVal = JSON.stringify(before?.[field] ?? null);
const newVal = JSON.stringify(after?.[field] ?? null);
if (oldVal !== newVal) {
beforeDiff[field] = before?.[field];
afterDiff[field] = after?.[field];
hasChanges = true;
}
}
return hasChanges ? { before: beforeDiff, after: afterDiff } : null;
}
export function isUserDisabled(user: {
deactivatedAt?: Date | null;
deletedAt?: Date | null;
}): boolean {
return !!(user.deactivatedAt || user.deletedAt);
}
const SENSITIVE_URL_PREFIXES = ['/api/sso/'];
export function redactSensitiveUrl(url: string): string {
if (url && SENSITIVE_URL_PREFIXES.some((prefix) => url.includes(prefix))) {
const qsIndex = url.indexOf('?');
if (qsIndex !== -1) {
return url.substring(0, qsIndex);
}
}
return url;
}
export function createByteCountingStream(source: Readable) {
let bytesRead = 0;
const stream = new Transform({
@@ -0,0 +1,29 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { ClsService } from 'nestjs-cls';
import { AuditContext, AUDIT_CONTEXT_KEY } from '../middlewares/audit-context.middleware';
@Injectable()
export class AuditActorInterceptor implements NestInterceptor {
constructor(private readonly cls: ClsService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const user = request.user?.user;
if (user?.id) {
const auditContext = this.cls.get<AuditContext>(AUDIT_CONTEXT_KEY);
if (auditContext) {
auditContext.actorId = user.id;
this.cls.set(AUDIT_CONTEXT_KEY, auditContext);
}
}
return next.handle();
}
}
+7 -14
View File
@@ -1,5 +1,6 @@
import { Params } from 'nestjs-pino';
import { stdTimeFunctions } from 'pino';
import { redactSensitiveUrl } from '../helpers/utils';
const CONTEXTS_TO_IGNORE = [
'InstanceLoader',
@@ -50,20 +51,12 @@ export function createPinoConfig(): Params {
},
},
serializers: {
req: (req) => {
const forwardedFor = req.headers?.['x-forwarded-for'];
const ip =
req.headers?.['cf-connecting-ip'] ||
(typeof forwardedFor === 'string' ? forwardedFor.split(',')[0]?.trim() : undefined) ||
req.remoteAddress;
return {
method: req.method,
url: req.url,
ip,
userAgent: req.headers?.['user-agent'],
};
},
req: (req) => ({
method: req.method,
url: redactSensitiveUrl(req.url),
ip: req.ip || req.remoteAddress,
userAgent: req.headers?.['user-agent'],
}),
res: (res) => ({
statusCode: res.statusCode,
}),
@@ -0,0 +1,39 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import { FastifyRequest, FastifyReply } from 'fastify';
import { ClsService } from 'nestjs-cls';
export interface AuditContext {
workspaceId: string | null;
actorId: string | null;
actorType: 'user' | 'system' | 'api_key';
ipAddress: string | null;
userAgent: string | null;
}
export const AUDIT_CONTEXT_KEY = 'auditContext';
@Injectable()
export class AuditContextMiddleware implements NestMiddleware {
constructor(private readonly cls: ClsService) {}
use(req: FastifyRequest['raw'], res: FastifyReply['raw'], next: () => void) {
const workspaceId = (req as any).workspaceId ?? null;
const ipAddress = (req as any).ip ?? (req as any).socket?.remoteAddress ?? null;
const userAgent =
(req.headers['user-agent'] as string) ?? null;
const auditContext: AuditContext = {
workspaceId,
actorId: null,
actorType: 'user',
ipAddress,
userAgent,
};
this.cls.set(AUDIT_CONTEXT_KEY, auditContext);
next();
}
}
@@ -0,0 +1,142 @@
import { containsDomain } from './no-urls.validator';
// containsDomain returns true if value contains a domain-like pattern
// The full NoUrls validator also checks for https:// URLs separately
describe('containsDomain', () => {
describe('bare domains with real TLDs — should block', () => {
it.each([
'example.com',
'example.net',
'example.org',
'example.io',
'example.co',
'example.dev',
'example.app',
'example.me',
'example.info',
'example.tech',
'example.aero',
'example.cloud',
'example.museum',
'example.abc',
'example.uk',
'example.de',
'example.fr',
'example.ru',
])('blocks "%s"', (value) => {
expect(containsDomain(value)).toBe(true);
});
});
describe('domains with paths — should block', () => {
it.each([
'example.com/reset',
'example.com/reset-password',
'click example.com/page',
'go to example.net/login',
])('blocks "%s"', (value) => {
expect(containsDomain(value)).toBe(true);
});
});
describe('multi-part domains — should block', () => {
it.each([
'Foo.com.net',
'Foo.com.',
'Foo.mine.net',
'Foo.mine.ne',
'sub.example.com',
'login.example.co.uk',
])('blocks "%s"', (value) => {
expect(containsDomain(value)).toBe(true);
});
});
describe('domain in sentence — should block', () => {
it.each([
'Reset your password at example.com',
'URGENT click example.com/reset',
'Visit example.org for details',
'go to mysite.io now',
])('blocks "%s"', (value) => {
expect(containsDomain(value)).toBe(true);
});
});
describe('case insensitive — should block', () => {
it.each(['EXAMPLE.COM', 'Example.Com', 'example.COM'])('blocks "%s"', (value) => {
expect(containsDomain(value)).toBe(true);
});
});
describe('fake TLDs — should allow', () => {
it.each([
'Foo.mine',
'Foo.blarg',
'Foo.qqq',
'Foo.zz',
'Foo.abcd',
'Foo.abcde',
'Foo.abcdef',
'Foo.abcdefg',
])('allows "%s"', (value) => {
expect(containsDomain(value)).toBe(false);
});
});
describe('too short suffix — should allow', () => {
it.each(['Foo.a', 'Foo.c', 'A.B'])('allows "%s"', (value) => {
expect(containsDomain(value)).toBe(false);
});
});
describe('multi-part with fake TLD — should allow', () => {
it.each(['Foo.mine.', 'Foo.mine.n'])('allows "%s"', (value) => {
expect(containsDomain(value)).toBe(false);
});
});
describe('emails — should allow', () => {
it.each([
'user@example.com',
'admin@company.org',
'test@sub.domain.co.uk',
])('allows "%s"', (value) => {
expect(containsDomain(value)).toBe(false);
});
});
describe('normal names — should allow', () => {
it.each([
'John Smith',
'Dr. Smith',
'A. B. Charlie',
'John',
'Mary Jane',
"O'Brien",
'Jean-Pierre',
'José García',
])('allows "%s"', (value) => {
expect(containsDomain(value)).toBe(false);
});
});
describe('IP addresses — should allow', () => {
it.each(['192.168.1.1', '10.0.0.1', '127.0.0.1'])(
'allows "%s"',
(value) => {
expect(containsDomain(value)).toBe(false);
},
);
});
describe('edge cases — should allow', () => {
it.each(['', ' ', '.', '..', 'hello', '.com', 'a.b'])(
'allows "%s"',
(value) => {
expect(containsDomain(value)).toBe(false);
},
);
});
});
@@ -0,0 +1,42 @@
import { registerDecorator, ValidationOptions } from 'class-validator';
import * as tlds from 'tlds';
const URL_PATTERN = /https?:\/\//i;
const tldSet = new Set(tlds.map((t) => t.toLowerCase()));
export function containsDomain(value: string): boolean {
const tokens = value.split(/\s+/);
for (const token of tokens) {
if (token.includes('@')) continue;
const segments = token.split('.');
for (let i = 1; i < segments.length; i++) {
const suffix = segments[i].replace(/[^\w].*/g, '');
if (segments[i - 1] && suffix && tldSet.has(suffix.toLowerCase())) {
return true;
}
}
}
return false;
}
export function NoUrls(validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
name: 'noUrls',
target: object.constructor,
propertyName,
options: {
message: 'Must not contain URLs or domain names',
...validationOptions,
},
validator: {
validate(value: unknown) {
if (typeof value !== 'string') return true;
if (URL_PATTERN.test(value)) return false;
if (containsDomain(value)) return false;
return true;
},
},
});
};
}
@@ -3,6 +3,7 @@ export enum AttachmentType {
WorkspaceIcon = 'workspace-icon',
SpaceIcon = 'space-icon',
File = 'file',
Chat = 'chat',
}
export const validImageExtensions = ['.jpg', '.png', '.jpeg'];
@@ -15,4 +16,9 @@ export const inlineFileExtensions = [
'.pdf',
'.mp4',
'.mov',
'.mp3',
'.wav',
'.ogg',
'.m4a',
'.webm',
];
@@ -6,6 +6,7 @@ import {
Get,
HttpCode,
HttpStatus,
Inject,
Logger,
NotFoundException,
Param,
@@ -52,7 +53,13 @@ import { EnvironmentService } from '../../integrations/environment/environment.s
import { TokenService } from '../auth/services/token.service';
import { JwtAttachmentPayload, JwtType } from '../auth/dto/jwt-payload';
import * as path from 'path';
import { RemoveIconDto } from './dto/attachment.dto';
import { AttachmentInfoDto, RemoveIconDto } from './dto/attachment.dto';
import { PageAccessService } from '../page/page-access/page-access.service';
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
import {
AUDIT_SERVICE,
IAuditService,
} from '../../integrations/audit/audit.service';
@Controller()
export class AttachmentController {
@@ -67,6 +74,8 @@ export class AttachmentController {
private readonly attachmentRepo: AttachmentRepo,
private readonly environmentService: EnvironmentService,
private readonly tokenService: TokenService,
private readonly pageAccessService: PageAccessService,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
@UseGuards(JwtAuthGuard)
@@ -111,13 +120,7 @@ export class AttachmentController {
throw new NotFoundException('Page not found');
}
const spaceAbility = await this.spaceAbility.createForUser(
user,
page.spaceId,
);
if (spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
await this.pageAccessService.validateCanEdit(page, user);
const spaceId = page.spaceId;
@@ -136,6 +139,18 @@ export class AttachmentController {
attachmentId: attachmentId,
});
this.auditService.log({
event: AuditEvent.ATTACHMENT_UPLOADED,
resourceType: AuditResource.ATTACHMENT,
resourceId: fileResponse?.id ?? attachmentId,
spaceId,
metadata: {
fileName: fileResponse?.fileName,
pageId,
spaceId,
},
});
return res.send(fileResponse);
} catch (err: any) {
if (err?.statusCode === 413) {
@@ -163,22 +178,28 @@ export class AttachmentController {
}
const attachment = await this.attachmentRepo.findById(fileId);
if (
!attachment ||
attachment.workspaceId !== workspace.id ||
!attachment.pageId ||
!attachment.spaceId
) {
if (!attachment || attachment.workspaceId !== workspace.id) {
throw new NotFoundException();
}
const spaceAbility = await this.spaceAbility.createForUser(
user,
attachment.spaceId,
);
if (attachment.aiChatId) {
// Chat-owned attachment: only the user who uploaded (and therefore
// owns the chat, per AttachmentRepo.claimAttachmentsForChat) can
// read it back.
if (attachment.creatorId !== user.id) {
throw new NotFoundException();
}
} else {
if (!attachment.pageId || !attachment.spaceId) {
throw new NotFoundException();
}
if (spaceAbility.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
const page = await this.pageRepo.findById(attachment.pageId);
if (!page) {
throw new NotFoundException();
}
await this.pageAccessService.validateCanView(page, user);
}
try {
@@ -335,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}`;
@@ -355,6 +386,34 @@ export class AttachmentController {
}
}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('files/info')
async getAttachmentInfo(
@Body() dto: AttachmentInfoDto,
@AuthWorkspace() workspace: Workspace,
@AuthUser() user: User,
) {
const attachment = await this.attachmentRepo.findById(dto.attachmentId);
if (
!attachment ||
!attachment.pageId ||
attachment.workspaceId !== workspace.id ||
attachment.type !== AttachmentType.File
) {
throw new NotFoundException('File not found');
}
const page = await this.pageRepo.findById(attachment.pageId);
if (!page) {
throw new NotFoundException('File not found');
}
await this.pageAccessService.validateCanView(page, user);
return attachment;
}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('attachments/remove-icon')
@@ -416,6 +475,10 @@ export class AttachmentController {
const rangeHeader = req.headers.range;
res.header('Accept-Ranges', 'bytes');
res.header(
'Content-Security-Policy',
"base-uri 'none'; object-src 'self'; default-src 'self';",
);
if (!inlineFileExtensions.includes(attachment.fileExt)) {
res.header(
@@ -2,6 +2,7 @@ import { MultipartFile } from '@fastify/multipart';
import * as path from 'path';
import { AttachmentType } from './attachment.constants';
import { sanitizeFileName } from '../../common/helpers';
import { getMimeType } from '../../common/helpers';
export interface PreparedFile {
buffer?: Buffer;
@@ -40,7 +41,7 @@ export async function prepareFile(
fileName,
fileSize,
fileExtension,
mimeType: file.mimetype,
mimeType: getMimeType(file.filename),
multiPartFile: file,
};
} catch (error) {
@@ -70,6 +71,8 @@ export function getAttachmentFolderPath(
return `${workspaceId}/space-logos`;
case AttachmentType.File:
return `${workspaceId}/files`;
case AttachmentType.Chat:
return `${workspaceId}/chat-files`;
default:
return `${workspaceId}/files`;
}
@@ -1,6 +1,12 @@
import { IsEnum, IsIn, IsNotEmpty, IsOptional, IsUUID } from 'class-validator';
import { AttachmentType } from '../attachment.constants';
export class AttachmentInfoDto {
@IsNotEmpty()
@IsUUID()
attachmentId: string;
}
export class RemoveIconDto {
@IsEnum(AttachmentType)
@IsIn([
@@ -28,6 +28,11 @@ export class AttachmentProcessor extends WorkerHost implements OnModuleDestroy {
job.data.pageId,
);
}
if (job.name === QueueJob.DELETE_AI_CHAT_ATTACHMENTS) {
await this.attachmentService.handleDeleteAiChatAttachments(
job.data.aiChatId,
);
}
if (
job.name === QueueJob.ATTACHMENT_INDEX_CONTENT ||
job.name === QueueJob.ATTACHMENT_INDEXING
@@ -70,8 +70,8 @@ export class AttachmentService {
}
if (
existingAttachment.pageId !== pageId &&
existingAttachment.fileExt !== preparedFile.fileExtension &&
existingAttachment.pageId !== pageId ||
existingAttachment.fileExt !== preparedFile.fileExtension ||
existingAttachment.workspaceId !== workspaceId
) {
throw new BadRequestException('File attachment does not match');
@@ -289,6 +289,31 @@ export class AttachmentService {
);
}
async handleDeleteAiChatAttachments(aiChatId: string) {
try {
const attachments = await this.attachmentRepo.findByAiChatId(aiChatId);
if (!attachments || attachments.length === 0) {
return;
}
await Promise.all(
attachments.map(async (attachment) => {
try {
await this.storageService.delete(attachment.filePath);
await this.attachmentRepo.deleteAttachmentById(attachment.id);
} catch (err) {
this.logger.log(
`DeleteAiChatAttachments: failed to delete attachment ${attachment.id}:`,
err,
);
}
}),
);
} catch (err) {
throw err;
}
}
async handleDeleteSpaceAttachments(spaceId: string) {
try {
const attachments = await this.attachmentRepo.findBySpaceId(spaceId);
@@ -1,3 +1,4 @@
export enum UserTokenType {
FORGOT_PASSWORD = 'forgot-password',
EMAIL_VERIFICATION = 'email-verification',
}
+50 -3
View File
@@ -3,13 +3,21 @@ import {
Controller,
HttpCode,
HttpStatus,
Inject,
Post,
Req,
Res,
UseGuards,
Logger,
} from '@nestjs/common';
import { SkipThrottle, ThrottlerGuard } from '@nestjs/throttler';
import {
AI_CHAT_THROTTLER,
AUTH_THROTTLER,
} from '../../integrations/throttle/throttler-names';
import { LoginDto } from './dto/login.dto';
import { AuthService } from './services/auth.service';
import { SessionService } from '../session/session.service';
import { SetupGuard } from './guards/setup.guard';
import { EnvironmentService } from '../../integrations/environment/environment.service';
import { CreateAdminUserDto } from './dto/create-admin-user.dto';
@@ -21,18 +29,27 @@ import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { ForgotPasswordDto } from './dto/forgot-password.dto';
import { PasswordResetDto } from './dto/password-reset.dto';
import { VerifyUserTokenDto } from './dto/verify-user-token.dto';
import { FastifyReply } from 'fastify';
import { FastifyReply, FastifyRequest } from 'fastify';
import { validateSsoEnforcement } from './auth.util';
import { ModuleRef } from '@nestjs/core';
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
import {
AUDIT_SERVICE,
IAuditService,
} from '../../integrations/audit/audit.service';
@SkipThrottle({ [AI_CHAT_THROTTLER]: true })
@UseGuards(ThrottlerGuard)
@Controller('auth')
export class AuthController {
private readonly logger = new Logger(AuthController.name);
constructor(
private authService: AuthService,
private sessionService: SessionService,
private environmentService: EnvironmentService,
private moduleRef: ModuleRef,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
@HttpCode(HttpStatus.OK)
@@ -101,6 +118,7 @@ export class AuthController {
return workspace;
}
@SkipThrottle({ [AUTH_THROTTLER]: true })
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('change-password')
@@ -108,8 +126,15 @@ export class AuthController {
@Body() dto: ChangePasswordDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
@Req() req: FastifyRequest,
) {
return this.authService.changePassword(dto, user.id, workspace.id);
const currentSessionId = (req.raw as any).sessionId;
return this.authService.changePassword(
dto,
user.id,
workspace.id,
currentSessionId,
);
}
@HttpCode(HttpStatus.OK)
@@ -156,6 +181,7 @@ export class AuthController {
return this.authService.verifyUserToken(verifyUserTokenDto, workspace.id);
}
@SkipThrottle({ [AUTH_THROTTLER]: true })
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('collab-token')
@@ -166,16 +192,37 @@ export class AuthController {
return this.authService.getCollabToken(user, workspace.id);
}
@SkipThrottle({ [AUTH_THROTTLER]: true })
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('logout')
async logout(@Res({ passthrough: true }) res: FastifyReply) {
async logout(
@AuthUser() user: User,
@Req() req: FastifyRequest,
@Res({ passthrough: true }) res: FastifyReply,
) {
const sessionId = (req.raw as any).sessionId;
if (sessionId) {
await this.sessionService.revokeSession(
sessionId,
user.id,
user.workspaceId,
);
}
res.clearCookie('authToken');
this.auditService.log({
event: AuditEvent.USER_LOGOUT,
resourceType: AuditResource.USER,
resourceId: user.id,
});
}
setAuthCookie(res: FastifyReply, token: string) {
res.setCookie('authToken', token, {
httpOnly: true,
sameSite: 'lax',
path: '/',
expires: this.environmentService.getCookieExpiresIn(),
secure: this.environmentService.isHttps(),
+32
View File
@@ -1,5 +1,37 @@
import { BadRequestException } from '@nestjs/common';
import { Workspace } from '@docmost/db/types/entity.types';
import { createHmac } from 'node:crypto';
export function computeEmailSignature(
email: string,
workspaceId: string,
appSecret: string,
): string {
return createHmac('sha256', appSecret)
.update(`${email.toLowerCase()}:${workspaceId}`)
.digest('hex');
}
export function throwIfEmailNotVerified(opts: {
isCloud: boolean;
emailVerifiedAt: Date | null;
email: string;
workspaceId: string;
appSecret: string;
}): void {
if (!opts.isCloud || opts.emailVerifiedAt) return;
const emailSignature = computeEmailSignature(
opts.email,
opts.workspaceId,
opts.appSecret,
);
throw new BadRequestException({
message:
'Please verify your email address. Check your inbox for the verification link.',
emailSignature,
});
}
export function validateSsoEnforcement(workspace: Workspace) {
if (workspace.enforceSso) {
@@ -7,11 +7,13 @@ import {
} from 'class-validator';
import { CreateUserDto } from './create-user.dto';
import { Transform, TransformFnParams } from 'class-transformer';
import { NoUrls } from '../../../common/validators/no-urls.validator';
export class CreateAdminUserDto extends CreateUserDto {
@IsNotEmpty()
@MinLength(1)
@MaxLength(50)
@NoUrls()
@Transform(({ value }: TransformFnParams) => value?.trim())
name: string;
@@ -7,12 +7,14 @@ import {
MinLength,
} from 'class-validator';
import { Transform, TransformFnParams } from 'class-transformer';
import { NoUrls } from '../../../common/validators/no-urls.validator';
export class CreateUserDto {
@IsOptional()
@MinLength(1)
@MaxLength(50)
@IsString()
@NoUrls()
@Transform(({ value }: TransformFnParams) => value?.trim())
name: string;
@@ -5,12 +5,15 @@ export enum JwtType {
ATTACHMENT = 'attachment',
MFA_TOKEN = 'mfa_token',
API_KEY = 'api_key',
PDF_RENDER = 'pdf_render',
PDF_EXPORT_DOWNLOAD = 'pdf_export_download',
}
export type JwtPayload = {
sub: string;
email: string;
workspaceId: string;
type: 'access';
sessionId?: string;
};
export type JwtCollabPayload = {
@@ -44,3 +47,15 @@ export type JwtApiKeyPayload = {
apiKeyId: string;
type: 'api_key';
};
export type JwtPdfRenderPayload = {
pageId: string;
workspaceId: string;
type: 'pdf_render';
};
export type JwtPdfExportDownloadPayload = {
fileTaskId: string;
workspaceId: string;
type: 'pdf_export_download';
};
@@ -1,5 +1,6 @@
import {
BadRequestException,
Inject,
Injectable,
NotFoundException,
UnauthorizedException,
@@ -7,14 +8,18 @@ import {
import { LoginDto } from '../dto/login.dto';
import { CreateUserDto } from '../dto/create-user.dto';
import { TokenService } from './token.service';
import { SessionService } from '../../session/session.service';
import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo';
import { SignupService } from './signup.service';
import { CreateAdminUserDto } from '../dto/create-admin-user.dto';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
import {
comparePasswordHash,
hashPassword,
isUserDisabled,
nanoIdGen,
} from '../../../common/helpers';
import { throwIfEmailNotVerified } from '../auth.util';
import { ChangePasswordDto } from '../dto/change-password.dto';
import { MailService } from '../../../integrations/mail/mail.service';
import ChangePasswordEmail from '@docmost/transactional/emails/change-password-email';
@@ -29,17 +34,27 @@ import { InjectKysely } from 'nestjs-kysely';
import { executeTx } from '@docmost/db/utils';
import { VerifyUserTokenDto } from '../dto/verify-user-token.dto';
import { DomainService } from '../../../integrations/environment/domain.service';
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
import {
AUDIT_SERVICE,
IAuditService,
} from '../../../integrations/audit/audit.service';
import { EnvironmentService } from '../../../integrations/environment/environment.service';
@Injectable()
export class AuthService {
constructor(
private signupService: SignupService,
private tokenService: TokenService,
private sessionService: SessionService,
private userSessionRepo: UserSessionRepo,
private userRepo: UserRepo,
private userTokenRepo: UserTokenRepo,
private mailService: MailService,
private domainService: DomainService,
private environmentService: EnvironmentService,
@InjectKysely() private readonly db: KyselyDB,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
async login(loginDto: LoginDto, workspaceId: string) {
@@ -48,7 +63,7 @@ export class AuthService {
});
const errorMessage = 'Email or password does not match';
if (!user || user?.deletedAt) {
if (!user || isUserDisabled(user)) {
throw new UnauthorizedException(errorMessage);
}
@@ -61,22 +76,37 @@ export class AuthService {
throw new UnauthorizedException(errorMessage);
}
throwIfEmailNotVerified({
isCloud: this.environmentService.isCloud(),
emailVerifiedAt: user.emailVerifiedAt,
email: user.email,
workspaceId,
appSecret: this.environmentService.getAppSecret(),
});
user.lastLoginAt = new Date();
await this.userRepo.updateLastLogin(user.id, workspaceId);
return this.tokenService.generateAccessToken(user);
this.auditService.log({
event: AuditEvent.USER_LOGIN,
resourceType: AuditResource.USER,
resourceId: user.id,
metadata: { source: 'password' },
});
return this.sessionService.createSessionAndToken(user);
}
async register(createUserDto: CreateUserDto, workspaceId: string) {
const user = await this.signupService.signup(createUserDto, workspaceId);
return this.tokenService.generateAccessToken(user);
return this.sessionService.createSessionAndToken(user);
}
async setup(createAdminUserDto: CreateAdminUserDto) {
const { workspace, user } =
await this.signupService.initialSetup(createAdminUserDto);
const authToken = await this.tokenService.generateAccessToken(user);
const authToken = await this.sessionService.createSessionAndToken(user);
return { workspace, authToken };
}
@@ -84,12 +114,13 @@ export class AuthService {
dto: ChangePasswordDto,
userId: string,
workspaceId: string,
currentSessionId?: string,
): Promise<void> {
const user = await this.userRepo.findById(userId, workspaceId, {
includePassword: true,
});
if (!user || user.deletedAt) {
if (!user || isUserDisabled(user)) {
throw new NotFoundException('User not found');
}
@@ -112,6 +143,22 @@ export class AuthService {
workspaceId,
);
if (currentSessionId) {
await this.userSessionRepo.deleteAllExceptCurrent(
currentSessionId,
userId,
workspaceId,
);
} else {
await this.userSessionRepo.deleteByUserId(userId, workspaceId);
}
this.auditService.log({
event: AuditEvent.USER_PASSWORD_CHANGED,
resourceType: AuditResource.USER,
resourceId: userId,
});
const emailTemplate = ChangePasswordEmail({ username: user.name });
await this.mailService.sendToQueue({
to: user.email,
@@ -129,22 +176,33 @@ export class AuthService {
workspace.id,
);
if (!user || user.deletedAt) {
if (!user || isUserDisabled(user)) {
return;
}
const token = nanoIdGen(16);
const resetLink = `${this.domainService.getUrl(workspace.hostname)}/password-reset?token=${token}`;
await executeTx(this.db, async (trx) => {
await trx
.deleteFrom('userTokens')
.where('userId', '=', user.id)
.where('type', '=', UserTokenType.FORGOT_PASSWORD)
.execute();
await this.userTokenRepo.insertUserToken({
token: token,
userId: user.id,
workspaceId: user.workspaceId,
expiresAt: new Date(new Date().getTime() + 60 * 60 * 1000), // 1 hour
type: UserTokenType.FORGOT_PASSWORD,
await this.userTokenRepo.insertUserToken(
{
token,
userId: user.id,
workspaceId: user.workspaceId,
expiresAt: new Date(Date.now() + 30 * 60 * 1000), // 30 minutes
type: UserTokenType.FORGOT_PASSWORD,
},
{ trx },
);
});
const resetLink = `${this.domainService.getUrl(workspace.hostname)}/password-reset?token=${token}`;
const emailTemplate = ForgotPasswordEmail({
username: user.name,
resetLink: resetLink,
@@ -177,7 +235,7 @@ export class AuthService {
const user = await this.userRepo.findById(userToken.userId, workspace.id, {
includeUserMfa: true,
});
if (!user || user.deletedAt) {
if (!user || isUserDisabled(user)) {
throw new NotFoundException('User not found');
}
@@ -201,6 +259,15 @@ export class AuthService {
.execute();
});
await this.userSessionRepo.deleteByUserId(user.id, workspace.id);
this.auditService.setActorId(user.id);
this.auditService.log({
event: AuditEvent.USER_PASSWORD_RESET,
resourceType: AuditResource.USER,
resourceId: user.id,
});
const emailTemplate = ChangePasswordEmail({ username: user.name });
await this.mailService.sendToQueue({
to: user.email,
@@ -208,6 +275,14 @@ export class AuthService {
template: emailTemplate,
});
if (this.environmentService.isCloud() && !user.emailVerifiedAt) {
await this.userRepo.updateUser(
{ emailVerifiedAt: new Date() },
user.id,
workspace.id,
);
}
// Check if user has MFA enabled or workspace enforces MFA
const userHasMfa = user?.['mfa']?.isEnabled || false;
const workspaceEnforcesMfa = workspace.enforceMfa || false;
@@ -218,7 +293,7 @@ export class AuthService {
};
}
const authToken = await this.tokenService.generateAccessToken(user);
const authToken = await this.sessionService.createSessionAndToken(user);
return { authToken };
}
@@ -1,4 +1,4 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { CreateUserDto } from '../dto/create-user.dto';
import { WorkspaceService } from '../../workspace/services/workspace.service';
import { CreateWorkspaceDto } from '../../workspace/dto/create-workspace.dto';
@@ -10,6 +10,11 @@ import { InjectKysely } from 'nestjs-kysely';
import { User, Workspace } from '@docmost/db/types/entity.types';
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
import { UserRole } from '../../../common/helpers/types/permission';
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
import {
AUDIT_SERVICE,
IAuditService,
} from '../../../integrations/audit/audit.service';
@Injectable()
export class SignupService {
@@ -18,6 +23,7 @@ export class SignupService {
private workspaceService: WorkspaceService,
private groupUserRepo: GroupUserRepo,
@InjectKysely() private readonly db: KyselyDB,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
async signup(
@@ -36,7 +42,7 @@ export class SignupService {
);
}
return await executeTx(
const user = await executeTx(
this.db,
async (trx) => {
// create user
@@ -66,6 +72,24 @@ export class SignupService {
},
trx,
);
this.auditService.log({
event: AuditEvent.USER_CREATED,
resourceType: AuditResource.USER,
resourceId: user.id,
changes: {
after: {
name: user.name,
email: user.email,
role: user.role,
},
},
metadata: {
source: 'signup',
},
});
return user;
}
async initialSetup(
@@ -4,6 +4,7 @@ import {
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import type { StringValue } from 'ms';
import { EnvironmentService } from '../../../integrations/environment/environment.service';
import {
JwtApiKeyPayload,
@@ -12,9 +13,12 @@ import {
JwtExchangePayload,
JwtMfaTokenPayload,
JwtPayload,
JwtPdfExportDownloadPayload,
JwtPdfRenderPayload,
JwtType,
} from '../dto/jwt-payload';
import { User } from '@docmost/db/types/entity.types';
import { isUserDisabled } from '../../../common/helpers';
@Injectable()
export class TokenService {
@@ -23,8 +27,8 @@ export class TokenService {
private environmentService: EnvironmentService,
) {}
async generateAccessToken(user: User): Promise<string> {
if (user.deactivatedAt || user.deletedAt) {
async generateAccessToken(user: User, sessionId: string): Promise<string> {
if (isUserDisabled(user)) {
throw new ForbiddenException();
}
@@ -33,12 +37,13 @@ export class TokenService {
email: user.email,
workspaceId: user.workspaceId,
type: JwtType.ACCESS,
sessionId,
};
return this.jwtService.sign(payload);
}
async generateCollabToken(user: User, workspaceId: string): Promise<string> {
if (user.deactivatedAt || user.deletedAt) {
if (isUserDisabled(user)) {
throw new ForbiddenException();
}
@@ -79,7 +84,7 @@ export class TokenService {
}
async generateMfaToken(user: User, workspaceId: string): Promise<string> {
if (user.deactivatedAt || user.deletedAt) {
if (isUserDisabled(user)) {
throw new ForbiddenException();
}
@@ -95,10 +100,10 @@ export class TokenService {
apiKeyId: string;
user: User;
workspaceId: string;
expiresIn?: string | number;
expiresIn?: StringValue | number;
}): Promise<string> {
const { apiKeyId, user, workspaceId, expiresIn } = opts;
if (user.deactivatedAt || user.deletedAt) {
if (isUserDisabled(user)) {
throw new ForbiddenException();
}
@@ -112,6 +117,30 @@ export class TokenService {
return this.jwtService.sign(payload, expiresIn ? { expiresIn } : {});
}
async generatePdfRenderToken(
pageId: string,
workspaceId: string,
): Promise<string> {
const payload: JwtPdfRenderPayload = {
pageId,
workspaceId,
type: JwtType.PDF_RENDER,
};
return this.jwtService.sign(payload, { expiresIn: '60s' });
}
async generatePdfExportDownloadToken(
fileTaskId: string,
workspaceId: string,
): Promise<string> {
const payload: JwtPdfExportDownloadPayload = {
fileTaskId,
workspaceId,
type: JwtType.PDF_EXPORT_DOWNLOAD,
};
return this.jwtService.sign(payload, { expiresIn: '1h' });
}
async verifyJwt(token: string, tokenType: string) {
const payload = await this.jwtService.verifyAsync(token, {
secret: this.environmentService.getAppSecret(),
@@ -5,8 +5,10 @@ import { EnvironmentService } from '../../../integrations/environment/environmen
import { JwtApiKeyPayload, JwtPayload, JwtType } from '../dto/jwt-payload';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo';
import { SessionActivityService } from '../../session/session-activity.service';
import { FastifyRequest } from 'fastify';
import { extractBearerTokenFromHeader } from '../../../common/helpers';
import { extractBearerTokenFromHeader, isUserDisabled } from '../../../common/helpers';
import { ModuleRef } from '@nestjs/core';
@Injectable()
@@ -16,6 +18,8 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(
private userRepo: UserRepo,
private workspaceRepo: WorkspaceRepo,
private userSessionRepo: UserSessionRepo,
private sessionActivityService: SessionActivityService,
private readonly environmentService: EnvironmentService,
private moduleRef: ModuleRef,
) {
@@ -53,10 +57,20 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
}
const user = await this.userRepo.findById(payload.sub, payload.workspaceId);
if (!user || user.deactivatedAt || user.deletedAt) {
if (!user || isUserDisabled(user)) {
throw new UnauthorizedException();
}
if ((payload as JwtPayload).sessionId) {
const sessionId = (payload as JwtPayload).sessionId;
const session = await this.userSessionRepo.findActiveById(sessionId);
if (!session || session.userId !== payload.sub || session.workspaceId !== payload.workspaceId) {
throw new UnauthorizedException();
}
req.raw.sessionId = sessionId;
this.sessionActivityService.trackActivity(sessionId, payload.sub, payload.workspaceId);
}
return { user, workspace };
}
+2 -1
View File
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import type { StringValue } from 'ms';
import { EnvironmentService } from '../../integrations/environment/environment.service';
import { TokenService } from './services/token.service';
@@ -10,7 +11,7 @@ import { TokenService } from './services/token.service';
return {
secret: environmentService.getAppSecret(),
signOptions: {
expiresIn: environmentService.getJwtTokenExpiresIn(),
expiresIn: environmentService.getJwtTokenExpiresIn() as StringValue,
issuer: 'Docmost',
},
};
@@ -41,6 +41,7 @@ function buildWorkspaceOwnerAbility() {
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Member);
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment);
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.API);
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Audit);
return build();
}
@@ -12,6 +12,7 @@ export enum WorkspaceCaslSubject {
Group = 'group',
Attachment = 'attachment',
API = 'api_key',
Audit = 'audit',
}
export type IWorkspaceAbility =
@@ -20,4 +21,5 @@ export type IWorkspaceAbility =
| [WorkspaceCaslAction, WorkspaceCaslSubject.Space]
| [WorkspaceCaslAction, WorkspaceCaslSubject.Group]
| [WorkspaceCaslAction, WorkspaceCaslSubject.Attachment]
| [WorkspaceCaslAction, WorkspaceCaslSubject.API];
| [WorkspaceCaslAction, WorkspaceCaslSubject.API]
| [WorkspaceCaslAction, WorkspaceCaslSubject.Audit];
@@ -5,6 +5,7 @@ import {
HttpCode,
HttpStatus,
UseGuards,
Inject,
NotFoundException,
ForbiddenException,
} from '@nestjs/common';
@@ -24,6 +25,13 @@ import {
SpaceCaslSubject,
} from '../casl/interfaces/space-ability.type';
import { CommentRepo } from '@docmost/db/repos/comment/comment.repo';
import { PageAccessService } from '../page/page-access/page-access.service';
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
import {
AUDIT_SERVICE,
IAuditService,
} from '../../integrations/audit/audit.service';
import { WsService } from '../../ws/ws.service';
@UseGuards(JwtAuthGuard)
@Controller('comments')
@@ -33,6 +41,9 @@ export class CommentController {
private readonly commentRepo: CommentRepo,
private readonly pageRepo: PageRepo,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly pageAccessService: PageAccessService,
private readonly wsService: WsService,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
@HttpCode(HttpStatus.OK)
@@ -47,19 +58,28 @@ export class CommentController {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
await this.pageAccessService.validateCanComment(page, user, workspace.id);
return this.commentService.create(
const comment = await this.commentService.create(
{
userId: user.id,
page,
workspaceId: workspace.id,
user,
},
createCommentDto,
);
this.auditService.log({
event: AuditEvent.COMMENT_CREATED,
resourceType: AuditResource.COMMENT,
resourceId: comment.id,
spaceId: page.spaceId,
metadata: {
pageId: page.id,
},
});
return comment;
}
@HttpCode(HttpStatus.OK)
@@ -75,10 +95,8 @@ export class CommentController {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
await this.pageAccessService.validateCanView(page, user);
return this.commentService.findByPageId(page.id, pagination);
}
@@ -90,88 +108,89 @@ export class CommentController {
throw new NotFoundException('Comment not found');
}
const ability = await this.spaceAbility.createForUser(
user,
comment.spaceId,
);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
const page = await this.pageRepo.findById(comment.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
await this.pageAccessService.validateCanView(page, user);
return comment;
}
@HttpCode(HttpStatus.OK)
@Post('update')
async update(@Body() dto: UpdateCommentDto, @AuthUser() user: User) {
const comment = await this.commentRepo.findById(dto.commentId);
async update(@Body() dto: UpdateCommentDto, @AuthUser() user: User, @AuthWorkspace() workspace: Workspace) {
const comment = await this.commentRepo.findById(dto.commentId, {
includeCreator: true,
includeResolvedBy: true,
});
if (!comment) {
throw new NotFoundException('Comment not found');
}
const ability = await this.spaceAbility.createForUser(
user,
comment.spaceId,
);
// must be a space member with edit permission
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
throw new ForbiddenException(
'You must have space edit permission to edit comments',
);
const page = await this.pageRepo.findById(comment.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
await this.pageAccessService.validateCanComment(page, user, workspace.id);
return this.commentService.update(comment, dto, user);
}
@HttpCode(HttpStatus.OK)
@Post('delete')
async delete(@Body() input: CommentIdDto, @AuthUser() user: User) {
async delete(@Body() input: CommentIdDto, @AuthUser() user: User, @AuthWorkspace() workspace: Workspace) {
const comment = await this.commentRepo.findById(input.commentId);
if (!comment) {
throw new NotFoundException('Comment not found');
}
const ability = await this.spaceAbility.createForUser(
user,
comment.spaceId,
);
// must be a space member with edit permission
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
const page = await this.pageRepo.findById(comment.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
await this.pageAccessService.validateCanComment(page, user, workspace.id);
// Check if user is the comment owner
const isOwner = comment.creatorId === user.id;
if (isOwner) {
/*
// Check if comment has children from other users
const hasChildrenFromOthers =
await this.commentRepo.hasChildrenFromOtherUsers(comment.id, user.id);
await this.commentRepo.deleteComment(comment.id);
} else {
const ability = await this.spaceAbility.createForUser(
user,
comment.spaceId,
);
// Owner can delete if no children from other users
if (!hasChildrenFromOthers) {
await this.commentRepo.deleteComment(comment.id);
return;
}
// If has children from others, only space admin can delete
// Space admin can delete any comment
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) {
throw new ForbiddenException(
'Only space admins can delete comments with replies from other users',
'You can only delete your own comments',
);
}*/
}
await this.commentRepo.deleteComment(comment.id);
return;
}
// Space admin can delete any comment
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) {
throw new ForbiddenException(
'You can only delete your own comments or must be a space admin',
);
}
await this.commentRepo.deleteComment(comment.id);
this.wsService.emitCommentEvent(comment.spaceId, comment.pageId, {
operation: 'commentDeleted',
pageId: comment.pageId,
commentId: comment.id,
});
this.auditService.log({
event: AuditEvent.COMMENT_DELETED,
resourceType: AuditResource.COMMENT,
resourceId: comment.id,
spaceId: comment.spaceId,
changes: {
before: {
pageId: comment.pageId,
creatorId: comment.creatorId,
},
},
});
}
}
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { CommentService } from './comment.service';
import { CommentController } from './comment.controller';
import { CollaborationModule } from '../../collaboration/collaboration.module';
@Module({
imports: [CollaborationModule],
controllers: [CommentController],
providers: [CommentService],
exports: [CommentService],
@@ -7,7 +7,8 @@ import {
} from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { CreateCommentDto } from './dto/create-comment.dto';
import { CreateCommentDto, yjsSelectionSchema } from './dto/create-comment.dto';
import { CollaborationGateway } from '../../collaboration/collaboration.gateway';
import { UpdateCommentDto } from './dto/update-comment.dto';
import { CommentRepo } from '@docmost/db/repos/comment/comment.repo';
import { Comment, Page, User } from '@docmost/db/types/entity.types';
@@ -17,6 +18,7 @@ import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination
import { QueueJob, QueueName } from '../../integrations/queue/constants';
import { extractUserMentionIdsFromJson } from '../../common/helpers/prosemirror/utils';
import { ICommentNotificationJob } from '../../integrations/queue/constants/queue.interface';
import { WsService } from '../../ws/ws.service';
@Injectable()
export class CommentService {
@@ -25,6 +27,8 @@ export class CommentService {
constructor(
private commentRepo: CommentRepo,
private pageRepo: PageRepo,
private wsService: WsService,
private collaborationGateway: CollaborationGateway,
@InjectQueue(QueueName.GENERAL_QUEUE)
private generalQueue: Queue,
@InjectQueue(QueueName.NOTIFICATION_QUEUE)
@@ -43,10 +47,10 @@ export class CommentService {
}
async create(
opts: { userId: string; page: Page; workspaceId: string },
opts: { page: Page; workspaceId: string; user: User },
createCommentDto: CreateCommentDto,
) {
const { userId, page, workspaceId } = opts;
const { page, workspaceId, user } = opts;
const commentContent = JSON.parse(createCommentDto.content);
if (createCommentDto.parentCommentId) {
@@ -63,20 +67,53 @@ export class CommentService {
}
}
const comment = await this.commentRepo.insertComment({
const inserted = await this.commentRepo.insertComment({
pageId: page.id,
content: commentContent,
selection: createCommentDto?.selection?.substring(0, 250),
type: 'inline',
selection: createCommentDto?.selection?.substring(0, 250) ?? null,
type: createCommentDto.type ?? 'page',
parentCommentId: createCommentDto?.parentCommentId,
creatorId: userId,
creatorId: user.id,
workspaceId: workspaceId,
spaceId: page.spaceId,
});
if (createCommentDto.yjsSelection) {
const parsed = yjsSelectionSchema.safeParse(createCommentDto.yjsSelection);
if (!parsed.success) {
this.logger.warn(
`Invalid yjsSelection for comment ${inserted.id}: ${parsed.error.message}`,
);
} else {
const documentName = `page.${page.id}`;
try {
await this.collaborationGateway.handleYjsEvent(
'setCommentMark',
documentName,
{
yjsSelection: parsed.data,
commentId: inserted.id,
resolved: false,
user,
},
);
} catch (error) {
this.logger.warn(
`Failed to apply comment mark for comment ${inserted.id}, comment saved without inline highlight`,
error,
);
}
}
}
const comment = await this.commentRepo.findById(inserted.id, {
includeCreator: true,
includeResolvedBy: true,
});
this.generalQueue
.add(QueueJob.ADD_PAGE_WATCHERS, {
userIds: [userId],
userIds: [user.id],
pageId: page.id,
spaceId: page.spaceId,
workspaceId,
@@ -94,11 +131,17 @@ export class CommentService {
page.id,
page.spaceId,
workspaceId,
userId,
user.id,
!isReply,
createCommentDto.parentCommentId,
);
this.wsService.emitCommentEvent(page.spaceId, page.id, {
operation: 'commentCreated',
pageId: page.id,
comment,
});
return comment;
}
@@ -154,6 +197,12 @@ export class CommentService {
comment.editedAt = editedAt;
comment.updatedAt = editedAt;
this.wsService.emitCommentEvent(comment.spaceId, comment.pageId, {
operation: 'commentUpdated',
pageId: comment.pageId,
comment,
});
return comment;
}
@@ -1,4 +1,22 @@
import { IsJSON, IsOptional, IsString, IsUUID } from 'class-validator';
import { IsIn, IsJSON, IsObject, IsOptional, IsString, IsUUID } from 'class-validator';
import { z } from 'zod';
const yjsIdSchema = z.object({
client: z.number().int().nonnegative(),
clock: z.number().int().nonnegative(),
});
const yjsRelativePositionSchema = z.object({
type: yjsIdSchema,
tname: z.string().nullable(),
item: yjsIdSchema.nullable(),
assoc: z.number().int(),
});
export const yjsSelectionSchema = z.object({
anchor: yjsRelativePositionSchema,
head: yjsRelativePositionSchema,
});
export class CreateCommentDto {
@IsString()
@@ -11,7 +29,18 @@ export class CreateCommentDto {
@IsString()
selection: string;
@IsOptional()
@IsIn(['inline', 'page'])
type: string;
@IsOptional()
@IsUUID()
parentCommentId: string;
@IsOptional()
@IsObject()
yjsSelection?: {
anchor: any;
head: any;
};
}
+21 -6
View File
@@ -14,11 +14,16 @@ import { SearchModule } from './search/search.module';
import { SpaceModule } from './space/space.module';
import { GroupModule } from './group/group.module';
import { CaslModule } from './casl/casl.module';
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';
import { SessionModule } from './session/session.module';
import { ClsMiddleware } from 'nestjs-cls';
@Module({
imports: [
@@ -28,26 +33,36 @@ import { WatcherModule } from './watcher/watcher.module';
PageModule,
AttachmentModule,
CommentModule,
FavoriteModule,
SearchModule,
SpaceModule,
GroupModule,
CaslModule,
PageAccessModule,
ShareModule,
LabelModule,
NotificationModule,
WatcherModule,
SessionModule,
],
})
export class CoreModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
const excludedRoutes = [
{ path: 'auth/setup', method: RequestMethod.POST },
{ path: 'health', method: RequestMethod.GET },
{ path: 'health/live', method: RequestMethod.GET },
{ path: 'billing/stripe/webhook', method: RequestMethod.POST },
];
consumer
.apply(DomainMiddleware)
.exclude(
{ path: 'auth/setup', method: RequestMethod.POST },
{ path: 'health', method: RequestMethod.GET },
{ path: 'health/live', method: RequestMethod.GET },
{ path: 'billing/stripe/webhook', method: RequestMethod.POST },
)
.exclude(...excludedRoutes)
.forRoutes('*');
consumer
.apply(AuditContextMiddleware)
.exclude(...excludedRoutes)
.forRoutes('*');
}
}
@@ -0,0 +1,12 @@
import { IsIn, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
export class FavoriteIdsDto {
@IsString()
@IsNotEmpty()
@IsIn(['page', 'space', 'template'])
type: 'page' | 'space' | 'template';
@IsOptional()
@IsUUID()
spaceId?: string;
}
@@ -0,0 +1,28 @@
import {
IsIn,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
export class AddFavoriteDto {
@IsString()
@IsNotEmpty()
@IsIn(['page', 'space', 'template'])
type: 'page' | 'space' | 'template';
@IsOptional()
@IsUUID()
pageId?: string;
@IsOptional()
@IsUUID()
spaceId?: string;
@IsOptional()
@IsUUID()
templateId?: string;
}
export class RemoveFavoriteDto extends AddFavoriteDto {}
@@ -0,0 +1,12 @@
import { IsIn, IsOptional, IsString, IsUUID } from 'class-validator';
export class ListFavoritesDto {
@IsOptional()
@IsString()
@IsIn(['page', 'space', 'template'])
type?: 'page' | 'space' | 'template';
@IsOptional()
@IsUUID()
spaceId?: string;
}
@@ -0,0 +1,153 @@
import {
BadRequestException,
Body,
Controller,
ForbiddenException,
HttpCode,
HttpStatus,
NotFoundException,
Post,
UseGuards,
} from '@nestjs/common';
import { FavoriteService } from './services/favorite.service';
import { AddFavoriteDto, RemoveFavoriteDto } from './dto/favorite.dto';
import { FavoriteIdsDto } from './dto/favorite-ids.dto';
import { ListFavoritesDto } from './dto/list-favorites.dto';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { Page, User, Workspace } from '@docmost/db/types/entity.types';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { PageAccessService } from '../page/page-access/page-access.service';
import { TemplateRepo } from '@docmost/db/repos/template/template.repo';
import { FavoriteType } from '@docmost/db/repos/favorite/favorite.repo';
@UseGuards(JwtAuthGuard)
@Controller('favorites')
export class FavoriteController {
constructor(
private readonly favoriteService: FavoriteService,
private readonly pageRepo: PageRepo,
private readonly spaceRepo: SpaceRepo,
private readonly spaceMemberRepo: SpaceMemberRepo,
private readonly pageAccessService: PageAccessService,
private readonly templateRepo: TemplateRepo,
) {}
@HttpCode(HttpStatus.OK)
@Post('add')
async addFavorite(
@Body() dto: AddFavoriteDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const resolved = await this.resolveAndValidate(dto, user, workspace.id);
await this.favoriteService.addFavorite(user.id, workspace.id, {
type: dto.type,
pageId: dto.pageId,
spaceId: dto.type === 'space' ? resolved.spaceId : undefined,
templateId: dto.templateId,
});
}
@HttpCode(HttpStatus.OK)
@Post('remove')
async removeFavorite(
@Body() dto: RemoveFavoriteDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
await this.resolveAndValidate(dto, user, workspace.id);
await this.favoriteService.removeFavorite(user.id, {
type: dto.type,
pageId: dto.pageId,
spaceId: dto.spaceId,
templateId: dto.templateId,
});
}
@HttpCode(HttpStatus.OK)
@Post('ids')
async getFavoriteIds(
@Body() dto: FavoriteIdsDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
return this.favoriteService.getFavoriteIds(
user.id,
workspace.id,
dto.type as FavoriteType,
dto.spaceId,
);
}
@HttpCode(HttpStatus.OK)
@Post()
async getUserFavorites(
@Body() dto: ListFavoritesDto,
@Body() pagination: PaginationOptions,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
return this.favoriteService.getUserFavorites(
user.id,
workspace.id,
pagination,
dto.type as FavoriteType | undefined,
dto.spaceId,
);
}
private async resolveAndValidate(
dto: AddFavoriteDto | RemoveFavoriteDto,
user: User,
workspaceId: string,
): Promise<{ spaceId: string; page?: Page }> {
if (dto.type === 'page') {
if (!dto.pageId) throw new BadRequestException('pageId is required');
const page = await this.pageRepo.findById(dto.pageId);
if (!page) throw new NotFoundException('Page not found');
await this.pageAccessService.validateCanView(page, user);
return { spaceId: page.spaceId, page };
}
if (dto.type === 'space') {
if (!dto.spaceId) throw new BadRequestException('spaceId is required');
const space = await this.spaceRepo.findById(dto.spaceId, workspaceId);
if (!space) throw new NotFoundException('Space not found');
await this.validateSpaceAccess(user.id, space.id);
return { spaceId: space.id };
}
if (dto.type === 'template') {
if (!dto.templateId)
throw new BadRequestException('templateId is required');
const template = await this.templateRepo.findById(
dto.templateId,
workspaceId,
);
if (!template) throw new NotFoundException('Template not found');
if (template.spaceId) {
await this.validateSpaceAccess(user.id, template.spaceId);
}
return { spaceId: template.spaceId };
}
throw new BadRequestException('Invalid favorite type');
}
private async validateSpaceAccess(
userId: string,
spaceId: string,
): Promise<void> {
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId);
if (!userSpaceIds.includes(spaceId)) {
throw new ForbiddenException();
}
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { FavoriteService } from './services/favorite.service';
import { FavoriteController } from './favorite.controller';
@Module({
controllers: [FavoriteController],
providers: [FavoriteService],
exports: [FavoriteService],
})
export class FavoriteModule {}
@@ -0,0 +1,148 @@
import { Injectable } from '@nestjs/common';
import {
FavoriteRepo,
FavoriteType,
} from '@docmost/db/repos/favorite/favorite.repo';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { InsertableFavorite } from '@docmost/db/types/entity.types';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
@Injectable()
export class FavoriteService {
constructor(
private readonly favoriteRepo: FavoriteRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
private readonly spaceMemberRepo: SpaceMemberRepo,
) {}
async getFavoriteIds(
userId: string,
workspaceId: string,
type: FavoriteType,
spaceId?: string,
) {
const result = await this.favoriteRepo.getFavoriteIds(
userId,
workspaceId,
type,
spaceId,
);
if (result.items.length === 0) {
return result;
}
if (type === FavoriteType.PAGE) {
const accessibleIds =
await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds: result.items,
userId,
});
const accessibleSet = new Set(accessibleIds);
result.items = result.items.filter((id) => accessibleSet.has(id));
}
if (type === FavoriteType.SPACE) {
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId);
const spaceSet = new Set(userSpaceIds);
result.items = result.items.filter((id) => spaceSet.has(id));
}
return result;
}
async addFavorite(
userId: string,
workspaceId: string,
opts: {
type: FavoriteType;
pageId?: string;
spaceId?: string;
templateId?: string;
},
): Promise<void> {
const favorite: InsertableFavorite = {
userId,
pageId: opts.pageId ?? null,
spaceId: opts.spaceId ?? null,
templateId: opts.templateId ?? null,
type: opts.type,
workspaceId,
};
await this.favoriteRepo.insert(favorite);
}
async removeFavorite(
userId: string,
opts: {
type: FavoriteType;
pageId?: string;
spaceId?: string;
templateId?: string;
},
): Promise<void> {
if (opts.type === FavoriteType.PAGE && opts.pageId) {
await this.favoriteRepo.deleteByUserAndPage(userId, opts.pageId);
} else if (opts.type === FavoriteType.SPACE && opts.spaceId) {
await this.favoriteRepo.deleteByUserAndSpace(userId, opts.spaceId);
} else if (opts.type === FavoriteType.TEMPLATE && opts.templateId) {
await this.favoriteRepo.deleteByUserAndTemplate(userId, opts.templateId);
}
}
async getUserFavorites(
userId: string,
workspaceId: string,
pagination: PaginationOptions,
type?: FavoriteType,
spaceId?: string,
) {
const result = await this.favoriteRepo.findUserFavorites(
userId,
workspaceId,
pagination,
type,
spaceId,
);
if (result.items.length === 0) {
return result;
}
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId);
const spaceSet = new Set(userSpaceIds);
const pageFavorites = result.items.filter(
(f) => f.type === FavoriteType.PAGE && f.pageId,
);
let accessiblePageSet: Set<string> | undefined;
if (pageFavorites.length > 0) {
const pageIds = pageFavorites.map((f) => f.pageId as string);
const accessibleIds =
await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds,
userId,
});
accessiblePageSet = new Set(accessibleIds);
}
result.items = result.items.filter((f) => {
if (f.type === FavoriteType.PAGE) {
return f.pageId && accessiblePageSet?.has(f.pageId);
}
if (f.type === FavoriteType.SPACE) {
return f.spaceId && spaceSet.has(f.spaceId);
}
if (f.type === FavoriteType.TEMPLATE) {
const templateSpaceId = (f as any).template?.spaceId;
return !templateSpaceId || spaceSet.has(templateSpaceId);
}
return true;
});
return result;
}
}
@@ -7,13 +7,20 @@ 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';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { executeTx } from '@docmost/db/utils';
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
import { FavoriteRepo } from '@docmost/db/repos/favorite/favorite.repo';
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
import {
AUDIT_SERVICE,
IAuditService,
} from '../../../integrations/audit/audit.service';
import { dbOrTx } from '@docmost/db/utils';
@Injectable()
export class GroupUserService {
@@ -24,7 +31,9 @@ export class GroupUserService {
@Inject(forwardRef(() => GroupService))
private groupService: GroupService,
private readonly watcherRepo: WatcherRepo,
private readonly favoriteRepo: FavoriteRepo,
@InjectKysely() private readonly db: KyselyDB,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
async getGroupUsers(
@@ -46,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) {
@@ -67,11 +82,25 @@ export class GroupUserService {
}
// batch insert new group users
await this.db
await db
.insertInto('groupUsers')
.values(groupUsersToInsert)
.onConflict((oc) => oc.columns(['userId', 'groupId']).doNothing())
.execute();
for (const user of validUsers) {
this.auditService.log({
event: AuditEvent.GROUP_MEMBER_ADDED,
resourceType: AuditResource.GROUP,
resourceId: groupId,
changes: {
after: {
userId: user.id,
userName: user.name,
},
},
});
}
}
async removeUserFromGroup(
@@ -115,8 +144,30 @@ export class GroupUserService {
await this.watcherRepo.deleteByUsersWithoutSpaceAccess(
[userId],
spaceId,
{ trx },
);
await this.favoriteRepo.deleteByUsersWithoutSpaceAccess(
[userId],
spaceId,
{ trx },
);
}
});
this.auditService.log({
event: AuditEvent.GROUP_MEMBER_REMOVED,
resourceType: AuditResource.GROUP,
resourceId: groupId,
changes: {
before: {
userId: user.id,
userName: user.name,
},
},
metadata: {
groupName: group.name,
},
});
}
}
@@ -16,8 +16,15 @@ import { Group, InsertableGroup, User } from '@docmost/db/types/entity.types';
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
import { GroupUserService } from './group-user.service';
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
import { FavoriteRepo } from '@docmost/db/repos/favorite/favorite.repo';
import { executeTx } from '@docmost/db/utils';
import { InjectKysely } from 'nestjs-kysely';
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
import { diffAuditTrackedFields } from '../../../common/helpers';
import {
AUDIT_SERVICE,
IAuditService,
} from '../../../integrations/audit/audit.service';
@Injectable()
export class GroupService {
@@ -28,7 +35,9 @@ export class GroupService {
@Inject(forwardRef(() => GroupUserService))
private groupUserService: GroupUserService,
private readonly watcherRepo: WatcherRepo,
private readonly favoriteRepo: FavoriteRepo,
@InjectKysely() private readonly db: KyselyDB,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
async getGroupInfo(groupId: string, workspaceId: string): Promise<Group> {
@@ -74,6 +83,18 @@ export class GroupService {
);
}
this.auditService.log({
event: AuditEvent.GROUP_CREATED,
resourceType: AuditResource.GROUP,
resourceId: createdGroup.id,
changes: {
after: {
name: createdGroup.name,
description: createdGroup.description,
},
},
});
return createdGroup;
}
@@ -95,6 +116,8 @@ export class GroupService {
throw new BadRequestException('You cannot update a default group');
}
const groupBefore = { name: group.name, description: group.description };
if (updateGroupDto.name) {
const existingGroup = await this.groupRepo.findByName(
updateGroupDto.name,
@@ -121,6 +144,22 @@ export class GroupService {
workspaceId,
);
const changes = diffAuditTrackedFields(
['name', 'description'],
updateGroupDto,
groupBefore,
group,
);
if (changes) {
this.auditService.log({
event: AuditEvent.GROUP_UPDATED,
resourceType: AuditResource.GROUP,
resourceId: group.id,
changes,
});
}
return group;
}
@@ -152,15 +191,36 @@ export class GroupService {
spaceId,
{ trx },
);
await this.favoriteRepo.deleteByUsersWithoutSpaceAccess(
userIds,
spaceId,
{ trx },
);
}
});
this.auditService.log({
event: AuditEvent.GROUP_DELETED,
resourceType: AuditResource.GROUP,
resourceId: groupId,
changes: {
before: {
name: group.name,
description: group.description,
},
},
});
}
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');
}
@@ -1,4 +1,5 @@
import { IsArray, IsOptional, IsUUID } from 'class-validator';
import { IsArray, IsIn, IsOptional, IsString, IsUUID } from 'class-validator';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
export class NotificationIdDto {
@IsUUID()
@@ -11,3 +12,10 @@ export class MarkNotificationsReadDto {
@IsOptional()
notificationIds?: string[];
}
export class ListNotificationsDto extends PaginationOptions {
@IsOptional()
@IsString()
@IsIn(['direct', 'updates', 'all'])
type?: 'direct' | 'updates' | 'all' = 'all';
}
@@ -3,7 +3,51 @@ export const NotificationType = {
COMMENT_CREATED: 'comment.created',
COMMENT_RESOLVED: 'comment.resolved',
PAGE_USER_MENTION: 'page.user_mention',
PAGE_PERMISSION_GRANTED: 'page.permission_granted',
PAGE_UPDATED: 'page.updated',
PAGE_VERIFICATION_EXPIRING: 'page.verification_expiring',
PAGE_VERIFICATION_EXPIRED: 'page.verification_expired',
PAGE_VERIFIED: 'page.verified',
PAGE_APPROVAL_REQUESTED: 'page.approval_requested',
PAGE_APPROVAL_REJECTED: 'page.approval_rejected',
} as const;
export type NotificationType =
(typeof NotificationType)[keyof typeof NotificationType];
export type NotificationSettingKey =
| 'page.updated'
| 'page.userMention'
| 'comment.userMention'
| 'comment.created'
| 'comment.resolved';
export const NotificationTypeToSettingKey: Partial<
Record<NotificationType, NotificationSettingKey>
> = {
[NotificationType.PAGE_UPDATED]: 'page.updated',
[NotificationType.PAGE_USER_MENTION]: 'page.userMention',
[NotificationType.COMMENT_USER_MENTION]: 'comment.userMention',
[NotificationType.COMMENT_CREATED]: 'comment.created',
[NotificationType.COMMENT_RESOLVED]: 'comment.resolved',
};
export type NotificationTab = 'direct' | 'updates' | 'all';
export const DIRECT_NOTIFICATION_TYPES: NotificationType[] = [
NotificationType.COMMENT_USER_MENTION,
NotificationType.COMMENT_CREATED,
NotificationType.COMMENT_RESOLVED,
NotificationType.PAGE_USER_MENTION,
NotificationType.PAGE_PERMISSION_GRANTED,
];
export const UPDATES_NOTIFICATION_TYPES: NotificationType[] = [
NotificationType.PAGE_UPDATED,
];
export function getTypesForTab(tab: NotificationTab): NotificationType[] | undefined {
if (tab === 'direct') return DIRECT_NOTIFICATION_TYPES;
if (tab === 'updates') return UPDATES_NOTIFICATION_TYPES;
return undefined;
}
@@ -9,9 +9,8 @@ import {
import { NotificationService } from './notification.service';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { User } from '@docmost/db/types/entity.types';
import { MarkNotificationsReadDto } from './dto/notification.dto';
import { ListNotificationsDto, MarkNotificationsReadDto } from './dto/notification.dto';
@UseGuards(JwtAuthGuard)
@Controller('notifications')
@@ -21,10 +20,10 @@ export class NotificationController {
@HttpCode(HttpStatus.OK)
@Post('/')
async getNotifications(
@Body() pagination: PaginationOptions,
@Body() dto: ListNotificationsDto,
@AuthUser() user: User,
) {
return this.notificationService.findByUserId(user.id, pagination);
return this.notificationService.findByUserId(user.id, dto, dto.type);
}
@HttpCode(HttpStatus.OK)
@@ -4,16 +4,19 @@ import { NotificationController } from './notification.controller';
import { NotificationProcessor } from './notification.processor';
import { CommentNotificationService } from './services/comment.notification';
import { PageNotificationService } from './services/page.notification';
import { WsModule } from '../../ws/ws.module';
import { VerificationNotificationService } from './services/verification.notification';
import { PageUpdateEmailRateLimiter } from './services/page-update-email-rate-limiter';
@Module({
imports: [WsModule],
imports: [],
controllers: [NotificationController],
providers: [
NotificationService,
NotificationProcessor,
CommentNotificationService,
PageNotificationService,
VerificationNotificationService,
PageUpdateEmailRateLimiter,
],
exports: [NotificationService],
})
@@ -1,16 +1,26 @@
import { Logger, OnModuleDestroy } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq';
import { Job } from 'bullmq';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { QueueJob, QueueName } from '../../integrations/queue/constants';
import {
IApprovalRejectedNotificationJob,
IApprovalRequestedNotificationJob,
ICommentNotificationJob,
ICommentResolvedNotificationJob,
IPageMentionNotificationJob,
IPageUpdateNotificationJob,
IPageVerifiedNotificationJob,
IPermissionGrantedNotificationJob,
IVerificationExpiringNotificationJob,
IVerificationExpiredNotificationJob,
IVerificationReconcileJob,
} from '../../integrations/queue/constants/queue.interface';
import { CommentNotificationService } from './services/comment.notification';
import { PageNotificationService } from './services/page.notification';
import { VerificationNotificationService } from './services/verification.notification';
import { DomainService } from '../../integrations/environment/domain.service';
@Processor(QueueName.NOTIFICATION_QUEUE)
@@ -23,7 +33,9 @@ export class NotificationProcessor
constructor(
private readonly commentNotificationService: CommentNotificationService,
private readonly pageNotificationService: PageNotificationService,
private readonly verificationNotificationService: VerificationNotificationService,
private readonly domainService: DomainService,
private readonly moduleRef: ModuleRef,
@InjectKysely() private readonly db: KyselyDB,
) {
super();
@@ -33,12 +45,25 @@ export class NotificationProcessor
job: Job<
| ICommentNotificationJob
| ICommentResolvedNotificationJob
| IPageMentionNotificationJob,
| IPageMentionNotificationJob
| IPageUpdateNotificationJob
| IPermissionGrantedNotificationJob
| IVerificationExpiringNotificationJob
| IVerificationExpiredNotificationJob
| IVerificationReconcileJob
| IPageVerifiedNotificationJob
| IApprovalRequestedNotificationJob
| IApprovalRejectedNotificationJob,
void
>,
): Promise<void> {
try {
const workspaceId = (job.data as { workspaceId: string }).workspaceId;
if (job.name === QueueJob.VERIFICATION_RECONCILE) {
await this.runVerificationReconcile();
return;
}
const workspaceId = await this.resolveWorkspaceId(job);
const appUrl = await this.getWorkspaceUrl(workspaceId);
switch (job.name) {
@@ -66,6 +91,67 @@ export class NotificationProcessor
break;
}
case QueueJob.PAGE_PERMISSION_GRANTED: {
await this.pageNotificationService.processPermissionGranted(
job.data as IPermissionGrantedNotificationJob,
appUrl,
);
break;
}
case QueueJob.PAGE_UPDATED: {
await this.pageNotificationService.processPageUpdate(
job.data as IPageUpdateNotificationJob,
appUrl,
);
break;
}
case QueueJob.PAGE_UPDATE_DIGEST: {
const { userId } = job.data as unknown as { userId: string };
await this.pageNotificationService.processDigest(userId, appUrl);
break;
}
case QueueJob.PAGE_VERIFICATION_EXPIRING: {
await this.verificationNotificationService.processVerificationExpiring(
job.data as IVerificationExpiringNotificationJob,
appUrl,
);
break;
}
case QueueJob.PAGE_VERIFICATION_EXPIRED: {
await this.verificationNotificationService.processVerificationExpired(
job.data as IVerificationExpiredNotificationJob,
appUrl,
);
break;
}
case QueueJob.PAGE_VERIFIED_NOTIFICATION: {
await this.verificationNotificationService.processPageVerified(
job.data as IPageVerifiedNotificationJob,
);
break;
}
case QueueJob.PAGE_APPROVAL_REQUESTED_NOTIFICATION: {
await this.verificationNotificationService.processApprovalRequested(
job.data as IApprovalRequestedNotificationJob,
appUrl,
);
break;
}
case QueueJob.PAGE_APPROVAL_REJECTED_NOTIFICATION: {
await this.verificationNotificationService.processApprovalRejected(
job.data as IApprovalRejectedNotificationJob,
appUrl,
);
break;
}
default:
this.logger.warn(`Unknown notification job: ${job.name}`);
}
@@ -76,6 +162,49 @@ export class NotificationProcessor
}
}
private async resolveWorkspaceId(job: Job): Promise<string> {
if (
job.name === QueueJob.PAGE_VERIFICATION_EXPIRING ||
job.name === QueueJob.PAGE_VERIFICATION_EXPIRED
) {
const { verificationId } = job.data as { verificationId: string };
const row = await this.db
.selectFrom('pageVerifications')
.select('workspaceId')
.where('id', '=', verificationId)
.executeTakeFirst();
return row?.workspaceId ?? '';
}
return (job.data as { workspaceId: string }).workspaceId;
}
private async runVerificationReconcile(): Promise<void> {
let eeModule: { PageVerificationSchedulerService?: unknown };
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
eeModule = require('../../ee/page-verification/page-verification-scheduler.service');
} catch {
this.logger.debug(
'VERIFICATION_RECONCILE fired but EE scheduler not bundled in this build',
);
return;
}
const schedulerClass = eeModule.PageVerificationSchedulerService as
| (new (...args: unknown[]) => { reconcile(): Promise<void> })
| undefined;
if (!schedulerClass) return;
const scheduler = this.moduleRef.get(schedulerClass, { strict: false });
if (!scheduler) {
this.logger.warn(
'VERIFICATION_RECONCILE fired but scheduler service not resolvable',
);
return;
}
await scheduler.reconcile();
}
private async getWorkspaceUrl(workspaceId: string): Promise<string> {
const workspace = await this.db
.selectFrom('workspaces')
@@ -6,6 +6,8 @@ import { InsertableNotification } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { WsGateway } from '../../ws/ws.gateway';
import { MailService } from '../../integrations/mail/mail.service';
import { NotificationTab, NotificationType, NotificationTypeToSettingKey } from './notification.constants';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
@Injectable()
export class NotificationService {
@@ -13,12 +15,23 @@ export class NotificationService {
constructor(
private readonly notificationRepo: NotificationRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
private readonly wsGateway: WsGateway,
private readonly mailService: MailService,
@InjectKysely() private readonly db: KyselyDB,
) {}
async create(data: InsertableNotification) {
const user = await this.db
.selectFrom('users')
.select(['id'])
.where('id', '=', data.userId)
.where('deletedAt', 'is', null)
.where('deactivatedAt', 'is', null)
.executeTakeFirst();
if (!user) return null;
const notification = await this.notificationRepo.insert(data);
this.wsGateway.server
@@ -28,8 +41,35 @@ export class NotificationService {
return notification;
}
async findByUserId(userId: string, pagination: PaginationOptions) {
return this.notificationRepo.findByUserId(userId, pagination);
async findByUserId(
userId: string,
pagination: PaginationOptions,
type: NotificationTab = 'all',
) {
const result = await this.notificationRepo.findByUserId(
userId,
pagination,
type,
);
const pageIds = result.items
.map((n: any) => n.pageId)
.filter(Boolean);
if (pageIds.length > 0) {
const accessiblePageIds =
await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds,
userId,
});
const accessibleSet = new Set(accessiblePageIds);
result.items = result.items.filter(
(n: any) => !n.pageId || accessibleSet.has(n.pageId),
);
}
return result;
}
async getUnreadCount(userId: string) {
@@ -53,17 +93,27 @@ export class NotificationService {
notificationId: string,
subject: string,
template: any,
type?: NotificationType,
) {
try {
const user = await this.db
.selectFrom('users')
.select(['email'])
.select(['email', 'settings'])
.where('id', '=', userId)
.where('deletedAt', 'is', null)
.where('deactivatedAt', 'is', null)
.executeTakeFirst();
if (!user?.email) return;
if (type) {
const settingKey = NotificationTypeToSettingKey[type];
if (settingKey) {
const settings = user.settings as any;
if (settings?.notifications?.[settingKey] === false) return;
}
}
await this.mailService.sendToQueue({
to: user.email,
subject,
@@ -8,6 +8,7 @@ import {
import { NotificationService } from '../notification.service';
import { NotificationType } from '../notification.constants';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
import { CommentMentionEmail } from '@docmost/transactional/emails/comment-mention-email';
import { CommentCreateEmail } from '@docmost/transactional/emails/comment-created-email';
@@ -22,6 +23,7 @@ export class CommentNotificationService {
@InjectKysely() private readonly db: KyselyDB,
private readonly notificationService: NotificationService,
private readonly spaceMemberRepo: SpaceMemberRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
private readonly watcherRepo: WatcherRepo,
) {}
@@ -59,12 +61,19 @@ export class CommentNotificationService {
const allCandidateIds = [
...new Set([...mentionedUserIds, ...recipientIds]),
];
const usersWithAccess =
const usersWithSpaceAccess =
await this.spaceMemberRepo.getUserIdsWithSpaceAccess(
allCandidateIds,
spaceId,
);
const usersWithPageAccess =
await this.pagePermissionRepo.getUserIdsWithPageAccess(
pageId,
[...usersWithSpaceAccess],
);
const usersWithAccess = new Set(usersWithPageAccess);
for (const userId of mentionedUserIds) {
if (!usersWithAccess.has(userId)) continue;
@@ -77,12 +86,14 @@ export class CommentNotificationService {
spaceId,
commentId,
});
if (!notification) continue;
await this.notificationService.queueEmail(
userId,
notification.id,
`${actor.name} mentioned you in a comment`,
CommentMentionEmail({ actorName: actor.name, pageTitle, pageUrl }),
NotificationType.COMMENT_USER_MENTION,
);
notifiedUserIds.add(userId);
@@ -101,12 +112,14 @@ export class CommentNotificationService {
spaceId,
commentId,
});
if (!notification) continue;
await this.notificationService.queueEmail(
recipientId,
notification.id,
`${actor.name} commented on ${pageTitle}`,
CommentCreateEmail({ actorName: actor.name, pageTitle, pageUrl }),
NotificationType.COMMENT_CREATED,
);
}
}
@@ -146,6 +159,13 @@ export class CommentNotificationService {
return;
}
const hasPageAccess =
await this.pagePermissionRepo.getUserIdsWithPageAccess(
pageId,
[commentCreatorId],
);
if (hasPageAccess.length === 0) return;
const notification = await this.notificationService.create({
userId: commentCreatorId,
workspaceId,
@@ -155,6 +175,7 @@ export class CommentNotificationService {
spaceId,
commentId,
});
if (!notification) return;
const subject = `${actor.name} resolved a comment on ${pageTitle}`;
@@ -163,6 +184,7 @@ export class CommentNotificationService {
notification.id,
subject,
CommentResolvedEmail({ actorName: actor.name, pageTitle, pageUrl }),
NotificationType.COMMENT_RESOLVED,
);
}
@@ -0,0 +1,43 @@
import { Injectable } from '@nestjs/common';
import { RedisService } from '@nestjs-labs/nestjs-ioredis';
import type { Redis } from 'ioredis';
const KEY_PREFIX = 'page-update:emails:';
const DIGEST_PREFIX = 'page-update:digest:';
const TTL_SECONDS = 86400; // 24 hours
const MAX_IMMEDIATE_EMAILS = 4;
@Injectable()
export class PageUpdateEmailRateLimiter {
private readonly redis: Redis;
constructor(private readonly redisService: RedisService) {
this.redis = this.redisService.getOrThrow();
}
async canSendEmail(userId: string): Promise<boolean> {
const key = KEY_PREFIX + userId;
const count = await this.redis.incr(key);
await this.redis.expire(key, TTL_SECONDS, 'NX');
return count <= MAX_IMMEDIATE_EMAILS;
}
async addToDigest(userId: string, notificationId: string): Promise<boolean> {
const key = DIGEST_PREFIX + userId;
const len = await this.redis.rpush(key, notificationId);
await this.redis.expire(key, TTL_SECONDS);
return len === 1;
}
async popDigest(userId: string): Promise<string[]> {
const key = DIGEST_PREFIX + userId;
const [ids] = await this.redis
.multi()
.lrange(key, 0, -1)
.del(key)
.exec();
return (ids?.[1] as string[]) ?? [];
}
}
@@ -1,19 +1,43 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { IPageMentionNotificationJob } from '../../../integrations/queue/constants/queue.interface';
import {
IPageMentionNotificationJob,
IPageUpdateNotificationJob,
IPermissionGrantedNotificationJob,
} from '../../../integrations/queue/constants/queue.interface';
import { NotificationService } from '../notification.service';
import { NotificationType } from '../notification.constants';
import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
import { PageUpdateEmailRateLimiter } from './page-update-email-rate-limiter';
import { PageMentionEmail } from '@docmost/transactional/emails/page-mention-email';
import { PageUpdateEmail } from '@docmost/transactional/emails/page-update-email';
import { PageUpdateDigestEmail } from '@docmost/transactional/emails/page-update-digest-email';
import { PermissionGrantedEmail } from '@docmost/transactional/emails/permission-granted-email';
import { getPageTitle } from '../../../common/helpers';
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
const PAGE_UPDATE_COOLDOWN_HOURS = 7;
const DIGEST_DELAY_MS = 12 * 60 * 60 * 1000; // 12 hours
@Injectable()
export class PageNotificationService {
private readonly logger = new Logger(PageNotificationService.name);
constructor(
@InjectKysely() private readonly db: KyselyDB,
private readonly notificationService: NotificationService,
private readonly notificationRepo: NotificationRepo,
private readonly spaceMemberRepo: SpaceMemberRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
private readonly watcherRepo: WatcherRepo,
private readonly rateLimiter: PageUpdateEmailRateLimiter,
@InjectQueue(QueueName.NOTIFICATION_QUEUE) private notificationQueue: Queue,
) {}
async processPageMention(data: IPageMentionNotificationJob, appUrl: string) {
@@ -28,12 +52,18 @@ export class PageNotificationService {
if (newMentions.length === 0) return;
const candidateUserIds = newMentions.map((m) => m.userId);
const usersWithAccess =
const usersWithSpaceAccess =
await this.spaceMemberRepo.getUserIdsWithSpaceAccess(
candidateUserIds,
spaceId,
);
const usersWithPageAccess =
await this.pagePermissionRepo.getUserIdsWithPageAccess(pageId, [
...usersWithSpaceAccess,
]);
const usersWithAccess = new Set(usersWithPageAccess);
const accessibleMentions = newMentions.filter((m) =>
usersWithAccess.has(m.userId),
);
@@ -84,6 +114,7 @@ export class PageNotificationService {
spaceId,
data: { mentionId },
});
if (!notification) continue;
const pageUrl = `${basePageUrl}`;
const subject = `${actor.name} mentioned you in ${pageTitle}`;
@@ -93,10 +124,289 @@ export class PageNotificationService {
notification.id,
subject,
PageMentionEmail({ actorName: actor.name, pageTitle, pageUrl }),
NotificationType.PAGE_USER_MENTION,
);
}
}
async processPermissionGranted(
data: IPermissionGrantedNotificationJob,
appUrl: string,
) {
const { userIds, pageId, spaceId, workspaceId, actorId, role } = data;
if (userIds.length === 0) return;
const usersWithSpaceAccess =
await this.spaceMemberRepo.getUserIdsWithSpaceAccess(userIds, spaceId);
if (usersWithSpaceAccess.size === 0) return;
const context = await this.getPageContext(actorId, pageId, spaceId, appUrl);
if (!context) return;
const { actor, pageTitle, basePageUrl } = context;
const accessLabel = role === 'writer' ? 'edit' : 'view';
for (const userId of usersWithSpaceAccess) {
const notification = await this.notificationService.create({
userId,
workspaceId,
type: NotificationType.PAGE_PERMISSION_GRANTED,
actorId,
pageId,
spaceId,
data: { role },
});
if (!notification) continue;
const subject = `${actor.name} gave you ${accessLabel} access to ${pageTitle}`;
await this.notificationService.queueEmail(
userId,
notification.id,
subject,
PermissionGrantedEmail({
actorName: actor.name,
pageTitle,
pageUrl: basePageUrl,
accessLabel,
}),
);
}
}
async processPageUpdate(data: IPageUpdateNotificationJob, appUrl: string) {
const { pageId, spaceId, workspaceId, actorIds } = data;
const watcherIds = await this.watcherRepo.getPageUpdateRecipientIds(
pageId,
spaceId,
);
if (watcherIds.length === 0) return;
const actorSet = new Set(actorIds);
const candidateIds = watcherIds.filter((id) => !actorSet.has(id));
if (candidateIds.length === 0) return;
const eligibleUsers = await this.getEligiblePageUpdateUsers(candidateIds);
if (eligibleUsers.size === 0) return;
const afterPrefs = [...eligibleUsers.keys()];
const recentlyNotified =
await this.notificationRepo.getRecentlyNotifiedUserIds(
afterPrefs,
pageId,
NotificationType.PAGE_UPDATED,
PAGE_UPDATE_COOLDOWN_HOURS,
);
const afterCooldown = afterPrefs.filter((id) => !recentlyNotified.has(id));
if (afterCooldown.length === 0) return;
const usersWithSpaceAccess =
await this.spaceMemberRepo.getUserIdsWithSpaceAccess(
afterCooldown,
spaceId,
);
const usersWithPageAccess =
await this.pagePermissionRepo.getUserIdsWithPageAccess(pageId, [
...usersWithSpaceAccess,
]);
if (usersWithPageAccess.length === 0) return;
const recipientIds = new Set(usersWithPageAccess);
const actorId = actorIds[0];
const context = await this.getPageContext(actorId, pageId, spaceId, appUrl);
if (!context) return;
const { actor, pageTitle, basePageUrl, spaceName } = context;
for (const userId of recipientIds) {
const notification = await this.notificationService.create({
userId,
workspaceId,
type: NotificationType.PAGE_UPDATED,
actorId,
pageId,
spaceId,
});
if (!notification) continue;
const canSend = await this.rateLimiter.canSendEmail(userId);
if (canSend) {
await this.notificationService.queueEmail(
userId,
notification.id,
`${actor.name} updated ${pageTitle}`,
PageUpdateEmail({
userName: eligibleUsers.get(userId) ?? '',
actorName: actor.name,
pageTitle,
pageUrl: basePageUrl,
spaceName,
}),
NotificationType.PAGE_UPDATED,
);
} else {
const isFirst = await this.rateLimiter.addToDigest(
userId,
notification.id,
);
if (isFirst) {
await this.scheduleDigest(userId, workspaceId);
}
}
}
}
private async getEligiblePageUpdateUsers(
userIds: string[],
): Promise<Map<string, string>> {
if (userIds.length === 0) return new Map();
const users = await this.db
.selectFrom('users')
.select(['id', 'name', 'settings'])
.where('id', 'in', userIds)
.where('deletedAt', 'is', null)
.where('deactivatedAt', 'is', null)
.execute();
const eligible = new Map<string, string>();
for (const u of users) {
const settings = u.settings as any;
if (settings?.notifications?.['page.updated'] !== false) {
eligible.set(u.id, u.name);
}
}
return eligible;
}
private async scheduleDigest(
userId: string,
workspaceId: string,
): Promise<void> {
await this.notificationQueue
.add(
QueueJob.PAGE_UPDATE_DIGEST,
{ userId, workspaceId },
{ delay: DIGEST_DELAY_MS, removeOnComplete: true },
)
.catch((err) => {
this.logger.error(
`Failed to schedule digest for ${userId}: ${err.message}`,
);
});
}
async processDigest(userId: string, appUrl: string): Promise<void> {
const notificationIds = await this.rateLimiter.popDigest(userId);
if (notificationIds.length === 0) return;
const [user, notifications] = await Promise.all([
this.db
.selectFrom('users')
.select(['id', 'name'])
.where('id', '=', userId)
.executeTakeFirst(),
this.db
.selectFrom('notifications')
.select(['id', 'pageId', 'actorId'])
.where('id', 'in', notificationIds)
.execute(),
]);
if (!user || notifications.length === 0) return;
const pageIds = [
...new Set(notifications.map((n) => n.pageId).filter(Boolean)),
];
const actorIds = [
...new Set(notifications.map((n) => n.actorId).filter(Boolean)),
];
const allPages = await this.db
.selectFrom('pages')
.innerJoin('spaces', 'spaces.id', 'pages.spaceId')
.select([
'pages.id',
'pages.title',
'pages.slugId',
'pages.spaceId',
'spaces.slug as spaceSlug',
])
.where('pages.id', 'in', pageIds)
.execute();
if (allPages.length === 0) return;
const spaceIds = [...new Set(allPages.map((p) => p.spaceId))];
const accessibleSpaceIds = new Set<string>();
for (const spaceId of spaceIds) {
const usersWithAccess =
await this.spaceMemberRepo.getUserIdsWithSpaceAccess([userId], spaceId);
if (usersWithAccess.has(userId)) accessibleSpaceIds.add(spaceId);
}
const spaceFilteredPages = allPages.filter((p) =>
accessibleSpaceIds.has(p.spaceId),
);
if (spaceFilteredPages.length === 0) return;
const accessiblePageIds = new Set<string>();
for (const p of spaceFilteredPages) {
const hasAccess = await this.pagePermissionRepo.getUserIdsWithPageAccess(
p.id,
[userId],
);
if (hasAccess.includes(userId)) accessiblePageIds.add(p.id);
}
const pages = spaceFilteredPages.filter((p) => accessiblePageIds.has(p.id));
if (pages.length === 0) return;
const actors = actorIds.length > 0
? await this.db
.selectFrom('users')
.select(['id', 'name'])
.where('id', 'in', actorIds)
.execute()
: [];
const actorMap = new Map(actors.map((a) => [a.id, a.name]));
const pageActors = new Map<string, Set<string>>();
for (const n of notifications) {
if (!n.pageId || !n.actorId) continue;
const names = pageActors.get(n.pageId) ?? new Set();
const name = actorMap.get(n.actorId);
if (name) names.add(name);
pageActors.set(n.pageId, names);
}
const pageUpdates = pages.map((p) => ({
title: getPageTitle(p.title),
url: `${appUrl}/s/${p.spaceSlug}/p/${p.slugId}`,
updatedBy: [...(pageActors.get(p.id) ?? [])],
}));
await this.notificationService.queueEmail(
userId,
notificationIds[0],
`Your digest: ${pageUpdates.length} page ${pageUpdates.length === 1 ? 'update' : 'updates'}`,
PageUpdateDigestEmail({
userName: user.name,
pageUpdates,
totalUpdates: pageUpdates.length,
}),
NotificationType.PAGE_UPDATED,
);
}
private async getPageContext(
actorId: string,
pageId: string,
@@ -116,7 +426,7 @@ export class PageNotificationService {
.executeTakeFirst(),
this.db
.selectFrom('spaces')
.select(['id', 'slug'])
.select(['id', 'slug', 'name'])
.where('id', '=', spaceId)
.executeTakeFirst(),
]);
@@ -127,6 +437,11 @@ export class PageNotificationService {
const basePageUrl = `${appUrl}/s/${space.slug}/p/${page.slugId}`;
return { actor, pageTitle: getPageTitle(page.title), basePageUrl };
return {
actor,
pageTitle: getPageTitle(page.title),
basePageUrl,
spaceName: space.name,
};
}
}
@@ -0,0 +1,355 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import {
IApprovalRejectedNotificationJob,
IApprovalRequestedNotificationJob,
IPageVerifiedNotificationJob,
IVerificationExpiringNotificationJob,
IVerificationExpiredNotificationJob,
} from '../../../integrations/queue/constants/queue.interface';
import { NotificationService } from '../notification.service';
import { NotificationType } from '../notification.constants';
import { VerificationExpiringEmail } from '@docmost/transactional/emails/verification-expiring-email';
import { VerificationExpiredEmail } from '@docmost/transactional/emails/verification-expired-email';
import { ApprovalRequestedEmail } from '@docmost/transactional/emails/approval-requested-email';
import { ApprovalRejectedEmail } from '@docmost/transactional/emails/approval-rejected-email';
import { getPageTitle } from '../../../common/helpers';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
@Injectable()
export class VerificationNotificationService {
constructor(
@InjectKysely() private readonly db: KyselyDB,
private readonly notificationService: NotificationService,
private readonly spaceMemberRepo: SpaceMemberRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
) {}
private async getAlreadyNotifiedUserIds(
pageVerificationId: string,
type: string,
): Promise<Set<string>> {
const rows = await this.db
.selectFrom('notifications')
.select('userId')
.where('pageVerificationId', '=', pageVerificationId)
.where('type', '=', type)
.execute();
return new Set(rows.map((r) => r.userId));
}
private async filterAccessibleRecipients(
userIds: string[],
pageId: string,
spaceId: string,
): Promise<string[]> {
if (userIds.length === 0) return [];
const inSpace = await this.spaceMemberRepo.getUserIdsWithSpaceAccess(
userIds,
spaceId,
);
if (inSpace.size === 0) return [];
return this.pagePermissionRepo.getUserIdsWithPageAccess(pageId, [
...inSpace,
]);
}
async processVerificationExpiring(
data: IVerificationExpiringNotificationJob,
appUrl: string,
) {
const verification = await this.db
.selectFrom('pageVerifications')
.selectAll()
.where('id', '=', data.verificationId)
.executeTakeFirst();
if (!verification) return;
if (verification.type !== 'expiring') return;
if (!verification.expiresAt) return;
const expiresAtMs = new Date(verification.expiresAt).getTime();
if (expiresAtMs <= Date.now()) return;
const verifierRows = await this.db
.selectFrom('pageVerifiers')
.select('userId')
.where('pageVerificationId', '=', verification.id)
.execute();
const verifierIds = verifierRows.map((r) => r.userId);
if (verifierIds.length === 0) return;
const accessibleVerifierIds = await this.filterAccessibleRecipients(
verifierIds,
verification.pageId,
verification.spaceId,
);
if (accessibleVerifierIds.length === 0) return;
const alreadyNotified = await this.getAlreadyNotifiedUserIds(
verification.id,
NotificationType.PAGE_VERIFICATION_EXPIRING,
);
const recipients = accessibleVerifierIds.filter(
(id) => !alreadyNotified.has(id),
);
if (recipients.length === 0) return;
const context = await this.getPageContext(
verification.pageId,
verification.spaceId,
appUrl,
);
if (!context) return;
const { pageTitle, spaceName, basePageUrl } = context;
const expiresAtIso = new Date(verification.expiresAt).toISOString();
for (const userId of recipients) {
const notification = await this.notificationService.create({
userId,
workspaceId: verification.workspaceId,
type: NotificationType.PAGE_VERIFICATION_EXPIRING,
pageId: verification.pageId,
spaceId: verification.spaceId,
pageVerificationId: verification.id,
data: { expiresAt: expiresAtIso },
});
const subject = `"${pageTitle}" needs to be re-verified soon`;
await this.notificationService.queueEmail(
userId,
notification.id,
subject,
VerificationExpiringEmail({
pageTitle,
spaceName,
pageUrl: basePageUrl,
expiresAt: new Date(verification.expiresAt).toLocaleDateString(),
}),
);
}
}
async processVerificationExpired(
data: IVerificationExpiredNotificationJob,
appUrl: string,
) {
const verification = await this.db
.selectFrom('pageVerifications')
.selectAll()
.where('id', '=', data.verificationId)
.executeTakeFirst();
if (!verification) return;
if (verification.type !== 'expiring') return;
if (!verification.expiresAt) return;
if (new Date(verification.expiresAt).getTime() > Date.now()) return;
const verifierRows = await this.db
.selectFrom('pageVerifiers')
.select('userId')
.where('pageVerificationId', '=', verification.id)
.execute();
const verifierIds = verifierRows.map((r) => r.userId);
if (verifierIds.length === 0) return;
const accessibleVerifierIds = await this.filterAccessibleRecipients(
verifierIds,
verification.pageId,
verification.spaceId,
);
if (accessibleVerifierIds.length === 0) return;
const alreadyNotified = await this.getAlreadyNotifiedUserIds(
verification.id,
NotificationType.PAGE_VERIFICATION_EXPIRED,
);
const recipients = accessibleVerifierIds.filter(
(id) => !alreadyNotified.has(id),
);
if (recipients.length === 0) return;
const context = await this.getPageContext(
verification.pageId,
verification.spaceId,
appUrl,
);
if (!context) return;
const { pageTitle, spaceName, basePageUrl } = context;
for (const userId of recipients) {
const notification = await this.notificationService.create({
userId,
workspaceId: verification.workspaceId,
type: NotificationType.PAGE_VERIFICATION_EXPIRED,
pageId: verification.pageId,
spaceId: verification.spaceId,
pageVerificationId: verification.id,
});
const subject = `"${pageTitle}" verification has expired`;
await this.notificationService.queueEmail(
userId,
notification.id,
subject,
VerificationExpiredEmail({
pageTitle,
spaceName,
pageUrl: basePageUrl,
}),
);
}
}
async processPageVerified(data: IPageVerifiedNotificationJob) {
const { verifierIds, pageId, spaceId, workspaceId, actorId } = data;
if (verifierIds.length === 0) return;
const accessibleVerifierIds = await this.filterAccessibleRecipients(
verifierIds,
pageId,
spaceId,
);
if (accessibleVerifierIds.length === 0) return;
for (const userId of accessibleVerifierIds) {
await this.notificationService.create({
userId,
workspaceId,
type: NotificationType.PAGE_VERIFIED,
actorId,
pageId,
spaceId,
});
}
}
async processApprovalRequested(
data: IApprovalRequestedNotificationJob,
appUrl: string,
) {
const { verifierIds, pageId, spaceId, workspaceId, actorId } = data;
if (verifierIds.length === 0) return;
const accessibleVerifierIds = await this.filterAccessibleRecipients(
verifierIds,
pageId,
spaceId,
);
if (accessibleVerifierIds.length === 0) return;
const context = await this.getPageContext(pageId, spaceId, appUrl);
if (!context) return;
const { pageTitle, spaceName, basePageUrl } = context;
const actorName = await this.getUserName(actorId);
for (const userId of accessibleVerifierIds) {
const notification = await this.notificationService.create({
userId,
workspaceId,
type: NotificationType.PAGE_APPROVAL_REQUESTED,
actorId,
pageId,
spaceId,
});
const subject = `"${pageTitle}" needs your approval`;
await this.notificationService.queueEmail(
userId,
notification.id,
subject,
ApprovalRequestedEmail({
actorName,
pageTitle,
spaceName,
pageUrl: basePageUrl,
}),
);
}
}
async processApprovalRejected(
data: IApprovalRejectedNotificationJob,
appUrl: string,
) {
const { pageId, spaceId, workspaceId, actorId, requestedById, comment } =
data;
const recipients = await this.filterAccessibleRecipients(
[requestedById],
pageId,
spaceId,
);
if (recipients.length === 0) return;
const context = await this.getPageContext(pageId, spaceId, appUrl);
if (!context) return;
const { pageTitle, spaceName, basePageUrl } = context;
const actorName = await this.getUserName(actorId);
const notification = await this.notificationService.create({
userId: requestedById,
workspaceId,
type: NotificationType.PAGE_APPROVAL_REJECTED,
actorId,
pageId,
spaceId,
});
const subject = `"${pageTitle}" was returned for revision`;
await this.notificationService.queueEmail(
requestedById,
notification.id,
subject,
ApprovalRejectedEmail({
actorName,
pageTitle,
spaceName,
pageUrl: basePageUrl,
comment,
}),
);
}
private async getUserName(userId: string): Promise<string> {
const user = await this.db
.selectFrom('users')
.select('name')
.where('id', '=', userId)
.executeTakeFirst();
return user?.name ?? 'Someone';
}
private async getPageContext(
pageId: string,
spaceId: string,
appUrl: string,
) {
const [page, space] = await Promise.all([
this.db
.selectFrom('pages')
.select(['id', 'title', 'slugId'])
.where('id', '=', pageId)
.executeTakeFirst(),
this.db
.selectFrom('spaces')
.select(['id', 'slug', 'name'])
.where('id', '=', spaceId)
.executeTakeFirst(),
]);
if (!page || !space) return null;
const basePageUrl = `${appUrl}/s/${space.slug}/p/${page.slugId}`;
return { pageTitle: getPageTitle(page.title), spaceName: space.name ?? space.slug, basePageUrl };
}
}
@@ -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;
}
@@ -0,0 +1,11 @@
import { IsOptional, IsUUID } from 'class-validator';
export class CreatedByUserDto {
@IsOptional()
@IsUUID()
userId?: string;
@IsOptional()
@IsUUID()
spaceId?: string;
}
@@ -1,5 +1,4 @@
import { IsOptional, IsString, IsUUID } from 'class-validator';
import { SpaceIdDto } from './page.dto';
export class SidebarPageDto {
@IsOptional()
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PageAccessService } from './page-access.service';
@Global()
@Module({
providers: [PageAccessService],
exports: [PageAccessService],
})
export class PageAccessModule {}
@@ -0,0 +1,125 @@
import { ForbiddenException, Injectable } from '@nestjs/common';
import { Page, User } from '@docmost/db/types/entity.types';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import SpaceAbilityFactory from '../../casl/abilities/space-ability.factory';
import {
SpaceCaslAction,
SpaceCaslSubject,
} from '../../casl/interfaces/space-ability.type';
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
@Injectable()
export class PageAccessService {
constructor(
private readonly pagePermissionRepo: PagePermissionRepo,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly spaceRepo: SpaceRepo,
) {}
/**
* Validate user can view page, throws ForbiddenException if not.
* If page has restrictions: page-level permission determines access.
* If no restrictions: space-level permission determines access.
*/
async validateCanView(page: Page, user: User): Promise<void> {
// TODO: cache by pageId and userId.
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
// User must be at least a space member
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
const canAccess = await this.pagePermissionRepo.canUserAccessPage(
user.id,
page.id,
);
if (!canAccess) {
throw new ForbiddenException();
}
}
/**
* Validate user can view page AND return effective canEdit permission.
* Combines access check + edit permission in a single query pass.
*/
async validateCanViewWithPermissions(
page: Page,
user: User,
): Promise<{ canEdit: boolean; hasRestriction: boolean }> {
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
const { hasAnyRestriction, canAccess, canEdit } =
await this.pagePermissionRepo.canUserEditPage(user.id, page.id);
if (hasAnyRestriction && !canAccess) {
throw new ForbiddenException();
}
return {
canEdit: hasAnyRestriction
? canEdit
: ability.can(SpaceCaslAction.Edit, SpaceCaslSubject.Page),
hasRestriction: hasAnyRestriction,
};
}
/**
* Validate user can edit page, throws ForbiddenException if not.
* If page has restrictions: page-level writer permission determines access.
* If no restrictions: space-level edit permission determines access.
*/
async validateCanEdit(
page: Page,
user: User,
): Promise<{ hasRestriction: boolean }> {
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
// User must be at least a space member
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
const { hasAnyRestriction, canEdit } =
await this.pagePermissionRepo.canUserEditPage(user.id, page.id);
if (hasAnyRestriction) {
// Page has restrictions - use page-level permission
if (!canEdit) {
throw new ForbiddenException();
}
} else {
// No restrictions - use space-level permission
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
}
return { hasRestriction: hasAnyRestriction };
}
async validateCanComment(
page: Page,
user: User,
workspaceId: string,
): Promise<void> {
try {
await this.validateCanEdit(page, user);
return;
} catch {
// User cannot edit — check if reader commenting is enabled
}
await this.validateCanView(page, user);
const space = await this.spaceRepo.findById(page.spaceId, workspaceId);
const settings = space?.settings as Record<string, any> | null;
if (!settings?.comments?.allowViewerComments) {
throw new ForbiddenException();
}
}
}
+291 -44
View File
@@ -5,11 +5,14 @@ import {
ForbiddenException,
HttpCode,
HttpStatus,
Inject,
NotFoundException,
Post,
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';
import { MovePageDto, MovePageToSpaceDto } from './dto/move-page.dto';
@@ -24,7 +27,7 @@ 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 { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { User, Workspace } from '@docmost/db/types/entity.types';
import { Page, User, Workspace } from '@docmost/db/types/entity.types';
import { SidebarPageDto } from './dto/sidebar-page.dto';
import {
SpaceCaslAction,
@@ -33,12 +36,20 @@ import {
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
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 {
jsonToHtml,
jsonToMarkdown,
} from '../../collaboration/collaboration.util';
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
import {
AUDIT_SERVICE,
IAuditService,
} from '../../integrations/audit/audit.service';
import { getPageTitle } from '../../common/helpers';
@UseGuards(JwtAuthGuard)
@Controller('pages')
@@ -48,6 +59,9 @@ export class PageController {
private readonly pageRepo: PageRepo,
private readonly pageHistoryService: PageHistoryService,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly pageAccessService: PageAccessService,
private readonly backlinkService: BacklinkService,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
@HttpCode(HttpStatus.OK)
@@ -65,10 +79,10 @@ export class PageController {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
const { canEdit, hasRestriction } =
await this.pageAccessService.validateCanViewWithPermissions(page, user);
const permissions = { canEdit, hasRestriction };
if (dto.format && dto.format !== 'json' && page.content) {
const contentOutput =
@@ -78,10 +92,47 @@ export class PageController {
return {
...page,
content: contentOutput,
permissions,
};
}
return page;
return { ...page, permissions };
}
@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)
@@ -91,12 +142,28 @@ export class PageController {
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const ability = await this.spaceAbility.createForUser(
user,
createPageDto.spaceId,
);
if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
if (createPageDto.parentPageId) {
// Creating under a parent page - check edit permission on parent
const parentPage = await this.pageRepo.findById(
createPageDto.parentPageId,
);
if (
!parentPage ||
parentPage.deletedAt ||
parentPage.spaceId !== createPageDto.spaceId
) {
throw new NotFoundException('Parent page not found');
}
await this.pageAccessService.validateCanEdit(parentPage, user);
} else {
// Creating at root level - require space-level permission
const ability = await this.spaceAbility.createForUser(
user,
createPageDto.spaceId,
);
if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
}
const page = await this.pageService.create(
@@ -105,6 +172,24 @@ export class PageController {
createPageDto,
);
const { canEdit, hasRestriction } =
await this.pageAccessService.validateCanViewWithPermissions(page, user);
const permissions = { canEdit, hasRestriction };
this.auditService.log({
event: AuditEvent.PAGE_CREATED,
resourceType: AuditResource.PAGE,
resourceId: page.id,
spaceId: page.spaceId,
changes: {
after: {
title: getPageTitle(page.title),
spaceId: page.spaceId,
},
},
});
if (
createPageDto.format &&
createPageDto.format !== 'json' &&
@@ -114,10 +199,10 @@ export class PageController {
createPageDto.format === 'markdown'
? jsonToMarkdown(page.content)
: jsonToHtml(page.content);
return { ...page, content: contentOutput };
return { ...page, content: contentOutput, permissions };
}
return page;
return { ...page, permissions };
}
@HttpCode(HttpStatus.OK)
@@ -129,10 +214,10 @@ export class PageController {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
const { hasRestriction } = await this.pageAccessService.validateCanEdit(
page,
user,
);
const updatedPage = await this.pageService.update(
page,
@@ -140,6 +225,8 @@ export class PageController {
user,
);
const permissions = { canEdit: true, hasRestriction };
if (
updatePageDto.format &&
updatePageDto.format !== 'json' &&
@@ -149,10 +236,10 @@ export class PageController {
updatePageDto.format === 'markdown'
? jsonToMarkdown(updatedPage.content)
: jsonToHtml(updatedPage.content);
return { ...updatedPage, content: contentOutput };
return { ...updatedPage, content: contentOutput, permissions };
}
return updatedPage;
return { ...updatedPage, permissions };
}
@HttpCode(HttpStatus.OK)
@@ -178,16 +265,45 @@ export class PageController {
);
}
await this.pageService.forceDelete(deletePageDto.pageId, workspace.id);
this.auditService.log({
event: AuditEvent.PAGE_DELETED,
resourceType: AuditResource.PAGE,
resourceId: page.id,
spaceId: page.spaceId,
changes: {
before: {
pageId: page.id,
slugId: page.slugId,
title: getPageTitle(page.title),
spaceId: page.spaceId,
},
},
});
} else {
// Soft delete requires page manage permissions
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
// User with edit permission can delete
await this.pageAccessService.validateCanEdit(page, user);
await this.pageService.removePage(
deletePageDto.pageId,
user.id,
workspace.id,
);
this.auditService.log({
event: AuditEvent.PAGE_TRASHED,
resourceType: AuditResource.PAGE,
resourceId: page.id,
spaceId: page.spaceId,
changes: {
before: {
pageId: page.id,
slugId: page.slugId,
title: getPageTitle(page.title),
spaceId: page.spaceId,
},
},
});
}
}
@@ -204,13 +320,30 @@ export class PageController {
throw new NotFoundException('Page not found');
}
// only users with "can edit" space level permission can restore pages
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
// make sure they have page level access to the page
await this.pageAccessService.validateCanEdit(page, user);
await this.pageRepo.restorePage(pageIdDto.pageId, workspace.id);
this.auditService.log({
event: AuditEvent.PAGE_RESTORED,
resourceType: AuditResource.PAGE,
resourceId: page.id,
spaceId: page.spaceId,
changes: {
after: {
title: getPageTitle(page.title),
spaceId: page.spaceId,
},
},
});
return this.pageRepo.findById(pageIdDto.pageId, {
includeHasChildren: true,
});
@@ -235,6 +368,7 @@ export class PageController {
return this.pageService.getRecentSpacePages(
recentPageDto.spaceId,
user.id,
pagination,
);
}
@@ -242,6 +376,29 @@ export class PageController {
return this.pageService.getRecentPages(user.id, pagination);
}
@HttpCode(HttpStatus.OK)
@Post('created-by-user')
async getCreatedByPages(
@Body() dto: CreatedByUserDto,
@Body() pagination: PaginationOptions,
@AuthUser() user: User,
) {
const targetUserId = dto.userId ?? user.id;
if (dto.spaceId) {
const ability = await this.spaceAbility.createForUser(
user,
dto.spaceId,
);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
}
return this.pageService.getCreatedByPages(targetUserId, user.id, pagination, dto.spaceId);
}
@HttpCode(HttpStatus.OK)
@Post('trash')
async getDeletedPages(
@@ -255,12 +412,13 @@ export class PageController {
deletedPageDto.spaceId,
);
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
return this.pageService.getDeletedSpacePages(
deletedPageDto.spaceId,
user.id,
pagination,
);
}
@@ -278,10 +436,7 @@ export class PageController {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
await this.pageAccessService.validateCanView(page, user);
return this.pageHistoryService.findHistoryByPageId(page.id, pagination);
}
@@ -297,13 +452,14 @@ export class PageController {
throw new NotFoundException('Page history not found');
}
const ability = await this.spaceAbility.createForUser(
user,
history.spaceId,
);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
// Get the page to check permissions
const page = await this.pageRepo.findById(history.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
await this.pageAccessService.validateCanView(page, user);
return history;
}
@@ -335,7 +491,18 @@ export class PageController {
throw new ForbiddenException();
}
return this.pageService.getSidebarPages(spaceId, pagination, dto.pageId);
const spaceCanEdit = ability.can(
SpaceCaslAction.Edit,
SpaceCaslSubject.Page,
);
return this.pageService.getSidebarPages(
spaceId,
pagination,
dto.pageId,
user.id,
spaceCanEdit,
);
}
@HttpCode(HttpStatus.OK)
@@ -365,7 +532,30 @@ export class PageController {
throw new ForbiddenException();
}
return this.pageService.movePageToSpace(movedPage, dto.spaceId);
// Check page-level edit permission on the source page
await this.pageAccessService.validateCanEdit(movedPage, user);
// Moves only accessible pages; inaccessible child pages become root pages in original space
const { childPageIds } = await this.pageService.movePageToSpace(
movedPage,
dto.spaceId,
user.id,
);
this.auditService.log({
event: AuditEvent.PAGE_MOVED_TO_SPACE,
resourceType: AuditResource.PAGE,
resourceId: movedPage.id,
spaceId: movedPage.spaceId,
changes: {
before: { spaceId: movedPage.spaceId },
after: { spaceId: dto.spaceId },
},
metadata: {
title: getPageTitle(movedPage.title),
...(childPageIds.length > 0 && { childPageIds }),
},
});
}
@HttpCode(HttpStatus.OK)
@@ -376,6 +566,12 @@ export class PageController {
throw new NotFoundException('Page to copy not found');
}
// Check page-level view permission on the source page (need to read to copy)
// Inaccessible child branches are automatically skipped during duplication
await this.pageAccessService.validateCanView(copiedPage, user);
let result;
// If spaceId is provided, it's a copy to different space
if (dto.spaceId) {
const abilities = await Promise.all([
@@ -391,7 +587,27 @@ export class PageController {
throw new ForbiddenException();
}
return this.pageService.duplicatePage(copiedPage, dto.spaceId, user);
result = await this.pageService.duplicatePage(
copiedPage,
dto.spaceId,
user,
);
this.auditService.log({
event: AuditEvent.PAGE_DUPLICATED,
resourceType: AuditResource.PAGE,
resourceId: result.id,
spaceId: dto.spaceId,
metadata: {
sourcePageId: copiedPage.id,
title: getPageTitle(copiedPage.title),
sourceSpaceId: copiedPage.spaceId,
targetSpaceId: dto.spaceId,
...(result.childPageIds.length > 0 && {
childPageIds: result.childPageIds,
}),
},
});
} else {
// If no spaceId, it's a duplicate in same space
const ability = await this.spaceAbility.createForUser(
@@ -402,8 +618,28 @@ export class PageController {
throw new ForbiddenException();
}
return this.pageService.duplicatePage(copiedPage, undefined, user);
result = await this.pageService.duplicatePage(
copiedPage,
undefined,
user,
);
this.auditService.log({
event: AuditEvent.PAGE_DUPLICATED,
resourceType: AuditResource.PAGE,
resourceId: result.id,
spaceId: copiedPage.spaceId,
metadata: {
sourcePageId: copiedPage.id,
title: getPageTitle(copiedPage.title),
...(result.childPageIds.length > 0 && {
childPageIds: result.childPageIds,
}),
},
});
}
return result;
}
@HttpCode(HttpStatus.OK)
@@ -418,10 +654,23 @@ export class PageController {
user,
movedPage.spaceId,
);
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
// Check page-level edit permission
await this.pageAccessService.validateCanEdit(movedPage, user);
// If moving to a new parent, check permission on the target parent
if (dto.parentPageId && dto.parentPageId !== movedPage.parentPageId) {
const targetParent = await this.pageRepo.findById(dto.parentPageId);
if (!targetParent || targetParent.deletedAt) {
throw new NotFoundException('Target parent page not found');
}
await this.pageAccessService.validateCanEdit(targetParent, user);
}
return this.pageService.movePage(dto, movedPage);
}
@@ -433,10 +682,8 @@ export class PageController {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
await this.pageAccessService.validateCanView(page, user);
return this.pageService.getPageBreadCrumbs(page.id);
}
}
+14 -2
View File
@@ -3,14 +3,26 @@ 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';
@Module({
controllers: [PageController],
providers: [PageService, PageHistoryService, TrashCleanupService],
providers: [
PageService,
PageHistoryService,
TrashCleanupService,
BacklinkService,
],
exports: [PageService, PageHistoryService],
imports: [StorageModule, CollaborationModule, WatcherModule],
imports: [
StorageModule,
CollaborationModule,
WatcherModule,
TransclusionModule,
],
})
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,
});
}
}
@@ -7,6 +7,7 @@ import {
import { CreatePageDto, ContentFormat } from '../dto/create-page.dto';
import { ContentOperation, UpdatePageDto } from '../dto/update-page.dto';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { InsertablePage, Page, User } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import {
@@ -46,9 +47,15 @@ import { QueueJob, QueueName } from '../../../integrations/queue/constants';
import { EventName } from '../../../common/events/event.contants';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { CollaborationGateway } from '../../../collaboration/collaboration.gateway';
import {
INTERNAL_LINK_REGEX,
extractPageSlugId,
} from '../../../integrations/export/utils';
import { markdownToHtml } from '@docmost/editor-ext';
import { WatcherService } from '../../watcher/watcher.service';
import { LabelRepo } from '@docmost/db/repos/label/label.repo';
import { sql } from 'kysely';
import { TransclusionService } from '../transclusion/transclusion.service';
@Injectable()
export class PageService {
@@ -56,6 +63,7 @@ export class PageService {
constructor(
private pageRepo: PageRepo,
private pagePermissionRepo: PagePermissionRepo,
private attachmentRepo: AttachmentRepo,
@InjectKysely() private readonly db: KyselyDB,
private readonly storageService: StorageService,
@@ -66,6 +74,7 @@ export class PageService {
private collaborationGateway: CollaborationGateway,
private readonly watcherService: WatcherService,
private readonly labelRepo: LabelRepo,
private readonly transclusionService: TransclusionService,
) {}
async findById(
@@ -94,7 +103,11 @@ export class PageService {
createPageDto.parentPageId,
);
if (!parentPage || parentPage.spaceId !== createPageDto.spaceId) {
if (
!parentPage ||
parentPage.deletedAt ||
parentPage.spaceId !== createPageDto.spaceId
) {
throw new NotFoundException('Parent page not found');
}
@@ -264,6 +277,8 @@ export class PageService {
spaceId: string,
pagination: PaginationOptions,
pageId?: string,
userId?: string,
spaceCanEdit?: boolean,
): Promise<CursorPaginationResult<Partial<Page> & { hasChildren: boolean }>> {
let query = this.db
.selectFrom('pages')
@@ -288,8 +303,8 @@ export class PageService {
query = query.where('parentPageId', 'is', null);
}
return executeWithCursorPagination(query, {
perPage: 250,
const result = await executeWithCursorPagination(query, {
perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [
@@ -305,10 +320,99 @@ export class PageService {
id: cursor.id,
}),
});
if (userId && result.items.length > 0) {
const hasRestrictions =
await this.pagePermissionRepo.hasRestrictedPagesInSpace(spaceId);
if (!hasRestrictions) {
result.items = result.items.map((p: any) => ({
...p,
canEdit: spaceCanEdit ?? true,
}));
} else {
const pageIds = result.items.map((p: any) => p.id);
const accessiblePages =
await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions(
pageIds,
userId,
);
const permissionMap = new Map(
accessiblePages.map((p) => [p.id, p.canEdit]),
);
result.items = result.items
.filter((p: any) => permissionMap.has(p.id))
.map((p: any) => ({
...p,
canEdit: permissionMap.get(p.id) && (spaceCanEdit ?? true),
}));
const pagesWithChildren = result.items.filter(
(p: any) => p.hasChildren,
);
if (pagesWithChildren.length > 0) {
const parentIds = pagesWithChildren.map((p: any) => p.id);
const parentsWithAccessibleChildren =
await this.pagePermissionRepo.getParentIdsWithAccessibleChildren(
parentIds,
userId,
);
const hasAccessibleChildrenSet = new Set(
parentsWithAccessibleChildren,
);
result.items = result.items.map((p: any) => ({
...p,
hasChildren: p.hasChildren && hasAccessibleChildrenSet.has(p.id),
}));
}
}
}
return result;
}
async movePageToSpace(rootPage: Page, spaceId: string) {
async movePageToSpace(rootPage: Page, spaceId: string, userId: string) {
let childPageIds: string[] = [];
const allPages = await this.pageRepo.getPageAndDescendants(rootPage.id, {
includeContent: false,
});
// Filter to only accessible pages while maintaining tree integrity
const accessiblePages = await this.filterAccessibleTreePages(
allPages,
rootPage.id,
userId,
rootPage.spaceId,
);
const accessibleIds = new Set(accessiblePages.map((p) => p.id));
// Find inaccessible pages whose parent is being moved - these need to be orphaned
const pagesToOrphan = allPages.filter(
(p) =>
!accessibleIds.has(p.id) &&
p.parentPageId &&
accessibleIds.has(p.parentPageId),
);
await executeTx(this.db, async (trx) => {
// Orphan inaccessible child pages (make them root pages in original space)
for (const page of pagesToOrphan) {
const orphanPosition = await this.nextPagePosition(
rootPage.spaceId,
null,
);
await this.pageRepo.updatePage(
{ parentPageId: null, position: orphanPosition },
page.id,
trx,
);
}
// Update root page
const nextPosition = await this.nextPagePosition(spaceId);
await this.pageRepo.updatePage(
@@ -316,52 +420,76 @@ export class PageService {
rootPage.id,
trx,
);
const pageIds = await this.pageRepo
.getPageAndDescendants(rootPage.id, { includeContent: false })
.then((pages) => pages.map((page) => page.id));
// The first id is the root page id
if (pageIds.length > 1) {
// Update sub pages
const pageIdsToMove = accessiblePages.map((p) => p.id);
childPageIds = pageIdsToMove.filter((id) => id !== rootPage.id);
if (pageIdsToMove.length > 1) {
// Update sub pages (all accessible pages except root)
await this.pageRepo.updatePages(
{ spaceId },
pageIds.filter((id) => id !== rootPage.id),
childPageIds,
trx,
);
}
if (pageIds.length > 0) {
if (pageIdsToMove.length > 0) {
// Clear page-level permissions - moved pages inherit destination space permissions
// (page_permissions cascade deletes via foreign key)
await trx
.deleteFrom('pageAccess')
.where('pageId', 'in', pageIdsToMove)
.execute();
// update spaceId in shares
await trx
.updateTable('shares')
.set({ spaceId: spaceId })
.where('pageId', 'in', pageIds)
.where('pageId', 'in', pageIdsToMove)
.execute();
// Update comments
await trx
.updateTable('comments')
.set({ spaceId: spaceId })
.where('pageId', 'in', pageIds)
.where('pageId', 'in', pageIdsToMove)
.execute();
// Update page verifications
await trx
.updateTable('pageVerifications')
.set({ spaceId: spaceId })
.where('pageId', 'in', pageIdsToMove)
.execute();
// Update notifications — access follows the page after a move
await trx
.updateTable('notifications')
.set({ spaceId: spaceId })
.where('pageId', 'in', pageIdsToMove)
.execute();
// Update attachments
await this.attachmentRepo.updateAttachmentsByPageId(
{ spaceId },
pageIds,
pageIdsToMove,
trx,
);
// Update watchers and remove those without access to new space
await this.watcherService.movePageWatchersToSpace(pageIds, spaceId, {
await this.watcherService.movePageWatchersToSpace(pageIdsToMove, spaceId, {
trx,
});
await this.aiQueue.add(QueueJob.PAGE_MOVED_TO_SPACE, {
pageId: pageIds,
pageId: pageIdsToMove,
workspaceId: rootPage.workspaceId,
});
}
});
return { childPageIds };
}
async duplicatePage(
@@ -383,10 +511,18 @@ export class PageService {
nextPosition = await this.nextPagePosition(spaceId);
}
const pages = await this.pageRepo.getPageAndDescendants(rootPage.id, {
const allPages = await this.pageRepo.getPageAndDescendants(rootPage.id, {
includeContent: true,
});
// Filter to only accessible pages while maintaining tree integrity
const pages = await this.filterAccessibleTreePages(
allPages,
rootPage.id,
authUser.id,
rootPage.spaceId,
);
const pageMap = new Map<string, CopyPageMapEntry>();
pages.forEach((page) => {
pageMap.set(page.id, {
@@ -396,6 +532,11 @@ export class PageService {
});
});
const slugIdMap = new Map<string, CopyPageMapEntry>();
for (const [, entry] of pageMap) {
slugIdMap.set(entry.oldSlugId, entry);
}
const attachmentMap = new Map<string, ICopyPageAttachment>();
const insertablePages: InsertablePage[] = await Promise.all(
@@ -462,6 +603,39 @@ export class PageService {
node.attrs.slugId = mappedPage.newSlugId;
}
}
// 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 (
mark.type.name === 'link' &&
mark.attrs.internal &&
mark.attrs.href
) {
const match = mark.attrs.href.match(INTERNAL_LINK_REGEX);
if (match) {
const slugId = extractPageSlugId(match[5]);
if (slugId && slugIdMap.has(slugId)) {
const mappedPage = slugIdMap.get(slugId);
//@ts-ignore
mark.attrs.href = mark.attrs.href.replace(
slugId,
mappedPage.newSlugId,
);
}
}
}
}
});
const prosemirrorJson = prosemirrorDoc.toJSON();
@@ -500,6 +674,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,
@@ -572,10 +779,12 @@ export class PageService {
});
const hasChildren = pages.length > 1;
const childPageIds = insertedPageIds.filter((id) => id !== newPageId);
return {
...duplicatedPage,
hasChildren,
childPageIds,
};
}
@@ -594,7 +803,11 @@ export class PageService {
// changing the page's parent
if (dto.parentPageId) {
const parentPage = await this.pageRepo.findById(dto.parentPageId);
if (!parentPage || parentPage.spaceId !== movedPage.spaceId) {
if (
!parentPage ||
parentPage.deletedAt ||
parentPage.spaceId !== movedPage.spaceId
) {
throw new NotFoundException('Parent page not found');
}
parentPageId = parentPage.id;
@@ -625,7 +838,6 @@ export class PageService {
'spaceId',
'deletedAt',
])
.select((eb) => this.pageRepo.withHasChildren(eb))
.where('id', '=', childPageId)
.where('deletedAt', 'is', null)
.unionAll((exp) =>
@@ -641,30 +853,21 @@ export class PageService {
'p.spaceId',
'p.deletedAt',
])
.select(
exp
.selectFrom('pages as child')
.select((eb) =>
eb
.case()
.when(eb.fn.countAll(), '>', 0)
.then(true)
.else(false)
.end()
.as('count'),
)
.whereRef('child.parentPageId', '=', 'id')
.where('child.deletedAt', 'is', null)
.limit(1)
.as('hasChildren'),
)
//.select((eb) => this.withHasChildren(eb))
.innerJoin('page_ancestors as pa', 'pa.parentPageId', 'p.id')
.where('p.deletedAt', 'is', null),
),
)
.selectFrom('page_ancestors')
.selectAll()
.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'),
)
.execute();
return ancestors.reverse();
@@ -672,23 +875,99 @@ export class PageService {
async getRecentSpacePages(
spaceId: string,
userId: string,
pagination: PaginationOptions,
): Promise<CursorPaginationResult<Page>> {
return this.pageRepo.getRecentPagesInSpace(spaceId, pagination);
const result = await this.pageRepo.getRecentPagesInSpace(
spaceId,
pagination,
);
if (result.items.length > 0) {
const pageIds = result.items.map((p) => p.id);
const accessibleIds =
await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds,
userId,
spaceId,
});
const accessibleSet = new Set(accessibleIds);
result.items = result.items.filter((p) => accessibleSet.has(p.id));
}
return result;
}
async getRecentPages(
userId: string,
pagination: PaginationOptions,
): Promise<CursorPaginationResult<Page>> {
return this.pageRepo.getRecentPages(userId, pagination);
const result = await this.pageRepo.getRecentPages(userId, pagination);
if (result.items.length > 0) {
const pageIds = result.items.map((p) => p.id);
const accessibleIds =
await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds,
userId,
});
const accessibleSet = new Set(accessibleIds);
result.items = result.items.filter((p) => accessibleSet.has(p.id));
}
return result;
}
async getCreatedByPages(
creatorId: string,
requestingUserId: string,
pagination: PaginationOptions,
spaceId?: string,
): Promise<CursorPaginationResult<Page>> {
const result = await this.pageRepo.getCreatedByPages(
creatorId,
requestingUserId,
pagination,
spaceId,
);
if (result.items.length > 0) {
const pageIds = result.items.map((p) => p.id);
const accessibleIds =
await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds,
userId: requestingUserId,
});
const accessibleSet = new Set(accessibleIds);
result.items = result.items.filter((p) => accessibleSet.has(p.id));
}
return result;
}
async getDeletedSpacePages(
spaceId: string,
userId: string,
pagination: PaginationOptions,
): Promise<CursorPaginationResult<Page>> {
return this.pageRepo.getDeletedPagesInSpace(spaceId, pagination);
const result = await this.pageRepo.getDeletedPagesInSpace(
spaceId,
pagination,
);
if (result.items.length > 0) {
const pageIds = result.items.map((p) => p.id);
const accessibleIds =
await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds,
userId,
spaceId,
});
const accessibleSet = new Set(accessibleIds);
result.items = result.items.filter((p) => accessibleSet.has(p.id));
}
return result;
}
async forceDelete(pageId: string, workspaceId: string): Promise<void> {
@@ -785,4 +1064,61 @@ export class PageService {
return prosemirrorJson;
}
/**
* Filters a list of pages to only those accessible to the user while maintaining tree integrity.
* A page is included only if:
* 1. The user has access to it
* 2. Its parent is also included (or it's the root page)
* This ensures that if a middle page is inaccessible, its entire subtree is excluded.
*/
private async filterAccessibleTreePages<
T extends { id: string; parentPageId: string | null },
>(
pages: T[],
rootPageId: string,
userId: string,
spaceId?: string,
): Promise<T[]> {
if (pages.length === 0) return [];
const pageIds = pages.map((p) => p.id);
const accessibleIds = await this.pagePermissionRepo.filterAccessiblePageIds(
{
pageIds,
userId,
spaceId,
},
);
const accessibleSet = new Set(accessibleIds);
// Prune: include a page only if it's accessible AND its parent chain to root is included
const includedIds = new Set<string>();
// Process pages in a way that ensures parents are processed before children
// We do this by iterating until no more pages can be added
let changed = true;
while (changed) {
changed = false;
for (const page of pages) {
if (includedIds.has(page.id)) continue;
if (!accessibleSet.has(page.id)) continue;
// Root page: include if accessible
if (page.id === rootPageId) {
includedIds.add(page.id);
changed = true;
continue;
}
// Non-root: include if parent is already included
if (page.parentPageId && includedIds.has(page.parentPageId)) {
includedIds.add(page.id);
changed = true;
}
}
}
return pages.filter((p) => includedIds.has(p.id));
}
}
@@ -7,10 +7,11 @@ import { Queue } from 'bullmq';
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
import { LabelRepo } from '@docmost/db/repos/label/label.repo';
const DEFAULT_RETENTION_DAYS = 30;
@Injectable()
export class TrashCleanupService {
private readonly logger = new Logger(TrashCleanupService.name);
private readonly RETENTION_DAYS = 30;
constructor(
@InjectKysely() private readonly db: KyselyDB,
@@ -23,36 +24,46 @@ export class TrashCleanupService {
try {
this.logger.debug('Starting trash cleanup job');
const retentionDate = new Date();
retentionDate.setDate(retentionDate.getDate() - this.RETENTION_DAYS);
// Get all pages that were deleted more than 30 days ago
const oldDeletedPages = await this.db
.selectFrom('pages')
.select(['id', 'spaceId', 'workspaceId'])
.where('deletedAt', '<', retentionDate)
const workspaces = await this.db
.selectFrom('workspaces')
.select(['id', 'trashRetentionDays'])
.where('deletedAt', 'is', null)
.execute();
if (oldDeletedPages.length === 0) {
this.logger.debug('No old trash items to clean up');
return;
}
let totalCleaned = 0;
this.logger.debug(`Found ${oldDeletedPages.length} pages to clean up`);
for (const workspace of workspaces) {
const retentionDays =
workspace.trashRetentionDays ?? DEFAULT_RETENTION_DAYS;
// Process each page
for (const page of oldDeletedPages) {
try {
await this.cleanupPage(page.id);
} catch (error) {
this.logger.error(
`Failed to cleanup page ${page.id}: ${error instanceof Error ? error.message : 'Unknown error'}`,
error instanceof Error ? error.stack : undefined,
);
const retentionDate = new Date();
retentionDate.setDate(retentionDate.getDate() - retentionDays);
const oldDeletedPages = await this.db
.selectFrom('pages')
.select(['id'])
.where('workspaceId', '=', workspace.id)
.where('deletedAt', '<', retentionDate)
.execute();
for (const page of oldDeletedPages) {
try {
await this.cleanupPage(page.id);
totalCleaned++;
} catch (error) {
this.logger.error(
`Failed to cleanup page ${page.id}: ${error instanceof Error ? error.message : 'Unknown error'}`,
error instanceof Error ? error.stack : undefined,
);
}
}
}
this.logger.debug('Trash cleanup job completed');
this.logger.debug(
totalCleaned > 0
? `Trash cleanup completed: ${totalCleaned} pages cleaned`
: 'No old trash items to clean up',
);
} catch (error) {
this.logger.error(
'Trash cleanup job failed',
@@ -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 };
}
@@ -5,5 +5,6 @@ import { SearchService } from './search.service';
@Module({
controllers: [SearchController],
providers: [SearchService],
exports: [SearchService],
})
export class SearchModule {}
+47 -11
View File
@@ -7,6 +7,7 @@ import { sql } from 'kysely';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
// eslint-disable-next-line @typescript-eslint/no-require-imports
const tsquery = require('pg-tsquery')();
@@ -18,6 +19,7 @@ export class SearchService {
private pageRepo: PageRepo,
private shareRepo: ShareRepo,
private spaceMemberRepo: SpaceMemberRepo,
private pagePermissionRepo: PagePermissionRepo,
) {}
async searchPage(
@@ -89,9 +91,15 @@ export class SearchService {
return { items: [] };
}
const isRestricted =
await this.pagePermissionRepo.hasRestrictedAncestor(share.pageId);
if (isRestricted) {
return { items: [] };
}
const pageIdsToSearch = [];
if (share.includeSubPages) {
const pageList = await this.pageRepo.getPageAndDescendants(
const pageList = await this.pageRepo.getPageAndDescendantsExcludingRestricted(
share.pageId,
{
includeContent: false,
@@ -115,10 +123,23 @@ export class SearchService {
}
//@ts-ignore
queryResults = await queryResults.execute();
let results: any[] = await queryResults.execute();
// Filter results by page-level permissions (if user is authenticated)
if (opts.userId && results.length > 0) {
const pageIds = results.map((r: any) => r.id);
const accessibleIds =
await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds,
userId: opts.userId,
spaceId: searchParams.spaceId,
});
const accessibleSet = new Set(accessibleIds);
results = results.filter((r: any) => accessibleSet.has(r.id));
}
//@ts-ignore
const searchResults = queryResults.map((result: SearchResponseDto) => {
const searchResults = results.map((result: SearchResponseDto) => {
if (result.highlight) {
result.highlight = result.highlight
.replace(/\r\n|\r|\n/g, ' ')
@@ -183,6 +204,7 @@ export class SearchService {
let pageSearch = this.db
.selectFrom('pages')
.select(['id', 'slugId', 'title', 'icon', 'spaceId'])
.select((eb) => this.pageRepo.withSpace(eb))
.where((eb) =>
eb(
sql`LOWER(f_unaccent(pages.title))`,
@@ -194,19 +216,33 @@ export class SearchService {
.where('workspaceId', '=', workspaceId)
.limit(limit);
// only search spaces the user has access to
// search all spaces the user has access to, prioritizing the current space
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId);
if (suggestion?.spaceId) {
if (userSpaceIds.includes(suggestion.spaceId)) {
pageSearch = pageSearch.where('spaceId', '=', suggestion.spaceId);
pages = await pageSearch.execute();
}
} else if (userSpaceIds?.length > 0) {
// we need this check or the query will throw an error if the userSpaceIds array is empty
if (userSpaceIds?.length > 0) {
pageSearch = pageSearch.where('spaceId', 'in', userSpaceIds);
if (suggestion?.spaceId) {
pageSearch = pageSearch.orderBy(
sql`CASE WHEN pages."space_id" = ${suggestion.spaceId} THEN 0 ELSE 1 END`,
'asc',
);
}
pages = await pageSearch.execute();
}
// Filter by page-level permissions
if (pages.length > 0) {
const pageIds = pages.map((p) => p.id);
const accessibleIds =
await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds,
userId,
});
const accessibleSet = new Set(accessibleIds);
pages = pages.filter((p) => accessibleSet.has(p.id));
}
}
return { users, groups, pages };
@@ -0,0 +1,7 @@
import { IsNotEmpty, IsUUID } from 'class-validator';
export class RevokeSessionDto {
@IsUUID()
@IsNotEmpty()
sessionId: string;
}
@@ -0,0 +1,36 @@
import { Injectable } from '@nestjs/common';
import { RedisService } from '@nestjs-labs/nestjs-ioredis';
import type { Redis } from 'ioredis';
import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
const THROTTLE_SECONDS = 15 * 60; // 15 minutes
@Injectable()
export class SessionActivityService {
private readonly redis: Redis;
constructor(
private readonly redisService: RedisService,
private readonly userSessionRepo: UserSessionRepo,
private readonly userRepo: UserRepo,
) {
this.redis = this.redisService.getOrThrow();
}
trackActivity(sessionId: string, userId: string, workspaceId: string): void {
const key = `session:activity:${sessionId}`;
this.redis
.set(key, '1', 'EX', THROTTLE_SECONDS, 'NX')
.then((result) => {
if (result === null) return; // key already exists, throttled
this.userSessionRepo.updateLastActiveAt(sessionId).catch(() => {});
this.userRepo
.updateUser({ lastActiveAt: new Date() }, userId, workspaceId)
.catch(() => {});
})
.catch(() => {});
}
}
@@ -0,0 +1,80 @@
import {
BadRequestException,
Body,
Controller,
HttpCode,
HttpStatus,
Post,
Req,
UseGuards,
} from '@nestjs/common';
import { SessionService } from './session.service';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { User, Workspace } from '@docmost/db/types/entity.types';
import { RevokeSessionDto } from './dto/revoke-session.dto';
import { FastifyRequest } from 'fastify';
@UseGuards(JwtAuthGuard)
@Controller('sessions')
export class SessionController {
constructor(private readonly sessionService: SessionService) {}
@HttpCode(HttpStatus.OK)
@Post()
async listSessions(
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
@Req() req: FastifyRequest,
) {
const currentSessionId = (req.raw as any).sessionId ?? null;
const sessions = await this.sessionService.getActiveSessions(
user.id,
workspace.id,
currentSessionId,
);
return { sessions };
}
@HttpCode(HttpStatus.OK)
@Post('revoke')
async revokeSession(
@Body() dto: RevokeSessionDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
@Req() req: FastifyRequest,
) {
const currentSessionId = (req.raw as any).sessionId;
if (dto.sessionId === currentSessionId) {
throw new BadRequestException(
'Cannot revoke current session. Use logout instead.',
);
}
await this.sessionService.revokeSession(
dto.sessionId,
user.id,
workspace.id,
);
}
@HttpCode(HttpStatus.OK)
@Post('revoke-all')
async revokeAllSessions(
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
@Req() req: FastifyRequest,
) {
const currentSessionId = (req.raw as any).sessionId;
if (!currentSessionId) {
throw new BadRequestException(
'Current session not found. Please log in again.',
);
}
await this.sessionService.revokeAllOtherSessions(
currentSessionId,
user.id,
workspace.id,
);
}
}
@@ -0,0 +1,14 @@
import { Global, Module } from '@nestjs/common';
import { SessionService } from './session.service';
import { SessionActivityService } from './session-activity.service';
import { SessionController } from './session.controller';
import { TokenModule } from '../auth/token.module';
@Global()
@Module({
imports: [TokenModule],
controllers: [SessionController],
providers: [SessionService, SessionActivityService],
exports: [SessionService, SessionActivityService],
})
export class SessionModule {}
@@ -0,0 +1,127 @@
import { Injectable, Logger } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { TokenService } from '../auth/services/token.service';
import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo';
import { EnvironmentService } from '../../integrations/environment/environment.service';
import { User } from '@docmost/db/types/entity.types';
import { ClsService } from 'nestjs-cls';
import {
AuditContext,
AUDIT_CONTEXT_KEY,
} from '../../common/middlewares/audit-context.middleware';
import * as Bowser from 'bowser';
const MAX_SESSIONS_PER_USER = 25;
const RETENTION_DAYS = 7;
@Injectable()
export class SessionService {
private readonly logger = new Logger(SessionService.name);
constructor(
private readonly tokenService: TokenService,
private readonly userSessionRepo: UserSessionRepo,
private readonly environmentService: EnvironmentService,
private readonly cls: ClsService,
) {}
@Interval('session-cleanup', 24 * 60 * 60 * 1000)
async cleanupSessions() {
try {
await this.userSessionRepo.deleteStale(RETENTION_DAYS);
await this.userSessionRepo.trimExcessSessions(MAX_SESSIONS_PER_USER);
this.logger.debug('Session cleanup completed');
} catch (err) {
this.logger.error('Session cleanup failed', err);
}
}
async createSessionAndToken(user: User): Promise<string> {
const auditContext = this.cls.get<AuditContext>(AUDIT_CONTEXT_KEY);
const ipAddress = auditContext?.ipAddress ?? null;
const userAgent = auditContext?.userAgent ?? null;
const deviceName = this.parseDeviceName(userAgent);
const expiresAt = this.environmentService.getCookieExpiresIn();
const session = await this.userSessionRepo.insertSession({
userId: user.id,
workspaceId: user.workspaceId,
deviceName,
ipAddress,
expiresAt,
});
return this.tokenService.generateAccessToken(user, session.id);
}
async getActiveSessions(
userId: string,
workspaceId: string,
currentSessionId: string | null,
) {
const sessions = await this.userSessionRepo.findActiveByUser(
userId,
workspaceId,
);
const mapped = sessions.map((s) => ({
id: s.id,
deviceName: s.deviceName,
geoLocation: s.geoLocation,
lastActiveAt: s.lastActiveAt,
createdAt: s.createdAt,
isCurrentDevice: s.id === currentSessionId,
}));
return mapped.sort((a, b) => {
if (a.isCurrentDevice) return -1;
if (b.isCurrentDevice) return 1;
return 0;
});
}
async revokeSession(
sessionId: string,
userId: string,
workspaceId: string,
): Promise<void> {
await this.userSessionRepo.revokeById(sessionId, userId, workspaceId);
}
async revokeAllOtherSessions(
currentSessionId: string,
userId: string,
workspaceId: string,
): Promise<void> {
await this.userSessionRepo.revokeAllExceptCurrent(
currentSessionId,
userId,
workspaceId,
);
}
private parseDeviceName(userAgent: string | null): string | null {
if (!userAgent) return null;
try {
const parsed = Bowser.parse(userAgent);
const os = parsed.os?.name;
const browser = parsed.browser?.name;
const platformType = parsed.platform?.type;
if (platformType === 'mobile' || platformType === 'tablet') {
return parsed.platform?.model || os || 'Mobile Device';
}
if (os) {
return browser ? `${browser} on ${os}` : os;
}
return browser || null;
} catch {
return null;
}
}
}

Some files were not shown because too many files have changed in this diff Show More