diff --git a/locales/en.yml b/locales/en.yml index 822ec4422a..9f58614f30 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -233,8 +233,14 @@ tools: placeholder-your-string-to-compare: Your string to compare... label-your-hash: 'Your hash: ' placeholder-your-hash-to-compare: Your hash to compare... - label-do-they-match: 'Do they match ? ' tag-copy-hash: Copy hash + hashed-string: Hashed string + comparison-result: Comparison result + matched: Matched + no-match: No match + timed-out-after-timeout-period: Timed out after {timeoutPeriod} + hashed-in-elapsed-period: Hashed in {elapsedPeriod} + compared-in-elapsed-period: Compared in {elapsedPeriod} crontab-generator: title: Crontab generator description: >- diff --git a/package.json b/package.json index 3511da5fcc..639b9744b8 100644 --- a/package.json +++ b/package.json @@ -101,7 +101,7 @@ "ansible-vault": "^1.3.0", "apache-md5": "^1.1.8", "arr-diff": "^4.0.0", - "bcryptjs": "^2.4.3", + "bcryptjs": "^3.0.3", "big.js": "^6.2.2", "braces": "^3.0.3", "bwip-js": "^4.5.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2e3217ab2f..70cce595d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -192,8 +192,8 @@ importers: specifier: ^4.0.0 version: 4.0.0 bcryptjs: - specifier: ^2.4.3 - version: 2.4.3 + specifier: ^3.0.3 + version: 3.0.3 big.js: specifier: ^6.2.2 version: 6.2.2 @@ -5068,8 +5068,9 @@ packages: bcrypt-pbkdf@1.0.2: resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} - bcryptjs@2.4.3: - resolution: {integrity: sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==} + bcryptjs@3.0.3: + resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==} + hasBin: true bencode@4.0.0: resolution: {integrity: sha512-AERXw18df0pF3ziGOCyUjqKZBVNH8HV3lBxnx5w0qtgMIk4a1wb9BkcCQbkp9Zstfrn/dzRwl7MmUHHocX3sRQ==} @@ -16273,7 +16274,7 @@ snapshots: dependencies: tweetnacl: 0.14.5 - bcryptjs@2.4.3: {} + bcryptjs@3.0.3: {} bencode@4.0.0: dependencies: diff --git a/scripts/extract-tools-strings.mjs b/scripts/extract-tools-strings.mjs index 2766c915bb..a1fd618f3f 100644 --- a/scripts/extract-tools-strings.mjs +++ b/scripts/extract-tools-strings.mjs @@ -54,8 +54,8 @@ function processVueComponent(filePath, toolName) { } const hasAlreayI18n = filePath.endsWith('.vue') - ? content.includes("const { t } = useI18n();") - : content.includes("import { translate as t } from '@/plugins/i18n.plugin';"); + ? /const\s+\{.*?\bt\b.*?\}\s+=\s+useI18n\(.*?\)/s.test(content) + : /import\s+\{.*?\btranslate\b.*?\}\s+from\s+(['"])@\/plugins\/i18n\.plugin\1/s.test(content); if (hasAlreayI18n) { console.log(`Already extracted: ${filePath}`); return; diff --git a/src/plugins/i18n.plugin.ts b/src/plugins/i18n.plugin.ts index e66ebaaf02..db35b95155 100644 --- a/src/plugins/i18n.plugin.ts +++ b/src/plugins/i18n.plugin.ts @@ -3,9 +3,11 @@ import { get } from '@vueuse/core'; import type { Plugin } from 'vue'; import { createI18n } from 'vue-i18n'; +const DEFAULT_LOCALE = String(import.meta.env.VITE_LANGUAGE || 'en'); + const i18n = createI18n({ legacy: false, - locale: import.meta.env.VITE_LANGUAGE || 'en', + locale: DEFAULT_LOCALE, messages, }); @@ -15,7 +17,8 @@ export const i18nPlugin: Plugin = { }, }; -export const translate = function (localeKey: string, list: unknown[] = []) { - const hasKey = i18n.global.te(localeKey, get(i18n.global.locale)); - return hasKey ? i18n.global.t(localeKey, list) : localeKey; -}; +export function getCurrentLocale(): string { + return get(i18n.global.locale); +} + +export const translate = i18n.global.t as typeof i18n.global.t; diff --git a/src/shims.d.ts b/src/shims.d.ts index 7f983204a1..decb0592c7 100644 --- a/src/shims.d.ts +++ b/src/shims.d.ts @@ -55,3 +55,12 @@ interface Navigator { interface FontFaceSet { add(fontFace: FontFace) } + +// TODO remove once https://github.com/microsoft/TypeScript/issues/60608 is resolved +// eslint-disable-next-line @typescript-eslint/no-namespace +namespace Intl { + class DurationFormat { + constructor(locale?: Intl.LocalesArgument, options?: { style?: 'long' }); + format(duration: { seconds?: number; milliseconds?: number }): string; + } +} diff --git a/src/tools/bcrypt/bcrypt.models.test.ts b/src/tools/bcrypt/bcrypt.models.test.ts new file mode 100644 index 0000000000..7d933410b7 --- /dev/null +++ b/src/tools/bcrypt/bcrypt.models.test.ts @@ -0,0 +1,49 @@ +import { compare, hash } from 'bcryptjs'; +import { assert, describe, expect, test } from 'vitest'; +import { type Update, bcryptWithProgressUpdates } from './bcrypt.models'; + +// simplified polyfill for https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/fromAsync +async function fromAsync(iter: AsyncIterable) { + const out: T[] = []; + for await (const val of iter) { + out.push(val); + } + return out; +} + +function checkProgressAndGetResult(updates: Update[]) { + const first = updates.at(0); + const penultimate = updates.at(-2); + const last = updates.at(-1); + const allExceptLast = updates.slice(0, -1); + + expect(allExceptLast.every(x => x.kind === 'progress')).toBeTruthy(); + expect(first).toEqual({ kind: 'progress', progress: 0 }); + expect(penultimate).toEqual({ kind: 'progress', progress: 1 }); + + assert(last != null && last.kind === 'success'); + + return last; +} + +describe('bcrypt models', () => { + describe(bcryptWithProgressUpdates.name, () => { + test('with bcrypt hash function', async () => { + const updates = await fromAsync(bcryptWithProgressUpdates(hash, ['abc', 5])); + const result = checkProgressAndGetResult(updates); + + expect(result.value).toMatch(/^\$2a\$05\$.{53}$/); + expect(result.timeTakenMs).toBeGreaterThan(0); + }); + + test('with bcrypt compare function', async () => { + const updates = await fromAsync( + bcryptWithProgressUpdates(compare, ['abc', '$2a$05$FHzYelm8Qn.IhGP.N8V1TOWFlRTK.8cphbxZSvSFo9B6HGscnQdhy']), + ); + const result = checkProgressAndGetResult(updates); + + expect(result.value).toBe(true); + expect(result.timeTakenMs).toBeGreaterThan(0); + }); + }); +}); diff --git a/src/tools/bcrypt/bcrypt.models.ts b/src/tools/bcrypt/bcrypt.models.ts new file mode 100644 index 0000000000..37fc4bff45 --- /dev/null +++ b/src/tools/bcrypt/bcrypt.models.ts @@ -0,0 +1,98 @@ +import { getCurrentLocale, translate as t } from '@/plugins/i18n.plugin'; + +Intl.DurationFormat ??= class DurationFormat { + format(duration: { seconds?: number; milliseconds?: number }): string { + return 'seconds' in duration + ? `${duration.seconds} seconds` + : `${duration.milliseconds} milliseconds`; + } +}; + +export type Update = + | { + kind: 'progress' + progress: number + } + | { + kind: 'success' + value: Result + timeTakenMs: number + } + | { + kind: 'error' + message: string + }; + +// generic type for the callback versions of bcryptjs's `hash` and `compare` +export type BcryptFn = ( + arg1: string, + arg2: Param, + callback: (err: Error | null, hash: Result) => void, + progressCallback: (percent: number) => void, +) => void; + +interface BcryptWithProgressOptions { + signal: AbortSignal + timeoutMs: number +} + +export async function* bcryptWithProgressUpdates( + fn: BcryptFn, + args: [string, Param], + options?: Partial, +): AsyncGenerator, undefined, undefined> { + const { timeoutMs = 10_000 } = options ?? {}; + const signal = AbortSignal.any([ + AbortSignal.timeout(timeoutMs), + options?.signal, + ].filter(x => x != null)); + + let res = (_: Update) => {}; + const nextPromise = () => new Promise>(resolve => res = resolve); + const promises = [nextPromise()]; + const nextValue = (value: Update) => { + res(value); + promises.push(nextPromise()); + }; + + const start = Date.now(); + + fn( + args[0], + args[1], + (err, result) => { + nextValue( + err == null + ? { kind: 'success', value: result, timeTakenMs: Date.now() - start } + : { kind: 'error', message: err.message }, + ); + }, + (progress) => { + if (signal.aborted) { + nextValue({ kind: 'progress', progress: 0 }); + if (signal.reason instanceof DOMException && signal.reason.name === 'TimeoutError') { + const message = t('tools.bcrypt.texts.timed-out-after-timeout-period', { + timeoutPeriod: new Intl.DurationFormat(getCurrentLocale(), { style: 'long' }) + .format({ seconds: Math.round(timeoutMs / 1000) }), + }); + + nextValue({ kind: 'error', message }); + } + + // throw inside callback to cancel execution of hashing/comparing + throw signal.reason; + } + else { + nextValue({ kind: 'progress', progress }); + } + }, + ); + + for await (const value of promises) { + yield value; + + if (value.kind === 'success' || value.kind === 'error') { + return; + } + } +} diff --git a/src/tools/bcrypt/bcrypt.vue b/src/tools/bcrypt/bcrypt.vue index 6b7e14620a..e9177d451a 100644 --- a/src/tools/bcrypt/bcrypt.vue +++ b/src/tools/bcrypt/bcrypt.vue @@ -1,21 +1,79 @@ -