Compare commits

...

4 Commits

Author SHA1 Message Date
Philipinho 6f92ecedf6 feat: add HTTP range request support for file serving 2026-02-04 10:29:38 -08:00
Philipinho 7e16c39f9a fix safari upload bug 2026-02-04 09:40:40 -08:00
Philipinho 03714a8bec fix hasFocus bug 2026-02-04 09:20:48 -08:00
Philipinho 3003befe07 use widely available arrayBuffer
* stream fails in safari
2026-02-04 09:08:23 -08:00
8 changed files with 140 additions and 49 deletions
@@ -170,6 +170,8 @@ const CommandGroups: SlashMenuGroupedItemsType = {
input.type = "file"; input.type = "file";
input.accept = "image/*"; input.accept = "image/*";
input.multiple = true; input.multiple = true;
input.style.display = "none";
document.body.appendChild(input);
input.onchange = async () => { input.onchange = async () => {
if (input.files?.length) { if (input.files?.length) {
for (const file of input.files) { 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.remove();
input.value = "";
}; };
input.click(); input.click();
}, },
@@ -202,6 +203,8 @@ const CommandGroups: SlashMenuGroupedItemsType = {
input.type = "file"; input.type = "file";
input.accept = "video/*"; input.accept = "video/*";
input.multiple = true; input.multiple = true;
input.style.display = "none";
document.body.appendChild(input);
input.onchange = async () => { input.onchange = async () => {
if (input.files?.length) { if (input.files?.length) {
for (const file of input.files) { 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.remove();
input.value = "";
}; };
input.click(); input.click();
}, },
@@ -234,6 +236,8 @@ const CommandGroups: SlashMenuGroupedItemsType = {
input.type = "file"; input.type = "file";
input.accept = ""; input.accept = "";
input.multiple = true; input.multiple = true;
input.style.display = "none";
document.body.appendChild(input);
input.onchange = async () => { input.onchange = async () => {
if (input.files?.length) { if (input.files?.length) {
for (const file of input.files) { 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.remove();
input.value = "";
}; };
input.click(); input.click();
}, },
@@ -157,7 +157,9 @@ export function TitleEditor({
useEffect(() => { useEffect(() => {
setTimeout(() => { setTimeout(() => {
titleEditor?.commands.focus("end"); // guard against Cannot access view['hasFocus'] error
if (!titleEditor?.isInitialized) return;
titleEditor?.commands?.focus("end");
}, 500); }, 500);
}, [titleEditor]); }, [titleEditor]);
@@ -17,13 +17,13 @@ import {
UseInterceptors, UseInterceptors,
} from '@nestjs/common'; } from '@nestjs/common';
import { AttachmentService } from './services/attachment.service'; import { AttachmentService } from './services/attachment.service';
import { FastifyReply } from 'fastify'; import { FastifyReply, FastifyRequest } from 'fastify';
import { FileInterceptor } from '../../common/interceptors/file.interceptor'; import { FileInterceptor } from '../../common/interceptors/file.interceptor';
import * as bytes from 'bytes'; import * as bytes from 'bytes';
import { AuthUser } from '../../common/decorators/auth-user.decorator'; import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator'; import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; 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 { StorageService } from '../../integrations/storage/storage.service';
import { import {
getAttachmentFolderPath, getAttachmentFolderPath,
@@ -151,6 +151,7 @@ export class AttachmentController {
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Get('/files/:fileId/:fileName') @Get('/files/:fileId/:fileName')
async getFile( async getFile(
@Req() req: FastifyRequest,
@Res() res: FastifyReply, @Res() res: FastifyReply,
@AuthUser() user: User, @AuthUser() user: User,
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
@@ -181,22 +182,7 @@ export class AttachmentController {
} }
try { try {
const fileStream = await this.storageService.readStream( return await this.sendFileResponse(req, res, attachment, 'private');
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);
} catch (err) { } catch (err) {
this.logger.error(err); this.logger.error(err);
throw new NotFoundException('File not found'); throw new NotFoundException('File not found');
@@ -205,6 +191,7 @@ export class AttachmentController {
@Get('/files/public/:fileId/:fileName') @Get('/files/public/:fileId/:fileName')
async getPublicFile( async getPublicFile(
@Req() req: FastifyRequest,
@Res() res: FastifyReply, @Res() res: FastifyReply,
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
@Param('fileId') fileId: string, @Param('fileId') fileId: string,
@@ -243,22 +230,7 @@ export class AttachmentController {
} }
try { try {
const fileStream = await this.storageService.readStream( return await this.sendFileResponse(req, res, attachment, 'public');
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);
} catch (err) { } catch (err) {
this.logger.error(err); this.logger.error(err);
throw new NotFoundException('File not found'); throw new NotFoundException('File not found');
@@ -433,4 +405,69 @@ export class AttachmentController {
return; 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);
}
} }
@@ -73,6 +73,20 @@ export class LocalDriver implements StorageDriver {
} }
} }
async readRangeStream(
filePath: string,
range: { start: number; end: number },
): Promise<Readable> {
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<boolean> { async exists(filePath: string): Promise<boolean> {
try { try {
return await fs.pathExists(this._fullPath(filePath)); return await fs.pathExists(this._fullPath(filePath));
@@ -130,6 +130,25 @@ export class S3Driver implements StorageDriver {
} }
} }
async readRangeStream(
filePath: string,
range: { start: number; end: number },
): Promise<Readable> {
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<boolean> { async exists(filePath: string): Promise<boolean> {
try { try {
const command = new HeadObjectCommand({ const command = new HeadObjectCommand({
@@ -11,6 +11,11 @@ export interface StorageDriver {
readStream(filePath: string): Promise<Readable>; readStream(filePath: string): Promise<Readable>;
readRangeStream(
filePath: string,
range: { start: number; end: number },
): Promise<Readable>;
exists(filePath: string): Promise<boolean>; exists(filePath: string): Promise<boolean>;
getUrl(filePath: string): string; getUrl(filePath: string): string;
@@ -33,6 +33,13 @@ export class StorageService {
return this.storageDriver.readStream(filePath); return this.storageDriver.readStream(filePath);
} }
async readRangeStream(
filePath: string,
range: { start: number; end: number },
): Promise<Readable> {
return this.storageDriver.readRangeStream(filePath, range);
}
async exists(filePath: string): Promise<boolean> { async exists(filePath: string): Promise<boolean> {
return this.storageDriver.exists(filePath); return this.storageDriver.exists(filePath);
} }
@@ -1,9 +1,9 @@
import { imageDimensionsFromStream } from "image-dimensions"; import { imageDimensionsFromData } from 'image-dimensions';
import { MediaUploadOptions, UploadFn } from "../media-utils"; import { MediaUploadOptions, UploadFn } from '../media-utils';
import { IAttachment } from "../types"; import { IAttachment } from '../types';
import { generateNodeId } from "../utils"; import { generateNodeId } from '../utils';
import { Node } from "@tiptap/pm/model"; import { Node } from '@tiptap/pm/model';
import { Command } from "@tiptap/core"; import { Command } from '@tiptap/core';
const findImageNodeByPlaceholderId = ( const findImageNodeByPlaceholderId = (
doc: Node, doc: Node,
@@ -14,7 +14,7 @@ const findImageNodeByPlaceholderId = (
doc.descendants((node, pos) => { doc.descendants((node, pos) => {
if (result) return false; if (result) return false;
if ( if (
node.type.name === "image" && node.type.name === 'image' &&
node.attrs.placeholder?.id === placeholderId node.attrs.placeholder?.id === placeholderId
) { ) {
result = { node, pos }; result = { node, pos };
@@ -34,7 +34,11 @@ const handleImageUpload =
if (!validated) return; if (!validated) return;
const objectUrl = URL.createObjectURL(file); const objectUrl = URL.createObjectURL(file);
const imageDimensions = await imageDimensionsFromStream(file.stream());
const imageDimensions = imageDimensionsFromData(
new Uint8Array(await file.arrayBuffer()),
);
const placeholderId = generateNodeId(); const placeholderId = generateNodeId();
const aspectRatio = imageDimensions const aspectRatio = imageDimensions
? imageDimensions.width / imageDimensions.height ? imageDimensions.width / imageDimensions.height