3163f50c98
Internal review found the two layered-extension flags landed in the SHARED markdownToProseMirrorCanonical wrapper, which mis-covered two tools. Move the decision to each caller by the tool's semantics. BLOCKER 1 — createPage was NOT covered. createPage POSTs to the server /pages/import endpoint, which imported with DEFAULTS (math + fuzzy autolink ON), so an agent's `$x=1$` still became a formula and `www.host` still autolinked. Add an optional `disableMarkdownExtensions` multipart field to /pages/import (default OFF, so human file uploads keep math ON); import.controller reads it, import.service.importPage/processMarkdown thread it into markdownToProseMirror. MCP createPage sends it true. BLOCKER 2 — import_page_markdown was wrongly disabled. It goes through the same wrapper, so hardcoding parseMath:false degraded an exported `$x^2$` to literal text on re-import, breaking the #328 lossless export→import pair. The wrapper no longer hardcodes the flags: it takes them from the caller and DEFAULTS to the package importer defaults (extensions ON). Callers now set them explicitly: - updatePageMarkdown (updatePageContentRealtime) -> OFF - patch_node/insert_node (importMarkdownFragment) -> OFF (unchanged) - import_page_markdown -> DEFAULTS (math ON) — #328 round-trip restored Tests: rewrite the mcp write test around the corrected per-caller semantics (agent-write OFF, import_page_markdown DEFAULTS incl. the REAL #328 round-trip); add a collab-backed wiring test driving client.updatePage (OFF) and client.importPageMarkdown (DEFAULTS) end-to-end; add a createPage multipart-flag test; add a server import.service.processMarkdown spec (flag OFF -> literal / no autolink, default -> math ON for human uploads); reword the prosemirror-markdown round-trip test to its package-default scope. Mutation-verified both caller wirings (flip updatePageMarkdown -> defaults reddens the OFF wiring test; make the wrapper default OFF reddens the #328 round-trip). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
208 lines
5.8 KiB
TypeScript
208 lines
5.8 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
Controller,
|
|
ForbiddenException,
|
|
HttpCode,
|
|
HttpStatus,
|
|
Inject,
|
|
Logger,
|
|
Post,
|
|
Req,
|
|
UseGuards,
|
|
UseInterceptors,
|
|
} from '@nestjs/common';
|
|
import SpaceAbilityFactory from '../../core/casl/abilities/space-ability.factory';
|
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
|
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
|
import { User, Workspace } from '@docmost/db/types/entity.types';
|
|
import {
|
|
SpaceCaslAction,
|
|
SpaceCaslSubject,
|
|
} from '../../core/casl/interfaces/space-ability.type';
|
|
import { FileInterceptor } from '../../common/interceptors/file.interceptor';
|
|
import * as bytes from 'bytes';
|
|
import * as path from 'path';
|
|
import { ImportService } from './services/import.service';
|
|
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
|
import { EnvironmentService } from '../environment/environment.service';
|
|
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
|
|
import {
|
|
AUDIT_SERVICE,
|
|
IAuditService,
|
|
} from '../../integrations/audit/audit.service';
|
|
|
|
@Controller()
|
|
export class ImportController {
|
|
private readonly logger = new Logger(ImportController.name);
|
|
|
|
constructor(
|
|
private readonly importService: ImportService,
|
|
private readonly spaceAbility: SpaceAbilityFactory,
|
|
private readonly environmentService: EnvironmentService,
|
|
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
|
) {}
|
|
|
|
@UseInterceptors(FileInterceptor)
|
|
@UseGuards(JwtAuthGuard)
|
|
@HttpCode(HttpStatus.OK)
|
|
@Post('pages/import')
|
|
async importPage(
|
|
@Req() req: any,
|
|
@AuthUser() user: User,
|
|
@AuthWorkspace() workspace: Workspace,
|
|
) {
|
|
const validFileExtensions = ['.md', '.html'];
|
|
|
|
const maxFileSize = bytes('30mb');
|
|
|
|
let file = null;
|
|
try {
|
|
file = await req.file({
|
|
limits: { fileSize: maxFileSize, fields: 4, files: 1 },
|
|
});
|
|
} catch (err: any) {
|
|
this.logger.error(err.message);
|
|
if (err?.statusCode === 413) {
|
|
throw new BadRequestException(
|
|
`File too large. Exceeds the 10mb import limit`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (!file) {
|
|
throw new BadRequestException('Failed to upload file');
|
|
}
|
|
|
|
if (
|
|
!validFileExtensions.includes(path.extname(file.filename).toLowerCase())
|
|
) {
|
|
throw new BadRequestException('Invalid import file type.');
|
|
}
|
|
|
|
const spaceId = file.fields?.spaceId?.value;
|
|
|
|
if (!spaceId) {
|
|
throw new BadRequestException('spaceId is required');
|
|
}
|
|
|
|
// #502: optional multipart field. Only the MCP agent `createPage` path sends
|
|
// `disableMarkdownExtensions=true` (its body is agent-authored plain prose /
|
|
// config, so a `$…$` span must stay literal and a bare `www.host` must not
|
|
// autolink). A HUMAN file upload omits the field, so it stays false and math
|
|
// + autolink remain ON for human imports. Settable ONLY via this API param.
|
|
const disableMarkdownExtensions =
|
|
file.fields?.disableMarkdownExtensions?.value === 'true';
|
|
|
|
const ability = await this.spaceAbility.createForUser(user, spaceId);
|
|
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
|
|
throw new ForbiddenException();
|
|
}
|
|
|
|
const createdPage = await this.importService.importPage(
|
|
file,
|
|
user.id,
|
|
spaceId,
|
|
workspace.id,
|
|
disableMarkdownExtensions,
|
|
);
|
|
|
|
const ext = path.extname(file.filename).toLowerCase();
|
|
const sourceMap: Record<string, string> = {
|
|
'.md': 'markdown',
|
|
'.html': 'html',
|
|
};
|
|
|
|
if (createdPage) {
|
|
this.auditService.log({
|
|
event: AuditEvent.PAGE_CREATED,
|
|
resourceType: AuditResource.PAGE,
|
|
resourceId: createdPage.id,
|
|
spaceId,
|
|
metadata: {
|
|
source: sourceMap[ext],
|
|
fileName: file.filename,
|
|
},
|
|
});
|
|
}
|
|
|
|
return createdPage;
|
|
}
|
|
|
|
@UseInterceptors(FileInterceptor)
|
|
@UseGuards(JwtAuthGuard)
|
|
@HttpCode(HttpStatus.OK)
|
|
@Post('pages/import-zip')
|
|
async importZip(
|
|
@Req() req: any,
|
|
@AuthUser() user: User,
|
|
@AuthWorkspace() workspace: Workspace,
|
|
) {
|
|
const validFileExtensions = ['.zip'];
|
|
|
|
const maxFileSize = bytes(this.environmentService.getFileImportSizeLimit());
|
|
|
|
let file = null;
|
|
try {
|
|
file = await req.file({
|
|
limits: { fileSize: maxFileSize, fields: 3, files: 1 },
|
|
});
|
|
} catch (err: any) {
|
|
this.logger.error(err.message);
|
|
if (err?.statusCode === 413) {
|
|
throw new BadRequestException(
|
|
`File too large. Exceeds the ${this.environmentService.getFileImportSizeLimit()} import limit`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (!file) {
|
|
throw new BadRequestException('Failed to upload file');
|
|
}
|
|
|
|
if (
|
|
!validFileExtensions.includes(path.extname(file.filename).toLowerCase())
|
|
) {
|
|
throw new BadRequestException('Invalid import file extension.');
|
|
}
|
|
|
|
const spaceId = file.fields?.spaceId?.value;
|
|
const source = file.fields?.source?.value;
|
|
|
|
const validZipSources = ['generic', 'notion'];
|
|
if (!validZipSources.includes(source)) {
|
|
throw new BadRequestException(
|
|
'Invalid import source. Import source must either be generic or notion.',
|
|
);
|
|
}
|
|
|
|
if (!spaceId) {
|
|
throw new BadRequestException('spaceId is required');
|
|
}
|
|
|
|
const ability = await this.spaceAbility.createForUser(user, spaceId);
|
|
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
|
|
throw new ForbiddenException();
|
|
}
|
|
|
|
this.auditService.log({
|
|
event: AuditEvent.PAGE_IMPORTED,
|
|
resourceType: AuditResource.PAGE,
|
|
resourceId: spaceId,
|
|
spaceId,
|
|
metadata: {
|
|
fileName: file.filename,
|
|
source,
|
|
spaceId,
|
|
},
|
|
});
|
|
|
|
return this.importService.importZip(
|
|
file,
|
|
source,
|
|
user.id,
|
|
spaceId,
|
|
workspace.id,
|
|
);
|
|
}
|
|
}
|