diff --git a/apps/client/src/components/ui/user-avatar.tsx b/apps/client/src/components/ui/user-avatar.tsx index 54a18bb..17b5513 100644 --- a/apps/client/src/components/ui/user-avatar.tsx +++ b/apps/client/src/components/ui/user-avatar.tsx @@ -1,5 +1,6 @@ -import React from "react"; +import React, { useRef } from "react"; import { Avatar } from "@mantine/core"; +import { getAvatarUrl } from "@/lib/config.ts"; interface UserAvatarProps { avatarUrl: string; @@ -13,6 +14,8 @@ interface UserAvatarProps { export const UserAvatar = React.forwardRef( ({ avatarUrl, name, ...props }: UserAvatarProps, ref) => { + const avatar = getAvatarUrl(avatarUrl); + const getInitials = (name: string) => { const names = name?.split(" "); return names @@ -21,8 +24,8 @@ export const UserAvatar = React.forwardRef( .join(""); }; - return avatarUrl ? ( - + return avatar ? ( + ) : ( {getInitials(name)} diff --git a/apps/client/src/features/editor/hooks/use-collaboration-url.ts b/apps/client/src/features/editor/hooks/use-collaboration-url.ts index c58af27..37b7872 100644 --- a/apps/client/src/features/editor/hooks/use-collaboration-url.ts +++ b/apps/client/src/features/editor/hooks/use-collaboration-url.ts @@ -1,24 +1,7 @@ +import { getCollaborationUrl } from "@/lib/config.ts"; + const useCollaborationURL = (): string => { - const PATH = "/collab"; - - // TODO: revisit - /* - if (import.meta.env.VITE_COLLABORATION_URL) { - return import.meta.env.VITE_COLLABORATION_URL + PATH; - } - - const API_URL = import.meta.env.VITE_BACKEND_API_URL; - if (!API_URL) { - throw new Error("Backend API URL is not defined"); - } - */ - - const API_URL = import.meta.env.DEV - ? "http://localhost:3000" - : window.location.protocol + "//" + window.location.host; - - const wsProtocol = API_URL.startsWith("https") ? "wss" : "ws"; - return `${wsProtocol}://${API_URL.split("://")[1]}${PATH}`; + return getCollaborationUrl(); }; export default useCollaborationURL; diff --git a/apps/client/src/features/page/tree/components/space-tree.tsx b/apps/client/src/features/page/tree/components/space-tree.tsx index f1eb3c3..05a4398 100644 --- a/apps/client/src/features/page/tree/components/space-tree.tsx +++ b/apps/client/src/features/page/tree/components/space-tree.tsx @@ -165,10 +165,10 @@ export default function SpaceTree({ spaceId }: SpaceTreeProps) { }, [isDataLoaded.current, currentPage?.id]); useEffect(() => { - if (currentPage) { - setTimeout(() => { - treeApiRef.current?.select(currentPage.id, { align: "auto" }); - }, 100); + if (currentPage?.id) { + treeApiRef.current?.select(currentPage.id, { align: "auto" }); + } else { + treeApiRef.current?.deselectAll(); } }, [currentPage?.id]); @@ -179,12 +179,6 @@ export default function SpaceTree({ spaceId }: SpaceTreeProps) { } }, [treeApiRef.current]); - useEffect(() => { - if (location.pathname === APP_ROUTE.HOME && treeApiRef.current) { - treeApiRef.current.deselectAll(); - } - }, [location.pathname]); - return (
{rootElement.current && ( diff --git a/apps/client/src/features/user/components/account-avatar.tsx b/apps/client/src/features/user/components/account-avatar.tsx index 5ced4b4..61f4b7d 100644 --- a/apps/client/src/features/user/components/account-avatar.tsx +++ b/apps/client/src/features/user/components/account-avatar.tsx @@ -3,7 +3,7 @@ import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts"; import { useState } from "react"; import { useAtom } from "jotai"; import { UserAvatar } from "@/components/ui/user-avatar.tsx"; -import { FileButton, Button, Text, Popover, Tooltip } from "@mantine/core"; +import { FileButton, Tooltip } from "@mantine/core"; import { uploadAvatar } from "@/features/user/services/user-service.ts"; const userAtom = focusAtom(currentUserAtom, (optic) => optic.prop("user")); @@ -29,8 +29,7 @@ export default function AccountAvatar() { try { setIsLoading(true); - const upload = await uploadAvatar(selectedFile); - console.log(upload); + await uploadAvatar(selectedFile); } catch (err) { console.log(err); } finally { diff --git a/apps/client/src/features/user/services/user-service.ts b/apps/client/src/features/user/services/user-service.ts index 733abf3..3685fe3 100644 --- a/apps/client/src/features/user/services/user-service.ts +++ b/apps/client/src/features/user/services/user-service.ts @@ -13,8 +13,10 @@ export async function updateUser(data: Partial): Promise { export async function uploadAvatar(file: File) { const formData = new FormData(); - formData.append("avatar", file); - const req = await api.post("/attachments/upload/avatar", formData, { + formData.append("type", "avatar"); + formData.append("image", file); + + const req = await api.post("/attachments/upload-image", formData, { headers: { "Content-Type": "multipart/form-data", }, diff --git a/apps/client/src/features/workspace/services/workspace-service.ts b/apps/client/src/features/workspace/services/workspace-service.ts index 7fcb1d2..4b96209 100644 --- a/apps/client/src/features/workspace/services/workspace-service.ts +++ b/apps/client/src/features/workspace/services/workspace-service.ts @@ -77,3 +77,16 @@ export async function getInvitationById(data: { const req = await api.post("/workspace/invites/info", data); return req.data; } + +export async function uploadLogo(file: File) { + const formData = new FormData(); + formData.append("type", "workspace-logo"); + formData.append("image", file); + + const req = await api.post("/attachments/upload-image", formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + }); + return req.data; +} diff --git a/apps/client/src/lib/config.ts b/apps/client/src/lib/config.ts new file mode 100644 index 0000000..9f3a5d9 --- /dev/null +++ b/apps/client/src/lib/config.ts @@ -0,0 +1,36 @@ +declare global { + interface Window { + CONFIG?: Record; + } +} + +export function getAppUrl(): string { + let appUrl = window.CONFIG?.APP_URL || process.env.APP_URL; + + if (!appUrl) { + appUrl = import.meta.env.DEV + ? "http://localhost:3000" + : window.location.protocol + "//" + window.location.host; + } + + return appUrl; +} + +export function getBackendUrl(): string { + return getAppUrl() + "/api"; +} + +export function getCollaborationUrl(): string { + const COLLAB_PATH = "/collab"; + + const wsProtocol = getAppUrl().startsWith("https") ? "wss" : "ws"; + return `${wsProtocol}://${getAppUrl().split("://")[1]}${COLLAB_PATH}`; +} + +export function getAvatarUrl(avatarUrl: string) { + if (avatarUrl.startsWith("http")) { + return avatarUrl; + } + + return getBackendUrl() + "/attachments/img/avatar/" + avatarUrl; +} diff --git a/apps/client/vite.config.ts b/apps/client/vite.config.ts index 4c71434..c05fdcd 100644 --- a/apps/client/vite.config.ts +++ b/apps/client/vite.config.ts @@ -1,12 +1,23 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' +import { defineConfig, loadEnv } from "vite"; +import react from "@vitejs/plugin-react"; +import * as path from "path"; -// https://vitejs.dev/config/ -export default defineConfig({ - plugins: [react()], - resolve: { - alias: { - '@': '/src' - } - } -}) +export const envPath = path.resolve(process.cwd(), "..", ".."); + +export default defineConfig(({ mode }) => { + const { APP_URL } = loadEnv(mode, envPath, ""); + + return { + define: { + "process.env": { + APP_URL, + }, + }, + plugins: [react()], + resolve: { + alias: { + "@": "/src", + }, + }, + }; +}); diff --git a/apps/server/src/core/attachment/attachment.constants.ts b/apps/server/src/core/attachment/attachment.constants.ts new file mode 100644 index 0000000..c2cf0d9 --- /dev/null +++ b/apps/server/src/core/attachment/attachment.constants.ts @@ -0,0 +1,12 @@ +export enum AttachmentType { + Avatar = 'avatar', + WorkspaceLogo = 'workspace-logo', + SpaceLogo = 'space-logo', + File = 'file', +} + +export const validImageExtensions = ['.jpg', '.png', '.jpeg']; +export const MAX_AVATAR_SIZE = '5MB'; + +export const validFileExtensions = ['.jpg', '.png', '.jpeg', '.pdf']; +export const MAX_FILE_SIZE = '20MB'; diff --git a/apps/server/src/core/attachment/attachment.controller.ts b/apps/server/src/core/attachment/attachment.controller.ts index bb0f64f..cf12dce 100644 --- a/apps/server/src/core/attachment/attachment.controller.ts +++ b/apps/server/src/core/attachment/attachment.controller.ts @@ -1,111 +1,226 @@ import { BadRequestException, Controller, + ForbiddenException, + Get, HttpCode, HttpStatus, + Logger, + NotFoundException, + Param, Post, Req, Res, UseGuards, UseInterceptors, } from '@nestjs/common'; -import { AttachmentService } from './attachment.service'; -import { FastifyReply, FastifyRequest } from 'fastify'; -import { AttachmentInterceptor } from './attachment.interceptor'; +import { AttachmentService } from './services/attachment.service'; +import { FastifyReply } from 'fastify'; +import { AttachmentInterceptor } from './interceptors/attachment.interceptor'; import * as bytes from 'bytes'; import { AuthUser } from '../../decorators/auth-user.decorator'; import { AuthWorkspace } from '../../decorators/auth-workspace.decorator'; import { JwtAuthGuard } from '../../guards/jwt-auth.guard'; import { User, Workspace } from '@docmost/db/types/entity.types'; +import { StorageService } from '../../integrations/storage/storage.service'; +import { + getAttachmentFolderPath, + validAttachmentTypes, +} from './attachment.utils'; +import { getMimeType } from '../../helpers'; +import { + AttachmentType, + MAX_AVATAR_SIZE, + MAX_FILE_SIZE, +} from './attachment.constants'; +import CaslAbilityFactory from '../casl/abilities/casl-ability.factory'; +import { + SpaceCaslAction, + SpaceCaslSubject, +} from '../casl/interfaces/space-ability.type'; +import { Action } from '../casl/ability.action'; +import SpaceAbilityFactory from '../casl/abilities/space-ability.factory'; @Controller('attachments') export class AttachmentController { - constructor(private readonly attachmentService: AttachmentService) {} + private readonly logger = new Logger(AttachmentController.name); + + constructor( + private readonly attachmentService: AttachmentService, + private readonly storageService: StorageService, + private readonly caslAbility: CaslAbilityFactory, + private readonly spaceAbility: SpaceAbilityFactory, + ) {} @UseGuards(JwtAuthGuard) - @HttpCode(HttpStatus.CREATED) - @Post('upload/avatar') - @UseInterceptors(AttachmentInterceptor) - async uploadAvatar( - @Req() req: FastifyRequest, - @Res() res: FastifyReply, - @AuthUser() user: User, - @AuthWorkspace() workspace: Workspace, - ) { - const maxFileSize = bytes('5MB'); - - try { - const file = req.file({ - limits: { fileSize: maxFileSize, fields: 1, files: 1 }, - }); - - const fileResponse = await this.attachmentService.uploadAvatar( - file, - user.id, - workspace.id, - ); - - return res.send(fileResponse); - } catch (err) { - throw new BadRequestException('Error processing file upload.'); - } - } - - @UseGuards(JwtAuthGuard) - @HttpCode(HttpStatus.CREATED) - @Post('upload/workspace-logo') - @UseInterceptors(AttachmentInterceptor) - async uploadWorkspaceLogo( - @Req() req: FastifyRequest, - @Res() res: FastifyReply, - @AuthUser() user: User, - @AuthWorkspace() workspace: Workspace, - ) { - const maxFileSize = bytes('5MB'); - - try { - const file = req.file({ - limits: { fileSize: maxFileSize, fields: 1, files: 1 }, - }); - - const fileResponse = await this.attachmentService.uploadWorkspaceLogo( - file, - workspace.id, - user.id, - ); - - return res.send(fileResponse); - } catch (err) { - throw new BadRequestException('Error processing file upload.'); - } - } - - @UseGuards(JwtAuthGuard) - @HttpCode(HttpStatus.CREATED) - @Post('upload/file') + @HttpCode(HttpStatus.OK) + @Post('upload-file') @UseInterceptors(AttachmentInterceptor) async uploadFile( - @Req() req: FastifyRequest, + @Req() req: any, @Res() res: FastifyReply, @AuthUser() user: User, @AuthWorkspace() workspace: Workspace, ) { - const maxFileSize = bytes('20MB'); + const maxFileSize = bytes(MAX_FILE_SIZE); + let file = null; try { - const file = req.file({ + file = await req.file({ limits: { fileSize: maxFileSize, fields: 1, files: 1 }, }); + } catch (err: any) { + if (err?.statusCode === 413) { + throw new BadRequestException( + `File too large. Exceeds the ${MAX_FILE_SIZE} limit`, + ); + } + } - const fileResponse = await this.attachmentService.uploadWorkspaceLogo( + if (!file) { + throw new BadRequestException('Invalid file upload'); + } + + const pageId = file.fields?.pageId.value; + + if (!pageId) { + throw new BadRequestException('PageId is required'); + } + + try { + const fileResponse = await this.attachmentService.uploadFile( file, - workspace.id, + pageId, user.id, + workspace.id, ); return res.send(fileResponse); - } catch (err) { + } catch (err: any) { throw new BadRequestException('Error processing file upload.'); } } + + @Get('/:fileId/:fileName') + async getFile( + @Req() req: any, + @Res() res: FastifyReply, + @Param('fileId') fileId: string, + @Param('fileName') fileName?: string, + ) { + // TODO + } + + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @Post('upload-image') + @UseInterceptors(AttachmentInterceptor) + 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.WorkspaceLogo) { + const ability = this.caslAbility.createForUser(user, workspace); + if (ability.cannot(Action.Manage, 'Workspace')) { + throw new ForbiddenException(); + } + } + + if (attachmentType === AttachmentType.SpaceLogo) { + 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('/img/:attachmentType/:fileName') + async getLogoOrAvatar( + @Req() req: any, + @Res() res: FastifyReply, + @Param('attachmentType') attachmentType: AttachmentType, + @Param('fileName') fileName?: string, + ) { + const workspaceId = req.raw?.workspaceId; + + if (!workspaceId) { + throw new BadRequestException('Invalid workspace'); + } + + if ( + !validAttachmentTypes.includes(attachmentType) || + attachmentType === AttachmentType.File + ) { + throw new BadRequestException('Invalid image attachment type'); + } + + const buildFilePath = `${getAttachmentFolderPath(attachmentType, workspaceId)}/${fileName}`; + + try { + const fileStream = await this.storageService.read(buildFilePath); + res.headers({ + 'Content-Type': getMimeType(buildFilePath), + }); + return res.send(fileStream); + } catch (err) { + this.logger.error(err); + throw new NotFoundException('File not found'); + } + } } diff --git a/apps/server/src/core/attachment/attachment.module.ts b/apps/server/src/core/attachment/attachment.module.ts index 9cb1006..b328d2f 100644 --- a/apps/server/src/core/attachment/attachment.module.ts +++ b/apps/server/src/core/attachment/attachment.module.ts @@ -1,5 +1,5 @@ import { Module } from '@nestjs/common'; -import { AttachmentService } from './attachment.service'; +import { AttachmentService } from './services/attachment.service'; import { AttachmentController } from './attachment.controller'; import { StorageModule } from '../../integrations/storage/storage.module'; import { UserModule } from '../user/user.module'; diff --git a/apps/server/src/core/attachment/attachment.service.spec.ts b/apps/server/src/core/attachment/attachment.service.spec.ts deleted file mode 100644 index 5b4a892..0000000 --- a/apps/server/src/core/attachment/attachment.service.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { AttachmentService } from './attachment.service'; - -describe('AttachmentService', () => { - let service: AttachmentService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [AttachmentService], - }).compile(); - - service = module.get(AttachmentService); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); -}); diff --git a/apps/server/src/core/attachment/attachment.service.ts b/apps/server/src/core/attachment/attachment.service.ts deleted file mode 100644 index 14ee645..0000000 --- a/apps/server/src/core/attachment/attachment.service.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; -import { StorageService } from '../../integrations/storage/storage.service'; -import { MultipartFile } from '@fastify/multipart'; -import { UserService } from '../user/user.service'; -import { UpdateUserDto } from '../user/dto/update-user.dto'; -import { - AttachmentType, - getAttachmentPath, - PreparedFile, - prepareFile, - validateFileType, -} from './attachment.utils'; -import { v4 as uuid4 } from 'uuid'; -import { WorkspaceService } from '../workspace/services/workspace.service'; -import { UpdateWorkspaceDto } from '../workspace/dto/update-workspace.dto'; -import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo'; - -// TODO: make code better -@Injectable() -export class AttachmentService { - constructor( - private readonly storageService: StorageService, - private readonly workspaceService: WorkspaceService, - private readonly userService: UserService, - private readonly attachmentRepo: AttachmentRepo, - ) {} - - async uploadToDrive(preparedFile: PreparedFile, filePath: string) { - try { - await this.storageService.upload(filePath, preparedFile.buffer); - } catch (err) { - console.error('Error uploading file to drive:', err); - throw new BadRequestException('Error uploading file to drive'); - } - } - - async updateUserAvatar(avatarUrl: string, userId: string, workspaceId) { - const updateUserDto = new UpdateUserDto(); - updateUserDto.avatarUrl = avatarUrl; - await this.userService.update(updateUserDto, userId, workspaceId); - } - - async updateWorkspaceLogo(workspaceId: string, logoUrl: string) { - const updateWorkspaceDto = new UpdateWorkspaceDto(); - updateWorkspaceDto.logo = logoUrl; - await this.workspaceService.update(workspaceId, updateWorkspaceDto); - } - - async uploadAvatar( - filePromise: Promise, - userId: string, - workspaceId: string, - ) { - try { - const preparedFile: PreparedFile = await prepareFile(filePromise); - const allowedImageTypes = ['.jpg', '.jpeg', '.png']; - - validateFileType(preparedFile.fileExtension, allowedImageTypes); - - preparedFile.fileName = uuid4() + preparedFile.fileExtension; - - const attachmentPath = getAttachmentPath(AttachmentType.Avatar); - const filePath = `${attachmentPath}/${preparedFile.fileName}`; - - await this.uploadToDrive(preparedFile, filePath); - - // todo: in transaction - const attachment = await this.attachmentRepo.insertAttachment({ - creatorId: userId, - type: AttachmentType.Avatar, - filePath: filePath, - fileName: preparedFile.fileName, - fileSize: preparedFile.fileSize, - mimeType: preparedFile.mimeType, - fileExt: preparedFile.fileExtension, - workspaceId: workspaceId, - }); - - await this.updateUserAvatar(filePath, userId, workspaceId); - - return attachment; - } catch (err) { - console.log(err); - throw new BadRequestException('Failed to upload file'); - } - } - - async uploadWorkspaceLogo( - filePromise: Promise, - workspaceId: string, - userId: string, - ) { - try { - const preparedFile: PreparedFile = await prepareFile(filePromise); - const allowedImageTypes = ['.jpg', '.jpeg', '.png']; - - validateFileType(preparedFile.fileExtension, allowedImageTypes); - - preparedFile.fileName = uuid4() + preparedFile.fileExtension; - - const attachmentPath = getAttachmentPath( - AttachmentType.WorkspaceLogo, - workspaceId, - ); - const filePath = `${attachmentPath}/${preparedFile.fileName}`; - - await this.uploadToDrive(preparedFile, filePath); - - // todo: in trx - const attachment = await this.attachmentRepo.insertAttachment({ - creatorId: userId, - type: AttachmentType.WorkspaceLogo, - filePath: filePath, - fileName: preparedFile.fileName, - fileSize: preparedFile.fileSize, - mimeType: preparedFile.mimeType, - fileExt: preparedFile.fileExtension, - workspaceId: workspaceId, - }); - - await this.updateWorkspaceLogo(workspaceId, filePath); - - return attachment; - } catch (err) { - console.log(err); - throw new BadRequestException('Failed to upload file'); - } - } - - async uploadFile( - filePromise: Promise, - pageId: string, - workspaceId: string, - userId: string, - ) { - try { - const preparedFile: PreparedFile = await prepareFile(filePromise); - const allowedImageTypes = ['.jpg', '.jpeg', '.png', '.pdf']; - - validateFileType(preparedFile.fileExtension, allowedImageTypes); - - const attachmentPath = getAttachmentPath( - AttachmentType.WorkspaceLogo, - workspaceId, - ); - const filePath = `${attachmentPath}/${preparedFile.fileName}`; - - await this.uploadToDrive(preparedFile, filePath); - - const attachment = await this.attachmentRepo.insertAttachment({ - creatorId: userId, - pageId: pageId, - type: AttachmentType.File, - filePath: filePath, - fileName: preparedFile.fileName, - fileSize: preparedFile.fileSize, - mimeType: preparedFile.mimeType, - fileExt: preparedFile.fileExtension, - workspaceId: workspaceId, - }); - - return attachment; - } catch (err) { - console.log(err); - throw new BadRequestException('Failed to upload file'); - } - } -} diff --git a/apps/server/src/core/attachment/attachment.utils.ts b/apps/server/src/core/attachment/attachment.utils.ts index a768aaa..71123db 100644 --- a/apps/server/src/core/attachment/attachment.utils.ts +++ b/apps/server/src/core/attachment/attachment.utils.ts @@ -2,6 +2,7 @@ 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'; export interface PreparedFile { buffer: Buffer; @@ -14,13 +15,14 @@ export interface PreparedFile { export async function prepareFile( filePromise: Promise, ): Promise { - try { - const rand = randomBytes(4).toString('hex'); - const file = await filePromise; + const file = await filePromise; - if (!file) { - throw new Error('No file provided'); - } + if (!file) { + throw new Error('No file provided'); + } + + try { + const rand = randomBytes(8).toString('hex'); const buffer = await file.toBuffer(); const sanitizedFilename = sanitize(file.filename).replace(/ /g, '_'); @@ -50,26 +52,22 @@ export function validateFileType( } } -export enum AttachmentType { - Avatar = 'Avatar', - WorkspaceLogo = 'WorkspaceLogo', - File = 'file', -} - -export function getAttachmentPath( +export function getAttachmentFolderPath( type: AttachmentType, - workspaceId?: string, + workspaceId: string, ): string { - if (!workspaceId && type != AttachmentType.Avatar) { - throw new Error('Workspace ID is required for this attachment type'); - } - switch (type) { case AttachmentType.Avatar: - return 'avatars'; + return `${workspaceId}/avatars`; case AttachmentType.WorkspaceLogo: - return `${workspaceId}/logo`; + return `${workspaceId}/workspace-logo`; + case AttachmentType.SpaceLogo: + return `${workspaceId}/space-logos`; + case AttachmentType.File: + return `${workspaceId}/files`; default: return `${workspaceId}/files`; } } + +export const validAttachmentTypes = Object.values(AttachmentType); diff --git a/apps/server/src/core/attachment/dto/get-file.dto.ts b/apps/server/src/core/attachment/dto/get-file.dto.ts new file mode 100644 index 0000000..27091bc --- /dev/null +++ b/apps/server/src/core/attachment/dto/get-file.dto.ts @@ -0,0 +1,7 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class GetFileDto { + @IsString() + @IsNotEmpty() + attachmentId: string; +} diff --git a/apps/server/src/core/attachment/dto/upload-file.dto.ts b/apps/server/src/core/attachment/dto/upload-file.dto.ts new file mode 100644 index 0000000..6c5490a --- /dev/null +++ b/apps/server/src/core/attachment/dto/upload-file.dto.ts @@ -0,0 +1,20 @@ +import { + IsDefined, + IsNotEmpty, + IsOptional, + IsString, + IsUUID, +} from 'class-validator'; + +export class UploadFileDto { + @IsString() + @IsNotEmpty() + attachmentType: string; + + @IsOptional() + @IsUUID() + pageId: string; + + @IsDefined() + file: any; +} diff --git a/apps/server/src/core/attachment/attachment.interceptor.ts b/apps/server/src/core/attachment/interceptors/attachment.interceptor.ts similarity index 100% rename from apps/server/src/core/attachment/attachment.interceptor.ts rename to apps/server/src/core/attachment/interceptors/attachment.interceptor.ts diff --git a/apps/server/src/core/attachment/services/attachment.service.ts b/apps/server/src/core/attachment/services/attachment.service.ts new file mode 100644 index 0000000..19824ee --- /dev/null +++ b/apps/server/src/core/attachment/services/attachment.service.ts @@ -0,0 +1,213 @@ +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import { StorageService } from '../../../integrations/storage/storage.service'; +import { MultipartFile } from '@fastify/multipart'; +import { + getAttachmentFolderPath, + PreparedFile, + prepareFile, + validateFileType, +} from '../attachment.utils'; +import { v4 as uuid4 } from 'uuid'; +import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo'; +import { + AttachmentType, + validFileExtensions, + validImageExtensions, +} from '../attachment.constants'; +import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; +import { Attachment } 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'; + +@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, + ) {} + + async uploadFile( + filePromise: Promise, + pageId: string, + userId: string, + workspaceId: string, + ) { + const preparedFile: PreparedFile = await prepareFile(filePromise); + validateFileType(preparedFile.fileExtension, validFileExtensions); + + const filePath = `${getAttachmentFolderPath(AttachmentType.File, workspaceId)}/${preparedFile.fileName}`; + + await this.uploadToDrive(filePath, preparedFile.buffer); + + let attachment: Attachment = null; + try { + attachment = await this.saveAttachment({ + preparedFile, + filePath, + type: AttachmentType.File, + userId, + workspaceId, + pageId, + }); + } catch (err) { + // delete uploaded file on error + } + + return attachment; + } + + async uploadImage( + filePromise: Promise, + type: + | AttachmentType.Avatar + | AttachmentType.WorkspaceLogo + | AttachmentType.SpaceLogo, + 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.WorkspaceLogo) { + const workspace = await this.workspaceRepo.findById(workspaceId, { + trx, + }); + + oldFileName = workspace.logo; + + await this.workspaceRepo.updateWorkspace( + { logo: preparedFile.fileName }, + workspaceId, + trx, + ); + } else if (type === AttachmentType.SpaceLogo && 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 + this.logger.error('Image upload error:', err); + 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, fileBuffer: any) { + try { + await this.storageService.upload(filePath, fileBuffer); + } catch (err) { + this.logger.error('Error uploading file to drive:', err); + throw new BadRequestException('Error uploading file to drive'); + } + } + + async saveAttachment(opts: { + preparedFile: PreparedFile; + filePath: string; + type: AttachmentType; + userId: string; + workspaceId: string; + pageId?: string; + spaceId?: string; + trx?: KyselyTransaction; + }): Promise { + const { + preparedFile, + filePath, + type, + userId, + workspaceId, + pageId, + spaceId, + trx, + } = opts; + return this.attachmentRepo.insertAttachment( + { + 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, + ); + } +} diff --git a/apps/server/src/database/migrations/20240324T085900-spaces.ts b/apps/server/src/database/migrations/20240324T085900-spaces.ts index 1f7d2a7..af9bcb6 100644 --- a/apps/server/src/database/migrations/20240324T085900-spaces.ts +++ b/apps/server/src/database/migrations/20240324T085900-spaces.ts @@ -10,7 +10,7 @@ export async function up(db: Kysely): Promise { .addColumn('name', 'varchar', (col) => col) .addColumn('description', 'text', (col) => col) .addColumn('slug', 'varchar', (col) => col) - .addColumn('icon', 'varchar', (col) => col) + .addColumn('logo', 'varchar', (col) => col) .addColumn('visibility', 'varchar', (col) => col.defaultTo(SpaceVisibility.OPEN).notNull(), ) diff --git a/apps/server/src/database/migrations/20240324T086700-attachments.ts b/apps/server/src/database/migrations/20240324T086700-attachments.ts index 92febe3..a813f23 100644 --- a/apps/server/src/database/migrations/20240324T086700-attachments.ts +++ b/apps/server/src/database/migrations/20240324T086700-attachments.ts @@ -15,9 +15,11 @@ export async function up(db: Kysely): Promise { .addColumn('creator_id', 'uuid', (col) => col.references('users.id').notNull(), ) - .addColumn('page_id', 'uuid', (col) => col.references('pages.id')) - .addColumn('space_id', 'uuid', (col) => col.references('spaces.id')) - .addColumn('workspace_id', 'uuid', (col) => col.references('workspaces.id')) + .addColumn('page_id', 'uuid', (col) => col) + .addColumn('space_id', 'uuid', (col) => col) + .addColumn('workspace_id', 'uuid', (col) => + col.references('workspaces.id').onDelete('cascade').notNull(), + ) .addColumn('created_at', 'timestamptz', (col) => col.notNull().defaultTo(sql`now()`), ) diff --git a/apps/server/src/database/repos/attachment/attachment.repo.ts b/apps/server/src/database/repos/attachment/attachment.repo.ts index f3bda7b..15a8fee 100644 --- a/apps/server/src/database/repos/attachment/attachment.repo.ts +++ b/apps/server/src/database/repos/attachment/attachment.repo.ts @@ -14,13 +14,16 @@ export class AttachmentRepo { async findById( attachmentId: string, - workspaceId: string, + opts?: { + trx?: KyselyTransaction; + }, ): Promise { - return this.db + const db = dbOrTx(this.db, opts?.trx); + + return db .selectFrom('attachments') .selectAll() .where('id', '=', attachmentId) - .where('workspaceId', '=', workspaceId) .executeTakeFirst(); } @@ -48,4 +51,18 @@ export class AttachmentRepo { .returningAll() .executeTakeFirst(); } + + async deleteAttachment(attachmentId: string): Promise { + await this.db + .deleteFrom('attachments') + .where('id', '=', attachmentId) + .executeTakeFirst(); + } + + async deleteAttachmentByFilePath(attachmentFilePath: string): Promise { + await this.db + .deleteFrom('attachments') + .where('filePath', '=', attachmentFilePath) + .executeTakeFirst(); + } } diff --git a/apps/server/src/database/repos/space/space.repo.ts b/apps/server/src/database/repos/space/space.repo.ts index 33857b1..d144a9c 100644 --- a/apps/server/src/database/repos/space/space.repo.ts +++ b/apps/server/src/database/repos/space/space.repo.ts @@ -19,9 +19,10 @@ export class SpaceRepo { async findById( spaceId: string, workspaceId: string, - opts?: { includeMemberCount: boolean }, + opts?: { includeMemberCount?: boolean; trx?: KyselyTransaction }, ): Promise { - return await this.db + const db = dbOrTx(this.db, opts?.trx); + return db .selectFrom('spaces') .selectAll('spaces') .$if(opts?.includeMemberCount, (qb) => qb.select(this.withMemberCount)) diff --git a/apps/server/src/database/repos/user/user.repo.ts b/apps/server/src/database/repos/user/user.repo.ts index 20ddc7c..4f3f385 100644 --- a/apps/server/src/database/repos/user/user.repo.ts +++ b/apps/server/src/database/repos/user/user.repo.ts @@ -70,8 +70,11 @@ export class UserRepo { updatableUser: UpdatableUser, userId: string, workspaceId: string, + trx?: KyselyTransaction, ) { - return await this.db + const db = dbOrTx(this.db, trx); + + return await db .updateTable('users') .set(updatableUser) .where('id', '=', userId) diff --git a/apps/server/src/database/repos/workspace/workspace.repo.ts b/apps/server/src/database/repos/workspace/workspace.repo.ts index 17a5830..5d1c8ab 100644 --- a/apps/server/src/database/repos/workspace/workspace.repo.ts +++ b/apps/server/src/database/repos/workspace/workspace.repo.ts @@ -13,8 +13,15 @@ import { sql } from 'kysely'; export class WorkspaceRepo { constructor(@InjectKysely() private readonly db: KyselyDB) {} - async findById(workspaceId: string): Promise { - return await this.db + async findById( + workspaceId: string, + opts?: { + trx?: KyselyTransaction; + }, + ): Promise { + const db = dbOrTx(this.db, opts?.trx); + + return db .selectFrom('workspaces') .selectAll() .where('id', '=', workspaceId) diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts index 5a15482..ba8303e 100644 --- a/apps/server/src/database/types/db.d.ts +++ b/apps/server/src/database/types/db.d.ts @@ -34,7 +34,7 @@ export interface Attachments { spaceId: string | null; type: string | null; updatedAt: Generated; - workspaceId: string | null; + workspaceId: string; } export interface Comments { @@ -127,8 +127,8 @@ export interface Spaces { defaultRole: Generated; deletedAt: Timestamp | null; description: string | null; - icon: string | null; id: Generated; + logo: string | null; name: string | null; slug: string | null; updatedAt: Generated; diff --git a/apps/server/src/helpers/file.helper.ts b/apps/server/src/helpers/file.helper.ts new file mode 100644 index 0000000..2e8dfb0 --- /dev/null +++ b/apps/server/src/helpers/file.helper.ts @@ -0,0 +1,7 @@ +import * as mime from 'mime-types'; +import * as path from 'node:path'; + +export function getMimeType(filePath: string): string { + const ext = path.extname(filePath); + return mime.contentType(ext) || 'application/octet-stream'; +} diff --git a/apps/server/src/helpers/index.ts b/apps/server/src/helpers/index.ts index 04bca77..dedfea3 100644 --- a/apps/server/src/helpers/index.ts +++ b/apps/server/src/helpers/index.ts @@ -1 +1,3 @@ export * from './utils'; +export * from './nanoid.utils'; +export * from './file.helper'; diff --git a/apps/server/src/integrations/static/static.module.ts b/apps/server/src/integrations/static/static.module.ts index 3ed38d0..583fbea 100644 --- a/apps/server/src/integrations/static/static.module.ts +++ b/apps/server/src/integrations/static/static.module.ts @@ -30,9 +30,9 @@ export class StaticModule implements OnModuleInit { const windowVar = ''; const configString = { - env: this.environmentService.getEnv(), - appUrl: this.environmentService.getAppUrl(), - isCloud: this.environmentService.isCloud(), + ENV: this.environmentService.getEnv(), + APP_URL: this.environmentService.getAppUrl(), + IS_CLOUD: this.environmentService.isCloud(), }; const windowScriptContent = ``; diff --git a/apps/server/src/integrations/storage/drivers/s3.driver.ts b/apps/server/src/integrations/storage/drivers/s3.driver.ts index a36d67b..bbefb39 100644 --- a/apps/server/src/integrations/storage/drivers/s3.driver.ts +++ b/apps/server/src/integrations/storage/drivers/s3.driver.ts @@ -9,8 +9,8 @@ import { } from '@aws-sdk/client-s3'; import { streamToBuffer } from '../storage.utils'; import { Readable } from 'stream'; -import * as mime from 'mime-types'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { getMimeType } from '../../../helpers'; export class S3Driver implements StorageDriver { private readonly s3Client: S3Client; @@ -23,8 +23,7 @@ export class S3Driver implements StorageDriver { async upload(filePath: string, file: Buffer): Promise { try { - const contentType = - mime.contentType(filePath) || 'application/octet-stream'; + const contentType = getMimeType(filePath); const command = new PutObjectCommand({ Bucket: this.config.bucket, @@ -75,7 +74,7 @@ export class S3Driver implements StorageDriver { } } getUrl(filePath: string): string { - return `${this.config.endpoint}/${this.config.bucket}/${filePath}`; + return `${this.config.baseUrl ?? this.config.endpoint}/${this.config.bucket}/${filePath}`; } async getSignedUrl(filePath: string, expiresIn: number): Promise { diff --git a/apps/server/src/integrations/storage/providers/storage.provider.ts b/apps/server/src/integrations/storage/providers/storage.provider.ts index 011d93a..0004078 100644 --- a/apps/server/src/integrations/storage/providers/storage.provider.ts +++ b/apps/server/src/integrations/storage/providers/storage.provider.ts @@ -45,6 +45,7 @@ export const storageDriverConfigProvider = { region: environmentService.getAwsS3Region(), endpoint: environmentService.getAwsS3Endpoint(), bucket: environmentService.getAwsS3Bucket(), + baseUrl: environmentService.getAwsS3Url(), credentials: { accessKeyId: environmentService.getAwsS3AccessKeyId(), secretAccessKey: environmentService.getAwsS3SecretAccessKey(), diff --git a/apps/server/src/integrations/storage/storage.service.ts b/apps/server/src/integrations/storage/storage.service.ts index a477d37..44c3d38 100644 --- a/apps/server/src/integrations/storage/storage.service.ts +++ b/apps/server/src/integrations/storage/storage.service.ts @@ -20,15 +20,19 @@ export class StorageService { return this.storageDriver.exists(filePath); } - async signedUrl(path: string, expireIn: number): Promise { + async getSignedUrl(path: string, expireIn: number): Promise { return this.storageDriver.getSignedUrl(path, expireIn); } - url(filePath: string): string { + getUrl(filePath: string): string { return this.storageDriver.getUrl(filePath); } async delete(filePath: string): Promise { await this.storageDriver.delete(filePath); } + + getDriverName(): string { + return this.storageDriver.getDriverName(); + } }