Skip to content

Commit 438d5f8

Browse files
author
Andrew Marcuse
committed
Hash page
1 parent a53729a commit 438d5f8

4 files changed

Lines changed: 395 additions & 0 deletions

File tree

hash.html

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
6+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7+
<title>Hash</title>
8+
</head>
9+
<body>
10+
<div id="app"></div>
11+
<script type="module" src="/src/hash.ts"></script>
12+
</body>
13+
</html>

src/hash.ts

Lines changed: 377 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,377 @@
1+
import './style.css'
2+
import { renderHeader } from './components/header.ts'
3+
4+
type HashResult = {
5+
algorithm: string
6+
value: string
7+
}
8+
9+
const hashAlgorithms = ['SHA-1', 'SHA-256', 'SHA-384', 'SHA-512'] as const
10+
const copyIconSvg =
11+
'<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="8" height="4" x="8" y="2" rx="1" ry="1"></rect><path d="M8 4H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"></path><path d="M16 4h2a2 2 0 0 1 2 2v4"></path><path d="M21 14H11"></path><path d="m15 10-4 4 4 4"></path></svg>'
12+
13+
document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
14+
<main class="min-h-screen bg-base-200" data-theme="light">
15+
<section class="mx-auto flex max-w-5xl flex-col gap-8 px-4 py-8 md:px-8 md:py-12">
16+
${renderHeader()}
17+
18+
<section class="rounded-box border border-base-300 bg-base-100 p-8 shadow-sm">
19+
<h1 class="text-3xl font-bold">Hash</h1>
20+
<p class="mt-3 text-base-content/70">Calculate hashes for files, bytes or strings in your browser. Alternatives: <a class="underline" href="https://www.fileformat.info/tool/hash.htm">Java</a> and <a class="underline" href="https://resolve.rs/crypto/hash.html">NodeJS</a></p>
21+
22+
<section class="mt-6 flex flex-col gap-4">
23+
<label class="form-control w-full">
24+
<div class="label">
25+
<span class="label-text">Input string (UTF-8)</span>
26+
</div>
27+
<textarea id="input-string" rows="4" class="textarea textarea-bordered w-full" placeholder="Paste or type text here"></textarea>
28+
</label>
29+
30+
<label class="form-control w-full">
31+
<div class="label">
32+
<span class="label-text">Input bytes</span>
33+
</div>
34+
<textarea id="input-bytes" rows="4" class="textarea textarea-bordered w-full" placeholder="Examples: 0x48 0x65 0x6C 0x6C 0x6F or 72 101 108 108 111"></textarea>
35+
</label>
36+
37+
<label class="form-control w-full">
38+
<div class="label">
39+
<span class="label-text">Input file</span>
40+
</div>
41+
<input id="input-file" type="file" class="file-input file-input-bordered w-full" />
42+
</label>
43+
44+
<div id="form-error" class="alert alert-error hidden" role="alert" aria-live="polite"></div>
45+
</section>
46+
47+
<section id="input-info" class="mt-8 hidden">
48+
<h2 class="text-xl font-semibold">Input info</h2>
49+
<div class="mt-4 overflow-x-auto">
50+
<table class="table">
51+
<tbody>
52+
<tr>
53+
<th class="w-56">Length</th>
54+
<td id="info-length"></td>
55+
</tr>
56+
<tr>
57+
<th>First bytes</th>
58+
<td id="info-bytes" class="font-mono"></td>
59+
</tr>
60+
<tr>
61+
<th>First characters</th>
62+
<td id="info-chars" class="whitespace-pre-wrap break-words"></td>
63+
</tr>
64+
</tbody>
65+
</table>
66+
</div>
67+
</section>
68+
69+
<section id="hash-results" class="mt-8 hidden">
70+
<h2 class="text-xl font-semibold">Hash results</h2>
71+
<div class="mt-4 overflow-x-auto">
72+
<table class="table table-zebra">
73+
<thead>
74+
<tr>
75+
<th>Algorithm</th>
76+
<th>Hash (hex)</th>
77+
</tr>
78+
</thead>
79+
<tbody id="hash-results-body"></tbody>
80+
</table>
81+
</div>
82+
</section>
83+
</section>
84+
</section>
85+
</main>
86+
`
87+
88+
const inputFile = document.querySelector<HTMLInputElement>('#input-file')
89+
const inputString = document.querySelector<HTMLTextAreaElement>('#input-string')
90+
const inputBytes = document.querySelector<HTMLTextAreaElement>('#input-bytes')
91+
const formError = document.querySelector<HTMLDivElement>('#form-error')
92+
93+
const inputInfoSection = document.querySelector<HTMLElement>('#input-info')
94+
const infoLength = document.querySelector<HTMLTableCellElement>('#info-length')
95+
const infoBytes = document.querySelector<HTMLTableCellElement>('#info-bytes')
96+
const infoChars = document.querySelector<HTMLTableCellElement>('#info-chars')
97+
98+
const hashResultsSection = document.querySelector<HTMLElement>('#hash-results')
99+
const hashResultsBody = document.querySelector<HTMLTableSectionElement>('#hash-results-body')
100+
101+
let updateCounter = 0
102+
103+
const nextUpdateId = (): number => {
104+
updateCounter += 1
105+
return updateCounter
106+
}
107+
108+
const isCurrentUpdate = (id: number): boolean => id === updateCounter
109+
110+
const showError = (message: string) => {
111+
if (!formError) {
112+
return
113+
}
114+
115+
formError.textContent = message
116+
formError.classList.remove('hidden')
117+
}
118+
119+
const hideError = () => {
120+
if (!formError) {
121+
return
122+
}
123+
124+
formError.textContent = ''
125+
formError.classList.add('hidden')
126+
}
127+
128+
const hideResults = () => {
129+
inputInfoSection?.classList.add('hidden')
130+
hashResultsSection?.classList.add('hidden')
131+
}
132+
133+
const formatCount = (value: number): string => value.toLocaleString()
134+
135+
const formatFirstBytes = (bytes: Uint8Array): string => {
136+
if (bytes.length === 0) {
137+
return '(none)'
138+
}
139+
140+
const maxPreviewLength = 16
141+
const head = Array.from(bytes.slice(0, maxPreviewLength), (byte) => byte.toString(16).toUpperCase().padStart(2, '0')).join(' ')
142+
143+
return bytes.length > maxPreviewLength ? `${head} …` : head
144+
}
145+
146+
const isAsciiByte = (byte: number): boolean => byte === 9 || byte === 10 || byte === 13 || (byte >= 32 && byte <= 126)
147+
const isAsciiData = (bytes: Uint8Array): boolean => bytes.every((byte) => isAsciiByte(byte))
148+
149+
const getAsciiPreview = (bytes: Uint8Array): string => {
150+
if (bytes.length === 0) {
151+
return '(none)'
152+
}
153+
154+
const maxChars = 64
155+
const preview = Array.from(bytes.slice(0, maxChars), (byte) => String.fromCharCode(byte)).join('')
156+
return bytes.length > maxChars ? `${preview}…` : preview
157+
}
158+
159+
const bytesToHex = (bytes: Uint8Array): string => Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
160+
161+
const formatHashDisplay = (hexValue: string): string => {
162+
const chunks = hexValue.match(/.{1,8}/g)
163+
return (chunks ?? [hexValue]).map((chunk) => `<span class="whitespace-nowrap">${chunk}</span>`).join(' ')
164+
}
165+
166+
const parseByteToken = (token: string): number => {
167+
if (/^0x[0-9a-fA-F]{1,2}$/.test(token)) {
168+
return Number.parseInt(token.slice(2), 16)
169+
}
170+
171+
if (/^[0-9]+$/.test(token)) {
172+
return Number.parseInt(token, 10)
173+
}
174+
175+
if (/^[0-9a-fA-F]{1,2}$/.test(token)) {
176+
return Number.parseInt(token, 16)
177+
}
178+
179+
throw new Error(`Invalid byte token: ${token}`)
180+
}
181+
182+
const parseBytesInput = (value: string): Uint8Array => {
183+
const trimmed = value.trim()
184+
if (!trimmed) {
185+
throw new Error('Please enter at least one byte value.')
186+
}
187+
188+
const tokens = trimmed.split(/[\s,]+/).filter(Boolean)
189+
const parsed = tokens.map((token) => {
190+
const byte = parseByteToken(token)
191+
if (!Number.isInteger(byte) || byte < 0 || byte > 255) {
192+
throw new Error(`Byte out of range (0-255): ${token}`)
193+
}
194+
195+
return byte
196+
})
197+
198+
return new Uint8Array(parsed)
199+
}
200+
201+
const hashBytes = async (bytes: Uint8Array): Promise<HashResult[]> => {
202+
const digestInput = new Uint8Array(bytes.byteLength)
203+
digestInput.set(bytes)
204+
205+
const results = await Promise.all(
206+
hashAlgorithms.map(async (algorithm) => {
207+
const digest = await crypto.subtle.digest(algorithm, digestInput)
208+
return {
209+
algorithm,
210+
value: bytesToHex(new Uint8Array(digest)),
211+
}
212+
}),
213+
)
214+
215+
return results
216+
}
217+
218+
const renderInputInfo = (bytes: Uint8Array, charPreview: string) => {
219+
if (!inputInfoSection || !infoLength || !infoBytes || !infoChars) {
220+
return
221+
}
222+
223+
infoLength.textContent = `${formatCount(bytes.length)} bytes`
224+
infoBytes.textContent = formatFirstBytes(bytes)
225+
infoChars.textContent = charPreview
226+
227+
inputInfoSection.classList.remove('hidden')
228+
}
229+
230+
const renderHashResults = (results: HashResult[]) => {
231+
if (!hashResultsSection || !hashResultsBody) {
232+
return
233+
}
234+
235+
hashResultsBody.innerHTML = results
236+
.map(
237+
(result) =>
238+
`<tr><td>${result.algorithm}</td><td><div class="flex items-center gap-2"><button type="button" class="btn btn-xs" data-copy-hash="${result.value}" title="Copy hash" aria-label="Copy ${result.algorithm} hash"><span class="block h-4 w-4">${copyIconSvg}</span></button><span class="font-mono">${formatHashDisplay(result.value)}</span></div></td></tr>`,
239+
)
240+
.join('')
241+
242+
hashResultsSection.classList.remove('hidden')
243+
}
244+
245+
const applyInput = async (bytes: Uint8Array, charPreview: string, updateId: number) => {
246+
const results = await hashBytes(bytes)
247+
if (!isCurrentUpdate(updateId)) {
248+
return
249+
}
250+
251+
renderInputInfo(bytes, charPreview)
252+
renderHashResults(results)
253+
}
254+
255+
const clearOtherInputs = (source: 'file' | 'string' | 'bytes') => {
256+
if (source !== 'file' && inputFile) {
257+
inputFile.value = ''
258+
}
259+
260+
if (source !== 'string' && inputString) {
261+
inputString.value = ''
262+
}
263+
264+
if (source !== 'bytes' && inputBytes) {
265+
inputBytes.value = ''
266+
}
267+
}
268+
269+
const handleStringInput = async () => {
270+
const updateId = nextUpdateId()
271+
clearOtherInputs('string')
272+
hideError()
273+
274+
const text = inputString?.value ?? ''
275+
const bytes = new TextEncoder().encode(text)
276+
const maxChars = 64
277+
const charPreview = text.length > maxChars ? `${text.slice(0, maxChars)}…` : text || '(none)'
278+
279+
try {
280+
await applyInput(bytes, charPreview, updateId)
281+
} catch {
282+
if (!isCurrentUpdate(updateId)) {
283+
return
284+
}
285+
286+
showError('Unable to hash input.')
287+
hideResults()
288+
}
289+
}
290+
291+
const handleBytesInput = async () => {
292+
const updateId = nextUpdateId()
293+
clearOtherInputs('bytes')
294+
hideError()
295+
296+
try {
297+
const bytes = parseBytesInput(inputBytes?.value ?? '')
298+
const charPreview = isAsciiData(bytes) ? getAsciiPreview(bytes) : '(not ASCII)'
299+
await applyInput(bytes, charPreview, updateId)
300+
} catch (error) {
301+
if (!isCurrentUpdate(updateId)) {
302+
return
303+
}
304+
305+
const message = error instanceof Error ? error.message : 'Unable to parse bytes input.'
306+
showError(message)
307+
hideResults()
308+
}
309+
}
310+
311+
const handleFileInput = async () => {
312+
const file = inputFile?.files?.[0]
313+
if (!file) {
314+
return
315+
}
316+
317+
const updateId = nextUpdateId()
318+
clearOtherInputs('file')
319+
hideError()
320+
321+
try {
322+
const bytes = new Uint8Array(await file.arrayBuffer())
323+
const charPreview = isAsciiData(bytes) ? getAsciiPreview(bytes) : '(not ASCII)'
324+
await applyInput(bytes, charPreview, updateId)
325+
} catch {
326+
if (!isCurrentUpdate(updateId)) {
327+
return
328+
}
329+
330+
showError('Unable to read the selected file.')
331+
hideResults()
332+
} finally {
333+
if (inputFile) {
334+
inputFile.value = ''
335+
}
336+
}
337+
}
338+
339+
hashResultsBody?.addEventListener('click', async (event) => {
340+
const target = event.target
341+
if (!(target instanceof Element)) {
342+
return
343+
}
344+
345+
const button = target.closest<HTMLButtonElement>('button[data-copy-hash]')
346+
const value = button?.dataset.copyHash
347+
348+
if (!button || !value) {
349+
return
350+
}
351+
352+
try {
353+
await navigator.clipboard.writeText(value)
354+
button.textContent = '✓'
355+
} catch {
356+
button.textContent = '!'
357+
}
358+
359+
window.setTimeout(() => {
360+
button.innerHTML = `<span class="block h-4 w-4">${copyIconSvg}</span>`
361+
}, 1200)
362+
})
363+
364+
inputString?.addEventListener('input', () => {
365+
void handleStringInput()
366+
})
367+
368+
inputBytes?.addEventListener('input', () => {
369+
void handleBytesInput()
370+
})
371+
372+
inputFile?.addEventListener('change', () => {
373+
void handleFileInput()
374+
})
375+
376+
inputString?.focus()
377+
void handleStringInput()

0 commit comments

Comments
 (0)