mirror of
https://github.com/docmost/docmost.git
synced 2026-08-21 06:41:37 +10:00
Merge branch 'main' into confluence-importer
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+1
-1
Submodule apps/server/src/ee updated: ae2f7eb411...3a080841e4
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user