Merge 52f498695d into 07eea0f484
				
					
				
			This commit is contained in:
		
						commit
						b5cde875f4
					
				
							
								
								
									
										49
									
								
								src/tools/bcrypt/bcrypt.models.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								src/tools/bcrypt/bcrypt.models.test.ts
									
									
									
									
									
										Normal file
									
								
							| @ -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<T>(iter: AsyncIterable<T>) { | ||||||
|  |   const out: T[] = []; | ||||||
|  |   for await (const val of iter) { | ||||||
|  |     out.push(val); | ||||||
|  |   } | ||||||
|  |   return out; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function checkProgressAndGetResult<T>(updates: Update<T>[]) { | ||||||
|  |   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); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
							
								
								
									
										93
									
								
								src/tools/bcrypt/bcrypt.models.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										93
									
								
								src/tools/bcrypt/bcrypt.models.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,93 @@ | |||||||
|  | export type Update<Result> = | ||||||
|  |   | { | ||||||
|  |     kind: 'progress' | ||||||
|  |     progress: number | ||||||
|  |   } | ||||||
|  |   | { | ||||||
|  |     kind: 'success' | ||||||
|  |     value: Result | ||||||
|  |     timeTakenMs: number | ||||||
|  |   } | ||||||
|  |   | { | ||||||
|  |     kind: 'error' | ||||||
|  |     message: string | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  | export class TimedOutError extends Error { | ||||||
|  |   name = 'TimedOutError'; | ||||||
|  | } | ||||||
|  | export class InvalidatedError extends Error { | ||||||
|  |   name = 'InvalidatedError'; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // generic type for the callback versions of bcryptjs's `hash` and `compare`
 | ||||||
|  | export type BcryptFn<Param, Result> = ( | ||||||
|  |   arg1: string, | ||||||
|  |   arg2: Param, | ||||||
|  |   callback: (err: Error | null, hash: Result) => void, | ||||||
|  |   progressCallback: (percent: number) => void, | ||||||
|  | ) => void; | ||||||
|  | 
 | ||||||
|  | interface BcryptWithProgressOptions { | ||||||
|  |   controller: AbortController | ||||||
|  |   timeoutMs: number | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export async function* bcryptWithProgressUpdates<Param, Result>( | ||||||
|  |   fn: BcryptFn<Param, Result>, | ||||||
|  |   args: [string, Param], | ||||||
|  |   options?: Partial<BcryptWithProgressOptions>, | ||||||
|  | ): AsyncGenerator<Update<Result>, undefined, undefined> { | ||||||
|  |   const { controller = new AbortController(), timeoutMs = 10_000 } = options ?? {}; | ||||||
|  | 
 | ||||||
|  |   let res = (_: Update<Result>) => {}; | ||||||
|  |   const nextPromise = () => | ||||||
|  |     new Promise<Update<Result>>((resolve) => { | ||||||
|  |       res = resolve; | ||||||
|  |     }); | ||||||
|  |   const promises = [nextPromise()]; | ||||||
|  |   const nextValue = (value: Update<Result>) => { | ||||||
|  |     res(value); | ||||||
|  |     promises.push(nextPromise()); | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   const start = Date.now(); | ||||||
|  | 
 | ||||||
|  |   fn( | ||||||
|  |     args[0], | ||||||
|  |     args[1], | ||||||
|  |     (err, value) => { | ||||||
|  |       nextValue( | ||||||
|  |         err == null | ||||||
|  |           ? { kind: 'success', value, timeTakenMs: Date.now() - start } | ||||||
|  |           : { kind: 'error', message: err.message }, | ||||||
|  |       ); | ||||||
|  |     }, | ||||||
|  |     (progress) => { | ||||||
|  |       if (controller.signal.aborted) { | ||||||
|  |         nextValue({ kind: 'progress', progress: 0 }); | ||||||
|  |         if (controller.signal.reason instanceof TimedOutError) { | ||||||
|  |           nextValue({ kind: 'error', message: controller.signal.reason.message }); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         // throw inside callback to cancel execution of hashing/comparing
 | ||||||
|  |         throw controller.signal.reason; | ||||||
|  |       } | ||||||
|  |       else { | ||||||
|  |         nextValue({ kind: 'progress', progress }); | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |   ); | ||||||
|  | 
 | ||||||
|  |   setTimeout(() => { | ||||||
|  |     controller.abort(new TimedOutError(`Timed out after ${(timeoutMs / 1000).toLocaleString('en-US')}\xA0seconds`)); | ||||||
|  |   }, timeoutMs); | ||||||
|  | 
 | ||||||
|  |   for await (const value of promises) { | ||||||
|  |     yield value; | ||||||
|  | 
 | ||||||
|  |     if (value.kind === 'success' || value.kind === 'error') { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
| @ -1,18 +1,76 @@ | |||||||
| <script setup lang="ts"> | <script setup lang="ts"> | ||||||
| import { compareSync, hashSync } from 'bcryptjs'; | import { compare, hash } from 'bcryptjs'; | ||||||
| import { useThemeVars } from 'naive-ui'; | import { useThemeVars } from 'naive-ui'; | ||||||
|  | import { type BcryptFn, InvalidatedError, bcryptWithProgressUpdates } from './bcrypt.models'; | ||||||
| import { useCopy } from '@/composable/copy'; | import { useCopy } from '@/composable/copy'; | ||||||
| 
 | 
 | ||||||
| const themeVars = useThemeVars(); | const themeVars = useThemeVars(); | ||||||
| 
 | 
 | ||||||
|  | interface ExecutionState<T> { | ||||||
|  |   result: T | null | ||||||
|  |   percentage: number | ||||||
|  |   error: string | null | ||||||
|  |   timeTakenMs: number | null | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | const blankState = () => ({ result: null, percentage: 0, error: null, timeTakenMs: null }); | ||||||
|  | 
 | ||||||
|  | async function exec<Param, Result>( | ||||||
|  |   fn: BcryptFn<Param, Result>, | ||||||
|  |   args: [string | null, Param | null], | ||||||
|  |   controller: AbortController, | ||||||
|  |   state: ExecutionState<Result>, | ||||||
|  | ) { | ||||||
|  |   const [arg0, arg1] = args; | ||||||
|  |   if (arg0 == null || arg1 == null) { | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   for await (const update of bcryptWithProgressUpdates(fn, [arg0, arg1], { controller, timeoutMs: 10_000 })) { | ||||||
|  |     switch (update.kind) { | ||||||
|  |       case 'progress': { | ||||||
|  |         state.percentage = Math.round(update.progress * 100); | ||||||
|  |         break; | ||||||
|  |       } | ||||||
|  |       case 'success': { | ||||||
|  |         state.result = update.value; | ||||||
|  |         state.timeTakenMs = update.timeTakenMs; | ||||||
|  |         break; | ||||||
|  |       } | ||||||
|  |       case 'error': { | ||||||
|  |         state.error = update.message; | ||||||
|  |         break; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function initWatcher<Param, Result>( | ||||||
|  |   fn: BcryptFn<Param, Result>, | ||||||
|  |   inputs: [Ref<string | null>, Ref<Param | null>], | ||||||
|  |   state: Ref<ExecutionState<Result>>, | ||||||
|  | ) { | ||||||
|  |   let controller = new AbortController(); | ||||||
|  |   watch(inputs, (inputs) => { | ||||||
|  |     controller.abort(new InvalidatedError()); | ||||||
|  |     controller = new AbortController(); | ||||||
|  |     state.value = blankState(); | ||||||
|  |     exec(fn, inputs, controller, state.value); | ||||||
|  |   }); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | const hashState = ref<ExecutionState<string>>(blankState()); | ||||||
| const input = ref(''); | const input = ref(''); | ||||||
| const saltCount = ref(10); | const saltCount = ref(10); | ||||||
| const hashed = computed(() => hashSync(input.value, saltCount.value)); | initWatcher(hash, [input, saltCount], hashState); | ||||||
| const { copy } = useCopy({ source: hashed, text: 'Hashed string copied to the clipboard' }); |  | ||||||
| 
 | 
 | ||||||
|  | const source = computed(() => hashState.value.result ?? ''); | ||||||
|  | const { copy } = useCopy({ source, text: 'Hashed string copied to the clipboard' }); | ||||||
|  | 
 | ||||||
|  | const compareState = ref<ExecutionState<boolean>>(blankState()); | ||||||
| const compareString = ref(''); | const compareString = ref(''); | ||||||
| const compareHash = ref(''); | const compareHash = ref(''); | ||||||
| const compareMatch = computed(() => compareSync(compareString.value, compareHash.value)); | initWatcher(compare, [compareString, compareHash], compareState); | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <template> | <template> | ||||||
| @ -28,10 +86,19 @@ const compareMatch = computed(() => compareSync(compareString.value, compareHash | |||||||
|       mb-2 |       mb-2 | ||||||
|     /> |     /> | ||||||
|     <n-form-item label="Salt count: " label-placement="left" label-width="120"> |     <n-form-item label="Salt count: " label-placement="left" label-width="120"> | ||||||
|       <n-input-number v-model:value="saltCount" placeholder="Salt rounds..." :max="100" :min="0" w-full /> |       <n-input-number v-model:value="saltCount" placeholder="Salt rounds..." :max="20" :min="0" w-full /> | ||||||
|     </n-form-item> |     </n-form-item> | ||||||
| 
 | 
 | ||||||
|     <c-input-text :value="hashed" readonly text-center /> |     <n-progress :percentage="hashState.percentage" :show-indicator="false" /> | ||||||
|  |     <c-input-text | ||||||
|  |       :value="hashState.result ?? undefined" | ||||||
|  |       :placeholder="hashState.error ?? 'Hashed string'" | ||||||
|  |       readonly | ||||||
|  |       text-center | ||||||
|  |     /> | ||||||
|  |     <div mt-1 h-3 op-60> | ||||||
|  |       {{ hashState.timeTakenMs == null ? '' : `Hashed in ${hashState.timeTakenMs}\xA0ms` }} | ||||||
|  |     </div> | ||||||
| 
 | 
 | ||||||
|     <div mt-5 flex justify-center> |     <div mt-5 flex justify-center> | ||||||
|       <c-button @click="copy()"> |       <c-button @click="copy()"> | ||||||
| @ -48,21 +115,37 @@ const compareMatch = computed(() => compareSync(compareString.value, compareHash | |||||||
|       <n-form-item label="Your hash: " label-placement="left"> |       <n-form-item label="Your hash: " label-placement="left"> | ||||||
|         <c-input-text v-model:value="compareHash" placeholder="Your hash to compare..." raw-text /> |         <c-input-text v-model:value="compareHash" placeholder="Your hash to compare..." raw-text /> | ||||||
|       </n-form-item> |       </n-form-item> | ||||||
|       <n-form-item label="Do they match ? " label-placement="left" :show-feedback="false"> | 
 | ||||||
|         <div class="compare-result" :class="{ positive: compareMatch }"> |       <n-progress :percentage="compareState.percentage" :show-indicator="false" /> | ||||||
|           {{ compareMatch ? 'Yes' : 'No' }} |       <div> | ||||||
|  |         <c-input-text | ||||||
|  |           id="bcrypt-compare-result" | ||||||
|  |           :value="compareState.result == null ? undefined : compareState.result ? 'Matched' : 'No match'" | ||||||
|  |           :placeholder="compareState.error ?? 'Comparison result'" | ||||||
|  |           readonly | ||||||
|  |           text-center | ||||||
|  |           class="compare-result" | ||||||
|  |           :class="compareState.result == null ? undefined : compareState.result ? 'positive' : 'negative'" | ||||||
|  |         /> | ||||||
|  |       </div> | ||||||
|  |       <div mb-1 mt-1 h-3 op-60> | ||||||
|  |         {{ compareState.timeTakenMs == null ? '' : `Compared in ${compareState.timeTakenMs}\xA0ms` }} | ||||||
|       </div> |       </div> | ||||||
|       </n-form-item> |  | ||||||
|     </n-form> |     </n-form> | ||||||
|   </c-card> |   </c-card> | ||||||
| </template> | </template> | ||||||
| 
 | 
 | ||||||
| <style lang="less" scoped> | <style lang="less"> | ||||||
| .compare-result { | .compare-result { | ||||||
|  |   &.negative { | ||||||
|  |     input#bcrypt-compare-result { | ||||||
|       color: v-bind('themeVars.errorColor'); |       color: v-bind('themeVars.errorColor'); | ||||||
| 
 |     } | ||||||
|  |   } | ||||||
|   &.positive { |   &.positive { | ||||||
|  |     input#bcrypt-compare-result { | ||||||
|       color: v-bind('themeVars.successColor'); |       color: v-bind('themeVars.successColor'); | ||||||
|     } |     } | ||||||
|  |   } | ||||||
| } | } | ||||||
| </style> | </style> | ||||||
|  | |||||||
		Loading…
	
		Reference in New Issue
	
	Block a user