diff --git a/apps/client/src/features/editor/components/slash-menu/menu-items.ts b/apps/client/src/features/editor/components/slash-menu/menu-items.ts index bebefed4..27793f62 100644 --- a/apps/client/src/features/editor/components/slash-menu/menu-items.ts +++ b/apps/client/src/features/editor/components/slash-menu/menu-items.ts @@ -170,6 +170,8 @@ const CommandGroups: SlashMenuGroupedItemsType = { input.type = "file"; input.accept = "image/*"; input.multiple = true; + input.style.display = "none"; + document.body.appendChild(input); input.onchange = async () => { if (input.files?.length) { for (const file of input.files) { @@ -179,8 +181,7 @@ const CommandGroups: SlashMenuGroupedItemsType = { } } - // Reset the input value to allow uploading the same file again if needed - input.value = ""; + input.remove(); }; input.click(); }, @@ -202,6 +203,8 @@ const CommandGroups: SlashMenuGroupedItemsType = { input.type = "file"; input.accept = "video/*"; input.multiple = true; + input.style.display = "none"; + document.body.appendChild(input); input.onchange = async () => { if (input.files?.length) { for (const file of input.files) { @@ -211,8 +214,7 @@ const CommandGroups: SlashMenuGroupedItemsType = { } } - // Reset the input value to allow uploading the same file again if needed - input.value = ""; + input.remove(); }; input.click(); }, @@ -234,6 +236,8 @@ const CommandGroups: SlashMenuGroupedItemsType = { input.type = "file"; input.accept = ""; input.multiple = true; + input.style.display = "none"; + document.body.appendChild(input); input.onchange = async () => { if (input.files?.length) { for (const file of input.files) { @@ -243,8 +247,7 @@ const CommandGroups: SlashMenuGroupedItemsType = { } } - // Reset the input value to allow uploading the same file again if needed - input.value = ""; + input.remove(); }; input.click(); }, diff --git a/apps/client/src/features/editor/title-editor.tsx b/apps/client/src/features/editor/title-editor.tsx index c3610394..33d1984e 100644 --- a/apps/client/src/features/editor/title-editor.tsx +++ b/apps/client/src/features/editor/title-editor.tsx @@ -157,7 +157,9 @@ export function TitleEditor({ useEffect(() => { setTimeout(() => { - titleEditor?.commands.focus("end"); + // guard against Cannot access view['hasFocus'] error + if (!titleEditor?.isInitialized) return; + titleEditor?.commands?.focus("end"); }, 500); }, [titleEditor]); diff --git a/apps/server/src/core/attachment/attachment.controller.ts b/apps/server/src/core/attachment/attachment.controller.ts index cc058ac6..3215a74d 100644 --- a/apps/server/src/core/attachment/attachment.controller.ts +++ b/apps/server/src/core/attachment/attachment.controller.ts @@ -17,13 +17,13 @@ import { UseInterceptors, } from '@nestjs/common'; import { AttachmentService } from './services/attachment.service'; -import { FastifyReply } from 'fastify'; +import { FastifyReply, FastifyRequest } from 'fastify'; import { FileInterceptor } from '../../common/interceptors/file.interceptor'; import * as bytes from 'bytes'; import { AuthUser } from '../../common/decorators/auth-user.decorator'; import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; -import { User, Workspace } from '@docmost/db/types/entity.types'; +import { Attachment, User, Workspace } from '@docmost/db/types/entity.types'; import { StorageService } from '../../integrations/storage/storage.service'; import { getAttachmentFolderPath, @@ -151,6 +151,7 @@ export class AttachmentController { @UseGuards(JwtAuthGuard) @Get('/files/:fileId/:fileName') async getFile( + @Req() req: FastifyRequest, @Res() res: FastifyReply, @AuthUser() user: User, @AuthWorkspace() workspace: Workspace, @@ -181,22 +182,7 @@ export class AttachmentController { } try { - const fileStream = await this.storageService.readStream( - attachment.filePath, - ); - res.headers({ - 'Content-Type': attachment.mimeType, - 'Cache-Control': 'private, max-age=3600', - }); - - if (!inlineFileExtensions.includes(attachment.fileExt)) { - res.header( - 'Content-Disposition', - `attachment; filename="${encodeURIComponent(attachment.fileName)}"`, - ); - } - - return res.send(fileStream); + return await this.sendFileResponse(req, res, attachment, 'private'); } catch (err) { this.logger.error(err); throw new NotFoundException('File not found'); @@ -205,6 +191,7 @@ export class AttachmentController { @Get('/files/public/:fileId/:fileName') async getPublicFile( + @Req() req: FastifyRequest, @Res() res: FastifyReply, @AuthWorkspace() workspace: Workspace, @Param('fileId') fileId: string, @@ -243,22 +230,7 @@ export class AttachmentController { } try { - const fileStream = await this.storageService.readStream( - attachment.filePath, - ); - res.headers({ - 'Content-Type': attachment.mimeType, - 'Cache-Control': 'public, max-age=3600', - }); - - if (!inlineFileExtensions.includes(attachment.fileExt)) { - res.header( - 'Content-Disposition', - `attachment; filename="${encodeURIComponent(attachment.fileName)}"`, - ); - } - - return res.send(fileStream); + return await this.sendFileResponse(req, res, attachment, 'public'); } catch (err) { this.logger.error(err); throw new NotFoundException('File not found'); @@ -433,4 +405,69 @@ export class AttachmentController { return; } } + + private async sendFileResponse( + req: FastifyRequest, + res: FastifyReply, + attachment: Attachment, + cacheScope: 'private' | 'public', + ) { + const fileSize = Number(attachment.fileSize); + const rangeHeader = req.headers.range; + + res.header('Accept-Ranges', 'bytes'); + + if (!inlineFileExtensions.includes(attachment.fileExt)) { + res.header( + 'Content-Disposition', + `attachment; filename="${encodeURIComponent(attachment.fileName)}"`, + ); + } + + if (rangeHeader && fileSize) { + const match = rangeHeader.match(/bytes=(\d+)-(\d*)/); + if (match) { + const start = parseInt(match[1], 10); + const end = match[2] + ? Math.min(parseInt(match[2], 10), fileSize - 1) + : fileSize - 1; + + if (start >= fileSize || start > end) { + res.status(416); + res.header('Content-Range', `bytes */${fileSize}`); + return res.send(); + } + + const fileStream = await this.storageService.readRangeStream( + attachment.filePath, + { start, end }, + ); + + res.status(206); + res.headers({ + 'Content-Type': attachment.mimeType, + 'Content-Range': `bytes ${start}-${end}/${fileSize}`, + 'Content-Length': end - start + 1, + 'Cache-Control': `${cacheScope}, max-age=3600`, + }); + + return res.send(fileStream); + } + } + + const fileStream = await this.storageService.readStream( + attachment.filePath, + ); + + res.headers({ + 'Content-Type': attachment.mimeType, + 'Cache-Control': `${cacheScope}, max-age=3600`, + }); + + if (fileSize) { + res.header('Content-Length', fileSize); + } + + return res.send(fileStream); + } } diff --git a/apps/server/src/integrations/storage/drivers/local.driver.ts b/apps/server/src/integrations/storage/drivers/local.driver.ts index aada2c05..90f7b7dd 100644 --- a/apps/server/src/integrations/storage/drivers/local.driver.ts +++ b/apps/server/src/integrations/storage/drivers/local.driver.ts @@ -73,6 +73,20 @@ export class LocalDriver implements StorageDriver { } } + async readRangeStream( + filePath: string, + range: { start: number; end: number }, + ): Promise { + try { + return createReadStream(this._fullPath(filePath), { + start: range.start, + end: range.end, + }); + } catch (err) { + throw new Error(`Failed to read file: ${(err as Error).message}`); + } + } + async exists(filePath: string): Promise { try { return await fs.pathExists(this._fullPath(filePath)); diff --git a/apps/server/src/integrations/storage/drivers/s3.driver.ts b/apps/server/src/integrations/storage/drivers/s3.driver.ts index 8221e466..02087176 100644 --- a/apps/server/src/integrations/storage/drivers/s3.driver.ts +++ b/apps/server/src/integrations/storage/drivers/s3.driver.ts @@ -130,6 +130,25 @@ export class S3Driver implements StorageDriver { } } + async readRangeStream( + filePath: string, + range: { start: number; end: number }, + ): Promise { + try { + const command = new GetObjectCommand({ + Bucket: this.config.bucket, + Key: filePath, + Range: `bytes=${range.start}-${range.end}`, + }); + + const response = await this.s3Client.send(command); + + return response.Body as Readable; + } catch (err) { + throw new Error(`Failed to read file from S3: ${(err as Error).message}`); + } + } + async exists(filePath: string): Promise { try { const command = new HeadObjectCommand({ diff --git a/apps/server/src/integrations/storage/interfaces/storage-driver.interface.ts b/apps/server/src/integrations/storage/interfaces/storage-driver.interface.ts index f376c56f..c5764e0e 100644 --- a/apps/server/src/integrations/storage/interfaces/storage-driver.interface.ts +++ b/apps/server/src/integrations/storage/interfaces/storage-driver.interface.ts @@ -11,6 +11,11 @@ export interface StorageDriver { readStream(filePath: string): Promise; + readRangeStream( + filePath: string, + range: { start: number; end: number }, + ): Promise; + exists(filePath: string): Promise; getUrl(filePath: string): string; diff --git a/apps/server/src/integrations/storage/storage.service.ts b/apps/server/src/integrations/storage/storage.service.ts index 3ed887af..cb643cc8 100644 --- a/apps/server/src/integrations/storage/storage.service.ts +++ b/apps/server/src/integrations/storage/storage.service.ts @@ -33,6 +33,13 @@ export class StorageService { return this.storageDriver.readStream(filePath); } + async readRangeStream( + filePath: string, + range: { start: number; end: number }, + ): Promise { + return this.storageDriver.readRangeStream(filePath, range); + } + async exists(filePath: string): Promise { return this.storageDriver.exists(filePath); } diff --git a/packages/editor-ext/src/lib/image/image-upload.ts b/packages/editor-ext/src/lib/image/image-upload.ts index d5acdcff..ca521bc8 100644 --- a/packages/editor-ext/src/lib/image/image-upload.ts +++ b/packages/editor-ext/src/lib/image/image-upload.ts @@ -1,9 +1,9 @@ -import { imageDimensionsFromStream } from "image-dimensions"; -import { MediaUploadOptions, UploadFn } from "../media-utils"; -import { IAttachment } from "../types"; -import { generateNodeId } from "../utils"; -import { Node } from "@tiptap/pm/model"; -import { Command } from "@tiptap/core"; +import { imageDimensionsFromData } from 'image-dimensions'; +import { MediaUploadOptions, UploadFn } from '../media-utils'; +import { IAttachment } from '../types'; +import { generateNodeId } from '../utils'; +import { Node } from '@tiptap/pm/model'; +import { Command } from '@tiptap/core'; const findImageNodeByPlaceholderId = ( doc: Node, @@ -14,7 +14,7 @@ const findImageNodeByPlaceholderId = ( doc.descendants((node, pos) => { if (result) return false; if ( - node.type.name === "image" && + node.type.name === 'image' && node.attrs.placeholder?.id === placeholderId ) { result = { node, pos }; @@ -34,7 +34,11 @@ const handleImageUpload = if (!validated) return; const objectUrl = URL.createObjectURL(file); - const imageDimensions = await imageDimensionsFromStream(file.stream()); + + const imageDimensions = imageDimensionsFromData( + new Uint8Array(await file.arrayBuffer()), + ); + const placeholderId = generateNodeId(); const aspectRatio = imageDimensions ? imageDimensions.width / imageDimensions.height