Skip to content

Commit 59f2318

Browse files
author
Andrew Marcuse
committed
Hash page
1 parent a53729a commit 59f2318

4 files changed

Lines changed: 390 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: 372 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,372 @@
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 parseByteToken = (token: string): number => {
162+
if (/^0x[0-9a-fA-F]{1,2}$/.test(token)) {
163+
return Number.parseInt(token.slice(2), 16)
164+
}
165+
166+
if (/^[0-9]+$/.test(token)) {
167+
return Number.parseInt(token, 10)
168+
}
169+
170+
if (/^[0-9a-fA-F]{1,2}$/.test(token)) {
171+
return Number.parseInt(token, 16)
172+
}
173+
174+
throw new Error(`Invalid byte token: ${token}`)
175+
}
176+
177+
const parseBytesInput = (value: string): Uint8Array => {
178+
const trimmed = value.trim()
179+
if (!trimmed) {
180+
throw new Error('Please enter at least one byte value.')
181+
}
182+
183+
const tokens = trimmed.split(/[\s,]+/).filter(Boolean)
184+
const parsed = tokens.map((token) => {
185+
const byte = parseByteToken(token)
186+
if (!Number.isInteger(byte) || byte < 0 || byte > 255) {
187+
throw new Error(`Byte out of range (0-255): ${token}`)
188+
}
189+
190+
return byte
191+
})
192+
193+
return new Uint8Array(parsed)
194+
}
195+
196+
const hashBytes = async (bytes: Uint8Array): Promise<HashResult[]> => {
197+
const digestInput = new Uint8Array(bytes.byteLength)
198+
digestInput.set(bytes)
199+
200+
const results = await Promise.all(
201+
hashAlgorithms.map(async (algorithm) => {
202+
const digest = await crypto.subtle.digest(algorithm, digestInput)
203+
return {
204+
algorithm,
205+
value: bytesToHex(new Uint8Array(digest)),
206+
}
207+
}),
208+
)
209+
210+
return results
211+
}
212+
213+
const renderInputInfo = (bytes: Uint8Array, charPreview: string) => {
214+
if (!inputInfoSection || !infoLength || !infoBytes || !infoChars) {
215+
return
216+
}
217+
218+
infoLength.textContent = `${formatCount(bytes.length)} bytes`
219+
infoBytes.textContent = formatFirstBytes(bytes)
220+
infoChars.textContent = charPreview
221+
222+
inputInfoSection.classList.remove('hidden')
223+
}
224+
225+
const renderHashResults = (results: HashResult[]) => {
226+
if (!hashResultsSection || !hashResultsBody) {
227+
return
228+
}
229+
230+
hashResultsBody.innerHTML = results
231+
.map(
232+
(result) =>
233+
`<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 break-all">${result.value}</span></div></td></tr>`,
234+
)
235+
.join('')
236+
237+
hashResultsSection.classList.remove('hidden')
238+
}
239+
240+
const applyInput = async (bytes: Uint8Array, charPreview: string, updateId: number) => {
241+
const results = await hashBytes(bytes)
242+
if (!isCurrentUpdate(updateId)) {
243+
return
244+
}
245+
246+
renderInputInfo(bytes, charPreview)
247+
renderHashResults(results)
248+
}
249+
250+
const clearOtherInputs = (source: 'file' | 'string' | 'bytes') => {
251+
if (source !== 'file' && inputFile) {
252+
inputFile.value = ''
253+
}
254+
255+
if (source !== 'string' && inputString) {
256+
inputString.value = ''
257+
}
258+
259+
if (source !== 'bytes' && inputBytes) {
260+
inputBytes.value = ''
261+
}
262+
}
263+
264+
const handleStringInput = async () => {
265+
const updateId = nextUpdateId()
266+
clearOtherInputs('string')
267+
hideError()
268+
269+
const text = inputString?.value ?? ''
270+
const bytes = new TextEncoder().encode(text)
271+
const maxChars = 64
272+
const charPreview = text.length > maxChars ? `${text.slice(0, maxChars)}…` : text || '(none)'
273+
274+
try {
275+
await applyInput(bytes, charPreview, updateId)
276+
} catch {
277+
if (!isCurrentUpdate(updateId)) {
278+
return
279+
}
280+
281+
showError('Unable to hash input.')
282+
hideResults()
283+
}
284+
}
285+
286+
const handleBytesInput = async () => {
287+
const updateId = nextUpdateId()
288+
clearOtherInputs('bytes')
289+
hideError()
290+
291+
try {
292+
const bytes = parseBytesInput(inputBytes?.value ?? '')
293+
const charPreview = isAsciiData(bytes) ? getAsciiPreview(bytes) : '(not ASCII)'
294+
await applyInput(bytes, charPreview, updateId)
295+
} catch (error) {
296+
if (!isCurrentUpdate(updateId)) {
297+
return
298+
}
299+
300+
const message = error instanceof Error ? error.message : 'Unable to parse bytes input.'
301+
showError(message)
302+
hideResults()
303+
}
304+
}
305+
306+
const handleFileInput = async () => {
307+
const file = inputFile?.files?.[0]
308+
if (!file) {
309+
return
310+
}
311+
312+
const updateId = nextUpdateId()
313+
clearOtherInputs('file')
314+
hideError()
315+
316+
try {
317+
const bytes = new Uint8Array(await file.arrayBuffer())
318+
const charPreview = isAsciiData(bytes) ? getAsciiPreview(bytes) : '(not ASCII)'
319+
await applyInput(bytes, charPreview, updateId)
320+
} catch {
321+
if (!isCurrentUpdate(updateId)) {
322+
return
323+
}
324+
325+
showError('Unable to read the selected file.')
326+
hideResults()
327+
} finally {
328+
if (inputFile) {
329+
inputFile.value = ''
330+
}
331+
}
332+
}
333+
334+
hashResultsBody?.addEventListener('click', async (event) => {
335+
const target = event.target
336+
if (!(target instanceof Element)) {
337+
return
338+
}
339+
340+
const button = target.closest<HTMLButtonElement>('button[data-copy-hash]')
341+
const value = button?.dataset.copyHash
342+
343+
if (!button || !value) {
344+
return
345+
}
346+
347+
try {
348+
await navigator.clipboard.writeText(value)
349+
button.textContent = '✓'
350+
} catch {
351+
button.textContent = '!'
352+
}
353+
354+
window.setTimeout(() => {
355+
button.innerHTML = `<span class="block h-4 w-4">${copyIconSvg}</span>`
356+
}, 1200)
357+
})
358+
359+
inputString?.addEventListener('input', () => {
360+
void handleStringInput()
361+
})
362+
363+
inputBytes?.addEventListener('input', () => {
364+
void handleBytesInput()
365+
})
366+
367+
inputFile?.addEventListener('change', () => {
368+
void handleFileInput()
369+
})
370+
371+
inputString?.focus()
372+
void handleStringInput()

src/main.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
2121
<a href="/haikunator.html" class="btn btn-primary w-40">Haikunator</a>
2222
<div>generate random Heroku-style names</div>
2323
</div>
24+
<div class="flex w-full items-center gap-3">
25+
<a href="/hash.html" class="btn btn-primary w-40">Hash</a>
26+
<div>hash file, string, or bytes with common algorithms</div>
27+
</div>
2428
<div class="flex w-full items-center gap-3">
2529
<a href="/runecount.html" class="btn btn-primary w-40">Rune Count</a>
2630
<div>count which characters are in a file</div>

0 commit comments

Comments
 (0)