mirror of
https://github.com/docmost/docmost.git
synced 2026-08-29 01:37:05 +08:00
Merge branch 'main' into permissions-2
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
export enum AttachmentType {
|
||||
Avatar = 'avatar',
|
||||
WorkspaceLogo = 'workspace-logo',
|
||||
SpaceLogo = 'space-logo',
|
||||
WorkspaceIcon = 'workspace-icon',
|
||||
SpaceIcon = 'space-icon',
|
||||
File = 'file',
|
||||
}
|
||||
|
||||
export const validImageExtensions = ['.jpg', '.png', '.jpeg'];
|
||||
export const MAX_AVATAR_SIZE = '5MB';
|
||||
export const MAX_AVATAR_SIZE = '10MB';
|
||||
|
||||
export const inlineFileExtensions = [
|
||||
'.jpg',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
@@ -51,6 +52,7 @@ import { EnvironmentService } from '../../integrations/environment/environment.s
|
||||
import { TokenService } from '../auth/services/token.service';
|
||||
import { JwtAttachmentPayload, JwtType } from '../auth/dto/jwt-payload';
|
||||
import * as path from 'path';
|
||||
import { RemoveIconDto } from './dto/attachment.dto';
|
||||
|
||||
@Controller()
|
||||
export class AttachmentController {
|
||||
@@ -302,7 +304,7 @@ export class AttachmentController {
|
||||
throw new BadRequestException('Invalid image attachment type');
|
||||
}
|
||||
|
||||
if (attachmentType === AttachmentType.WorkspaceLogo) {
|
||||
if (attachmentType === AttachmentType.WorkspaceIcon) {
|
||||
const ability = this.workspaceAbility.createForUser(user, workspace);
|
||||
if (
|
||||
ability.cannot(
|
||||
@@ -314,7 +316,7 @@ export class AttachmentController {
|
||||
}
|
||||
}
|
||||
|
||||
if (attachmentType === AttachmentType.SpaceLogo) {
|
||||
if (attachmentType === AttachmentType.SpaceIcon) {
|
||||
if (!spaceId) {
|
||||
throw new BadRequestException('spaceId is required');
|
||||
}
|
||||
@@ -372,8 +374,59 @@ export class AttachmentController {
|
||||
});
|
||||
return res.send(fileStream);
|
||||
} catch (err) {
|
||||
this.logger.error(err);
|
||||
// this.logger.error(err);
|
||||
throw new NotFoundException('File not found');
|
||||
}
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('attachments/remove-icon')
|
||||
async removeIcon(
|
||||
@Body() dto: RemoveIconDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const { type, spaceId } = dto;
|
||||
|
||||
// remove current user avatar
|
||||
if (type === AttachmentType.Avatar) {
|
||||
await this.attachmentService.removeUserAvatar(user);
|
||||
return;
|
||||
}
|
||||
|
||||
// remove space icon
|
||||
if (type === AttachmentType.SpaceIcon) {
|
||||
if (!spaceId) {
|
||||
throw new BadRequestException(
|
||||
'spaceId is required to change space icons',
|
||||
);
|
||||
}
|
||||
|
||||
const spaceAbility = await this.spaceAbility.createForUser(user, spaceId);
|
||||
if (
|
||||
spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)
|
||||
) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
await this.attachmentService.removeSpaceIcon(spaceId, workspace.id);
|
||||
return;
|
||||
}
|
||||
|
||||
// remove workspace icon
|
||||
if (type === AttachmentType.WorkspaceIcon) {
|
||||
const ability = this.workspaceAbility.createForUser(user, workspace);
|
||||
if (
|
||||
ability.cannot(
|
||||
WorkspaceCaslAction.Manage,
|
||||
WorkspaceCaslSubject.Settings,
|
||||
)
|
||||
) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
await this.attachmentService.removeWorkspaceIcon(workspace);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { MultipartFile } from '@fastify/multipart';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { sanitize } from 'sanitize-filename-ts';
|
||||
import * as path from 'path';
|
||||
import { AttachmentType } from './attachment.constants';
|
||||
import { sanitizeFileName } from '../../common/helpers';
|
||||
import * as sharp from 'sharp';
|
||||
|
||||
export interface PreparedFile {
|
||||
buffer: Buffer;
|
||||
@@ -22,10 +22,8 @@ export async function prepareFile(
|
||||
}
|
||||
|
||||
try {
|
||||
const rand = randomBytes(8).toString('hex');
|
||||
|
||||
const buffer = await file.toBuffer();
|
||||
const sanitizedFilename = sanitize(file.filename).replace(/ /g, '_');
|
||||
const sanitizedFilename = sanitizeFileName(file.filename);
|
||||
const fileName = sanitizedFilename.slice(0, 255);
|
||||
const fileSize = buffer.length;
|
||||
const fileExtension = path.extname(file.filename).toLowerCase();
|
||||
@@ -58,9 +56,9 @@ export function getAttachmentFolderPath(
|
||||
switch (type) {
|
||||
case AttachmentType.Avatar:
|
||||
return `${workspaceId}/avatars`;
|
||||
case AttachmentType.WorkspaceLogo:
|
||||
return `${workspaceId}/workspace-logo`;
|
||||
case AttachmentType.SpaceLogo:
|
||||
case AttachmentType.WorkspaceIcon:
|
||||
return `${workspaceId}/workspace-logos`;
|
||||
case AttachmentType.SpaceIcon:
|
||||
return `${workspaceId}/space-logos`;
|
||||
case AttachmentType.File:
|
||||
return `${workspaceId}/files`;
|
||||
@@ -70,3 +68,51 @@ export function getAttachmentFolderPath(
|
||||
}
|
||||
|
||||
export const validAttachmentTypes = Object.values(AttachmentType);
|
||||
|
||||
export async function compressAndResizeIcon(
|
||||
buffer: Buffer,
|
||||
attachmentType?: AttachmentType,
|
||||
): Promise<Buffer> {
|
||||
try {
|
||||
let sharpInstance = sharp(buffer);
|
||||
const metadata = await sharpInstance.metadata();
|
||||
|
||||
const targetWidth = 300;
|
||||
const targetHeight = 300;
|
||||
|
||||
// Only resize if image is larger than target dimensions
|
||||
if (metadata.width > targetWidth || metadata.height > targetHeight) {
|
||||
sharpInstance = sharpInstance.resize(targetWidth, targetHeight, {
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Handle based on original format
|
||||
if (metadata.format === 'png') {
|
||||
// Only flatten avatars to remove transparency
|
||||
if (attachmentType === AttachmentType.Avatar) {
|
||||
sharpInstance = sharpInstance.flatten({
|
||||
background: { r: 255, g: 255, b: 255 },
|
||||
});
|
||||
}
|
||||
|
||||
return await sharpInstance
|
||||
.png({
|
||||
quality: 85,
|
||||
compressionLevel: 6,
|
||||
})
|
||||
.toBuffer();
|
||||
} else {
|
||||
return await sharpInstance
|
||||
.jpeg({
|
||||
quality: 85,
|
||||
progressive: true,
|
||||
mozjpeg: true,
|
||||
})
|
||||
.toBuffer();
|
||||
}
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { IsEnum, IsIn, IsNotEmpty, IsOptional, IsUUID } from 'class-validator';
|
||||
import { AttachmentType } from '../attachment.constants';
|
||||
|
||||
export class RemoveIconDto {
|
||||
@IsEnum(AttachmentType)
|
||||
@IsIn([
|
||||
AttachmentType.Avatar,
|
||||
AttachmentType.SpaceIcon,
|
||||
AttachmentType.WorkspaceIcon,
|
||||
])
|
||||
@IsNotEmpty()
|
||||
type: AttachmentType;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
spaceId: string;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import { IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
export class AvatarUploadDto {}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class GetFileDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
attachmentId: string;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import {
|
||||
IsDefined,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UploadFileDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
attachmentType: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
pageId: string;
|
||||
|
||||
@IsDefined()
|
||||
file: any;
|
||||
}
|
||||
@@ -3,12 +3,15 @@ import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq';
|
||||
import { Job } from 'bullmq';
|
||||
import { AttachmentService } from '../services/attachment.service';
|
||||
import { QueueJob, QueueName } from 'src/integrations/queue/constants';
|
||||
import { Space } from '@docmost/db/types/entity.types';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
|
||||
@Processor(QueueName.ATTACHMENT_QUEUE)
|
||||
export class AttachmentProcessor extends WorkerHost implements OnModuleDestroy {
|
||||
private readonly logger = new Logger(AttachmentProcessor.name);
|
||||
constructor(private readonly attachmentService: AttachmentService) {
|
||||
constructor(
|
||||
private readonly attachmentService: AttachmentService,
|
||||
private moduleRef: ModuleRef,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -25,6 +28,33 @@ export class AttachmentProcessor extends WorkerHost implements OnModuleDestroy {
|
||||
job.data.pageId,
|
||||
);
|
||||
}
|
||||
if (
|
||||
job.name === QueueJob.ATTACHMENT_INDEX_CONTENT ||
|
||||
job.name === QueueJob.ATTACHMENT_INDEXING
|
||||
) {
|
||||
let AttachmentEeModule: any;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
AttachmentEeModule = require('./../../../ee/attachments-ee/attachment-ee.service');
|
||||
} catch (err) {
|
||||
this.logger.debug(
|
||||
'Attachment enterprise module requested but EE module not bundled in this build',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const attachmentEeService = this.moduleRef.get(
|
||||
AttachmentEeModule.AttachmentEeService,
|
||||
{ strict: false },
|
||||
);
|
||||
|
||||
if (job.name === QueueJob.ATTACHMENT_INDEX_CONTENT) {
|
||||
await attachmentEeService.indexAttachment(job.data.attachmentId);
|
||||
} else if (job.name === QueueJob.ATTACHMENT_INDEXING) {
|
||||
await attachmentEeService.indexAttachments(
|
||||
job.data.workspaceId,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
@@ -37,9 +67,15 @@ export class AttachmentProcessor extends WorkerHost implements OnModuleDestroy {
|
||||
|
||||
@OnWorkerEvent('failed')
|
||||
onError(job: Job) {
|
||||
this.logger.error(
|
||||
`Error processing ${job.name} job. Reason: ${job.failedReason}`,
|
||||
);
|
||||
if (job.name === QueueJob.ATTACHMENT_INDEX_CONTENT) {
|
||||
this.logger.debug(
|
||||
`Error processing ${job.name} job for attachment ${job.data?.attachmentId}. Reason: ${job.failedReason}`,
|
||||
);
|
||||
} else {
|
||||
this.logger.error(
|
||||
`Error processing ${job.name} job. Reason: ${job.failedReason}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@OnWorkerEvent('completed')
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { StorageService } from '../../../integrations/storage/storage.service';
|
||||
import { MultipartFile } from '@fastify/multipart';
|
||||
import {
|
||||
compressAndResizeIcon,
|
||||
getAttachmentFolderPath,
|
||||
PreparedFile,
|
||||
prepareFile,
|
||||
@@ -16,12 +17,15 @@ import { v4 as uuid4, v7 as uuid7 } from 'uuid';
|
||||
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
|
||||
import { AttachmentType, validImageExtensions } from '../attachment.constants';
|
||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||
import { Attachment } from '@docmost/db/types/entity.types';
|
||||
import { Attachment, User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
|
||||
import { Queue } from 'bullmq';
|
||||
|
||||
@Injectable()
|
||||
export class AttachmentService {
|
||||
@@ -33,6 +37,7 @@ export class AttachmentService {
|
||||
private readonly workspaceRepo: WorkspaceRepo,
|
||||
private readonly spaceRepo: SpaceRepo,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
|
||||
) {}
|
||||
|
||||
async uploadFile(opts: {
|
||||
@@ -99,6 +104,23 @@ export class AttachmentService {
|
||||
pageId,
|
||||
});
|
||||
}
|
||||
|
||||
// Only index PDFs and DOCX files
|
||||
if (['.pdf', '.docx'].includes(attachment.fileExt.toLowerCase())) {
|
||||
await this.attachmentQueue.add(
|
||||
QueueJob.ATTACHMENT_INDEX_CONTENT,
|
||||
{
|
||||
attachmentId: attachmentId,
|
||||
},
|
||||
{
|
||||
attempts: 2,
|
||||
backoff: {
|
||||
type: 'exponential',
|
||||
delay: 10000,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
// delete uploaded file on error
|
||||
this.logger.error(err);
|
||||
@@ -111,8 +133,8 @@ export class AttachmentService {
|
||||
filePromise: Promise<MultipartFile>,
|
||||
type:
|
||||
| AttachmentType.Avatar
|
||||
| AttachmentType.WorkspaceLogo
|
||||
| AttachmentType.SpaceLogo,
|
||||
| AttachmentType.WorkspaceIcon
|
||||
| AttachmentType.SpaceIcon,
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
spaceId?: string,
|
||||
@@ -120,6 +142,9 @@ export class AttachmentService {
|
||||
const preparedFile: PreparedFile = await prepareFile(filePromise);
|
||||
validateFileType(preparedFile.fileExtension, validImageExtensions);
|
||||
|
||||
const processedBuffer = await compressAndResizeIcon(preparedFile.buffer, type);
|
||||
preparedFile.buffer = processedBuffer;
|
||||
preparedFile.fileSize = processedBuffer.length;
|
||||
preparedFile.fileName = uuid4() + preparedFile.fileExtension;
|
||||
|
||||
const filePath = `${getAttachmentFolderPath(type, workspaceId)}/${preparedFile.fileName}`;
|
||||
@@ -153,7 +178,7 @@ export class AttachmentService {
|
||||
workspaceId,
|
||||
trx,
|
||||
);
|
||||
} else if (type === AttachmentType.WorkspaceLogo) {
|
||||
} else if (type === AttachmentType.WorkspaceIcon) {
|
||||
const workspace = await this.workspaceRepo.findById(workspaceId, {
|
||||
trx,
|
||||
});
|
||||
@@ -165,7 +190,7 @@ export class AttachmentService {
|
||||
workspaceId,
|
||||
trx,
|
||||
);
|
||||
} else if (type === AttachmentType.SpaceLogo && spaceId) {
|
||||
} else if (type === AttachmentType.SpaceIcon && spaceId) {
|
||||
const space = await this.spaceRepo.findById(spaceId, workspaceId, {
|
||||
trx,
|
||||
});
|
||||
@@ -184,7 +209,6 @@ export class AttachmentService {
|
||||
});
|
||||
} catch (err) {
|
||||
// delete uploaded file on db update failure
|
||||
this.logger.error('Image upload error:', err);
|
||||
await this.deleteRedundantFile(filePath);
|
||||
throw new BadRequestException('Failed to upload image');
|
||||
}
|
||||
@@ -367,4 +391,41 @@ export class AttachmentService {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async removeUserAvatar(user: User) {
|
||||
if (user.avatarUrl && !user.avatarUrl.toLowerCase().startsWith('http')) {
|
||||
const filePath = `${getAttachmentFolderPath(AttachmentType.Avatar, user.workspaceId)}/${user.avatarUrl}`;
|
||||
await this.deleteRedundantFile(filePath);
|
||||
}
|
||||
|
||||
await this.userRepo.updateUser(
|
||||
{ avatarUrl: null },
|
||||
user.id,
|
||||
user.workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
async removeSpaceIcon(spaceId: string, workspaceId: string) {
|
||||
const space = await this.spaceRepo.findById(spaceId, workspaceId);
|
||||
|
||||
if (!space) {
|
||||
throw new NotFoundException('Space not found');
|
||||
}
|
||||
|
||||
if (space.logo && !space.logo.toLowerCase().startsWith('http')) {
|
||||
const filePath = `${getAttachmentFolderPath(AttachmentType.SpaceIcon, workspaceId)}/${space.logo}`;
|
||||
await this.deleteRedundantFile(filePath);
|
||||
}
|
||||
|
||||
await this.spaceRepo.updateSpace({ logo: null }, spaceId, workspaceId);
|
||||
}
|
||||
|
||||
async removeWorkspaceIcon(workspace: Workspace) {
|
||||
if (workspace.logo && !workspace.logo.toLowerCase().startsWith('http')) {
|
||||
const filePath = `${getAttachmentFolderPath(AttachmentType.WorkspaceIcon, workspace.id)}/${workspace.logo}`;
|
||||
await this.deleteRedundantFile(filePath);
|
||||
}
|
||||
|
||||
await this.workspaceRepo.updateWorkspace({ logo: null }, workspace.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ export enum JwtType {
|
||||
EXCHANGE = 'exchange',
|
||||
ATTACHMENT = 'attachment',
|
||||
MFA_TOKEN = 'mfa_token',
|
||||
API_KEY = 'api_key',
|
||||
}
|
||||
export type JwtPayload = {
|
||||
sub: string;
|
||||
@@ -36,3 +37,10 @@ export interface JwtMfaTokenPayload {
|
||||
workspaceId: string;
|
||||
type: 'mfa_token';
|
||||
}
|
||||
|
||||
export type JwtApiKeyPayload = {
|
||||
sub: string;
|
||||
workspaceId: string;
|
||||
apiKeyId: string;
|
||||
type: 'api_key';
|
||||
};
|
||||
|
||||
@@ -106,6 +106,7 @@ export class AuthService {
|
||||
await this.userRepo.updateUser(
|
||||
{
|
||||
password: newPasswordHash,
|
||||
hasGeneratedPassword: false,
|
||||
},
|
||||
userId,
|
||||
workspaceId,
|
||||
@@ -186,6 +187,7 @@ export class AuthService {
|
||||
await this.userRepo.updateUser(
|
||||
{
|
||||
password: newPasswordHash,
|
||||
hasGeneratedPassword: false,
|
||||
},
|
||||
user.id,
|
||||
workspace.id,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
||||
import {
|
||||
JwtApiKeyPayload,
|
||||
JwtAttachmentPayload,
|
||||
JwtCollabPayload,
|
||||
JwtExchangePayload,
|
||||
@@ -90,6 +91,27 @@ export class TokenService {
|
||||
return this.jwtService.sign(payload, { expiresIn: '5m' });
|
||||
}
|
||||
|
||||
async generateApiToken(opts: {
|
||||
apiKeyId: string;
|
||||
user: User;
|
||||
workspaceId: string;
|
||||
expiresIn?: string | number;
|
||||
}): Promise<string> {
|
||||
const { apiKeyId, user, workspaceId, expiresIn } = opts;
|
||||
if (user.deactivatedAt || user.deletedAt) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
const payload: JwtApiKeyPayload = {
|
||||
sub: user.id,
|
||||
apiKeyId: apiKeyId,
|
||||
workspaceId,
|
||||
type: JwtType.API_KEY,
|
||||
};
|
||||
|
||||
return this.jwtService.sign(payload, expiresIn ? { expiresIn } : {});
|
||||
}
|
||||
|
||||
async verifyJwt(token: string, tokenType: string) {
|
||||
const payload = await this.jwtService.verifyAsync(token, {
|
||||
secret: this.environmentService.getAppSecret(),
|
||||
|
||||
@@ -2,11 +2,12 @@ import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Strategy } from 'passport-jwt';
|
||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
||||
import { JwtPayload, JwtType } from '../dto/jwt-payload';
|
||||
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 { FastifyRequest } from 'fastify';
|
||||
import { extractBearerTokenFromHeader } from '../../../common/helpers';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
@@ -16,6 +17,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
private userRepo: UserRepo,
|
||||
private workspaceRepo: WorkspaceRepo,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private moduleRef: ModuleRef,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: (req: FastifyRequest) => {
|
||||
@@ -27,8 +29,8 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
});
|
||||
}
|
||||
|
||||
async validate(req: any, payload: JwtPayload) {
|
||||
if (!payload.workspaceId || payload.type !== JwtType.ACCESS) {
|
||||
async validate(req: any, payload: JwtPayload | JwtApiKeyPayload) {
|
||||
if (!payload.workspaceId) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
@@ -36,6 +38,14 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
throw new UnauthorizedException('Workspace does not match');
|
||||
}
|
||||
|
||||
if (payload.type === JwtType.API_KEY) {
|
||||
return this.validateApiKey(req, payload as JwtApiKeyPayload);
|
||||
}
|
||||
|
||||
if (payload.type !== JwtType.ACCESS) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
const workspace = await this.workspaceRepo.findById(payload.workspaceId);
|
||||
|
||||
if (!workspace) {
|
||||
@@ -49,4 +59,30 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
|
||||
return { user, workspace };
|
||||
}
|
||||
|
||||
private async validateApiKey(req: any, payload: JwtApiKeyPayload) {
|
||||
let ApiKeyModule: any;
|
||||
let isApiKeyModuleReady = false;
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
ApiKeyModule = require('./../../../ee/api-key/api-key.service');
|
||||
isApiKeyModuleReady = true;
|
||||
} catch (err) {
|
||||
this.logger.debug(
|
||||
'API Key module requested but enterprise module not bundled in this build',
|
||||
);
|
||||
isApiKeyModuleReady = false;
|
||||
}
|
||||
|
||||
if (isApiKeyModuleReady) {
|
||||
const ApiKeyService = this.moduleRef.get(ApiKeyModule.ApiKeyService, {
|
||||
strict: false,
|
||||
});
|
||||
|
||||
return ApiKeyService.validateApiKey(payload);
|
||||
}
|
||||
|
||||
throw new UnauthorizedException('Enterprise API Key module missing');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ function buildWorkspaceOwnerAbility() {
|
||||
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Group);
|
||||
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Member);
|
||||
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment);
|
||||
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.API);
|
||||
|
||||
return build();
|
||||
}
|
||||
@@ -55,6 +56,7 @@ function buildWorkspaceAdminAbility() {
|
||||
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Group);
|
||||
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Member);
|
||||
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment);
|
||||
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.API);
|
||||
|
||||
return build();
|
||||
}
|
||||
@@ -68,6 +70,7 @@ function buildWorkspaceMemberAbility() {
|
||||
can(WorkspaceCaslAction.Read, WorkspaceCaslSubject.Space);
|
||||
can(WorkspaceCaslAction.Read, WorkspaceCaslSubject.Group);
|
||||
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment);
|
||||
can(WorkspaceCaslAction.Create, WorkspaceCaslSubject.API);
|
||||
|
||||
return build();
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export enum WorkspaceCaslSubject {
|
||||
Space = 'space',
|
||||
Group = 'group',
|
||||
Attachment = 'attachment',
|
||||
API = 'api_key',
|
||||
}
|
||||
|
||||
export type IWorkspaceAbility =
|
||||
@@ -18,4 +19,5 @@ export type IWorkspaceAbility =
|
||||
| [WorkspaceCaslAction, WorkspaceCaslSubject.Member]
|
||||
| [WorkspaceCaslAction, WorkspaceCaslSubject.Space]
|
||||
| [WorkspaceCaslAction, WorkspaceCaslSubject.Group]
|
||||
| [WorkspaceCaslAction, WorkspaceCaslSubject.Attachment];
|
||||
| [WorkspaceCaslAction, WorkspaceCaslSubject.Attachment]
|
||||
| [WorkspaceCaslAction, WorkspaceCaslSubject.API];
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
import { IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
import { SpaceIdDto } from './page.dto';
|
||||
|
||||
export class SidebarPageDto extends SpaceIdDto {
|
||||
export class SidebarPageDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
spaceId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
pageId: string;
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { PageService } from './services/page.service';
|
||||
import { CreatePageDto } from './dto/create-page.dto';
|
||||
import { UpdatePageDto } from './dto/update-page.dto';
|
||||
import { MovePageDto, MovePageToSpaceDto } from './dto/move-page.dto';
|
||||
import {
|
||||
DeletePageDto,
|
||||
PageHistoryIdDto,
|
||||
PageIdDto,
|
||||
PageInfoDto,
|
||||
DeletePageDto,
|
||||
} from './dto/page.dto';
|
||||
import { PageHistoryService } from './services/page-history.service';
|
||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||
@@ -134,7 +134,11 @@ export class PageController {
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('delete')
|
||||
async delete(@Body() deletePageDto: DeletePageDto, @AuthUser() user: User) {
|
||||
async delete(
|
||||
@Body() deletePageDto: DeletePageDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const page = await this.pageRepo.findById(deletePageDto.pageId);
|
||||
|
||||
if (!page) {
|
||||
@@ -150,19 +154,27 @@ export class PageController {
|
||||
'Only space admins can permanently delete pages',
|
||||
);
|
||||
}
|
||||
await this.pageService.forceDelete(deletePageDto.pageId);
|
||||
await this.pageService.forceDelete(deletePageDto.pageId, workspace.id);
|
||||
} else {
|
||||
// Soft delete requires page manage permissions
|
||||
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
await this.pageService.remove(deletePageDto.pageId, user.id);
|
||||
await this.pageService.removePage(
|
||||
deletePageDto.pageId,
|
||||
user.id,
|
||||
workspace.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('restore')
|
||||
async restore(@Body() pageIdDto: PageIdDto, @AuthUser() user: User) {
|
||||
async restore(
|
||||
@Body() pageIdDto: PageIdDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const page = await this.pageRepo.findById(pageIdDto.pageId);
|
||||
|
||||
if (!page) {
|
||||
@@ -174,13 +186,11 @@ export class PageController {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
await this.pageRepo.restorePage(pageIdDto.pageId);
|
||||
await this.pageRepo.restorePage(pageIdDto.pageId, workspace.id);
|
||||
|
||||
// Return the restored page data with hasChildren info
|
||||
const restoredPage = await this.pageRepo.findById(pageIdDto.pageId, {
|
||||
return this.pageRepo.findById(pageIdDto.pageId, {
|
||||
includeHasChildren: true,
|
||||
});
|
||||
return restoredPage;
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@@ -282,21 +292,28 @@ export class PageController {
|
||||
@Body() pagination: PaginationOptions,
|
||||
@AuthUser() user: User,
|
||||
) {
|
||||
const ability = await this.spaceAbility.createForUser(user, dto.spaceId);
|
||||
if (!dto.spaceId && !dto.pageId) {
|
||||
throw new BadRequestException(
|
||||
'Either spaceId or pageId must be provided',
|
||||
);
|
||||
}
|
||||
let spaceId = dto.spaceId;
|
||||
|
||||
if (dto.pageId) {
|
||||
const page = await this.pageRepo.findById(dto.pageId);
|
||||
if (!page) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
spaceId = page.spaceId;
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
let pageId = null;
|
||||
if (dto.pageId) {
|
||||
const page = await this.pageRepo.findById(dto.pageId);
|
||||
if (page.spaceId !== dto.spaceId) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
pageId = page.id;
|
||||
}
|
||||
|
||||
return this.pageService.getSidebarPages(dto.spaceId, pagination, pageId);
|
||||
return this.pageService.getSidebarPages(spaceId, pagination, dto.pageId);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
|
||||
@@ -38,6 +38,8 @@ import { StorageService } from '../../../integrations/storage/storage.service';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Queue } from 'bullmq';
|
||||
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
|
||||
import { EventName } from '../../../common/events/event.contants';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
|
||||
@Injectable()
|
||||
export class PageService {
|
||||
@@ -49,6 +51,8 @@ export class PageService {
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private readonly storageService: StorageService,
|
||||
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
|
||||
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
|
||||
private eventEmitter: EventEmitter2,
|
||||
) {}
|
||||
|
||||
async findById(
|
||||
@@ -231,21 +235,33 @@ export class PageService {
|
||||
);
|
||||
}
|
||||
|
||||
// update spaceId in shares
|
||||
if (pageIds.length > 0) {
|
||||
// update spaceId in shares
|
||||
await trx
|
||||
.updateTable('shares')
|
||||
.set({ spaceId: spaceId })
|
||||
.where('pageId', 'in', pageIds)
|
||||
.execute();
|
||||
}
|
||||
|
||||
// Update attachments
|
||||
await this.attachmentRepo.updateAttachmentsByPageId(
|
||||
{ spaceId },
|
||||
pageIds,
|
||||
trx,
|
||||
);
|
||||
// Update comments
|
||||
await trx
|
||||
.updateTable('comments')
|
||||
.set({ spaceId: spaceId })
|
||||
.where('pageId', 'in', pageIds)
|
||||
.execute();
|
||||
|
||||
// Update attachments
|
||||
await this.attachmentRepo.updateAttachmentsByPageId(
|
||||
{ spaceId },
|
||||
pageIds,
|
||||
trx,
|
||||
);
|
||||
|
||||
await this.aiQueue.add(QueueJob.PAGE_MOVED_TO_SPACE, {
|
||||
pageId: pageIds,
|
||||
workspaceId: rootPage.workspaceId
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -371,15 +387,21 @@ export class PageService {
|
||||
workspaceId: page.workspaceId,
|
||||
creatorId: authUser.id,
|
||||
lastUpdatedById: authUser.id,
|
||||
parentPageId: page.parentPageId
|
||||
? pageMap.get(page.parentPageId)?.newPageId
|
||||
: null,
|
||||
parentPageId: page.id === rootPage.id
|
||||
? (isDuplicateInSameSpace ? rootPage.parentPageId : null)
|
||||
: (page.parentPageId ? pageMap.get(page.parentPageId)?.newPageId : null),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
await this.db.insertInto('pages').values(insertablePages).execute();
|
||||
|
||||
const insertedPageIds = insertablePages.map((page) => page.id);
|
||||
this.eventEmitter.emit(EventName.PAGE_CREATED, {
|
||||
pageIds: insertedPageIds,
|
||||
workspaceId: authUser.workspaceId,
|
||||
});
|
||||
|
||||
//TODO: best to handle this in a queue
|
||||
const attachmentsIds = Array.from(attachmentMap.keys());
|
||||
if (attachmentsIds.length > 0) {
|
||||
@@ -565,7 +587,7 @@ export class PageService {
|
||||
return await this.pageRepo.getDeletedPagesInSpace(spaceId, pagination);
|
||||
}
|
||||
|
||||
async forceDelete(pageId: string): Promise<void> {
|
||||
async forceDelete(pageId: string, workspaceId: string): Promise<void> {
|
||||
// Get all descendant IDs (including the page itself) using recursive CTE
|
||||
const descendants = await this.db
|
||||
.withRecursive('page_descendants', (db) =>
|
||||
@@ -606,10 +628,18 @@ export class PageService {
|
||||
|
||||
if (pageIds.length > 0) {
|
||||
await this.db.deleteFrom('pages').where('id', 'in', pageIds).execute();
|
||||
this.eventEmitter.emit(EventName.PAGE_DELETED, {
|
||||
pageIds: pageIds,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async remove(pageId: string, userId: string): Promise<void> {
|
||||
await this.pageRepo.removePage(pageId, userId);
|
||||
async removePage(
|
||||
pageId: string,
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
await this.pageRepo.removePage(pageId, userId, workspaceId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Space } from '@docmost/db/types/entity.types';
|
||||
|
||||
export class SearchResponseDto {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -8,4 +10,5 @@ export class SearchResponseDto {
|
||||
highlight: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
space: Partial<Space>;
|
||||
}
|
||||
|
||||
@@ -5,15 +5,13 @@ import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
} from 'class-validator';
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateWorkspaceDto } from '../../workspace/dto/create-workspace.dto';
|
||||
|
||||
export class SearchDTO {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
query: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
spaceId: string;
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ForbiddenException,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
@@ -24,13 +25,19 @@ import {
|
||||
} from '../casl/interfaces/space-ability.type';
|
||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||
import { Public } from 'src/common/decorators/public.decorator';
|
||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('search')
|
||||
export class SearchController {
|
||||
private readonly logger = new Logger(SearchController.name);
|
||||
|
||||
constructor(
|
||||
private readonly searchService: SearchService,
|
||||
private readonly spaceAbility: SpaceAbilityFactory,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private moduleRef: ModuleRef,
|
||||
) {}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@@ -53,7 +60,14 @@ export class SearchController {
|
||||
}
|
||||
}
|
||||
|
||||
return this.searchService.searchPage(searchDto.query, searchDto, {
|
||||
if (this.environmentService.getSearchDriver() === 'typesense') {
|
||||
return this.searchTypesense(searchDto, {
|
||||
userId: user.id,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
}
|
||||
|
||||
return this.searchService.searchPage(searchDto, {
|
||||
userId: user.id,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
@@ -81,8 +95,47 @@ export class SearchController {
|
||||
throw new BadRequestException('shareId is required');
|
||||
}
|
||||
|
||||
return this.searchService.searchPage(searchDto.query, searchDto, {
|
||||
if (this.environmentService.getSearchDriver() === 'typesense') {
|
||||
return this.searchTypesense(searchDto, {
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
}
|
||||
|
||||
return this.searchService.searchPage(searchDto, {
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
}
|
||||
|
||||
async searchTypesense(
|
||||
searchParams: SearchDTO,
|
||||
opts: {
|
||||
userId?: string;
|
||||
workspaceId: string;
|
||||
},
|
||||
) {
|
||||
const { userId, workspaceId } = opts;
|
||||
let TypesenseModule: any;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
TypesenseModule = require('./../../ee/typesense/services/page-search.service');
|
||||
|
||||
const PageSearchService = this.moduleRef.get(
|
||||
TypesenseModule.PageSearchService,
|
||||
{
|
||||
strict: false,
|
||||
},
|
||||
);
|
||||
|
||||
return PageSearchService.searchPage(searchParams, {
|
||||
userId: userId,
|
||||
workspaceId,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.debug(
|
||||
'Typesense module requested but enterprise module not bundled in this build',
|
||||
);
|
||||
}
|
||||
|
||||
throw new BadRequestException('Enterprise Typesense search module missing');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,13 +21,14 @@ export class SearchService {
|
||||
) {}
|
||||
|
||||
async searchPage(
|
||||
query: string,
|
||||
searchParams: SearchDTO,
|
||||
opts: {
|
||||
userId?: string;
|
||||
workspaceId: string;
|
||||
},
|
||||
): Promise<SearchResponseDto[]> {
|
||||
const { query } = searchParams;
|
||||
|
||||
if (query.length < 1) {
|
||||
return;
|
||||
}
|
||||
@@ -61,7 +62,7 @@ export class SearchService {
|
||||
)
|
||||
.where('deletedAt', 'is', null)
|
||||
.orderBy('rank', 'desc')
|
||||
.limit(searchParams.limit | 20)
|
||||
.limit(searchParams.limit | 25)
|
||||
.offset(searchParams.offset || 0);
|
||||
|
||||
if (!searchParams.shareId) {
|
||||
|
||||
@@ -31,6 +31,7 @@ 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';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('shares')
|
||||
@@ -65,9 +66,11 @@ export class ShareController {
|
||||
|
||||
return {
|
||||
...(await this.shareService.getSharedPage(dto, workspace.id)),
|
||||
hasLicenseKey:
|
||||
Boolean(workspace.licenseKey) ||
|
||||
(this.environmentService.isCloud() && workspace.plan === 'business'),
|
||||
hasLicenseKey: hasLicenseOrEE({
|
||||
licenseKey: workspace.licenseKey,
|
||||
isCloud: this.environmentService.isCloud(),
|
||||
plan: workspace.plan,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -175,9 +178,11 @@ export class ShareController {
|
||||
) {
|
||||
return {
|
||||
...(await this.shareService.getShareTree(dto.shareId, workspace.id)),
|
||||
hasLicenseKey:
|
||||
Boolean(workspace.licenseKey) ||
|
||||
(this.environmentService.isCloud() && workspace.plan === 'business'),
|
||||
hasLicenseKey: hasLicenseOrEE({
|
||||
licenseKey: workspace.licenseKey,
|
||||
isCloud: this.environmentService.isCloud(),
|
||||
plan: workspace.plan,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,8 +69,8 @@ export class ShareService {
|
||||
return await this.shareRepo.insertShare({
|
||||
key: nanoIdGen().toLowerCase(),
|
||||
pageId: page.id,
|
||||
includeSubPages: createShareDto.includeSubPages || true,
|
||||
searchIndexing: createShareDto.searchIndexing || true,
|
||||
includeSubPages: createShareDto.includeSubPages ?? false,
|
||||
searchIndexing: createShareDto.searchIndexing ?? false,
|
||||
creatorId: authUserId,
|
||||
spaceId: page.spaceId,
|
||||
workspaceId,
|
||||
|
||||
@@ -18,4 +18,16 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enforceMfa: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
restrictApiToAdmins: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
aiSearch: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
generativeAi: boolean;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
|
||||
import { Queue } from 'bullmq';
|
||||
import { generateRandomSuffixNumbers } from '../../../common/helpers';
|
||||
import { isPageEmbeddingsTableExists } from '@docmost/db/helpers/helpers';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceService {
|
||||
@@ -50,6 +51,7 @@ export class WorkspaceService {
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
|
||||
@InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue,
|
||||
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
|
||||
) {}
|
||||
|
||||
async findById(workspaceId: string) {
|
||||
@@ -303,6 +305,60 @@ export class WorkspaceService {
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined') {
|
||||
await this.workspaceRepo.updateApiSettings(
|
||||
workspaceId,
|
||||
'restrictToAdmins',
|
||||
updateWorkspaceDto.restrictApiToAdmins,
|
||||
);
|
||||
delete updateWorkspaceDto.restrictApiToAdmins;
|
||||
}
|
||||
|
||||
if (typeof updateWorkspaceDto.aiSearch !== 'undefined') {
|
||||
await this.workspaceRepo.updateAiSettings(
|
||||
workspaceId,
|
||||
'search',
|
||||
updateWorkspaceDto.aiSearch,
|
||||
);
|
||||
|
||||
if (updateWorkspaceDto.aiSearch) {
|
||||
const tableExists = await isPageEmbeddingsTableExists(this.db);
|
||||
if (!tableExists) {
|
||||
throw new BadRequestException(
|
||||
'Failed to activate. Make sure pgvector postgres extension is installed.',
|
||||
);
|
||||
}
|
||||
|
||||
await this.aiQueue.add(QueueJob.WORKSPACE_CREATE_EMBEDDINGS, {
|
||||
workspaceId,
|
||||
});
|
||||
} else {
|
||||
// Schedule deletion after 24 hours
|
||||
const deleteJobId = `ai-search-disabled-${workspaceId}`;
|
||||
await this.aiQueue.add(
|
||||
QueueJob.WORKSPACE_DELETE_EMBEDDINGS,
|
||||
{ workspaceId },
|
||||
{
|
||||
jobId: deleteJobId,
|
||||
delay: 24 * 60 * 60 * 1000,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
delete updateWorkspaceDto.aiSearch;
|
||||
}
|
||||
|
||||
if (typeof updateWorkspaceDto.generativeAi !== 'undefined') {
|
||||
await this.workspaceRepo.updateAiSettings(
|
||||
workspaceId,
|
||||
'generative',
|
||||
updateWorkspaceDto.generativeAi,
|
||||
);
|
||||
delete updateWorkspaceDto.generativeAi;
|
||||
}
|
||||
|
||||
await this.workspaceRepo.updateWorkspace(updateWorkspaceDto, workspaceId);
|
||||
|
||||
const workspace = await this.workspaceRepo.findById(workspaceId, {
|
||||
|
||||
Reference in New Issue
Block a user