snapshot of https://github.com/docmost/docmost.git @ 6205bbeb908fe846f87dd6a2cbf562e24777db38 (2026-09-15)

This commit is contained in:
upstream-mirror
2026-09-15 15:15:34 +05:00
commit 2653349432
1615 changed files with 202632 additions and 0 deletions

View File

@@ -0,0 +1,24 @@
export enum AttachmentType {
Avatar = 'avatar',
WorkspaceIcon = 'workspace-icon',
SpaceIcon = 'space-icon',
File = 'file',
Chat = 'chat',
}
export const validImageExtensions = ['.jpg', '.png', '.jpeg'];
export const MAX_AVATAR_SIZE = '10MB';
export const inlineFileExtensions = [
'.jpg',
'.png',
'.jpeg',
'.pdf',
'.mp4',
'.mov',
'.mp3',
'.wav',
'.ogg',
'.m4a',
'.webm',
];

View File

@@ -0,0 +1,584 @@
import {
BadRequestException,
Body,
Controller,
ForbiddenException,
Get,
HttpCode,
HttpStatus,
Inject,
Logger,
NotFoundException,
Param,
Post,
Query,
Req,
Res,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { AttachmentService } from './services/attachment.service';
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 { OAuthScope } from '../../common/decorators/oauth-scope.decorator';
import { Attachment, User, Workspace } from '@docmost/db/types/entity.types';
import { StorageService } from '../../integrations/storage/storage.service';
import {
getAttachmentFolderPath,
validAttachmentTypes,
} from './attachment.utils';
import { getMimeType } from '../../common/helpers';
import {
AttachmentType,
inlineFileExtensions,
MAX_AVATAR_SIZE,
} from './attachment.constants';
import {
SpaceCaslAction,
SpaceCaslSubject,
} from '../casl/interfaces/space-ability.type';
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
import {
WorkspaceCaslAction,
WorkspaceCaslSubject,
} from '../casl/interfaces/workspace-ability.type';
import WorkspaceAbilityFactory from '../casl/abilities/workspace-ability.factory';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
import { validate as isValidUUID } from 'uuid';
import { EnvironmentService } from '../../integrations/environment/environment.service';
import { TokenService } from '../auth/services/token.service';
import { JwtAttachmentPayload, JwtType } from '../auth/dto/jwt-payload';
import * as path from 'path';
import {
AttachmentInfoDto,
PageIdDto,
RemoveIconDto,
} from './dto/attachment.dto';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { PageAccessService } from '../page/page-access/page-access.service';
import { DomainService } from '../../integrations/environment/domain.service';
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
import {
AUDIT_SERVICE,
IAuditService,
} from '../../integrations/audit/audit.service';
@Controller()
export class AttachmentController {
private readonly logger = new Logger(AttachmentController.name);
constructor(
private readonly attachmentService: AttachmentService,
private readonly storageService: StorageService,
private readonly workspaceAbility: WorkspaceAbilityFactory,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly pageRepo: PageRepo,
private readonly attachmentRepo: AttachmentRepo,
private readonly environmentService: EnvironmentService,
private readonly tokenService: TokenService,
private readonly pageAccessService: PageAccessService,
private readonly domainService: DomainService,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('files/upload')
@UseInterceptors(FileInterceptor)
async uploadFile(
@Req() req: any,
@Res() res: FastifyReply,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const maxFileSize = bytes(this.environmentService.getFileUploadSizeLimit());
let file = null;
try {
file = await req.file({
limits: { fileSize: maxFileSize, fields: 3, files: 1 },
});
} catch (err: any) {
this.logger.error(err.message);
if (err?.statusCode === 413) {
throw new BadRequestException(
`File too large. Exceeds the ${this.environmentService.getFileUploadSizeLimit()} limit`,
);
}
}
if (!file) {
throw new BadRequestException('Failed to upload file');
}
const pageId = file.fields?.pageId?.value;
if (!pageId) {
throw new BadRequestException('PageId is required');
}
const page = await this.pageRepo.findById(pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
await this.pageAccessService.validateCanEdit(page, user);
const spaceId = page.spaceId;
const attachmentId = file.fields?.attachmentId?.value;
if (attachmentId && !isValidUUID(attachmentId)) {
throw new BadRequestException('Invalid attachment id');
}
try {
const fileResponse = await this.attachmentService.uploadFile({
filePromise: file,
pageId: pageId,
spaceId: spaceId,
userId: user.id,
workspaceId: workspace.id,
attachmentId: attachmentId,
});
this.auditService.log({
event: AuditEvent.ATTACHMENT_UPLOADED,
resourceType: AuditResource.ATTACHMENT,
resourceId: fileResponse?.id ?? attachmentId,
spaceId,
metadata: {
fileName: fileResponse?.fileName,
pageId,
spaceId,
},
});
return res.send({
...fileResponse,
url: this.buildFileUrl(workspace, fileResponse),
});
} catch (err: any) {
if (err?.statusCode === 413) {
const errMessage = `File too large. Exceeds the ${this.environmentService.getFileUploadSizeLimit()} limit`;
this.logger.error(errMessage);
throw new BadRequestException(errMessage);
}
this.logger.error(err);
throw new BadRequestException('Error processing file upload.');
}
}
@UseGuards(JwtAuthGuard)
@Get('/files/:fileId/:fileName')
@OAuthScope('read')
async getFile(
@Req() req: FastifyRequest,
@Res() res: FastifyReply,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
@Param('fileId') fileId: string,
@Param('fileName') fileName?: string,
) {
if (!isValidUUID(fileId)) {
throw new NotFoundException('Invalid file id');
}
const attachment = await this.attachmentRepo.findById(fileId);
if (!attachment || attachment.workspaceId !== workspace.id) {
throw new NotFoundException();
}
if (attachment.aiChatId) {
// Chat-owned attachment: only the user who uploaded (and therefore
// owns the chat, per AttachmentRepo.claimAttachmentsForChat) can
// read it back.
if (attachment.creatorId !== user.id) {
throw new NotFoundException();
}
} else {
if (!attachment.pageId || !attachment.spaceId) {
throw new NotFoundException();
}
const page = await this.pageRepo.findById(attachment.pageId);
if (!page) {
throw new NotFoundException();
}
await this.pageAccessService.validateCanView(page, user);
}
try {
return await this.sendFileResponse(req, res, attachment, 'private');
} catch (err) {
this.logger.error(err);
throw new NotFoundException('File not found');
}
}
@Get('/files/public/:fileId/:fileName')
async getPublicFile(
@Req() req: FastifyRequest,
@Res() res: FastifyReply,
@AuthWorkspace() workspace: Workspace,
@Param('fileId') fileId: string,
@Param('fileName') fileName?: string,
@Query('jwt') jwtToken?: string,
) {
let jwtPayload: JwtAttachmentPayload = null;
try {
jwtPayload = await this.tokenService.verifyJwt(
jwtToken,
JwtType.ATTACHMENT,
);
} catch (err) {
throw new BadRequestException(
'Expired or invalid attachment access token',
);
}
if (
!isValidUUID(fileId) ||
fileId !== jwtPayload.attachmentId ||
jwtPayload.workspaceId !== workspace.id
) {
throw new NotFoundException('File not found');
}
const attachment = await this.attachmentRepo.findById(fileId);
if (
!attachment ||
attachment.workspaceId !== workspace.id ||
!attachment.pageId ||
!attachment.spaceId ||
jwtPayload.pageId !== attachment.pageId
) {
throw new NotFoundException('File not found');
}
try {
return await this.sendFileResponse(req, res, attachment, 'public');
} catch (err) {
this.logger.error(err);
throw new NotFoundException('File not found');
}
}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('attachments/upload-image')
@UseInterceptors(FileInterceptor)
async uploadAvatarOrLogo(
@Req() req: any,
@Res() res: FastifyReply,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const maxFileSize = bytes(MAX_AVATAR_SIZE);
let file = null;
try {
file = await req.file({
limits: { fileSize: maxFileSize, fields: 3, files: 1 },
});
} catch (err: any) {
if (err?.statusCode === 413) {
throw new BadRequestException(
`File too large. Exceeds the ${MAX_AVATAR_SIZE} limit`,
);
}
}
if (!file) {
throw new BadRequestException('Invalid file upload');
}
const attachmentType = file.fields?.type?.value;
const spaceId = file.fields?.spaceId?.value;
if (!attachmentType) {
throw new BadRequestException('attachment type is required');
}
if (
!validAttachmentTypes.includes(attachmentType) ||
attachmentType === AttachmentType.File
) {
throw new BadRequestException('Invalid image attachment type');
}
if (attachmentType === AttachmentType.WorkspaceIcon) {
const ability = this.workspaceAbility.createForUser(user, workspace);
if (
ability.cannot(
WorkspaceCaslAction.Manage,
WorkspaceCaslSubject.Settings,
)
) {
throw new ForbiddenException();
}
}
if (attachmentType === AttachmentType.SpaceIcon) {
if (!spaceId) {
throw new BadRequestException('spaceId is required');
}
const spaceAbility = await this.spaceAbility.createForUser(user, spaceId);
if (
spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)
) {
throw new ForbiddenException();
}
}
try {
const fileResponse = await this.attachmentService.uploadImage(
file,
attachmentType,
user.id,
workspace.id,
spaceId,
);
return res.send(fileResponse);
} catch (err: any) {
this.logger.error(err);
throw new BadRequestException('Error processing file upload.');
}
}
@Get('attachments/img/:attachmentType/:fileName')
async getLogoOrAvatar(
@Res() res: FastifyReply,
@AuthWorkspace() workspace: Workspace,
@Param('attachmentType') attachmentType: AttachmentType,
@Param('fileName') fileName?: string,
) {
if (
!validAttachmentTypes.includes(attachmentType) ||
attachmentType === AttachmentType.File
) {
throw new BadRequestException('Invalid image attachment type');
}
if (!fileName) {
throw new BadRequestException('Invalid file name');
}
const ext = path.extname(fileName);
const filenameWithoutExt = path.basename(fileName, ext);
if (
!ext ||
!isValidUUID(filenameWithoutExt) ||
`${filenameWithoutExt}${ext}` !== fileName
) {
throw new BadRequestException('Invalid file name');
}
const filePath = `${getAttachmentFolderPath(attachmentType, workspace.id)}/${fileName}`;
try {
const fileStream = await this.storageService.readStream(filePath);
res.headers({
'Content-Type': getMimeType(filePath),
'Cache-Control': 'private, max-age=86400',
});
return res.send(fileStream);
} catch (err) {
// this.logger.error(err);
throw new NotFoundException('File not found');
}
}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('files/info')
async getAttachmentInfo(
@Body() dto: AttachmentInfoDto,
@AuthWorkspace() workspace: Workspace,
@AuthUser() user: User,
) {
const attachment = await this.attachmentRepo.findById(dto.attachmentId);
if (
!attachment ||
!attachment.pageId ||
attachment.workspaceId !== workspace.id ||
attachment.type !== AttachmentType.File
) {
throw new NotFoundException('File not found');
}
const page = await this.pageRepo.findById(attachment.pageId);
if (!page) {
throw new NotFoundException('File not found');
}
await this.pageAccessService.validateCanView(page, user);
return { ...attachment, url: this.buildFileUrl(workspace, attachment) };
}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('pages/attachments')
@OAuthScope('read')
async getPageAttachments(
@Body() dto: PageIdDto,
@Body() pagination: PaginationOptions,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const page = await this.pageRepo.findById(dto.pageId);
if (!page || page.workspaceId !== workspace.id) {
throw new NotFoundException('Page not found');
}
await this.pageAccessService.validateCanView(page, user);
const result = await this.attachmentRepo.findPageAttachments(
page.id,
pagination,
);
return {
...result,
items: result.items.map((attachment) => ({
...attachment,
url: this.buildFileUrl(workspace, attachment),
})),
};
}
@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;
}
}
private buildFileUrl(workspace: Workspace, attachment: Attachment): string {
return `${this.domainService.getUrl(workspace.hostname)}/api/files/${attachment.id}/${encodeURIComponent(attachment.fileName)}`;
}
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');
res.header(
'Content-Security-Policy',
"base-uri 'none'; object-src 'self'; default-src 'self';",
);
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);
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { AttachmentService } from './services/attachment.service';
import { AttachmentController } from './attachment.controller';
import { StorageModule } from '../../integrations/storage/storage.module';
import { UserModule } from '../user/user.module';
import { WorkspaceModule } from '../workspace/workspace.module';
import { AttachmentProcessor } from './processors/attachment.processor';
import { TokenModule } from '../auth/token.module';
@Module({
imports: [StorageModule, UserModule, WorkspaceModule, TokenModule],
controllers: [AttachmentController],
providers: [AttachmentService, AttachmentProcessor],
})
export class AttachmentModule {}

View File

@@ -0,0 +1,81 @@
import { MultipartFile } from '@fastify/multipart';
import * as path from 'path';
import { AttachmentType } from './attachment.constants';
import { sanitizeFileName } from '../../common/helpers';
import { getMimeType } from '../../common/helpers';
export interface PreparedFile {
buffer?: Buffer;
fileName: string;
fileSize: number;
fileExtension: string;
mimeType: string;
multiPartFile?: MultipartFile;
}
export async function prepareFile(
filePromise: Promise<MultipartFile>,
options: { skipBuffer?: boolean } = {},
): Promise<PreparedFile> {
const file = await filePromise;
if (!file) {
throw new Error('No file provided');
}
try {
let buffer: Buffer | undefined;
let fileSize = 0;
if (!options.skipBuffer) {
buffer = await file.toBuffer();
fileSize = buffer.length;
}
const sanitizedFilename = sanitizeFileName(file.filename);
const fileName = sanitizedFilename.slice(0, 255);
const fileExtension = path.extname(file.filename).toLowerCase();
return {
buffer,
fileName,
fileSize,
fileExtension,
mimeType: getMimeType(file.filename),
multiPartFile: file,
};
} catch (error) {
throw error;
}
}
export function validateFileType(
fileExtension: string,
allowedTypes: string[],
) {
if (!allowedTypes.includes(fileExtension)) {
throw new Error('Invalid file type');
}
}
export function getAttachmentFolderPath(
type: AttachmentType,
workspaceId: string,
): string {
switch (type) {
case AttachmentType.Avatar:
return `${workspaceId}/avatars`;
case AttachmentType.WorkspaceIcon:
return `${workspaceId}/workspace-logos`;
case AttachmentType.SpaceIcon:
return `${workspaceId}/space-logos`;
case AttachmentType.File:
return `${workspaceId}/files`;
case AttachmentType.Chat:
return `${workspaceId}/chat-files`;
default:
return `${workspaceId}/files`;
}
}
export const validAttachmentTypes = Object.values(AttachmentType);

View File

@@ -0,0 +1,36 @@
import {
IsEnum,
IsIn,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import { AttachmentType } from '../attachment.constants';
export class AttachmentInfoDto {
@IsNotEmpty()
@IsUUID()
attachmentId: string;
}
export class PageIdDto {
@IsString()
@IsNotEmpty()
pageId: string;
}
export class RemoveIconDto {
@IsEnum(AttachmentType)
@IsIn([
AttachmentType.Avatar,
AttachmentType.SpaceIcon,
AttachmentType.WorkspaceIcon,
])
@IsNotEmpty()
type: AttachmentType;
@IsOptional()
@IsUUID()
spaceId: string;
}

View File

@@ -0,0 +1,96 @@
import { Logger, OnModuleDestroy } from '@nestjs/common';
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 { 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,
private moduleRef: ModuleRef,
) {
super();
}
async process(job: Job<any, void>): Promise<void> {
try {
if (job.name === QueueJob.DELETE_SPACE_ATTACHMENTS) {
await this.attachmentService.handleDeleteSpaceAttachments(job.data.id);
}
if (job.name === QueueJob.DELETE_USER_AVATARS) {
await this.attachmentService.handleDeleteUserAvatars(job.data.id);
}
if (job.name === QueueJob.DELETE_PAGE_ATTACHMENTS) {
await this.attachmentService.handleDeletePageAttachments(
job.data.pageId,
);
}
if (job.name === QueueJob.DELETE_AI_CHAT_ATTACHMENTS) {
await this.attachmentService.handleDeleteAiChatAttachments(
job.data.aiChatId,
);
}
if (
job.name === QueueJob.ATTACHMENT_INDEX_CONTENT ||
job.name === QueueJob.ATTACHMENT_INDEXING
) {
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;
}
}
@OnWorkerEvent('active')
onActive(job: Job) {
this.logger.debug(`Processing ${job.name} job`);
}
@OnWorkerEvent('failed')
onError(job: Job) {
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')
onCompleted(job: Job) {
this.logger.debug(`Completed ${job.name} job`);
}
async onModuleDestroy(): Promise<void> {
if (this.worker) {
await this.worker.close();
}
}
}

View File

@@ -0,0 +1,464 @@
import {
BadRequestException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { Readable } from 'stream';
import { StorageService } from '../../../integrations/storage/storage.service';
import { MultipartFile } from '@fastify/multipart';
import {
getAttachmentFolderPath,
PreparedFile,
prepareFile,
validateFileType,
} from '../attachment.utils';
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, 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';
import { createByteCountingStream } from '../../../common/helpers/utils';
@Injectable()
export class AttachmentService {
private readonly logger = new Logger(AttachmentService.name);
constructor(
private readonly storageService: StorageService,
private readonly attachmentRepo: AttachmentRepo,
private readonly userRepo: UserRepo,
private readonly workspaceRepo: WorkspaceRepo,
private readonly spaceRepo: SpaceRepo,
@InjectKysely() private readonly db: KyselyDB,
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
) {}
async uploadFile(opts: {
filePromise: Promise<MultipartFile>;
pageId?: string;
userId: string;
spaceId: string;
workspaceId: string;
attachmentId?: string;
}) {
const { filePromise, pageId, spaceId, userId, workspaceId } = opts;
const preparedFile: PreparedFile = await prepareFile(filePromise, {
skipBuffer: true,
});
let isUpdate = false;
let attachmentId = null;
// passing attachmentId to allow for updating diagrams
// instead of creating new files for each save
if (opts?.attachmentId) {
const existingAttachment = await this.attachmentRepo.findById(
opts.attachmentId,
);
if (!existingAttachment) {
throw new NotFoundException(
'Existing attachment to overwrite not found',
);
}
if (
existingAttachment.pageId !== pageId ||
existingAttachment.fileExt !== preparedFile.fileExtension ||
existingAttachment.workspaceId !== workspaceId
) {
throw new BadRequestException('File attachment does not match');
}
attachmentId = opts.attachmentId;
isUpdate = true;
} else {
attachmentId = uuid7();
}
const filePath = `${getAttachmentFolderPath(AttachmentType.File, workspaceId)}/${attachmentId}/${preparedFile.fileName}`;
const { stream, getBytesRead } = createByteCountingStream(
preparedFile.multiPartFile.file,
);
await this.uploadToDrive(filePath, stream);
// Update fileSize from the consumed stream
preparedFile.fileSize = getBytesRead();
let attachment: Attachment = null;
try {
if (isUpdate) {
attachment = await this.attachmentRepo.updateAttachment(
{
fileSize: preparedFile.fileSize,
updatedAt: new Date(),
},
attachmentId,
);
} else {
attachment = await this.saveAttachment({
attachmentId,
preparedFile,
filePath,
type: AttachmentType.File,
userId,
spaceId,
workspaceId,
pageId,
});
}
// Only index PDF, DOCX and TXT files
if (['.pdf', '.docx', '.txt'].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);
}
return attachment;
}
async uploadImage(
filePromise: Promise<MultipartFile>,
type:
| AttachmentType.Avatar
| AttachmentType.WorkspaceIcon
| AttachmentType.SpaceIcon,
userId: string,
workspaceId: string,
spaceId?: string,
) {
const preparedFile: PreparedFile = await prepareFile(filePromise);
validateFileType(preparedFile.fileExtension, validImageExtensions);
preparedFile.fileName = uuid4() + preparedFile.fileExtension;
const filePath = `${getAttachmentFolderPath(type, workspaceId)}/${preparedFile.fileName}`;
await this.uploadToDrive(filePath, preparedFile.buffer);
let attachment: Attachment = null;
let oldFileName: string = null;
try {
await executeTx(this.db, async (trx) => {
attachment = await this.saveAttachment({
preparedFile,
filePath,
type,
userId,
workspaceId,
trx,
});
if (type === AttachmentType.Avatar) {
const user = await this.userRepo.findById(userId, workspaceId, {
trx,
});
oldFileName = user.avatarUrl;
await this.userRepo.updateUser(
{ avatarUrl: preparedFile.fileName },
userId,
workspaceId,
trx,
);
} else if (type === AttachmentType.WorkspaceIcon) {
const workspace = await this.workspaceRepo.findById(workspaceId, {
trx,
});
oldFileName = workspace.logo;
await this.workspaceRepo.updateWorkspace(
{ logo: preparedFile.fileName },
workspaceId,
trx,
);
} else if (type === AttachmentType.SpaceIcon && spaceId) {
const space = await this.spaceRepo.findById(spaceId, workspaceId, {
trx,
});
oldFileName = space.logo;
await this.spaceRepo.updateSpace(
{ logo: preparedFile.fileName },
spaceId,
workspaceId,
trx,
);
} else {
throw new BadRequestException(`Image upload aborted.`);
}
});
} catch (err) {
// delete uploaded file on db update failure
await this.deleteRedundantFile(filePath);
throw new BadRequestException('Failed to upload image');
}
if (oldFileName && !oldFileName.toLowerCase().startsWith('http')) {
// delete old avatar or logo
const oldFilePath =
getAttachmentFolderPath(type, workspaceId) + '/' + oldFileName;
await this.deleteRedundantFile(oldFilePath);
}
return attachment;
}
async deleteRedundantFile(filePath: string) {
try {
await this.storageService.delete(filePath);
await this.attachmentRepo.deleteAttachmentByFilePath(filePath);
} catch (error) {
this.logger.error('deleteRedundantFile', error);
}
}
async uploadToDrive(filePath: string, fileContent: Buffer | Readable) {
try {
await this.storageService.upload(filePath, fileContent);
} catch (err) {
this.logger.error('Error uploading file to drive:', err);
throw new BadRequestException('Error uploading file to drive');
}
}
async saveAttachment(opts: {
attachmentId?: string;
preparedFile: PreparedFile;
filePath: string;
type: AttachmentType;
userId: string;
workspaceId: string;
pageId?: string;
spaceId?: string;
trx?: KyselyTransaction;
}): Promise<Attachment> {
const {
attachmentId,
preparedFile,
filePath,
type,
userId,
workspaceId,
pageId,
spaceId,
trx,
} = opts;
return this.attachmentRepo.insertAttachment(
{
id: attachmentId,
type: type,
filePath: filePath,
fileName: preparedFile.fileName,
fileSize: preparedFile.fileSize,
mimeType: preparedFile.mimeType,
fileExt: preparedFile.fileExtension,
creatorId: userId,
workspaceId: workspaceId,
pageId: pageId,
spaceId: spaceId,
},
trx,
);
}
async handleDeleteAiChatAttachments(aiChatId: string) {
try {
const attachments = await this.attachmentRepo.findByAiChatId(aiChatId);
if (!attachments || attachments.length === 0) {
return;
}
await Promise.all(
attachments.map(async (attachment) => {
try {
await this.storageService.delete(attachment.filePath);
await this.attachmentRepo.deleteAttachmentById(attachment.id);
} catch (err) {
this.logger.log(
`DeleteAiChatAttachments: failed to delete attachment ${attachment.id}:`,
err,
);
}
}),
);
} catch (err) {
throw err;
}
}
async handleDeleteSpaceAttachments(spaceId: string) {
try {
const attachments = await this.attachmentRepo.findBySpaceId(spaceId);
if (!attachments || attachments.length === 0) {
return;
}
const failedDeletions = [];
await Promise.all(
attachments.map(async (attachment) => {
try {
await this.storageService.delete(attachment.filePath);
await this.attachmentRepo.deleteAttachmentById(attachment.id);
} catch (err) {
failedDeletions.push(attachment.id);
this.logger.log(
`DeleteSpaceAttachments: failed to delete attachment ${attachment.id}:`,
err,
);
}
}),
);
if (failedDeletions.length === attachments.length) {
throw new Error(
`Failed to delete any attachments for spaceId: ${spaceId}`,
);
}
} catch (err) {
throw err;
}
}
async handleDeleteUserAvatars(userId: string) {
try {
const userAvatars = await this.db
.selectFrom('attachments')
.select(['id', 'filePath'])
.where('creatorId', '=', userId)
.where('type', '=', AttachmentType.Avatar)
.execute();
if (!userAvatars || userAvatars.length === 0) {
return;
}
await Promise.all(
userAvatars.map(async (attachment) => {
try {
await this.storageService.delete(attachment.filePath);
await this.attachmentRepo.deleteAttachmentById(attachment.id);
} catch (err) {
this.logger.log(
`DeleteUserAvatar: failed to delete user avatar ${attachment.id}:`,
err,
);
}
}),
);
} catch (err) {
throw err;
}
}
async handleDeletePageAttachments(pageId: string) {
try {
// Fetch attachments for this page from database
const attachments = await this.db
.selectFrom('attachments')
.select(['id', 'filePath'])
.where('pageId', '=', pageId)
.execute();
if (!attachments || attachments.length === 0) {
return;
}
const failedDeletions = [];
await Promise.all(
attachments.map(async (attachment) => {
try {
// Delete from storage
await this.storageService.delete(attachment.filePath);
// Delete from database
await this.attachmentRepo.deleteAttachmentById(attachment.id);
} catch (err) {
failedDeletions.push(attachment.id);
this.logger.error(
`Failed to delete attachment ${attachment.id} for page ${pageId}:`,
err,
);
}
}),
);
if (failedDeletions.length > 0) {
this.logger.warn(
`Failed to delete ${failedDeletions.length} attachments for page ${pageId}`,
);
}
} catch (err) {
this.logger.error(
`Error in handleDeletePageAttachments for page ${pageId}:`,
err,
);
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);
}
}