mirror of
https://github.com/docmost/docmost.git
synced 2026-08-29 18:14:58 +08:00
merge main
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { AUDIT_SERVICE, NoopAuditService } from './audit.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: AUDIT_SERVICE,
|
||||
useClass: NoopAuditService,
|
||||
},
|
||||
],
|
||||
exports: [AUDIT_SERVICE],
|
||||
})
|
||||
export class NoopAuditModule {}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuditLogPayload, ActorType } from '../../common/events/audit-events';
|
||||
|
||||
export type AuditLogContext = {
|
||||
workspaceId: string;
|
||||
actorId?: string;
|
||||
actorType?: ActorType;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
};
|
||||
|
||||
export type IAuditService = {
|
||||
log(payload: AuditLogPayload): void | Promise<void>;
|
||||
logWithContext(
|
||||
payload: AuditLogPayload,
|
||||
context: AuditLogContext,
|
||||
): void | Promise<void>;
|
||||
logBatchWithContext(
|
||||
payloads: AuditLogPayload[],
|
||||
context: AuditLogContext,
|
||||
): void | Promise<void>;
|
||||
setActorId(actorId: string): void;
|
||||
setActorType(actorType: ActorType): void;
|
||||
updateRetention(
|
||||
workspaceId: string,
|
||||
retentionDays: number,
|
||||
): void | Promise<void>;
|
||||
};
|
||||
|
||||
export const AUDIT_SERVICE = Symbol('AUDIT_SERVICE');
|
||||
|
||||
@Injectable()
|
||||
export class NoopAuditService implements IAuditService {
|
||||
log(_payload: AuditLogPayload): void {
|
||||
// No-op: swallow the log when EE module is not available
|
||||
}
|
||||
|
||||
logWithContext(_payload: AuditLogPayload, _context: AuditLogContext): void {
|
||||
// No-op: swallow the log when EE module is not available
|
||||
}
|
||||
|
||||
logBatchWithContext(
|
||||
_payloads: AuditLogPayload[],
|
||||
_context: AuditLogContext,
|
||||
): void {
|
||||
// No-op: swallow the log when EE module is not available
|
||||
}
|
||||
|
||||
setActorId(_actorId: string): void {
|
||||
// No-op
|
||||
}
|
||||
|
||||
setActorType(_actorType: ActorType): void {
|
||||
// No-op
|
||||
}
|
||||
|
||||
updateRetention(
|
||||
_workspaceId: string,
|
||||
_retentionDays: number,
|
||||
): void {
|
||||
// No-op
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,10 @@ export class EnvironmentService {
|
||||
return new Date(Date.now() + msUntilExpiry);
|
||||
}
|
||||
|
||||
getGotenbergUrl(): string | undefined {
|
||||
return this.configService.get<string>('GOTENBERG_URL');
|
||||
}
|
||||
|
||||
getStorageDriver(): string {
|
||||
return this.configService.get<string>('STORAGE_DRIVER', 'local');
|
||||
}
|
||||
@@ -108,13 +112,36 @@ export class EnvironmentService {
|
||||
}
|
||||
|
||||
getAwsS3ForcePathStyle(): boolean {
|
||||
return this.configService.get<boolean>('AWS_S3_FORCE_PATH_STYLE');
|
||||
const forcePathStyle = this.configService
|
||||
.get<string>('AWS_S3_FORCE_PATH_STYLE', 'false')
|
||||
.toLowerCase();
|
||||
return forcePathStyle === 'true';
|
||||
}
|
||||
|
||||
getAwsS3Url(): string {
|
||||
return this.configService.get<string>('AWS_S3_URL');
|
||||
}
|
||||
|
||||
getAzureStorageAccountName(): string {
|
||||
return this.configService.get<string>('AZURE_STORAGE_ACCOUNT_NAME');
|
||||
}
|
||||
|
||||
getAzureStorageContainer(): string {
|
||||
return this.configService.get<string>('AZURE_STORAGE_CONTAINER');
|
||||
}
|
||||
|
||||
getAzureStorageAccountKey(): string {
|
||||
return this.configService.get<string>('AZURE_STORAGE_ACCOUNT_KEY');
|
||||
}
|
||||
|
||||
getAzureStorageEndpoint(): string {
|
||||
return this.configService.get<string>('AZURE_STORAGE_ENDPOINT');
|
||||
}
|
||||
|
||||
getAzureStorageUrl(): string {
|
||||
return this.configService.get<string>('AZURE_STORAGE_URL');
|
||||
}
|
||||
|
||||
getMailDriver(): string {
|
||||
return this.configService.get<string>('MAIL_DRIVER', 'log');
|
||||
}
|
||||
@@ -127,6 +154,17 @@ export class EnvironmentService {
|
||||
return this.configService.get<string>('MAIL_FROM_NAME', 'Docmost');
|
||||
}
|
||||
|
||||
getMailBlockedRecipientDomains(): string[] {
|
||||
const raw = this.configService.get<string>(
|
||||
'MAIL_BLOCKED_RECIPIENT_DOMAINS',
|
||||
'',
|
||||
);
|
||||
return raw
|
||||
.split(',')
|
||||
.map((d) => d.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
getSmtpHost(): string {
|
||||
return this.configService.get<string>('SMTP_HOST');
|
||||
}
|
||||
@@ -252,6 +290,13 @@ export class EnvironmentService {
|
||||
return this.configService.get<string>('AI_COMPLETION_MODEL');
|
||||
}
|
||||
|
||||
getAiChatModel(): string {
|
||||
return (
|
||||
this.configService.get<string>('AI_CHAT_MODEL') ||
|
||||
this.configService.get<string>('AI_COMPLETION_MODEL')
|
||||
);
|
||||
}
|
||||
|
||||
getAiEmbeddingDimension(): number {
|
||||
return parseInt(
|
||||
this.configService.get<string>('AI_EMBEDDING_DIMENSION'),
|
||||
@@ -259,6 +304,12 @@ export class EnvironmentService {
|
||||
);
|
||||
}
|
||||
|
||||
getAiEmbeddingSupportsMrl(): boolean | undefined {
|
||||
const val = this.configService.get<string>('AI_EMBEDDING_SUPPORTS_MRL');
|
||||
if (val === undefined || val === null || val === '') return undefined;
|
||||
return val === 'true';
|
||||
}
|
||||
|
||||
getOpenAiApiKey(): string {
|
||||
return this.configService.get<string>('OPENAI_API_KEY');
|
||||
}
|
||||
@@ -277,4 +328,36 @@ export class EnvironmentService {
|
||||
'http://localhost:11434',
|
||||
);
|
||||
}
|
||||
|
||||
getEventStoreDriver(): string {
|
||||
return this.configService
|
||||
.get<string>('EVENT_STORE_DRIVER', 'postgres')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
getClickHouseUrl(): string {
|
||||
return this.configService.get<string>('CLICKHOUSE_URL');
|
||||
}
|
||||
|
||||
getSamlDisableRequestedAuthnContext(): boolean {
|
||||
const disabled = this.configService
|
||||
.get<string>('SAML_DISABLE_REQUESTED_AUTHN_CONTEXT', 'false')
|
||||
.toLowerCase();
|
||||
return disabled === 'true';
|
||||
}
|
||||
|
||||
isIframeEmbedAllowed(): boolean {
|
||||
const allowed = this.configService
|
||||
.get<string>('IFRAME_EMBED_ALLOWED', 'false')
|
||||
.toLowerCase();
|
||||
return allowed === 'true';
|
||||
}
|
||||
|
||||
getIframeAllowedOrigins(): string[] {
|
||||
const raw = this.configService.get<string>('IFRAME_ALLOWED_ORIGINS', '');
|
||||
return raw
|
||||
.split(',')
|
||||
.map((o) => o.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
validateSync,
|
||||
} from 'class-validator';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { IsISO6391 } from '../../common/validator/is-iso6391';
|
||||
import { IsISO6391 } from '../../common/validators/is-iso6391';
|
||||
|
||||
export class EnvironmentVariables {
|
||||
@IsNotEmpty()
|
||||
@@ -49,7 +49,7 @@ export class EnvironmentVariables {
|
||||
MAIL_DRIVER: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['local', 's3'])
|
||||
@IsIn(['local', 's3', 'azure'])
|
||||
STORAGE_DRIVER: string;
|
||||
|
||||
@IsOptional()
|
||||
@@ -117,6 +117,12 @@ export class EnvironmentVariables {
|
||||
@IsString()
|
||||
AI_EMBEDDING_DIMENSION: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((obj) => obj.AI_EMBEDDING_SUPPORTS_MRL)
|
||||
@IsIn(['true', 'false'])
|
||||
@IsString()
|
||||
AI_EMBEDDING_SUPPORTS_MRL: string;
|
||||
|
||||
@ValidateIf((obj) => obj.AI_DRIVER)
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@@ -148,6 +154,22 @@ export class EnvironmentVariables {
|
||||
@ValidateIf((obj) => obj.AI_DRIVER && obj.AI_DRIVER === 'ollama')
|
||||
@IsUrl({ protocols: ['http', 'https'], require_tld: false })
|
||||
OLLAMA_API_URL: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['postgres', 'clickhouse'])
|
||||
@IsString()
|
||||
EVENT_STORE_DRIVER: string;
|
||||
|
||||
@ValidateIf((obj) => obj.EVENT_STORE_DRIVER === 'clickhouse')
|
||||
@IsNotEmpty()
|
||||
@IsUrl(
|
||||
{ protocols: ['http', 'https'], require_tld: false },
|
||||
{
|
||||
message:
|
||||
'CLICKHOUSE_URL must be a valid URL e.g http://user:password@localhost:8123/docmost',
|
||||
},
|
||||
)
|
||||
CLICKHOUSE_URL: string;
|
||||
}
|
||||
|
||||
export function validate(config: Record<string, any>) {
|
||||
|
||||
@@ -25,4 +25,75 @@ export class LicenseCheckService {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
hasFeature(licenseKey: string, feature: string, plan?: string): boolean {
|
||||
if (this.environmentService.isCloud()) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { getFeaturesForCloudPlan } = require('../../ee/licence/feature-registry');
|
||||
return getFeaturesForCloudPlan(plan).has(feature);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const LicenseModule = require('../../ee/licence/license.service');
|
||||
const licenseService = this.moduleRef.get(LicenseModule.LicenseService, {
|
||||
strict: false,
|
||||
});
|
||||
return licenseService.hasFeature(licenseKey, feature);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
getFeatures(licenseKey: string): string[] {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const LicenseModule = require('../../ee/licence/license.service');
|
||||
const licenseService = this.moduleRef.get(LicenseModule.LicenseService, {
|
||||
strict: false,
|
||||
});
|
||||
return licenseService.getFeatures(licenseKey);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
resolveFeatures(licenseKey: string, plan: string): string[] {
|
||||
if (this.environmentService.isCloud()) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { getFeaturesForCloudPlan } = require('../../ee/licence/feature-registry');
|
||||
return [...getFeaturesForCloudPlan(plan)];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return this.getFeatures(licenseKey);
|
||||
}
|
||||
|
||||
resolveTier(licenseKey: string, plan: string): string {
|
||||
if (this.environmentService.isCloud()) {
|
||||
return plan ?? 'standard';
|
||||
}
|
||||
|
||||
return this.getLicenseType(licenseKey) ?? 'free';
|
||||
}
|
||||
|
||||
private getLicenseType(licenseKey: string): string | null {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const LicenseModule = require('../../ee/licence/license.service');
|
||||
const licenseService = this.moduleRef.get(LicenseModule.LicenseService, {
|
||||
strict: false,
|
||||
});
|
||||
return licenseService.getLicenseType(licenseKey);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ForbiddenException,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
NotFoundException,
|
||||
Post,
|
||||
Res,
|
||||
@@ -16,15 +17,24 @@ import { User } from '@docmost/db/types/entity.types';
|
||||
import SpaceAbilityFactory from '../../core/casl/abilities/space-ability.factory';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { PageAccessService } from '../../core/page/page-access/page-access.service';
|
||||
import {
|
||||
SpaceCaslAction,
|
||||
SpaceCaslSubject,
|
||||
} from '../../core/casl/interfaces/space-ability.type';
|
||||
import { FastifyReply } from 'fastify';
|
||||
import { sanitize } from 'sanitize-filename-ts';
|
||||
import { getExportExtension } from './utils';
|
||||
import { getMimeType } from '../../common/helpers';
|
||||
import {
|
||||
getMimeType,
|
||||
getPageTitle,
|
||||
sanitizeFileName,
|
||||
} from '../../common/helpers';
|
||||
import * as path from 'path';
|
||||
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
|
||||
import {
|
||||
AUDIT_SERVICE,
|
||||
IAuditService,
|
||||
} from '../../integrations/audit/audit.service';
|
||||
|
||||
@Controller()
|
||||
export class ExportController {
|
||||
@@ -32,6 +42,8 @@ export class ExportController {
|
||||
private readonly exportService: ExportService,
|
||||
private readonly pageRepo: PageRepo,
|
||||
private readonly spaceAbility: SpaceAbilityFactory,
|
||||
private readonly pageAccessService: PageAccessService,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@@ -50,27 +62,57 @@ export class ExportController {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
await this.pageAccessService.validateCanView(page, user);
|
||||
|
||||
const zipFileStream = await this.exportService.exportPages(
|
||||
const result = await this.exportService.exportPages(
|
||||
dto.pageId,
|
||||
dto.format,
|
||||
dto.includeAttachments,
|
||||
dto.includeChildren,
|
||||
user.id,
|
||||
);
|
||||
|
||||
const fileName = sanitize(page.title || 'untitled') + '.zip';
|
||||
|
||||
res.headers({
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Disposition':
|
||||
'attachment; filename="' + encodeURIComponent(fileName) + '"',
|
||||
this.auditService.log({
|
||||
event: AuditEvent.PAGE_EXPORTED,
|
||||
resourceType: AuditResource.PAGE,
|
||||
resourceId: page.id,
|
||||
spaceId: page.spaceId,
|
||||
metadata: {
|
||||
title: getPageTitle(page.title),
|
||||
format: dto.format,
|
||||
includeChildren: dto.includeChildren,
|
||||
includeAttachments: dto.includeAttachments,
|
||||
spaceId: page.spaceId,
|
||||
},
|
||||
});
|
||||
|
||||
res.send(zipFileStream);
|
||||
if (result.type === 'file') {
|
||||
const ext = getExportExtension(dto.format);
|
||||
const fileName =
|
||||
sanitizeFileName(page.title || 'untitled', { preserveSpaces: true }) +
|
||||
ext;
|
||||
const contentType = getMimeType(path.extname(fileName));
|
||||
|
||||
res.headers({
|
||||
'Content-Type': contentType,
|
||||
'Content-Disposition':
|
||||
'attachment; filename="' + encodeURIComponent(fileName) + '"',
|
||||
});
|
||||
|
||||
res.send(result.content);
|
||||
} else {
|
||||
const fileName =
|
||||
sanitizeFileName(page.title || 'untitled', { preserveSpaces: true }) +
|
||||
'.zip';
|
||||
|
||||
res.headers({
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Disposition':
|
||||
'attachment; filename="' + encodeURIComponent(fileName) + '"',
|
||||
});
|
||||
|
||||
res.send(result.stream);
|
||||
}
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@@ -82,7 +124,7 @@ export class ExportController {
|
||||
@Res() res: FastifyReply,
|
||||
) {
|
||||
const ability = await this.spaceAbility.createForUser(user, dto.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
|
||||
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
@@ -90,13 +132,28 @@ export class ExportController {
|
||||
dto.spaceId,
|
||||
dto.format,
|
||||
dto.includeAttachments,
|
||||
user.id,
|
||||
);
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.SPACE_EXPORTED,
|
||||
resourceType: AuditResource.SPACE,
|
||||
resourceId: dto.spaceId,
|
||||
spaceId: dto.spaceId,
|
||||
metadata: {
|
||||
format: dto.format,
|
||||
includeAttachments: dto.includeAttachments ?? false,
|
||||
spaceName: exportFile.spaceName,
|
||||
},
|
||||
});
|
||||
|
||||
res.headers({
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Disposition':
|
||||
'attachment; filename="' +
|
||||
encodeURIComponent(sanitize(exportFile.fileName)) +
|
||||
encodeURIComponent(
|
||||
sanitizeFileName(exportFile.fileName, { preserveSpaces: true }),
|
||||
) +
|
||||
'"',
|
||||
});
|
||||
|
||||
|
||||
@@ -25,28 +25,33 @@ import {
|
||||
ExportPageMetadata,
|
||||
} from '../../common/helpers/types/export-metadata.types';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||
import { Node } from '@tiptap/pm/model';
|
||||
import { EditorState } from '@tiptap/pm/state';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import slugify = require('@sindresorhus/slugify');
|
||||
import slugify from '@sindresorhus/slugify';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const packageJson = require('../../../package.json');
|
||||
import { EnvironmentService } from '../environment/environment.service';
|
||||
import { DomainService } from '../environment/domain.service';
|
||||
import {
|
||||
getAttachmentIds,
|
||||
getProsemirrorContent,
|
||||
} 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);
|
||||
|
||||
constructor(
|
||||
private readonly pageRepo: PageRepo,
|
||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private readonly storageService: StorageService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private readonly domainService: DomainService,
|
||||
) {}
|
||||
|
||||
async exportPage(format: string, page: Page, singlePage?: boolean) {
|
||||
@@ -59,9 +64,11 @@ export class ExportService {
|
||||
let prosemirrorJson: any;
|
||||
|
||||
if (singlePage) {
|
||||
const baseUrl = await this.getWorkspaceBaseUrl(page.workspaceId);
|
||||
prosemirrorJson = await this.turnPageMentionsToLinks(
|
||||
getProsemirrorContent(page.content),
|
||||
page.workspaceId,
|
||||
baseUrl,
|
||||
);
|
||||
} else {
|
||||
// mentions is already turned to links during the zip process
|
||||
@@ -100,6 +107,8 @@ export class ExportService {
|
||||
format: string,
|
||||
includeAttachments: boolean,
|
||||
includeChildren: boolean,
|
||||
userId?: string,
|
||||
ignorePermissions = false,
|
||||
) {
|
||||
let pages: Page[];
|
||||
|
||||
@@ -113,7 +122,7 @@ export class ExportService {
|
||||
const page = await this.pageRepo.findById(pageId, {
|
||||
includeContent: true,
|
||||
});
|
||||
if (page){
|
||||
if (page) {
|
||||
pages = [page];
|
||||
}
|
||||
}
|
||||
@@ -122,14 +131,47 @@ export class ExportService {
|
||||
throw new BadRequestException('No pages to export');
|
||||
}
|
||||
|
||||
if (!ignorePermissions && userId) {
|
||||
pages = await this.filterPagesForExport(
|
||||
pages,
|
||||
pageId,
|
||||
userId,
|
||||
pages[0].spaceId,
|
||||
);
|
||||
if (pages.length === 0) {
|
||||
throw new BadRequestException('No accessible pages to export');
|
||||
}
|
||||
}
|
||||
|
||||
const parentPageIndex = pages.findIndex((obj) => obj.id === pageId);
|
||||
|
||||
//After filtering by permissions, if the root page itself is not accessible to the user, findIndex returns -1
|
||||
if (parentPageIndex === -1) {
|
||||
throw new BadRequestException('Root page is not accessible');
|
||||
}
|
||||
// set to null to make export of pages with parentId work
|
||||
pages[parentPageIndex].parentPageId = null;
|
||||
|
||||
const isSinglePage = pages.length === 1 && !includeAttachments;
|
||||
|
||||
if (isSinglePage) {
|
||||
const pageContent = await this.exportPage(format, pages[0], true);
|
||||
return { type: 'file' as const, content: pageContent, page: pages[0] };
|
||||
}
|
||||
|
||||
const tree = buildTree(pages as Page[]);
|
||||
|
||||
const baseUrl = await this.getWorkspaceBaseUrl(pages[0].workspaceId);
|
||||
const zip = new JSZip();
|
||||
await this.zipPages(tree, format, zip, includeAttachments);
|
||||
await this.zipPages(
|
||||
tree,
|
||||
format,
|
||||
zip,
|
||||
includeAttachments,
|
||||
baseUrl,
|
||||
userId,
|
||||
ignorePermissions,
|
||||
);
|
||||
|
||||
const zipFile = zip.generateNodeStream({
|
||||
type: 'nodebuffer',
|
||||
@@ -137,17 +179,19 @@ export class ExportService {
|
||||
compression: 'DEFLATE',
|
||||
});
|
||||
|
||||
return zipFile;
|
||||
return { type: 'zip' as const, stream: zipFile, page: pages[0] };
|
||||
}
|
||||
|
||||
async exportSpace(
|
||||
spaceId: string,
|
||||
format: string,
|
||||
includeAttachments: boolean,
|
||||
userId?: string,
|
||||
ignorePermissions = false,
|
||||
) {
|
||||
const space = await this.db
|
||||
.selectFrom('spaces')
|
||||
.selectAll()
|
||||
.select(['id', 'name'])
|
||||
.where('id', '=', spaceId)
|
||||
.executeTakeFirst();
|
||||
|
||||
@@ -155,7 +199,7 @@ export class ExportService {
|
||||
throw new NotFoundException('Space not found');
|
||||
}
|
||||
|
||||
const pages = await this.db
|
||||
let pages = await this.db
|
||||
.selectFrom('pages')
|
||||
.select([
|
||||
'pages.id',
|
||||
@@ -174,11 +218,32 @@ export class ExportService {
|
||||
.where('deletedAt', 'is', null)
|
||||
.execute();
|
||||
|
||||
if (!ignorePermissions && userId) {
|
||||
pages = await this.filterPagesForExport(
|
||||
pages as Page[],
|
||||
null,
|
||||
userId,
|
||||
spaceId,
|
||||
);
|
||||
if (pages.length === 0) {
|
||||
throw new BadRequestException('No accessible pages to export');
|
||||
}
|
||||
}
|
||||
|
||||
const tree = buildTree(pages as Page[]);
|
||||
|
||||
const baseUrl = await this.getWorkspaceBaseUrl(pages[0].workspaceId);
|
||||
const zip = new JSZip();
|
||||
|
||||
await this.zipPages(tree, format, zip, includeAttachments);
|
||||
await this.zipPages(
|
||||
tree,
|
||||
format,
|
||||
zip,
|
||||
includeAttachments,
|
||||
baseUrl,
|
||||
userId,
|
||||
ignorePermissions,
|
||||
);
|
||||
|
||||
const zipFile = zip.generateNodeStream({
|
||||
type: 'nodebuffer',
|
||||
@@ -190,6 +255,7 @@ export class ExportService {
|
||||
return {
|
||||
fileStream: zipFile,
|
||||
fileName,
|
||||
spaceName: space.name,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -198,6 +264,9 @@ export class ExportService {
|
||||
format: string,
|
||||
zip: JSZip,
|
||||
includeAttachments: boolean,
|
||||
baseUrl: string,
|
||||
userId?: string,
|
||||
ignorePermissions = false,
|
||||
): Promise<void> {
|
||||
const slugIdToPath: Record<string, string> = {};
|
||||
const pageIdToFilePath: Record<string, string> = {};
|
||||
@@ -205,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<string, AllowedAttachment>();
|
||||
|
||||
const stack: { folder: JSZip; parentPageId: string | null }[] = [
|
||||
{ folder: zip, parentPageId: null },
|
||||
];
|
||||
@@ -219,6 +294,9 @@ export class ExportService {
|
||||
const prosemirrorJson = await this.turnPageMentionsToLinks(
|
||||
getProsemirrorContent(page.content),
|
||||
page.workspaceId,
|
||||
baseUrl,
|
||||
userId,
|
||||
ignorePermissions,
|
||||
);
|
||||
|
||||
const currentPagePath = slugIdToPath[page.slugId];
|
||||
@@ -227,10 +305,11 @@ export class ExportService {
|
||||
prosemirrorJson,
|
||||
slugIdToPath,
|
||||
currentPagePath,
|
||||
baseUrl,
|
||||
);
|
||||
|
||||
if (includeAttachments) {
|
||||
await this.zipAttachments(updatedJsonContent, page.spaceId, folder);
|
||||
await this.zipAttachments(updatedJsonContent, folder, allowedAttachments);
|
||||
updatedJsonContent =
|
||||
updateAttachmentUrlsToLocalPaths(updatedJsonContent);
|
||||
}
|
||||
@@ -276,37 +355,92 @@ 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<string, AllowedAttachment>,
|
||||
) {
|
||||
const attachmentIds = getAttachmentIds(prosemirrorJson);
|
||||
|
||||
if (attachmentIds.length > 0) {
|
||||
const attachments = await this.db
|
||||
.selectFrom('attachments')
|
||||
.selectAll()
|
||||
.where('id', 'in', attachmentIds)
|
||||
.where('spaceId', '=', spaceId)
|
||||
.execute();
|
||||
|
||||
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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async turnPageMentionsToLinks(prosemirrorJson: any, workspaceId: string) {
|
||||
private async resolveAccessibleAttachments(
|
||||
tree: PageExportTree,
|
||||
userId: string | undefined,
|
||||
ignorePermissions: boolean,
|
||||
): Promise<Map<string, AllowedAttachment>> {
|
||||
const allAttachmentIds = new Set<string>();
|
||||
let spaceId: string | undefined;
|
||||
for (const siblings of Object.values(tree)) {
|
||||
for (const page of siblings) {
|
||||
if (!spaceId) spaceId = page.spaceId;
|
||||
for (const id of getAttachmentIds(getProsemirrorContent(page.content))) {
|
||||
allAttachmentIds.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (allAttachmentIds.size === 0 || !spaceId) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const attachments = await this.db
|
||||
.selectFrom('attachments')
|
||||
.select(['id', 'fileName', 'filePath', 'pageId'])
|
||||
.where('id', 'in', [...allAttachmentIds])
|
||||
.where('spaceId', '=', spaceId)
|
||||
.execute();
|
||||
|
||||
let visible = attachments;
|
||||
if (!ignorePermissions && userId) {
|
||||
const ownerPageIds = [
|
||||
...new Set(
|
||||
attachments
|
||||
.map((a) => a.pageId)
|
||||
.filter((id): id is string => !!id),
|
||||
),
|
||||
];
|
||||
const accessible = ownerPageIds.length
|
||||
? await this.pagePermissionRepo.filterAccessiblePageIds({
|
||||
pageIds: ownerPageIds,
|
||||
userId,
|
||||
spaceId,
|
||||
})
|
||||
: [];
|
||||
const accessibleSet = new Set(accessible);
|
||||
visible = attachments.filter(
|
||||
(a) => a.pageId && accessibleSet.has(a.pageId),
|
||||
);
|
||||
}
|
||||
|
||||
return new Map(visible.map((a) => [a.id, a]));
|
||||
}
|
||||
|
||||
async turnPageMentionsToLinks(
|
||||
prosemirrorJson: any,
|
||||
workspaceId: string,
|
||||
baseUrl: string,
|
||||
userId?: string,
|
||||
ignorePermissions = false,
|
||||
) {
|
||||
const doc = jsonToNode(prosemirrorJson);
|
||||
|
||||
const pageMentionIds = [];
|
||||
let pageMentionIds: string[] = [];
|
||||
|
||||
doc.descendants((node: Node) => {
|
||||
if (node.type.name === 'mention' && node.attrs.entityType === 'page') {
|
||||
@@ -320,13 +454,31 @@ export class ExportService {
|
||||
return prosemirrorJson;
|
||||
}
|
||||
|
||||
const pages = await this.db
|
||||
.selectFrom('pages')
|
||||
.select(['id', 'slugId', 'title', 'creatorId', 'spaceId', 'workspaceId'])
|
||||
.select((eb) => this.pageRepo.withSpace(eb))
|
||||
.where('id', 'in', pageMentionIds)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.execute();
|
||||
// Filter to only accessible pages if permissions are enforced
|
||||
if (!ignorePermissions && userId) {
|
||||
pageMentionIds = await this.pagePermissionRepo.filterAccessiblePageIds({
|
||||
pageIds: pageMentionIds,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
const pages =
|
||||
pageMentionIds.length > 0
|
||||
? await this.db
|
||||
.selectFrom('pages')
|
||||
.select([
|
||||
'id',
|
||||
'slugId',
|
||||
'title',
|
||||
'creatorId',
|
||||
'spaceId',
|
||||
'workspaceId',
|
||||
])
|
||||
.select((eb) => this.pageRepo.withSpace(eb))
|
||||
.where('id', 'in', pageMentionIds)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.execute()
|
||||
: [];
|
||||
|
||||
const pageMap = new Map(pages.map((page) => [page.id, page]));
|
||||
|
||||
@@ -352,8 +504,7 @@ export class ExportService {
|
||||
const truncatedTitle = linkTitle?.substring(0, 70);
|
||||
const pageSlug = `${slugify(truncatedTitle)}-${slugId}`;
|
||||
|
||||
// Create the link URL
|
||||
const link = `${this.environmentService.getAppUrl()}/s/${spaceSlug}/p/${pageSlug}`;
|
||||
const link = `${baseUrl}/s/${spaceSlug}/p/${pageSlug}`;
|
||||
|
||||
// Create a link mark and a text node with that mark
|
||||
const linkMark = editorState.schema.marks.link.create({ href: link });
|
||||
@@ -398,4 +549,62 @@ export class ExportService {
|
||||
|
||||
return updatedDoc.toJSON();
|
||||
}
|
||||
|
||||
private async getWorkspaceBaseUrl(workspaceId: string): Promise<string> {
|
||||
const workspace = await this.db
|
||||
.selectFrom('workspaces')
|
||||
.select('hostname')
|
||||
.where('id', '=', workspaceId)
|
||||
.executeTakeFirst();
|
||||
|
||||
return this.domainService.getUrl(workspace?.hostname);
|
||||
}
|
||||
|
||||
private async filterPagesForExport(
|
||||
pages: Page[],
|
||||
rootPageId: string | null,
|
||||
userId: string,
|
||||
spaceId: string,
|
||||
): Promise<Page[]> {
|
||||
if (pages.length === 0) return [];
|
||||
|
||||
const pageIds = pages.map((p) => p.id);
|
||||
const accessibleIds = await this.pagePermissionRepo.filterAccessiblePageIds(
|
||||
{
|
||||
pageIds,
|
||||
userId,
|
||||
spaceId,
|
||||
},
|
||||
);
|
||||
const accessibleSet = new Set(accessibleIds);
|
||||
|
||||
const includedIds = new Set<string>();
|
||||
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const page of pages) {
|
||||
if (includedIds.has(page.id)) continue;
|
||||
if (!accessibleSet.has(page.id)) continue;
|
||||
|
||||
// Root page or top-level page in space export
|
||||
if (
|
||||
page.id === rootPageId ||
|
||||
(rootPageId === null && page.parentPageId === null)
|
||||
) {
|
||||
includedIds.add(page.id);
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Non-root: include if parent is already included
|
||||
if (page.parentPageId && includedIds.has(page.parentPageId)) {
|
||||
includedIds.add(page.id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pages.filter((p) => includedIds.has(p.id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ export function replaceInternalLinks(
|
||||
prosemirrorJson: any,
|
||||
slugIdToPath: Record<string, string>,
|
||||
currentPagePath: string,
|
||||
baseUrl?: string,
|
||||
) {
|
||||
const doc = jsonToNode(prosemirrorJson);
|
||||
|
||||
@@ -76,6 +77,10 @@ export function replaceInternalLinks(
|
||||
const localPath = slugIdToPath[slugId];
|
||||
|
||||
if (!localPath) {
|
||||
if (baseUrl && mark.attrs.href.startsWith('/')) {
|
||||
//@ts-expect-error
|
||||
mark.attrs.href = `${baseUrl}${mark.attrs.href}`;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ForbiddenException,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
Logger,
|
||||
Post,
|
||||
Req,
|
||||
@@ -24,6 +25,11 @@ 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 {
|
||||
@@ -33,6 +39,7 @@ export class ImportController {
|
||||
private readonly importService: ImportService,
|
||||
private readonly spaceAbility: SpaceAbilityFactory,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
|
||||
@UseInterceptors(FileInterceptor)
|
||||
@@ -44,9 +51,9 @@ export class ImportController {
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const validFileExtensions = ['.md', '.html', '.docx'];
|
||||
const validFileExtensions = ['.md', '.html', '.docx', '.pdf'];
|
||||
|
||||
const maxFileSize = bytes('10mb');
|
||||
const maxFileSize = bytes('30mb');
|
||||
|
||||
let file = null;
|
||||
try {
|
||||
@@ -83,7 +90,35 @@ export class ImportController {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.importService.importPage(file, user.id, spaceId, workspace.id);
|
||||
const createdPage = await this.importService.importPage(
|
||||
file,
|
||||
user.id,
|
||||
spaceId,
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
const ext = path.extname(file.filename).toLowerCase();
|
||||
const sourceMap: Record<string, string> = {
|
||||
'.md': 'markdown',
|
||||
'.html': 'html',
|
||||
'.docx': 'docx',
|
||||
'.pdf': 'pdf',
|
||||
};
|
||||
|
||||
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)
|
||||
@@ -142,6 +177,18 @@ export class ImportController {
|
||||
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,
|
||||
|
||||
@@ -5,6 +5,9 @@ import { QueueJob, QueueName } from 'src/integrations/queue/constants';
|
||||
import { FileImportTaskService } from '../services/file-import-task.service';
|
||||
import { FileTaskStatus } from '../utils/file.utils';
|
||||
import { StorageService } from '../../storage/storage.service';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
|
||||
@Processor(QueueName.FILE_TASK_QUEUE)
|
||||
export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
|
||||
@@ -13,6 +16,8 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
|
||||
constructor(
|
||||
private readonly fileTaskService: FileImportTaskService,
|
||||
private readonly storageService: StorageService,
|
||||
private readonly moduleRef: ModuleRef,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@@ -23,8 +28,11 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
|
||||
case QueueJob.IMPORT_TASK:
|
||||
await this.fileTaskService.processZIpImport(job.data.fileTaskId);
|
||||
break;
|
||||
case QueueJob.EXPORT_TASK:
|
||||
// TODO: export task
|
||||
case QueueJob.PDF_EXPORT_TASK:
|
||||
await this.processExportTask(job.data.fileTaskId);
|
||||
break;
|
||||
case QueueJob.PDF_EXPORT_CLEANUP:
|
||||
await this.processExportCleanup();
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -33,6 +41,24 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private getPdfExportService() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const PdfExportModule = require('./../../../ee/pdf-export/pdf-export.service');
|
||||
return this.moduleRef.get(PdfExportModule.PdfExportService, {
|
||||
strict: false,
|
||||
});
|
||||
}
|
||||
|
||||
private async processExportTask(fileTaskId: string): Promise<void> {
|
||||
const pdfExportService = this.getPdfExportService();
|
||||
await pdfExportService.generateAndStorePdf(fileTaskId);
|
||||
}
|
||||
|
||||
private async processExportCleanup(): Promise<void> {
|
||||
const pdfExportService = this.getPdfExportService();
|
||||
await pdfExportService.cleanupExpiredExports();
|
||||
}
|
||||
|
||||
@OnWorkerEvent('active')
|
||||
onActive(job: Job) {
|
||||
this.logger.debug(`Processing ${job.name} job`);
|
||||
@@ -40,33 +66,46 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
|
||||
|
||||
@OnWorkerEvent('failed')
|
||||
async onFailed(job: Job) {
|
||||
const fileTaskId = job.data?.fileTaskId;
|
||||
this.logger.error(
|
||||
`Error processing ${job.name} job. Import Task ID: ${job.data.fileTaskId}. Reason: ${job.failedReason}`,
|
||||
fileTaskId
|
||||
? `Error processing ${job.name} job. File Task ID: ${fileTaskId}. Reason: ${job.failedReason}`
|
||||
: `Error processing ${job.name} job. Reason: ${job.failedReason}`,
|
||||
);
|
||||
|
||||
await this.handleFailedJob(job);
|
||||
if (job.name === QueueJob.IMPORT_TASK) {
|
||||
await this.handleFailedImportJob(job);
|
||||
} else if (job.name === QueueJob.PDF_EXPORT_TASK) {
|
||||
await this.handleFailedExportJob(job);
|
||||
}
|
||||
}
|
||||
|
||||
@OnWorkerEvent('completed')
|
||||
async onCompleted(job: Job) {
|
||||
const fileTaskId = job.data?.fileTaskId;
|
||||
this.logger.log(
|
||||
`Completed ${job.name} job for File task ID ${job.data.fileTaskId}`,
|
||||
fileTaskId
|
||||
? `Completed ${job.name} job for File task ID ${fileTaskId}`
|
||||
: `Completed ${job.name} job`,
|
||||
);
|
||||
|
||||
try {
|
||||
const fileTask = await this.fileTaskService.getFileTask(
|
||||
job.data.fileTaskId,
|
||||
);
|
||||
if (fileTask) {
|
||||
await this.storageService.delete(fileTask.filePath);
|
||||
this.logger.debug(`Deleted imported zip file: ${fileTask.filePath}`);
|
||||
if (job.name === QueueJob.IMPORT_TASK) {
|
||||
try {
|
||||
const fileTask = await this.fileTaskService.getFileTask(
|
||||
job.data.fileTaskId,
|
||||
);
|
||||
if (fileTask) {
|
||||
await this.storageService.delete(fileTask.filePath);
|
||||
this.logger.debug(`Deleted imported zip file: ${fileTask.filePath}`);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to delete imported zip file:`, err);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to delete imported zip file:`, err);
|
||||
}
|
||||
// Export tasks: do NOT delete the file on completion (kept for 24h cache)
|
||||
}
|
||||
|
||||
private async handleFailedJob(job: Job) {
|
||||
private async handleFailedImportJob(job: Job) {
|
||||
try {
|
||||
const fileTaskId = job.data.fileTaskId;
|
||||
const reason = job.failedReason || 'Unknown error';
|
||||
@@ -86,6 +125,25 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleFailedExportJob(job: Job) {
|
||||
try {
|
||||
const fileTaskId = job.data.fileTaskId;
|
||||
const reason = job.failedReason || 'Unknown error';
|
||||
|
||||
await this.db
|
||||
.updateTable('fileTasks')
|
||||
.set({
|
||||
status: FileTaskStatus.Failed,
|
||||
errorMessage: reason,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where('id', '=', fileTaskId)
|
||||
.execute();
|
||||
} catch (err) {
|
||||
this.logger.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
if (this.worker) {
|
||||
await this.worker.close();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import * as path from 'path';
|
||||
import { jsonToText } from '../../../collaboration/collaboration.util';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
buildAttachmentCandidates,
|
||||
collectMarkdownAndHtmlFiles,
|
||||
encodeFilePath,
|
||||
extractNotionPartialId,
|
||||
readDocmostMetadata,
|
||||
stripNotionID,
|
||||
} from '../utils/import.utils';
|
||||
@@ -36,6 +37,11 @@ import { PageService } from '../../../core/page/services/page.service';
|
||||
import { ImportPageNode } from '../dto/file-task-dto';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventName } from '../../../common/events/event.contants';
|
||||
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
|
||||
import {
|
||||
AUDIT_SERVICE,
|
||||
IAuditService,
|
||||
} from '../../../integrations/audit/audit.service';
|
||||
|
||||
@Injectable()
|
||||
export class FileImportTaskService {
|
||||
@@ -50,6 +56,7 @@ export class FileImportTaskService {
|
||||
private readonly importAttachmentService: ImportAttachmentService,
|
||||
private moduleRef: ModuleRef,
|
||||
private eventEmitter: EventEmitter2,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
|
||||
async processZIpImport(fileTaskId: string): Promise<void> {
|
||||
@@ -154,10 +161,17 @@ export class FileImportTaskService {
|
||||
fileTask: FileTask;
|
||||
}): Promise<void> {
|
||||
const { extractDir, fileTask } = opts;
|
||||
const isNotion = fileTask.source === FileImportSource.Notion;
|
||||
const allFiles = await collectMarkdownAndHtmlFiles(extractDir);
|
||||
const attachmentCandidates = await buildAttachmentCandidates(extractDir);
|
||||
const docmostMetadata = await readDocmostMetadata(extractDir);
|
||||
|
||||
const space = await this.db
|
||||
.selectFrom('spaces')
|
||||
.select(['slug'])
|
||||
.where('id', '=', fileTask.spaceId)
|
||||
.executeTakeFirst();
|
||||
|
||||
const pagesMap = new Map<string, ImportPageNode>();
|
||||
|
||||
for (const absPath of allFiles) {
|
||||
@@ -218,7 +232,17 @@ export class FileImportTaskService {
|
||||
}
|
||||
|
||||
// For each folder with content, create a placeholder page if no corresponding .md or .html exists
|
||||
foldersWithContent.forEach((folderPath) => {
|
||||
// Process folders with partial UUIDs first so they claim their specific files
|
||||
// before plain folders (without partial UUIDs) take whatever remains.
|
||||
const sortedFolders = isNotion
|
||||
? [...foldersWithContent].sort((a, b) => {
|
||||
const aHasPartial = extractNotionPartialId(path.basename(a)) ? 0 : 1;
|
||||
const bHasPartial = extractNotionPartialId(path.basename(b)) ? 0 : 1;
|
||||
return aHasPartial - bHasPartial;
|
||||
})
|
||||
: [...foldersWithContent];
|
||||
|
||||
sortedFolders.forEach((folderPath) => {
|
||||
if (
|
||||
skipRootFolder &&
|
||||
folderPath?.toLowerCase() === skipRootFolder?.toLowerCase()
|
||||
@@ -231,18 +255,54 @@ export class FileImportTaskService {
|
||||
|
||||
if (!pagesMap.has(mdPath) && !pagesMap.has(htmlPath)) {
|
||||
const folderName = path.basename(folderPath);
|
||||
const encodedMdPath = encodeFilePath(mdPath);
|
||||
const placeholderMetadata = docmostMetadata?.pages[encodedMdPath];
|
||||
pagesMap.set(mdPath, {
|
||||
id: v7(),
|
||||
slugId: generateSlugId(),
|
||||
name: stripNotionID(folderName),
|
||||
content: '',
|
||||
parentPageId: null,
|
||||
fileExtension: '.md',
|
||||
filePath: mdPath,
|
||||
icon: placeholderMetadata?.icon ?? null,
|
||||
});
|
||||
const parentDir = path.dirname(folderPath);
|
||||
|
||||
// Notion no longer adds UUIDs to folder names, but still adds them to files.
|
||||
// For duplicate names, Notion adds a partial UUID "{first4}-{last4}" to the folder.
|
||||
let matched = false;
|
||||
if (isNotion) {
|
||||
const partialId = extractNotionPartialId(folderName);
|
||||
const strippedFolderName = stripNotionID(folderName);
|
||||
const isSameDir = (fileDir: string) =>
|
||||
fileDir === parentDir || (parentDir === '.' && !fileDir.includes('/'));
|
||||
|
||||
for (const [filePath, page] of pagesMap.entries()) {
|
||||
if (!isSameDir(path.dirname(filePath))) continue;
|
||||
if (page.name !== strippedFolderName) continue;
|
||||
|
||||
if (partialId) {
|
||||
// Match partial UUID against the full UUID in the filename
|
||||
const fileBase = path.basename(filePath, path.extname(filePath));
|
||||
const fullIdMatch = fileBase.match(/[a-f0-9]{32}$/i);
|
||||
if (!fullIdMatch) continue;
|
||||
const fullId = fullIdMatch[0].toLowerCase();
|
||||
if (!fullId.startsWith(partialId.prefix) || !fullId.endsWith(partialId.suffix)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
pagesMap.delete(filePath);
|
||||
page.filePath = mdPath;
|
||||
pagesMap.set(mdPath, page);
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!matched) {
|
||||
const encodedMdPath = encodeFilePath(mdPath);
|
||||
const placeholderMetadata = docmostMetadata?.pages[encodedMdPath];
|
||||
pagesMap.set(mdPath, {
|
||||
id: v7(),
|
||||
slugId: generateSlugId(),
|
||||
name: stripNotionID(folderName),
|
||||
content: '',
|
||||
parentPageId: null,
|
||||
fileExtension: '.md',
|
||||
filePath: mdPath,
|
||||
icon: placeholderMetadata?.icon ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -402,6 +462,7 @@ export class FileImportTaskService {
|
||||
// Process pages level by level sequentially to respect foreign key constraints
|
||||
const allBacklinks: any[] = [];
|
||||
const validPageIds = new Set<string>();
|
||||
const pageTitles = new Map<string, string>();
|
||||
let totalPagesProcessed = 0;
|
||||
|
||||
// Sort levels to process in order
|
||||
@@ -451,6 +512,7 @@ export class FileImportTaskService {
|
||||
creatorId: fileTask.creatorId,
|
||||
sourcePageId: page.id,
|
||||
workspaceId: fileTask.workspaceId,
|
||||
spaceSlug: space?.slug,
|
||||
});
|
||||
|
||||
const pmState = getProsemirrorContent(
|
||||
@@ -478,8 +540,9 @@ export class FileImportTaskService {
|
||||
|
||||
await trx.insertInto('pages').values(insertablePage).execute();
|
||||
|
||||
// Track valid page IDs and collect backlinks
|
||||
// Track valid page IDs, titles, and collect backlinks
|
||||
validPageIds.add(insertablePage.id);
|
||||
pageTitles.set(insertablePage.id, insertablePage.title);
|
||||
allBacklinks.push(...backlinks);
|
||||
totalPagesProcessed++;
|
||||
|
||||
@@ -522,6 +585,26 @@ export class FileImportTaskService {
|
||||
`Successfully imported ${totalPagesProcessed} pages with ${filteredBacklinks.length} backlinks`,
|
||||
);
|
||||
});
|
||||
|
||||
if (validPageIds.size > 0) {
|
||||
const auditPayloads = Array.from(validPageIds).map((pageId) => ({
|
||||
event: AuditEvent.PAGE_CREATED,
|
||||
resourceType: AuditResource.PAGE,
|
||||
resourceId: pageId,
|
||||
spaceId: fileTask.spaceId,
|
||||
metadata: {
|
||||
source: fileTask.source,
|
||||
fileTaskId: fileTask.id,
|
||||
title: pageTitles.get(pageId),
|
||||
},
|
||||
}));
|
||||
|
||||
this.auditService.logBatchWithContext(auditPayloads, {
|
||||
workspaceId: fileTask.workspaceId,
|
||||
actorId: fileTask.creatorId,
|
||||
actorType: 'user',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to import files:', error);
|
||||
throw new Error(`File import failed: ${error?.['message']}`);
|
||||
|
||||
@@ -14,6 +14,7 @@ import { getAttachmentFolderPath } from '../../../core/attachment/attachment.uti
|
||||
import { AttachmentType } from '../../../core/attachment/attachment.constants';
|
||||
import { unwrapFromParagraph } from '../utils/import-formatter';
|
||||
import { resolveRelativeAttachmentPath } from '../utils/import.utils';
|
||||
import { imageDimensionsFromData } from 'image-dimensions';
|
||||
import { load } from 'cheerio';
|
||||
import pLimit from 'p-limit';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
@@ -189,13 +190,41 @@ export class ImportAttachmentService {
|
||||
}
|
||||
}
|
||||
|
||||
// Build a map from resolved archive path → real filename from Confluence
|
||||
// metadata. Confluence Server archives often store files under numeric IDs
|
||||
// (e.g. "attachments/65601/65602") instead of the original filename.
|
||||
// Also register aliases so HTML references using the original filename
|
||||
// (e.g. "attachments/pageId/original.mp3") resolve to the numeric path.
|
||||
const pageDir = path.dirname(pageRelativePath);
|
||||
const attachmentNameByRelPath = new Map<string, string>();
|
||||
for (const attachment of pageAttachments) {
|
||||
const relPath = resolveRelativeAttachmentPath(
|
||||
attachment.href,
|
||||
pageDir,
|
||||
attachmentCandidates,
|
||||
);
|
||||
if (relPath && attachment.fileName) {
|
||||
attachmentNameByRelPath.set(relPath, attachment.fileName);
|
||||
|
||||
const dir = path.posix.dirname(relPath);
|
||||
const aliasKey = `${dir}/${attachment.fileName}`;
|
||||
if (!attachmentCandidates.has(aliasKey)) {
|
||||
attachmentCandidates.set(aliasKey, attachmentCandidates.get(relPath)!);
|
||||
attachmentNameByRelPath.set(aliasKey, attachment.fileName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const uploadOnce = (relPath: string) => {
|
||||
const abs = attachmentCandidates.get(relPath)!;
|
||||
const attachmentId = v7();
|
||||
const ext = path.extname(abs);
|
||||
|
||||
const realName = attachmentNameByRelPath.get(relPath);
|
||||
const baseName = realName || path.basename(abs);
|
||||
const ext = path.extname(baseName);
|
||||
|
||||
const fileNameWithExt =
|
||||
sanitizeFileName(path.basename(abs, ext)) + ext.toLowerCase();
|
||||
sanitizeFileName(path.basename(baseName, ext)) + ext.toLowerCase();
|
||||
|
||||
const storageFilePath = `${getAttachmentFolderPath(
|
||||
AttachmentType.File,
|
||||
@@ -239,7 +268,6 @@ export class ImportAttachmentService {
|
||||
return fresh;
|
||||
};
|
||||
|
||||
const pageDir = path.dirname(pageRelativePath);
|
||||
const $ = load(html);
|
||||
|
||||
// image
|
||||
@@ -271,15 +299,37 @@ export class ImportAttachmentService {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { attachmentId, apiFilePath } = processFile(relPath);
|
||||
const { attachmentId, apiFilePath, abs } = processFile(relPath);
|
||||
|
||||
const width = $img.attr('width') ?? '100%';
|
||||
let width = $img.attr('width');
|
||||
const height = $img.attr('height');
|
||||
const align = $img.attr('data-align') ?? 'center';
|
||||
|
||||
if (!width) {
|
||||
try {
|
||||
const buf = await fs.readFile(abs);
|
||||
const natural = imageDimensionsFromData(new Uint8Array(buf));
|
||||
if (natural) {
|
||||
width = height
|
||||
? String(
|
||||
Math.round((natural.width / natural.height) * Number(height)),
|
||||
)
|
||||
: String(natural.width);
|
||||
}
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
|
||||
if (!width) {
|
||||
width = '600';
|
||||
}
|
||||
}
|
||||
|
||||
$img
|
||||
.attr('src', apiFilePath)
|
||||
.attr('data-attachment-id', attachmentId)
|
||||
.attr('width', width)
|
||||
.attr('height', height)
|
||||
.attr('data-align', align);
|
||||
|
||||
unwrapFromParagraph($, $img);
|
||||
@@ -312,6 +362,28 @@ export class ImportAttachmentService {
|
||||
unwrapFromParagraph($, $vid);
|
||||
}
|
||||
|
||||
// audio
|
||||
for (const audEl of $('audio').toArray()) {
|
||||
const $aud = $(audEl);
|
||||
const src = cleanUrlString($aud.attr('src') ?? '')!;
|
||||
if (!src || src.startsWith('http')) continue;
|
||||
|
||||
const relPath = resolveRelativeAttachmentPath(
|
||||
src,
|
||||
pageDir,
|
||||
attachmentCandidates,
|
||||
);
|
||||
if (!relPath) continue;
|
||||
|
||||
const { attachmentId, apiFilePath } = processFile(relPath);
|
||||
|
||||
$aud
|
||||
.attr('src', apiFilePath)
|
||||
.attr('data-attachment-id', attachmentId);
|
||||
|
||||
unwrapFromParagraph($, $aud);
|
||||
}
|
||||
|
||||
// <div data-type="attachment">
|
||||
for (const el of $('div[data-type="attachment"]').toArray()) {
|
||||
const $oldDiv = $(el);
|
||||
@@ -378,7 +450,18 @@ export class ImportAttachmentService {
|
||||
const { attachmentId, apiFilePath, abs } = processFile(relPath);
|
||||
const ext = path.extname(relPath).toLowerCase();
|
||||
|
||||
if (ext === '.mp4') {
|
||||
const audioExtensions = new Set(['.mp3', '.wav', '.ogg', '.m4a', '.webm', '.flac', '.aac']);
|
||||
|
||||
if (ext === '.pdf') {
|
||||
const $pdf = $('<div>')
|
||||
.attr('data-type', 'pdf')
|
||||
.attr('src', apiFilePath)
|
||||
.attr('data-attachment-id', attachmentId)
|
||||
.attr('width', '800')
|
||||
.attr('height', '600');
|
||||
$a.replaceWith($pdf);
|
||||
unwrapFromParagraph($, $pdf);
|
||||
} else if (ext === '.mp4') {
|
||||
const $video = $('<video>')
|
||||
.attr('src', apiFilePath)
|
||||
.attr('data-attachment-id', attachmentId)
|
||||
@@ -386,6 +469,12 @@ export class ImportAttachmentService {
|
||||
.attr('data-align', 'center');
|
||||
$a.replaceWith($video);
|
||||
unwrapFromParagraph($, $video);
|
||||
} else if (audioExtensions.has(ext)) {
|
||||
const $audio = $('<audio>')
|
||||
.attr('src', apiFilePath)
|
||||
.attr('data-attachment-id', attachmentId);
|
||||
$a.replaceWith($audio);
|
||||
unwrapFromParagraph($, $audio);
|
||||
} else {
|
||||
const confAliasName = $a.attr('data-linked-resource-default-alias');
|
||||
let attachmentName = path.basename(abs);
|
||||
@@ -420,7 +509,7 @@ export class ImportAttachmentService {
|
||||
const { attachmentId, apiFilePath, abs } = processFile(relPath);
|
||||
const fileName = path.basename(abs);
|
||||
|
||||
const width = $oldDiv.attr('data-width') || '100%';
|
||||
const width = $oldDiv.attr('data-width') || '600';
|
||||
const align = $oldDiv.attr('data-align') || 'center';
|
||||
|
||||
const $newDiv = $('<div>')
|
||||
@@ -460,7 +549,7 @@ export class ImportAttachmentService {
|
||||
.attr('data-type', 'drawio')
|
||||
.attr('data-src', drawioSvg.apiFilePath)
|
||||
.attr('data-title', 'diagram')
|
||||
.attr('data-width', '100%')
|
||||
.attr('data-width', '600')
|
||||
.attr('data-align', 'center')
|
||||
.attr('data-attachment-id', drawioSvg.attachmentId);
|
||||
|
||||
@@ -482,18 +571,31 @@ export class ImportAttachmentService {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if already processed (was referenced in HTML)
|
||||
if (processed.has(href)) {
|
||||
continue;
|
||||
}
|
||||
// Resolve the metadata href to the actual archive path
|
||||
const resolvedHref = resolveRelativeAttachmentPath(
|
||||
href,
|
||||
pageDir,
|
||||
attachmentCandidates,
|
||||
);
|
||||
if (!resolvedHref) continue;
|
||||
|
||||
// Skip if the file doesn't exist
|
||||
if (!attachmentCandidates.has(href)) {
|
||||
// Check if already processed (was referenced in HTML).
|
||||
// Inline elements may have been processed under an alias key (original
|
||||
// filename) rather than the numeric archive path, so also check whether
|
||||
// the underlying absolute file path has already been uploaded.
|
||||
const absPath = attachmentCandidates.get(resolvedHref);
|
||||
const alreadyProcessed =
|
||||
processed.has(resolvedHref) ||
|
||||
(absPath &&
|
||||
Array.from(processed.values()).some(
|
||||
(entry) => entry.abs === absPath,
|
||||
));
|
||||
if (alreadyProcessed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// This attachment was in the list but not referenced in HTML - add it
|
||||
const { attachmentId, apiFilePath, abs } = processFile(href);
|
||||
const { attachmentId, apiFilePath, abs } = processFile(resolvedHref);
|
||||
const mime = mimeType || getMimeType(abs);
|
||||
|
||||
// Add as attachment node at the end
|
||||
@@ -531,7 +633,9 @@ export class ImportAttachmentService {
|
||||
|
||||
// Post-process DOM elements to add file sizes after uploads complete
|
||||
// This avoids blocking file operations during initial DOM processing
|
||||
const elementsNeedingSize = $('[data-attachment-id]:not([data-size])');
|
||||
const elementsNeedingSize = $(
|
||||
'[data-attachment-id]:not([data-attachment-size]):not([data-size])',
|
||||
);
|
||||
for (const element of elementsNeedingSize.toArray()) {
|
||||
const $el = $(element);
|
||||
const attachmentId = $el.attr('data-attachment-id');
|
||||
@@ -545,7 +649,14 @@ export class ImportAttachmentService {
|
||||
if (processedEntry) {
|
||||
try {
|
||||
const stat = await fs.stat(processedEntry.abs);
|
||||
$el.attr('data-size', stat.size.toString());
|
||||
const sizeStr = stat.size.toString();
|
||||
const tagName = $el.prop('tagName')?.toLowerCase();
|
||||
// audio and pdf nodes use data-size, attachment nodes use data-attachment-size
|
||||
if (tagName === 'audio' || $el.attr('data-type') === 'pdf') {
|
||||
$el.attr('data-size', sizeStr);
|
||||
} else {
|
||||
$el.attr('data-attachment-size', sizeStr);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.debug(
|
||||
`Could not get size for ${processedEntry.abs}:`,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { MultipartFile } from '@fastify/multipart';
|
||||
import { sanitize } from 'sanitize-filename-ts';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
htmlToJson,
|
||||
@@ -30,6 +29,8 @@ import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Queue } from 'bullmq';
|
||||
import { QueueJob, QueueName } from '../../queue/constants';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { load } from 'cheerio';
|
||||
import { normalizeImportHtml } from '../utils/import-formatter';
|
||||
|
||||
@Injectable()
|
||||
export class ImportService {
|
||||
@@ -49,12 +50,12 @@ export class ImportService {
|
||||
userId: string,
|
||||
spaceId: string,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
) {
|
||||
const file = await filePromise;
|
||||
const fileBuffer = await file.toBuffer();
|
||||
const fileExtension = path.extname(file.filename).toLowerCase();
|
||||
const fileName = sanitize(
|
||||
path.basename(file.filename, fileExtension).slice(0, 255),
|
||||
const fileName = sanitizeFileName(
|
||||
path.basename(file.filename, fileExtension),
|
||||
);
|
||||
const fileContent = fileBuffer.toString();
|
||||
|
||||
@@ -62,7 +63,10 @@ export class ImportService {
|
||||
let createdPage = null;
|
||||
|
||||
// For DOCX, we need the page ID upfront so images can reference it
|
||||
const pageId = fileExtension === '.docx' ? uuid7() : undefined;
|
||||
const pageId =
|
||||
fileExtension === '.docx' || fileExtension === '.pdf'
|
||||
? uuid7()
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
if (fileExtension.endsWith('.md')) {
|
||||
@@ -77,6 +81,14 @@ export class ImportService {
|
||||
pageId,
|
||||
userId,
|
||||
);
|
||||
} else if (fileExtension.endsWith('.pdf')) {
|
||||
prosemirrorState = await this.processPdf(
|
||||
fileBuffer,
|
||||
workspaceId,
|
||||
spaceId,
|
||||
pageId,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = 'Error processing file content';
|
||||
@@ -137,7 +149,9 @@ export class ImportService {
|
||||
|
||||
async processHTML(htmlInput: string): Promise<any> {
|
||||
try {
|
||||
return htmlToJson(htmlInput);
|
||||
const $ = load(htmlInput);
|
||||
normalizeImportHtml($, $.root());
|
||||
return htmlToJson($.html() || '');
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
@@ -153,7 +167,7 @@ export class ImportService {
|
||||
let DocxImportModule: any;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
DocxImportModule = require('./../../../ee/docx-import/docx-import.service');
|
||||
DocxImportModule = require('./../../../ee/document-import/docx-import.service');
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
'DOCX import requested but EE module not bundled in this build',
|
||||
@@ -179,6 +193,42 @@ export class ImportService {
|
||||
return this.processHTML(html);
|
||||
}
|
||||
|
||||
async processPdf(
|
||||
fileBuffer: Buffer,
|
||||
workspaceId: string,
|
||||
spaceId: string,
|
||||
pageId: string,
|
||||
userId: string,
|
||||
): Promise<any> {
|
||||
let PdfImportModule: any;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
PdfImportModule = require('./../../../ee/document-import/pdf-import.service');
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
'PDF import requested but EE module not bundled in this build',
|
||||
);
|
||||
throw new BadRequestException(
|
||||
'This feature requires a valid enterprise license.',
|
||||
);
|
||||
}
|
||||
|
||||
const pdfImportService = this.moduleRef.get(
|
||||
PdfImportModule.PdfImportService,
|
||||
{ strict: false },
|
||||
);
|
||||
|
||||
const html = await pdfImportService.convertPdfToHtml(
|
||||
fileBuffer,
|
||||
workspaceId,
|
||||
spaceId,
|
||||
pageId,
|
||||
userId,
|
||||
);
|
||||
|
||||
return this.processHTML(html);
|
||||
}
|
||||
|
||||
async createYdoc(prosemirrorJson: any): Promise<Buffer | null> {
|
||||
if (prosemirrorJson) {
|
||||
// this.logger.debug(`Converting prosemirror json state to ydoc`);
|
||||
|
||||
@@ -4,6 +4,8 @@ import * as path from 'path';
|
||||
import { v7 } from 'uuid';
|
||||
import { InsertableBacklink } from '@docmost/db/types/entity.types';
|
||||
import { Cheerio, CheerioAPI, load } from 'cheerio';
|
||||
import slugify from '@sindresorhus/slugify';
|
||||
import { normalizeTableColumnWidths } from './table-utils';
|
||||
|
||||
// Check if text contains Unicode characters (for emojis/icons)
|
||||
function isUnicodeCharacter(text: string): boolean {
|
||||
@@ -22,6 +24,7 @@ export async function formatImportHtml(opts: {
|
||||
workspaceId: string;
|
||||
pageDir?: string;
|
||||
attachmentCandidates?: string[];
|
||||
spaceSlug?: string;
|
||||
}): Promise<{
|
||||
html: string;
|
||||
backlinks: InsertableBacklink[];
|
||||
@@ -49,8 +52,7 @@ export async function formatImportHtml(opts: {
|
||||
}
|
||||
}
|
||||
|
||||
notionFormatter($, $root);
|
||||
defaultHtmlFormatter($, $root);
|
||||
normalizeImportHtml($, $root);
|
||||
|
||||
const backlinks = await rewriteInternalLinksToMentionHtml(
|
||||
$,
|
||||
@@ -60,6 +62,7 @@ export async function formatImportHtml(opts: {
|
||||
creatorId,
|
||||
sourcePageId,
|
||||
workspaceId,
|
||||
opts.spaceSlug,
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -69,7 +72,34 @@ export async function formatImportHtml(opts: {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Contextless HTML cleanup shared by every import path.
|
||||
* - notionFormatter: no-op on non-Notion HTML (class-selector-based).
|
||||
* - xwikiFormatter: no-op on non-XWiki HTML (looks for #xwikicontent).
|
||||
* - defaultHtmlFormatter: table column widths + provider auto-embeds.
|
||||
*
|
||||
* Does NOT run rewriteInternalLinksToMentionHtml — that requires zip context.
|
||||
*/
|
||||
export function normalizeImportHtml(
|
||||
$: CheerioAPI,
|
||||
$root: Cheerio<any>,
|
||||
): void {
|
||||
notionFormatter($, $root);
|
||||
xwikiFormatter($, $root);
|
||||
defaultHtmlFormatter($, $root);
|
||||
}
|
||||
|
||||
export function xwikiFormatter($: CheerioAPI, $root: Cheerio<any>) {
|
||||
const $content = $root.find('#xwikicontent');
|
||||
if ($content.length) {
|
||||
$root.children().remove();
|
||||
$root.append($content.contents());
|
||||
}
|
||||
}
|
||||
|
||||
export function defaultHtmlFormatter($: CheerioAPI, $root: Cheerio<any>) {
|
||||
normalizeTableColumnWidths($, $root);
|
||||
|
||||
$root.find('a[href]').each((_, el) => {
|
||||
const $el = $(el);
|
||||
const url = $el.attr('href')!;
|
||||
@@ -90,6 +120,15 @@ export function defaultHtmlFormatter($: CheerioAPI, $root: Cheerio<any>) {
|
||||
});
|
||||
}
|
||||
|
||||
const COLUMN_LAYOUTS = [
|
||||
'',
|
||||
'',
|
||||
'two_equal',
|
||||
'three_equal',
|
||||
'four_equal',
|
||||
'five_equal',
|
||||
] as const;
|
||||
|
||||
export function notionFormatter($: CheerioAPI, $root: Cheerio<any>) {
|
||||
// remove page header icon and cover image
|
||||
$root.find('.page-header-icon').remove();
|
||||
@@ -100,6 +139,31 @@ export function notionFormatter($: CheerioAPI, $root: Cheerio<any>) {
|
||||
if (!$(el).text().trim()) $(el).remove();
|
||||
});
|
||||
|
||||
// columns
|
||||
$root.find('div.column-list').each((_, el) => {
|
||||
const $list = $(el);
|
||||
const $cols = $list.find('div.column');
|
||||
|
||||
if ($cols.length <= 1) {
|
||||
$list.replaceWith($cols.html() || '');
|
||||
return;
|
||||
}
|
||||
|
||||
const layout = COLUMN_LAYOUTS[$cols.length] ?? 'two_equal';
|
||||
let cells = '';
|
||||
$cols.each((_, col) => {
|
||||
const $col = $(col);
|
||||
$col.children('div[style*="display:contents"]').each((_, wrapper) => {
|
||||
$(wrapper).replaceWith($(wrapper).html() || '');
|
||||
});
|
||||
cells += `<div data-type="column">${$col.html()}</div>`;
|
||||
});
|
||||
|
||||
$list.replaceWith(
|
||||
`<div data-type="columns" data-layout="${layout}">${cells}</div>`,
|
||||
);
|
||||
});
|
||||
|
||||
// block math → mathBlock
|
||||
$root.find('figure.equation').each((_: any, fig: any) => {
|
||||
const $fig = $(fig);
|
||||
@@ -273,6 +337,7 @@ export async function rewriteInternalLinksToMentionHtml(
|
||||
creatorId: string,
|
||||
sourcePageId: string,
|
||||
workspaceId: string,
|
||||
spaceSlug?: string,
|
||||
): Promise<InsertableBacklink[]> {
|
||||
const normalize = (p: string) => p.replace(/\\/g, '/');
|
||||
const backlinks: InsertableBacklink[] = [];
|
||||
@@ -296,19 +361,37 @@ export async function rewriteInternalLinksToMentionHtml(
|
||||
);
|
||||
const meta = filePathToPageMetaMap.get(resolved);
|
||||
if (!meta) return;
|
||||
const mentionId = v7();
|
||||
const $mention = $('<span>')
|
||||
.attr({
|
||||
'data-type': 'mention',
|
||||
'data-id': mentionId,
|
||||
'data-entity-type': 'page',
|
||||
'data-entity-id': meta.id,
|
||||
'data-label': meta.title,
|
||||
'data-slug-id': meta.slugId,
|
||||
'data-creator-id': creatorId,
|
||||
})
|
||||
.text(meta.title);
|
||||
$a.replaceWith($mention);
|
||||
|
||||
const linkText = $a.text().trim();
|
||||
const titleMatch =
|
||||
linkText === meta.title ||
|
||||
linkText === meta.title?.trim();
|
||||
|
||||
if (titleMatch) {
|
||||
const mentionId = v7();
|
||||
const $mention = $('<span>')
|
||||
.attr({
|
||||
'data-type': 'mention',
|
||||
'data-id': mentionId,
|
||||
'data-entity-type': 'page',
|
||||
'data-entity-id': meta.id,
|
||||
'data-label': meta.title,
|
||||
'data-slug-id': meta.slugId,
|
||||
'data-creator-id': creatorId,
|
||||
})
|
||||
.text(meta.title);
|
||||
$a.replaceWith($mention);
|
||||
} else {
|
||||
const titleSlug = slugify(meta.title?.substring(0, 70) || 'untitled');
|
||||
const pageSlug = `${titleSlug}-${meta.slugId}`;
|
||||
const internalHref = spaceSlug
|
||||
? `/s/${spaceSlug}/p/${pageSlug}`
|
||||
: `/p/${pageSlug}`;
|
||||
|
||||
$a.attr('href', internalHref);
|
||||
$a.attr('data-internal', 'true');
|
||||
}
|
||||
|
||||
backlinks.push({ sourcePageId, targetPageId: meta.id, workspaceId });
|
||||
});
|
||||
|
||||
|
||||
@@ -41,6 +41,15 @@ export function resolveRelativeAttachmentPath(
|
||||
'ImportUtils',
|
||||
);
|
||||
}
|
||||
|
||||
// Confluence Server uses "/download/attachments/..." in HTML but the ZIP
|
||||
// stores files under "attachments/...". Strip the "download/" prefix so
|
||||
// the path can match candidates from the archive.
|
||||
const confluenceStripped = mainRel.replace(
|
||||
/^download\/attachments\//,
|
||||
'attachments/',
|
||||
);
|
||||
|
||||
const fallback = path
|
||||
.normalize(path.join(pageDir, mainRel))
|
||||
.split(path.sep)
|
||||
@@ -49,9 +58,13 @@ export function resolveRelativeAttachmentPath(
|
||||
if (attachmentCandidates.has(mainRel)) {
|
||||
return mainRel;
|
||||
}
|
||||
if (confluenceStripped !== mainRel && attachmentCandidates.has(confluenceStripped)) {
|
||||
return confluenceStripped;
|
||||
}
|
||||
if (attachmentCandidates.has(fallback)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -81,7 +94,25 @@ export async function collectMarkdownAndHtmlFiles(
|
||||
export function stripNotionID(fileName: string): string {
|
||||
// Handle optional separator (space or dash) + 32 alphanumeric chars at end
|
||||
const notionIdPattern = /[ -]?[a-z0-9]{32}$/i;
|
||||
return fileName.replace(notionIdPattern, '').trim();
|
||||
// Handle partial UUID format used for duplicate names: "Name abcd-ef12"
|
||||
const partialIdPattern = / [a-f0-9]{4}-[a-f0-9]{4}$/i;
|
||||
return fileName
|
||||
.replace(notionIdPattern, '')
|
||||
.replace(partialIdPattern, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a partial Notion UUID suffix from a folder name.
|
||||
* Notion adds "{first4}-{last4}" when multiple pages share the same title.
|
||||
* e.g. "Cool 324d-35ab" → { prefix: "324d", suffix: "35ab" }
|
||||
*/
|
||||
export function extractNotionPartialId(
|
||||
folderName: string,
|
||||
): { prefix: string; suffix: string } | null {
|
||||
const match = folderName.match(/ ([a-f0-9]{4})-([a-f0-9]{4})$/i);
|
||||
if (!match) return null;
|
||||
return { prefix: match[1].toLowerCase(), suffix: match[2].toLowerCase() };
|
||||
}
|
||||
|
||||
export function encodeFilePath(filePath: string): string {
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { CheerioAPI, Cheerio } from 'cheerio';
|
||||
|
||||
const DEFAULT_IMPORT_COL_WIDTH_PX = 150;
|
||||
|
||||
/**
|
||||
* Extracts a pixel-integer width from either the `width` attribute or
|
||||
* `style="width: Npx"` on a <col>/<td>/<th>. Returns null when absent,
|
||||
* non-numeric, or a non-px unit (em, %).
|
||||
*/
|
||||
function parsePixelWidth(el: Cheerio<any>): number | null {
|
||||
const attr = el.attr('width');
|
||||
if (attr) {
|
||||
const n = parseInt(attr, 10);
|
||||
if (Number.isFinite(n) && n > 0) return n;
|
||||
}
|
||||
const style = el.attr('style') || '';
|
||||
const m = style.match(/(?:^|;)\s*width\s*:\s*([\d.]+)\s*px/i);
|
||||
if (m) {
|
||||
const n = parseInt(m[1], 10);
|
||||
if (Number.isFinite(n) && n > 0) return n;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives per-column widths for a table, in visual column order.
|
||||
* Priority: <colgroup><col> → first-row cells' own width style.
|
||||
* Returns an array of length = number of columns, with null entries
|
||||
* for columns whose width couldn't be determined.
|
||||
*/
|
||||
function deriveColumnWidths(
|
||||
$: CheerioAPI,
|
||||
table: Cheerio<any>,
|
||||
): (number | null)[] | null {
|
||||
const cols = table.find('> colgroup > col');
|
||||
if (cols.length > 0) {
|
||||
const widths: (number | null)[] = [];
|
||||
cols.each(function () {
|
||||
widths.push(parsePixelWidth($(this)));
|
||||
});
|
||||
if (widths.some((w) => w !== null)) return widths;
|
||||
}
|
||||
|
||||
// Fallback: first row's cells.
|
||||
const firstRow = table.find('> tbody > tr, > thead > tr, > tr').first();
|
||||
if (!firstRow.length) return null;
|
||||
|
||||
const widths: (number | null)[] = [];
|
||||
firstRow.children('td, th').each(function () {
|
||||
const cell = $(this);
|
||||
const colspan = parseInt(cell.attr('colspan') || '1', 10) || 1;
|
||||
const w = parsePixelWidth(cell);
|
||||
for (let i = 0; i < colspan; i++) {
|
||||
widths.push(w !== null ? Math.round(w / colspan) : null);
|
||||
}
|
||||
});
|
||||
if (widths.every((w) => w === null)) return null;
|
||||
return widths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply colwidth attributes to the first row of each table based on
|
||||
* derived column widths. Accounts for colspan. Idempotent — re-running
|
||||
* on already-normalized markup is a no-op.
|
||||
*
|
||||
* This lives upstream of tiptap's generateJSON: tiptap reads
|
||||
* `colwidth="N[,N...]"` on <td>/<th> to build the runtime <colgroup>.
|
||||
*/
|
||||
export function normalizeTableColumnWidths(
|
||||
$: CheerioAPI,
|
||||
$root: Cheerio<any>,
|
||||
): void {
|
||||
$root.find('table').each(function () {
|
||||
const table = $(this);
|
||||
const firstRow = table.find('> tbody > tr, > thead > tr, > tr').first();
|
||||
if (!firstRow.length) return;
|
||||
|
||||
let colWidths = deriveColumnWidths($, table);
|
||||
if (!colWidths) {
|
||||
// No widths anywhere (e.g. markdown-sourced tables). Apply a default
|
||||
// per-column width so the table's intrinsic width can exceed the
|
||||
// editor container, letting .tableWrapper's overflow-x: auto scroll
|
||||
// instead of cramming columns into the available width.
|
||||
let count = 0;
|
||||
firstRow.children('td, th').each(function () {
|
||||
count += parseInt($(this).attr('colspan') || '1', 10) || 1;
|
||||
});
|
||||
if (count === 0) return;
|
||||
colWidths = new Array(count).fill(DEFAULT_IMPORT_COL_WIDTH_PX);
|
||||
}
|
||||
|
||||
let col = 0;
|
||||
firstRow.children('td, th').each(function () {
|
||||
const cell = $(this);
|
||||
if (cell.attr('colwidth')) {
|
||||
col += parseInt(cell.attr('colspan') || '1', 10) || 1;
|
||||
return;
|
||||
}
|
||||
const colspan = parseInt(cell.attr('colspan') || '1', 10) || 1;
|
||||
const slice = colWidths.slice(col, col + colspan);
|
||||
col += colspan;
|
||||
if (slice.length === 0 || slice.every((w) => w === null)) return;
|
||||
const values = slice.map((w) => (w == null ? 100 : w));
|
||||
cell.attr('colwidth', values.join(','));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { EnvironmentService } from '../environment/environment.service';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { QueueName, QueueJob } from '../queue/constants';
|
||||
import { Queue } from 'bullmq';
|
||||
import { render } from '@react-email/render';
|
||||
import { render } from 'react-email';
|
||||
|
||||
@Injectable()
|
||||
export class MailService {
|
||||
@@ -17,6 +17,10 @@ export class MailService {
|
||||
) {}
|
||||
|
||||
async sendEmail(message: MailMessage): Promise<void> {
|
||||
if (this.isRecipientBlocked(message.to)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.template) {
|
||||
// in case this method is used directly. we do not send the tsx template from queue
|
||||
message.html = await render(message.template, {
|
||||
@@ -35,6 +39,10 @@ export class MailService {
|
||||
}
|
||||
|
||||
async sendToQueue(message: MailMessage): Promise<void> {
|
||||
if (this.isRecipientBlocked(message.to)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.template) {
|
||||
// transform the React object because it gets lost when sent via the queue
|
||||
message.html = await render(message.template, {
|
||||
@@ -47,4 +55,11 @@ export class MailService {
|
||||
}
|
||||
await this.emailQueue.add(QueueJob.SEND_EMAIL, message);
|
||||
}
|
||||
|
||||
private isRecipientBlocked(to: string): boolean {
|
||||
const blocked = this.environmentService.getMailBlockedRecipientDomains();
|
||||
if (blocked.length === 0) return false;
|
||||
const domain = to?.split('@')[1]?.toLowerCase();
|
||||
return !!domain && blocked.includes(domain);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export enum QueueName {
|
||||
HISTORY_QUEUE = '{history-queue}',
|
||||
NOTIFICATION_QUEUE = '{notification-queue}',
|
||||
INTEGRATION_QUEUE = '{integration-queue}',
|
||||
AUDIT_QUEUE = '{audit-queue}',
|
||||
}
|
||||
|
||||
export enum QueueJob {
|
||||
@@ -17,6 +18,7 @@ export enum QueueJob {
|
||||
ATTACHMENT_INDEX_CONTENT = 'attachment-index-content',
|
||||
ATTACHMENT_INDEXING = 'attachment-indexing',
|
||||
DELETE_PAGE_ATTACHMENTS = 'delete-page-attachments',
|
||||
DELETE_AI_CHAT_ATTACHMENTS = 'delete-ai-chat-attachments',
|
||||
|
||||
DELETE_USER_AVATARS = 'delete-user-avatars',
|
||||
|
||||
@@ -68,6 +70,20 @@ export enum QueueJob {
|
||||
COMMENT_NOTIFICATION = 'comment-notification',
|
||||
COMMENT_RESOLVED_NOTIFICATION = 'comment-resolved-notification',
|
||||
PAGE_MENTION_NOTIFICATION = 'page-mention-notification',
|
||||
PAGE_PERMISSION_GRANTED = 'page-permission-granted',
|
||||
PAGE_UPDATE_DIGEST = 'page-update-digest',
|
||||
PAGE_VERIFICATION_EXPIRING = 'page-verification-expiring',
|
||||
PAGE_VERIFICATION_EXPIRED = 'page-verification-expired',
|
||||
VERIFICATION_RECONCILE = 'verification-reconcile',
|
||||
PAGE_VERIFIED_NOTIFICATION = 'page-verified-notification',
|
||||
PAGE_APPROVAL_REQUESTED_NOTIFICATION = 'page-approval-requested-notification',
|
||||
PAGE_APPROVAL_REJECTED_NOTIFICATION = 'page-approval-rejected-notification',
|
||||
|
||||
AUDIT_LOG = 'audit-log',
|
||||
AUDIT_CLEANUP = 'audit-cleanup',
|
||||
|
||||
PDF_EXPORT_TASK = 'pdf-export-task',
|
||||
PDF_EXPORT_CLEANUP = 'pdf-export-cleanup',
|
||||
|
||||
INTEGRATION_EVENT = 'integration-event',
|
||||
INTEGRATION_TOKEN_REFRESH = 'integration-token-refresh',
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { MentionNode } from "../../../common/helpers/prosemirror/utils";
|
||||
|
||||
import { MentionNode } from '../../../common/helpers/prosemirror/utils';
|
||||
|
||||
export interface IPageBacklinkJob {
|
||||
pageId: string;
|
||||
workspaceId: string;
|
||||
mentions: MentionNode[];
|
||||
internalLinkSlugIds?: string[];
|
||||
}
|
||||
|
||||
export interface IAddPageWatchersJob {
|
||||
@@ -60,3 +60,56 @@ export interface IPageMentionNotificationJob {
|
||||
spaceId: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface IPageUpdateNotificationJob {
|
||||
pageId: string;
|
||||
spaceId: string;
|
||||
workspaceId: string;
|
||||
actorIds: string[];
|
||||
}
|
||||
|
||||
export interface IPermissionGrantedNotificationJob {
|
||||
userIds: string[];
|
||||
pageId: string;
|
||||
spaceId: string;
|
||||
workspaceId: string;
|
||||
actorId: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface IVerificationExpiringNotificationJob {
|
||||
verificationId: string;
|
||||
}
|
||||
|
||||
export interface IVerificationExpiredNotificationJob {
|
||||
verificationId: string;
|
||||
}
|
||||
|
||||
export interface IVerificationReconcileJob {
|
||||
// no payload
|
||||
}
|
||||
|
||||
export interface IPageVerifiedNotificationJob {
|
||||
pageId: string;
|
||||
spaceId: string;
|
||||
workspaceId: string;
|
||||
actorId: string;
|
||||
verifierIds: string[];
|
||||
}
|
||||
|
||||
export interface IApprovalRequestedNotificationJob {
|
||||
pageId: string;
|
||||
spaceId: string;
|
||||
workspaceId: string;
|
||||
actorId: string;
|
||||
verifierIds: string[];
|
||||
}
|
||||
|
||||
export interface IApprovalRejectedNotificationJob {
|
||||
pageId: string;
|
||||
spaceId: string;
|
||||
workspaceId: string;
|
||||
actorId: string;
|
||||
requestedById: string;
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
@@ -84,6 +84,14 @@ import { GeneralQueueProcessor } from './processors/general-queue.processor';
|
||||
BullModule.registerQueue({
|
||||
name: QueueName.NOTIFICATION_QUEUE,
|
||||
}),
|
||||
BullModule.registerQueue({
|
||||
name: QueueName.AUDIT_QUEUE,
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
attempts: 3,
|
||||
},
|
||||
}),
|
||||
BullModule.registerQueue({
|
||||
name: QueueName.INTEGRATION_QUEUE,
|
||||
defaultJobOptions: {
|
||||
|
||||
@@ -11,7 +11,7 @@ export async function processBacklinks(
|
||||
backlinkRepo: BacklinkRepo,
|
||||
data: IPageBacklinkJob,
|
||||
): Promise<void> {
|
||||
const { pageId, mentions, workspaceId } = data;
|
||||
const { pageId, mentions, workspaceId, internalLinkSlugIds = [] } = data;
|
||||
|
||||
await executeTx(db, async (trx) => {
|
||||
const existingBacklinks = await trx
|
||||
@@ -20,7 +20,28 @@ export async function processBacklinks(
|
||||
.where('sourcePageId', '=', pageId)
|
||||
.execute();
|
||||
|
||||
if (existingBacklinks.length === 0 && mentions.length === 0) {
|
||||
const mentionTargetPageIds = mentions
|
||||
.filter((mention) => mention.entityId !== pageId)
|
||||
.map((mention) => mention.entityId);
|
||||
|
||||
let resolvedLinkPageIds: string[] = [];
|
||||
if (internalLinkSlugIds.length > 0) {
|
||||
const resolvedPages = await trx
|
||||
.selectFrom('pages')
|
||||
.select('id')
|
||||
.where('slugId', 'in', internalLinkSlugIds)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.execute();
|
||||
resolvedLinkPageIds = resolvedPages
|
||||
.map((p) => p.id)
|
||||
.filter((id) => id !== pageId);
|
||||
}
|
||||
|
||||
const allTargetPageIds = [
|
||||
...new Set([...mentionTargetPageIds, ...resolvedLinkPageIds]),
|
||||
];
|
||||
|
||||
if (existingBacklinks.length === 0 && allTargetPageIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -28,16 +49,12 @@ export async function processBacklinks(
|
||||
(backlink) => backlink.targetPageId,
|
||||
);
|
||||
|
||||
const targetPageIds = mentions
|
||||
.filter((mention) => mention.entityId !== pageId)
|
||||
.map((mention) => mention.entityId);
|
||||
|
||||
let validTargetPages = [];
|
||||
if (targetPageIds.length > 0) {
|
||||
if (allTargetPageIds.length > 0) {
|
||||
validTargetPages = await trx
|
||||
.selectFrom('pages')
|
||||
.select('id')
|
||||
.where('id', 'in', targetPageIds)
|
||||
.where('id', 'in', allTargetPageIds)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
@@ -71,7 +71,10 @@ export class StaticModule implements OnModuleInit {
|
||||
|
||||
app.get(RENDER_PATH, (req: any, res: any) => {
|
||||
const stream = fs.createReadStream(indexFilePath);
|
||||
res.type('text/html').send(stream);
|
||||
res
|
||||
.header('Cache-Control', 'no-cache, no-store, must-revalidate')
|
||||
.type('text/html')
|
||||
.send(stream);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { Readable } from 'stream';
|
||||
import {
|
||||
AzureStorageConfig,
|
||||
StorageDriver,
|
||||
StorageOption,
|
||||
} from '../interfaces';
|
||||
import {
|
||||
BlobSASPermissions,
|
||||
BlobServiceClient,
|
||||
BlockBlobClient,
|
||||
ContainerClient,
|
||||
generateBlobSASQueryParameters,
|
||||
SASProtocol,
|
||||
StorageSharedKeyCredential,
|
||||
} from '@azure/storage-blob';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { getMimeType } from '../../../common/helpers';
|
||||
|
||||
export class AzureDriver implements StorageDriver {
|
||||
private readonly config: AzureStorageConfig;
|
||||
private readonly blobServiceClient: BlobServiceClient;
|
||||
private readonly containerClient: ContainerClient;
|
||||
private readonly sharedKeyCredential: StorageSharedKeyCredential;
|
||||
private readonly accountUrl: string;
|
||||
|
||||
constructor(config: AzureStorageConfig) {
|
||||
this.config = config;
|
||||
|
||||
if (!config.accountName) {
|
||||
throw new Error('AzureDriver: accountName is required');
|
||||
}
|
||||
if (!config.container) {
|
||||
throw new Error('AzureDriver: container is required');
|
||||
}
|
||||
if (!config.accountKey) {
|
||||
throw new Error('AzureDriver: accountKey is required');
|
||||
}
|
||||
|
||||
this.accountUrl =
|
||||
config.endpoint ??
|
||||
`https://${config.accountName}.blob.core.windows.net`;
|
||||
|
||||
this.sharedKeyCredential = new StorageSharedKeyCredential(
|
||||
config.accountName,
|
||||
config.accountKey,
|
||||
);
|
||||
|
||||
this.blobServiceClient = this.createBlobServiceClient();
|
||||
this.containerClient = this.blobServiceClient.getContainerClient(
|
||||
config.container,
|
||||
);
|
||||
}
|
||||
|
||||
private blockBlob(filePath: string): BlockBlobClient {
|
||||
return this.containerClient.getBlockBlobClient(filePath);
|
||||
}
|
||||
|
||||
async upload(filePath: string, file: Buffer | Readable): Promise<void> {
|
||||
const stream: Readable = Buffer.isBuffer(file) ? Readable.from(file) : file;
|
||||
await this.uploadStream(filePath, stream);
|
||||
}
|
||||
|
||||
async uploadStream(
|
||||
filePath: string,
|
||||
file: Readable,
|
||||
options?: { recreateClient?: boolean },
|
||||
): Promise<void> {
|
||||
const clientToUse = options?.recreateClient
|
||||
? this.createBlobServiceClient()
|
||||
.getContainerClient(this.config.container)
|
||||
.getBlockBlobClient(filePath)
|
||||
: this.blockBlob(filePath);
|
||||
|
||||
try {
|
||||
const contentType = getMimeType(filePath);
|
||||
await clientToUse.uploadStream(file, undefined, undefined, {
|
||||
blobHTTPHeaders: { blobContentType: contentType },
|
||||
});
|
||||
} catch (err) {
|
||||
Logger.error(err);
|
||||
throw new Error(`Failed to upload file: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async copy(fromFilePath: string, toFilePath: string): Promise<void> {
|
||||
try {
|
||||
if (!(await this.exists(fromFilePath))) {
|
||||
return;
|
||||
}
|
||||
const sourceUrl = await this.getSignedUrl(fromFilePath, 60);
|
||||
const dest = this.blockBlob(toFilePath);
|
||||
await dest.syncCopyFromURL(sourceUrl);
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to copy file: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async read(filePath: string): Promise<Buffer> {
|
||||
try {
|
||||
return await this.blockBlob(filePath).downloadToBuffer();
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Failed to read file from Azure: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async readStream(filePath: string): Promise<Readable> {
|
||||
try {
|
||||
const response = await this.blockBlob(filePath).download();
|
||||
return response.readableStreamBody as Readable;
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Failed to read file from Azure: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async readRangeStream(
|
||||
filePath: string,
|
||||
range: { start: number; end: number },
|
||||
): Promise<Readable> {
|
||||
try {
|
||||
const count = range.end - range.start + 1;
|
||||
const response = await this.blockBlob(filePath).download(
|
||||
range.start,
|
||||
count,
|
||||
);
|
||||
return response.readableStreamBody as Readable;
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Failed to read file from Azure: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async exists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
return await this.blockBlob(filePath).exists();
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Failed to check existence in Azure: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getUrl(filePath: string): string {
|
||||
const base = this.config.baseUrl ?? this.accountUrl;
|
||||
return `${base}/${this.config.container}/${filePath}`;
|
||||
}
|
||||
|
||||
async getSignedUrl(filePath: string, expiresIn: number): Promise<string> {
|
||||
const expiresOn = new Date(Date.now() + expiresIn * 1000);
|
||||
const sas = generateBlobSASQueryParameters(
|
||||
{
|
||||
containerName: this.config.container,
|
||||
blobName: filePath,
|
||||
permissions: BlobSASPermissions.parse('r'),
|
||||
expiresOn,
|
||||
protocol: SASProtocol.HttpsAndHttp,
|
||||
},
|
||||
this.sharedKeyCredential,
|
||||
).toString();
|
||||
return `${this.accountUrl}/${this.config.container}/${filePath}?${sas}`;
|
||||
}
|
||||
|
||||
async delete(filePath: string): Promise<void> {
|
||||
try {
|
||||
await this.blockBlob(filePath).delete();
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Error deleting file ${filePath} from Azure: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getDriver(): BlobServiceClient {
|
||||
return this.blobServiceClient;
|
||||
}
|
||||
|
||||
getDriverName(): string {
|
||||
return StorageOption.AZURE;
|
||||
}
|
||||
|
||||
getConfig(): Record<string, any> {
|
||||
return this.config;
|
||||
}
|
||||
|
||||
private createBlobServiceClient(): BlobServiceClient {
|
||||
return new BlobServiceClient(this.accountUrl, this.sharedKeyCredential);
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export { LocalDriver } from './local.driver';
|
||||
export { S3Driver } from './s3.driver';
|
||||
export { AzureDriver } from './azure.driver';
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { resolve, sep } from 'path';
|
||||
import { LocalDriver } from './local.driver';
|
||||
|
||||
type FullPath = (filePath: string) => string;
|
||||
|
||||
describe('LocalDriver._fullPath', () => {
|
||||
const ROOT = resolve('/data/storage');
|
||||
const driver = new LocalDriver({ storagePath: ROOT });
|
||||
const fullPath = ((driver as any)._fullPath as FullPath).bind(driver);
|
||||
|
||||
describe('legitimate inputs (behavior preserved)', () => {
|
||||
it.each([
|
||||
['workspace-id/avatars/uuid.png', `${ROOT}${sep}workspace-id${sep}avatars${sep}uuid.png`],
|
||||
['workspace-id/files/uuid/file.pdf', `${ROOT}${sep}workspace-id${sep}files${sep}uuid${sep}file.pdf`],
|
||||
['a/b/c/d/e.bin', `${ROOT}${sep}a${sep}b${sep}c${sep}d${sep}e.bin`],
|
||||
['', ROOT],
|
||||
['.', ROOT],
|
||||
['./x/y.png', `${ROOT}${sep}x${sep}y.png`],
|
||||
['a//b', `${ROOT}${sep}a${sep}b`],
|
||||
['a/b/../c', `${ROOT}${sep}a${sep}c`],
|
||||
])('resolves %j to %j', (input, expected) => {
|
||||
expect(fullPath(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('traversal rejected', () => {
|
||||
it.each([
|
||||
'../etc/passwd',
|
||||
'../../../etc/passwd',
|
||||
'workspace/../../../etc/passwd',
|
||||
'..',
|
||||
'../..',
|
||||
'a/../../..',
|
||||
])('throws for %j', (input) => {
|
||||
expect(() => fullPath(input)).toThrow('Invalid file path');
|
||||
});
|
||||
});
|
||||
|
||||
describe('absolute path rejected', () => {
|
||||
it.each([
|
||||
'/etc/passwd',
|
||||
'/root/.ssh/id_rsa',
|
||||
sep + 'absolute',
|
||||
])('throws for %j', (input) => {
|
||||
expect(() => fullPath(input)).toThrow('Invalid file path');
|
||||
});
|
||||
});
|
||||
|
||||
describe('prefix-confusion rejected', () => {
|
||||
it('rejects a sibling directory whose name starts with the storage root', () => {
|
||||
const siblingDriver = new LocalDriver({ storagePath: '/data/storage' });
|
||||
const siblingFullPath = ((siblingDriver as any)._fullPath as FullPath).bind(siblingDriver);
|
||||
// Attempt to reach /data/storage-evil/secret by traversal:
|
||||
// resolve('/data/storage', '../storage-evil/secret') === '/data/storage-evil/secret'
|
||||
// Without the `+ sep` guard, a startsWith check would match.
|
||||
expect(() => siblingFullPath('../storage-evil/secret')).toThrow('Invalid file path');
|
||||
});
|
||||
});
|
||||
|
||||
describe('storage root itself', () => {
|
||||
it('accepts the root when input resolves to it', () => {
|
||||
expect(fullPath('')).toBe(ROOT);
|
||||
expect(fullPath('.')).toBe(ROOT);
|
||||
expect(fullPath('a/..')).toBe(ROOT);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
LocalStorageConfig,
|
||||
StorageOption,
|
||||
} from '../interfaces';
|
||||
import { join, dirname } from 'path';
|
||||
import { dirname, resolve, sep } from 'path';
|
||||
import * as fs from 'fs-extra';
|
||||
import { Readable } from 'stream';
|
||||
import { createReadStream, createWriteStream } from 'node:fs';
|
||||
@@ -17,7 +17,12 @@ export class LocalDriver implements StorageDriver {
|
||||
}
|
||||
|
||||
private _fullPath(filePath: string): string {
|
||||
return join(this.config.storagePath, filePath);
|
||||
const storageRoot = resolve(this.config.storagePath);
|
||||
const fullPath = resolve(storageRoot, filePath);
|
||||
if (fullPath !== storageRoot && !fullPath.startsWith(storageRoot + sep)) {
|
||||
throw new Error('Invalid file path');
|
||||
}
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
async upload(filePath: string, file: Buffer | Readable): Promise<void> {
|
||||
@@ -66,25 +71,25 @@ export class LocalDriver implements StorageDriver {
|
||||
}
|
||||
|
||||
async readStream(filePath: string): Promise<Readable> {
|
||||
try {
|
||||
return createReadStream(this._fullPath(filePath));
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to read file: ${(err as Error).message}`);
|
||||
const fullPath = this._fullPath(filePath);
|
||||
if (!(await fs.pathExists(fullPath))) {
|
||||
throw new Error(`File not found: ${filePath}`);
|
||||
}
|
||||
return createReadStream(fullPath);
|
||||
}
|
||||
|
||||
async readRangeStream(
|
||||
filePath: string,
|
||||
range: { start: number; end: number },
|
||||
): Promise<Readable> {
|
||||
try {
|
||||
return createReadStream(this._fullPath(filePath), {
|
||||
start: range.start,
|
||||
end: range.end,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to read file: ${(err as Error).message}`);
|
||||
const fullPath = this._fullPath(filePath);
|
||||
if (!(await fs.pathExists(fullPath))) {
|
||||
throw new Error(`File not found: ${filePath}`);
|
||||
}
|
||||
return createReadStream(fullPath, {
|
||||
start: range.start,
|
||||
end: range.end,
|
||||
});
|
||||
}
|
||||
|
||||
async exists(filePath: string): Promise<boolean> {
|
||||
|
||||
@@ -3,11 +3,13 @@ import { S3ClientConfig } from '@aws-sdk/client-s3';
|
||||
export enum StorageOption {
|
||||
LOCAL = 'local',
|
||||
S3 = 's3',
|
||||
AZURE = 'azure',
|
||||
}
|
||||
|
||||
export type StorageConfig =
|
||||
| { driver: StorageOption.LOCAL; config: LocalStorageConfig }
|
||||
| { driver: StorageOption.S3; config: S3StorageConfig };
|
||||
| { driver: StorageOption.S3; config: S3StorageConfig }
|
||||
| { driver: StorageOption.AZURE; config: AzureStorageConfig };
|
||||
|
||||
export interface LocalStorageConfig {
|
||||
storagePath: string;
|
||||
@@ -20,6 +22,14 @@ export interface S3StorageConfig
|
||||
baseUrl?: string; // Optional CDN URL for assets
|
||||
}
|
||||
|
||||
export interface AzureStorageConfig {
|
||||
accountName: string;
|
||||
container: string;
|
||||
accountKey: string;
|
||||
endpoint?: string;
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
export interface StorageOptions {
|
||||
disk: StorageConfig;
|
||||
}
|
||||
|
||||
@@ -4,13 +4,14 @@ import {
|
||||
} from '../constants/storage.constants';
|
||||
import { EnvironmentService } from '../../environment/environment.service';
|
||||
import {
|
||||
AzureStorageConfig,
|
||||
LocalStorageConfig,
|
||||
S3StorageConfig,
|
||||
StorageConfig,
|
||||
StorageDriver,
|
||||
StorageOption,
|
||||
} from '../interfaces';
|
||||
import { LocalDriver, S3Driver } from '../drivers';
|
||||
import { AzureDriver, LocalDriver, S3Driver } from '../drivers';
|
||||
import * as process from 'node:process';
|
||||
import { LOCAL_STORAGE_PATH } from '../../../common/helpers';
|
||||
import path from 'path';
|
||||
@@ -21,6 +22,8 @@ function createStorageDriver(disk: StorageConfig): StorageDriver {
|
||||
return new LocalDriver(disk.config as LocalStorageConfig);
|
||||
case StorageOption.S3:
|
||||
return new S3Driver(disk.config as S3StorageConfig);
|
||||
case StorageOption.AZURE:
|
||||
return new AzureDriver(disk.config as AzureStorageConfig);
|
||||
default:
|
||||
throw new Error(`Unknown storage driver`);
|
||||
}
|
||||
@@ -70,6 +73,18 @@ export const storageDriverConfigProvider = {
|
||||
|
||||
return s3Config; }
|
||||
|
||||
case StorageOption.AZURE:
|
||||
return {
|
||||
driver,
|
||||
config: {
|
||||
accountName: environmentService.getAzureStorageAccountName(),
|
||||
container: environmentService.getAzureStorageContainer(),
|
||||
accountKey: environmentService.getAzureStorageAccountKey(),
|
||||
endpoint: environmentService.getAzureStorageEndpoint() || undefined,
|
||||
baseUrl: environmentService.getAzureStorageUrl() || undefined,
|
||||
},
|
||||
};
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown storage driver: ${driver}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ThrottlerModule } from '@nestjs/throttler';
|
||||
import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis';
|
||||
import { EnvironmentService } from '../environment/environment.service';
|
||||
import { EnvironmentModule } from '../environment/environment.module';
|
||||
import { parseRedisUrl } from '../../common/helpers';
|
||||
import { AUTH_THROTTLER, AI_CHAT_THROTTLER } from './throttler-names';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ThrottlerModule.forRootAsync({
|
||||
imports: [EnvironmentModule],
|
||||
useFactory: (environmentService: EnvironmentService) => {
|
||||
const redisConfig = parseRedisUrl(environmentService.getRedisUrl());
|
||||
|
||||
return {
|
||||
throttlers: [
|
||||
{ name: AUTH_THROTTLER, ttl: 60_000, limit: 10 },
|
||||
{ name: AI_CHAT_THROTTLER, ttl: 60_000, limit: 25 },
|
||||
],
|
||||
errorMessage: 'Too many requests',
|
||||
storage: new ThrottlerStorageRedisService(
|
||||
new Redis({
|
||||
host: redisConfig.host,
|
||||
port: redisConfig.port,
|
||||
password: redisConfig.password,
|
||||
db: redisConfig.db,
|
||||
family: redisConfig.family,
|
||||
keyPrefix: 'throttle:',
|
||||
}),
|
||||
),
|
||||
};
|
||||
},
|
||||
inject: [EnvironmentService],
|
||||
}),
|
||||
],
|
||||
})
|
||||
export class ThrottleModule {}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const AUTH_THROTTLER = 'auth';
|
||||
export const AI_CHAT_THROTTLER = 'ai-chat';
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ThrottlerGuard } from '@nestjs/throttler';
|
||||
|
||||
type AuthedRequest = { user?: { id?: string } };
|
||||
|
||||
@Injectable()
|
||||
export class UserThrottlerGuard extends ThrottlerGuard {
|
||||
protected async getTracker(req: AuthedRequest): Promise<string> {
|
||||
const userId = req.user?.id;
|
||||
if (userId) return `user:${userId}`;
|
||||
return super.getTracker(req as Parameters<ThrottlerGuard['getTracker']>[0]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Section, Text } from 'react-email';
|
||||
import * as React from 'react';
|
||||
import { content, paragraph } from '../css/styles';
|
||||
import { EmailButton, MailBody } from '../partials/partials';
|
||||
|
||||
interface Props {
|
||||
actorName: string;
|
||||
pageTitle: string;
|
||||
spaceName: string;
|
||||
pageUrl: string;
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
export const ApprovalRejectedEmail = ({
|
||||
actorName,
|
||||
pageTitle,
|
||||
spaceName,
|
||||
pageUrl,
|
||||
comment,
|
||||
}: Props) => {
|
||||
return (
|
||||
<MailBody>
|
||||
<Section style={content}>
|
||||
<Text style={paragraph}>Hi there,</Text>
|
||||
<Text style={paragraph}>
|
||||
<strong>{actorName}</strong> returned{' '}
|
||||
<strong>{pageTitle}</strong> in the{' '}
|
||||
<strong>{spaceName}</strong> space for revision.
|
||||
</Text>
|
||||
{comment && (
|
||||
<Text style={{ ...paragraph, fontStyle: 'italic' }}>
|
||||
“{comment}”
|
||||
</Text>
|
||||
)}
|
||||
</Section>
|
||||
<EmailButton href={pageUrl}>View page</EmailButton>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApprovalRejectedEmail;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Section, Text } from 'react-email';
|
||||
import * as React from 'react';
|
||||
import { content, paragraph } from '../css/styles';
|
||||
import { EmailButton, MailBody } from '../partials/partials';
|
||||
|
||||
interface Props {
|
||||
actorName: string;
|
||||
pageTitle: string;
|
||||
spaceName: string;
|
||||
pageUrl: string;
|
||||
}
|
||||
|
||||
export const ApprovalRequestedEmail = ({
|
||||
actorName,
|
||||
pageTitle,
|
||||
spaceName,
|
||||
pageUrl,
|
||||
}: Props) => {
|
||||
return (
|
||||
<MailBody>
|
||||
<Section style={content}>
|
||||
<Text style={paragraph}>Hi there,</Text>
|
||||
<Text style={paragraph}>
|
||||
<strong>{actorName}</strong> submitted{' '}
|
||||
<strong>{pageTitle}</strong> in the{' '}
|
||||
<strong>{spaceName}</strong> space for your approval.
|
||||
</Text>
|
||||
</Section>
|
||||
<EmailButton href={pageUrl}>Review page</EmailButton>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApprovalRequestedEmail;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Section, Text } from '@react-email/components';
|
||||
import { Section, Text } from 'react-email';
|
||||
import * as React from 'react';
|
||||
import { content, paragraph } from '../css/styles';
|
||||
import { MailBody } from '../partials/partials';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Section, Text, Button } from '@react-email/components';
|
||||
import { Section, Text } from 'react-email';
|
||||
import * as React from 'react';
|
||||
import { button, content, paragraph } from '../css/styles';
|
||||
import { MailBody } from '../partials/partials';
|
||||
import { content, paragraph } from '../css/styles';
|
||||
import { EmailButton, MailBody } from '../partials/partials';
|
||||
|
||||
interface Props {
|
||||
actorName: string;
|
||||
@@ -23,19 +23,7 @@ export const CommentCreateEmail = ({
|
||||
<strong>{pageTitle}</strong>.
|
||||
</Text>
|
||||
</Section>
|
||||
<Section
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingLeft: '15px',
|
||||
paddingBottom: '15px',
|
||||
}}
|
||||
>
|
||||
<Button href={pageUrl} style={button}>
|
||||
View
|
||||
</Button>
|
||||
</Section>
|
||||
<EmailButton href={pageUrl}>View</EmailButton>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Section, Text, Button } from '@react-email/components';
|
||||
import { Section, Text } from 'react-email';
|
||||
import * as React from 'react';
|
||||
import { button, content, paragraph } from '../css/styles';
|
||||
import { MailBody } from '../partials/partials';
|
||||
import { content, paragraph } from '../css/styles';
|
||||
import { EmailButton, MailBody } from '../partials/partials';
|
||||
|
||||
interface Props {
|
||||
actorName: string;
|
||||
@@ -23,19 +23,7 @@ export const CommentMentionEmail = ({
|
||||
<strong>{pageTitle}</strong>.
|
||||
</Text>
|
||||
</Section>
|
||||
<Section
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingLeft: '15px',
|
||||
paddingBottom: '15px',
|
||||
}}
|
||||
>
|
||||
<Button href={pageUrl} style={button}>
|
||||
View
|
||||
</Button>
|
||||
</Section>
|
||||
<EmailButton href={pageUrl}>View</EmailButton>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Section, Text, Button } from '@react-email/components';
|
||||
import { Section, Text } from 'react-email';
|
||||
import * as React from 'react';
|
||||
import { button, content, paragraph } from '../css/styles';
|
||||
import { MailBody } from '../partials/partials';
|
||||
import { content, paragraph } from '../css/styles';
|
||||
import { EmailButton, MailBody } from '../partials/partials';
|
||||
|
||||
interface Props {
|
||||
actorName: string;
|
||||
@@ -23,19 +23,7 @@ export const CommentResolvedEmail = ({
|
||||
<strong>{pageTitle}</strong>.
|
||||
</Text>
|
||||
</Section>
|
||||
<Section
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingLeft: '15px',
|
||||
paddingBottom: '15px',
|
||||
}}
|
||||
>
|
||||
<Button href={pageUrl} style={button}>
|
||||
View
|
||||
</Button>
|
||||
</Section>
|
||||
<EmailButton href={pageUrl}>View</EmailButton>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button, Link, Section, Text } from '@react-email/components';
|
||||
import { Button, Link, Section, Text } from 'react-email';
|
||||
import * as React from 'react';
|
||||
import { button, content, paragraph } from '../css/styles';
|
||||
import { MailBody } from '../partials/partials';
|
||||
@@ -17,6 +17,9 @@ export const ForgotPasswordEmail = ({ username, resetLink }: Props) => {
|
||||
We received a request from you to reset your password.
|
||||
</Text>
|
||||
<Link href={resetLink}> Click here to set a new password</Link>
|
||||
<Text style={paragraph}>
|
||||
This link is valid for 30 minutes.
|
||||
</Text>
|
||||
<Text style={paragraph}>
|
||||
If you did not request a password reset, please ignore this email.
|
||||
</Text>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Section, Text } from '@react-email/components';
|
||||
import { Section, Text } from 'react-email';
|
||||
import * as React from 'react';
|
||||
import { content, paragraph } from '../css/styles';
|
||||
import { MailBody } from '../partials/partials';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Section, Text, Button } from '@react-email/components';
|
||||
import { Section, Text } from 'react-email';
|
||||
import * as React from 'react';
|
||||
import { button, content, paragraph } from '../css/styles';
|
||||
import { MailBody } from '../partials/partials';
|
||||
import { content, paragraph } from '../css/styles';
|
||||
import { EmailButton, MailBody } from '../partials/partials';
|
||||
|
||||
interface Props {
|
||||
inviteLink: string;
|
||||
@@ -17,19 +17,7 @@ export const InvitationEmail = ({ inviteLink }: Props) => {
|
||||
Please click the button below to accept this invitation.
|
||||
</Text>
|
||||
</Section>
|
||||
<Section
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingLeft: '15px',
|
||||
paddingBottom: '15px',
|
||||
}}
|
||||
>
|
||||
<Button href={inviteLink} style={button}>
|
||||
Accept Invite
|
||||
</Button>
|
||||
</Section>
|
||||
<EmailButton href={inviteLink}>Accept Invite</EmailButton>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Section, Text, Button } from '@react-email/components';
|
||||
import { Section, Text } from 'react-email';
|
||||
import * as React from 'react';
|
||||
import { button, content, paragraph } from '../css/styles';
|
||||
import { MailBody } from '../partials/partials';
|
||||
import { content, paragraph } from '../css/styles';
|
||||
import { EmailButton, MailBody } from '../partials/partials';
|
||||
|
||||
interface Props {
|
||||
actorName: string;
|
||||
@@ -19,19 +19,7 @@ export const PageMentionEmail = ({ actorName, pageTitle, pageUrl }: Props) => {
|
||||
<strong>{pageTitle}</strong>.
|
||||
</Text>
|
||||
</Section>
|
||||
<Section
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingLeft: '15px',
|
||||
paddingBottom: '15px',
|
||||
}}
|
||||
>
|
||||
<Button href={pageUrl} style={button}>
|
||||
View
|
||||
</Button>
|
||||
</Section>
|
||||
<EmailButton href={pageUrl}>View</EmailButton>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Link, Section, Text } from 'react-email';
|
||||
import * as React from 'react';
|
||||
import { content, link, paragraph } from '../css/styles';
|
||||
import { getGreetingName, MailBody } from '../partials/partials';
|
||||
|
||||
interface PageUpdate {
|
||||
title: string;
|
||||
url: string;
|
||||
updatedBy: string[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
userName: string;
|
||||
pageUpdates: PageUpdate[];
|
||||
totalUpdates: number;
|
||||
}
|
||||
|
||||
export const PageUpdateDigestEmail = ({
|
||||
userName,
|
||||
pageUpdates,
|
||||
totalUpdates,
|
||||
}: Props) => {
|
||||
return (
|
||||
<MailBody>
|
||||
<Section style={content}>
|
||||
<Text style={paragraph}>
|
||||
Hi {getGreetingName(userName)},
|
||||
</Text>
|
||||
<Text style={paragraph}>
|
||||
There {totalUpdates === 1 ? 'has' : 'have'} been{' '}
|
||||
<strong>
|
||||
{totalUpdates} update{totalUpdates === 1 ? '' : 's'}
|
||||
</strong>{' '}
|
||||
since your last update.
|
||||
</Text>
|
||||
|
||||
{pageUpdates.map((page, i) => (
|
||||
<Section key={i} style={pageCard}>
|
||||
<Text style={pageTitle}>
|
||||
<Link href={page.url} style={link}>
|
||||
{page.title}
|
||||
</Link>
|
||||
</Text>
|
||||
{page.updatedBy.length > 0 && (
|
||||
<Text style={updatedByText}>
|
||||
Edited by {page.updatedBy.join(', ')}
|
||||
</Text>
|
||||
)}
|
||||
</Section>
|
||||
))}
|
||||
</Section>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
const pageCard = {
|
||||
borderLeft: '3px solid #e8e5ef',
|
||||
paddingLeft: '12px',
|
||||
marginBottom: '12px',
|
||||
};
|
||||
|
||||
const pageTitle = {
|
||||
...paragraph,
|
||||
margin: '0 0 2px 0',
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold' as const,
|
||||
};
|
||||
|
||||
const updatedByText = {
|
||||
...paragraph,
|
||||
margin: '0',
|
||||
fontSize: 13,
|
||||
color: '#666',
|
||||
};
|
||||
|
||||
export default PageUpdateDigestEmail;
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Link, Section, Text } from 'react-email';
|
||||
import * as React from 'react';
|
||||
import { content, link, paragraph } from '../css/styles';
|
||||
import { EmailButton, getGreetingName, MailBody } from '../partials/partials';
|
||||
|
||||
interface Props {
|
||||
userName: string;
|
||||
actorName: string;
|
||||
pageTitle: string;
|
||||
pageUrl: string;
|
||||
spaceName: string;
|
||||
}
|
||||
|
||||
export const PageUpdateEmail = ({
|
||||
userName,
|
||||
actorName,
|
||||
pageTitle,
|
||||
pageUrl,
|
||||
spaceName,
|
||||
}: Props) => {
|
||||
return (
|
||||
<MailBody>
|
||||
<Section style={content}>
|
||||
<Text style={paragraph}>Hi {getGreetingName(userName)},</Text>
|
||||
<Text style={paragraph}>
|
||||
<strong>{actorName}</strong> updated{' '}
|
||||
<Link href={pageUrl} style={link}>
|
||||
<strong>{pageTitle}</strong>
|
||||
</Link>{' '}
|
||||
in the <strong>{spaceName}</strong> space.
|
||||
</Text>
|
||||
</Section>
|
||||
<EmailButton href={pageUrl}>View page</EmailButton>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageUpdateEmail;
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Section, Text } from 'react-email';
|
||||
import * as React from 'react';
|
||||
import { content, paragraph } from '../css/styles';
|
||||
import { EmailButton, MailBody } from '../partials/partials';
|
||||
|
||||
interface Props {
|
||||
actorName: string;
|
||||
pageTitle: string;
|
||||
pageUrl: string;
|
||||
accessLabel: string;
|
||||
}
|
||||
|
||||
export const PermissionGrantedEmail = ({
|
||||
actorName,
|
||||
pageTitle,
|
||||
pageUrl,
|
||||
accessLabel,
|
||||
}: Props) => {
|
||||
return (
|
||||
<MailBody>
|
||||
<Section style={content}>
|
||||
<Text style={paragraph}>Hi there,</Text>
|
||||
<Text style={paragraph}>
|
||||
<strong>{actorName}</strong> gave you {accessLabel} access to{' '}
|
||||
<strong>{pageTitle}</strong>.
|
||||
</Text>
|
||||
</Section>
|
||||
<EmailButton href={pageUrl}>View</EmailButton>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
export default PermissionGrantedEmail;
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Section, Text } from 'react-email';
|
||||
import * as React from 'react';
|
||||
import { content, paragraph } from '../css/styles';
|
||||
import { EmailButton, MailBody } from '../partials/partials';
|
||||
|
||||
interface Props {
|
||||
pageTitle: string;
|
||||
spaceName: string;
|
||||
pageUrl: string;
|
||||
}
|
||||
|
||||
export const VerificationExpiredEmail = ({ pageTitle, spaceName, pageUrl }: Props) => {
|
||||
return (
|
||||
<MailBody>
|
||||
<Section style={content}>
|
||||
<Text style={paragraph}>Hi there,</Text>
|
||||
<Text style={paragraph}>
|
||||
The verification for <strong>{pageTitle}</strong> in the{' '}
|
||||
<strong>{spaceName}</strong> space has expired. Please re-verify the
|
||||
page to confirm it is still accurate.
|
||||
</Text>
|
||||
</Section>
|
||||
<EmailButton href={pageUrl}>Re-verify page</EmailButton>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
export default VerificationExpiredEmail;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Section, Text } from 'react-email';
|
||||
import * as React from 'react';
|
||||
import { content, paragraph } from '../css/styles';
|
||||
import { EmailButton, MailBody } from '../partials/partials';
|
||||
|
||||
interface Props {
|
||||
pageTitle: string;
|
||||
spaceName: string;
|
||||
pageUrl: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export const VerificationExpiringEmail = ({
|
||||
pageTitle,
|
||||
spaceName,
|
||||
pageUrl,
|
||||
expiresAt,
|
||||
}: Props) => {
|
||||
return (
|
||||
<MailBody>
|
||||
<Section style={content}>
|
||||
<Text style={paragraph}>Hi there,</Text>
|
||||
<Text style={paragraph}>
|
||||
The page <strong>{pageTitle}</strong> in the{' '}
|
||||
<strong>{spaceName}</strong> space needs to be re-verified. The
|
||||
verification expires on <strong>{expiresAt}</strong>.
|
||||
</Text>
|
||||
</Section>
|
||||
<EmailButton href={pageUrl}>Review page</EmailButton>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
export default VerificationExpiringEmail;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { container, footer, h1, logo, main } from '../css/styles';
|
||||
import { button as buttonStyle, container, footer, h1, logo, main } from '../css/styles';
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
Row,
|
||||
Section,
|
||||
Text,
|
||||
} from '@react-email/components';
|
||||
} from 'react-email';
|
||||
import * as React from 'react';
|
||||
|
||||
interface MailBodyProps {
|
||||
@@ -35,6 +35,47 @@ export function MailHeader() {
|
||||
);
|
||||
}
|
||||
|
||||
interface EmailButtonProps {
|
||||
href: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function EmailButton({ href, children }: EmailButtonProps) {
|
||||
return (
|
||||
<table
|
||||
role="presentation"
|
||||
cellPadding="0"
|
||||
cellSpacing="0"
|
||||
style={{ margin: '0 0 15px 15px' }}
|
||||
>
|
||||
<tr>
|
||||
<td
|
||||
style={{
|
||||
backgroundColor: buttonStyle.backgroundColor,
|
||||
borderRadius: buttonStyle.borderRadius,
|
||||
textAlign: 'center' as const,
|
||||
}}
|
||||
>
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
style={{
|
||||
color: buttonStyle.color,
|
||||
fontFamily: buttonStyle.fontFamily,
|
||||
fontSize: buttonStyle.fontSize,
|
||||
textDecoration: 'none',
|
||||
display: 'inline-block',
|
||||
padding: '8px 16px',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
export function MailFooter() {
|
||||
return (
|
||||
<Section style={footer}>
|
||||
@@ -46,3 +87,7 @@ export function MailFooter() {
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
export function getGreetingName(name?: string): string {
|
||||
return name?.split(' ')[0] || 'there';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user