Merge branch 'main' into perm-x

This commit is contained in:
Philipinho
2026-02-09 19:13:52 -08:00
111 changed files with 4539 additions and 2292 deletions
@@ -7,9 +7,10 @@ import { CollabWsAdapter } from './adapter/collab-ws.adapter';
import { IncomingMessage } from 'http';
import { WebSocket } from 'ws';
import { TokenModule } from '../core/auth/token.module';
import { HistoryListener } from './listeners/history.listener';
import { HistoryProcessor } from './processors/history.processor';
import { LoggerExtension } from './extensions/logger.extension';
import { CollaborationHandler } from './collaboration.handler';
import { CollabHistoryService } from './services/collab-history.service';
@Module({
providers: [
@@ -17,7 +18,8 @@ import { CollaborationHandler } from './collaboration.handler';
AuthenticationExtension,
PersistenceExtension,
LoggerExtension,
HistoryListener,
HistoryProcessor,
CollabHistoryService,
CollaborationHandler,
],
exports: [CollaborationGateway],
@@ -0,0 +1,3 @@
export const HISTORY_INTERVAL = 5 * 60 * 1000;
export const HISTORY_FAST_INTERVAL = 60 * 1000;
export const HISTORY_FAST_THRESHOLD = 5 * 60 * 1000;
@@ -13,7 +13,6 @@ import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { executeTx } from '@docmost/db/utils';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { InjectQueue } from '@nestjs/bullmq';
import { QueueJob, QueueName } from '../../integrations/queue/constants';
import { Queue } from 'bullmq';
@@ -22,8 +21,17 @@ import {
extractPageMentions,
} from '../../common/helpers/prosemirror/utils';
import { isDeepStrictEqual } from 'node:util';
import { IPageBacklinkJob } from '../../integrations/queue/constants/queue.interface';
import {
IPageBacklinkJob,
IPageHistoryJob,
} from '../../integrations/queue/constants/queue.interface';
import { Page } from '@docmost/db/types/entity.types';
import { CollabHistoryService } from '../services/collab-history.service';
import {
HISTORY_FAST_INTERVAL,
HISTORY_FAST_THRESHOLD,
HISTORY_INTERVAL,
} from '../constants';
@Injectable()
export class PersistenceExtension implements Extension {
@@ -33,9 +41,10 @@ export class PersistenceExtension implements Extension {
constructor(
private readonly pageRepo: PageRepo,
@InjectKysely() private readonly db: KyselyDB,
private eventEmitter: EventEmitter2,
@InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue,
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
@InjectQueue(QueueName.HISTORY_QUEUE) private historyQueue: Queue,
private readonly collabHistory: CollabHistoryService,
) {}
async onLoadDocument(data: onLoadDocumentPayload) {
@@ -101,6 +110,7 @@ export class PersistenceExtension implements Extension {
}
let page: Page = null;
const editingUserIds = this.consumeContributors(documentName);
try {
await executeTx(this.db, async (trx) => {
@@ -123,13 +133,9 @@ export class PersistenceExtension implements Extension {
let contributorIds = undefined;
try {
const existingContributors = page.contributorIds || [];
const contributorSet = this.contributors.get(documentName);
contributorSet.add(page.creatorId);
const newContributors = [...contributorSet];
contributorIds = Array.from(
new Set([...existingContributors, ...newContributors]),
new Set([...existingContributors, ...editingUserIds, page.creatorId]),
);
this.contributors.delete(documentName);
} catch (err) {
//this.logger.debug('Contributors error:' + err?.['message']);
}
@@ -153,13 +159,7 @@ export class PersistenceExtension implements Extension {
}
if (page) {
this.eventEmitter.emit('collab.page.updated', {
page: {
...page,
content: tiptapJson,
lastUpdatedById: context.user.id,
},
});
await this.collabHistory.addContributors(pageId, editingUserIds);
const mentions = extractMentions(tiptapJson);
const pageMentions = extractPageMentions(mentions);
@@ -174,6 +174,8 @@ export class PersistenceExtension implements Extension {
pageIds: [pageId],
workspaceId: page.workspaceId,
});
await this.enqueuePageHistory(page);
}
}
@@ -193,4 +195,26 @@ export class PersistenceExtension implements Extension {
const documentName = data.documentName;
this.contributors.delete(documentName);
}
private consumeContributors(documentName: string): string[] {
const contributorSet = this.contributors.get(documentName);
if (!contributorSet) return [];
const userIds = [...contributorSet];
this.contributors.delete(documentName);
return userIds;
}
private async enqueuePageHistory(page: Page): Promise<void> {
const pageAge = Date.now() - new Date(page.createdAt).getTime();
const delay =
pageAge < HISTORY_FAST_THRESHOLD
? HISTORY_FAST_INTERVAL
: HISTORY_INTERVAL;
await this.historyQueue.add(
QueueJob.PAGE_HISTORY,
{ pageId: page.id } as IPageHistoryJob,
{ jobId: page.id, delay },
);
}
}
@@ -1,44 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo';
import { Page } from '@docmost/db/types/entity.types';
import { isDeepStrictEqual } from 'node:util';
export class UpdatedPageEvent {
page: Page;
}
@Injectable()
export class HistoryListener {
private readonly logger = new Logger(HistoryListener.name);
constructor(private readonly pageHistoryRepo: PageHistoryRepo) {}
@OnEvent('collab.page.updated')
async handleCreatePageHistory(event: UpdatedPageEvent) {
const { page } = event;
const pageCreationTime = new Date(page.createdAt).getTime();
const currentTime = Date.now();
const FIVE_MINUTES = 5 * 60 * 1000;
if (currentTime - pageCreationTime < FIVE_MINUTES) {
return;
}
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(page.id);
if (
!lastHistory ||
(!isDeepStrictEqual(lastHistory.content, page.content) &&
currentTime - new Date(lastHistory.createdAt).getTime() >= FIVE_MINUTES)
) {
try {
await this.pageHistoryRepo.saveHistory(page);
this.logger.debug(`New history created for: ${page.id}`);
} catch (err) {
this.logger.error(`Failed to create history for page: ${page.id}`, err);
}
}
}
}
@@ -0,0 +1,84 @@
import { Logger, OnModuleDestroy } from '@nestjs/common';
import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq';
import { Job } from 'bullmq';
import { QueueJob, QueueName } from '../../integrations/queue/constants';
import { IPageHistoryJob } from '../../integrations/queue/constants/queue.interface';
import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { isDeepStrictEqual } from 'node:util';
import { CollabHistoryService } from '../services/collab-history.service';
@Processor(QueueName.HISTORY_QUEUE)
export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
private readonly logger = new Logger(HistoryProcessor.name);
constructor(
private readonly pageHistoryRepo: PageHistoryRepo,
private readonly pageRepo: PageRepo,
private readonly collabHistory: CollabHistoryService,
) {
super();
}
async process(job: Job<IPageHistoryJob, void>): Promise<void> {
if (job.name !== QueueJob.PAGE_HISTORY) return;
try {
const { pageId } = job.data;
const page = await this.pageRepo.findById(pageId, {
includeContent: true,
});
if (!page) {
this.logger.warn(`Page ${pageId} not found, skipping history`);
await this.collabHistory.clearContributors(pageId);
return;
}
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(
pageId,
{ includeContent: true },
);
if (
!lastHistory ||
!isDeepStrictEqual(lastHistory.content, page.content)
) {
const contributorIds =
await this.collabHistory.popContributors(pageId);
try {
await this.pageHistoryRepo.saveHistory(page, { contributorIds });
this.logger.debug(`History created for page: ${pageId}`);
} catch (err) {
await this.collabHistory.addContributors(
pageId,
contributorIds,
);
throw err;
}
}
} catch (err) {
throw err;
}
}
@OnWorkerEvent('active')
onActive(job: Job) {
this.logger.debug(`Processing ${job.name} for page: ${job.data.pageId}`);
}
@OnWorkerEvent('failed')
onError(job: Job) {
this.logger.error(
`Failed ${job.name} for page: ${job.data.pageId}. Reason: ${job.failedReason}`,
);
}
async onModuleDestroy(): Promise<void> {
if (this.worker) {
await this.worker.close();
}
}
}
@@ -9,6 +9,8 @@ import { EventEmitterModule } from '@nestjs/event-emitter';
import { HealthModule } from '../../integrations/health/health.module';
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';
@Module({
imports: [
@@ -19,6 +21,9 @@ import { LoggerModule } from '../../common/logger/logger.module';
QueueModule,
HealthModule,
EventEmitterModule.forRoot(),
RedisModule.forRootAsync({
useClass: RedisConfigService,
}),
],
controllers: [
AppController,
@@ -0,0 +1,30 @@
import { Injectable } from '@nestjs/common';
import { RedisService } from '@nestjs-labs/nestjs-ioredis';
import type { Redis } from 'ioredis';
const REDIS_KEY_PREFIX = 'history:contributors:';
@Injectable()
export class CollabHistoryService {
private readonly redis: Redis;
constructor(private readonly redisService: RedisService) {
this.redis = this.redisService.getOrThrow();
}
async addContributors(pageId: string, userIds: string[]): Promise<void> {
if (userIds.length === 0) return;
await this.redis.sadd(REDIS_KEY_PREFIX + pageId, ...userIds);
}
async popContributors(pageId: string): Promise<string[]> {
const key = REDIS_KEY_PREFIX + pageId;
const count = await this.redis.scard(key);
if (count === 0) return [];
return await this.redis.spop(key, count);
}
async clearContributors(pageId: string): Promise<void> {
await this.redis.del(REDIS_KEY_PREFIX + pageId);
}
}
@@ -17,13 +17,13 @@ import {
UseInterceptors,
} from '@nestjs/common';
import { AttachmentService } from './services/attachment.service';
import { FastifyReply } from 'fastify';
import { FastifyReply, FastifyRequest } from 'fastify';
import { FileInterceptor } from '../../common/interceptors/file.interceptor';
import * as bytes from 'bytes';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { User, Workspace } from '@docmost/db/types/entity.types';
import { Attachment, User, Workspace } from '@docmost/db/types/entity.types';
import { StorageService } from '../../integrations/storage/storage.service';
import {
getAttachmentFolderPath,
@@ -147,6 +147,7 @@ export class AttachmentController {
@UseGuards(JwtAuthGuard)
@Get('/files/:fileId/:fileName')
async getFile(
@Req() req: FastifyRequest,
@Res() res: FastifyReply,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
@@ -175,22 +176,7 @@ export class AttachmentController {
await this.pageAccessService.validateCanView(page, user);
try {
const fileStream = await this.storageService.readStream(
attachment.filePath,
);
res.headers({
'Content-Type': attachment.mimeType,
'Cache-Control': 'private, max-age=3600',
});
if (!inlineFileExtensions.includes(attachment.fileExt)) {
res.header(
'Content-Disposition',
`attachment; filename="${encodeURIComponent(attachment.fileName)}"`,
);
}
return res.send(fileStream);
return await this.sendFileResponse(req, res, attachment, 'private');
} catch (err) {
this.logger.error(err);
throw new NotFoundException('File not found');
@@ -199,6 +185,7 @@ export class AttachmentController {
@Get('/files/public/:fileId/:fileName')
async getPublicFile(
@Req() req: FastifyRequest,
@Res() res: FastifyReply,
@AuthWorkspace() workspace: Workspace,
@Param('fileId') fileId: string,
@@ -237,22 +224,7 @@ export class AttachmentController {
}
try {
const fileStream = await this.storageService.readStream(
attachment.filePath,
);
res.headers({
'Content-Type': attachment.mimeType,
'Cache-Control': 'public, max-age=3600',
});
if (!inlineFileExtensions.includes(attachment.fileExt)) {
res.header(
'Content-Disposition',
`attachment; filename="${encodeURIComponent(attachment.fileName)}"`,
);
}
return res.send(fileStream);
return await this.sendFileResponse(req, res, attachment, 'public');
} catch (err) {
this.logger.error(err);
throw new NotFoundException('File not found');
@@ -427,4 +399,70 @@ export class AttachmentController {
return;
}
}
private async sendFileResponse(
req: FastifyRequest,
res: FastifyReply,
attachment: Attachment,
cacheScope: 'private' | 'public',
) {
const fileSize = Number(attachment.fileSize);
const rangeHeader = req.headers.range;
res.header('Accept-Ranges', 'bytes');
if (!inlineFileExtensions.includes(attachment.fileExt)) {
res.header(
'Content-Disposition',
`attachment; filename="${encodeURIComponent(attachment.fileName)}"`,
);
}
if (rangeHeader && fileSize) {
const match = rangeHeader.match(/bytes=(\d+)-(\d*)/);
if (match) {
const start = parseInt(match[1], 10);
const end = match[2]
? Math.min(parseInt(match[2], 10), fileSize - 1)
: fileSize - 1;
if (start >= fileSize || start > end) {
res.status(416);
res.header('Content-Range', `bytes */${fileSize}`);
return res.send();
}
const fileStream = await this.storageService.readRangeStream(
attachment.filePath,
{ start, end },
);
res.status(206);
res.headers({
'Content-Type': attachment.mimeType,
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Content-Length': end - start + 1,
'Cache-Control': `${cacheScope}, max-age=3600`,
});
return res.send(fileStream);
}
}
const fileStream = await this.storageService.readStream(
attachment.filePath,
);
res.headers({
'Content-Type': attachment.mimeType,
'Cache-Control': `${cacheScope}, max-age=3600`,
});
const isSvg = attachment.fileExt === '.svg';
if (fileSize && !isSvg) {
res.header('Content-Length', fileSize);
}
return res.send(fileStream);
}
}
@@ -99,6 +99,7 @@ export class AttachmentService {
if (isUpdate) {
attachment = await this.attachmentRepo.updateAttachment(
{
fileSize: preparedFile.fileSize,
updatedAt: new Date(),
},
attachmentId,
@@ -9,7 +9,9 @@ export class PageHistoryService {
constructor(private pageHistoryRepo: PageHistoryRepo) {}
async findById(historyId: string): Promise<PageHistory> {
return await this.pageHistoryRepo.findById(historyId);
return await this.pageHistoryRepo.findById(historyId, {
includeContent: true,
});
}
async findHistoryByPageId(
+41 -2
View File
@@ -61,8 +61,18 @@ export class ShareController {
throw new BadRequestException();
}
const shareData = await this.shareService.getSharedPage(dto, workspace.id);
const sharingAllowed = await this.shareService.isSharingAllowed(
workspace.id,
shareData.share.spaceId,
);
if (!sharingAllowed) {
throw new NotFoundException('Shared page not found');
}
return {
...(await this.shareService.getSharedPage(dto, workspace.id)),
...shareData,
hasLicenseKey: hasLicenseOrEE({
licenseKey: workspace.licenseKey,
isCloud: this.environmentService.isCloud(),
@@ -83,6 +93,14 @@ export class ShareController {
throw new NotFoundException('Share not found');
}
const sharingAllowed = await this.shareService.isSharingAllowed(
share.workspaceId,
share.spaceId,
);
if (!sharingAllowed) {
throw new NotFoundException('Share not found');
}
return share;
}
@@ -129,6 +147,14 @@ export class ShareController {
throw new BadRequestException('Cannot share a restricted page');
}
const sharingAllowed = await this.shareService.isSharingAllowed(
workspace.id,
page.spaceId,
);
if (!sharingAllowed) {
throw new ForbiddenException('Public sharing is disabled');
}
return this.shareService.createShare({
page,
authUserId: user.id,
@@ -184,8 +210,21 @@ export class ShareController {
@Body() dto: ShareIdDto,
@AuthWorkspace() workspace: Workspace,
) {
const treeData = await this.shareService.getShareTree(
dto.shareId,
workspace.id,
);
const sharingAllowed = await this.shareService.isSharingAllowed(
workspace.id,
treeData.share.spaceId,
);
if (!sharingAllowed) {
throw new NotFoundException('Share not found');
}
return {
...(await this.shareService.getShareTree(dto.shareId, workspace.id)),
...treeData,
hasLicenseKey: hasLicenseOrEE({
licenseKey: workspace.licenseKey,
isCloud: this.environmentService.isCloud(),
@@ -281,6 +281,31 @@ export class ShareService {
return ancestor;
}
async isSharingAllowed(
workspaceId: string,
spaceId: string,
): Promise<boolean> {
const result = await this.db
.selectFrom('workspaces')
.innerJoin('spaces', 'spaces.workspaceId', 'workspaces.id')
.select([
'workspaces.settings as workspaceSettings',
'spaces.settings as spaceSettings',
])
.where('workspaces.id', '=', workspaceId)
.where('spaces.id', '=', spaceId)
.executeTakeFirst();
if (!result) return false;
const workspaceDisabled =
(result.workspaceSettings as any)?.sharing?.disabled === true;
const spaceDisabled =
(result.spaceSettings as any)?.sharing?.disabled === true;
return !workspaceDisabled && !spaceDisabled;
}
async updatePublicAttachments(page: Page): Promise<any> {
const prosemirrorJson = getProsemirrorContent(page.content);
const attachmentIds = getAttachmentIds(prosemirrorJson);
@@ -1,10 +1,14 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateSpaceDto } from './create-space.dto';
import { IsNotEmpty, IsString, IsUUID } from 'class-validator';
import { IsBoolean, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
export class UpdateSpaceDto extends PartialType(CreateSpaceDto) {
@IsString()
@IsNotEmpty()
@IsUUID()
spaceId: string;
@IsOptional()
@IsBoolean()
disablePublicSharing: boolean;
}
@@ -1,5 +1,6 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
@@ -17,12 +18,18 @@ import { QueueJob, QueueName } from 'src/integrations/queue/constants';
import { Queue } from 'bullmq';
import { InjectQueue } from '@nestjs/bullmq';
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { LicenseCheckService } from '../../../integrations/environment/license-check.service';
@Injectable()
export class SpaceService {
constructor(
private spaceRepo: SpaceRepo,
private spaceMemberService: SpaceMemberService,
private shareRepo: ShareRepo,
private workspaceRepo: WorkspaceRepo,
private licenseCheckService: LicenseCheckService,
@InjectKysely() private readonly db: KyselyDB,
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
) {}
@@ -105,6 +112,31 @@ export class SpaceService {
}
}
if (typeof updateSpaceDto.disablePublicSharing !== 'undefined') {
const workspace = await this.workspaceRepo.findById(workspaceId, {
withLicenseKey: true,
});
if (
!this.licenseCheckService.isValidEELicense(workspace.licenseKey)
) {
throw new ForbiddenException(
'This feature requires a valid enterprise license',
);
}
await this.spaceRepo.updateSharingSettings(
updateSpaceDto.spaceId,
workspaceId,
'disabled',
updateSpaceDto.disablePublicSharing,
);
if (updateSpaceDto.disablePublicSharing) {
await this.shareRepo.deleteBySpaceId(updateSpaceDto.spaceId);
}
}
return await this.spaceRepo.updateSpace(
{
name: updateSpaceDto.name,
@@ -30,4 +30,8 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
@IsOptional()
@IsBoolean()
generativeAi: boolean;
@IsOptional()
@IsBoolean()
disablePublicSharing: boolean;
}
@@ -5,6 +5,7 @@ import {
Logger,
NotFoundException,
} from '@nestjs/common';
import { LicenseCheckService } from '../../../integrations/environment/license-check.service';
import { CreateWorkspaceDto } from '../dto/create-workspace.dto';
import { UpdateWorkspaceDto } from '../dto/update-workspace.dto';
import { SpaceService } from '../../space/services/space.service';
@@ -33,6 +34,7 @@ import { Queue } from 'bullmq';
import { generateRandomSuffixNumbers } from '../../../common/helpers';
import { isPageEmbeddingsTableExists } from '@docmost/db/helpers/helpers';
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
@Injectable()
export class WorkspaceService {
@@ -47,6 +49,8 @@ export class WorkspaceService {
private userRepo: UserRepo,
private environmentService: EnvironmentService,
private domainService: DomainService,
private licenseCheckService: LicenseCheckService,
private shareRepo: ShareRepo,
@InjectKysely() private readonly db: KyselyDB,
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
@InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue,
@@ -358,6 +362,32 @@ export class WorkspaceService {
delete updateWorkspaceDto.generativeAi;
}
if (typeof updateWorkspaceDto.disablePublicSharing !== 'undefined') {
const currentWorkspace = await this.workspaceRepo.findById(workspaceId, {
withLicenseKey: true,
});
if (
!this.licenseCheckService.isValidEELicense(currentWorkspace.licenseKey)
) {
throw new ForbiddenException(
'This feature requires a valid enterprise license',
);
}
await this.workspaceRepo.updateSharingSettings(
workspaceId,
'disabled',
updateWorkspaceDto.disablePublicSharing,
);
if (updateWorkspaceDto.disablePublicSharing) {
await this.shareRepo.deleteByWorkspaceId(workspaceId);
}
delete updateWorkspaceDto.disablePublicSharing;
}
await this.workspaceRepo.updateWorkspace(updateWorkspaceDto, workspaceId);
const workspace = await this.workspaceRepo.findById(workspaceId, {
@@ -0,0 +1,9 @@
import { Kysely } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema.alterTable('spaces').addColumn('settings', 'jsonb').execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.alterTable('spaces').dropColumn('settings').execute();
}
@@ -0,0 +1,15 @@
import { type Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('page_history')
.addColumn('contributor_ids', sql`uuid[]`, (col) => col.defaultTo('{}'))
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('page_history')
.dropColumn('contributor_ids')
.execute();
}
@@ -9,24 +9,43 @@ import {
} from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { ExpressionBuilder } from 'kysely';
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { ExpressionBuilder, sql } from 'kysely';
import { DB } from '@docmost/db/types/db';
@Injectable()
export class PageHistoryRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
private baseFields: Array<keyof PageHistory> = [
'id',
'pageId',
'slugId',
'title',
'icon',
'coverPhoto',
'lastUpdatedById',
'contributorIds',
'spaceId',
'workspaceId',
'createdAt',
];
async findById(
pageHistoryId: string,
trx?: KyselyTransaction,
opts?: {
includeContent?: boolean;
trx?: KyselyTransaction;
},
): Promise<PageHistory> {
const db = dbOrTx(this.db, trx);
const db = dbOrTx(this.db, opts?.trx);
return await db
.selectFrom('pageHistory')
.selectAll()
.select(this.baseFields)
.$if(opts?.includeContent, (qb) => qb.select('content'))
.select((eb) => this.withLastUpdatedBy(eb))
.select((eb) => this.withContributors(eb))
.where('id', '=', pageHistoryId)
.executeTakeFirst();
}
@@ -43,7 +62,10 @@ export class PageHistoryRepo {
.executeTakeFirst();
}
async saveHistory(page: Page, trx?: KyselyTransaction): Promise<void> {
async saveHistory(
page: Page,
opts?: { contributorIds?: string[]; trx?: KyselyTransaction },
): Promise<void> {
await this.insertPageHistory(
{
pageId: page.id,
@@ -53,18 +75,20 @@ export class PageHistoryRepo {
icon: page.icon,
coverPhoto: page.coverPhoto,
lastUpdatedById: page.lastUpdatedById ?? page.creatorId,
contributorIds: opts?.contributorIds,
spaceId: page.spaceId,
workspaceId: page.workspaceId,
},
trx,
opts?.trx,
);
}
async findPageHistoryByPageId(pageId: string, pagination: PaginationOptions) {
const query = this.db
.selectFrom('pageHistory')
.selectAll()
.select(this.baseFields)
.select((eb) => this.withLastUpdatedBy(eb))
.select((eb) => this.withContributors(eb))
.where('pageId', '=', pageId);
return executeWithCursorPagination(query, {
@@ -76,12 +100,19 @@ export class PageHistoryRepo {
});
}
async findPageLastHistory(pageId: string, trx?: KyselyTransaction) {
const db = dbOrTx(this.db, trx);
async findPageLastHistory(
pageId: string,
opts?: {
includeContent?: boolean;
trx?: KyselyTransaction;
},
) {
const db = dbOrTx(this.db, opts?.trx);
return await db
.selectFrom('pageHistory')
.selectAll()
.select(this.baseFields)
.$if(opts?.includeContent, (qb) => qb.select('content'))
.where('pageId', '=', pageId)
.limit(1)
.orderBy('createdAt', 'desc')
@@ -96,4 +127,17 @@ export class PageHistoryRepo {
.whereRef('users.id', '=', 'pageHistory.lastUpdatedById'),
).as('lastUpdatedBy');
}
withContributors(eb: ExpressionBuilder<DB, 'pageHistory'>) {
return jsonArrayFrom(
eb
.selectFrom('users')
.select(['users.id', 'users.name', 'users.avatarUrl'])
.whereRef(
'users.id',
'=',
sql`ANY(${eb.ref('pageHistory.contributorIds')})`,
),
).as('contributors');
}
}
@@ -136,6 +136,20 @@ export class ShareRepo {
await query.execute();
}
async deleteBySpaceId(spaceId: string): Promise<void> {
await this.db
.deleteFrom('shares')
.where('spaceId', '=', spaceId)
.execute();
}
async deleteByWorkspaceId(workspaceId: string): Promise<void> {
await this.db
.deleteFrom('shares')
.where('workspaceId', '=', workspaceId)
.execute();
}
async getShares(userId: string, pagination: PaginationOptions) {
const query = this.db
.selectFrom('shares')
@@ -89,6 +89,26 @@ export class SpaceRepo {
.executeTakeFirst();
}
async updateSharingSettings(
spaceId: string,
workspaceId: string,
prefKey: string,
prefValue: string | boolean,
) {
return this.db
.updateTable('spaces')
.set({
settings: sql`COALESCE(settings, '{}'::jsonb)
|| jsonb_build_object('sharing', COALESCE(settings->'sharing', '{}'::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,
@@ -167,7 +167,7 @@ export class WorkspaceRepo {
.updateTable('workspaces')
.set({
settings: sql`COALESCE(settings, '{}'::jsonb)
|| jsonb_build_object('api', COALESCE(settings->'api', '{}'::jsonb)
|| jsonb_build_object('api', COALESCE(settings->'api', '{}'::jsonb)
|| jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`,
updatedAt: new Date(),
})
@@ -185,7 +185,25 @@ export class WorkspaceRepo {
.updateTable('workspaces')
.set({
settings: sql`COALESCE(settings, '{}'::jsonb)
|| jsonb_build_object('ai', COALESCE(settings->'ai', '{}'::jsonb)
|| jsonb_build_object('ai', COALESCE(settings->'ai', '{}'::jsonb)
|| jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`,
updatedAt: new Date(),
})
.where('id', '=', workspaceId)
.returning(this.baseFields)
.executeTakeFirst();
}
async updateSharingSettings(
workspaceId: string,
prefKey: string,
prefValue: string | boolean,
) {
return this.db
.updateTable('workspaces')
.set({
settings: sql`COALESCE(settings, '{}'::jsonb)
|| jsonb_build_object('sharing', COALESCE(settings->'sharing', '{}'::jsonb)
|| jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`,
updatedAt: new Date(),
})
+2
View File
@@ -205,6 +205,7 @@ export interface PageHierarchy {
export interface PageHistory {
content: Json | null;
contributorIds: Generated<string[] | null>;
coverPhoto: string | null;
createdAt: Generated<Timestamp>;
icon: string | null;
@@ -279,6 +280,7 @@ export interface Spaces {
id: Generated<string>;
logo: string | null;
name: string | null;
settings: Json | null;
slug: string;
updatedAt: Generated<Timestamp>;
visibility: Generated<string>;
@@ -4,6 +4,7 @@ import { ConfigModule } from '@nestjs/config';
import { validate } from './environment.validation';
import { envPath } from '../../common/helpers';
import { DomainService } from './domain.service';
import { LicenseCheckService } from './license-check.service';
@Global()
@Module({
@@ -15,7 +16,7 @@ import { DomainService } from './domain.service';
validate,
}),
],
providers: [EnvironmentService, DomainService],
exports: [EnvironmentService, DomainService],
providers: [EnvironmentService, DomainService, LicenseCheckService],
exports: [EnvironmentService, DomainService, LicenseCheckService],
})
export class EnvironmentModule {}
@@ -0,0 +1,28 @@
import { Injectable } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { EnvironmentService } from './environment.service';
@Injectable()
export class LicenseCheckService {
constructor(
private moduleRef: ModuleRef,
private environmentService: EnvironmentService,
) {}
isValidEELicense(licenseKey: string): boolean {
if (this.environmentService.isCloud()) {
return true;
}
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.isValidEELicense(licenseKey);
} catch {
return false;
}
}
}
@@ -44,7 +44,7 @@ export class ImportController {
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const validFileExtensions = ['.md', '.html'];
const validFileExtensions = ['.md', '.html', '.docx'];
const maxFileSize = bytes('10mb');
@@ -29,6 +29,7 @@ import { StorageService } from '../../storage/storage.service';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { QueueJob, QueueName } from '../../queue/constants';
import { ModuleRef } from '@nestjs/core';
@Injectable()
export class ImportService {
@@ -40,6 +41,7 @@ export class ImportService {
@InjectKysely() private readonly db: KyselyDB,
@InjectQueue(QueueName.FILE_TASK_QUEUE)
private readonly fileTaskQueue: Queue,
private moduleRef: ModuleRef,
) {}
async importPage(
@@ -59,11 +61,22 @@ export class ImportService {
let prosemirrorState = null;
let createdPage = null;
// For DOCX, we need the page ID upfront so images can reference it
const pageId = fileExtension === '.docx' ? uuid7() : undefined;
try {
if (fileExtension.endsWith('.md')) {
prosemirrorState = await this.processMarkdown(fileContent);
} else if (fileExtension.endsWith('.html')) {
prosemirrorState = await this.processHTML(fileContent);
} else if (fileExtension.endsWith('.docx')) {
prosemirrorState = await this.processDocx(
fileBuffer,
workspaceId,
spaceId,
pageId,
userId,
);
}
} catch (err) {
const message = 'Error processing file content';
@@ -87,6 +100,7 @@ export class ImportService {
const pagePosition = await this.getNewPagePosition(spaceId);
createdPage = await this.pageRepo.insertPage({
...(pageId ? { id: pageId } : {}),
slugId: generateSlugId(),
title: pageTitle,
content: prosemirrorJson,
@@ -129,6 +143,42 @@ export class ImportService {
}
}
async processDocx(
fileBuffer: Buffer,
workspaceId: string,
spaceId: string,
pageId: string,
userId: string,
): Promise<any> {
let DocxImportModule: any;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
DocxImportModule = require('./../../../ee/docx-import/docx-import.service');
} catch (err) {
this.logger.error(
'DOCX import requested but EE module not bundled in this build',
);
throw new BadRequestException(
'This feature requires a valid enterprise license.',
);
}
const docxImportService = this.moduleRef.get(
DocxImportModule.DocxImportService,
{ strict: false },
);
const html = await docxImportService.convertDocxToHtml(
fileBuffer,
workspaceId,
spaceId,
pageId,
userId,
);
return this.processHTML(html);
}
async createYdoc(prosemirrorJson: any): Promise<Buffer | null> {
if (prosemirrorJson) {
// this.logger.debug(`Converting prosemirror json state to ydoc`);
@@ -6,6 +6,7 @@ export enum QueueName {
FILE_TASK_QUEUE = '{file-task-queue}',
SEARCH_QUEUE = '{search-queue}',
AI_QUEUE = '{ai-queue}',
HISTORY_QUEUE = '{history-queue}',
}
export enum QueueJob {
@@ -58,4 +59,6 @@ export enum QueueJob {
GENERATE_PAGE_EMBEDDINGS = 'generate-page-embeddings',
DELETE_PAGE_EMBEDDINGS = 'delete-page-embeddings',
PAGE_HISTORY = 'page-history',
}
@@ -8,4 +8,8 @@ export interface IPageBacklinkJob {
export interface IStripeSeatsSyncJob {
workspaceId: string;
}
export interface IPageHistoryJob {
pageId: string;
}
@@ -73,6 +73,14 @@ import { BacklinksProcessor } from './processors/backlinks.processor';
attempts: 1,
},
}),
BullModule.registerQueue({
name: QueueName.HISTORY_QUEUE,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: true,
attempts: 2,
},
}),
],
exports: [BullModule],
providers: [BacklinksProcessor],
@@ -73,6 +73,20 @@ export class LocalDriver implements StorageDriver {
}
}
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}`);
}
}
async exists(filePath: string): Promise<boolean> {
try {
return await fs.pathExists(this._fullPath(filePath));
@@ -13,6 +13,7 @@ import { Readable } from 'stream';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { getMimeType } from '../../../common/helpers';
import { Upload } from '@aws-sdk/lib-storage';
import { Logger } from '@nestjs/common';
export class S3Driver implements StorageDriver {
private readonly s3Client: S3Client;
@@ -39,6 +40,7 @@ export class S3Driver implements StorageDriver {
await upload.done();
} catch (err) {
Logger.error(err);
throw new Error(`Failed to upload file: ${(err as Error).message}`);
}
}
@@ -73,6 +75,7 @@ export class S3Driver implements StorageDriver {
await upload.done();
} catch (err) {
Logger.error(err);
throw new Error(`Failed to upload file: ${(err as Error).message}`);
} finally {
if (shouldDestroyClient && clientToUse) {
@@ -127,6 +130,25 @@ export class S3Driver implements StorageDriver {
}
}
async readRangeStream(
filePath: string,
range: { start: number; end: number },
): Promise<Readable> {
try {
const command = new GetObjectCommand({
Bucket: this.config.bucket,
Key: filePath,
Range: `bytes=${range.start}-${range.end}`,
});
const response = await this.s3Client.send(command);
return response.Body as Readable;
} catch (err) {
throw new Error(`Failed to read file from S3: ${(err as Error).message}`);
}
}
async exists(filePath: string): Promise<boolean> {
try {
const command = new HeadObjectCommand({
@@ -11,6 +11,11 @@ export interface StorageDriver {
readStream(filePath: string): Promise<Readable>;
readRangeStream(
filePath: string,
range: { start: number; end: number },
): Promise<Readable>;
exists(filePath: string): Promise<boolean>;
getUrl(filePath: string): string;
@@ -33,6 +33,13 @@ export class StorageService {
return this.storageDriver.readStream(filePath);
}
async readRangeStream(
filePath: string,
range: { start: number; end: number },
): Promise<Readable> {
return this.storageDriver.readRangeStream(filePath, range);
}
async exists(filePath: string): Promise<boolean> {
return this.storageDriver.exists(filePath);
}