mirror of
https://github.com/hcengineering/platform.git
synced 2026-08-17 18:05:42 +02:00
Merge branch 'develop' of https://github.com/hcengineering/platform into staging-new
This commit is contained in:
+68
-1583
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -123,6 +123,7 @@
|
||||
"ExecutionInitiator": "実行の発起人",
|
||||
"ExecutionStarted": "実行が開始されました",
|
||||
"Filter": "フィルター",
|
||||
"AllMatchValue": "一致するすべての値",
|
||||
"FirstMatchValue": "最初の一致する値",
|
||||
"ConfigLabel": "プロセス構成",
|
||||
"ConfigDescription": "ワークフローやビジネスプロセスを自動化するためのプロセスを定義します。",
|
||||
|
||||
@@ -124,6 +124,7 @@
|
||||
"ExecutionInitiator": "실행 시작자",
|
||||
"ExecutionStarted": "실행 시작됨",
|
||||
"Filter": "필터",
|
||||
"AllMatchValue": "모든 일치 값",
|
||||
"FirstMatchValue": "첫 일치 값",
|
||||
"ConfigLabel": "프로세스 구성",
|
||||
"ConfigDescription": "워크플로와 비즈니스 프로세스를 자동화할 프로세스를 정의합니다.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -124,6 +124,7 @@
|
||||
"ExecutionInitiator": "Инициатор выполнения",
|
||||
"ExecutionStarted": "Выполнение начато",
|
||||
"Filter": "Фильтр",
|
||||
"AllMatchValue": "Все подходящие значения",
|
||||
"FirstMatchValue": "Первое подходящее значение",
|
||||
"ConfigLabel": "Конфигурация процессов",
|
||||
"ConfigDescription": "Определяйте процессы для автоматизации рабочих процессов и бизнес-процессов.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -124,6 +124,7 @@
|
||||
"ExecutionInitiator": "执行发起者",
|
||||
"ExecutionStarted": "执行已启动",
|
||||
"Filter": "过滤",
|
||||
"AllMatchValue": "所有匹配值",
|
||||
"FirstMatchValue": "第一个匹配值",
|
||||
"ConfigLabel": "流程配置",
|
||||
"ConfigDescription": "定义流程以自动化工作流和业务流程。",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }))
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
export * from './backup'
|
||||
export * from './check'
|
||||
export * from './restore'
|
||||
export * from './service'
|
||||
export * from './types'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 ?? {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user