mirror of
https://github.com/Shadowfita/docmost.git
synced 2025-11-12 15:52:32 +10:00
more work on attachments
* fix frontend env usage
This commit is contained in:
@ -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)}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 && (
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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",
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
36
apps/client/src/lib/config.ts
Normal file
36
apps/client/src/lib/config.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|||||||
12
apps/server/src/core/attachment/attachment.constants.ts
Normal file
12
apps/server/src/core/attachment/attachment.constants.ts
Normal 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';
|
||||||
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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);
|
||||||
|
|||||||
7
apps/server/src/core/attachment/dto/get-file.dto.ts
Normal file
7
apps/server/src/core/attachment/dto/get-file.dto.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class GetFileDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
attachmentId: string;
|
||||||
|
}
|
||||||
20
apps/server/src/core/attachment/dto/upload-file.dto.ts
Normal file
20
apps/server/src/core/attachment/dto/upload-file.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
213
apps/server/src/core/attachment/services/attachment.service.ts
Normal file
213
apps/server/src/core/attachment/services/attachment.service.ts
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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(),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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()`),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
4
apps/server/src/database/types/db.d.ts
vendored
4
apps/server/src/database/types/db.d.ts
vendored
@ -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>;
|
||||||
|
|||||||
7
apps/server/src/helpers/file.helper.ts
Normal file
7
apps/server/src/helpers/file.helper.ts
Normal 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';
|
||||||
|
}
|
||||||
@ -1 +1,3 @@
|
|||||||
export * from './utils';
|
export * from './utils';
|
||||||
|
export * from './nanoid.utils';
|
||||||
|
export * from './file.helper';
|
||||||
|
|||||||
@ -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>`;
|
||||||
|
|||||||
@ -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> {
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user