diff --git a/apps/client/package.json b/apps/client/package.json
index 6b8a8b60..404df47e 100644
--- a/apps/client/package.json
+++ b/apps/client/package.json
@@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
- "version": "0.80.0",
+ "version": "0.80.1",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
@@ -31,8 +31,8 @@
"emoji-mart": "^5.6.0",
"file-saver": "^2.0.5",
"highlightjs-sap-abap": "^0.3.0",
- "i18next": "^25.10.1",
- "i18next-http-backend": "^3.0.2",
+ "i18next": "25.10.1",
+ "i18next-http-backend": "3.0.6",
"jotai": "^2.18.1",
"jotai-optics": "^0.4.0",
"js-cookie": "^3.0.5",
@@ -42,7 +42,7 @@
"mantine-form-zod-resolver": "^1.3.0",
"mermaid": "^11.13.0",
"mitt": "^3.0.1",
- "posthog-js": "1.363.1",
+ "posthog-js": "1.372.2",
"react": "^18.3.1",
"react-arborist": "3.4.0",
"react-clear-modal": "^2.0.18",
@@ -50,7 +50,7 @@
"react-drawio": "^1.0.7",
"react-error-boundary": "^6.1.1",
"react-helmet-async": "^3.0.0",
- "react-i18next": "^16.5.8",
+ "react-i18next": "16.5.8",
"react-router-dom": "^7.13.1",
"semver": "^7.7.4",
"socket.io-client": "^4.8.3",
@@ -74,7 +74,7 @@
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^15.13.0",
"optics-ts": "^2.4.1",
- "postcss": "^8.5.8",
+ "postcss": "^8.5.12",
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"prettier": "^3.8.1",
diff --git a/apps/client/src/ee/page-permission/components/page-permission-list.tsx b/apps/client/src/ee/page-permission/components/page-permission-list.tsx
index e586b968..111bb83f 100644
--- a/apps/client/src/ee/page-permission/components/page-permission-list.tsx
+++ b/apps/client/src/ee/page-permission/components/page-permission-list.tsx
@@ -140,7 +140,7 @@ export function PagePermissionList({
)}
-
+
{sortedMembers.map((member) => (
)}
-
+
>
);
}
diff --git a/apps/client/src/features/editor/components/attachment/upload-attachment-action.tsx b/apps/client/src/features/editor/components/attachment/upload-attachment-action.tsx
index 9f668963..e9408891 100644
--- a/apps/client/src/features/editor/components/attachment/upload-attachment-action.tsx
+++ b/apps/client/src/features/editor/components/attachment/upload-attachment-action.tsx
@@ -19,7 +19,9 @@ export const uploadAttachmentAction = handleAttachmentUpload({
},
validateFn: (file, allowMedia: boolean) => {
if (
- (file.type.includes("image/") || file.type.includes("video/")) &&
+ (file.type.includes("image/") ||
+ file.type.includes("video/") ||
+ file.type === "application/pdf") &&
!allowMedia
) {
return false;
diff --git a/apps/server/package.json b/apps/server/package.json
index 4e075207..6010cfbf 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -1,6 +1,6 @@
{
"name": "server",
- "version": "0.80.0",
+ "version": "0.80.1",
"description": "",
"author": "",
"private": true,
@@ -33,13 +33,13 @@
"@ai-sdk/google": "^3.0.52",
"@ai-sdk/openai": "^3.0.47",
"@ai-sdk/openai-compatible": "^2.0.37",
- "@aws-sdk/client-s3": "3.1014.0",
- "@aws-sdk/lib-storage": "3.1014.0",
- "@aws-sdk/s3-request-presigner": "3.1014.0",
+ "@aws-sdk/client-s3": "3.1037.0",
+ "@aws-sdk/lib-storage": "3.1037.0",
+ "@aws-sdk/s3-request-presigner": "3.1037.0",
"@clickhouse/client": "^1.18.2",
"@fastify/cookie": "^11.0.2",
- "@fastify/multipart": "^9.4.0",
- "@fastify/static": "^9.0.0",
+ "@fastify/multipart": "^10.0.0",
+ "@fastify/static": "^9.1.3",
"@keyv/redis": "^5.1.6",
"@langchain/core": "1.1.39",
"@langchain/textsplitters": "1.0.1",
@@ -48,19 +48,19 @@
"@nestjs-labs/nestjs-ioredis": "^11.0.4",
"@nestjs/bullmq": "^11.0.4",
"@nestjs/cache-manager": "^3.1.0",
- "@nestjs/common": "^11.1.18",
- "@nestjs/config": "^4.0.3",
- "@nestjs/core": "^11.1.18",
+ "@nestjs/common": "^11.1.19",
+ "@nestjs/config": "^4.0.4",
+ "@nestjs/core": "^11.1.19",
"@nestjs/event-emitter": "^3.0.1",
"@nestjs/jwt": "11.0.2",
"@nestjs/mapped-types": "^2.1.1",
"@nestjs/passport": "^11.0.5",
- "@nestjs/platform-fastify": "^11.1.18",
- "@nestjs/platform-socket.io": "^11.1.18",
- "@nestjs/schedule": "^6.1.1",
+ "@nestjs/platform-fastify": "^11.1.19",
+ "@nestjs/platform-socket.io": "^11.1.19",
+ "@nestjs/schedule": "^6.1.3",
"@nestjs/terminus": "^11.1.1",
"@nestjs/throttler": "^6.5.0",
- "@nestjs/websockets": "^11.1.18",
+ "@nestjs/websockets": "^11.1.19",
"@node-saml/passport-saml": "^5.1.0",
"@react-email/components": "1.0.10",
"@react-email/render": "2.0.4",
@@ -69,7 +69,7 @@
"ai-sdk-ollama": "^3.8.1",
"bcrypt": "^6.0.0",
"bowser": "^2.14.1",
- "bullmq": "^5.71.0",
+ "bullmq": "^5.76.0",
"cache-manager": "^7.2.8",
"cheerio": "^1.2.0",
"class-transformer": "^0.5.1",
@@ -110,22 +110,23 @@
"react": "^18.3.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
- "sanitize-filename-ts": "1.0.2",
+ "sanitize-filename": "1.6.3",
"socket.io": "^4.8.3",
"stripe": "^17.7.0",
"tlds": "^1.261.0",
"tmp-promise": "^3.0.3",
"tseep": "^1.3.1",
"typesense": "^3.0.5",
+ "undici": "7.24.0",
"ws": "^8.20.0",
"yauzl": "^3.2.1",
"zod": "^4.3.6"
},
"devDependencies": {
"@eslint/js": "^9.28.0",
- "@nestjs/cli": "^11.0.18",
+ "@nestjs/cli": "^11.0.21",
"@nestjs/schematics": "^11.0.10",
- "@nestjs/testing": "^11.1.18",
+ "@nestjs/testing": "^11.1.19",
"@types/bcrypt": "^6.0.0",
"@types/debounce": "^1.2.4",
"@types/fs-extra": "^11.0.4",
@@ -165,6 +166,9 @@
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
+ "transformIgnorePatterns": [
+ "/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked)(@|/))"
+ ],
"collectCoverageFrom": [
"**/*.(t|j)s"
],
diff --git a/apps/server/src/common/helpers/utils.spec.ts b/apps/server/src/common/helpers/utils.spec.ts
new file mode 100644
index 00000000..f444e007
Binary files /dev/null and b/apps/server/src/common/helpers/utils.spec.ts differ
diff --git a/apps/server/src/common/helpers/utils.ts b/apps/server/src/common/helpers/utils.ts
index c37e9a47..f65067e1 100644
--- a/apps/server/src/common/helpers/utils.ts
+++ b/apps/server/src/common/helpers/utils.ts
@@ -1,6 +1,6 @@
import * as path from 'path';
import * as bcrypt from 'bcrypt';
-import { sanitize } from 'sanitize-filename-ts';
+import sanitize = require('sanitize-filename');
import { FastifyRequest } from 'fastify';
import { Readable, Transform } from 'stream';
@@ -72,11 +72,33 @@ export function extractDateFromUuid7(uuid7: string) {
return new Date(timestamp);
}
-export function sanitizeFileName(fileName: string): string {
- const sanitizedFilename = sanitize(fileName)
- .replace(/ /g, '_')
- .replace(/#/g, '_');
- return sanitizedFilename.slice(0, 255);
+export type SanitizeFileNameOptions = {
+ /** Keep spaces and `#` instead of replacing them with `_`. Useful for
+ * download filenames where readability matters. Defaults to false. */
+ preserveSpaces?: boolean;
+};
+
+export function sanitizeFileName(
+ fileName: string,
+ options: SanitizeFileNameOptions = {},
+): string {
+ // Decode percent-encoded sequences so that bypasses like "..%2F" reach
+ // sanitize() as literal "../" and get stripped. sanitize-filename only
+ // strips literal characters and won't catch encoded path separators
+ // on its own.
+ const decoded = fileName.replace(/%[0-9a-fA-F]{2}/g, (m) => {
+ try {
+ return decodeURIComponent(m);
+ } catch {
+ return m;
+ }
+ });
+
+ const sanitized = sanitize(decoded);
+ if (options.preserveSpaces) {
+ return sanitized;
+ }
+ return sanitized.replace(/ /g, '_').replace(/#/g, '_');
}
export function removeAccent(str: string): string {
diff --git a/apps/server/src/core/attachment/attachment.controller.ts b/apps/server/src/core/attachment/attachment.controller.ts
index 7b24cc35..73605819 100644
--- a/apps/server/src/core/attachment/attachment.controller.ts
+++ b/apps/server/src/core/attachment/attachment.controller.ts
@@ -356,9 +356,19 @@ export class AttachmentController {
throw new BadRequestException('Invalid image attachment type');
}
- const filenameWithoutExt = path.basename(fileName, path.extname(fileName));
- if (!isValidUUID(filenameWithoutExt)) {
- throw new BadRequestException('Invalid file id');
+ if (!fileName) {
+ throw new BadRequestException('Invalid file name');
+ }
+
+ const ext = path.extname(fileName);
+ const filenameWithoutExt = path.basename(fileName, ext);
+
+ if (
+ !ext ||
+ !isValidUUID(filenameWithoutExt) ||
+ `${filenameWithoutExt}${ext}` !== fileName
+ ) {
+ throw new BadRequestException('Invalid file name');
}
const filePath = `${getAttachmentFolderPath(attachmentType, workspace.id)}/${fileName}`;
diff --git a/apps/server/src/core/user/dto/update-user.dto.ts b/apps/server/src/core/user/dto/update-user.dto.ts
index f1c02c51..9d8ef8ef 100644
--- a/apps/server/src/core/user/dto/update-user.dto.ts
+++ b/apps/server/src/core/user/dto/update-user.dto.ts
@@ -13,10 +13,6 @@ import { CreateUserDto } from '../../auth/dto/create-user.dto';
export class UpdateUserDto extends PartialType(
OmitType(CreateUserDto, ['password'] as const),
) {
- @IsOptional()
- @IsString()
- avatarUrl: string;
-
@IsOptional()
@IsBoolean()
fullPageWidth: boolean;
diff --git a/apps/server/src/core/user/user.service.ts b/apps/server/src/core/user/user.service.ts
index fa229827..7a143976 100644
--- a/apps/server/src/core/user/user.service.ts
+++ b/apps/server/src/core/user/user.service.ts
@@ -110,10 +110,6 @@ export class UserService {
user.email = updateUserDto.email;
}
- if (updateUserDto.avatarUrl) {
- user.avatarUrl = updateUserDto.avatarUrl;
- }
-
if (updateUserDto.locale) {
user.locale = updateUserDto.locale;
}
diff --git a/apps/server/src/core/workspace/dto/update-workspace.dto.ts b/apps/server/src/core/workspace/dto/update-workspace.dto.ts
index 4b148208..a08b1e52 100644
--- a/apps/server/src/core/workspace/dto/update-workspace.dto.ts
+++ b/apps/server/src/core/workspace/dto/update-workspace.dto.ts
@@ -5,15 +5,10 @@ import {
IsBoolean,
IsInt,
IsOptional,
- IsString,
Min,
} from 'class-validator';
export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
- @IsOptional()
- @IsString()
- logo: string;
-
@IsOptional()
@IsArray()
emailDomains: string[];
diff --git a/apps/server/src/ee b/apps/server/src/ee
index 4bac9b0a..a22ec396 160000
--- a/apps/server/src/ee
+++ b/apps/server/src/ee
@@ -1 +1 @@
-Subproject commit 4bac9b0a3fe6c238351b0814d49cce7f5fa2e4af
+Subproject commit a22ec3969e0a5be1a7bc154415988b9b60bc6880
diff --git a/apps/server/src/integrations/export/export.controller.ts b/apps/server/src/integrations/export/export.controller.ts
index 1ce3f8c8..140622da 100644
--- a/apps/server/src/integrations/export/export.controller.ts
+++ b/apps/server/src/integrations/export/export.controller.ts
@@ -23,9 +23,12 @@ import {
SpaceCaslSubject,
} from '../../core/casl/interfaces/space-ability.type';
import { FastifyReply } from 'fastify';
-import { sanitize } from 'sanitize-filename-ts';
import { getExportExtension } from './utils';
-import { getMimeType, getPageTitle } from '../../common/helpers';
+import {
+ getMimeType,
+ getPageTitle,
+ sanitizeFileName,
+} from '../../common/helpers';
import * as path from 'path';
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
import {
@@ -85,7 +88,9 @@ export class ExportController {
if (result.type === 'file') {
const ext = getExportExtension(dto.format);
- const fileName = sanitize(page.title || 'untitled') + ext;
+ const fileName =
+ sanitizeFileName(page.title || 'untitled', { preserveSpaces: true }) +
+ ext;
const contentType = getMimeType(path.extname(fileName));
res.headers({
@@ -96,7 +101,9 @@ export class ExportController {
res.send(result.content);
} else {
- const fileName = sanitize(page.title || 'untitled') + '.zip';
+ const fileName =
+ sanitizeFileName(page.title || 'untitled', { preserveSpaces: true }) +
+ '.zip';
res.headers({
'Content-Type': 'application/zip',
@@ -144,7 +151,9 @@ export class ExportController {
'Content-Type': 'application/zip',
'Content-Disposition':
'attachment; filename="' +
- encodeURIComponent(sanitize(exportFile.fileName)) +
+ encodeURIComponent(
+ sanitizeFileName(exportFile.fileName, { preserveSpaces: true }),
+ ) +
'"',
});
diff --git a/apps/server/src/integrations/export/export.service.ts b/apps/server/src/integrations/export/export.service.ts
index 3f8da1bf..b4bc73fa 100644
--- a/apps/server/src/integrations/export/export.service.ts
+++ b/apps/server/src/integrations/export/export.service.ts
@@ -39,6 +39,8 @@ import {
} from '../../common/helpers/prosemirror/utils';
import { htmlToMarkdown } from '@docmost/editor-ext';
+type AllowedAttachment = { id: string; fileName: string; filePath: string };
+
@Injectable()
export class ExportService {
private readonly logger = new Logger(ExportService.name);
@@ -272,6 +274,12 @@ export class ExportService {
computeLocalPath(tree, format, null, '', slugIdToPath);
+ // Batch resolve attachments once for the whole export so we only run the
+ // owning-page view check a single time, regardless of page count.
+ const allowedAttachments = includeAttachments
+ ? await this.resolveAccessibleAttachments(tree, userId, ignorePermissions)
+ : new Map();
+
const stack: { folder: JSZip; parentPageId: string | null }[] = [
{ folder: zip, parentPageId: null },
];
@@ -301,7 +309,7 @@ export class ExportService {
);
if (includeAttachments) {
- await this.zipAttachments(updatedJsonContent, page.spaceId, folder);
+ await this.zipAttachments(updatedJsonContent, folder, allowedAttachments);
updatedJsonContent =
updateAttachmentUrlsToLocalPaths(updatedJsonContent);
}
@@ -347,31 +355,80 @@ export class ExportService {
zip.file('docmost-metadata.json', JSON.stringify(metadata, null, 2));
}
- async zipAttachments(prosemirrorJson: any, spaceId: string, zip: JSZip) {
+ async zipAttachments(
+ prosemirrorJson: any,
+ zip: JSZip,
+ allowed: Map,
+ ) {
const attachmentIds = getAttachmentIds(prosemirrorJson);
- if (attachmentIds.length > 0) {
- const attachments = await this.db
- .selectFrom('attachments')
- .select(['id', 'fileName', 'filePath'])
- .where('id', 'in', attachmentIds)
- .where('spaceId', '=', spaceId)
- .execute();
+ await Promise.all(
+ attachmentIds.map(async (id) => {
+ const attachment = allowed.get(id);
+ if (!attachment) return;
+ try {
+ const fileBuffer = await this.storageService.read(
+ attachment.filePath,
+ );
+ const filePath = `/files/${attachment.id}/${attachment.fileName}`;
+ zip.file(filePath, fileBuffer);
+ } catch (err) {
+ this.logger.debug(`Attachment export error ${attachment.id}`, err);
+ }
+ }),
+ );
+ }
- await Promise.all(
- attachments.map(async (attachment) => {
- try {
- const fileBuffer = await this.storageService.read(
- attachment.filePath,
- );
- const filePath = `/files/${attachment.id}/${attachment.fileName}`;
- zip.file(filePath, fileBuffer);
- } catch (err) {
- this.logger.debug(`Attachment export error ${attachment.id}`, err);
- }
- }),
+ private async resolveAccessibleAttachments(
+ tree: PageExportTree,
+ userId: string | undefined,
+ ignorePermissions: boolean,
+ ): Promise