mirror of
https://github.com/docmost/docmost.git
synced 2026-08-30 02:25:01 +08:00
Merge branch 'main' into feat/integrations
# Conflicts: # apps/client/src/App.tsx # apps/server/src/ee # apps/server/src/integrations/queue/constants/queue.constants.ts # apps/server/src/integrations/queue/queue.module.ts # packages/editor-ext/src/index.ts
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -31,31 +31,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);
|
||||
|
||||
@@ -73,6 +81,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);
|
||||
@@ -80,7 +97,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();
|
||||
@@ -91,13 +108,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) => {
|
||||
@@ -143,6 +168,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 });
|
||||
|
||||
@@ -14,6 +14,7 @@ export enum QueueName {
|
||||
// Separate queue for /docmost ask: AI work takes seconds and would
|
||||
// otherwise starve fast inbound event dispatch.
|
||||
SLACK_ASK = '{slack-ask}',
|
||||
BASE_QUEUE = '{base-queue}',
|
||||
}
|
||||
|
||||
export enum QueueJob {
|
||||
@@ -93,4 +94,8 @@ export enum QueueJob {
|
||||
INTEGRATION_TOKEN_REFRESH = 'integration-token-refresh',
|
||||
SLACK_EVENT = 'slack-event',
|
||||
SLACK_ASK = 'slack-ask',
|
||||
|
||||
BASE_TYPE_CONVERSION = 'base-type-conversion',
|
||||
BASE_CELL_GC = 'base-cell-gc',
|
||||
BASE_FORMULA_RECOMPUTE = 'base-formula-recompute',
|
||||
}
|
||||
|
||||
@@ -113,3 +113,47 @@ export interface IApprovalRejectedNotificationJob {
|
||||
requestedById: string;
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
export interface IBaseTypeConversionJob {
|
||||
pageId: string;
|
||||
propertyId: string;
|
||||
workspaceId: string;
|
||||
fromType: string;
|
||||
toType: string;
|
||||
// Snapshots taken at enqueue time so the job stays correct even if the
|
||||
// property's current typeOptions drift while the job waits in the queue.
|
||||
fromTypeOptions: unknown;
|
||||
toTypeOptions: unknown;
|
||||
// When true, the job nulls the cell values for that property instead of
|
||||
// attempting a value conversion. Used for any conversion where the new
|
||||
// type has no meaningful representation of the old value (e.g. involving
|
||||
// a system type).
|
||||
clearMode: boolean;
|
||||
// Staging identity: guards redelivery and failure cleanup against a
|
||||
// same-type re-stage made after this job was enqueued.
|
||||
pendingToken: string;
|
||||
actorId?: string;
|
||||
}
|
||||
|
||||
export interface IBaseCellGcJob {
|
||||
pageId: string;
|
||||
propertyId: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface IBaseFormulaRecomputeJob {
|
||||
pageId: string;
|
||||
workspaceId: string;
|
||||
propertyIds: string[]; // formula properties to recompute
|
||||
reason:
|
||||
| 'formula_created'
|
||||
| 'formula_edited'
|
||||
| 'dep_type_changed'
|
||||
| 'dep_deleted'
|
||||
| 'bulk_import'
|
||||
| 'manual';
|
||||
actorId?: string | null;
|
||||
// When set, scope recompute to these row IDs instead of the whole base.
|
||||
// Used by the bulk-write path (> FORMULA_INLINE_ROW_THRESHOLD).
|
||||
rowIds?: string[];
|
||||
}
|
||||
|
||||
@@ -100,6 +100,14 @@ import { GeneralQueueProcessor } from './processors/general-queue.processor';
|
||||
attempts: 3,
|
||||
},
|
||||
}),
|
||||
BullModule.registerQueue({
|
||||
name: QueueName.BASE_QUEUE,
|
||||
defaultJobOptions: {
|
||||
attempts: 2,
|
||||
removeOnComplete: { count: 200 },
|
||||
removeOnFail: { count: 100 },
|
||||
},
|
||||
}),
|
||||
],
|
||||
exports: [BullModule],
|
||||
providers: [GeneralQueueProcessor],
|
||||
|
||||
@@ -15,13 +15,21 @@ import { getMimeType } from '../../../common/helpers';
|
||||
import { Upload } from '@aws-sdk/lib-storage';
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
const S3_MAX_SOCKETS = parseInt(process.env.AWS_S3_MAX_SOCKETS) || 200;
|
||||
|
||||
export class S3Driver implements StorageDriver {
|
||||
private readonly s3Client: S3Client;
|
||||
private readonly config: S3StorageConfig;
|
||||
|
||||
constructor(config: S3StorageConfig) {
|
||||
this.config = config;
|
||||
this.s3Client = new S3Client(config as any);
|
||||
this.config = {
|
||||
...config,
|
||||
requestHandler: {
|
||||
httpAgent: { maxSockets: S3_MAX_SOCKETS },
|
||||
httpsAgent: { maxSockets: S3_MAX_SOCKETS },
|
||||
},
|
||||
};
|
||||
this.s3Client = new S3Client(this.config as any);
|
||||
}
|
||||
|
||||
async upload(filePath: string, file: Buffer | Readable): Promise<void> {
|
||||
|
||||
Reference in New Issue
Block a user