more work on attachments

* fix frontend env usage
This commit is contained in:
Philipinho
2024-05-22 23:24:57 +01:00
parent b06a78b6ec
commit ccf9d5d99f
31 changed files with 612 additions and 349 deletions

View File

@ -1,5 +1,6 @@
import React from "react"; import React, { useRef } from "react";
import { Avatar } from "@mantine/core"; import { Avatar } from "@mantine/core";
import { getAvatarUrl } from "@/lib/config.ts";
interface UserAvatarProps { interface UserAvatarProps {
avatarUrl: string; avatarUrl: string;
@ -13,6 +14,8 @@ interface UserAvatarProps {
export const UserAvatar = React.forwardRef<HTMLInputElement, UserAvatarProps>( export const UserAvatar = React.forwardRef<HTMLInputElement, UserAvatarProps>(
({ avatarUrl, name, ...props }: UserAvatarProps, ref) => { ({ avatarUrl, name, ...props }: UserAvatarProps, ref) => {
const avatar = getAvatarUrl(avatarUrl);
const getInitials = (name: string) => { const getInitials = (name: string) => {
const names = name?.split(" "); const names = name?.split(" ");
return names return names
@ -21,8 +24,8 @@ export const UserAvatar = React.forwardRef<HTMLInputElement, UserAvatarProps>(
.join(""); .join("");
}; };
return avatarUrl ? ( return avatar ? (
<Avatar ref={ref} src={avatarUrl} alt={name} radius="xl" {...props} /> <Avatar ref={ref} src={avatar} alt={name} radius="xl" {...props} />
) : ( ) : (
<Avatar ref={ref} {...props}> <Avatar ref={ref} {...props}>
{getInitials(name)} {getInitials(name)}

View File

@ -1,24 +1,7 @@
import { getCollaborationUrl } from "@/lib/config.ts";
const useCollaborationURL = (): string => { const useCollaborationURL = (): string => {
const PATH = "/collab"; return getCollaborationUrl();
// 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}`;
}; };
export default useCollaborationURL; export default useCollaborationURL;

View File

@ -165,10 +165,10 @@ export default function SpaceTree({ spaceId }: SpaceTreeProps) {
}, [isDataLoaded.current, currentPage?.id]); }, [isDataLoaded.current, currentPage?.id]);
useEffect(() => { useEffect(() => {
if (currentPage) { if (currentPage?.id) {
setTimeout(() => { treeApiRef.current?.select(currentPage.id, { align: "auto" });
treeApiRef.current?.select(currentPage.id, { align: "auto" }); } else {
}, 100); treeApiRef.current?.deselectAll();
} }
}, [currentPage?.id]); }, [currentPage?.id]);
@ -179,12 +179,6 @@ export default function SpaceTree({ spaceId }: SpaceTreeProps) {
} }
}, [treeApiRef.current]); }, [treeApiRef.current]);
useEffect(() => {
if (location.pathname === APP_ROUTE.HOME && treeApiRef.current) {
treeApiRef.current.deselectAll();
}
}, [location.pathname]);
return ( return (
<div ref={mergedRef} className={classes.treeContainer}> <div ref={mergedRef} className={classes.treeContainer}>
{rootElement.current && ( {rootElement.current && (

View File

@ -3,7 +3,7 @@ import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useState } from "react"; import { useState } from "react";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { UserAvatar } from "@/components/ui/user-avatar.tsx"; 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"; import { uploadAvatar } from "@/features/user/services/user-service.ts";
const userAtom = focusAtom(currentUserAtom, (optic) => optic.prop("user")); const userAtom = focusAtom(currentUserAtom, (optic) => optic.prop("user"));
@ -29,8 +29,7 @@ export default function AccountAvatar() {
try { try {
setIsLoading(true); setIsLoading(true);
const upload = await uploadAvatar(selectedFile); await uploadAvatar(selectedFile);
console.log(upload);
} catch (err) { } catch (err) {
console.log(err); console.log(err);
} finally { } finally {

View File

@ -13,8 +13,10 @@ export async function updateUser(data: Partial<IUser>): Promise<IUser> {
export async function uploadAvatar(file: File) { export async function uploadAvatar(file: File) {
const formData = new FormData(); const formData = new FormData();
formData.append("avatar", file); formData.append("type", "avatar");
const req = await api.post("/attachments/upload/avatar", formData, { formData.append("image", file);
const req = await api.post("/attachments/upload-image", formData, {
headers: { headers: {
"Content-Type": "multipart/form-data", "Content-Type": "multipart/form-data",
}, },

View File

@ -77,3 +77,16 @@ export async function getInvitationById(data: {
const req = await api.post("/workspace/invites/info", data); const req = await api.post("/workspace/invites/info", data);
return req.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;
}

View File

@ -0,0 +1,36 @@
declare global {
interface Window {
CONFIG?: Record<string, string>;
}
}
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;
}

View File

@ -1,12 +1,23 @@
import { defineConfig } from 'vite' import { defineConfig, loadEnv } from "vite";
import react from '@vitejs/plugin-react' import react from "@vitejs/plugin-react";
import * as path from "path";
// https://vitejs.dev/config/ export const envPath = path.resolve(process.cwd(), "..", "..");
export default defineConfig({
plugins: [react()], export default defineConfig(({ mode }) => {
resolve: { const { APP_URL } = loadEnv(mode, envPath, "");
alias: {
'@': '/src' return {
} define: {
} "process.env": {
}) APP_URL,
},
},
plugins: [react()],
resolve: {
alias: {
"@": "/src",
},
},
};
});

View File

@ -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';

View File

@ -1,111 +1,226 @@
import { import {
BadRequestException, BadRequestException,
Controller, Controller,
ForbiddenException,
Get,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
Logger,
NotFoundException,
Param,
Post, Post,
Req, Req,
Res, Res,
UseGuards, UseGuards,
UseInterceptors, UseInterceptors,
} from '@nestjs/common'; } from '@nestjs/common';
import { AttachmentService } from './attachment.service'; import { AttachmentService } from './services/attachment.service';
import { FastifyReply, FastifyRequest } from 'fastify'; import { FastifyReply } from 'fastify';
import { AttachmentInterceptor } from './attachment.interceptor'; import { AttachmentInterceptor } from './interceptors/attachment.interceptor';
import * as bytes from 'bytes'; import * as bytes from 'bytes';
import { AuthUser } from '../../decorators/auth-user.decorator'; import { AuthUser } from '../../decorators/auth-user.decorator';
import { AuthWorkspace } from '../../decorators/auth-workspace.decorator'; import { AuthWorkspace } from '../../decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../guards/jwt-auth.guard';
import { User, Workspace } from '@docmost/db/types/entity.types'; 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') @Controller('attachments')
export class AttachmentController { 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) @UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.CREATED) @HttpCode(HttpStatus.OK)
@Post('upload/avatar') @Post('upload-file')
@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')
@UseInterceptors(AttachmentInterceptor) @UseInterceptors(AttachmentInterceptor)
async uploadFile( async uploadFile(
@Req() req: FastifyRequest, @Req() req: any,
@Res() res: FastifyReply, @Res() res: FastifyReply,
@AuthUser() user: User, @AuthUser() user: User,
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
) { ) {
const maxFileSize = bytes('20MB'); const maxFileSize = bytes(MAX_FILE_SIZE);
let file = null;
try { try {
const file = req.file({ file = await req.file({
limits: { fileSize: maxFileSize, fields: 1, files: 1 }, 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, file,
workspace.id, pageId,
user.id, user.id,
workspace.id,
); );
return res.send(fileResponse); return res.send(fileResponse);
} catch (err) { } catch (err: any) {
throw new BadRequestException('Error processing file upload.'); 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');
}
}
} }

View File

@ -1,5 +1,5 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AttachmentService } from './attachment.service'; import { AttachmentService } from './services/attachment.service';
import { AttachmentController } from './attachment.controller'; import { AttachmentController } from './attachment.controller';
import { StorageModule } from '../../integrations/storage/storage.module'; import { StorageModule } from '../../integrations/storage/storage.module';
import { UserModule } from '../user/user.module'; import { UserModule } from '../user/user.module';

View File

@ -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>(AttachmentService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@ -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<MultipartFile>,
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<MultipartFile>,
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<MultipartFile>,
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');
}
}
}

View File

@ -2,6 +2,7 @@ import { MultipartFile } from '@fastify/multipart';
import { randomBytes } from 'crypto'; import { randomBytes } from 'crypto';
import { sanitize } from 'sanitize-filename-ts'; import { sanitize } from 'sanitize-filename-ts';
import * as path from 'path'; import * as path from 'path';
import { AttachmentType } from './attachment.constants';
export interface PreparedFile { export interface PreparedFile {
buffer: Buffer; buffer: Buffer;
@ -14,13 +15,14 @@ export interface PreparedFile {
export async function prepareFile( export async function prepareFile(
filePromise: Promise<MultipartFile>, filePromise: Promise<MultipartFile>,
): Promise<PreparedFile> { ): Promise<PreparedFile> {
try { const file = await filePromise;
const rand = randomBytes(4).toString('hex');
const file = await filePromise;
if (!file) { if (!file) {
throw new Error('No file provided'); throw new Error('No file provided');
} }
try {
const rand = randomBytes(8).toString('hex');
const buffer = await file.toBuffer(); const buffer = await file.toBuffer();
const sanitizedFilename = sanitize(file.filename).replace(/ /g, '_'); const sanitizedFilename = sanitize(file.filename).replace(/ /g, '_');
@ -50,26 +52,22 @@ export function validateFileType(
} }
} }
export enum AttachmentType { export function getAttachmentFolderPath(
Avatar = 'Avatar',
WorkspaceLogo = 'WorkspaceLogo',
File = 'file',
}
export function getAttachmentPath(
type: AttachmentType, type: AttachmentType,
workspaceId?: string, workspaceId: string,
): string { ): string {
if (!workspaceId && type != AttachmentType.Avatar) {
throw new Error('Workspace ID is required for this attachment type');
}
switch (type) { switch (type) {
case AttachmentType.Avatar: case AttachmentType.Avatar:
return 'avatars'; return `${workspaceId}/avatars`;
case AttachmentType.WorkspaceLogo: case AttachmentType.WorkspaceLogo:
return `${workspaceId}/logo`; return `${workspaceId}/workspace-logo`;
case AttachmentType.SpaceLogo:
return `${workspaceId}/space-logos`;
case AttachmentType.File:
return `${workspaceId}/files`;
default: default:
return `${workspaceId}/files`; return `${workspaceId}/files`;
} }
} }
export const validAttachmentTypes = Object.values(AttachmentType);

View File

@ -0,0 +1,7 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class GetFileDto {
@IsString()
@IsNotEmpty()
attachmentId: string;
}

View File

@ -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;
}

View File

@ -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<MultipartFile>,
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<MultipartFile>,
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<Attachment> {
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,
);
}
}

View File

@ -10,7 +10,7 @@ export async function up(db: Kysely<any>): Promise<void> {
.addColumn('name', 'varchar', (col) => col) .addColumn('name', 'varchar', (col) => col)
.addColumn('description', 'text', (col) => col) .addColumn('description', 'text', (col) => col)
.addColumn('slug', 'varchar', (col) => col) .addColumn('slug', 'varchar', (col) => col)
.addColumn('icon', 'varchar', (col) => col) .addColumn('logo', 'varchar', (col) => col)
.addColumn('visibility', 'varchar', (col) => .addColumn('visibility', 'varchar', (col) =>
col.defaultTo(SpaceVisibility.OPEN).notNull(), col.defaultTo(SpaceVisibility.OPEN).notNull(),
) )

View File

@ -15,9 +15,11 @@ export async function up(db: Kysely<any>): Promise<void> {
.addColumn('creator_id', 'uuid', (col) => .addColumn('creator_id', 'uuid', (col) =>
col.references('users.id').notNull(), col.references('users.id').notNull(),
) )
.addColumn('page_id', 'uuid', (col) => col.references('pages.id')) .addColumn('page_id', 'uuid', (col) => col)
.addColumn('space_id', 'uuid', (col) => col.references('spaces.id')) .addColumn('space_id', 'uuid', (col) => col)
.addColumn('workspace_id', 'uuid', (col) => col.references('workspaces.id')) .addColumn('workspace_id', 'uuid', (col) =>
col.references('workspaces.id').onDelete('cascade').notNull(),
)
.addColumn('created_at', 'timestamptz', (col) => .addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`), col.notNull().defaultTo(sql`now()`),
) )

View File

@ -14,13 +14,16 @@ export class AttachmentRepo {
async findById( async findById(
attachmentId: string, attachmentId: string,
workspaceId: string, opts?: {
trx?: KyselyTransaction;
},
): Promise<Attachment> { ): Promise<Attachment> {
return this.db const db = dbOrTx(this.db, opts?.trx);
return db
.selectFrom('attachments') .selectFrom('attachments')
.selectAll() .selectAll()
.where('id', '=', attachmentId) .where('id', '=', attachmentId)
.where('workspaceId', '=', workspaceId)
.executeTakeFirst(); .executeTakeFirst();
} }
@ -48,4 +51,18 @@ export class AttachmentRepo {
.returningAll() .returningAll()
.executeTakeFirst(); .executeTakeFirst();
} }
async deleteAttachment(attachmentId: string): Promise<void> {
await this.db
.deleteFrom('attachments')
.where('id', '=', attachmentId)
.executeTakeFirst();
}
async deleteAttachmentByFilePath(attachmentFilePath: string): Promise<void> {
await this.db
.deleteFrom('attachments')
.where('filePath', '=', attachmentFilePath)
.executeTakeFirst();
}
} }

View File

@ -19,9 +19,10 @@ export class SpaceRepo {
async findById( async findById(
spaceId: string, spaceId: string,
workspaceId: string, workspaceId: string,
opts?: { includeMemberCount: boolean }, opts?: { includeMemberCount?: boolean; trx?: KyselyTransaction },
): Promise<Space> { ): Promise<Space> {
return await this.db const db = dbOrTx(this.db, opts?.trx);
return db
.selectFrom('spaces') .selectFrom('spaces')
.selectAll('spaces') .selectAll('spaces')
.$if(opts?.includeMemberCount, (qb) => qb.select(this.withMemberCount)) .$if(opts?.includeMemberCount, (qb) => qb.select(this.withMemberCount))

View File

@ -70,8 +70,11 @@ export class UserRepo {
updatableUser: UpdatableUser, updatableUser: UpdatableUser,
userId: string, userId: string,
workspaceId: string, workspaceId: string,
trx?: KyselyTransaction,
) { ) {
return await this.db const db = dbOrTx(this.db, trx);
return await db
.updateTable('users') .updateTable('users')
.set(updatableUser) .set(updatableUser)
.where('id', '=', userId) .where('id', '=', userId)

View File

@ -13,8 +13,15 @@ import { sql } from 'kysely';
export class WorkspaceRepo { export class WorkspaceRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {} constructor(@InjectKysely() private readonly db: KyselyDB) {}
async findById(workspaceId: string): Promise<Workspace> { async findById(
return await this.db workspaceId: string,
opts?: {
trx?: KyselyTransaction;
},
): Promise<Workspace> {
const db = dbOrTx(this.db, opts?.trx);
return db
.selectFrom('workspaces') .selectFrom('workspaces')
.selectAll() .selectAll()
.where('id', '=', workspaceId) .where('id', '=', workspaceId)

View File

@ -34,7 +34,7 @@ export interface Attachments {
spaceId: string | null; spaceId: string | null;
type: string | null; type: string | null;
updatedAt: Generated<Timestamp>; updatedAt: Generated<Timestamp>;
workspaceId: string | null; workspaceId: string;
} }
export interface Comments { export interface Comments {
@ -127,8 +127,8 @@ export interface Spaces {
defaultRole: Generated<string>; defaultRole: Generated<string>;
deletedAt: Timestamp | null; deletedAt: Timestamp | null;
description: string | null; description: string | null;
icon: string | null;
id: Generated<string>; id: Generated<string>;
logo: string | null;
name: string | null; name: string | null;
slug: string | null; slug: string | null;
updatedAt: Generated<Timestamp>; updatedAt: Generated<Timestamp>;

View File

@ -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';
}

View File

@ -1 +1,3 @@
export * from './utils'; export * from './utils';
export * from './nanoid.utils';
export * from './file.helper';

View File

@ -30,9 +30,9 @@ export class StaticModule implements OnModuleInit {
const windowVar = '<!--window-config-->'; const windowVar = '<!--window-config-->';
const configString = { const configString = {
env: this.environmentService.getEnv(), ENV: this.environmentService.getEnv(),
appUrl: this.environmentService.getAppUrl(), APP_URL: this.environmentService.getAppUrl(),
isCloud: this.environmentService.isCloud(), IS_CLOUD: this.environmentService.isCloud(),
}; };
const windowScriptContent = `<script>window.CONFIG=${JSON.stringify(configString)};</script>`; const windowScriptContent = `<script>window.CONFIG=${JSON.stringify(configString)};</script>`;

View File

@ -9,8 +9,8 @@ import {
} from '@aws-sdk/client-s3'; } from '@aws-sdk/client-s3';
import { streamToBuffer } from '../storage.utils'; import { streamToBuffer } from '../storage.utils';
import { Readable } from 'stream'; import { Readable } from 'stream';
import * as mime from 'mime-types';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { getMimeType } from '../../../helpers';
export class S3Driver implements StorageDriver { export class S3Driver implements StorageDriver {
private readonly s3Client: S3Client; private readonly s3Client: S3Client;
@ -23,8 +23,7 @@ export class S3Driver implements StorageDriver {
async upload(filePath: string, file: Buffer): Promise<void> { async upload(filePath: string, file: Buffer): Promise<void> {
try { try {
const contentType = const contentType = getMimeType(filePath);
mime.contentType(filePath) || 'application/octet-stream';
const command = new PutObjectCommand({ const command = new PutObjectCommand({
Bucket: this.config.bucket, Bucket: this.config.bucket,
@ -75,7 +74,7 @@ export class S3Driver implements StorageDriver {
} }
} }
getUrl(filePath: string): string { 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<string> { async getSignedUrl(filePath: string, expiresIn: number): Promise<string> {

View File

@ -45,6 +45,7 @@ export const storageDriverConfigProvider = {
region: environmentService.getAwsS3Region(), region: environmentService.getAwsS3Region(),
endpoint: environmentService.getAwsS3Endpoint(), endpoint: environmentService.getAwsS3Endpoint(),
bucket: environmentService.getAwsS3Bucket(), bucket: environmentService.getAwsS3Bucket(),
baseUrl: environmentService.getAwsS3Url(),
credentials: { credentials: {
accessKeyId: environmentService.getAwsS3AccessKeyId(), accessKeyId: environmentService.getAwsS3AccessKeyId(),
secretAccessKey: environmentService.getAwsS3SecretAccessKey(), secretAccessKey: environmentService.getAwsS3SecretAccessKey(),

View File

@ -20,15 +20,19 @@ export class StorageService {
return this.storageDriver.exists(filePath); return this.storageDriver.exists(filePath);
} }
async signedUrl(path: string, expireIn: number): Promise<string> { async getSignedUrl(path: string, expireIn: number): Promise<string> {
return this.storageDriver.getSignedUrl(path, expireIn); return this.storageDriver.getSignedUrl(path, expireIn);
} }
url(filePath: string): string { getUrl(filePath: string): string {
return this.storageDriver.getUrl(filePath); return this.storageDriver.getUrl(filePath);
} }
async delete(filePath: string): Promise<void> { async delete(filePath: string): Promise<void> {
await this.storageDriver.delete(filePath); await this.storageDriver.delete(filePath);
} }
getDriverName(): string {
return this.storageDriver.getDriverName();
}
} }