mirror of
https://github.com/docmost/docmost.git
synced 2026-05-16 14:14:06 +08:00
Merge branch 'main' into chat
This commit is contained in:
@@ -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,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';
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
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',
|
||||
} 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',
|
||||
];
|
||||
|
||||
@@ -91,15 +91,6 @@ export function extractBearerTokenFromHeader(
|
||||
return type === 'Bearer' ? token : undefined;
|
||||
}
|
||||
|
||||
export function hasLicenseOrEE(opts: {
|
||||
licenseKey: string;
|
||||
plan: string;
|
||||
isCloud: boolean;
|
||||
}): boolean {
|
||||
const { licenseKey, plan, isCloud } = opts;
|
||||
return Boolean(licenseKey) || (isCloud && plan === 'business');
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a database URL for postgres.js compatibility.
|
||||
* - Removes `sslmode=no-verify` (not supported by postgres.js), keeps other sslmode values
|
||||
@@ -151,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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { containsDomain } from './no-urls.validator';
|
||||
|
||||
// containsDomain returns true if value contains a domain-like pattern
|
||||
// The full NoUrls validator also checks for https:// URLs separately
|
||||
|
||||
describe('containsDomain', () => {
|
||||
describe('bare domains with real TLDs — should block', () => {
|
||||
it.each([
|
||||
'example.com',
|
||||
'example.net',
|
||||
'example.org',
|
||||
'example.io',
|
||||
'example.co',
|
||||
'example.dev',
|
||||
'example.app',
|
||||
'example.me',
|
||||
'example.info',
|
||||
'example.tech',
|
||||
'example.aero',
|
||||
'example.cloud',
|
||||
'example.museum',
|
||||
'example.abc',
|
||||
'example.uk',
|
||||
'example.de',
|
||||
'example.fr',
|
||||
'example.ru',
|
||||
])('blocks "%s"', (value) => {
|
||||
expect(containsDomain(value)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('domains with paths — should block', () => {
|
||||
it.each([
|
||||
'example.com/reset',
|
||||
'example.com/reset-password',
|
||||
'click example.com/page',
|
||||
'go to example.net/login',
|
||||
])('blocks "%s"', (value) => {
|
||||
expect(containsDomain(value)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multi-part domains — should block', () => {
|
||||
it.each([
|
||||
'Foo.com.net',
|
||||
'Foo.com.',
|
||||
'Foo.mine.net',
|
||||
'Foo.mine.ne',
|
||||
'sub.example.com',
|
||||
'login.example.co.uk',
|
||||
])('blocks "%s"', (value) => {
|
||||
expect(containsDomain(value)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('domain in sentence — should block', () => {
|
||||
it.each([
|
||||
'Reset your password at example.com',
|
||||
'URGENT click example.com/reset',
|
||||
'Visit example.org for details',
|
||||
'go to mysite.io now',
|
||||
])('blocks "%s"', (value) => {
|
||||
expect(containsDomain(value)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('case insensitive — should block', () => {
|
||||
it.each(['EXAMPLE.COM', 'Example.Com', 'example.COM'])('blocks "%s"', (value) => {
|
||||
expect(containsDomain(value)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fake TLDs — should allow', () => {
|
||||
it.each([
|
||||
'Foo.mine',
|
||||
'Foo.blarg',
|
||||
'Foo.qqq',
|
||||
'Foo.zz',
|
||||
'Foo.abcd',
|
||||
'Foo.abcde',
|
||||
'Foo.abcdef',
|
||||
'Foo.abcdefg',
|
||||
])('allows "%s"', (value) => {
|
||||
expect(containsDomain(value)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('too short suffix — should allow', () => {
|
||||
it.each(['Foo.a', 'Foo.c', 'A.B'])('allows "%s"', (value) => {
|
||||
expect(containsDomain(value)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multi-part with fake TLD — should allow', () => {
|
||||
it.each(['Foo.mine.', 'Foo.mine.n'])('allows "%s"', (value) => {
|
||||
expect(containsDomain(value)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('emails — should allow', () => {
|
||||
it.each([
|
||||
'user@example.com',
|
||||
'admin@company.org',
|
||||
'test@sub.domain.co.uk',
|
||||
])('allows "%s"', (value) => {
|
||||
expect(containsDomain(value)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normal names — should allow', () => {
|
||||
it.each([
|
||||
'John Smith',
|
||||
'Dr. Smith',
|
||||
'A. B. Charlie',
|
||||
'John',
|
||||
'Mary Jane',
|
||||
"O'Brien",
|
||||
'Jean-Pierre',
|
||||
'José García',
|
||||
])('allows "%s"', (value) => {
|
||||
expect(containsDomain(value)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('IP addresses — should allow', () => {
|
||||
it.each(['192.168.1.1', '10.0.0.1', '127.0.0.1'])(
|
||||
'allows "%s"',
|
||||
(value) => {
|
||||
expect(containsDomain(value)).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('edge cases — should allow', () => {
|
||||
it.each(['', ' ', '.', '..', 'hello', '.com', 'a.b'])(
|
||||
'allows "%s"',
|
||||
(value) => {
|
||||
expect(containsDomain(value)).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { registerDecorator, ValidationOptions } from 'class-validator';
|
||||
import * as tlds from 'tlds';
|
||||
|
||||
const URL_PATTERN = /https?:\/\//i;
|
||||
const tldSet = new Set(tlds.map((t) => t.toLowerCase()));
|
||||
|
||||
export function containsDomain(value: string): boolean {
|
||||
const tokens = value.split(/\s+/);
|
||||
for (const token of tokens) {
|
||||
if (token.includes('@')) continue;
|
||||
const segments = token.split('.');
|
||||
for (let i = 1; i < segments.length; i++) {
|
||||
const suffix = segments[i].replace(/[^\w].*/g, '');
|
||||
if (segments[i - 1] && suffix && tldSet.has(suffix.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function NoUrls(validationOptions?: ValidationOptions) {
|
||||
return function (object: object, propertyName: string) {
|
||||
registerDecorator({
|
||||
name: 'noUrls',
|
||||
target: object.constructor,
|
||||
propertyName,
|
||||
options: {
|
||||
message: 'Must not contain URLs or domain names',
|
||||
...validationOptions,
|
||||
},
|
||||
validator: {
|
||||
validate(value: unknown) {
|
||||
if (typeof value !== 'string') return true;
|
||||
if (URL_PATTERN.test(value)) return false;
|
||||
if (containsDomain(value)) return false;
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -15,4 +15,9 @@ export const inlineFileExtensions = [
|
||||
'.pdf',
|
||||
'.mp4',
|
||||
'.mov',
|
||||
'.mp3',
|
||||
'.wav',
|
||||
'.ogg',
|
||||
'.m4a',
|
||||
'.webm',
|
||||
];
|
||||
|
||||
@@ -457,6 +457,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(
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export enum UserTokenType {
|
||||
FORGOT_PASSWORD = 'forgot-password',
|
||||
EMAIL_VERIFICATION = 'email-verification',
|
||||
}
|
||||
|
||||
@@ -5,12 +5,15 @@ import {
|
||||
HttpStatus,
|
||||
Inject,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
UseGuards,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { SkipThrottle, ThrottlerGuard } from '@nestjs/throttler';
|
||||
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 +25,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 +34,14 @@ import {
|
||||
IAuditService,
|
||||
} from '../../integrations/audit/audit.service';
|
||||
|
||||
@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 +113,7 @@ export class AuthController {
|
||||
return workspace;
|
||||
}
|
||||
|
||||
@SkipThrottle()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('change-password')
|
||||
@@ -115,8 +121,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 +176,7 @@ export class AuthController {
|
||||
return this.authService.verifyUserToken(verifyUserTokenDto, workspace.id);
|
||||
}
|
||||
|
||||
@SkipThrottle()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('collab-token')
|
||||
@@ -173,13 +187,24 @@ export class AuthController {
|
||||
return this.authService.getCollabToken(user, workspace.id);
|
||||
}
|
||||
|
||||
@SkipThrottle()
|
||||
@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 +217,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(),
|
||||
|
||||
@@ -1,5 +1,37 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { Workspace } from '@docmost/db/types/entity.types';
|
||||
import { createHmac } from 'node:crypto';
|
||||
|
||||
export function computeEmailSignature(
|
||||
email: string,
|
||||
workspaceId: string,
|
||||
appSecret: string,
|
||||
): string {
|
||||
return createHmac('sha256', appSecret)
|
||||
.update(`${email.toLowerCase()}:${workspaceId}`)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
export function throwIfEmailNotVerified(opts: {
|
||||
isCloud: boolean;
|
||||
emailVerifiedAt: Date | null;
|
||||
email: string;
|
||||
workspaceId: string;
|
||||
appSecret: string;
|
||||
}): void {
|
||||
if (!opts.isCloud || opts.emailVerifiedAt) return;
|
||||
|
||||
const emailSignature = computeEmailSignature(
|
||||
opts.email,
|
||||
opts.workspaceId,
|
||||
opts.appSecret,
|
||||
);
|
||||
throw new BadRequestException({
|
||||
message:
|
||||
'Please verify your email address. Check your inbox for the verification link.',
|
||||
emailSignature,
|
||||
});
|
||||
}
|
||||
|
||||
export function validateSsoEnforcement(workspace: Workspace) {
|
||||
if (workspace.enforceSso) {
|
||||
|
||||
@@ -7,11 +7,13 @@ import {
|
||||
} from 'class-validator';
|
||||
import { CreateUserDto } from './create-user.dto';
|
||||
import { Transform, TransformFnParams } from 'class-transformer';
|
||||
import { NoUrls } from '../../../common/validators/no-urls.validator';
|
||||
|
||||
export class CreateAdminUserDto extends CreateUserDto {
|
||||
@IsNotEmpty()
|
||||
@MinLength(1)
|
||||
@MaxLength(50)
|
||||
@NoUrls()
|
||||
@Transform(({ value }: TransformFnParams) => value?.trim())
|
||||
name: string;
|
||||
|
||||
|
||||
@@ -7,12 +7,14 @@ import {
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { Transform, TransformFnParams } from 'class-transformer';
|
||||
import { NoUrls } from '../../../common/validators/no-urls.validator';
|
||||
|
||||
export class CreateUserDto {
|
||||
@IsOptional()
|
||||
@MinLength(1)
|
||||
@MaxLength(50)
|
||||
@IsString()
|
||||
@NoUrls()
|
||||
@Transform(({ value }: TransformFnParams) => value?.trim())
|
||||
name: string;
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -17,6 +19,7 @@ import {
|
||||
isUserDisabled,
|
||||
nanoIdGen,
|
||||
} from '../../../common/helpers';
|
||||
import { throwIfEmailNotVerified } from '../auth.util';
|
||||
import { ChangePasswordDto } from '../dto/change-password.dto';
|
||||
import { MailService } from '../../../integrations/mail/mail.service';
|
||||
import ChangePasswordEmail from '@docmost/transactional/emails/change-password-email';
|
||||
@@ -36,16 +39,20 @@ import {
|
||||
AUDIT_SERVICE,
|
||||
IAuditService,
|
||||
} from '../../../integrations/audit/audit.service';
|
||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private signupService: SignupService,
|
||||
private tokenService: TokenService,
|
||||
private sessionService: SessionService,
|
||||
private userSessionRepo: UserSessionRepo,
|
||||
private userRepo: UserRepo,
|
||||
private userTokenRepo: UserTokenRepo,
|
||||
private mailService: MailService,
|
||||
private domainService: DomainService,
|
||||
private environmentService: EnvironmentService,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
@@ -69,6 +76,14 @@ export class AuthService {
|
||||
throw new UnauthorizedException(errorMessage);
|
||||
}
|
||||
|
||||
throwIfEmailNotVerified({
|
||||
isCloud: this.environmentService.isCloud(),
|
||||
emailVerifiedAt: user.emailVerifiedAt,
|
||||
email: user.email,
|
||||
workspaceId,
|
||||
appSecret: this.environmentService.getAppSecret(),
|
||||
});
|
||||
|
||||
user.lastLoginAt = new Date();
|
||||
await this.userRepo.updateLastLogin(user.id, workspaceId);
|
||||
|
||||
@@ -79,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 };
|
||||
}
|
||||
|
||||
@@ -99,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,
|
||||
@@ -127,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,
|
||||
@@ -233,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,
|
||||
@@ -247,6 +275,14 @@ export class AuthService {
|
||||
template: emailTemplate,
|
||||
});
|
||||
|
||||
if (this.environmentService.isCloud() && !user.emailVerifiedAt) {
|
||||
await this.userRepo.updateUser(
|
||||
{ emailVerifiedAt: new Date() },
|
||||
user.id,
|
||||
workspace.id,
|
||||
);
|
||||
}
|
||||
|
||||
// Check if user has MFA enabled or workspace enforces MFA
|
||||
const userHasMfa = user?.['mfa']?.isEnabled || false;
|
||||
const workspaceEnforcesMfa = workspace.enforceMfa || false;
|
||||
@@ -257,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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { AuditContextMiddleware } from '../common/middlewares/audit-context.midd
|
||||
import { ShareModule } from './share/share.module';
|
||||
import { NotificationModule } from './notification/notification.module';
|
||||
import { WatcherModule } from './watcher/watcher.module';
|
||||
import { SessionModule } from './session/session.module';
|
||||
import { ClsMiddleware } from 'nestjs-cls';
|
||||
|
||||
@Module({
|
||||
@@ -38,6 +39,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,232 @@ export class PageNotificationService {
|
||||
}
|
||||
}
|
||||
|
||||
async processPageUpdate(data: IPageUpdateNotificationJob, appUrl: string) {
|
||||
const { pageId, spaceId, workspaceId, actorIds } = data;
|
||||
|
||||
const watcherIds = await this.watcherRepo.getPageWatcherIds(pageId);
|
||||
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 } = 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,
|
||||
}),
|
||||
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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -91,9 +91,15 @@ export class SearchService {
|
||||
return { items: [] };
|
||||
}
|
||||
|
||||
const isRestricted =
|
||||
await this.pagePermissionRepo.hasRestrictedAncestor(share.pageId);
|
||||
if (isRestricted) {
|
||||
return { items: [] };
|
||||
}
|
||||
|
||||
const pageIdsToSearch = [];
|
||||
if (share.includeSubPages) {
|
||||
const pageList = await this.pageRepo.getPageAndDescendants(
|
||||
const pageList = await this.pageRepo.getPageAndDescendantsExcludingRestricted(
|
||||
share.pageId,
|
||||
{
|
||||
includeContent: false,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,8 +28,7 @@ import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { Public } from '../../common/decorators/public.decorator';
|
||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||
import { hasLicenseOrEE } from '../../common/helpers';
|
||||
import { LicenseCheckService } from '../../integrations/environment/license-check.service';
|
||||
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
|
||||
import {
|
||||
AUDIT_SERVICE,
|
||||
@@ -45,7 +44,7 @@ export class ShareController {
|
||||
private readonly pageRepo: PageRepo,
|
||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||
private readonly pageAccessService: PageAccessService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private readonly licenseCheckService: LicenseCheckService,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
|
||||
@@ -81,11 +80,10 @@ export class ShareController {
|
||||
|
||||
return {
|
||||
...shareData,
|
||||
hasLicenseKey: hasLicenseOrEE({
|
||||
licenseKey: workspace.licenseKey,
|
||||
isCloud: this.environmentService.isCloud(),
|
||||
plan: workspace.plan,
|
||||
}),
|
||||
features: this.licenseCheckService.resolveFeatures(
|
||||
workspace.licenseKey,
|
||||
workspace.plan,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -259,11 +257,10 @@ export class ShareController {
|
||||
|
||||
return {
|
||||
...treeData,
|
||||
hasLicenseKey: hasLicenseOrEE({
|
||||
licenseKey: workspace.licenseKey,
|
||||
isCloud: this.environmentService.isCloud(),
|
||||
plan: workspace.plan,
|
||||
}),
|
||||
features: this.licenseCheckService.resolveFeatures(
|
||||
workspace.licenseKey,
|
||||
workspace.plan,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.isValidEELicense(workspace.licenseKey)
|
||||
typeof updateSpaceDto.disablePublicSharing !== 'undefined' &&
|
||||
!this.licenseCheckService.hasFeature(
|
||||
workspace.licenseKey,
|
||||
Feature.SECURITY_SETTINGS,
|
||||
workspace.plan,
|
||||
)
|
||||
) {
|
||||
throw new ForbiddenException(
|
||||
'This feature requires a valid enterprise 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;
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@ export class UserController {
|
||||
const workspaceInfo = {
|
||||
...rest,
|
||||
memberCount,
|
||||
hasLicenseKey: Boolean(licenseKey),
|
||||
};
|
||||
|
||||
return { user: authUser, workspace: workspaceInfo };
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,10 +57,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.unwatchPage(user.id, page.id);
|
||||
|
||||
@@ -85,15 +72,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,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { WatcherService } from './watcher.service';
|
||||
import { CaslModule } from '../casl/casl.module';
|
||||
import { WatcherController } from './watcher.controller';
|
||||
import { PageAccessModule } from '../page/page-access/page-access.module';
|
||||
|
||||
@Module({
|
||||
imports: [CaslModule],
|
||||
controllers: [],
|
||||
imports: [PageAccessModule],
|
||||
controllers: [WatcherController],
|
||||
providers: [WatcherService],
|
||||
exports: [WatcherService],
|
||||
})
|
||||
|
||||
@@ -32,8 +32,10 @@ import {
|
||||
} from '../../casl/interfaces/workspace-ability.type';
|
||||
import { FastifyReply } from 'fastify';
|
||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
||||
import { LicenseCheckService } from '../../../integrations/environment/license-check.service';
|
||||
import { CheckHostnameDto } from '../dto/check-hostname.dto';
|
||||
import { RemoveWorkspaceUserDto } from '../dto/remove-workspace-user.dto';
|
||||
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('workspace')
|
||||
@@ -42,7 +44,9 @@ export class WorkspaceController {
|
||||
private readonly workspaceService: WorkspaceService,
|
||||
private readonly workspaceInvitationService: WorkspaceInvitationService,
|
||||
private readonly workspaceAbility: WorkspaceAbilityFactory,
|
||||
private readonly workspaceRepo: WorkspaceRepo,
|
||||
private environmentService: EnvironmentService,
|
||||
private licenseCheckService: LicenseCheckService,
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@@ -58,6 +62,23 @@ export class WorkspaceController {
|
||||
return this.workspaceService.getWorkspaceInfo(workspace.id);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('entitlements')
|
||||
async getEntitlements(@AuthWorkspace() workspace: Workspace) {
|
||||
let { licenseKey } = workspace;
|
||||
const { plan } = workspace;
|
||||
|
||||
if (!licenseKey) {
|
||||
licenseKey = await this.workspaceRepo.findLicenseKeyById(workspace.id);
|
||||
}
|
||||
|
||||
return {
|
||||
cloud: this.environmentService.isCloud(),
|
||||
tier: this.licenseCheckService.resolveTier(licenseKey, plan),
|
||||
features: this.licenseCheckService.resolveFeatures(licenseKey, plan),
|
||||
};
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('update')
|
||||
async updateWorkspace(
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { UserRole } from '../../../common/helpers/types/permission';
|
||||
import { NoUrls } from '../../../common/validators/no-urls.validator';
|
||||
|
||||
export class InviteUserDto {
|
||||
@IsArray()
|
||||
@@ -44,6 +45,7 @@ export class AcceptInviteDto extends InvitationIdDto {
|
||||
@MinLength(2)
|
||||
@MaxLength(60)
|
||||
@IsString()
|
||||
@NoUrls()
|
||||
name: string;
|
||||
|
||||
@MinLength(8)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -67,6 +69,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) {
|
||||
@@ -85,7 +88,7 @@ export class WorkspaceService {
|
||||
async getWorkspacePublicData(workspaceId: string) {
|
||||
const workspace = await this.db
|
||||
.selectFrom('workspaces')
|
||||
.select(['id', 'name', 'logo', 'hostname', 'enforceSso', 'licenseKey'])
|
||||
.select(['id', 'name', 'logo', 'hostname', 'enforceSso', 'licenseKey', 'plan'])
|
||||
.select((eb) =>
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
@@ -106,12 +109,9 @@ export class WorkspaceService {
|
||||
throw new NotFoundException('Workspace not found');
|
||||
}
|
||||
|
||||
const { licenseKey, ...rest } = workspace;
|
||||
const { licenseKey, plan, ...rest } = workspace;
|
||||
|
||||
return {
|
||||
...rest,
|
||||
hasLicenseKey: Boolean(licenseKey),
|
||||
};
|
||||
return rest;
|
||||
}
|
||||
|
||||
async create(
|
||||
@@ -244,7 +244,7 @@ export class WorkspaceService {
|
||||
await this.billingQueue.add(
|
||||
QueueJob.WELCOME_EMAIL,
|
||||
{ userId: user.id },
|
||||
{ delay: 60 * 1000 }, // 1m
|
||||
{ delay: 30 * 60 * 1000 }, // 30m
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(err);
|
||||
@@ -332,14 +332,32 @@ export class WorkspaceService {
|
||||
) {
|
||||
const ws = await this.db
|
||||
.selectFrom('workspaces')
|
||||
.select(['id', 'licenseKey', 'trashRetentionDays'])
|
||||
.select(['id', 'licenseKey', 'plan', 'trashRetentionDays'])
|
||||
.where('id', '=', workspaceId)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!this.licenseCheckService.isValidEELicense(ws.licenseKey)) {
|
||||
throw new ForbiddenException(
|
||||
'This feature requires a valid enterprise license',
|
||||
);
|
||||
if (!ws) {
|
||||
throw new NotFoundException('Workspace not found');
|
||||
}
|
||||
|
||||
if (typeof updateWorkspaceDto.mcpEnabled !== 'undefined') {
|
||||
if (!this.licenseCheckService.hasFeature(ws.licenseKey, 'mcp', ws.plan)) {
|
||||
throw new ForbiddenException(
|
||||
'This feature requires a valid license',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
typeof updateWorkspaceDto.disablePublicSharing !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined'
|
||||
) {
|
||||
if (!this.licenseCheckService.hasFeature(ws.licenseKey, Feature.SECURITY_SETTINGS, ws.plan)) {
|
||||
throw new ForbiddenException(
|
||||
'This feature requires a valid license',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -518,10 +536,7 @@ export class WorkspaceService {
|
||||
}
|
||||
|
||||
const { licenseKey, ...rest } = workspace;
|
||||
return {
|
||||
...rest,
|
||||
hasLicenseKey: Boolean(licenseKey),
|
||||
};
|
||||
return rest;
|
||||
}
|
||||
|
||||
async getWorkspaceUsers(
|
||||
@@ -670,11 +685,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,
|
||||
@@ -788,6 +807,8 @@ export class WorkspaceService {
|
||||
await this.watcherRepo.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';
|
||||
@@ -76,6 +77,7 @@ import { normalizePostgresUrl } from '../common/helpers';
|
||||
CommentRepo,
|
||||
AttachmentRepo,
|
||||
UserTokenRepo,
|
||||
UserSessionRepo,
|
||||
BacklinkRepo,
|
||||
ShareRepo,
|
||||
NotificationRepo,
|
||||
@@ -95,6 +97,7 @@ import { normalizePostgresUrl } from '../common/helpers';
|
||||
CommentRepo,
|
||||
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();
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+16
@@ -449,6 +449,21 @@ export interface AiChatMessages {
|
||||
createdAt: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -473,6 +488,7 @@ export interface DB {
|
||||
spaces: Spaces;
|
||||
userMfa: UserMfa;
|
||||
users: Users;
|
||||
userSessions: UserSessions;
|
||||
userTokens: UserTokens;
|
||||
watchers: Watchers;
|
||||
workspaceInvitations: WorkspaceInvitations;
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
Shares,
|
||||
FileTasks,
|
||||
UserMfa as _UserMFA,
|
||||
UserSessions,
|
||||
ApiKeys,
|
||||
Watchers,
|
||||
Audit as _Audit,
|
||||
@@ -168,6 +169,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>;
|
||||
|
||||
+1
-1
Submodule apps/server/src/ee updated: 8b48e93a1b...a9d3d46869
@@ -266,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');
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
validateSync,
|
||||
} from 'class-validator';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { IsISO6391 } from '../../common/validator/is-iso6391';
|
||||
import { IsISO6391 } from '../../common/validators/is-iso6391';
|
||||
|
||||
export class EnvironmentVariables {
|
||||
@IsNotEmpty()
|
||||
@@ -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()
|
||||
|
||||
@@ -25,4 +25,75 @@ export class LicenseCheckService {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
hasFeature(licenseKey: string, feature: string, plan?: string): boolean {
|
||||
if (this.environmentService.isCloud()) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { getFeaturesForCloudPlan } = require('../../ee/licence/feature-registry');
|
||||
return getFeaturesForCloudPlan(plan).has(feature);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const LicenseModule = require('../../ee/licence/license.service');
|
||||
const licenseService = this.moduleRef.get(LicenseModule.LicenseService, {
|
||||
strict: false,
|
||||
});
|
||||
return licenseService.hasFeature(licenseKey, feature);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
getFeatures(licenseKey: string): string[] {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const LicenseModule = require('../../ee/licence/license.service');
|
||||
const licenseService = this.moduleRef.get(LicenseModule.LicenseService, {
|
||||
strict: false,
|
||||
});
|
||||
return licenseService.getFeatures(licenseKey);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
resolveFeatures(licenseKey: string, plan: string): string[] {
|
||||
if (this.environmentService.isCloud()) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { getFeaturesForCloudPlan } = require('../../ee/licence/feature-registry');
|
||||
return [...getFeaturesForCloudPlan(plan)];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return this.getFeatures(licenseKey);
|
||||
}
|
||||
|
||||
resolveTier(licenseKey: string, plan: string): string {
|
||||
if (this.environmentService.isCloud()) {
|
||||
return plan ?? 'standard';
|
||||
}
|
||||
|
||||
return this.getLicenseType(licenseKey) ?? 'free';
|
||||
}
|
||||
|
||||
private getLicenseType(licenseKey: string): string | null {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const LicenseModule = require('../../ee/licence/license.service');
|
||||
const licenseService = this.moduleRef.get(LicenseModule.LicenseService, {
|
||||
strict: false,
|
||||
});
|
||||
return licenseService.getLicenseType(licenseKey);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
@@ -291,6 +297,7 @@ export class ExportService {
|
||||
prosemirrorJson,
|
||||
slugIdToPath,
|
||||
currentPagePath,
|
||||
baseUrl,
|
||||
);
|
||||
|
||||
if (includeAttachments) {
|
||||
|
||||
@@ -62,6 +62,7 @@ export function replaceInternalLinks(
|
||||
prosemirrorJson: any,
|
||||
slugIdToPath: Record<string, string>,
|
||||
currentPagePath: string,
|
||||
baseUrl?: string,
|
||||
) {
|
||||
const doc = jsonToNode(prosemirrorJson);
|
||||
|
||||
@@ -76,6 +77,10 @@ export function replaceInternalLinks(
|
||||
const localPath = slugIdToPath[slugId];
|
||||
|
||||
if (!localPath) {
|
||||
if (baseUrl && mark.attrs.href.startsWith('/')) {
|
||||
//@ts-expect-error
|
||||
mark.attrs.href = `${baseUrl}${mark.attrs.href}`;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
buildAttachmentCandidates,
|
||||
collectMarkdownAndHtmlFiles,
|
||||
encodeFilePath,
|
||||
extractNotionPartialId,
|
||||
readDocmostMetadata,
|
||||
stripNotionID,
|
||||
} from '../utils/import.utils';
|
||||
@@ -160,10 +161,17 @@ export class FileImportTaskService {
|
||||
fileTask: FileTask;
|
||||
}): Promise<void> {
|
||||
const { extractDir, fileTask } = opts;
|
||||
const isNotion = fileTask.source === FileImportSource.Notion;
|
||||
const allFiles = await collectMarkdownAndHtmlFiles(extractDir);
|
||||
const attachmentCandidates = await buildAttachmentCandidates(extractDir);
|
||||
const docmostMetadata = await readDocmostMetadata(extractDir);
|
||||
|
||||
const space = await this.db
|
||||
.selectFrom('spaces')
|
||||
.select(['slug'])
|
||||
.where('id', '=', fileTask.spaceId)
|
||||
.executeTakeFirst();
|
||||
|
||||
const pagesMap = new Map<string, ImportPageNode>();
|
||||
|
||||
for (const absPath of allFiles) {
|
||||
@@ -224,7 +232,17 @@ export class FileImportTaskService {
|
||||
}
|
||||
|
||||
// For each folder with content, create a placeholder page if no corresponding .md or .html exists
|
||||
foldersWithContent.forEach((folderPath) => {
|
||||
// Process folders with partial UUIDs first so they claim their specific files
|
||||
// before plain folders (without partial UUIDs) take whatever remains.
|
||||
const sortedFolders = isNotion
|
||||
? [...foldersWithContent].sort((a, b) => {
|
||||
const aHasPartial = extractNotionPartialId(path.basename(a)) ? 0 : 1;
|
||||
const bHasPartial = extractNotionPartialId(path.basename(b)) ? 0 : 1;
|
||||
return aHasPartial - bHasPartial;
|
||||
})
|
||||
: [...foldersWithContent];
|
||||
|
||||
sortedFolders.forEach((folderPath) => {
|
||||
if (
|
||||
skipRootFolder &&
|
||||
folderPath?.toLowerCase() === skipRootFolder?.toLowerCase()
|
||||
@@ -237,18 +255,54 @@ export class FileImportTaskService {
|
||||
|
||||
if (!pagesMap.has(mdPath) && !pagesMap.has(htmlPath)) {
|
||||
const folderName = path.basename(folderPath);
|
||||
const encodedMdPath = encodeFilePath(mdPath);
|
||||
const placeholderMetadata = docmostMetadata?.pages[encodedMdPath];
|
||||
pagesMap.set(mdPath, {
|
||||
id: v7(),
|
||||
slugId: generateSlugId(),
|
||||
name: stripNotionID(folderName),
|
||||
content: '',
|
||||
parentPageId: null,
|
||||
fileExtension: '.md',
|
||||
filePath: mdPath,
|
||||
icon: placeholderMetadata?.icon ?? null,
|
||||
});
|
||||
const parentDir = path.dirname(folderPath);
|
||||
|
||||
// Notion no longer adds UUIDs to folder names, but still adds them to files.
|
||||
// For duplicate names, Notion adds a partial UUID "{first4}-{last4}" to the folder.
|
||||
let matched = false;
|
||||
if (isNotion) {
|
||||
const partialId = extractNotionPartialId(folderName);
|
||||
const strippedFolderName = stripNotionID(folderName);
|
||||
const isSameDir = (fileDir: string) =>
|
||||
fileDir === parentDir || (parentDir === '.' && !fileDir.includes('/'));
|
||||
|
||||
for (const [filePath, page] of pagesMap.entries()) {
|
||||
if (!isSameDir(path.dirname(filePath))) continue;
|
||||
if (page.name !== strippedFolderName) continue;
|
||||
|
||||
if (partialId) {
|
||||
// Match partial UUID against the full UUID in the filename
|
||||
const fileBase = path.basename(filePath, path.extname(filePath));
|
||||
const fullIdMatch = fileBase.match(/[a-f0-9]{32}$/i);
|
||||
if (!fullIdMatch) continue;
|
||||
const fullId = fullIdMatch[0].toLowerCase();
|
||||
if (!fullId.startsWith(partialId.prefix) || !fullId.endsWith(partialId.suffix)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
pagesMap.delete(filePath);
|
||||
page.filePath = mdPath;
|
||||
pagesMap.set(mdPath, page);
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!matched) {
|
||||
const encodedMdPath = encodeFilePath(mdPath);
|
||||
const placeholderMetadata = docmostMetadata?.pages[encodedMdPath];
|
||||
pagesMap.set(mdPath, {
|
||||
id: v7(),
|
||||
slugId: generateSlugId(),
|
||||
name: stripNotionID(folderName),
|
||||
content: '',
|
||||
parentPageId: null,
|
||||
fileExtension: '.md',
|
||||
filePath: mdPath,
|
||||
icon: placeholderMetadata?.icon ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -458,6 +512,7 @@ export class FileImportTaskService {
|
||||
creatorId: fileTask.creatorId,
|
||||
sourcePageId: page.id,
|
||||
workspaceId: fileTask.workspaceId,
|
||||
spaceSlug: space?.slug,
|
||||
});
|
||||
|
||||
const pmState = getProsemirrorContent(
|
||||
|
||||
@@ -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,6 +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';
|
||||
import slugify from '@sindresorhus/slugify';
|
||||
|
||||
// Check if text contains Unicode characters (for emojis/icons)
|
||||
function isUnicodeCharacter(text: string): boolean {
|
||||
@@ -22,6 +23,7 @@ export async function formatImportHtml(opts: {
|
||||
workspaceId: string;
|
||||
pageDir?: string;
|
||||
attachmentCandidates?: string[];
|
||||
spaceSlug?: string;
|
||||
}): Promise<{
|
||||
html: string;
|
||||
backlinks: InsertableBacklink[];
|
||||
@@ -61,6 +63,7 @@ export async function formatImportHtml(opts: {
|
||||
creatorId,
|
||||
sourcePageId,
|
||||
workspaceId,
|
||||
opts.spaceSlug,
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -316,6 +319,7 @@ export async function rewriteInternalLinksToMentionHtml(
|
||||
creatorId: string,
|
||||
sourcePageId: string,
|
||||
workspaceId: string,
|
||||
spaceSlug?: string,
|
||||
): Promise<InsertableBacklink[]> {
|
||||
const normalize = (p: string) => p.replace(/\\/g, '/');
|
||||
const backlinks: InsertableBacklink[] = [];
|
||||
@@ -339,19 +343,37 @@ export async function rewriteInternalLinksToMentionHtml(
|
||||
);
|
||||
const meta = filePathToPageMetaMap.get(resolved);
|
||||
if (!meta) return;
|
||||
const mentionId = v7();
|
||||
const $mention = $('<span>')
|
||||
.attr({
|
||||
'data-type': 'mention',
|
||||
'data-id': mentionId,
|
||||
'data-entity-type': 'page',
|
||||
'data-entity-id': meta.id,
|
||||
'data-label': meta.title,
|
||||
'data-slug-id': meta.slugId,
|
||||
'data-creator-id': creatorId,
|
||||
})
|
||||
.text(meta.title);
|
||||
$a.replaceWith($mention);
|
||||
|
||||
const linkText = $a.text().trim();
|
||||
const titleMatch =
|
||||
linkText === meta.title ||
|
||||
linkText === meta.title?.trim();
|
||||
|
||||
if (titleMatch) {
|
||||
const mentionId = v7();
|
||||
const $mention = $('<span>')
|
||||
.attr({
|
||||
'data-type': 'mention',
|
||||
'data-id': mentionId,
|
||||
'data-entity-type': 'page',
|
||||
'data-entity-id': meta.id,
|
||||
'data-label': meta.title,
|
||||
'data-slug-id': meta.slugId,
|
||||
'data-creator-id': creatorId,
|
||||
})
|
||||
.text(meta.title);
|
||||
$a.replaceWith($mention);
|
||||
} else {
|
||||
const titleSlug = slugify(meta.title?.substring(0, 70) || 'untitled');
|
||||
const pageSlug = `${titleSlug}-${meta.slugId}`;
|
||||
const internalHref = spaceSlug
|
||||
? `/s/${spaceSlug}/p/${pageSlug}`
|
||||
: `/p/${pageSlug}`;
|
||||
|
||||
$a.attr('href', internalHref);
|
||||
$a.attr('data-internal', 'true');
|
||||
}
|
||||
|
||||
backlinks.push({ sourcePageId, targetPageId: meta.id, workspaceId });
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -81,7 +94,25 @@ export async function collectMarkdownAndHtmlFiles(
|
||||
export function stripNotionID(fileName: string): string {
|
||||
// Handle optional separator (space or dash) + 32 alphanumeric chars at end
|
||||
const notionIdPattern = /[ -]?[a-z0-9]{32}$/i;
|
||||
return fileName.replace(notionIdPattern, '').trim();
|
||||
// Handle partial UUID format used for duplicate names: "Name abcd-ef12"
|
||||
const partialIdPattern = / [a-f0-9]{4}-[a-f0-9]{4}$/i;
|
||||
return fileName
|
||||
.replace(notionIdPattern, '')
|
||||
.replace(partialIdPattern, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a partial Notion UUID suffix from a folder name.
|
||||
* Notion adds "{first4}-{last4}" when multiple pages share the same title.
|
||||
* e.g. "Cool 324d-35ab" → { prefix: "324d", suffix: "35ab" }
|
||||
*/
|
||||
export function extractNotionPartialId(
|
||||
folderName: string,
|
||||
): { prefix: string; suffix: string } | null {
|
||||
const match = folderName.match(/ ([a-f0-9]{4})-([a-f0-9]{4})$/i);
|
||||
if (!match) return null;
|
||||
return { prefix: match[1].toLowerCase(), suffix: match[2].toLowerCase() };
|
||||
}
|
||||
|
||||
export function encodeFilePath(filePath: string): string {
|
||||
|
||||
@@ -69,6 +69,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,35 @@
|
||||
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 Redis from 'ioredis';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ThrottlerModule.forRootAsync({
|
||||
imports: [EnvironmentModule],
|
||||
useFactory: (environmentService: EnvironmentService) => {
|
||||
const redisConfig = parseRedisUrl(environmentService.getRedisUrl());
|
||||
|
||||
return {
|
||||
throttlers: [{ name: 'auth', ttl: 60_000, limit: 10 }],
|
||||
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 {}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Section, Text, Button } from '@react-email/components';
|
||||
import { Section, Text } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
import { button, content, paragraph } from '../css/styles';
|
||||
import { MailBody } from '../partials/partials';
|
||||
import { content, paragraph } from '../css/styles';
|
||||
import { EmailButton, MailBody } from '../partials/partials';
|
||||
|
||||
interface Props {
|
||||
actorName: string;
|
||||
@@ -23,19 +23,7 @@ export const CommentCreateEmail = ({
|
||||
<strong>{pageTitle}</strong>.
|
||||
</Text>
|
||||
</Section>
|
||||
<Section
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingLeft: '15px',
|
||||
paddingBottom: '15px',
|
||||
}}
|
||||
>
|
||||
<Button href={pageUrl} style={button}>
|
||||
View
|
||||
</Button>
|
||||
</Section>
|
||||
<EmailButton href={pageUrl}>View</EmailButton>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Section, Text, Button } from '@react-email/components';
|
||||
import { Section, Text } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
import { button, content, paragraph } from '../css/styles';
|
||||
import { MailBody } from '../partials/partials';
|
||||
import { content, paragraph } from '../css/styles';
|
||||
import { EmailButton, MailBody } from '../partials/partials';
|
||||
|
||||
interface Props {
|
||||
actorName: string;
|
||||
@@ -23,19 +23,7 @@ export const CommentMentionEmail = ({
|
||||
<strong>{pageTitle}</strong>.
|
||||
</Text>
|
||||
</Section>
|
||||
<Section
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingLeft: '15px',
|
||||
paddingBottom: '15px',
|
||||
}}
|
||||
>
|
||||
<Button href={pageUrl} style={button}>
|
||||
View
|
||||
</Button>
|
||||
</Section>
|
||||
<EmailButton href={pageUrl}>View</EmailButton>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Section, Text, Button } from '@react-email/components';
|
||||
import { Section, Text } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
import { button, content, paragraph } from '../css/styles';
|
||||
import { MailBody } from '../partials/partials';
|
||||
import { content, paragraph } from '../css/styles';
|
||||
import { EmailButton, MailBody } from '../partials/partials';
|
||||
|
||||
interface Props {
|
||||
actorName: string;
|
||||
@@ -23,19 +23,7 @@ export const CommentResolvedEmail = ({
|
||||
<strong>{pageTitle}</strong>.
|
||||
</Text>
|
||||
</Section>
|
||||
<Section
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingLeft: '15px',
|
||||
paddingBottom: '15px',
|
||||
}}
|
||||
>
|
||||
<Button href={pageUrl} style={button}>
|
||||
View
|
||||
</Button>
|
||||
</Section>
|
||||
<EmailButton href={pageUrl}>View</EmailButton>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Section, Text, Button } from '@react-email/components';
|
||||
import { Section, Text } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
import { button, content, paragraph } from '../css/styles';
|
||||
import { MailBody } from '../partials/partials';
|
||||
import { content, paragraph } from '../css/styles';
|
||||
import { EmailButton, MailBody } from '../partials/partials';
|
||||
|
||||
interface Props {
|
||||
inviteLink: string;
|
||||
@@ -17,19 +17,7 @@ export const InvitationEmail = ({ inviteLink }: Props) => {
|
||||
Please click the button below to accept this invitation.
|
||||
</Text>
|
||||
</Section>
|
||||
<Section
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingLeft: '15px',
|
||||
paddingBottom: '15px',
|
||||
}}
|
||||
>
|
||||
<Button href={inviteLink} style={button}>
|
||||
Accept Invite
|
||||
</Button>
|
||||
</Section>
|
||||
<EmailButton href={inviteLink}>Accept Invite</EmailButton>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Section, Text, Button } from '@react-email/components';
|
||||
import { Section, Text } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
import { button, content, paragraph } from '../css/styles';
|
||||
import { MailBody } from '../partials/partials';
|
||||
import { content, paragraph } from '../css/styles';
|
||||
import { EmailButton, MailBody } from '../partials/partials';
|
||||
|
||||
interface Props {
|
||||
actorName: string;
|
||||
@@ -19,19 +19,7 @@ export const PageMentionEmail = ({ actorName, pageTitle, pageUrl }: Props) => {
|
||||
<strong>{pageTitle}</strong>.
|
||||
</Text>
|
||||
</Section>
|
||||
<Section
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingLeft: '15px',
|
||||
paddingBottom: '15px',
|
||||
}}
|
||||
>
|
||||
<Button href={pageUrl} style={button}>
|
||||
View
|
||||
</Button>
|
||||
</Section>
|
||||
<EmailButton href={pageUrl}>View</EmailButton>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,36 @@
|
||||
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;
|
||||
}
|
||||
|
||||
export const PageUpdateEmail = ({
|
||||
userName,
|
||||
actorName,
|
||||
pageTitle,
|
||||
pageUrl,
|
||||
}: 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>
|
||||
.
|
||||
</Text>
|
||||
</Section>
|
||||
<EmailButton href={pageUrl}>View page</EmailButton>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageUpdateEmail;
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Section, Text, Button } from '@react-email/components';
|
||||
import { Section, Text } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
import { button, content, paragraph } from '../css/styles';
|
||||
import { MailBody } from '../partials/partials';
|
||||
import { content, paragraph } from '../css/styles';
|
||||
import { EmailButton, MailBody } from '../partials/partials';
|
||||
|
||||
interface Props {
|
||||
actorName: string;
|
||||
@@ -25,19 +25,7 @@ export const PermissionGrantedEmail = ({
|
||||
<strong>{pageTitle}</strong>.
|
||||
</Text>
|
||||
</Section>
|
||||
<Section
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingLeft: '15px',
|
||||
paddingBottom: '15px',
|
||||
}}
|
||||
>
|
||||
<Button href={pageUrl} style={button}>
|
||||
View
|
||||
</Button>
|
||||
</Section>
|
||||
<EmailButton href={pageUrl}>View</EmailButton>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { container, footer, h1, logo, main } from '../css/styles';
|
||||
import { button as buttonStyle, container, footer, h1, logo, main } from '../css/styles';
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
@@ -35,6 +35,47 @@ export function MailHeader() {
|
||||
);
|
||||
}
|
||||
|
||||
interface EmailButtonProps {
|
||||
href: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function EmailButton({ href, children }: EmailButtonProps) {
|
||||
return (
|
||||
<table
|
||||
role="presentation"
|
||||
cellPadding="0"
|
||||
cellSpacing="0"
|
||||
style={{ margin: '0 0 15px 15px' }}
|
||||
>
|
||||
<tr>
|
||||
<td
|
||||
style={{
|
||||
backgroundColor: buttonStyle.backgroundColor,
|
||||
borderRadius: buttonStyle.borderRadius,
|
||||
textAlign: 'center' as const,
|
||||
}}
|
||||
>
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
style={{
|
||||
color: buttonStyle.color,
|
||||
fontFamily: buttonStyle.fontFamily,
|
||||
fontSize: buttonStyle.fontSize,
|
||||
textDecoration: 'none',
|
||||
display: 'inline-block',
|
||||
padding: '8px 16px',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
export function MailFooter() {
|
||||
return (
|
||||
<Section style={footer}>
|
||||
@@ -46,3 +87,7 @@ export function MailFooter() {
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
export function getGreetingName(name?: string): string {
|
||||
return name?.split(' ')[0] || 'there';
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -67,6 +69,7 @@ async function bootstrap() {
|
||||
'/api/sso/google',
|
||||
'/api/workspace/create',
|
||||
'/api/workspace/joined',
|
||||
'/api/workspace/find-by-email',
|
||||
];
|
||||
|
||||
if (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user