diff --git a/packages/text/src/markdown/serializer.ts b/packages/text/src/markdown/serializer.ts index b3c7b9695c..a78d8572db 100644 --- a/packages/text/src/markdown/serializer.ts +++ b/packages/text/src/markdown/serializer.ts @@ -466,7 +466,11 @@ export class MarkdownState implements IState { for (let i = 0; i < len; i++) { const mark = state.marks[i] - if (!this.marks[mark.type].mixable) break + const mm = this.marks[mark.type] + if (mm == null) { + break + } + if (!mm.mixable) break this.reorderMixableMark(state, mark, i, len) } } @@ -735,6 +739,9 @@ export class MarkdownState implements IState { let value = mark.attrs?.marker if (value === undefined) { const info = this.marks[mark.type] + if (info == null) { + throw new Error(`No info for mark ${mark.type}`) + } value = open ? info.open : info.close } return typeof value === 'string' ? value : value(this, mark, parent, index) ?? '' diff --git a/plugins/hr-assets/lang/ru.json b/plugins/hr-assets/lang/ru.json index fe24bea6db..323e6f1934 100644 --- a/plugins/hr-assets/lang/ru.json +++ b/plugins/hr-assets/lang/ru.json @@ -27,11 +27,11 @@ "NoEmployeesInDepartment": "Нет сотрудников в выбранном департаменте", "Vacation": "Отпуск", "Sick": "Больничный", - "PTO": "Отпуск", - "PTOs": "Отпускных дней", + "PTO": "Отгул(PTO)", + "PTOs": "Отгулов", "Remote": "Удаленно", "Overtime": "Переработка", - "PTO2": "Отпуск/2", + "PTO2": "Отгул(PTO)/2", "Overtime2": "Переработка/2", "EditRequest": "Редактировать {type}", "EditRequestType": "Редактировать тип", diff --git a/plugins/tracker-resources/src/components/issues/timereport/TimePresenter.svelte b/plugins/tracker-resources/src/components/issues/timereport/TimePresenter.svelte index cf31db36b4..9364b42829 100644 --- a/plugins/tracker-resources/src/components/issues/timereport/TimePresenter.svelte +++ b/plugins/tracker-resources/src/components/issues/timereport/TimePresenter.svelte @@ -13,9 +13,9 @@ // limitations under the License. --> @@ -63,13 +64,7 @@ class:link={kind === 'link'} class:fs-bold={accent} on:click - use:tooltip={{ - component: Label, - props: { - label: tracker.string.TimeSpendHours, - params: { value } - } - }} + use:tooltip={{ label: getEmbeddedLabel(label) }} > {label} diff --git a/pods/front/src/__start.ts b/pods/front/src/__start.ts index 9a19915188..69c05de9a1 100644 --- a/pods/front/src/__start.ts +++ b/pods/front/src/__start.ts @@ -48,5 +48,6 @@ startFront(metricsContext, { AI_URL: process.env.AI_URL, TELEGRAM_BOT_URL: process.env.TELEGRAM_BOT_URL, STATS_URL: process.env.STATS_API ?? process.env.STATS_URL, - BACKUP_URL: process.env.BACKUP_URL + BACKUP_URL: process.env.BACKUP_URL, + TRANSACTOR_OVERRIDE: process.env.TRANSACTOR_OVERRIDE }) diff --git a/server/server/src/utils.ts b/server/server/src/utils.ts index 6475799706..84cddb0fe6 100644 --- a/server/server/src/utils.ts +++ b/server/server/src/utils.ts @@ -78,6 +78,7 @@ export function processRequest ( } } } + export function sendResponse ( ctx: MeasureContext, session: Session, diff --git a/services/github/pod-github/src/platform.ts b/services/github/pod-github/src/platform.ts index b39270a77f..05d05bbb11 100644 --- a/services/github/pod-github/src/platform.ts +++ b/services/github/pod-github/src/platform.ts @@ -11,6 +11,7 @@ import core, { ClientConnectEvent, DocumentUpdate, isActiveMode, + isDeletingMode, MeasureContext, RateLimiter, TimeRateLimiter, @@ -714,35 +715,37 @@ export class PlatformWorker { return Array.from(workspaces) } - async checkWorkspaceIsActive (token: string, workspace: string): Promise { + async checkWorkspaceIsActive ( + token: string, + workspace: string + ): Promise<{ workspaceInfo: WorkspaceInfoWithStatus | undefined, needRecheck: boolean }> { let workspaceInfo: WorkspaceInfoWithStatus | undefined try { workspaceInfo = await getAccountClient(token).getWorkspaceInfo(true) } catch (err: any) { this.ctx.error('Workspace not found:', { workspace }) - return + return { workspaceInfo: undefined, needRecheck: false } } if (workspaceInfo?.uuid === undefined) { this.ctx.error('No workspace exists for workspaceId', { workspace }) - return + return { workspaceInfo: undefined, needRecheck: false } + } + if (workspaceInfo?.isDisabled === true || isDeletingMode(workspaceInfo?.mode)) { + this.ctx.warn('Workspace is disabled', { workspace }) + return { workspaceInfo: undefined, needRecheck: false } } if (!isActiveMode(workspaceInfo?.mode)) { - this.ctx.warn('Workspace is in maitenance, skipping for now.', { workspace }) - return - } - if (workspaceInfo?.isDisabled === true) { - this.ctx.warn('Workspace is disabled', { workspace }) - return + this.ctx.warn('Workspace is in maitenance, skipping for now.', { workspace, mode: workspaceInfo?.mode }) + return { workspaceInfo: undefined, needRecheck: true } } const lastVisit = (Date.now() - (workspaceInfo.lastVisit ?? 0)) / (3600 * 24 * 1000) // In days if (config.WorkspaceInactivityInterval > 0 && lastVisit > config.WorkspaceInactivityInterval) { this.ctx.warn('Workspace is inactive for too long, skipping for now.', { workspace }) - return + return { workspaceInfo: undefined, needRecheck: true } } - - return workspaceInfo + return { workspaceInfo, needRecheck: true } } private async checkWorkspaces (): Promise { @@ -779,9 +782,11 @@ export class PlatformWorker { } await rateLimiter.add(async () => { const token = generateToken(systemAccountUuid, workspace, { service: 'github', mode: 'github' }) - const workspaceInfo = await this.checkWorkspaceIsActive(token, workspace) + const { workspaceInfo, needRecheck } = await this.checkWorkspaceIsActive(token, workspace) if (workspaceInfo === undefined) { - errors++ + if (needRecheck) { + errors++ + } return } try { diff --git a/services/github/pod-github/src/sync/issueBase.ts b/services/github/pod-github/src/sync/issueBase.ts index 27793567b5..ccd645ddee 100644 --- a/services/github/pod-github/src/sync/issueBase.ts +++ b/services/github/pod-github/src/sync/issueBase.ts @@ -499,7 +499,15 @@ export abstract class IssueSyncManagerBase { return (pField.node.options ?? []).find((it) => it.id === field.optionId) } - findOptionId (container: ContainerFocus, fieldId: string, value: string, target: IssueSyncTarget): string | undefined { + findOptionId ( + container: ContainerFocus, + fieldId: string, + value: string | null, + target: IssueSyncTarget + ): string | undefined { + if (value == null) { + return + } const structure = container.container.projectStructure.get(target.target._id) if (structure === undefined) { return @@ -508,7 +516,7 @@ export abstract class IssueSyncManagerBase { if (pField === undefined) { return undefined } - return (pField.node.options ?? []).find((it) => it.name.toLowerCase() === value.toLowerCase())?.id + return (pField.node.options ?? []).find((it) => it.name?.toLowerCase() === value.toLowerCase())?.id } async toPlatformField ( diff --git a/services/github/pod-github/src/sync/projects.ts b/services/github/pod-github/src/sync/projects.ts index 088e6263a2..9007ea458b 100644 --- a/services/github/pod-github/src/sync/projects.ts +++ b/services/github/pod-github/src/sync/projects.ts @@ -346,7 +346,7 @@ export class ProjectsSyncManager implements DocSyncManager { derivedClient: TxOperations, deleteExisting: boolean ): Promise { - return false + return true } async externalSync ( diff --git a/services/github/pod-github/src/sync/utils.ts b/services/github/pod-github/src/sync/utils.ts index a36c72c64c..af1756c435 100644 --- a/services/github/pod-github/src/sync/utils.ts +++ b/services/github/pod-github/src/sync/utils.ts @@ -127,6 +127,9 @@ export async function getSinceRaw ( export function gqlp (params: Record): string { let result = '' let first = true + function escape (str: string): string { + return str.replace(/"/g, '\\"') + } for (const [k, v] of Object.entries(params)) { if (v !== undefined) { if (!first) { @@ -136,9 +139,9 @@ export function gqlp (params: Record `"${it}"`).join(', ')}]` + result += `${k}: [${v.map((it) => `"${escape(it)}"`).join(', ')}]` } else { - result += `${k}: "${v}"` + result += `${k}: "${escape(v)}"` } } } diff --git a/services/github/pod-github/src/worker.ts b/services/github/pod-github/src/worker.ts index 5c8554bb42..b459a15230 100644 --- a/services/github/pod-github/src/worker.ts +++ b/services/github/pod-github/src/worker.ts @@ -238,7 +238,10 @@ export class GithubWorker implements IntegrationManager { } } - async getAccountU (user: User): Promise { + async getAccountU (user?: User): Promise { + if (user == null) { + return undefined + } return await this.getAccount({ id: user.node_id, login: user.login, @@ -1126,38 +1129,41 @@ export class GithubWorker implements IntegrationManager { this.ctx.error('Failed to perform full sync', { error: err }) }) } - - const { projects, repositories } = await this.collectActiveProjects() - if (projects.length === 0 && repositories.length === 0) { - await this.waitChanges() - continue - } - - // Check if we have documents with external sync request's pending. - const hadExternalChanges = await this.performExternalSync( - projects, - repositories, - 'externalVersion', - githubExternalSyncVersion - ) - const hadSyncChanges = await this.performSync(projects, repositories) - - // Perform derived operations - // Sync derived external data, like pull request reviews, files etc. - const hadDerivedChanges = await this.performExternalSync( - projects, - repositories, - 'derivedVersion', - githubDerivedSyncVersion - ) - - if (!hadExternalChanges && !hadSyncChanges && !hadDerivedChanges) { - if (this.previousWait !== 0) { - this.ctx.info('Wait for changes:', { previousWait: this.previousWait, workspace: this.workspace.uuid }) - this.previousWait = 0 + try { + const { projects, repositories } = await this.collectActiveProjects() + if (projects.length === 0 && repositories.length === 0) { + await this.waitChanges() + continue } - // Wait until some sync documents will be modified, updated. - await this.waitChanges() + + // Check if we have documents with external sync request's pending. + const hadExternalChanges = await this.performExternalSync( + projects, + repositories, + 'externalVersion', + githubExternalSyncVersion + ) + const hadSyncChanges = await this.performSync(projects, repositories) + + // Perform derived operations + // Sync derived external data, like pull request reviews, files etc. + const hadDerivedChanges = await this.performExternalSync( + projects, + repositories, + 'derivedVersion', + githubDerivedSyncVersion + ) + + if (!hadExternalChanges && !hadSyncChanges && !hadDerivedChanges) { + if (this.previousWait !== 0) { + this.ctx.info('Wait for changes:', { previousWait: this.previousWait, workspace: this.workspace.url }) + this.previousWait = 0 + } + // Wait until some sync documents will be modified, updated. + await this.waitChanges() + } + } catch (err: any) { + this.ctx.error('failed to perform sync', { err, workspace: this.workspace.url }) } } } @@ -1352,25 +1358,30 @@ export class GithubWorker implements IntegrationManager { const targetProject = await this.client.findOne(github.mixin.GithubProject, { _id: existing.space as Ref }) - if (await mapper.handleDelete(existing, info, derivedClient, false, parent)) { - const h = this._client.getHierarchy() - await derivedClient.remove(info) - if (h.hasMixin(existing, github.mixin.GithubIssue)) { - const mixinData = this._client.getHierarchy().as(existing, github.mixin.GithubIssue) - await this._client.update( - mixinData, - { - url: '', - githubNumber: 0, - repository: '' as Ref - }, - false, - Date.now(), - existing.modifiedBy - ) + try { + if (await mapper.handleDelete(existing, info, derivedClient, false, parent)) { + const h = this._client.getHierarchy() + await derivedClient.remove(info) + if (h.hasMixin(existing, github.mixin.GithubIssue)) { + const mixinData = this._client.getHierarchy().as(existing, github.mixin.GithubIssue) + await this._client.update( + mixinData, + { + url: '', + githubNumber: 0, + repository: '' as Ref + }, + false, + Date.now(), + existing.modifiedBy + ) + } + return } - return + } catch (err: any) { + this.ctx.error('failed to handle delete', { err }) } + if (targetProject !== undefined) { // We need to sync into new project. await derivedClient.update(info, { @@ -1386,8 +1397,12 @@ export class GithubWorker implements IntegrationManager { } if (info.deleted === true) { - if (await mapper.handleDelete(existing, info, derivedClient, true)) { - await derivedClient.remove(info) + try { + if (await mapper.handleDelete(existing, info, derivedClient, true)) { + await derivedClient.remove(info) + } + } catch (err: any) { + this.ctx.error('failed to handle delete', { err }) } return } diff --git a/services/print/pod-print/src/config.ts b/services/print/pod-print/src/config.ts index 343d5c7c40..2dc7d1ba53 100644 --- a/services/print/pod-print/src/config.ts +++ b/services/print/pod-print/src/config.ts @@ -6,15 +6,19 @@ export interface Config { Port: number Secret: string AccountsUrl: string + AllowedHostnames: string[] } const parseNumber = (str: string | undefined): number | undefined => (str !== undefined ? Number(str) : undefined) const config: Config = (() => { + const allowedHostnames = process.env.ALLOWED_HOSTNAMES + const params: Partial = { Port: parseNumber(process.env.PORT) ?? 4005, Secret: process.env.SECRET, - AccountsUrl: process.env.ACCOUNTS_URL + AccountsUrl: process.env.ACCOUNTS_URL, + AllowedHostnames: allowedHostnames == null ? [] : allowedHostnames.split(',') } const missingEnv = (Object.keys(params) as Array).filter((key) => params[key] === undefined) diff --git a/services/print/pod-print/src/main.ts b/services/print/pod-print/src/main.ts index bf77e34e98..5a6894a2d8 100644 --- a/services/print/pod-print/src/main.ts +++ b/services/print/pod-print/src/main.ts @@ -17,7 +17,7 @@ export const main = async (): Promise => { setupMetadata() const storageConfig = storageConfigFromEnv() - const { app, close } = createServer(storageConfig) + const { app, close } = createServer(storageConfig, config.AllowedHostnames) const server = listen(app, config.Port) const shutdown = (): void => { diff --git a/services/print/pod-print/src/server.ts b/services/print/pod-print/src/server.ts index 2b4e6e6d6d..a9cccc6a12 100644 --- a/services/print/pod-print/src/server.ts +++ b/services/print/pod-print/src/server.ts @@ -124,9 +124,13 @@ const wrapRequest = (fn: AsyncRequestHandler) => (req: Request, res: Response, n handleRequest(fn, req, res, next) } -export function createServer (storageConfig: StorageConfiguration): { app: Express, close: () => void } { +export function createServer ( + storageConfig: StorageConfiguration, + allowedHostnames: string[] +): { app: Express, close: () => void } { const storageAdapter = buildStorageFromConfig(storageConfig) const measureCtx = initStatisticsContext('print', {}) + const whitelistedHostnames = allowedHostnames.length > 0 ? new Set(allowedHostnames) : null const app = express() app.use(cors()) @@ -134,9 +138,20 @@ export function createServer (storageConfig: StorageConfiguration): { app: Expre app.get( '/print', - wrapRequest(async (req, res, wsIds) => { + wrapRequest(async (req, res, wsIds, token) => { const rawlink = req.query.link as string const link = decodeURIComponent(rawlink) + + // Verify that link is from the same host and protocol is among the allowed + const url = new URL(link) + if ( + !['http:', 'https:'].includes(url.protocol) || + (whitelistedHostnames != null && !whitelistedHostnames.has(url.hostname)) + ) { + console.error(`Rejected processing unexpected link: ${link}. Token: ${JSON.stringify(token)}`) + throw new ApiError(403, 'Cannot process provided link') + } + const kind = req.query.kind as PrintOptions['kind'] if (kind !== undefined && !validKinds.includes(kind as any)) { diff --git a/tests/sanity/tests/tracker/tracker.utils.ts b/tests/sanity/tests/tracker/tracker.utils.ts index 7d87d0f70c..fede06cee5 100644 --- a/tests/sanity/tests/tracker/tracker.utils.ts +++ b/tests/sanity/tests/tracker/tracker.utils.ts @@ -294,7 +294,7 @@ export function convertEstimation (estimation: number | string): string { const days = Math.floor(value / hoursInWorkingDay) const hours = Math.floor(value % hoursInWorkingDay) - const minutes = Math.floor((value % 1) * 60) + const minutes = Math.round((value % 1) * 60) const result = [ ...(days === 0 ? [] : [`${days}d`]), ...(hours === 0 ? [] : [`${hours}h`]),