Merge branch 'main'

This commit is contained in:
Philipinho
2026-04-12 20:29:38 +01:00
258 changed files with 16319 additions and 5049 deletions
+64 -58
View File
@@ -1,6 +1,6 @@
{
"name": "server",
"version": "0.70.3",
"version": "0.71.1",
"description": "",
"author": "",
"private": true,
@@ -30,123 +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.1000.0",
"@aws-sdk/lib-storage": "3.1000.0",
"@aws-sdk/s3-request-presigner": "3.1000.0",
"@clickhouse/client": "^1.17.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.1014.0",
"@aws-sdk/lib-storage": "3.1014.0",
"@aws-sdk/s3-request-presigner": "3.1014.0",
"@clickhouse/client": "^1.18.2",
"@fastify/cookie": "^11.0.2",
"@fastify/multipart": "^9.4.0",
"@fastify/static": "^9.0.0",
"@keyv/redis": "^5.1.6",
"@langchain/core": "1.1.29",
"@langchain/core": "1.1.34",
"@langchain/textsplitters": "1.0.1",
"@modelcontextprotocol/sdk": "^1.27.1",
"@nest-lab/throttler-storage-redis": "^1.2.0",
"@nestjs-labs/nestjs-ioredis": "^11.0.4",
"@nestjs/bullmq": "^11.0.4",
"@nestjs/cache-manager": "^3.1.0",
"@nestjs/common": "^11.1.14",
"@nestjs/common": "^11.1.18",
"@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.1.14",
"@nestjs/core": "^11.1.18",
"@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.14",
"@nestjs/platform-socket.io": "^11.1.14",
"@nestjs/platform-fastify": "^11.1.18",
"@nestjs/platform-socket.io": "^11.1.18",
"@nestjs/schedule": "^6.1.1",
"@nestjs/terminus": "^11.1.1",
"@nestjs/websockets": "^11.1.14",
"@nestjs/throttler": "^6.5.0",
"@nestjs/websockets": "^11.1.18",
"@node-saml/passport-saml": "^5.1.0",
"@react-email/components": "1.0.7",
"@react-email/components": "1.0.10",
"@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.70.1",
"bowser": "^2.14.1",
"bullmq": "^5.71.0",
"cache-manager": "^7.2.8",
"cheerio": "^1.2.0",
"class-transformer": "^0.5.1",
"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",
"mammoth": "^1.12.0",
"mime-types": "^3.0.2",
"msgpackr": "^1.11.9",
"nanoid": "5.1.7",
"nestjs-cls": "^6.2.0",
"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",
"nestjs-kysely": "^3.1.2",
"nestjs-pino": "^4.6.1",
"nodemailer": "^8.0.4",
"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",
"pdfjs-dist": "^5.5.207",
"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",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"sanitize-filename-ts": "1.0.2",
"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",
"typesense": "^3.0.3",
"ws": "^8.19.0",
"yauzl": "^3.2.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.18",
"@nestjs/schematics": "^11.0.10",
"@nestjs/testing": "^11.1.18",
"@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.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",
"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.5.1",
"react-email": "5.2.8",
"prettier": "^3.8.1",
"react-email": "5.2.10",
"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": [
+2
View File
@@ -26,6 +26,7 @@ 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 {
@@ -83,6 +84,7 @@ try {
EventEmitterModule.forRoot(),
SecurityModule,
TelemetryModule,
ThrottleModule,
...enterpriseModules,
],
controllers: [AppController],
@@ -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);
}
},
@@ -24,6 +24,8 @@ import {
CustomTable,
TiptapImage,
TiptapVideo,
TiptapAudio,
TiptapPdf,
TrailingNode,
Attachment,
Drawio,
@@ -86,6 +88,8 @@ export const tiptapExtensions = [
Youtube,
TiptapImage,
TiptapVideo,
TiptapAudio,
TiptapPdf,
Callout,
Attachment,
CustomCodeBlock,
@@ -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';
@@ -43,7 +41,6 @@ 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,
@@ -165,13 +162,6 @@ export class PersistenceExtension implements Extension {
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) : [];
@@ -1,8 +1,18 @@
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';
@@ -18,6 +28,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();
}
@@ -47,8 +59,7 @@ export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
!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 +72,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,
+1 -1
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';
+23
View File
@@ -0,0 +1,23 @@
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',
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',
} as const;
export type FeatureKey = (typeof Feature)[keyof typeof Feature];
@@ -7,6 +7,10 @@ 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';
export interface MentionNode {
id: string;
@@ -64,6 +68,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[] = [];
@@ -102,6 +127,8 @@ export function isAttachmentNode(nodeType: string) {
'attachment',
'image',
'video',
'audio',
'pdf',
'excalidraw',
'drawio',
];
+12
View File
@@ -142,6 +142,18 @@ export function isUserDisabled(user: {
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({
+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,
}),
@@ -7,6 +7,7 @@ export interface AuditContext {
actorId: string | null;
actorType: 'user' | 'system' | 'api_key';
ipAddress: string | null;
userAgent: string | null;
}
export const AUDIT_CONTEXT_KEY = 'auditContext';
@@ -17,34 +18,22 @@ export class AuditContextMiddleware implements NestMiddleware {
use(req: FastifyRequest['raw'], res: FastifyReply['raw'], next: () => void) {
const workspaceId = (req as any).workspaceId ?? null;
const ipAddress = this.extractIpAddress(req);
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();
}
private extractIpAddress(req: FastifyRequest['raw']): string | null {
const xForwardedFor = req.headers['x-forwarded-for'];
if (xForwardedFor) {
const ips = Array.isArray(xForwardedFor)
? xForwardedFor[0]
: xForwardedFor.split(',')[0];
return ips?.trim() ?? null;
}
const xRealIp = req.headers['x-real-ip'];
if (xRealIp) {
return Array.isArray(xRealIp) ? xRealIp[0] : xRealIp;
}
return (req as any).socket?.remoteAddress ?? null;
}
}
@@ -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',
];
@@ -178,21 +178,29 @@ 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 page = await this.pageRepo.findById(attachment.pageId);
if (!page) {
throw new NotFoundException();
}
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();
}
await this.pageAccessService.validateCanView(page, user);
const page = await this.pageRepo.findById(attachment.pageId);
if (!page) {
throw new NotFoundException();
}
await this.pageAccessService.validateCanView(page, user);
}
try {
return await this.sendFileResponse(req, res, attachment, 'private');
@@ -457,6 +465,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(
@@ -71,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`;
}
@@ -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);
+33 -2
View File
@@ -5,12 +5,19 @@ import {
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';
@@ -22,7 +29,7 @@ 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';
@@ -31,12 +38,15 @@ import {
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,
@@ -108,6 +118,7 @@ export class AuthController {
return workspace;
}
@SkipThrottle({ [AUTH_THROTTLER]: true })
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('change-password')
@@ -115,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)
@@ -163,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')
@@ -173,13 +192,24 @@ export class AuthController {
return this.authService.getCollabToken(user, workspace.id);
}
@SkipThrottle({ [AUTH_THROTTLER]: true })
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('logout')
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({
@@ -192,6 +222,7 @@ export class AuthController {
setAuthCookie(res: FastifyReply, token: string) {
res.setCookie('authToken', token, {
httpOnly: true,
sameSite: 'lax',
path: '/',
expires: this.environmentService.getCookieExpiresIn(),
secure: this.environmentService.isHttps(),
@@ -11,6 +11,7 @@ export type JwtPayload = {
email: string;
workspaceId: string;
type: 'access';
sessionId?: string;
};
export type JwtCollabPayload = {
@@ -8,6 +8,8 @@ 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';
@@ -44,6 +46,8 @@ 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,
@@ -90,19 +94,19 @@ export class AuthService {
metadata: { source: 'password' },
});
return this.tokenService.generateAccessToken(user);
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 };
}
@@ -110,6 +114,7 @@ export class AuthService {
dto: ChangePasswordDto,
userId: string,
workspaceId: string,
currentSessionId?: string,
): Promise<void> {
const user = await this.userRepo.findById(userId, workspaceId, {
includePassword: true,
@@ -138,6 +143,16 @@ 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,
@@ -244,6 +259,8 @@ 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,
@@ -276,7 +293,7 @@ export class AuthService {
};
}
const authToken = await this.tokenService.generateAccessToken(user);
const authToken = await this.sessionService.createSessionAndToken(user);
return { authToken };
}
@@ -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,
@@ -24,7 +25,7 @@ export class TokenService {
private environmentService: EnvironmentService,
) {}
async generateAccessToken(user: User): Promise<string> {
async generateAccessToken(user: User, sessionId: string): Promise<string> {
if (isUserDisabled(user)) {
throw new ForbiddenException();
}
@@ -34,6 +35,7 @@ export class TokenService {
email: user.email,
workspaceId: user.workspaceId,
type: JwtType.ACCESS,
sessionId,
};
return this.jwtService.sign(payload);
}
@@ -96,7 +98,7 @@ export class TokenService {
apiKeyId: string;
user: User;
workspaceId: string;
expiresIn?: string | number;
expiresIn?: StringValue | number;
}): Promise<string> {
const { apiKeyId, user, workspaceId, expiresIn } = opts;
if (isUserDisabled(user)) {
@@ -5,6 +5,8 @@ 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, isUserDisabled } from '../../../common/helpers';
import { ModuleRef } from '@nestjs/core';
@@ -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,
) {
@@ -57,6 +61,16 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
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',
},
};
@@ -58,13 +58,13 @@ export class CommentController {
throw new NotFoundException('Page not found');
}
await this.pageAccessService.validateCanEdit(page, user);
await this.pageAccessService.validateCanComment(page, user, workspace.id);
const comment = await this.commentService.create(
{
userId: user.id,
page,
workspaceId: workspace.id,
user,
},
createCommentDto,
);
@@ -120,7 +120,7 @@ export class CommentController {
@HttpCode(HttpStatus.OK)
@Post('update')
async update(@Body() dto: UpdateCommentDto, @AuthUser() user: User) {
async update(@Body() dto: UpdateCommentDto, @AuthUser() user: User, @AuthWorkspace() workspace: Workspace) {
const comment = await this.commentRepo.findById(dto.commentId, {
includeCreator: true,
includeResolvedBy: true,
@@ -134,14 +134,14 @@ export class CommentController {
throw new NotFoundException('Page not found');
}
await this.pageAccessService.validateCanEdit(page, user);
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');
@@ -152,8 +152,7 @@ export class CommentController {
throw new NotFoundException('Page not found');
}
// Check page-level edit permission first
await this.pageAccessService.validateCanEdit(page, user);
await this.pageAccessService.validateCanComment(page, user, workspace.id);
// Check if user is the comment owner
const isOwner = comment.creatorId === user.id;
@@ -169,7 +168,7 @@ export class CommentController {
// 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',
'You can only delete your own comments',
);
}
await this.commentRepo.deleteComment(comment.id);
@@ -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';
@@ -27,6 +28,7 @@ export class CommentService {
private commentRepo: CommentRepo,
private pageRepo: PageRepo,
private wsService: WsService,
private collaborationGateway: CollaborationGateway,
@InjectQueue(QueueName.GENERAL_QUEUE)
private generalQueue: Queue,
@InjectQueue(QueueName.NOTIFICATION_QUEUE)
@@ -45,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) {
@@ -71,11 +73,39 @@ export class CommentService {
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,
@@ -83,7 +113,7 @@ export class CommentService {
this.generalQueue
.add(QueueJob.ADD_PAGE_WATCHERS, {
userIds: [userId],
userIds: [user.id],
pageId: page.id,
spaceId: page.spaceId,
workspaceId,
@@ -101,7 +131,7 @@ export class CommentService {
page.id,
page.spaceId,
workspaceId,
userId,
user.id,
!isReply,
createCommentDto.parentCommentId,
);
@@ -1,4 +1,22 @@
import { IsIn, 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()
@@ -18,4 +36,11 @@ export class CreateCommentDto {
@IsOptional()
@IsUUID()
parentCommentId: string;
@IsOptional()
@IsObject()
yjsSelection?: {
anchor: any;
head: any;
};
}
+2
View File
@@ -21,6 +21,7 @@ import { ShareModule } from './share/share.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({
@@ -40,6 +41,7 @@ import { ClsMiddleware } from 'nestjs-cls';
ShareModule,
NotificationModule,
WatcherModule,
SessionModule,
],
})
export class CoreModule implements NestModule {
@@ -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';
}
@@ -4,7 +4,45 @@ export const NotificationType = {
COMMENT_RESOLVED: 'comment.resolved',
PAGE_USER_MENTION: 'page.user_mention',
PAGE_PERMISSION_GRANTED: 'page.permission_granted',
PAGE_UPDATED: 'page.updated',
} 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,6 +4,7 @@ import { NotificationController } from './notification.controller';
import { NotificationProcessor } from './notification.processor';
import { CommentNotificationService } from './services/comment.notification';
import { PageNotificationService } from './services/page.notification';
import { PageUpdateEmailRateLimiter } from './services/page-update-email-rate-limiter';
@Module({
imports: [],
@@ -13,6 +14,7 @@ import { PageNotificationService } from './services/page.notification';
NotificationProcessor,
CommentNotificationService,
PageNotificationService,
PageUpdateEmailRateLimiter,
],
exports: [NotificationService],
})
@@ -8,6 +8,7 @@ import {
ICommentNotificationJob,
ICommentResolvedNotificationJob,
IPageMentionNotificationJob,
IPageUpdateNotificationJob,
IPermissionGrantedNotificationJob,
} from '../../integrations/queue/constants/queue.interface';
import { CommentNotificationService } from './services/comment.notification';
@@ -35,6 +36,7 @@ export class NotificationProcessor
| ICommentNotificationJob
| ICommentResolvedNotificationJob
| IPageMentionNotificationJob
| IPageUpdateNotificationJob
| IPermissionGrantedNotificationJob,
void
>,
@@ -76,6 +78,20 @@ export class NotificationProcessor
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;
}
default:
this.logger.warn(`Unknown notification job: ${job.name}`);
}
@@ -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,
@@ -86,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);
@@ -110,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,
);
}
}
@@ -171,6 +175,7 @@ export class CommentNotificationService {
spaceId,
commentId,
});
if (!notification) return;
const subject = `${actor.name} resolved a comment on ${pageTitle}`;
@@ -179,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,25 +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,
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) {
@@ -41,10 +59,9 @@ export class PageNotificationService {
);
const usersWithPageAccess =
await this.pagePermissionRepo.getUserIdsWithPageAccess(
pageId,
[...usersWithSpaceAccess],
);
await this.pagePermissionRepo.getUserIdsWithPageAccess(pageId, [
...usersWithSpaceAccess,
]);
const usersWithAccess = new Set(usersWithPageAccess);
const accessibleMentions = newMentions.filter((m) =>
@@ -97,6 +114,7 @@ export class PageNotificationService {
spaceId,
data: { mentionId },
});
if (!notification) continue;
const pageUrl = `${basePageUrl}`;
const subject = `${actor.name} mentioned you in ${pageTitle}`;
@@ -106,6 +124,7 @@ export class PageNotificationService {
notification.id,
subject,
PageMentionEmail({ actorName: actor.name, pageTitle, pageUrl }),
NotificationType.PAGE_USER_MENTION,
);
}
}
@@ -139,6 +158,7 @@ export class PageNotificationService {
spaceId,
data: { role },
});
if (!notification) continue;
const subject = `${actor.name} gave you ${accessLabel} access to ${pageTitle}`;
@@ -156,6 +176,237 @@ export class PageNotificationService {
}
}
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,
@@ -175,7 +426,7 @@ export class PageNotificationService {
.executeTakeFirst(),
this.db
.selectFrom('spaces')
.select(['id', 'slug'])
.select(['id', 'slug', 'name'])
.where('id', '=', spaceId)
.executeTakeFirst(),
]);
@@ -186,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,
};
}
}
@@ -6,12 +6,14 @@ 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,
) {}
/**
@@ -99,4 +101,25 @@ export class PageAccessService {
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();
}
}
}
@@ -47,6 +47,10 @@ 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 { sql } from 'kysely';
@@ -510,6 +514,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(
@@ -576,6 +585,28 @@ export class PageService {
node.attrs.slugId = mappedPage.newSlugId;
}
}
// 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();
@@ -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;
}
}
}
@@ -11,4 +11,8 @@ export class UpdateSpaceDto extends PartialType(CreateSpaceDto) {
@IsOptional()
@IsBoolean()
disablePublicSharing: boolean;
@IsOptional()
@IsBoolean()
allowViewerComments: boolean;
}
@@ -13,6 +13,7 @@ import { Space, User } from '@docmost/db/types/entity.types';
import { UpdateSpaceDto } from '../dto/update-space.dto';
import { executeTx } from '@docmost/db/utils';
import { InjectKysely } from 'nestjs-kysely';
import { Feature } from '../../../common/features';
import { SpaceMemberService } from './space-member.service';
import { SpaceRole } from '../../../common/helpers/types/permission';
import { QueueJob, QueueName } from 'src/integrations/queue/constants';
@@ -133,17 +134,34 @@ export class SpaceService {
}
}
if (typeof updateSpaceDto.disablePublicSharing !== 'undefined') {
if (
typeof updateSpaceDto.disablePublicSharing !== 'undefined' ||
typeof updateSpaceDto.allowViewerComments !== 'undefined'
) {
const workspace = await this.workspaceRepo.findById(workspaceId, {
withLicenseKey: true,
});
if (
!this.licenseCheckService.hasFeature(workspace.licenseKey, 'security:settings', workspace.plan)
typeof updateSpaceDto.disablePublicSharing !== 'undefined' &&
!this.licenseCheckService.hasFeature(
workspace.licenseKey,
Feature.SECURITY_SETTINGS,
workspace.plan,
)
) {
throw new ForbiddenException(
'This feature requires a valid license',
);
throw new ForbiddenException('This feature requires a valid license');
}
if (
typeof updateSpaceDto.allowViewerComments !== 'undefined' &&
!this.licenseCheckService.hasFeature(
workspace.licenseKey,
Feature.VIEWER_COMMENTS,
workspace.plan,
)
) {
throw new ForbiddenException('This feature requires a valid license');
}
}
@@ -179,6 +197,22 @@ export class SpaceService {
}
}
if (typeof updateSpaceDto.allowViewerComments !== 'undefined') {
const prev = settingsBefore?.comments?.allowViewerComments ?? false;
if (prev !== updateSpaceDto.allowViewerComments) {
before.allowViewerComments = prev;
after.allowViewerComments = updateSpaceDto.allowViewerComments;
}
await this.spaceRepo.updateCommentSettings(
updateSpaceDto.spaceId,
workspaceId,
'allowViewerComments',
updateSpaceDto.allowViewerComments,
trx,
);
}
updatedSpace = await this.spaceRepo.updateSpace(
{
name: updateSpaceDto.name,
@@ -35,4 +35,24 @@ export class UpdateUserDto extends PartialType(
@MaxLength(70)
@IsString()
confirmPassword: string;
@IsOptional()
@IsBoolean()
notificationPageUpdates: boolean;
@IsOptional()
@IsBoolean()
notificationPageUserMention: boolean;
@IsOptional()
@IsBoolean()
notificationCommentUserMention: boolean;
@IsOptional()
@IsBoolean()
notificationCommentCreated: boolean;
@IsOptional()
@IsBoolean()
notificationCommentResolved: boolean;
}
+19
View File
@@ -7,6 +7,7 @@ import {
UnauthorizedException,
} from '@nestjs/common';
import { UpdateUserDto } from './dto/update-user.dto';
import { NotificationSettingKey } from '../notification/notification.constants';
import { comparePasswordHash, diffAuditTrackedFields } from 'src/common/helpers/utils';
import { Workspace } from '@docmost/db/types/entity.types';
import { validateSsoEnforcement } from '../auth/auth.util';
@@ -60,6 +61,24 @@ export class UserService {
);
}
const notificationSettings: Record<string, NotificationSettingKey> = {
notificationPageUpdates: 'page.updated',
notificationPageUserMention: 'page.userMention',
notificationCommentUserMention: 'comment.userMention',
notificationCommentCreated: 'comment.created',
notificationCommentResolved: 'comment.resolved',
};
for (const [dtoField, settingKey] of Object.entries(notificationSettings)) {
if (typeof updateUserDto[dtoField] !== 'undefined') {
return this.userRepo.updateNotificationSetting(
userId,
settingKey,
updateUserDto[dtoField],
);
}
}
const userBefore = { name: user.name, email: user.email, locale: user.locale };
if (updateUserDto.name) {
@@ -0,0 +1,7 @@
import { IsString, IsNotEmpty } from 'class-validator';
export class SpaceWatcherDto {
@IsString()
@IsNotEmpty()
spaceId: string;
}
@@ -0,0 +1,95 @@
import {
Body,
Controller,
ForbiddenException,
HttpCode,
HttpStatus,
NotFoundException,
Post,
UseGuards,
} from '@nestjs/common';
import { WatcherService } from './watcher.service';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { User, Workspace } from '@docmost/db/types/entity.types';
import { SpaceWatcherDto } from './dto/space-watcher.dto';
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
import {
SpaceCaslAction,
SpaceCaslSubject,
} from '../casl/interfaces/space-ability.type';
@UseGuards(JwtAuthGuard)
@Controller('spaces')
export class SpaceWatcherController {
constructor(
private readonly watcherService: WatcherService,
private readonly spaceRepo: SpaceRepo,
private readonly spaceAbility: SpaceAbilityFactory,
) {}
private async loadSpaceAndAuthorize(
spaceId: string,
user: User,
workspace: Workspace,
) {
const space = await this.spaceRepo.findById(spaceId, workspace.id);
if (!space) {
throw new NotFoundException('Space not found');
}
const ability = await this.spaceAbility.createForUser(user, space.id);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Settings)) {
throw new ForbiddenException();
}
return space;
}
@HttpCode(HttpStatus.OK)
@Post('watch')
async watchSpace(
@Body() dto: SpaceWatcherDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const space = await this.loadSpaceAndAuthorize(dto.spaceId, user, workspace);
await this.watcherService.watchSpace(user.id, space.id, workspace.id);
return { watching: true };
}
@HttpCode(HttpStatus.OK)
@Post('unwatch')
async unwatchSpace(
@Body() dto: SpaceWatcherDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const space = await this.loadSpaceAndAuthorize(dto.spaceId, user, workspace);
await this.watcherService.unwatchSpace(user.id, space.id);
return { watching: false };
}
@HttpCode(HttpStatus.OK)
@Post('watch-status')
async getWatchStatus(
@Body() dto: SpaceWatcherDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const space = await this.loadSpaceAndAuthorize(dto.spaceId, user, workspace);
const watching = await this.watcherService.isWatchingSpace(
user.id,
space.id,
);
return { watching };
}
}
@@ -1,8 +1,6 @@
/***
import {
import {
Body,
Controller,
ForbiddenException,
HttpCode,
HttpStatus,
NotFoundException,
@@ -16,12 +14,7 @@ import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { User, Workspace } from '@docmost/db/types/entity.types';
import { WatcherPageDto } from './dto/watcher.dto';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
import {
SpaceCaslAction,
SpaceCaslSubject,
} from '../casl/interfaces/space-ability.type';
import { PageAccessService } from '../page/page-access/page-access.service';
@UseGuards(JwtAuthGuard)
@Controller('pages')
@@ -29,7 +22,7 @@ export class WatcherController {
constructor(
private readonly watcherService: WatcherService,
private readonly pageRepo: PageRepo,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly pageAccessService: PageAccessService,
) {}
@HttpCode(HttpStatus.OK)
@@ -44,10 +37,7 @@ export class WatcherController {
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);
await this.watcherService.watchPage(
user.id,
@@ -67,12 +57,14 @@ export class WatcherController {
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);
await this.watcherService.unwatchPage(user.id, page.id);
await this.watcherService.unwatchPage(
user.id,
page.id,
page.spaceId,
page.workspaceId,
);
return { watching: false };
}
@@ -85,15 +77,10 @@ export class WatcherController {
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);
const watching = await this.watcherService.isWatchingPage(user.id, page.id);
return { watching };
}
}
***/
@@ -1,10 +1,12 @@
import { Module } from '@nestjs/common';
import { WatcherService } from './watcher.service';
import { CaslModule } from '../casl/casl.module';
import { WatcherController } from './watcher.controller';
import { SpaceWatcherController } from './space-watcher.controller';
import { PageAccessModule } from '../page/page-access/page-access.module';
@Module({
imports: [CaslModule],
controllers: [],
imports: [PageAccessModule],
controllers: [WatcherController, SpaceWatcherController],
providers: [WatcherService],
exports: [WatcherService],
})
@@ -50,14 +50,44 @@ export class WatcherService {
return this.watcherRepo.insertMany(watchers, trx);
}
async unwatchPage(userId: string, pageId: string) {
return this.watcherRepo.mute(userId, pageId);
async unwatchPage(
userId: string,
pageId: string,
spaceId: string,
workspaceId: string,
) {
return this.watcherRepo.mute(userId, pageId, spaceId, workspaceId);
}
async isWatchingPage(userId: string, pageId: string): Promise<boolean> {
return this.watcherRepo.isWatching(userId, pageId);
}
async watchSpace(
userId: string,
spaceId: string,
workspaceId: string,
trx?: KyselyTransaction,
) {
const watcher: InsertableWatcher = {
userId,
pageId: null,
spaceId,
workspaceId,
type: WatcherType.SPACE,
addedById: userId,
};
return this.watcherRepo.upsertSpace(watcher, trx);
}
async unwatchSpace(userId: string, spaceId: string) {
return this.watcherRepo.deleteSpaceWatch(userId, spaceId);
}
async isWatchingSpace(userId: string, spaceId: string): Promise<boolean> {
return this.watcherRepo.isWatchingSpace(userId, spaceId);
}
async getPageWatchers(pageId: string, pagination: PaginationOptions) {
return this.watcherRepo.findPageWatchers(pageId, pagination);
}
@@ -1,4 +1,5 @@
import { IsNotEmpty, IsString, IsUUID } from 'class-validator';
import { IsEnum, IsNotEmpty, IsUUID } from 'class-validator';
import { UserRole } from '../../../common/helpers/types/permission';
export class UpdateWorkspaceUserRoleDto {
@IsNotEmpty()
@@ -6,6 +7,6 @@ export class UpdateWorkspaceUserRoleDto {
userId: string;
@IsNotEmpty()
@IsString()
@IsEnum(UserRole)
role: string;
}
@@ -46,6 +46,10 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
@IsBoolean()
mcpEnabled: boolean;
@IsOptional()
@IsBoolean()
aiChat: boolean;
@IsOptional()
@IsInt()
@Min(1)
@@ -22,6 +22,7 @@ import InvitationEmail from '@docmost/transactional/emails/invitation-email';
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
import InvitationAcceptedEmail from '@docmost/transactional/emails/invitation-accepted-email';
import { TokenService } from '../../auth/services/token.service';
import { SessionService } from '../../session/session.service';
import { nanoIdGen } from '../../../common/helpers';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
@@ -49,6 +50,7 @@ export class WorkspaceInvitationService {
private mailService: MailService,
private domainService: DomainService,
private tokenService: TokenService,
private sessionService: SessionService,
@InjectKysely() private readonly db: KyselyDB,
@InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue,
private readonly environmentService: EnvironmentService,
@@ -350,7 +352,7 @@ export class WorkspaceInvitationService {
};
}
const authToken = await this.tokenService.generateAccessToken(newUser);
const authToken = await this.sessionService.createSessionAndToken(newUser);
return { authToken };
}
@@ -7,6 +7,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import { LicenseCheckService } from '../../../integrations/environment/license-check.service';
import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo';
import { CreateWorkspaceDto } from '../dto/create-workspace.dto';
import { UpdateWorkspaceDto } from '../dto/update-workspace.dto';
import { SpaceService } from '../../space/services/space.service';
@@ -17,6 +18,7 @@ import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { executeTx } from '@docmost/db/utils';
import { InjectKysely } from 'nestjs-kysely';
import { Feature } from '../../../common/features';
import { User } from '@docmost/db/types/entity.types';
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
@@ -69,6 +71,7 @@ export class WorkspaceService {
@InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue,
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
private userSessionRepo: UserSessionRepo,
) {}
async findById(workspaceId: string) {
@@ -141,7 +144,7 @@ export class WorkspaceService {
status = WorkspaceStatus.Active;
plan = 'standard';
billingEmail = user.email;
settings = { ai: { generative: true } };
settings = { ai: { generative: true, chat: true } };
}
// create workspace
@@ -354,7 +357,7 @@ export class WorkspaceService {
typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined' ||
typeof updateWorkspaceDto.allowMemberTemplates !== 'undefined'
) {
if (!this.licenseCheckService.hasFeature(ws.licenseKey, 'security:settings', ws.plan)) {
if (!this.licenseCheckService.hasFeature(ws.licenseKey, Feature.SECURITY_SETTINGS, ws.plan)) {
throw new ForbiddenException(
'This feature requires a valid license',
);
@@ -473,12 +476,27 @@ export class WorkspaceService {
);
}
if (typeof updateWorkspaceDto.aiChat !== 'undefined') {
const prev = settingsBefore?.ai?.chat ?? false;
if (prev !== updateWorkspaceDto.aiChat) {
before.aiChat = prev;
after.aiChat = updateWorkspaceDto.aiChat;
}
await this.workspaceRepo.updateAiSettings(
workspaceId,
'chat',
updateWorkspaceDto.aiChat,
trx,
);
}
delete updateWorkspaceDto.restrictApiToAdmins;
delete updateWorkspaceDto.aiSearch;
delete updateWorkspaceDto.generativeAi;
delete updateWorkspaceDto.disablePublicSharing;
delete updateWorkspaceDto.mcpEnabled;
delete updateWorkspaceDto.allowMemberTemplates;
delete updateWorkspaceDto.aiChat;
await this.workspaceRepo.updateWorkspace(
updateWorkspaceDto,
@@ -686,11 +704,15 @@ export class WorkspaceService {
}
}
await this.userRepo.updateUser(
{ deactivatedAt: new Date() },
userId,
workspaceId,
);
await executeTx(this.db, async (trx) => {
await this.userRepo.updateUser(
{ deactivatedAt: new Date() },
userId,
workspaceId,
trx,
);
await this.userSessionRepo.revokeByUserId(userId, workspaceId, trx);
});
this.auditService.log({
event: AuditEvent.USER_DEACTIVATED,
@@ -808,6 +830,8 @@ export class WorkspaceService {
await this.favoriteRepo.deleteByUserAndWorkspace(userId, workspaceId, {
trx,
});
await this.userSessionRepo.revokeByUserId(userId, workspaceId, trx);
});
this.auditService.log({
@@ -17,6 +17,7 @@ import { KyselyDB } from '@docmost/db/types/kysely.types';
import * as process from 'node:process';
import { MigrationService } from '@docmost/db/services/migration.service';
import { UserTokenRepo } from './repos/user-token/user-token.repo';
import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo';
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo';
@@ -79,6 +80,7 @@ import { normalizePostgresUrl } from '../common/helpers';
FavoriteRepo,
AttachmentRepo,
UserTokenRepo,
UserSessionRepo,
BacklinkRepo,
ShareRepo,
NotificationRepo,
@@ -100,6 +102,7 @@ import { normalizePostgresUrl } from '../common/helpers';
FavoriteRepo,
AttachmentRepo,
UserTokenRepo,
UserSessionRepo,
BacklinkRepo,
ShareRepo,
NotificationRepo,
@@ -0,0 +1,45 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('user_sessions')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('user_id', 'uuid', (col) =>
col.notNull().references('users.id').onDelete('cascade'),
)
.addColumn('workspace_id', 'uuid', (col) =>
col.notNull().references('workspaces.id').onDelete('cascade'),
)
.addColumn('device_name', 'varchar')
.addColumn('user_agent', 'text')
.addColumn('ip_address', sql`inet`)
.addColumn('geo_location', 'varchar')
.addColumn('last_active_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('expires_at', 'timestamptz', (col) => col.notNull())
.addColumn('metadata', 'jsonb')
.addColumn('revoked_at', 'timestamptz')
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.execute();
await sql`
CREATE INDEX idx_user_sessions_active
ON user_sessions (user_id, workspace_id, last_active_at DESC)
WHERE revoked_at IS NULL
`.execute(db);
await sql`
CREATE INDEX idx_user_sessions_revoked
ON user_sessions (expires_at)
WHERE revoked_at IS NOT NULL
`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('user_sessions').execute();
}
@@ -0,0 +1,333 @@
import { type Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createIndex('idx_group_users_user_id')
.ifNotExists()
.on('group_users')
.column('user_id')
.execute();
await db.schema
.createIndex('idx_space_members_user_id')
.ifNotExists()
.on('space_members')
.column('user_id')
.execute();
await db.schema
.createIndex('idx_space_members_group_id')
.ifNotExists()
.on('space_members')
.column('group_id')
.execute();
// Page tree
await sql`
CREATE INDEX IF NOT EXISTS idx_pages_space_parent_position
ON pages (space_id, parent_page_id, position COLLATE "C")
WHERE deleted_at IS NULL
`.execute(db);
await sql`
CREATE INDEX IF NOT EXISTS idx_pages_parent_page_id
ON pages (parent_page_id)
WHERE deleted_at IS NULL
`.execute(db);
// Recent pages query
await sql`
CREATE INDEX IF NOT EXISTS idx_pages_space_updated
ON pages (space_id, updated_at DESC)
WHERE deleted_at IS NULL
`.execute(db);
// Trash view
await sql`
CREATE INDEX IF NOT EXISTS idx_pages_space_deleted
ON pages (space_id, deleted_at DESC)
WHERE deleted_at IS NOT NULL
`.execute(db);
await sql`
CREATE UNIQUE INDEX IF NOT EXISTS idx_workspaces_hostname_lower
ON workspaces (LOWER(hostname))
`.execute(db);
await db.schema
.createIndex('idx_workspaces_created_at')
.ifNotExists()
.on('workspaces')
.column('created_at')
.execute();
await db.schema
.createIndex('idx_users_workspace_deleted')
.ifNotExists()
.on('users')
.columns(['workspace_id', 'deleted_at'])
.execute();
await sql`
CREATE UNIQUE INDEX IF NOT EXISTS idx_spaces_slug_lower_workspace
ON spaces (LOWER(slug), workspace_id)
`.execute(db);
await db.schema
.createIndex('idx_spaces_workspace_id')
.ifNotExists()
.on('spaces')
.column('workspace_id')
.execute();
await sql`
CREATE UNIQUE INDEX IF NOT EXISTS idx_groups_name_lower_workspace
ON groups (LOWER(name), workspace_id)
`.execute(db);
await db.schema
.createIndex('idx_groups_workspace_id')
.ifNotExists()
.on('groups')
.column('workspace_id')
.execute();
await db.schema
.createIndex('idx_shares_page_id')
.ifNotExists()
.on('shares')
.column('page_id')
.execute();
await db.schema
.createIndex('idx_attachments_page_id')
.ifNotExists()
.on('attachments')
.column('page_id')
.execute();
await db.schema
.createIndex('idx_attachments_space_id')
.ifNotExists()
.on('attachments')
.column('space_id')
.execute();
await db.schema
.createIndex('idx_comments_page_id')
.ifNotExists()
.on('comments')
.column('page_id')
.execute();
await db.schema
.createIndex('idx_comments_parent_comment_id')
.ifNotExists()
.on('comments')
.column('parent_comment_id')
.execute();
await sql`
CREATE INDEX IF NOT EXISTS idx_page_history_page_created
ON page_history (page_id, created_at DESC)
`.execute(db);
await db.schema
.createIndex('idx_attachments_workspace_id')
.ifNotExists()
.on('attachments')
.column('workspace_id')
.execute();
await db.schema
.createIndex('idx_backlinks_target_page_id')
.ifNotExists()
.on('backlinks')
.column('target_page_id')
.execute();
await db.schema
.createIndex('idx_pages_workspace_id')
.ifNotExists()
.on('pages')
.column('workspace_id')
.execute();
await db.schema
.createIndex('idx_pages_creator_id')
.ifNotExists()
.on('pages')
.column('creator_id')
.execute();
// Notifications: FK cascade from pages, spaces, comments
await db.schema
.createIndex('idx_notifications_page_id')
.ifNotExists()
.on('notifications')
.column('page_id')
.execute();
await db.schema
.createIndex('idx_notifications_space_id')
.ifNotExists()
.on('notifications')
.column('space_id')
.execute();
await db.schema
.createIndex('idx_notifications_comment_id')
.ifNotExists()
.on('notifications')
.column('comment_id')
.execute();
// Watchers: cleanup queries and FK cascade
await db.schema
.createIndex('idx_watchers_user_workspace')
.ifNotExists()
.on('watchers')
.columns(['user_id', 'workspace_id'])
.execute();
await db.schema
.createIndex('idx_watchers_space_id')
.ifNotExists()
.on('watchers')
.column('space_id')
.execute();
// Auth providers: all queries filter by workspaceId
await db.schema
.createIndex('idx_auth_providers_workspace_id')
.ifNotExists()
.on('auth_providers')
.column('workspace_id')
.execute();
// Auth accounts: SSO login lookup by provider user
await db.schema
.createIndex('idx_auth_accounts_provider_user_id')
.ifNotExists()
.on('auth_accounts')
.columns(['provider_user_id', 'auth_provider_id'])
.execute();
// Workspace invitations: listing and SSO lookup
await db.schema
.createIndex('idx_workspace_invitations_workspace_id')
.ifNotExists()
.on('workspace_invitations')
.column('workspace_id')
.execute();
// API keys: query and FK cascade
await db.schema
.createIndex('idx_api_keys_workspace_id')
.ifNotExists()
.on('api_keys')
.column('workspace_id')
.execute();
// User sessions: delete queries and FK cascade on all session states
await db.schema
.createIndex('idx_user_sessions_user_workspace')
.ifNotExists()
.on('user_sessions')
.columns(['user_id', 'workspace_id'])
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropIndex('idx_group_users_user_id').ifExists().execute();
await db.schema.dropIndex('idx_space_members_user_id').ifExists().execute();
await db.schema.dropIndex('idx_space_members_group_id').ifExists().execute();
await db.schema
.dropIndex('idx_pages_space_parent_position')
.ifExists()
.execute();
await db.schema.dropIndex('idx_pages_parent_page_id').ifExists().execute();
await db.schema.dropIndex('idx_pages_space_updated').ifExists().execute();
await db.schema.dropIndex('idx_pages_space_deleted').ifExists().execute();
await db.schema
.dropIndex('idx_workspaces_hostname_lower')
.ifExists()
.execute();
await db.schema.dropIndex('idx_workspaces_created_at').ifExists().execute();
await db.schema
.dropIndex('idx_users_workspace_deleted')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_spaces_slug_lower_workspace')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_spaces_workspace_id')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_groups_name_lower_workspace')
.ifExists()
.execute();
await db.schema.dropIndex('idx_groups_workspace_id').ifExists().execute();
await db.schema.dropIndex('idx_shares_page_id').ifExists().execute();
await db.schema.dropIndex('idx_attachments_page_id').ifExists().execute();
await db.schema.dropIndex('idx_attachments_space_id').ifExists().execute();
await db.schema.dropIndex('idx_comments_page_id').ifExists().execute();
await db.schema
.dropIndex('idx_comments_parent_comment_id')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_page_history_page_created')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_attachments_workspace_id')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_backlinks_target_page_id')
.ifExists()
.execute();
await db.schema.dropIndex('idx_pages_workspace_id').ifExists().execute();
await db.schema.dropIndex('idx_pages_creator_id').ifExists().execute();
await db.schema
.dropIndex('idx_notifications_page_id')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_notifications_space_id')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_notifications_comment_id')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_watchers_user_workspace')
.ifExists()
.execute();
await db.schema.dropIndex('idx_watchers_space_id').ifExists().execute();
await db.schema
.dropIndex('idx_auth_providers_workspace_id')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_auth_accounts_provider_user_id')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_workspace_invitations_workspace_id')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_api_keys_workspace_id')
.ifExists()
.execute();
await db.schema
.dropIndex('idx_user_sessions_user_workspace')
.ifExists()
.execute();
}
@@ -0,0 +1,118 @@
import { type Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('ai_chats')
.ifNotExists()
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('workspace_id', 'uuid', (col) =>
col.references('workspaces.id').onDelete('cascade').notNull(),
)
.addColumn('creator_id', 'uuid', (col) =>
col.references('users.id').notNull(),
)
.addColumn('title', 'varchar', (col) => col)
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('updated_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('deleted_at', 'timestamptz', (col) => col)
.execute();
await db.schema
.createIndex('idx_ai_chats_workspace_creator')
.ifNotExists()
.on('ai_chats')
.columns(['workspace_id', 'creator_id', 'id'])
.execute();
await db.schema
.createTable('ai_chat_messages')
.ifNotExists()
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('chat_id', 'uuid', (col) =>
col.references('ai_chats.id').onDelete('cascade').notNull(),
)
.addColumn('workspace_id', 'uuid', (col) =>
col.references('workspaces.id').onDelete('cascade').notNull(),
)
.addColumn('user_id', 'uuid', (col) =>
col.references('users.id').onDelete('set null'),
)
.addColumn('role', 'varchar', (col) => col.notNull())
.addColumn('content', 'text', (col) => col)
.addColumn('tool_calls', 'jsonb', (col) => col)
.addColumn('metadata', 'jsonb', (col) => col)
.addColumn('tsv', sql`tsvector`, (col) => col)
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('updated_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('deleted_at', 'timestamptz', (col) => col)
.execute();
await db.schema
.createIndex('idx_ai_chat_messages_chat_id')
.ifNotExists()
.on('ai_chat_messages')
.columns(['chat_id', 'id'])
.execute();
await db.schema
.createIndex('idx_ai_chat_messages_tsv')
.ifNotExists()
.on('ai_chat_messages')
.using('GIN')
.column('tsv')
.execute();
//ts-vector
await sql`
CREATE OR REPLACE FUNCTION ai_chat_messages_tsvector_trigger() RETURNS trigger AS $$
BEGIN
NEW.tsv := to_tsvector('english', f_unaccent(substring(coalesce(NEW.content, ''), 1, 100000)));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
`.execute(db);
await sql`
CREATE OR REPLACE TRIGGER ai_chat_messages_tsvector_update
BEFORE INSERT OR UPDATE ON ai_chat_messages
FOR EACH ROW EXECUTE FUNCTION ai_chat_messages_tsvector_trigger();
`.execute(db);
await db.schema
.alterTable('attachments')
.addColumn('ai_chat_id', 'uuid', (col) => col)
.execute();
await db.schema
.createIndex('idx_attachments_ai_chat_id')
.ifNotExists()
.on('attachments')
.column('ai_chat_id')
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropIndex('idx_attachments_ai_chat_id').execute();
await db.schema.alterTable('attachments').dropColumn('ai_chat_id').execute();
await sql`DROP TRIGGER IF EXISTS ai_chat_messages_tsvector_update ON ai_chat_messages`.execute(
db,
);
await sql`DROP FUNCTION IF EXISTS ai_chat_messages_tsvector_trigger`.execute(
db,
);
await db.schema.dropTable('ai_chat_messages').execute();
await db.schema.dropTable('ai_chats').execute();
}
@@ -7,6 +7,7 @@ import {
InsertableAttachment,
UpdatableAttachment,
} from '@docmost/db/types/entity.types';
import { AttachmentType } from '../../../core/attachment/attachment.constants';
@Injectable()
export class AttachmentRepo {
@@ -23,6 +24,7 @@ export class AttachmentRepo {
'creatorId',
'pageId',
'spaceId',
'aiChatId',
'workspaceId',
'createdAt',
'updatedAt',
@@ -44,6 +46,21 @@ export class AttachmentRepo {
.executeTakeFirst();
}
async findByIdWithContent(
attachmentId: string,
opts?: {
trx?: KyselyTransaction;
},
): Promise<Attachment> {
const db = dbOrTx(this.db, opts?.trx);
return db
.selectFrom('attachments')
.select([...this.baseFields, 'textContent'])
.where('id', '=', attachmentId)
.executeTakeFirst();
}
async insertAttachment(
insertableAttachment: InsertableAttachment,
trx?: KyselyTransaction,
@@ -72,6 +89,21 @@ export class AttachmentRepo {
.execute();
}
async findByAiChatId(
aiChatId: string,
opts?: {
trx?: KyselyTransaction;
},
): Promise<Attachment[]> {
const db = dbOrTx(this.db, opts?.trx);
return db
.selectFrom('attachments')
.select(this.baseFields)
.where('aiChatId', '=', aiChatId)
.execute();
}
updateAttachmentsByPageId(
updatableAttachment: UpdatableAttachment,
pageIds: string[],
@@ -97,6 +129,25 @@ export class AttachmentRepo {
.executeTakeFirst();
}
async claimAttachmentsForChat(
attachmentIds: string[],
aiChatId: string,
creatorId: string,
workspaceId: string,
): Promise<void> {
if (attachmentIds.length === 0) return;
await this.db
.updateTable('attachments')
.set({ aiChatId })
.where('id', 'in', attachmentIds)
.where('creatorId', '=', creatorId)
.where('workspaceId', '=', workspaceId)
.where('type', '=', AttachmentType.Chat)
.where('aiChatId', 'is', null)
.execute();
}
async deleteAttachmentById(attachmentId: string): Promise<void> {
await this.db
.deleteFrom('attachments')
@@ -11,6 +11,7 @@ import { ExpressionBuilder } from 'kysely';
import { DB } from '@docmost/db/types/db';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { NotificationTab, NotificationType } from '../../../core/notification/notification.constants';
@Injectable()
export class NotificationRepo {
@@ -27,8 +28,12 @@ export class NotificationRepo {
.executeTakeFirst();
}
async findByUserId(userId: string, pagination: PaginationOptions) {
const query = this.db
async findByUserId(
userId: string,
pagination: PaginationOptions,
type: NotificationTab = 'all',
) {
let query = this.db
.selectFrom('notifications')
.selectAll('notifications')
.select((eb) => this.withActor(eb))
@@ -42,6 +47,12 @@ export class NotificationRepo {
]),
);
if (type === 'direct') {
query = query.where('type', '!=', NotificationType.PAGE_UPDATED);
} else if (type === 'updates') {
query = query.where('type', '=', NotificationType.PAGE_UPDATED);
}
return executeWithCursorPagination(query, {
perPage: pagination.limit,
cursor: pagination.cursor,
@@ -138,6 +149,29 @@ export class NotificationRepo {
.execute();
}
async getRecentlyNotifiedUserIds(
userIds: string[],
pageId: string,
type: string,
withinHours: number,
): Promise<Set<string>> {
if (userIds.length === 0) return new Set();
const cutoff = new Date(Date.now() - withinHours * 60 * 60 * 1000);
const rows = await this.db
.selectFrom('notifications')
.select('userId')
.where('userId', 'in', userIds)
.where('pageId', '=', pageId)
.where('type', '=', type)
.where('createdAt', '>', cutoff)
.groupBy('userId')
.execute();
return new Set(rows.map((r) => r.userId));
}
withActor(eb: ExpressionBuilder<DB, 'notifications'>) {
return jsonObjectFrom(
eb
@@ -0,0 +1,162 @@
import {
InsertableUserSession,
UserSession,
} from '@docmost/db/types/entity.types';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { dbOrTx } from '@docmost/db/utils';
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { sql } from 'kysely';
@Injectable()
export class UserSessionRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
async insertSession(
session: InsertableUserSession,
trx?: KyselyTransaction,
): Promise<UserSession> {
const db = dbOrTx(this.db, trx);
return db
.insertInto('userSessions')
.values(session)
.returningAll()
.executeTakeFirstOrThrow();
}
async findActiveById(id: string): Promise<UserSession | undefined> {
return this.db
.selectFrom('userSessions')
.selectAll()
.where('id', '=', id)
.where('expiresAt', '>', new Date())
.where('revokedAt', 'is', null)
.executeTakeFirst();
}
async findActiveByUser(
userId: string,
workspaceId: string,
): Promise<UserSession[]> {
return this.db
.selectFrom('userSessions')
.selectAll()
.where('userId', '=', userId)
.where('workspaceId', '=', workspaceId)
.where('expiresAt', '>', new Date())
.where('revokedAt', 'is', null)
.orderBy('lastActiveAt', 'desc')
.execute();
}
async updateLastActiveAt(id: string): Promise<void> {
await this.db
.updateTable('userSessions')
.set({ lastActiveAt: new Date() })
.where('id', '=', id)
.execute();
}
async revokeById(
id: string,
userId: string,
workspaceId: string,
): Promise<void> {
await this.db
.updateTable('userSessions')
.set({ revokedAt: new Date() })
.where('id', '=', id)
.where('userId', '=', userId)
.where('workspaceId', '=', workspaceId)
.where('revokedAt', 'is', null)
.execute();
}
async revokeAllExceptCurrent(
currentSessionId: string,
userId: string,
workspaceId: string,
): Promise<void> {
await this.db
.updateTable('userSessions')
.set({ revokedAt: new Date() })
.where('userId', '=', userId)
.where('workspaceId', '=', workspaceId)
.where('id', '!=', currentSessionId)
.where('revokedAt', 'is', null)
.execute();
}
async revokeByUserId(
userId: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.updateTable('userSessions')
.set({ revokedAt: new Date() })
.where('userId', '=', userId)
.where('workspaceId', '=', workspaceId)
.where('revokedAt', 'is', null)
.execute();
}
async deleteByUserId(
userId: string,
workspaceId: string,
): Promise<void> {
await this.db
.deleteFrom('userSessions')
.where('userId', '=', userId)
.where('workspaceId', '=', workspaceId)
.execute();
}
async deleteAllExceptCurrent(
currentSessionId: string,
userId: string,
workspaceId: string,
): Promise<void> {
await this.db
.deleteFrom('userSessions')
.where('userId', '=', userId)
.where('workspaceId', '=', workspaceId)
.where('id', '!=', currentSessionId)
.execute();
}
async deleteStale(retentionDays: number): Promise<void> {
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000);
await this.db
.deleteFrom('userSessions')
.where((eb) =>
eb.or([
eb('revokedAt', '<', cutoff),
eb('expiresAt', '<', cutoff),
]),
)
.execute();
}
async trimExcessSessions(maxPerUser: number): Promise<void> {
const overflowed = await this.db
.selectFrom('userSessions')
.select(['userId', 'workspaceId'])
.groupBy(['userId', 'workspaceId'])
.having(sql`COUNT(*)`, '>', maxPerUser)
.execute();
for (const { userId, workspaceId } of overflowed) {
await sql`
DELETE FROM user_sessions
WHERE id IN (
SELECT id FROM user_sessions
WHERE user_id = ${userId} AND workspace_id = ${workspaceId}
ORDER BY last_active_at DESC
OFFSET ${maxPerUser}
)
`.execute(this.db);
}
}
}
@@ -111,6 +111,28 @@ export class SpaceRepo {
.executeTakeFirst();
}
async updateCommentSettings(
spaceId: string,
workspaceId: string,
prefKey: string,
prefValue: string | boolean,
trx?: KyselyTransaction,
) {
const db = dbOrTx(this.db, trx);
return db
.updateTable('spaces')
.set({
settings: sql`COALESCE(settings, '{}'::jsonb)
|| jsonb_build_object('comments', COALESCE(settings->'comments', '{}'::jsonb)
|| jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`,
updatedAt: new Date(),
})
.where('id', '=', spaceId)
.where('workspaceId', '=', workspaceId)
.returningAll()
.executeTakeFirst();
}
async insertSpace(
insertableSpace: InsertableSpace,
trx?: KyselyTransaction,
@@ -13,6 +13,7 @@ import { PaginationOptions } from '../../pagination/pagination-options';
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
import { ExpressionBuilder, sql } from 'kysely';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { NotificationSettingKey } from '../../../core/notification/notification.constants';
@Injectable()
export class UserRepo {
@@ -191,6 +192,24 @@ export class UserRepo {
.executeTakeFirst();
}
async updateNotificationSetting(
userId: string,
settingKey: NotificationSettingKey,
settingValue: boolean,
) {
return await this.db
.updateTable('users')
.set({
settings: sql`COALESCE(settings, '{}'::jsonb)
|| jsonb_build_object('notifications', COALESCE(settings->'notifications', '{}'::jsonb)
|| jsonb_build_object(${sql.lit(settingKey)}, ${sql.lit(settingValue)}))`,
updatedAt: new Date(),
})
.where('id', '=', userId)
.returning(this.baseFields)
.executeTakeFirst();
}
withUserMfa(eb: ExpressionBuilder<DB, 'users'>) {
return jsonObjectFrom(
eb
@@ -20,18 +20,6 @@ export type WatcherType = (typeof WatcherType)[keyof typeof WatcherType];
export class WatcherRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
async findByUserAndPage(
userId: string,
pageId: string,
): Promise<Watcher | undefined> {
return this.db
.selectFrom('watchers')
.selectAll()
.where('userId', '=', userId)
.where('pageId', '=', pageId)
.executeTakeFirst();
}
async findPageWatchers(pageId: string, pagination: PaginationOptions) {
const query = this.db
.selectFrom('watchers')
@@ -66,6 +54,53 @@ export class WatcherRepo {
return watchers.map((w) => w.userId);
}
/**
* Recipients for a `page.updated` notification, combining:
* - Active page watchers on this page, AND
* - Active space watchers on this space, EXCLUDING any user who has a
* muted page watcher row for this page (per-page mute always wins).
*
* Deduplicated at the SQL level — a user watching both the page and the
* containing space appears once.
*/
async getPageUpdateRecipientIds(
pageId: string,
spaceId: string,
trx?: KyselyTransaction,
): Promise<string[]> {
const db = dbOrTx(this.db, trx);
const pageWatchers = db
.selectFrom('watchers')
.select('userId')
.where('pageId', '=', pageId)
.where('type', '=', WatcherType.PAGE)
.where('mutedAt', 'is', null);
const spaceWatchers = db
.selectFrom('watchers as sw')
.select('sw.userId')
.where('sw.spaceId', '=', spaceId)
.where('sw.pageId', 'is', null)
.where('sw.type', '=', WatcherType.SPACE)
.where((eb) =>
eb.not(
eb.exists(
eb
.selectFrom('watchers as pw')
.select('pw.id')
.whereRef('pw.userId', '=', 'sw.userId')
.where('pw.pageId', '=', pageId)
.where('pw.type', '=', WatcherType.PAGE)
.where('pw.mutedAt', 'is not', null),
),
),
);
const rows = await pageWatchers.union(spaceWatchers).execute();
return [...new Set(rows.map((r) => r.userId))];
}
async insert(
watcher: InsertableWatcher,
trx?: KyselyTransaction,
@@ -110,20 +145,81 @@ export class WatcherRepo {
.executeTakeFirst();
}
async upsertSpace(
watcher: InsertableWatcher,
trx?: KyselyTransaction,
): Promise<Watcher | undefined> {
const db = dbOrTx(this.db, trx);
return db
.insertInto('watchers')
.values(watcher)
.onConflict((oc) =>
oc
.columns(['userId', 'spaceId'])
.where('pageId', 'is', null)
.doNothing(),
)
.returningAll()
.executeTakeFirst();
}
async mute(
userId: string,
pageId: string,
spaceId: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
const mutedAt = new Date();
await db
.insertInto('watchers')
.values({
userId,
pageId,
spaceId,
workspaceId,
type: WatcherType.PAGE,
addedById: userId,
mutedAt,
})
.onConflict((oc) =>
oc
.columns(['userId', 'pageId'])
.where('pageId', 'is not', null)
.doUpdateSet({ mutedAt }),
)
.execute();
}
async deleteSpaceWatch(
userId: string,
spaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.updateTable('watchers')
.set({ mutedAt: new Date() })
.deleteFrom('watchers')
.where('userId', '=', userId)
.where('pageId', '=', pageId)
.where('spaceId', '=', spaceId)
.where('pageId', 'is', null)
.where('type', '=', WatcherType.SPACE)
.execute();
}
async isWatchingSpace(userId: string, spaceId: string): Promise<boolean> {
const watcher = await this.db
.selectFrom('watchers')
.select('id')
.where('userId', '=', userId)
.where('spaceId', '=', spaceId)
.where('pageId', 'is', null)
.where('type', '=', WatcherType.SPACE)
.executeTakeFirst();
return !!watcher;
}
async isWatching(userId: string, pageId: string): Promise<boolean> {
const watcher = await this.db
.selectFrom('watchers')
@@ -164,14 +260,14 @@ export class WatcherRepo {
.where('spaceId', '=', spaceId)
.where('userId', 'is not', null)
.union(
this.db
db
.selectFrom('spaceMembers')
.innerJoin('groupUsers', 'groupUsers.groupId', 'spaceMembers.groupId')
.select('groupUsers.userId')
.where('spaceMembers.spaceId', '=', spaceId),
);
await this.db
await db
.deleteFrom('watchers')
.where('userId', 'in', userIds)
.where('spaceId', '=', spaceId)
+44
View File
@@ -43,6 +43,7 @@ export interface ApiKeys {
}
export interface Attachments {
aiChatId: string | null;
createdAt: Generated<Timestamp>;
creatorId: string;
deletedAt: Timestamp | null;
@@ -459,7 +460,49 @@ export interface Templates {
deletedAt: Timestamp | null;
}
export interface AiChats {
id: Generated<string>;
workspaceId: string;
creatorId: string;
title: string | null;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
deletedAt: Timestamp | null;
}
export interface AiChatMessages {
id: Generated<string>;
chatId: string;
workspaceId: string;
userId: string | null;
role: string;
content: string | null;
toolCalls: Json | null;
metadata: Json | null;
tsv: string | null;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
deletedAt: Timestamp | null;
}
export interface UserSessions {
id: Generated<string>;
userId: string;
workspaceId: string;
deviceName: string | null;
userAgent: string | null;
ipAddress: string | null;
geoLocation: string | null;
metadata: Json | null;
lastActiveAt: Generated<Timestamp>;
expiresAt: Timestamp;
revokedAt: Timestamp | null;
createdAt: Generated<Timestamp>;
}
export interface DB {
aiChats: AiChats;
aiChatMessages: AiChatMessages;
apiKeys: ApiKeys;
attachments: Attachments;
audit: Audit;
@@ -483,6 +526,7 @@ export interface DB {
templates: Templates;
userMfa: UserMfa;
users: Users;
userSessions: UserSessions;
userTokens: UserTokens;
watchers: Watchers;
workspaceInvitations: WorkspaceInvitations;
@@ -1,5 +1,7 @@
import { Insertable, Selectable, Updateable } from 'kysely';
import {
AiChats,
AiChatMessages,
Attachments,
Comments,
Groups,
@@ -23,6 +25,7 @@ import {
Favorites,
FileTasks,
UserMfa as _UserMFA,
UserSessions,
ApiKeys,
Watchers,
Audit as _Audit,
@@ -30,6 +33,21 @@ import {
} from './db';
import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
// AI Chat
export type AiChat = Selectable<AiChats>;
export type InsertableAiChat = Insertable<AiChats>;
export type UpdatableAiChat = Updateable<Omit<AiChats, 'id'>>;
// AI Chat Message
// `tsv` is an internal tsvector column maintained by a trigger for
// full-text search. It is omitted from the public type so it never leaks
// into HTTP responses or the chat history fed to the language model.
export type AiChatMessage = Omit<Selectable<AiChatMessages>, 'tsv'>;
export type InsertableAiChatMessage = Omit<
Insertable<AiChatMessages>,
'tsv'
>;
// Workspace
export type Workspace = Selectable<Workspaces>;
export type InsertableWorkspace = Insertable<Workspaces>;
@@ -164,6 +182,11 @@ export type PagePermission = Selectable<_PagePermissions>;
export type InsertablePagePermission = Insertable<_PagePermissions>;
export type UpdatablePagePermission = Updateable<Omit<_PagePermissions, 'id'>>;
// User Session
export type UserSession = Selectable<UserSessions>;
export type InsertableUserSession = Insertable<UserSessions>;
export type UpdatableUserSession = Updateable<Omit<UserSessions, 'id'>>;
// Audit
export type Audit = Selectable<_Audit>;
export type InsertableAudit = Insertable<_Audit>;
@@ -252,6 +252,13 @@ export class EnvironmentService {
return this.configService.get<string>('AI_COMPLETION_MODEL');
}
getAiChatModel(): string {
return (
this.configService.get<string>('AI_CHAT_MODEL') ||
this.configService.get<string>('AI_COMPLETION_MODEL')
);
}
getAiEmbeddingDimension(): number {
return parseInt(
this.configService.get<string>('AI_EMBEDDING_DIMENSION'),
@@ -259,6 +266,12 @@ export class EnvironmentService {
);
}
getAiEmbeddingSupportsMrl(): boolean | undefined {
const val = this.configService.get<string>('AI_EMBEDDING_SUPPORTS_MRL');
if (val === undefined || val === null || val === '') return undefined;
return val === 'true';
}
getOpenAiApiKey(): string {
return this.configService.get<string>('OPENAI_API_KEY');
}
@@ -117,6 +117,12 @@ export class EnvironmentVariables {
@IsString()
AI_EMBEDDING_DIMENSION: string;
@IsOptional()
@ValidateIf((obj) => obj.AI_EMBEDDING_SUPPORTS_MRL)
@IsIn(['true', 'false'])
@IsString()
AI_EMBEDDING_SUPPORTS_MRL: string;
@ValidateIf((obj) => obj.AI_DRIVER)
@IsString()
@IsNotEmpty()
@@ -61,7 +61,7 @@ export class ExportController {
await this.pageAccessService.validateCanView(page, user);
const zipFileStream = await this.exportService.exportPages(
const result = await this.exportService.exportPages(
dto.pageId,
dto.format,
dto.includeAttachments,
@@ -83,15 +83,29 @@ export class ExportController {
},
});
const fileName = sanitize(page.title || 'untitled') + '.zip';
if (result.type === 'file') {
const ext = getExportExtension(dto.format);
const fileName = sanitize(page.title || 'untitled') + ext;
const contentType = getMimeType(path.extname(fileName));
res.headers({
'Content-Type': 'application/zip',
'Content-Disposition':
'attachment; filename="' + encodeURIComponent(fileName) + '"',
});
res.headers({
'Content-Type': contentType,
'Content-Disposition':
'attachment; filename="' + encodeURIComponent(fileName) + '"',
});
res.send(zipFileStream);
res.send(result.content);
} else {
const fileName = sanitize(page.title || 'untitled') + '.zip';
res.headers({
'Content-Type': 'application/zip',
'Content-Disposition':
'attachment; filename="' + encodeURIComponent(fileName) + '"',
});
res.send(result.stream);
}
}
@UseGuards(JwtAuthGuard)
@@ -28,8 +28,7 @@ import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { Node } from '@tiptap/pm/model';
import { EditorState } from '@tiptap/pm/state';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import slugify = require('@sindresorhus/slugify');
import slugify from '@sindresorhus/slugify';
// eslint-disable-next-line @typescript-eslint/no-require-imports
const packageJson = require('../../../package.json');
import { EnvironmentService } from '../environment/environment.service';
@@ -151,6 +150,13 @@ export class ExportService {
// set to null to make export of pages with parentId work
pages[parentPageIndex].parentPageId = null;
const isSinglePage = pages.length === 1 && !includeAttachments;
if (isSinglePage) {
const pageContent = await this.exportPage(format, pages[0], true);
return { type: 'file' as const, content: pageContent, page: pages[0] };
}
const tree = buildTree(pages as Page[]);
const baseUrl = await this.getWorkspaceBaseUrl(pages[0].workspaceId);
@@ -171,7 +177,7 @@ export class ExportService {
compression: 'DEFLATE',
});
return zipFile;
return { type: 'zip' as const, stream: zipFile, page: pages[0] };
}
async exportSpace(
@@ -347,7 +353,7 @@ export class ExportService {
if (attachmentIds.length > 0) {
const attachments = await this.db
.selectFrom('attachments')
.selectAll()
.select(['id', 'fileName', 'filePath'])
.where('id', 'in', attachmentIds)
.where('spaceId', '=', spaceId)
.execute();
@@ -190,13 +190,41 @@ export class ImportAttachmentService {
}
}
// Build a map from resolved archive path → real filename from Confluence
// metadata. Confluence Server archives often store files under numeric IDs
// (e.g. "attachments/65601/65602") instead of the original filename.
// Also register aliases so HTML references using the original filename
// (e.g. "attachments/pageId/original.mp3") resolve to the numeric path.
const pageDir = path.dirname(pageRelativePath);
const attachmentNameByRelPath = new Map<string, string>();
for (const attachment of pageAttachments) {
const relPath = resolveRelativeAttachmentPath(
attachment.href,
pageDir,
attachmentCandidates,
);
if (relPath && attachment.fileName) {
attachmentNameByRelPath.set(relPath, attachment.fileName);
const dir = path.posix.dirname(relPath);
const aliasKey = `${dir}/${attachment.fileName}`;
if (!attachmentCandidates.has(aliasKey)) {
attachmentCandidates.set(aliasKey, attachmentCandidates.get(relPath)!);
attachmentNameByRelPath.set(aliasKey, attachment.fileName);
}
}
}
const uploadOnce = (relPath: string) => {
const abs = attachmentCandidates.get(relPath)!;
const attachmentId = v7();
const ext = path.extname(abs);
const realName = attachmentNameByRelPath.get(relPath);
const baseName = realName || path.basename(abs);
const ext = path.extname(baseName);
const fileNameWithExt =
sanitizeFileName(path.basename(abs, ext)) + ext.toLowerCase();
sanitizeFileName(path.basename(baseName, ext)) + ext.toLowerCase();
const storageFilePath = `${getAttachmentFolderPath(
AttachmentType.File,
@@ -240,7 +268,6 @@ export class ImportAttachmentService {
return fresh;
};
const pageDir = path.dirname(pageRelativePath);
const $ = load(html);
// image
@@ -335,6 +362,28 @@ export class ImportAttachmentService {
unwrapFromParagraph($, $vid);
}
// audio
for (const audEl of $('audio').toArray()) {
const $aud = $(audEl);
const src = cleanUrlString($aud.attr('src') ?? '')!;
if (!src || src.startsWith('http')) continue;
const relPath = resolveRelativeAttachmentPath(
src,
pageDir,
attachmentCandidates,
);
if (!relPath) continue;
const { attachmentId, apiFilePath } = processFile(relPath);
$aud
.attr('src', apiFilePath)
.attr('data-attachment-id', attachmentId);
unwrapFromParagraph($, $aud);
}
// <div data-type="attachment">
for (const el of $('div[data-type="attachment"]').toArray()) {
const $oldDiv = $(el);
@@ -401,7 +450,18 @@ export class ImportAttachmentService {
const { attachmentId, apiFilePath, abs } = processFile(relPath);
const ext = path.extname(relPath).toLowerCase();
if (ext === '.mp4') {
const audioExtensions = new Set(['.mp3', '.wav', '.ogg', '.m4a', '.webm', '.flac', '.aac']);
if (ext === '.pdf') {
const $pdf = $('<div>')
.attr('data-type', 'pdf')
.attr('src', apiFilePath)
.attr('data-attachment-id', attachmentId)
.attr('width', '800')
.attr('height', '600');
$a.replaceWith($pdf);
unwrapFromParagraph($, $pdf);
} else if (ext === '.mp4') {
const $video = $('<video>')
.attr('src', apiFilePath)
.attr('data-attachment-id', attachmentId)
@@ -409,6 +469,12 @@ export class ImportAttachmentService {
.attr('data-align', 'center');
$a.replaceWith($video);
unwrapFromParagraph($, $video);
} else if (audioExtensions.has(ext)) {
const $audio = $('<audio>')
.attr('src', apiFilePath)
.attr('data-attachment-id', attachmentId);
$a.replaceWith($audio);
unwrapFromParagraph($, $audio);
} else {
const confAliasName = $a.attr('data-linked-resource-default-alias');
let attachmentName = path.basename(abs);
@@ -505,18 +571,31 @@ export class ImportAttachmentService {
continue;
}
// Check if already processed (was referenced in HTML)
if (processed.has(href)) {
continue;
}
// Resolve the metadata href to the actual archive path
const resolvedHref = resolveRelativeAttachmentPath(
href,
pageDir,
attachmentCandidates,
);
if (!resolvedHref) continue;
// Skip if the file doesn't exist
if (!attachmentCandidates.has(href)) {
// Check if already processed (was referenced in HTML).
// Inline elements may have been processed under an alias key (original
// filename) rather than the numeric archive path, so also check whether
// the underlying absolute file path has already been uploaded.
const absPath = attachmentCandidates.get(resolvedHref);
const alreadyProcessed =
processed.has(resolvedHref) ||
(absPath &&
Array.from(processed.values()).some(
(entry) => entry.abs === absPath,
));
if (alreadyProcessed) {
continue;
}
// This attachment was in the list but not referenced in HTML - add it
const { attachmentId, apiFilePath, abs } = processFile(href);
const { attachmentId, apiFilePath, abs } = processFile(resolvedHref);
const mime = mimeType || getMimeType(abs);
// Add as attachment node at the end
@@ -555,7 +634,7 @@ export class ImportAttachmentService {
// Post-process DOM elements to add file sizes after uploads complete
// This avoids blocking file operations during initial DOM processing
const elementsNeedingSize = $(
'[data-attachment-id]:not([data-attachment-size])',
'[data-attachment-id]:not([data-attachment-size]):not([data-size])',
);
for (const element of elementsNeedingSize.toArray()) {
const $el = $(element);
@@ -570,7 +649,14 @@ export class ImportAttachmentService {
if (processedEntry) {
try {
const stat = await fs.stat(processedEntry.abs);
$el.attr('data-attachment-size', stat.size.toString());
const sizeStr = stat.size.toString();
const tagName = $el.prop('tagName')?.toLowerCase();
// audio and pdf nodes use data-size, attachment nodes use data-attachment-size
if (tagName === 'audio' || $el.attr('data-type') === 'pdf') {
$el.attr('data-size', sizeStr);
} else {
$el.attr('data-attachment-size', sizeStr);
}
} catch (error) {
this.logger.debug(
`Could not get size for ${processedEntry.abs}:`,
@@ -4,8 +4,7 @@ import * as path from 'path';
import { v7 } from 'uuid';
import { InsertableBacklink } from '@docmost/db/types/entity.types';
import { Cheerio, CheerioAPI, load } from 'cheerio';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import slugify = require('@sindresorhus/slugify');
import slugify from '@sindresorhus/slugify';
// Check if text contains Unicode characters (for emojis/icons)
function isUnicodeCharacter(text: string): boolean {
@@ -41,6 +41,15 @@ export function resolveRelativeAttachmentPath(
'ImportUtils',
);
}
// Confluence Server uses "/download/attachments/..." in HTML but the ZIP
// stores files under "attachments/...". Strip the "download/" prefix so
// the path can match candidates from the archive.
const confluenceStripped = mainRel.replace(
/^download\/attachments\//,
'attachments/',
);
const fallback = path
.normalize(path.join(pageDir, mainRel))
.split(path.sep)
@@ -49,9 +58,13 @@ export function resolveRelativeAttachmentPath(
if (attachmentCandidates.has(mainRel)) {
return mainRel;
}
if (confluenceStripped !== mainRel && attachmentCandidates.has(confluenceStripped)) {
return confluenceStripped;
}
if (attachmentCandidates.has(fallback)) {
return fallback;
}
return null;
}
@@ -17,6 +17,7 @@ export enum QueueJob {
ATTACHMENT_INDEX_CONTENT = 'attachment-index-content',
ATTACHMENT_INDEXING = 'attachment-indexing',
DELETE_PAGE_ATTACHMENTS = 'delete-page-attachments',
DELETE_AI_CHAT_ATTACHMENTS = 'delete-ai-chat-attachments',
DELETE_USER_AVATARS = 'delete-user-avatars',
@@ -69,6 +70,7 @@ export enum QueueJob {
COMMENT_RESOLVED_NOTIFICATION = 'comment-resolved-notification',
PAGE_MENTION_NOTIFICATION = 'page-mention-notification',
PAGE_PERMISSION_GRANTED = 'page-permission-granted',
PAGE_UPDATE_DIGEST = 'page-update-digest',
AUDIT_LOG = 'audit-log',
AUDIT_CLEANUP = 'audit-cleanup',
@@ -4,6 +4,7 @@ export interface IPageBacklinkJob {
pageId: string;
workspaceId: string;
mentions: MentionNode[];
internalLinkSlugIds?: string[];
}
export interface IAddPageWatchersJob {
@@ -60,6 +61,13 @@ export interface IPageMentionNotificationJob {
workspaceId: string;
}
export interface IPageUpdateNotificationJob {
pageId: string;
spaceId: string;
workspaceId: string;
actorIds: string[];
}
export interface IPermissionGrantedNotificationJob {
userIds: string[];
pageId: string;
@@ -11,7 +11,7 @@ export async function processBacklinks(
backlinkRepo: BacklinkRepo,
data: IPageBacklinkJob,
): Promise<void> {
const { pageId, mentions, workspaceId } = data;
const { pageId, mentions, workspaceId, internalLinkSlugIds = [] } = data;
await executeTx(db, async (trx) => {
const existingBacklinks = await trx
@@ -20,7 +20,28 @@ export async function processBacklinks(
.where('sourcePageId', '=', pageId)
.execute();
if (existingBacklinks.length === 0 && mentions.length === 0) {
const mentionTargetPageIds = mentions
.filter((mention) => mention.entityId !== pageId)
.map((mention) => mention.entityId);
let resolvedLinkPageIds: string[] = [];
if (internalLinkSlugIds.length > 0) {
const resolvedPages = await trx
.selectFrom('pages')
.select('id')
.where('slugId', 'in', internalLinkSlugIds)
.where('workspaceId', '=', workspaceId)
.execute();
resolvedLinkPageIds = resolvedPages
.map((p) => p.id)
.filter((id) => id !== pageId);
}
const allTargetPageIds = [
...new Set([...mentionTargetPageIds, ...resolvedLinkPageIds]),
];
if (existingBacklinks.length === 0 && allTargetPageIds.length === 0) {
return;
}
@@ -28,16 +49,12 @@ export async function processBacklinks(
(backlink) => backlink.targetPageId,
);
const targetPageIds = mentions
.filter((mention) => mention.entityId !== pageId)
.map((mention) => mention.entityId);
let validTargetPages = [];
if (targetPageIds.length > 0) {
if (allTargetPageIds.length > 0) {
validTargetPages = await trx
.selectFrom('pages')
.select('id')
.where('id', 'in', targetPageIds)
.where('id', 'in', allTargetPageIds)
.where('workspaceId', '=', workspaceId)
.execute();
}
@@ -71,7 +71,10 @@ export class StaticModule implements OnModuleInit {
app.get(RENDER_PATH, (req: any, res: any) => {
const stream = fs.createReadStream(indexFilePath);
res.type('text/html').send(stream);
res
.header('Cache-Control', 'no-cache, no-store, must-revalidate')
.type('text/html')
.send(stream);
});
}
}
@@ -66,25 +66,25 @@ export class LocalDriver implements StorageDriver {
}
async readStream(filePath: string): Promise<Readable> {
try {
return createReadStream(this._fullPath(filePath));
} catch (err) {
throw new Error(`Failed to read file: ${(err as Error).message}`);
const fullPath = this._fullPath(filePath);
if (!(await fs.pathExists(fullPath))) {
throw new Error(`File not found: ${filePath}`);
}
return createReadStream(fullPath);
}
async readRangeStream(
filePath: string,
range: { start: number; end: number },
): Promise<Readable> {
try {
return createReadStream(this._fullPath(filePath), {
start: range.start,
end: range.end,
});
} catch (err) {
throw new Error(`Failed to read file: ${(err as Error).message}`);
const fullPath = this._fullPath(filePath);
if (!(await fs.pathExists(fullPath))) {
throw new Error(`File not found: ${filePath}`);
}
return createReadStream(fullPath, {
start: range.start,
end: range.end,
});
}
async exists(filePath: string): Promise<boolean> {
@@ -0,0 +1,39 @@
import { Module } from '@nestjs/common';
import { ThrottlerModule } from '@nestjs/throttler';
import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis';
import { EnvironmentService } from '../environment/environment.service';
import { EnvironmentModule } from '../environment/environment.module';
import { parseRedisUrl } from '../../common/helpers';
import { AUTH_THROTTLER, AI_CHAT_THROTTLER } from './throttler-names';
import Redis from 'ioredis';
@Module({
imports: [
ThrottlerModule.forRootAsync({
imports: [EnvironmentModule],
useFactory: (environmentService: EnvironmentService) => {
const redisConfig = parseRedisUrl(environmentService.getRedisUrl());
return {
throttlers: [
{ name: AUTH_THROTTLER, ttl: 60_000, limit: 10 },
{ name: AI_CHAT_THROTTLER, ttl: 60_000, limit: 25 },
],
errorMessage: 'Too many requests',
storage: new ThrottlerStorageRedisService(
new Redis({
host: redisConfig.host,
port: redisConfig.port,
password: redisConfig.password,
db: redisConfig.db,
family: redisConfig.family,
keyPrefix: 'throttle:',
}),
),
};
},
inject: [EnvironmentService],
}),
],
})
export class ThrottleModule {}
@@ -0,0 +1,2 @@
export const AUTH_THROTTLER = 'auth';
export const AI_CHAT_THROTTLER = 'ai-chat';
@@ -0,0 +1,13 @@
import { Injectable } from '@nestjs/common';
import { ThrottlerGuard } from '@nestjs/throttler';
type AuthedRequest = { user?: { id?: string } };
@Injectable()
export class UserThrottlerGuard extends ThrottlerGuard {
protected async getTracker(req: AuthedRequest): Promise<string> {
const userId = req.user?.id;
if (userId) return `user:${userId}`;
return super.getTracker(req as Parameters<ThrottlerGuard['getTracker']>[0]);
}
}
@@ -0,0 +1,76 @@
import { Link, Section, Text } from '@react-email/components';
import * as React from 'react';
import { content, link, paragraph } from '../css/styles';
import { getGreetingName, MailBody } from '../partials/partials';
interface PageUpdate {
title: string;
url: string;
updatedBy: string[];
}
interface Props {
userName: string;
pageUpdates: PageUpdate[];
totalUpdates: number;
}
export const PageUpdateDigestEmail = ({
userName,
pageUpdates,
totalUpdates,
}: Props) => {
return (
<MailBody>
<Section style={content}>
<Text style={paragraph}>
Hi {getGreetingName(userName)},
</Text>
<Text style={paragraph}>
There {totalUpdates === 1 ? 'has' : 'have'} been{' '}
<strong>
{totalUpdates} update{totalUpdates === 1 ? '' : 's'}
</strong>{' '}
since your last update.
</Text>
{pageUpdates.map((page, i) => (
<Section key={i} style={pageCard}>
<Text style={pageTitle}>
<Link href={page.url} style={link}>
{page.title}
</Link>
</Text>
{page.updatedBy.length > 0 && (
<Text style={updatedByText}>
Edited by {page.updatedBy.join(', ')}
</Text>
)}
</Section>
))}
</Section>
</MailBody>
);
};
const pageCard = {
borderLeft: '3px solid #e8e5ef',
paddingLeft: '12px',
marginBottom: '12px',
};
const pageTitle = {
...paragraph,
margin: '0 0 2px 0',
fontSize: 14,
fontWeight: 'bold' as const,
};
const updatedByText = {
...paragraph,
margin: '0',
fontSize: 13,
color: '#666',
};
export default PageUpdateDigestEmail;
@@ -0,0 +1,38 @@
import { Link, Section, Text } from '@react-email/components';
import * as React from 'react';
import { content, link, paragraph } from '../css/styles';
import { EmailButton, getGreetingName, MailBody } from '../partials/partials';
interface Props {
userName: string;
actorName: string;
pageTitle: string;
pageUrl: string;
spaceName: string;
}
export const PageUpdateEmail = ({
userName,
actorName,
pageTitle,
pageUrl,
spaceName,
}: Props) => {
return (
<MailBody>
<Section style={content}>
<Text style={paragraph}>Hi {getGreetingName(userName)},</Text>
<Text style={paragraph}>
<strong>{actorName}</strong> updated{' '}
<Link href={pageUrl} style={link}>
<strong>{pageTitle}</strong>
</Link>{' '}
in <strong>{spaceName}</strong>.
</Text>
</Section>
<EmailButton href={pageUrl}>View page</EmailButton>
</MailBody>
);
};
export default PageUpdateEmail;
@@ -87,3 +87,7 @@ export function MailFooter() {
</Section>
);
}
export function getGreetingName(name?: string): string {
return name?.split(' ')[0] || 'there';
}
+2
View File
@@ -10,6 +10,7 @@ import { TransformHttpResponseInterceptor } from './common/interceptors/http-res
import { WsRedisIoAdapter } from './ws/adapter/ws-redis.adapter';
import fastifyMultipart from '@fastify/multipart';
import fastifyCookie from '@fastify/cookie';
import fastifyIp from 'fastify-ip';
import { InternalLogFilter } from './common/logger/internal-log-filter';
async function bootstrap() {
@@ -45,6 +46,7 @@ async function bootstrap() {
app.useWebSocketAdapter(redisIoAdapter);
await app.register(fastifyIp);
await app.register(fastifyMultipart);
await app.register(fastifyCookie);
+2 -3
View File
@@ -65,12 +65,10 @@ export class WsGateway
async handleMessage(client: Socket, data: any): Promise<void> {
if (this.wsService.isTreeEvent(data)) {
await this.wsService.handleTreeEvent(client, data);
return;
}
client.broadcast.emit('message', data);
}
/*
@SubscribeMessage('join-room')
handleJoinRoom(client: Socket, @MessageBody() roomName: string): void {
// if room is a space, check if user has permissions
@@ -81,6 +79,7 @@ export class WsGateway
handleLeaveRoom(client: Socket, @MessageBody() roomName: string): void {
client.leave(roomName);
}
*/
onModuleDestroy() {
if (this.server) {
+9
View File
@@ -27,6 +27,15 @@ export class WsService {
async handleTreeEvent(client: Socket, data: any): Promise<void> {
const room = getSpaceRoomName(data.spaceId);
if (!client.rooms.has(room)) {
return;
}
if (data.operation === 'refetchRootTreeNodeEvent') {
client.broadcast.to(room).emit('message', data);
return;
}
const hasRestrictions = await this.spaceHasRestrictions(data.spaceId);
if (!hasRestrictions) {
client.broadcast.to(room).emit('message', data);
+1
View File
@@ -14,4 +14,5 @@ export const TREE_EVENTS = new Set([
'addTreeNode',
'moveTreeNode',
'deleteTreeNode',
'refetchRootTreeNodeEvent',
]);