diff --git a/src/app/zxdb/TapeIdentifier.tsx b/src/app/zxdb/TapeIdentifier.tsx new file mode 100644 index 0000000..b177c9d --- /dev/null +++ b/src/app/zxdb/TapeIdentifier.tsx @@ -0,0 +1,195 @@ +"use client"; + +import { useState, useRef, useCallback } from "react"; +import Link from "next/link"; +import { computeMd5 } from "@/utils/md5"; +import { identifyTape } from "./actions"; +import type { TapeMatch } from "@/server/repo/zxdb"; + +const SUPPORTED_EXTS = [".tap", ".tzx", ".pzx", ".csw", ".p", ".o"]; + +type State = + | { kind: "idle" } + | { kind: "hashing" } + | { kind: "identifying" } + | { kind: "results"; matches: TapeMatch[]; fileName: string } + | { kind: "not-found"; fileName: string } + | { kind: "error"; message: string }; + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +export default function TapeIdentifier() { + const [state, setState] = useState({ kind: "idle" }); + const [dragOver, setDragOver] = useState(false); + const inputRef = useRef(null); + + const processFile = useCallback(async (file: File) => { + const ext = file.name.substring(file.name.lastIndexOf(".")).toLowerCase(); + if (!SUPPORTED_EXTS.includes(ext)) { + setState({ + kind: "error", + message: `Unsupported file type "${ext}". Supported: ${SUPPORTED_EXTS.join(", ")}`, + }); + return; + } + + setState({ kind: "hashing" }); + try { + const md5 = await computeMd5(file); + setState({ kind: "identifying" }); + const matches = await identifyTape(md5, file.size); + if (matches.length > 0) { + setState({ kind: "results", matches, fileName: file.name }); + } else { + setState({ kind: "not-found", fileName: file.name }); + } + } catch { + setState({ kind: "error", message: "Something went wrong. Please try again." }); + } + }, []); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + setDragOver(false); + const file = e.dataTransfer.files[0]; + if (file) processFile(file); + }, + [processFile] + ); + + const handleFileInput = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) processFile(file); + // Reset so re-selecting the same file triggers change + e.target.value = ""; + }, + [processFile] + ); + + const reset = useCallback(() => { + setState({ kind: "idle" }); + }, []); + + // Dropzone view (idle, hashing, identifying, error) + if (state.kind === "results" || state.kind === "not-found") { + return ( +
+
+
+ + Tape Identifier +
+ + {state.kind === "results" ? ( + <> +

+ {state.fileName} matched {state.matches.length === 1 ? "1 entry" : `${state.matches.length} entries`}: +

+
+ {state.matches.map((m) => ( +
+
+
+ + {m.entryTitle} + +
+ {m.innerPath} +
+
+
+
{formatBytes(m.sizeBytes)}
+
{m.md5}
+
+
+
+ ))} +
+ + ) : ( +

+ No matching tape found in ZXDB for {state.fileName}. +

+ )} + + +
+
+ ); + } + + const isProcessing = state.kind === "hashing" || state.kind === "identifying"; + + return ( +
+
+
+ + Tape Identifier +
+ +
{ + e.preventDefault(); + if (!isProcessing) setDragOver(true); + }} + onDragLeave={() => setDragOver(false)} + onDrop={isProcessing ? (e) => e.preventDefault() : handleDrop} + onClick={isProcessing ? undefined : () => inputRef.current?.click()} + > + {isProcessing ? ( +
+
+ + {state.kind === "hashing" ? "Computing hash\u2026" : "Searching ZXDB\u2026"} + +
+ ) : ( + <> +
+ +
+

+ Drop a tape file to identify it +

+

+ {SUPPORTED_EXTS.join(" ")} — or{" "} + + choose file + +

+ + )} + + +
+ + {state.kind === "error" && ( +
+ {state.message} +
+ )} +
+
+ ); +} diff --git a/src/app/zxdb/actions.ts b/src/app/zxdb/actions.ts new file mode 100644 index 0000000..d8e08fe --- /dev/null +++ b/src/app/zxdb/actions.ts @@ -0,0 +1,22 @@ +"use server"; + +import { lookupByMd5, type TapeMatch } from "@/server/repo/zxdb"; + +export async function identifyTape( + md5: string, + sizeBytes: number +): Promise { + // Validate input shape + if (!/^[0-9a-f]{32}$/i.test(md5)) return []; + if (!Number.isFinite(sizeBytes) || sizeBytes < 0) return []; + + const matches = await lookupByMd5(md5); + + // If multiple matches and size can disambiguate, filter by size + if (matches.length > 1) { + const bySz = matches.filter((m) => m.sizeBytes === sizeBytes); + if (bySz.length > 0) return bySz; + } + + return matches; +} diff --git a/src/app/zxdb/page.tsx b/src/app/zxdb/page.tsx index 59873a9..a3cfaf9 100644 --- a/src/app/zxdb/page.tsx +++ b/src/app/zxdb/page.tsx @@ -1,4 +1,5 @@ import Link from "next/link"; +import TapeIdentifier from "./TapeIdentifier"; export const metadata = { title: "ZXDB Explorer", @@ -57,6 +58,18 @@ export default async function Page() {
+
+
+ +
+
+

+ Drop a .tap, .tzx, or other tape file to identify it against 32,000+ ZXDB entries. + The file stays in your browser — only its hash is sent. +

+
+
+

Start exploring

diff --git a/src/server/repo/zxdb.ts b/src/server/repo/zxdb.ts index 72b1c40..3c0fa79 100644 --- a/src/server/repo/zxdb.ts +++ b/src/server/repo/zxdb.ts @@ -56,6 +56,7 @@ import { searchByMagrefs, referencetypes, countries, + softwareHashes, } from "@/server/schema/zxdb"; export type EntrySearchScope = "title" | "title_aliases" | "title_aliases_origins"; @@ -2621,3 +2622,42 @@ export async function getIssue(id: number): Promise { })), }; } + +// ----- Tape identification via software_hashes ----- + +export type TapeMatch = { + downloadId: number; + entryId: number; + entryTitle: string; + innerPath: string; + md5: string; + crc32: string; + sizeBytes: number; +}; + +export async function lookupByMd5(md5: string): Promise { + const rows = await db + .select({ + downloadId: softwareHashes.downloadId, + entryId: downloads.entryId, + entryTitle: entries.title, + innerPath: softwareHashes.innerPath, + md5: softwareHashes.md5, + crc32: softwareHashes.crc32, + sizeBytes: softwareHashes.sizeBytes, + }) + .from(softwareHashes) + .innerJoin(downloads, eq(downloads.id, softwareHashes.downloadId)) + .innerJoin(entries, eq(entries.id, downloads.entryId)) + .where(eq(softwareHashes.md5, md5.toLowerCase())); + + return rows.map((r) => ({ + downloadId: Number(r.downloadId), + entryId: Number(r.entryId), + entryTitle: r.entryTitle ?? "", + innerPath: r.innerPath, + md5: r.md5, + crc32: r.crc32, + sizeBytes: Number(r.sizeBytes), + })); +} diff --git a/src/utils/md5.ts b/src/utils/md5.ts new file mode 100644 index 0000000..b227764 --- /dev/null +++ b/src/utils/md5.ts @@ -0,0 +1,164 @@ +// Pure-JS MD5 for browser use (Web Crypto doesn't support MD5). +// Standard RFC 1321 implementation, typed for TypeScript. + +function md5cycle(x: number[], k: number[]) { + let a = x[0], b = x[1], c = x[2], d = x[3]; + + a = ff(a, b, c, d, k[0], 7, -680876936); + d = ff(d, a, b, c, k[1], 12, -389564586); + c = ff(c, d, a, b, k[2], 17, 606105819); + b = ff(b, c, d, a, k[3], 22, -1044525330); + a = ff(a, b, c, d, k[4], 7, -176418897); + d = ff(d, a, b, c, k[5], 12, 1200080426); + c = ff(c, d, a, b, k[6], 17, -1473231341); + b = ff(b, c, d, a, k[7], 22, -45705983); + a = ff(a, b, c, d, k[8], 7, 1770035416); + d = ff(d, a, b, c, k[9], 12, -1958414417); + c = ff(c, d, a, b, k[10], 17, -42063); + b = ff(b, c, d, a, k[11], 22, -1990404162); + a = ff(a, b, c, d, k[12], 7, 1804603682); + d = ff(d, a, b, c, k[13], 12, -40341101); + c = ff(c, d, a, b, k[14], 17, -1502002290); + b = ff(b, c, d, a, k[15], 22, 1236535329); + + a = gg(a, b, c, d, k[1], 5, -165796510); + d = gg(d, a, b, c, k[6], 9, -1069501632); + c = gg(c, d, a, b, k[11], 14, 643717713); + b = gg(b, c, d, a, k[0], 20, -373897302); + a = gg(a, b, c, d, k[5], 5, -701558691); + d = gg(d, a, b, c, k[10], 9, 38016083); + c = gg(c, d, a, b, k[15], 14, -660478335); + b = gg(b, c, d, a, k[4], 20, -405537848); + a = gg(a, b, c, d, k[9], 5, 568446438); + d = gg(d, a, b, c, k[14], 9, -1019803690); + c = gg(c, d, a, b, k[3], 14, -187363961); + b = gg(b, c, d, a, k[8], 20, 1163531501); + a = gg(a, b, c, d, k[13], 5, -1444681467); + d = gg(d, a, b, c, k[2], 9, -51403784); + c = gg(c, d, a, b, k[7], 14, 1735328473); + b = gg(b, c, d, a, k[12], 20, -1926607734); + + a = hh(a, b, c, d, k[5], 4, -378558); + d = hh(d, a, b, c, k[8], 11, -2022574463); + c = hh(c, d, a, b, k[11], 16, 1839030562); + b = hh(b, c, d, a, k[14], 23, -35309556); + a = hh(a, b, c, d, k[1], 4, -1530992060); + d = hh(d, a, b, c, k[4], 11, 1272893353); + c = hh(c, d, a, b, k[7], 16, -155497632); + b = hh(b, c, d, a, k[10], 23, -1094730640); + a = hh(a, b, c, d, k[13], 4, 681279174); + d = hh(d, a, b, c, k[0], 11, -358537222); + c = hh(c, d, a, b, k[3], 16, -722521979); + b = hh(b, c, d, a, k[6], 23, 76029189); + a = hh(a, b, c, d, k[9], 4, -640364487); + d = hh(d, a, b, c, k[12], 11, -421815835); + c = hh(c, d, a, b, k[15], 16, 530742520); + b = hh(b, c, d, a, k[2], 23, -995338651); + + a = ii(a, b, c, d, k[0], 6, -198630844); + d = ii(d, a, b, c, k[7], 10, 1126891415); + c = ii(c, d, a, b, k[14], 15, -1416354905); + b = ii(b, c, d, a, k[5], 21, -57434055); + a = ii(a, b, c, d, k[12], 6, 1700485571); + d = ii(d, a, b, c, k[3], 10, -1894986606); + c = ii(c, d, a, b, k[10], 15, -1051523); + b = ii(b, c, d, a, k[1], 21, -2054922799); + a = ii(a, b, c, d, k[8], 6, 1873313359); + d = ii(d, a, b, c, k[15], 10, -30611744); + c = ii(c, d, a, b, k[6], 15, -1560198380); + b = ii(b, c, d, a, k[13], 21, 1309151649); + a = ii(a, b, c, d, k[4], 6, -145523070); + d = ii(d, a, b, c, k[11], 10, -1120210379); + c = ii(c, d, a, b, k[2], 15, 718787259); + b = ii(b, c, d, a, k[9], 21, -343485551); + + x[0] = add32(a, x[0]); + x[1] = add32(b, x[1]); + x[2] = add32(c, x[2]); + x[3] = add32(d, x[3]); +} + +function cmn(q: number, a: number, b: number, x: number, s: number, t: number) { + a = add32(add32(a, q), add32(x, t)); + return add32((a << s) | (a >>> (32 - s)), b); +} + +function ff(a: number, b: number, c: number, d: number, x: number, s: number, t: number) { + return cmn((b & c) | (~b & d), a, b, x, s, t); +} +function gg(a: number, b: number, c: number, d: number, x: number, s: number, t: number) { + return cmn((b & d) | (c & ~d), a, b, x, s, t); +} +function hh(a: number, b: number, c: number, d: number, x: number, s: number, t: number) { + return cmn(b ^ c ^ d, a, b, x, s, t); +} +function ii(a: number, b: number, c: number, d: number, x: number, s: number, t: number) { + return cmn(c ^ (b | ~d), a, b, x, s, t); +} + +function add32(a: number, b: number) { + return (a + b) & 0xffffffff; +} + +function md5blk(s: Uint8Array, offset: number): number[] { + const md5blks: number[] = []; + for (let i = 0; i < 64; i += 4) { + md5blks[i >> 2] = + s[offset + i] + + (s[offset + i + 1] << 8) + + (s[offset + i + 2] << 16) + + (s[offset + i + 3] << 24); + } + return md5blks; +} + +const hex = "0123456789abcdef".split(""); + +function rhex(n: number) { + let s = ""; + for (let j = 0; j < 4; j++) { + s += hex[(n >> (j * 8 + 4)) & 0x0f] + hex[(n >> (j * 8)) & 0x0f]; + } + return s; +} + +function md5raw(bytes: Uint8Array): string { + const n = bytes.length; + const state = [1732584193, -271733879, -1732584194, 271733878]; + + let i: number; + for (i = 64; i <= n; i += 64) { + md5cycle(state, md5blk(bytes, i - 64)); + } + + // Tail: copy remaining bytes into a padded buffer + const tail = new Uint8Array(64); + const remaining = n - (i - 64); + for (let j = 0; j < remaining; j++) { + tail[j] = bytes[i - 64 + j]; + } + tail[remaining] = 0x80; + + // If remaining >= 56 we need an extra block + if (remaining >= 56) { + md5cycle(state, md5blk(tail, 0)); + tail.fill(0); + } + + // Append bit length as 64-bit little-endian + const bitLen = n * 8; + tail[56] = bitLen & 0xff; + tail[57] = (bitLen >> 8) & 0xff; + tail[58] = (bitLen >> 16) & 0xff; + tail[59] = (bitLen >> 24) & 0xff; + // For files < 512 MB the high 32 bits are 0; safe for tape images + md5cycle(state, md5blk(tail, 0)); + + return rhex(state[0]) + rhex(state[1]) + rhex(state[2]) + rhex(state[3]); +} + +// Reads a File as ArrayBuffer and returns its MD5 hex digest. +export async function computeMd5(file: File): Promise { + const buffer = await file.arrayBuffer(); + return md5raw(new Uint8Array(buffer)); +}