Fix export issues (#10967)

* Fix export

Signed-off-by: Artyom Savchenko <armisav@gmail.com>

* Fix copyright error

Signed-off-by: Artyom Savchenko <armisav@gmail.com>

* Potential fix for pull request finding 'CodeQL / Uncontrolled data used in path expression'

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Signed-off-by: Artyom Savchenko <armisav@gmail.com>

* Format fixes

Signed-off-by: Artyom Savchenko <armisav@gmail.com>

* Sanitize names

Signed-off-by: Artyom Savchenko <armisav@gmail.com>

* Fix file format

Signed-off-by: Artyom Savchenko <armisav@gmail.com>

* Update copyright year

Signed-off-by: Artyom Savchenko <armisav@gmail.com>

---------

Signed-off-by: Artyom Savchenko <armisav@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
This commit is contained in:
Artyom Savchenko
2026-07-10 17:53:48 +07:00
committed by GitHub
co-authored by Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
parent 8ba43d54eb
commit 2bcf85beb4
5 changed files with 439 additions and 23 deletions
@@ -0,0 +1,276 @@
//
// Copyright © 2026 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import {
type Class,
type Client,
type Doc,
type Hierarchy,
type MarkupBlobRef,
type MeasureContext,
type Ref,
type WorkspaceIds
} from '@hcengineering/core'
import { type StorageAdapter } from '@hcengineering/server-core'
import { Buffer } from 'buffer'
import { UnifiedConverter } from '../converter'
// isId requires a 24-character hex string
const refA = '6763a1b2c3d4e5f601234567' as Ref<Doc>
const refB = '6763a1b2c3d4e5f601234568' as Ref<Doc>
const targetClass = 'test:class:Target' as Ref<Class<Doc>>
const abstractClass = 'core:class:Doc' as Ref<Class<Doc>>
function createMockMeasureContext (): MeasureContext {
return {
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
newChild: jest.fn(),
with: jest.fn(),
withSync: jest.fn(),
measure: jest.fn(),
end: jest.fn()
} as unknown as MeasureContext
}
function createMockHierarchy (): Hierarchy {
return {
getAllAttributes: jest.fn(() => new Map()),
isMixin: jest.fn(() => false),
hasMixin: jest.fn(() => false),
isDerived: jest.fn(() => true),
getBaseClass: jest.fn((c: any) => c),
findDomain: jest.fn(() => 'test-domain'),
getClass: jest.fn(),
findAttribute: jest.fn()
} as unknown as Hierarchy
}
function createMockClient (hierarchy: Hierarchy): Client {
return {
findAll: jest.fn().mockResolvedValue([]),
findOne: jest.fn(),
getHierarchy: jest.fn(() => hierarchy)
} as unknown as Client
}
function createMockStorageAdapter (): StorageAdapter {
return {
read: jest.fn(),
stat: jest.fn(),
put: jest.fn(),
remove: jest.fn(),
exists: jest.fn()
} as unknown as StorageAdapter
}
describe('UnifiedConverter', () => {
let context: MeasureContext
let hierarchy: Hierarchy
let client: Client
let storage: StorageAdapter
let wsIds: WorkspaceIds
let converter: UnifiedConverter
beforeEach(() => {
jest.clearAllMocks()
context = createMockMeasureContext()
hierarchy = createMockHierarchy()
client = createMockClient(hierarchy)
storage = createMockStorageAdapter()
wsIds = { uuid: 'test-ws' as any, url: 'ws://test' }
converter = new UnifiedConverter(context, client, storage, wsIds)
})
describe('resolveReference', () => {
it('should return raw ref for abstract classes without domain instead of querying', async () => {
;(hierarchy.findDomain as jest.Mock).mockReturnValue(undefined)
const result = await (converter as any).resolveReference(refA, abstractClass)
expect(result).toBe(refA)
// Must not attempt findAll: abstract classes have no domain and the query would fail
expect(client.findAll).not.toHaveBeenCalled()
expect(context.error).not.toHaveBeenCalled()
expect(context.warn).not.toHaveBeenCalled()
})
it('should resolve a reference to a meaningful identifier', async () => {
const doc = { _id: refA, _class: targetClass, identifier: 'TSK-1' }
;(client.findAll as jest.Mock)
.mockResolvedValueOnce([{ _id: refA }]) // ids query
.mockResolvedValueOnce([doc]) // batch query
const result = await (converter as any).resolveReference(refA, targetClass)
expect(result).toBe('TSK-1')
})
it('should warn only once per missing reference', async () => {
;(client.findAll as jest.Mock).mockResolvedValue([])
const first = await (converter as any).resolveReference(refA, targetClass)
const second = await (converter as any).resolveReference(refA, targetClass)
expect(first).toBe(refA)
expect(second).toBe(refA)
const warns = (context.warn as jest.Mock).mock.calls.filter((c) => String(c[0]).includes(refA))
expect(warns).toHaveLength(1)
})
it('should not retry cache loading after a failure and should log the failure once', async () => {
;(client.findAll as jest.Mock).mockRejectedValue(new Error('domain not found: core:class:Doc '))
const first = await (converter as any).resolveReference(refA, targetClass)
const second = await (converter as any).resolveReference(refB, targetClass)
// References are kept as-is, export continues
expect(first).toBe(refA)
expect(second).toBe(refB)
// The failed query must not be retried for every reference
expect(client.findAll).toHaveBeenCalledTimes(1)
// Cache load failure is logged once
const cacheErrors = (context.error as jest.Mock).mock.calls.filter((c) =>
String(c[0]).includes('Failed to load document cache')
)
expect(cacheErrors).toHaveLength(1)
// No 'Failed to resolve reference' spam per each reference
const refErrors = (context.error as jest.Mock).mock.calls.filter((c) =>
String(c[0]).includes('Failed to resolve reference')
)
expect(refErrors).toHaveLength(0)
})
it('should return non-id values as-is', async () => {
const result = await (converter as any).resolveReference('not-an-id', targetClass)
expect(result).toBe('not-an-id')
expect(client.findAll).not.toHaveBeenCalled()
})
})
describe('resolveMarkdown', () => {
const blobRef = 'blob-description-1' as MarkupBlobRef
it('should return markup content when blob exists', async () => {
;(storage.stat as jest.Mock).mockResolvedValue({ size: 4, contentType: 'application/json' })
;(storage.read as jest.Mock).mockResolvedValue([Buffer.from('test')])
const result = await (converter as any).resolveMarkdown(blobRef)
expect(result).toBe('test')
expect(context.warn).not.toHaveBeenCalled()
expect(context.error).not.toHaveBeenCalled()
})
it('should warn and return empty string when blob does not exist (stat undefined)', async () => {
;(storage.stat as jest.Mock).mockResolvedValue(undefined)
const result = await (converter as any).resolveMarkdown(blobRef)
expect(result).toBe('')
expect(storage.read).not.toHaveBeenCalled()
expect(context.warn).toHaveBeenCalledWith(expect.stringContaining(`Blob not found: ${blobRef}`))
expect(context.error).not.toHaveBeenCalled()
})
it('should treat "missing" read errors as warnings, not export errors', async () => {
;(storage.stat as jest.Mock).mockResolvedValue({ size: 4, contentType: 'application/json' })
;(storage.read as jest.Mock).mockRejectedValue(new Error(`uuid=x dataId=y missing ${blobRef}`))
const result = await (converter as any).resolveMarkdown(blobRef)
expect(result).toBe('')
expect(context.warn).toHaveBeenCalledWith(expect.stringContaining(`Blob content not found: ${blobRef}`))
expect(context.error).not.toHaveBeenCalled()
})
it('should warn only once per missing blob', async () => {
;(storage.stat as jest.Mock).mockResolvedValue(undefined)
await (converter as any).resolveMarkdown(blobRef)
await (converter as any).resolveMarkdown(blobRef)
const warns = (context.warn as jest.Mock).mock.calls.filter((c) => String(c[0]).includes(blobRef))
expect(warns).toHaveLength(1)
})
it('should log unexpected read errors as errors and return empty string', async () => {
;(storage.stat as jest.Mock).mockResolvedValue({ size: 4, contentType: 'application/json' })
;(storage.read as jest.Mock).mockRejectedValue(new Error('connection refused'))
const result = await (converter as any).resolveMarkdown(blobRef)
expect(result).toBe('')
expect(context.error).toHaveBeenCalledWith(
expect.stringContaining(`Failed to resolve markup content: ${blobRef}`),
expect.objectContaining({ error: 'connection refused' })
)
})
})
describe('resolveAttachments', () => {
const docId = refA
const docClass = 'test:class:Issue' as Ref<Class<Doc>>
function mockAttachmentCache (): void {
const att = {
_id: refB,
_class: 'attachment:class:Attachment' as Ref<Class<Doc>>,
attachedTo: docId,
attachedToClass: docClass,
collection: 'attachments',
name: 'file.txt',
size: 4,
type: 'text/plain',
file: 'blob-file-1'
}
;(client.findAll as jest.Mock)
.mockResolvedValueOnce([{ _id: att._id }]) // ids query
.mockResolvedValueOnce([att]) // batch query
}
it('should return empty buffer and warn when attachment read fails', async () => {
mockAttachmentCache()
;(storage.read as jest.Mock).mockRejectedValue(new Error('missing blob-file-1'))
const attachments = await (converter as any).resolveAttachments(docId, docClass)
expect(attachments).toHaveLength(1)
const data = await attachments[0].getData()
expect(data).toEqual(Buffer.from([]))
expect(context.warn).toHaveBeenCalledWith(
expect.stringContaining(`Failed to read attachment: ${refB}`),
expect.objectContaining({ error: 'missing blob-file-1' })
)
})
it('should return attachment data when read succeeds', async () => {
mockAttachmentCache()
;(storage.read as jest.Mock).mockResolvedValue([Buffer.from('test')])
const attachments = await (converter as any).resolveAttachments(docId, docClass)
const data = await attachments[0].getData()
expect(data).toEqual(Buffer.from('test'))
expect(context.warn).not.toHaveBeenCalled()
})
})
})
@@ -0,0 +1,49 @@
//
// Copyright © 2026 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { sanitizeSpaceFileName } from '../exporter'
describe('sanitizeSpaceFileName', () => {
it('should keep ordinary names unchanged', () => {
expect(sanitizeSpaceFileName('DEV')).toBe('DEV')
expect(sanitizeSpaceFileName('Wellness Vault')).toBe('Wellness Vault')
})
it('should replace path separators to avoid nested directories', () => {
expect(sanitizeSpaceFileName('UI/UX Portfolio')).toBe('UI_UX Portfolio')
expect(sanitizeSpaceFileName('Ventas/Marketing')).toBe('Ventas_Marketing')
expect(sanitizeSpaceFileName('back\\slash')).toBe('back_slash')
})
it('should prevent path traversal', () => {
expect(sanitizeSpaceFileName('../../etc/passwd')).not.toContain('..')
expect(sanitizeSpaceFileName('../../etc/passwd')).not.toContain('/')
expect(sanitizeSpaceFileName('..')).toBe('_')
})
it('should strip control characters and reserved characters', () => {
expect(sanitizeSpaceFileName('name with\u0007control')).toBe('name withcontrol')
expect(sanitizeSpaceFileName('a:b*c?d"e<f>g|h')).toBe('a_b_c_d_e_f_g_h')
})
it('should not produce hidden files from leading dots', () => {
expect(sanitizeSpaceFileName('.hidden')).toBe('hidden')
})
it('should fall back to a placeholder for empty results', () => {
expect(sanitizeSpaceFileName('')).toBe('unnamed')
expect(sanitizeSpaceFileName('\u0000\u0001')).toBe('unnamed')
})
})
+59 -11
View File
@@ -46,6 +46,10 @@ export class UnifiedConverter {
// Fields that should not be resolved
private readonly skipResolveFields = new Set(['_class', '_id', 'collection', 'attachedTo', 'attachedToClass'])
// Deduplication sets to avoid flooding logs with repeated warnings
private readonly reportedMissingRefs = new Set<string>()
private readonly reportedMissingBlobs = new Set<string>()
constructor (
private readonly context: MeasureContext,
private readonly client: Client,
@@ -264,12 +268,21 @@ export class UnifiedConverter {
private async resolveReference (ref: Ref<Doc>, to: Ref<Class<Doc>>): Promise<string> {
if (!isId(ref)) return ref
// References to abstract classes (e.g. core:class:Doc) cannot be resolved
// via findAll since they have no associated domain, keep the raw identifier
if (this.client.getHierarchy().findDomain(to) === undefined) {
return ref
}
try {
const { byId } = await this.getCache(to)
const doc = byId.get(ref)
if (doc === undefined) {
this.context.warn(`Referenced document not found: ${ref}`)
if (!this.reportedMissingRefs.has(ref)) {
this.reportedMissingRefs.add(ref)
this.context.warn(`Referenced document not found: ${ref}`)
}
return ref
}
@@ -290,7 +303,15 @@ export class UnifiedConverter {
p = await p
}
if (p === undefined) {
p = this.loadCache(to)
p = this.loadCache(to).catch((err) => {
// Cache an empty result on failure, otherwise every reference
// to this class retries the query and floods logs with errors
this.context.error(`Failed to load document cache for ${to}`, {
error: err instanceof Error ? err.message : String(err)
})
const empty: DocCache = { byId: new Map(), byAttached: new Map() }
return empty
})
this.documentCache.set(to, p)
p = await p
this.documentCache.set(to, p)
@@ -324,6 +345,17 @@ export class UnifiedConverter {
private async resolveMarkdown (blobRef: MarkupBlobRef): Promise<string> {
try {
// Check blob existence first: storage adapters throw on missing blobs,
// and a missing description blob should not be treated as an export error
const stat = await this.storage.stat(this.context, this.wsIds, blobRef)
if (stat === undefined) {
if (!this.reportedMissingBlobs.has(blobRef)) {
this.reportedMissingBlobs.add(blobRef)
this.context.warn(`Blob not found: ${blobRef}`)
}
return ''
}
const buffer = await this.storage.read(this.context, this.wsIds, blobRef)
if (buffer === undefined) {
this.context.warn(`Blob not found: ${blobRef}`)
@@ -334,10 +366,19 @@ export class UnifiedConverter {
// const markdown = await markupToMarkdown(markup, '', '')
return markup // todo: test it is a markdown
} catch (err) {
this.context.error(`Failed to resolve markup content: ${blobRef}`, {
error: err instanceof Error ? err.message : String(err),
blobRef
})
const message = err instanceof Error ? err.message : String(err)
// Missing blob content is a data consistency issue, not an export failure
if (message.includes('missing')) {
if (!this.reportedMissingBlobs.has(blobRef)) {
this.reportedMissingBlobs.add(blobRef)
this.context.warn(`Blob content not found: ${blobRef}`)
}
} else {
this.context.error(`Failed to resolve markup content: ${blobRef}`, {
error: message,
blobRef
})
}
return ''
}
}
@@ -371,14 +412,21 @@ export class UnifiedConverter {
size: att.size,
contentType: att.type,
getData: async () => {
const buffer = await this.storage.read(this.context, this.wsIds, att.file)
try {
const buffer = await this.storage.read(this.context, this.wsIds, att.file)
if (buffer === undefined) {
this.context.warn(`Attachment not found: ${att._id}`)
if (buffer === undefined) {
this.context.warn(`Attachment not found: ${att._id}`)
return Buffer.from([])
}
return Buffer.concat(buffer as any)
} catch (err) {
this.context.warn(`Failed to read attachment: ${att._id}`, {
error: err instanceof Error ? err.message : String(err)
})
return Buffer.from([])
}
return Buffer.concat(buffer as any)
}
})
)
+18 -1
View File
@@ -44,6 +44,17 @@ export interface ExportOptions {
query?: DocumentQuery<Doc>
}
export function sanitizeSpaceFileName (name: string): string {
const sanitized = name
// eslint-disable-next-line no-control-regex
.replace(/[\u0000-\u001f]/g, '')
.replace(/[/\\:*?"<>|]/g, '_')
.replace(/\.{2,}/g, '_')
.trim()
.replace(/^\.+/, '')
return sanitized.length > 0 ? sanitized : 'unnamed'
}
export class WorkspaceExporter {
private readonly jsonSerializer: UnifiedJsonSerializer
private readonly csvSerializer: UnifiedCsvSerializer
@@ -81,6 +92,7 @@ export class WorkspaceExporter {
)
const limiter = new RateLimiter(50)
const usedSpaceNames = new Set<string>()
// Process each space
for (const [spaceId, spaceDocs] of docsBySpace) {
const space = allSpaces.get(spaceId)
@@ -89,7 +101,12 @@ export class WorkspaceExporter {
continue
}
const spaceName = space.name ?? spaceId
let spaceName = sanitizeSpaceFileName(space.name ?? spaceId)
if (usedSpaceNames.has(spaceName)) {
// Different spaces may share the same (sanitized) name, avoid overwriting
spaceName = `${spaceName}-${spaceId}`
}
usedSpaceNames.add(spaceName)
const spaceDir = path.join(outputDir, spaceName)
// Convert all docs to UnifiedDoc format
+37 -11
View File
@@ -67,7 +67,7 @@ import archiver from 'archiver'
import { sendExportCompletionNotification } from './notifications'
import cors from 'cors'
import express, { type Express, type NextFunction, type Request, type Response } from 'express'
import { createWriteStream } from 'fs'
import { createReadStream, createWriteStream } from 'fs'
import fs from 'fs/promises'
import { IncomingHttpHeaders, type Server } from 'http'
import { tmpdir } from 'os'
@@ -254,6 +254,16 @@ const wrapRequest = (fn: AsyncRequestHandler) => (req: Request, res: Response, n
handleRequest(fn, req, res, next)
}
// Only formats actually supported by WorkspaceExporter
const supportedExportFormats: readonly ExportFormat[] = [ExportFormat.JSON, ExportFormat.CSV]
function parseExportFormat (rawFormat: unknown): ExportFormat {
if (typeof rawFormat !== 'string' || !supportedExportFormats.includes(rawFormat as ExportFormat)) {
throw new ApiError(400, `Invalid format. Supported formats: ${supportedExportFormats.join(', ')}`)
}
return rawFormat as ExportFormat
}
export function createServer (
storageConfig: StorageConfiguration,
dbUrl: string,
@@ -269,7 +279,7 @@ export function createServer (
app.post(
'/exportAsync',
wrapRequest(async (req, res, wsIds, token, socialId) => {
const format = req.query.format as ExportFormat
const format = parseExportFormat(req.query.format)
const {
_class,
@@ -281,7 +291,7 @@ export function createServer (
attributesOnly: boolean
} = req.body
if (_class == null || format == null) {
if (_class == null) {
throw new ApiError(400, 'Missing required parameters')
}
@@ -343,9 +353,23 @@ export function createServer (
await sendSuccessNotification(txOperations, account, exportDrive, archiveName)
} catch (err: any) {
measureCtx.error('Export failed:', err)
await sendFailureNotification(txOperations, account, err.message ?? 'Unknown error when exporting')
try {
// Attach the failure notification to the export drive, otherwise
// the user is never notified that the export has failed
const exportDrive = await ensureExportDrive(txOperations, account)
await sendFailureNotification(
txOperations,
account,
err.message ?? 'Unknown error when exporting',
drive.class.Drive,
exportDrive,
core.space.Space
)
} catch (notifyErr: any) {
measureCtx.error('Failed to send export failure notification:', notifyErr)
}
} finally {
await fs.rmdir(exportDir, { recursive: true })
await fs.rm(exportDir, { recursive: true, force: true })
}
})()
})
@@ -354,7 +378,7 @@ export function createServer (
app.post(
'/exportSync',
wrapRequest(async (req, res, wsIds, token, socialId) => {
const format = req.query.format as ExportFormat
const format = parseExportFormat(req.query.format)
const {
_class,
query,
@@ -367,7 +391,7 @@ export function createServer (
config?: TransformConfig
} = req.body
if (_class == null || format == null) {
if (_class == null) {
throw new ApiError(400, 'Missing required parameters')
}
@@ -394,7 +418,7 @@ export function createServer (
measureCtx.error('Export failed:', err)
throw err
} finally {
void fs.rmdir(exportDir, { recursive: true })
void fs.rm(exportDir, { recursive: true, force: true })
}
})
)
@@ -655,14 +679,16 @@ async function saveToDrive (
): Promise<Ref<Drive>> {
const exportDrive = await ensureExportDrive(client, account)
const fileContent = await fs.readFile(archivePath)
// Stream the archive instead of reading it into memory:
// fs.readFile fails with ERR_FS_FILE_TOO_LARGE for archives larger than 2 GiB
const { size } = await fs.stat(archivePath)
const blobId = uuid() as Ref<Blob>
await storage.put(ctx, wsIds, blobId, fileContent, 'application/zip', fileContent.length)
await storage.put(ctx, wsIds, blobId, createReadStream(archivePath), 'application/zip', size)
await createFile(client, exportDrive, drive.ids.Root, {
title: basename(archivePath),
file: blobId,
size: fileContent.length,
size,
type: 'application/zip',
lastModified: Date.now()
})