Merge branch 'main' into confluence-importer

This commit is contained in:
Philipinho
2026-07-01 23:05:52 +01:00
42 changed files with 1237 additions and 199 deletions
@@ -4,6 +4,8 @@ import { WorkspaceService } from '../../workspace/services/workspace.service';
import { CreateWorkspaceDto } from '../../workspace/dto/create-workspace.dto';
import { CreateAdminUserDto } from '../dto/create-admin-user.dto';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { getWorkspaceDefaultPageEditMode } from '../../workspace/workspace.util';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { executeTx } from '@docmost/db/utils';
import { InjectKysely } from 'nestjs-kysely';
@@ -20,6 +22,7 @@ import {
export class SignupService {
constructor(
private userRepo: UserRepo,
private workspaceRepo: WorkspaceRepo,
private workspaceService: WorkspaceService,
private groupUserRepo: GroupUserRepo,
@InjectKysely() private readonly db: KyselyDB,
@@ -46,12 +49,16 @@ export class SignupService {
this.db,
async (trx) => {
// create user
const workspace = await this.workspaceRepo.findById(workspaceId, {
trx,
});
const user = await this.userRepo.insertUser(
{
...createUserDto,
workspaceId: workspaceId,
},
trx,
{ pageEditMode: getWorkspaceDefaultPageEditMode(workspace) },
);
// add user to workspace
@@ -4,6 +4,7 @@ import {
IsNumber,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
export class SearchDTO {
@@ -12,7 +13,7 @@ export class SearchDTO {
query: string;
@IsOptional()
@IsString()
@IsUUID()
spaceId: string;
@IsOptional()
@@ -20,7 +21,7 @@ export class SearchDTO {
shareId?: string;
@IsOptional()
@IsString()
@IsUUID()
creatorId?: string;
@IsOptional()
@@ -38,7 +39,7 @@ export class SearchShareDTO extends SearchDTO {
shareId: string;
@IsOptional()
@IsString()
@IsUUID()
spaceId: string;
}
@@ -3,8 +3,10 @@ import { CreateWorkspaceDto } from './create-workspace.dto';
import {
IsArray,
IsBoolean,
IsIn,
IsInt,
IsOptional,
IsString,
Min,
} from 'class-validator';
@@ -61,4 +63,9 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
@IsOptional()
@IsBoolean()
allowPersonalSpaces: boolean;
@IsOptional()
@IsString()
@IsIn(['read', 'edit'])
defaultPageEditMode: string;
}
@@ -41,7 +41,10 @@ import {
AUDIT_SERVICE,
IAuditService,
} from '../../../integrations/audit/audit.service';
import { isAdminActingOnOwner } from '../workspace.util';
import {
getWorkspaceDefaultPageEditMode,
isAdminActingOnOwner,
} from '../workspace.util';
@Injectable()
export class WorkspaceInvitationService {
@@ -257,6 +260,7 @@ export class WorkspaceInvitationService {
workspaceId: workspace.id,
},
trx,
{ pageEditMode: getWorkspaceDefaultPageEditMode(workspace) },
);
// add user to default group
@@ -527,6 +527,20 @@ export class WorkspaceService {
);
}
if (typeof updateWorkspaceDto.defaultPageEditMode !== 'undefined') {
const prev = settingsBefore?.defaultPageEditMode ?? null;
const next = updateWorkspaceDto.defaultPageEditMode.toLowerCase();
if (prev !== next) {
before.defaultPageEditMode = prev;
after.defaultPageEditMode = next;
}
await this.workspaceRepo.updateDefaultPageEditMode(
workspaceId,
next,
trx,
);
}
delete updateWorkspaceDto.restrictApiToAdmins;
delete updateWorkspaceDto.aiSearch;
delete updateWorkspaceDto.generativeAi;
@@ -535,6 +549,7 @@ export class WorkspaceService {
delete updateWorkspaceDto.allowMemberTemplates;
delete updateWorkspaceDto.aiChat;
delete updateWorkspaceDto.allowPersonalSpaces;
delete updateWorkspaceDto.defaultPageEditMode;
await this.workspaceRepo.updateWorkspace(
updateWorkspaceDto,
@@ -6,3 +6,18 @@ export function isAdminActingOnOwner(
): boolean {
return authUserRole === UserRole.ADMIN && targetRole === UserRole.OWNER;
}
export type PageEditMode = 'read' | 'edit';
export function getWorkspaceDefaultPageEditMode(
workspace: { settings?: unknown } | null | undefined,
): PageEditMode | undefined {
const settings = (workspace?.settings ?? {}) as {
defaultPageEditMode?: unknown;
};
const mode = settings.defaultPageEditMode;
if (mode === 'read' || mode === 'edit') {
return mode;
}
return undefined;
}
@@ -112,6 +112,7 @@ export class UserRepo {
async insertUser(
insertableUser: InsertableUser,
trx?: KyselyTransaction,
opts?: { pageEditMode?: string },
): Promise<User> {
const user: InsertableUser = {
name:
@@ -126,7 +127,17 @@ export class UserRepo {
const db = dbOrTx(this.db, trx);
return db
.insertInto('users')
.values({ ...insertableUser, ...user })
.values({
...insertableUser,
...user,
...(opts?.pageEditMode
? {
settings: sql`${JSON.stringify({
preferences: { pageEditMode: opts.pageEditMode },
})}::text::jsonb`,
}
: {}),
})
.returning(this.baseFields)
.executeTakeFirst();
}
@@ -271,4 +271,22 @@ export class WorkspaceRepo {
.executeTakeFirst();
}
async updateDefaultPageEditMode(
workspaceId: string,
pageEditMode: string,
trx?: KyselyTransaction,
) {
const db = dbOrTx(this.db, trx);
return db
.updateTable('workspaces')
.set({
settings: sql`COALESCE(settings, '{}'::jsonb)
|| jsonb_build_object('defaultPageEditMode', ${sql.lit(pageEditMode)})`,
updatedAt: new Date(),
})
.where('id', '=', workspaceId)
.returning(this.baseFields)
.executeTakeFirst();
}
}
@@ -16,6 +16,7 @@ import {
computeLocalPath,
getExportExtension,
getPageTitle,
getSafePageTitle,
PageExportTree,
replaceInternalLinks,
updateAttachmentUrlsToLocalPaths,
@@ -314,7 +315,7 @@ export class ExportService {
updateAttachmentUrlsToLocalPaths(updatedJsonContent);
}
const pageTitle = getPageTitle(page.title);
const pageTitle = getSafePageTitle(page.title);
const pageExportContent = await this.exportPage(format, {
...page,
content: updatedJsonContent,
+9 -1
View File
@@ -6,6 +6,7 @@ import { validate as isValidUUID } from 'uuid';
import * as path from 'path';
import { Page } from '@docmost/db/types/entity.types';
import { isAttachmentNode } from '../../common/helpers/prosemirror/utils';
import { sanitizeFileName } from '../../common/helpers';
export type PageExportTree = Record<string, Page[]>;
@@ -27,6 +28,13 @@ export function getPageTitle(title: string) {
return title ? title : 'untitled';
}
export function getSafePageTitle(title: string): string {
const sanitized = sanitizeFileName(getPageTitle(title), {
preserveSpaces: true,
});
return sanitized || 'untitled';
}
export function updateAttachmentUrlsToLocalPaths(prosemirrorJson: any) {
const doc = jsonToNode(prosemirrorJson);
if (!doc) return null;
@@ -167,7 +175,7 @@ export function computeLocalPath(
const children = tree[parentPageId] || [];
for (const page of children) {
const title = encodeURIComponent(getPageTitle(page.title));
const title = encodeURIComponent(getSafePageTitle(page.title));
const localPath = `${currentPath}${title}`;
slugIdToPath[page.slugId] = `${localPath}${getExportExtension(format)}`;
@@ -102,8 +102,10 @@ export class ImportService {
throw new BadRequestException(message);
}
const { title, prosemirrorJson } =
this.extractTitleAndRemoveHeading(prosemirrorState);
const { title, prosemirrorJson } = this.extractTitleAndRemoveHeading(
prosemirrorState,
{ anyHeadingLevel: true },
);
const pageTitle = title || fileName;
@@ -246,18 +248,29 @@ export class ImportService {
return null;
}
extractTitleAndRemoveHeading(prosemirrorState: any) {
extractTitleAndRemoveHeading(
prosemirrorState: any,
opts?: { anyHeadingLevel?: boolean },
) {
let title: string | null = null;
const content = prosemirrorState.content ?? [];
const firstNode = content[0];
if (
content.length > 0 &&
content[0].type === 'heading' &&
content[0].attrs?.level === 1
) {
title = content[0].content?.[0]?.text ?? null;
content.shift();
const isTitleHeading =
firstNode?.type === 'heading' &&
(opts?.anyHeadingLevel || firstNode.attrs?.level === 1);
if (isTitleHeading) {
const headingText = (firstNode.content ?? [])
.map((node: any) => node.text ?? '')
.join('')
.trim();
if (headingText) {
title = headingText;
content.shift();
}
}
// ensure at least one paragraph
@@ -32,31 +32,39 @@ export function getFileTaskFolderPath(
}
}
/**
* Extracts a ZIP archive.
*/
const COMPRESSION_HEADROOM = 10;
const MIN_EXTRACTED_BYTES = 256 * 1024 * 1024;
const MAX_ENTRIES = 250_000;
type SizeBudget = { used: number; max: number };
export async function extractZip(
source: string,
target: string,
): Promise<void> {
return extractZipInternal(source, target, true);
const { size: compressedSize } = await fs.promises.stat(source);
const max = Math.max(
compressedSize * COMPRESSION_HEADROOM,
MIN_EXTRACTED_BYTES,
);
return extractZipInternal(source, target, true, { used: 0, max });
}
/**
* Internal helper to extract a ZIP, with optional single-nested-ZIP handling.
* @param source Path to the ZIP file
* @param target Directory to extract into
* @param allowNested Whether to check and unwrap one level of nested ZIP
*/
function extractZipInternal(
source: string,
target: string,
allowNested: boolean,
budget: SizeBudget,
): Promise<void> {
return new Promise((resolve, reject) => {
yauzl.open(
source,
{ lazyEntries: true, decodeStrings: false, autoClose: true },
{
lazyEntries: true,
decodeStrings: false,
autoClose: true,
validateEntrySizes: true,
},
(err, zipfile) => {
if (err) return reject(err);
@@ -74,6 +82,15 @@ function extractZipInternal(
? source.slice(0, -4) + '.inner.zip'
: source + '.inner.zip';
budget.used += entry.uncompressedSize;
if (budget.used > budget.max) {
return reject(
new Error(
'Import archive exceeds the allowed extracted size limit',
),
);
}
zipfile.openReadStream(entry, (openErr, rs) => {
if (openErr) return reject(openErr);
const ws = fs.createWriteStream(nestedPath);
@@ -81,7 +98,7 @@ function extractZipInternal(
ws.on('error', reject);
ws.on('finish', () => {
zipfile.close();
extractZipInternal(nestedPath, target, false)
extractZipInternal(nestedPath, target, false, budget)
.then(() => {
fs.unlinkSync(nestedPath);
resolve();
@@ -92,13 +109,21 @@ function extractZipInternal(
});
} else {
zipfile.close();
extractZipInternal(source, target, false).then(resolve, reject);
extractZipInternal(source, target, false, budget).then(
resolve,
reject,
);
}
});
zipfile.once('error', reject);
return;
}
if (zipfile.entryCount > MAX_ENTRIES) {
zipfile.close();
return reject(new Error('Import archive has too many entries'));
}
// Normal extraction
zipfile.readEntry();
zipfile.on('entry', (entry) => {
@@ -144,6 +169,15 @@ function extractZipInternal(
return;
}
budget.used += entry.uncompressedSize;
if (budget.used > budget.max) {
return reject(
new Error(
'Import archive exceeds the allowed extracted size limit',
),
);
}
// Handle files
try {
fs.mkdirSync(path.dirname(fullPath), { recursive: true });