Merge branch 'develop' of https://github.com/hcengineering/platform into staging-new

This commit is contained in:
Artyom Savchenko
2026-08-01 08:31:26 +07:00
39 changed files with 1214 additions and 1656 deletions
+68 -1583
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -185,6 +185,19 @@ export function defineFunctions (builder: Builder): void {
process.function.All
)
builder.createDoc(
process.class.ProcessFunction,
core.space.Model,
{
of: core.class.ArrOf,
category: 'array',
label: process.string.AllMatchValue,
type: 'reduce',
editor: process.transformEditor.FilterEditor
},
process.function.AllMatchValue
)
builder.createDoc(
process.class.ProcessFunction,
core.space.Model,
+4
View File
@@ -388,6 +388,10 @@ export function createModel (builder: Builder): void {
func: serverProcess.transform.FirstMatchValue
})
builder.mixin(process.function.AllMatchValue, process.class.ProcessFunction, serverProcess.mixin.FuncImpl, {
func: serverProcess.transform.AllMatchValue
})
builder.mixin(process.function.Filter, process.class.ProcessFunction, serverProcess.mixin.FuncImpl, {
func: serverProcess.transform.Filter
})
+2 -1
View File
@@ -117,6 +117,7 @@
"ExecutionInitiator": "Iniciátor provedení",
"ExecutionStarted": "Provedení spuštěno",
"Filter": "Filtr",
"AllMatchValue": "Všechny odpovídající hodnoty",
"FirstMatchValue": "První odpovídající hodnota",
"ConfigLabel": "Konfigurace procesů",
"ConfigDescription": "Definujte procesy pro automatizaci pracovních postupů a obchodních procesů.",
@@ -197,4 +198,4 @@
"TooDeepTransitionRecursion": "Příliš hluboká rekurze přechodů",
"ToDoAlreadyCompleted": "Akční položka již byla dokončena"
}
}
}
+1
View File
@@ -117,6 +117,7 @@
"ExecutionInitiator": "Ausführungsinitiator",
"ExecutionStarted": "Ausführung gestartet",
"Filter": "Filter",
"AllMatchValue": "Alle passenden Werte",
"FirstMatchValue": "Erster passender Wert",
"ConfigLabel": "Prozesskonfiguration",
"ConfigDescription": "Definieren Sie Prozesse, um Workflows und Geschäftsprozesse zu automatisieren.",
+1
View File
@@ -124,6 +124,7 @@
"ExecutionInitiator": "Execution initiator",
"ExecutionStarted": "Execution started",
"Filter": "Filter",
"AllMatchValue": "All matching values",
"FirstMatchValue": "First match value",
"ConfigLabel": "Process configuration",
"ConfigDescription": "Define processes to automate workflows and business processes.",
+1
View File
@@ -124,6 +124,7 @@
"ExecutionInitiator": "Iniciador de Ejecución",
"ExecutionStarted": "Ejecución Iniciada",
"Filter": "Filtro",
"AllMatchValue": "Todos los valores coincidentes",
"FirstMatchValue": "Primer valor coincidente",
"ConfigLabel": "Configuración de procesos",
"ConfigDescription": "Defina procesos para automatizar flujos de trabajo y procesos comerciales.",
+1
View File
@@ -124,6 +124,7 @@
"ExecutionInitiator": "Initiateur d'exécution",
"ExecutionStarted": "Exécution démarrée",
"Filter": "Filtrer",
"AllMatchValue": "Toutes les valeurs correspondantes",
"FirstMatchValue": "Première valeur correspondante",
"ConfigLabel": "Configuration des processus",
"ConfigDescription": "Définissez des processus pour automatiser les flux de travail et les processus métier.",
+1
View File
@@ -124,6 +124,7 @@
"ExecutionInitiator": "Iniziatore dell'esecuzione",
"ExecutionStarted": "Esecuzione avviata",
"Filter": "Filtro",
"AllMatchValue": "Tutti i valori corrispondenti",
"FirstMatchValue": "Primo valore corrispondente",
"ConfigLabel": "Configurazione del processo",
"ConfigDescription": "Definire i processi per automatizzare i flussi di lavoro e i processi aziendali.",
+1
View File
@@ -123,6 +123,7 @@
"ExecutionInitiator": "実行の発起人",
"ExecutionStarted": "実行が開始されました",
"Filter": "フィルター",
"AllMatchValue": "一致するすべての値",
"FirstMatchValue": "最初の一致する値",
"ConfigLabel": "プロセス構成",
"ConfigDescription": "ワークフローやビジネスプロセスを自動化するためのプロセスを定義します。",
+1
View File
@@ -124,6 +124,7 @@
"ExecutionInitiator": "실행 시작자",
"ExecutionStarted": "실행 시작됨",
"Filter": "필터",
"AllMatchValue": "모든 일치 값",
"FirstMatchValue": "첫 일치 값",
"ConfigLabel": "프로세스 구성",
"ConfigDescription": "워크플로와 비즈니스 프로세스를 자동화할 프로세스를 정의합니다.",
+1
View File
@@ -124,6 +124,7 @@
"ExecutionInitiator": "Inicjator wykonania",
"ExecutionStarted": "Wykonanie rozpoczęte",
"Filter": "Filtr",
"AllMatchValue": "Wszystkie pasujące wartości",
"FirstMatchValue": "Pierwsza pasująca wartość",
"ConfigLabel": "Konfiguracja procesu",
"ConfigDescription": "Definiuj procesy do automatyzacji organizacji zadań i procesów biznesowych.",
+1
View File
@@ -124,6 +124,7 @@
"ExecutionInitiator": "Iniciador de Execução",
"ExecutionStarted": "Execução Iniciada",
"Filter": "Filtrar",
"AllMatchValue": "Todos os valores correspondentes",
"FirstMatchValue": "Primeiro Valor Correspondente",
"ConfigLabel": "Configuração de processos",
"ConfigDescription": "Defina processos para automatizar fluxos de trabalho e processos de negócios.",
+1
View File
@@ -124,6 +124,7 @@
"ExecutionInitiator": "Iniciador de Execução",
"ExecutionStarted": "Execução Iniciada",
"Filter": "Filtrar",
"AllMatchValue": "Todos os valores correspondentes",
"FirstMatchValue": "Primeiro Valor Correspondente",
"ConfigLabel": "Configuração de processos",
"ConfigDescription": "Defina processos para automatizar fluxos de trabalho e processos de negócios.",
+1
View File
@@ -124,6 +124,7 @@
"ExecutionInitiator": "Инициатор выполнения",
"ExecutionStarted": "Выполнение начато",
"Filter": "Фильтр",
"AllMatchValue": "Все подходящие значения",
"FirstMatchValue": "Первое подходящее значение",
"ConfigLabel": "Конфигурация процессов",
"ConfigDescription": "Определяйте процессы для автоматизации рабочих процессов и бизнес-процессов.",
+1
View File
@@ -121,6 +121,7 @@
"ProcessFinished": "\"{process}\" süreci \"{state}\" durumunda tamamlandı",
"NewProcessToDo": "Yeni süreç Eylem öğesi",
"Filter": "Filtre",
"AllMatchValue": "Tüm eşleşen değerler",
"FirstMatchValue": "İlk eşleşen değer",
"ConfigLabel": "Süreç yapılandırması",
"ConfigDescription": "İş akışlarını ve iş süreçlerini otomatikleştirmek için süreçler tanımlayın.",
+1
View File
@@ -124,6 +124,7 @@
"ExecutionInitiator": "执行发起者",
"ExecutionStarted": "执行已启动",
"Filter": "过滤",
"AllMatchValue": "所有匹配值",
"FirstMatchValue": "第一个匹配值",
"ConfigLabel": "流程配置",
"ConfigDescription": "定义流程以自动化工作流和业务流程。",
+1
View File
@@ -157,6 +157,7 @@ export default mergeIds(processId, process, {
Split: '' as IntlString,
Cut: '' as IntlString,
Filter: '' as IntlString,
AllMatchValue: '' as IntlString,
FirstMatchValue: '' as IntlString,
FirstValue: '' as IntlString,
LastValue: '' as IntlString,
+1
View File
@@ -353,6 +353,7 @@ export default plugin(processId, {
OnEvent: '' as Asset
},
function: {
AllMatchValue: '' as Ref<ProcessFunction>,
FirstMatchValue: '' as Ref<ProcessFunction>,
Filter: '' as Ref<ProcessFunction>,
FirstValue: '' as Ref<ProcessFunction>,
@@ -0,0 +1,140 @@
//
// 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 AccountUuid, systemAccountUuid, type WorkspaceUuid } from '@hcengineering/core'
import { extractToken } from '@hcengineering/server-client'
import { type Token } from '@hcengineering/server-token'
import { type NextFunction, type Response } from 'express'
import { HttpError } from '../error'
import { type RequestWithAuth, withAuthorization, withBlob } from '../middleware'
jest.mock('@hcengineering/server-client', () => ({
extractToken: jest.fn()
}))
const extractTokenMock = extractToken as jest.MockedFunction<typeof extractToken>
const workspaceA = '00000000-0000-4000-8000-00000000000a' as WorkspaceUuid
const workspaceB = '00000000-0000-4000-8000-00000000000b' as WorkspaceUuid
const account = '00000000-0000-4000-8000-0000000000ac' as AccountUuid
function makeToken (token: Partial<Token>): Token {
const result: Token = { account, workspace: workspaceA, extra: {} }
return { ...result, ...token }
}
function makeRequest (workspace: string, name: string, token?: Token): RequestWithAuth {
return { headers: {}, params: { workspace, name }, token } as unknown as RequestWithAuth
}
const res = {} as unknown as Response
describe('withAuthorization', () => {
beforeEach(() => {
extractTokenMock.mockReset()
})
it('rejects requests without a token', () => {
extractTokenMock.mockReturnValue(undefined)
const next = jest.fn() as unknown as NextFunction
withAuthorization(makeRequest(workspaceA, 'blob'), res, next)
expect(next).toHaveBeenCalledWith(expect.objectContaining({ code: 401 }))
})
it('rejects guest and readonly tokens', () => {
for (const extra of [{ guest: 'true' }, { readonly: 'true' }]) {
extractTokenMock.mockReturnValue(makeToken({ extra }))
const next = jest.fn() as unknown as NextFunction
withAuthorization(makeRequest(workspaceA, 'blob'), res, next)
expect(next).toHaveBeenCalledWith(expect.objectContaining({ code: 401 }))
}
})
it('attaches a valid token to the request', () => {
const token = makeToken({})
extractTokenMock.mockReturnValue(token)
const req = makeRequest(workspaceA, 'blob')
const next = jest.fn() as unknown as NextFunction
withAuthorization(req, res, next)
expect(req.token).toBe(token)
expect(next).toHaveBeenCalledWith()
})
})
describe('withBlob', () => {
it('rejects a missing workspace', () => {
const next = jest.fn() as unknown as NextFunction
withBlob(makeRequest('', 'blob'), res, next)
expect(next).toHaveBeenCalledWith(expect.objectContaining({ code: 400 }))
})
it('accepts a non-uuid workspace id when the token matches it', () => {
const workspace = 'not-a-uuid'
const next = jest.fn() as unknown as NextFunction
withBlob(makeRequest(workspace, 'blob', makeToken({ workspace: workspace as WorkspaceUuid })), res, next)
expect(next).toHaveBeenCalledWith()
})
it('rejects a missing blob name', () => {
const next = jest.fn() as unknown as NextFunction
withBlob(makeRequest(workspaceA, ''), res, next)
expect(next).toHaveBeenCalledWith(expect.objectContaining({ code: 400 }))
})
it('rejects a token scoped to another workspace', () => {
const next = jest.fn() as unknown as NextFunction
withBlob(makeRequest(workspaceB, 'blob', makeToken({ workspace: workspaceA })), res, next)
expect(next).toHaveBeenCalledWith(expect.any(HttpError))
expect(next).toHaveBeenCalledWith(expect.objectContaining({ code: 401 }))
})
it('allows a token scoped to the requested workspace', () => {
const next = jest.fn() as unknown as NextFunction
withBlob(makeRequest(workspaceA, 'blob', makeToken({ workspace: workspaceA })), res, next)
expect(next).toHaveBeenCalledWith()
})
it('allows the system account and admins to access any workspace', () => {
for (const token of [
makeToken({ account: systemAccountUuid, workspace: workspaceA }),
makeToken({ workspace: workspaceA, extra: { admin: 'true' } })
]) {
const next = jest.fn() as unknown as NextFunction
withBlob(makeRequest(workspaceB, 'blob', token), res, next)
expect(next).toHaveBeenCalledWith()
}
})
it('rejects requests with no token attached', () => {
const next = jest.fn() as unknown as NextFunction
withBlob(makeRequest(workspaceA, 'blob'), res, next)
expect(next).toHaveBeenCalledWith(expect.objectContaining({ code: 401 }))
})
})
+34
View File
@@ -66,6 +66,40 @@ export const withAuthorization = (req: RequestWithAuth, res: Response, next: Nex
}
}
/**
* Validates blob route params and ensures the caller's token grants access to
* the workspace taken from the URL. Must run after `withAuthorization`, which
* guarantees a token is present.
*/
export const withBlob = (req: RequestWithAuth, res: Response, next: NextFunction): void => {
try {
const workspace = req.params.workspace
const name = req.params.name
if (workspace === undefined || workspace === '') {
throw new HttpError(400, 'Missing workspace')
}
if (name === undefined || name === '') {
throw new HttpError(400, 'Missing blob name')
}
const token = req.token
if (token == null) {
throw new HttpError(401, 'Unauthorized')
}
const hasWorkspaceAccess =
(token.workspace as string) === workspace || token.account === systemAccountUuid || token.extra?.admin === 'true'
if (!hasWorkspaceAccess) {
throw new HttpError(401, 'Unauthorized')
}
next()
} catch (err: any) {
next(err)
}
}
export interface ErrorHandlerOptions {
ctx: MeasureContext
}
+5 -1
View File
@@ -29,7 +29,7 @@ import { pipeline } from 'stream/promises'
import { createCache } from './cache'
import { type Config } from './config'
import { type RequestWithAuth, errorHandler, keepAlive } from './middleware'
import { type RequestWithAuth, errorHandler, keepAlive, withAuthorization, withBlob } from './middleware'
import { createPreviewService, ThumbnailParams } from './service'
import { TemporaryDir } from './tempdir'
@@ -174,6 +174,8 @@ export async function createServer (ctx: MeasureContext, config: Config): Promis
app.get(
'/metadata/:workspace/:name',
withAuthorization,
withBlob,
wrapRequest(ctx, 'getMetadata', async (ctx, req, res) => {
const workspace = req.params.workspace as WorkspaceUuid
const name = req.params.name
@@ -185,6 +187,8 @@ export async function createServer (ctx: MeasureContext, config: Config): Promis
app.get(
'/image/:transform/:workspace/:name',
withAuthorization,
withBlob,
wrapRequest(ctx, 'getThumbnail', async (ctx, req, res) => {
const workspace = req.params.workspace as WorkspaceUuid
const name = req.params.name
@@ -85,6 +85,7 @@ import { FieldChangedRollback, ToDoCancellRollback, ToDoCloseRollback } from './
import {
Absolute,
Add,
AllMatchValue,
All,
Append,
Ceil,
@@ -819,6 +820,7 @@ export default async () => ({
EmptyValue,
ExecutionInitiator,
ExecutionStarted,
AllMatchValue,
FirstMatchValue,
Filter,
StringFromNumber,
@@ -63,6 +63,26 @@ export async function FirstMatchValue (
}
}
export async function AllMatchValue (
value: any[],
props: Record<string, any>,
control: ProcessControl
): Promise<any[] | undefined> {
if (value == null) {
return
}
if (!Array.isArray(value)) return value
const { _class, ...otherProps } = props
if (_class == null) return
if (value.length === 0) return
if (typeof value[0] === 'string') {
const docs = await control.client.findAll(_class, { _id: { $in: value } })
return matchQuery(docs, otherProps, core.class.Doc, control.client.getHierarchy(), true).map((p) => p._id)
} else if (typeof value[0] === 'object') {
return matchQuery(value, otherProps, core.class.Doc, control.client.getHierarchy(), true)
}
}
// #endregion
// #region Array
+1
View File
@@ -115,6 +115,7 @@ export default plugin(serverProcessId, {
EmptyValue: '' as Resource<TransformFunc>,
EmptyArray: '' as Resource<TransformFunc>,
Filter: '' as Resource<TransformFunc>,
AllMatchValue: '' as Resource<TransformFunc>,
FirstMatchValue: '' as Resource<TransformFunc>,
ExecutionInitiator: '' as Resource<TransformFunc>,
ExecutionStarted: '' as Resource<TransformFunc>,
+287 -1
View File
@@ -53,7 +53,9 @@ import {
createAccessLink,
getSubscriptions,
leaveWorkspace,
checkJoin
checkJoin,
mergeSpecifiedPersons,
canMergeSpecifiedPersons
} from '../operations'
import { accountPlugin } from '../plugin'
@@ -3183,3 +3185,287 @@ describe('getSubscriptions', () => {
await expect(getSubscriptions(mockCtx, mockDb, mockBranding, 'test-token', {})).rejects.toThrow(PlatformError)
})
})
describe('merge specified persons', () => {
const mockCtx = {
error: jest.fn(),
info: jest.fn(),
warn: jest.fn()
} as unknown as MeasureContext
const mockBranding = null
const workspaceUuid = 'caller-workspace-uuid' as WorkspaceUuid
const callerUuid = 'caller-account-uuid' as AccountUuid
const primaryPerson = 'primary-person-uuid' as PersonUuid
const secondaryPerson = 'secondary-person-uuid' as PersonUuid
const params = { primaryPerson, secondaryPerson }
let mockDb: any
beforeEach(() => {
jest.clearAllMocks()
jest.restoreAllMocks()
mockDb = {
account: {
findOne: jest.fn().mockResolvedValue(null)
},
person: {
findOne: jest.fn().mockImplementation(async ({ uuid }: { uuid: PersonUuid }) => ({ uuid }))
},
socialId: {
find: jest.fn().mockResolvedValue([])
},
getWorkspaceRole: jest.fn().mockResolvedValue(null)
}
;(decodeTokenVerbose as jest.Mock).mockReturnValue({
account: callerUuid,
workspace: workspaceUuid,
extra: {}
})
})
// The caller maintains the workspace, and neither merged person belongs to another one.
const asWorkspaceMaintainer = (): void => {
mockDb.getWorkspaceRole.mockImplementation(async (account: AccountUuid) =>
account === callerUuid ? AccountRole.Maintainer : null
)
}
describe('mergeSpecifiedPersons', () => {
test('should throw BadRequest for empty params', async () => {
await expect(
mergeSpecifiedPersons(mockCtx, mockDb, mockBranding, 'test-token', {
primaryPerson: '' as PersonUuid,
secondaryPerson
})
).rejects.toThrow(PlatformError)
expect(mockDb.getWorkspaceRole).not.toHaveBeenCalled()
})
test('should throw Forbidden for a token without workspace', async () => {
;(decodeTokenVerbose as jest.Mock).mockReturnValue({ account: callerUuid, extra: {} })
const spy = jest.spyOn(utils, 'doMergePersons').mockResolvedValue()
await expect(mergeSpecifiedPersons(mockCtx, mockDb, mockBranding, 'test-token', params)).rejects.toThrow(
PlatformError
)
expect(spy).not.toHaveBeenCalled()
// Pins the workspace guard itself rather than the role lookup that follows it.
expect(mockDb.getWorkspaceRole).not.toHaveBeenCalled()
})
test('should throw Forbidden when caller is below Maintainer', async () => {
mockDb.getWorkspaceRole.mockResolvedValue(AccountRole.User)
const spy = jest.spyOn(utils, 'doMergePersons').mockResolvedValue()
await expect(mergeSpecifiedPersons(mockCtx, mockDb, mockBranding, 'test-token', params)).rejects.toThrow(
PlatformError
)
expect(spy).not.toHaveBeenCalled()
})
test('should throw Forbidden when the secondary person is an account of another workspace', async () => {
asWorkspaceMaintainer()
mockDb.account.findOne.mockImplementation(async ({ uuid }: { uuid: AccountUuid }) =>
uuid === secondaryPerson ? { uuid } : null
)
const spy = jest.spyOn(utils, 'doMergePersons').mockResolvedValue()
await expect(mergeSpecifiedPersons(mockCtx, mockDb, mockBranding, 'test-token', params)).rejects.toThrow(
PlatformError
)
expect(spy).not.toHaveBeenCalled()
})
test('should throw Forbidden when the primary person is an account of another workspace', async () => {
asWorkspaceMaintainer()
mockDb.account.findOne.mockImplementation(async ({ uuid }: { uuid: AccountUuid }) =>
uuid === primaryPerson ? { uuid } : null
)
const spy = jest.spyOn(utils, 'doMergePersons').mockResolvedValue()
await expect(mergeSpecifiedPersons(mockCtx, mockDb, mockBranding, 'test-token', params)).rejects.toThrow(
PlatformError
)
expect(spy).not.toHaveBeenCalled()
})
test('should merge workspace contacts without accounts for a Maintainer', async () => {
asWorkspaceMaintainer()
const spy = jest.spyOn(utils, 'doMergePersons').mockResolvedValue()
await mergeSpecifiedPersons(mockCtx, mockDb, mockBranding, 'test-token', params)
expect(spy).toHaveBeenCalledWith(mockDb, primaryPerson, secondaryPerson)
})
test('should merge a contact into a member of the caller workspace', async () => {
mockDb.getWorkspaceRole.mockImplementation(async (account: AccountUuid) =>
account === secondaryPerson ? null : AccountRole.Maintainer
)
mockDb.account.findOne.mockImplementation(async ({ uuid }: { uuid: AccountUuid }) =>
uuid === primaryPerson ? { uuid } : null
)
const spy = jest.spyOn(utils, 'doMergePersons').mockResolvedValue()
await mergeSpecifiedPersons(mockCtx, mockDb, mockBranding, 'test-token', params)
expect(spy).toHaveBeenCalledWith(mockDb, primaryPerson, secondaryPerson)
})
test('should throw Forbidden when a login capable social id would move onto a foreign account', async () => {
// A maintainer minting a person that carries their own email and merging it into a
// co-member would hand them that member's account through password recovery.
mockDb.getWorkspaceRole.mockImplementation(async (account: AccountUuid) =>
account === secondaryPerson ? null : AccountRole.Maintainer
)
mockDb.account.findOne.mockImplementation(async ({ uuid }: { uuid: AccountUuid }) =>
uuid === primaryPerson ? { uuid } : null
)
mockDb.socialId.find.mockResolvedValue([{ _id: 'attacker-email', type: SocialIdType.EMAIL }])
const spy = jest.spyOn(utils, 'doMergePersons').mockResolvedValue()
await expect(mergeSpecifiedPersons(mockCtx, mockDb, mockBranding, 'test-token', params)).rejects.toThrow(
PlatformError
)
expect(spy).not.toHaveBeenCalled()
})
test('should allow a login capable social id to move onto the caller own account', async () => {
;(decodeTokenVerbose as jest.Mock).mockReturnValue({
account: primaryPerson as unknown as AccountUuid,
workspace: workspaceUuid,
extra: {}
})
mockDb.getWorkspaceRole.mockImplementation(async (account: AccountUuid) =>
account === secondaryPerson ? null : AccountRole.Maintainer
)
mockDb.account.findOne.mockImplementation(async ({ uuid }: { uuid: AccountUuid }) =>
uuid === primaryPerson ? { uuid } : null
)
mockDb.socialId.find.mockResolvedValue([{ _id: 'own-email', type: SocialIdType.EMAIL }])
const spy = jest.spyOn(utils, 'doMergePersons').mockResolvedValue()
await mergeSpecifiedPersons(mockCtx, mockDb, mockBranding, 'test-token', params)
expect(spy).toHaveBeenCalledWith(mockDb, primaryPerson, secondaryPerson)
})
test('should throw Forbidden when merging the platform guest account', async () => {
asWorkspaceMaintainer()
const spy = jest.spyOn(utils, 'doMergePersons').mockResolvedValue()
await expect(
mergeSpecifiedPersons(mockCtx, mockDb, mockBranding, 'test-token', {
primaryPerson: readOnlyGuestAccountUuid as PersonUuid,
secondaryPerson
})
).rejects.toThrow(PlatformError)
expect(spy).not.toHaveBeenCalled()
})
test('should merge for an allowed service token', async () => {
;(decodeTokenVerbose as jest.Mock).mockReturnValue({
account: systemAccountUuid,
extra: { service: 'tool' }
})
const spy = jest.spyOn(utils, 'doMergePersons').mockResolvedValue()
await mergeSpecifiedPersons(mockCtx, mockDb, mockBranding, 'test-token', params)
expect(spy).toHaveBeenCalledWith(mockDb, primaryPerson, secondaryPerson)
expect(mockDb.getWorkspaceRole).not.toHaveBeenCalled()
})
test('should merge for a global admin token', async () => {
;(decodeTokenVerbose as jest.Mock).mockReturnValue({
account: callerUuid,
extra: { admin: 'true' }
})
const spy = jest.spyOn(utils, 'doMergePersons').mockResolvedValue()
await mergeSpecifiedPersons(mockCtx, mockDb, mockBranding, 'test-token', params)
expect(spy).toHaveBeenCalledWith(mockDb, primaryPerson, secondaryPerson)
expect(mockDb.getWorkspaceRole).not.toHaveBeenCalled()
})
})
describe('canMergeSpecifiedPersons', () => {
beforeEach(() => {
asWorkspaceMaintainer()
})
// The merge dialog awaits this predicate without a catch, so refusals must be answered,
// not thrown: a rejection leaves it spinning on a disabled Save button forever.
test('should return false without looking persons up when caller does not maintain a workspace', async () => {
mockDb.getWorkspaceRole.mockResolvedValue(null)
expect(await canMergeSpecifiedPersons(mockCtx, mockDb, mockBranding, 'test-token', params)).toBe(false)
expect(mockDb.person.findOne).not.toHaveBeenCalled()
})
test('should return false when a person is an account of another workspace', async () => {
mockDb.account.findOne.mockImplementation(async ({ uuid }: { uuid: AccountUuid }) =>
uuid === secondaryPerson ? { uuid } : null
)
expect(await canMergeSpecifiedPersons(mockCtx, mockDb, mockBranding, 'test-token', params)).toBe(false)
expect(mockDb.person.findOne).not.toHaveBeenCalled()
})
test('should return false for equal persons without authorizing or looking them up', async () => {
const result = await canMergeSpecifiedPersons(mockCtx, mockDb, mockBranding, 'test-token', {
primaryPerson,
secondaryPerson: primaryPerson
})
expect(result).toBe(false)
expect(mockDb.person.findOne).not.toHaveBeenCalled()
expect(mockDb.getWorkspaceRole).not.toHaveBeenCalled()
})
test('should return true for a Maintainer when secondary has no verified social ids', async () => {
const result = await canMergeSpecifiedPersons(mockCtx, mockDb, mockBranding, 'test-token', params)
expect(result).toBe(true)
expect(mockDb.socialId.find).toHaveBeenCalledWith({ personUuid: secondaryPerson, verifiedOn: { $ne: null } })
})
test('should return false when secondary person has verified social ids', async () => {
mockDb.socialId.find.mockResolvedValue([{ _id: 'verified-social-id' }])
const result = await canMergeSpecifiedPersons(mockCtx, mockDb, mockBranding, 'test-token', params)
expect(result).toBe(false)
})
test('should allow an allowed service token', async () => {
;(decodeTokenVerbose as jest.Mock).mockReturnValue({
account: systemAccountUuid,
extra: { service: 'tool' }
})
expect(await canMergeSpecifiedPersons(mockCtx, mockDb, mockBranding, 'test-token', params)).toBe(true)
expect(mockDb.getWorkspaceRole).not.toHaveBeenCalled()
})
test('should allow a global admin token', async () => {
;(decodeTokenVerbose as jest.Mock).mockReturnValue({
account: callerUuid,
extra: { admin: 'true' }
})
expect(await canMergeSpecifiedPersons(mockCtx, mockDb, mockBranding, 'test-token', params)).toBe(true)
expect(mockDb.getWorkspaceRole).not.toHaveBeenCalled()
})
})
})
+90 -3
View File
@@ -37,7 +37,13 @@ import {
type IntegrationKind
} from '@hcengineering/core'
import platform, { getMetadata, PlatformError, Severity, Status, translate } from '@hcengineering/platform'
import { decodeToken, decodeTokenVerbose, generateToken, type PermissionsGrant } from '@hcengineering/server-token'
import {
decodeToken,
decodeTokenVerbose,
generateToken,
type PermissionsGrant,
type Token
} from '@hcengineering/server-token'
import { isAdminEmail } from './admin'
import { accountPlugin } from './plugin'
@@ -2877,6 +2883,79 @@ export async function deleteAccount (
})
}
// Social ids that resolve to an account on their own, and therefore hand over the ability to
// authenticate as its owner once they are re-pointed. Password recovery and OTP login look an
// account up by social id value alone (see requestPasswordReset, loginOtp).
const loginCapableSocialTypes = [SocialIdType.EMAIL, SocialIdType.HULY]
/**
* Merging re-points the secondary person's social ids onto the primary person, so an unrestricted
* caller could both absorb the identifiers of a person they do not own and inject their own
* identifiers into somebody else's person. Restrict it to callers with authority over both persons.
*/
async function verifyMergePersonsAuthority (
db: AccountDB,
{ account, workspace, extra }: Token,
primaryPerson: PersonUuid,
secondaryPerson: PersonUuid,
shouldThrow = true
): Promise<boolean> {
// Global admins and the tool/workspace services act on behalf of the whole installation,
// the same way the account level merge (mergeSpecifiedAccounts) allows them to.
// Note this must precede the workspace check below: such tokens carry no workspace.
if (extra?.admin === 'true' || verifyAllowedServices(['tool', 'workspace'], extra, false)) {
return true
}
const forbidden = (): boolean => {
if (shouldThrow) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
return false
}
// Everybody else acts within a single workspace they maintain.
if (workspace == null) {
return forbidden()
}
if (!verifyAllowedRole(await db.getWorkspaceRole(account, workspace), AccountRole.Maintainer, extra, false)) {
return forbidden()
}
// The platform wide accounts are not anybody's to merge.
for (const person of [primaryPerson, secondaryPerson]) {
if (person === systemAccountUuid || person === readOnlyGuestAccountUuid) {
return forbidden()
}
if ((await db.getWorkspaceRole(person as AccountUuid, workspace)) != null) {
// A member of the caller's workspace.
continue
}
if ((await db.account.findOne({ uuid: person as AccountUuid })) != null) {
// An account outside of the caller's workspace: no workspace maintainer may take it over.
return forbidden()
}
}
// Both persons are in reach of the caller by now, but the primary keeps receiving the secondary's
// social ids. When the primary is somebody else's account, a login capable social id would grant
// whoever controls it access to that account, so leave those merges to the verification flows.
// Note doMergePersons only refuses *verified* secondary social ids, which does not cover this.
if (primaryPerson !== account && (await db.account.findOne({ uuid: primaryPerson as AccountUuid })) != null) {
const secondarySocialIds = await db.socialId.find({ personUuid: secondaryPerson })
if (secondarySocialIds.some((si) => loginCapableSocialTypes.includes(si.type))) {
return forbidden()
}
}
return true
}
export async function canMergeSpecifiedPersons (
ctx: MeasureContext,
db: AccountDB,
@@ -2887,7 +2966,7 @@ export async function canMergeSpecifiedPersons (
secondaryPerson: PersonUuid
}
): Promise<boolean> {
decodeTokenVerbose(ctx, token)
const decodedToken = decodeTokenVerbose(ctx, token)
const { primaryPerson, secondaryPerson } = params
if (primaryPerson == null || primaryPerson === '' || secondaryPerson == null || secondaryPerson === '') {
@@ -2899,6 +2978,12 @@ export async function canMergeSpecifiedPersons (
return false
}
// This is a predicate the merge dialog polls, so an unauthorized caller is answered
// rather than thrown at. mergeSpecifiedPersons below enforces the same rules.
if (!(await verifyMergePersonsAuthority(db, decodedToken, primaryPerson, secondaryPerson, false))) {
return false
}
const primaryPersonObj = await db.person.findOne({ uuid: primaryPerson })
if (primaryPersonObj == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.PersonNotFound, { person: primaryPerson }))
@@ -2928,13 +3013,15 @@ export async function mergeSpecifiedPersons (
secondaryPerson: PersonUuid
}
): Promise<void> {
decodeTokenVerbose(ctx, token)
const decodedToken = decodeTokenVerbose(ctx, token)
const { primaryPerson, secondaryPerson } = params
if (primaryPerson == null || primaryPerson === '' || secondaryPerson == null || secondaryPerson === '') {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
await verifyMergePersonsAuthority(db, decodedToken, primaryPerson, secondaryPerson)
await doMergePersons(db, primaryPerson, secondaryPerson)
}
@@ -0,0 +1,83 @@
//
// 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 { compareDomainDigest, findMissingBlobs } from '../utils'
describe('compareDomainDigest', () => {
it('reports nothing when workspace fully matches backup', () => {
const backup = new Map([
['doc1', 'hash1'],
['doc2', 'hash2']
])
const workspace = new Map([
['doc1', 'hash1'],
['doc2', 'hash2']
])
expect(compareDomainDigest(backup, workspace)).toEqual({ missing: [], modified: [] })
})
it('reports documents present in backup but absent from workspace as missing', () => {
const backup = new Map([
['doc1', 'hash1'],
['doc2', 'hash2']
])
const workspace = new Map([['doc1', 'hash1']])
expect(compareDomainDigest(backup, workspace)).toEqual({ missing: ['doc2'], modified: [] })
})
it('reports documents with a different hash as modified, not missing', () => {
const backup = new Map([['doc1', 'hash1']])
const workspace = new Map([['doc1', 'hash1-changed']])
expect(compareDomainDigest(backup, workspace)).toEqual({ missing: [], modified: ['doc1'] })
})
it('ignores documents present in workspace but not in backup', () => {
const backup = new Map([['doc1', 'hash1']])
const workspace = new Map([
['doc1', 'hash1'],
['doc2', 'hash2']
])
expect(compareDomainDigest(backup, workspace)).toEqual({ missing: [], modified: [] })
})
it('treats quoted and unquoted equal hashes as the same (matches restore hash trimming)', () => {
const backup = new Map([['doc1', '"hash1"']])
const workspace = new Map([['doc1', 'hash1']])
expect(compareDomainDigest(backup, workspace)).toEqual({ missing: [], modified: [] })
})
it('returns an empty result for an empty backup digest', () => {
const workspace = new Map([['doc1', 'hash1']])
expect(compareDomainDigest(new Map(), workspace)).toEqual({ missing: [], modified: [] })
})
})
describe('findMissingBlobs', () => {
it('returns nothing when every backup blob exists in storage', () => {
expect(findMissingBlobs(['blob1', 'blob2'], new Set(['blob1', 'blob2', 'blob3']))).toEqual([])
})
it('reports backup blobs absent from storage', () => {
expect(findMissingBlobs(['blob1', 'blob2'], new Set(['blob1']))).toEqual(['blob2'])
})
it('reports all backup blobs when storage is empty', () => {
expect(findMissingBlobs(['blob1', 'blob2'], new Set())).toEqual(['blob1', 'blob2'])
})
it('returns nothing for an empty list of backup blobs', () => {
expect(findMissingBlobs([], new Set(['blob1']))).toEqual([])
})
})
+254
View File
@@ -0,0 +1,254 @@
//
// 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 {
Doc,
Domain,
DOMAIN_BLOB,
MeasureContext,
Ref,
type Blob,
type LowLevelStorage,
type WorkspaceIds
} from '@hcengineering/core'
import { BackupClientOps, createDummyStorageAdapter, type Pipeline } from '@hcengineering/server-core'
import { gunzipSync } from 'zlib'
import { BackupStorage } from './storage'
import type { BackupDocId, BackupInfo, BackupSnapshot } from './types'
import { compareDomainDigest, findMissingBlobs, isAccountDomain, loadDigest } from './utils'
export * from './storage'
/**
* @public
*/
export interface DomainCheckResult {
domain: Domain
backupCount: number
workspaceCount: number
missing: BackupDocId[]
modified: BackupDocId[]
}
/**
* @public
*/
export interface BlobCheckResult {
total: number
missing: Ref<Blob>[]
ok: boolean
}
/**
* @public
*/
export interface WorkspaceCheckResult {
date: number
domains: DomainCheckResult[]
blobs: BlobCheckResult
ok: boolean
}
async function resolveSnapshots (
storage: BackupStorage,
date: number
): Promise<{ backupInfo: BackupInfo, snapshots: BackupSnapshot[], date: number }> {
const infoFile = 'backup.json.gz'
if (!(await storage.exists(infoFile))) {
throw new Error(`${infoFile} should present to check`)
}
const backupInfo: BackupInfo = JSON.parse(gunzipSync(new Uint8Array(await storage.loadFile(infoFile))).toString())
let snapshots = backupInfo.snapshots
if (date !== -1) {
const bk = backupInfo.snapshots.findIndex((it) => it.date === date)
if (bk === -1) {
throw new Error(`${infoFile} has no snapshot at ${date}`)
}
snapshots = backupInfo.snapshots.slice(0, bk + 1)
} else {
date = snapshots[snapshots.length - 1]?.date ?? -1
}
return { backupInfo, snapshots, date }
}
/**
* Checks whether all documents recorded in a backup are present, and unchanged, in the given
* workspace's document domains, and whether every backed-up blob's content exists in blob
* storage (see {@link checkWorkspaceBlobs}).
*
* This is read-only: nothing is uploaded, removed, or otherwise modified in either the workspace
* or the backup. It is meant as a diagnostic to run before trusting a backup (or after a restore)
* — to find out if the workspace is missing data the backup has, without acting on it.
*
* Account domains (person/socialId) are skipped, since they live in the account database rather
* than in the workspace's own domains and can't be checked against `pipeline.context.lowLevelStorage`.
*
* @param date optional snapshot date to check against, defaults to the latest snapshot (-1).
* @public
*/
export async function checkWorkspaceBackup (
ctx: MeasureContext,
pipeline: Pipeline,
wsIds: WorkspaceIds,
storage: BackupStorage,
date: number = -1
): Promise<WorkspaceCheckResult> {
const resolved = await resolveSnapshots(storage, date)
const snapshots = resolved.snapshots
date = resolved.date
ctx.info('checking workspace against backup', { workspace: wsIds.uuid, date })
const domains = new Set<Domain>()
for (const s of snapshots) {
Object.keys(s.domains).forEach((it) => domains.add(it as Domain))
}
const connection = pipeline.context.lowLevelStorage as LowLevelStorage
const ops = new BackupClientOps(connection)
const results: DomainCheckResult[] = []
for (const domain of domains) {
if (isAccountDomain(domain)) {
continue
}
ctx.info('checking domain', { domain })
const backupDigest = (await loadDigest(ctx, storage, snapshots, domain, date)) as Map<Ref<Doc>, string>
const workspaceDigest = new Map<Ref<Doc>, string>()
let idx: number | undefined
try {
while (true) {
const it = await ops.loadChunk(ctx, domain, idx)
idx = it.idx
for (const { id, hash } of it.docs) {
workspaceDigest.set(id as Ref<Doc>, hash)
}
if (it.finished) {
break
}
}
} finally {
if (idx !== undefined) {
await ops.closeChunk(ctx, idx)
}
}
const { missing, modified } = compareDomainDigest(backupDigest, workspaceDigest)
const result: DomainCheckResult = {
domain,
backupCount: backupDigest.size,
workspaceCount: workspaceDigest.size,
missing,
modified
}
results.push(result)
if (missing.length > 0 || modified.length > 0) {
ctx.warn('backup data not fully present in workspace', {
domain,
backupCount: result.backupCount,
workspaceCount: result.workspaceCount,
missing: missing.length,
modified: modified.length,
sampleMissing: missing.slice(0, 10),
sampleModified: modified.slice(0, 10)
})
} else {
ctx.info('domain ok', { domain, count: result.backupCount })
}
}
const blobs = await checkWorkspaceBlobs(ctx, pipeline, wsIds, storage, date, snapshots)
const ok = results.every((it) => it.missing.length === 0 && it.modified.length === 0) && blobs.ok
ctx.info('check complete', {
workspace: wsIds.uuid,
ok,
domains: results.length,
missing: results.reduce((sum, it) => sum + it.missing.length, 0),
modified: results.reduce((sum, it) => sum + it.modified.length, 0),
missingBlobs: blobs.missing.length
})
return { date, domains: results, blobs, ok }
}
/**
* Checks whether every blob recorded in a backup actually has its content present in the
* workspace's blob storage (S3/minio/datalake), as opposed to just a metadata record in
* `DOMAIN_BLOB`.
*
* Read-only: only lists and stats existing blobs, never uploads or removes anything.
*
* @param date optional snapshot date to check against, defaults to the latest snapshot (-1).
* @param snapshots pre-resolved snapshots, to avoid re-reading `backup.json.gz` when called from
* {@link checkWorkspaceBackup}. If omitted, it's resolved from `storage`/`date`.
* @public
*/
export async function checkWorkspaceBlobs (
ctx: MeasureContext,
pipeline: Pipeline,
wsIds: WorkspaceIds,
storage: BackupStorage,
date: number = -1,
snapshots?: BackupSnapshot[]
): Promise<BlobCheckResult> {
if (snapshots === undefined) {
const resolved = await resolveSnapshots(storage, date)
snapshots = resolved.snapshots
date = resolved.date
}
ctx.info('checking blobs against backup', { workspace: wsIds.uuid, date })
const backupDigest = await loadDigest(ctx, storage, snapshots, DOMAIN_BLOB, date)
const storageAdapter = pipeline.context.storageAdapter ?? createDummyStorageAdapter()
const existingBlobIds = new Set<string>()
const iterator = await storageAdapter.listStream(ctx, wsIds)
try {
while (true) {
const batch = await iterator.next()
if (batch.length === 0) {
break
}
for (const b of batch) {
existingBlobIds.add(b._id)
}
}
} finally {
await iterator.close()
}
const missing = findMissingBlobs(backupDigest.keys(), existingBlobIds) as Ref<Blob>[]
const ok = missing.length === 0
if (ok) {
ctx.info('blobs ok', { total: backupDigest.size })
} else {
ctx.warn('backup blobs missing from storage', {
total: backupDigest.size,
missing: missing.length,
sampleMissing: missing.slice(0, 10)
})
}
return { total: backupDigest.size, missing, ok }
}
+1
View File
@@ -13,6 +13,7 @@
// limitations under the License.
//
export * from './backup'
export * from './check'
export * from './restore'
export * from './service'
export * from './types'
+45
View File
@@ -941,6 +941,51 @@ export function doTrimHash (s: string | undefined): string | undefined {
return s
}
/**
* Compares a per-domain digest reconstructed from a backup with a digest read from a live
* workspace and reports the difference from the backup's point of view.
*
* - `missing` — documents present in the backup but absent from the workspace.
* - `modified` — documents present in both, but with a different content hash (the workspace
* version diverged from the backed-up one).
*
* Documents present in the workspace but not in the backup are intentionally not reported here:
* this check only answers "is everything from the backup present in the workspace", not the
* reverse.
* @public
*/
export function compareDomainDigest (
backupDigest: Map<BackupDocId, string>,
workspaceDigest: Map<BackupDocId, string>
): { missing: BackupDocId[], modified: BackupDocId[] } {
const missing: BackupDocId[] = []
const modified: BackupDocId[] = []
for (const [id, hash] of backupDigest) {
const workspaceHash = workspaceDigest.get(id)
if (workspaceHash === undefined) {
missing.push(id)
} else if (doTrimHash(workspaceHash) !== doTrimHash(hash)) {
modified.push(id)
}
}
return { missing, modified }
}
/**
* Finds blob ids that are recorded in a backup but do not exist in the workspace's blob storage
* (e.g. S3/minio/datalake), as opposed to just the blob metadata record in a domain.
* @public
*/
export function findMissingBlobs (backupBlobIds: Iterable<BackupDocId>, existingBlobIds: Set<string>): BackupDocId[] {
const missing: BackupDocId[] = []
for (const id of backupBlobIds) {
if (!existingBlobIds.has(id as string)) {
missing.push(id)
}
}
return missing
}
export async function loadDigest (
ctx: MeasureContext,
storage: BackupStorage,
+2 -1
View File
@@ -738,7 +738,8 @@ export class PlatformWorker {
}
async checkRefreshToken (ctx: MeasureContext, auth: GithubUserRecord, force: boolean = false): Promise<boolean> {
if (auth.refreshToken != null && auth.expiresIn != null && auth.expiresIn < Date.now() / 1000) {
const expired = auth.expiresIn != null && auth.expiresIn < Date.now() / 1000
if (auth.refreshToken != null && (force || expired)) {
const uri =
'https://github.com/login/oauth/access_token?' +
makeQuery({
+3 -2
View File
@@ -36,11 +36,12 @@ export class UserManager {
}
private secretToUserRecord (secret: IntegrationSecret, login: string): GithubUserRecord | undefined {
const parsed = JSON.parse(secret.secret) ?? {} // TODO: Add security
return {
...(JSON.parse(secret.secret) ?? {}), // TODO: Add security
...parsed,
account: secret.socialId,
_id: login,
accounts: {}
accounts: parsed.accounts ?? {}
}
}
+1
View File
@@ -643,6 +643,7 @@ export class GithubWorker implements IntegrationManager {
ctx.info('get octokit', { account, recordId: record._id, workspace: this.workspace.uuid })
if (!(await this.platform.checkRefreshToken(ctx, record))) {
record.octokit = undefined
return undefined
}
if (record.octokit !== undefined) {
return record.octokit
@@ -0,0 +1,53 @@
//
// 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 { stat } from 'fs/promises'
import { withTempFile } from '../tempfile'
async function exists (path: string): Promise<boolean> {
try {
await stat(path)
return true
} catch {
return false
}
}
describe('withTempFile', () => {
it('removes the temp dir after a successful run', async () => {
let capturedDir = ''
const result = await withTempFile('content.txt', Buffer.from('hello'), async (filePath, tempDir) => {
capturedDir = tempDir
expect(await exists(filePath)).toBe(true)
return 'ok'
})
expect(result).toBe('ok')
expect(await exists(capturedDir)).toBe(false)
})
it('removes the temp dir even when the run callback throws', async () => {
let capturedDir = ''
await expect(
withTempFile('content.txt', Buffer.from('hello'), async (_filePath, tempDir) => {
capturedDir = tempDir
throw new Error('boom')
})
).rejects.toThrow('boom')
expect(capturedDir).not.toBe('')
expect(await exists(capturedDir)).toBe(false)
})
})
+24 -30
View File
@@ -1,8 +1,6 @@
import { exec } from 'child_process'
import { mkdtemp, rm, rmdir, writeFile } from 'fs/promises'
import { contentType } from 'mime-types'
import { tmpdir } from 'os'
import { join } from 'path'
import { withTempFile } from '../tempfile'
import { rtfExtractor } from './rtf'
import { DocumentExtractor } from './types'
@@ -18,36 +16,32 @@ export const docExtractor: DocumentExtractor = {
},
async extract (fileName: string, type: string, data): Promise<string> {
const tempDir = await mkdtemp(join(tmpdir(), 'rekoni-'))
const distFileName = join(tempDir, 'content.doc')
await writeFile(distFileName, data)
const text = await new Promise<string>((resolve, reject) => {
exec(
`antiword -i 1 -f -m UTF-8 "${distFileName}"`,
{ encoding: 'utf-8', cwd: tempDir },
(error, stdout, stderr) => {
if (error != null) {
if (stderr.includes('is not a Word Document. It is probably a Rich Text Format file')) {
rtfExtractor
.extract(fileName, type, data)
.then((value) => {
resolve(value)
})
.catch((err) => {
reject(err)
})
return
return await withTempFile('content.doc', data, async (distFileName, tempDir) => {
return await new Promise<string>((resolve, reject) => {
exec(
`antiword -i 1 -f -m UTF-8 "${distFileName}"`,
{ encoding: 'utf-8', cwd: tempDir },
(error, stdout, stderr) => {
if (error != null) {
if (stderr.includes('is not a Word Document. It is probably a Rich Text Format file')) {
rtfExtractor
.extract(fileName, type, data)
.then((value) => {
resolve(value)
})
.catch((err) => {
reject(err)
})
return
}
reject(new Error(`Error ${JSON.stringify(error)} ${stderr}`))
} else {
resolve(stdout)
}
reject(new Error(`Error ${JSON.stringify(error)} ${stderr}`))
} else {
resolve(stdout)
}
}
)
)
})
})
await rm(distFileName)
await rmdir(tempDir)
return text
}
}
function isType (type: string): boolean {
+10 -17
View File
@@ -1,8 +1,6 @@
import { exec } from 'child_process'
import { mkdtemp, rm, rmdir, writeFile } from 'fs/promises'
import { contentType } from 'mime-types'
import { tmpdir } from 'os'
import { join } from 'path'
import { withTempFile } from '../tempfile'
import { DocumentExtractor } from './types'
export const pdfExtractor: DocumentExtractor = {
@@ -22,21 +20,16 @@ export const pdfExtractor: DocumentExtractor = {
},
async extract (fileName: string, type: string, data): Promise<string> {
const tempDir = await mkdtemp(join(tmpdir(), 'rekoni-'))
const distFileName = join(tempDir, 'content.pdf')
await writeFile(distFileName, data)
const text = await new Promise<string>((resolve, reject) => {
exec(`pdftotext -layout "${distFileName}" -`, { encoding: 'utf-8', cwd: tempDir }, (error, stdout, stderr) => {
if (error != null) {
reject(new Error(`Error ${JSON.stringify(error)} ${stderr}`))
} else {
resolve(stdout)
}
return await withTempFile('content.pdf', data, async (distFileName, tempDir) => {
return await new Promise<string>((resolve, reject) => {
exec(`pdftotext -layout "${distFileName}" -`, { encoding: 'utf-8', cwd: tempDir }, (error, stdout, stderr) => {
if (error != null) {
reject(new Error(`Error ${JSON.stringify(error)} ${stderr}`))
} else {
resolve(stdout)
}
})
})
})
await rm(distFileName)
await rmdir(tempDir)
return text
}
}
+15 -17
View File
@@ -1,8 +1,6 @@
import { exec } from 'child_process'
import { mkdtemp, rm, rmdir, writeFile } from 'fs/promises'
import { contentType } from 'mime-types'
import { tmpdir } from 'os'
import { join } from 'path'
import { withTempFile } from '../tempfile'
import { convertString } from './html'
import { DocumentExtractor } from './types'
@@ -18,23 +16,23 @@ export const rtfExtractor: DocumentExtractor = {
},
async extract (fileName: string, type: string, data): Promise<string> {
const tempDir = await mkdtemp(join(tmpdir(), 'rekoni-'))
const distFileName = join(tempDir, 'content.rtf')
await writeFile(distFileName, data)
const htmlText = await new Promise<string>((resolve, reject) => {
exec(`unrtf --nopict --html "${distFileName}"`, { encoding: 'utf-8', cwd: tempDir }, (error, stdout, stderr) => {
if (error != null) {
reject(new Error(`Error ${JSON.stringify(error)} ${stderr}`))
} else {
resolve(stdout)
}
const htmlText = await withTempFile('content.rtf', data, async (distFileName, tempDir) => {
return await new Promise<string>((resolve, reject) => {
exec(
`unrtf --nopict --html "${distFileName}"`,
{ encoding: 'utf-8', cwd: tempDir },
(error, stdout, stderr) => {
if (error != null) {
reject(new Error(`Error ${JSON.stringify(error)} ${stderr}`))
} else {
resolve(stdout)
}
}
)
})
})
const text = convertString(htmlText)
await rm(distFileName)
await rmdir(tempDir)
return text
return convertString(htmlText)
}
}
function isType (type: string): boolean {
+42
View File
@@ -0,0 +1,42 @@
//
// 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 { mkdtemp, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
/**
* Writes `data` into a fresh temp directory under a file named `fileName`, runs `run` against it,
* and guarantees the whole temp directory is removed afterwards whether `run` succeeds or throws.
*
* Extractors shell out to external binaries (pdftotext, antiword, unrtf) that can fail on malformed
* or unsupported input. Without a `finally`-guaranteed cleanup, a failed extraction leaks its temp
* directory (including the uploaded file content) for the lifetime of the host.
*
* @public
*/
export async function withTempFile<T> (
fileName: string,
data: Buffer,
run: (filePath: string, tempDir: string) => Promise<T>
): Promise<T> {
const tempDir = await mkdtemp(join(tmpdir(), 'rekoni-'))
try {
const filePath = join(tempDir, fileName)
await writeFile(filePath, data)
return await run(filePath, tempDir)
} finally {
await rm(tempDir, { recursive: true, force: true })
}
}