commit 8eed5b60dac8700e7f71e58c89f41a5b80125454 Author: Evan Hahn Date: Mon Jul 31 21:16:15 2023 -0500 Initial commit: basic homepage with routing diff --git a/README.md b/README.md new file mode 100644 index 0000000..ca56cc1 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# Formats Exposed + +To develop, start a static file server in `public/`. + +To deploy, copy the files from `public/` to a static file host. + +To run tests, run `deno test`. + +To format code, run `deno fmt`. + +To lint, run `deno lint`. + +To run type checks, run `deno task typecheck`. + +To run all checks (tests, linting, code formatting, and type checks), run +`deno task check-all`. diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..3c93e9a --- /dev/null +++ b/deno.json @@ -0,0 +1,12 @@ +{ + "tasks": { + "typecheck": "deno check **/*.js **/*.ts", + "check-all": "deno fmt --check && deno lint && deno task typecheck && deno test --allow-read" + }, + "imports": { + "assert": "https://deno.land/std@0.196.0/testing/asserts.ts" + }, + "compilerOptions": { + "lib": ["deno.ns", "dom"] + } +} diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..8723785 --- /dev/null +++ b/deno.lock @@ -0,0 +1,34 @@ +{ + "version": "2", + "remote": { + "https://deno.land/std@0.196.0/_util/diff.ts": "1a3c044aedf77647d6cac86b798c6417603361b66b54c53331b312caeb447aea", + "https://deno.land/std@0.196.0/assert/_constants.ts": "8a9da298c26750b28b326b297316cdde860bc237533b07e1337c021379e6b2a9", + "https://deno.land/std@0.196.0/assert/_format.ts": "a69126e8a469009adf4cf2a50af889aca364c349797e63174884a52ff75cf4c7", + "https://deno.land/std@0.196.0/assert/assert.ts": "9a97dad6d98c238938e7540736b826440ad8c1c1e54430ca4c4e623e585607ee", + "https://deno.land/std@0.196.0/assert/assert_almost_equals.ts": "e15ca1f34d0d5e0afae63b3f5d975cbd18335a132e42b0c747d282f62ad2cd6c", + "https://deno.land/std@0.196.0/assert/assert_array_includes.ts": "6856d7f2c3544bc6e62fb4646dfefa3d1df5ff14744d1bca19f0cbaf3b0d66c9", + "https://deno.land/std@0.196.0/assert/assert_equals.ts": "a0ee60574e437bcab2dcb79af9d48dc88845f8fd559468d9c21b15fd638ef943", + "https://deno.land/std@0.196.0/assert/assert_exists.ts": "407cb6b9fb23a835cd8d5ad804e2e2edbbbf3870e322d53f79e1c7a512e2efd7", + "https://deno.land/std@0.196.0/assert/assert_false.ts": "a9962749f4bf5844e3fa494257f1de73d69e4fe0e82c34d0099287552163a2dc", + "https://deno.land/std@0.196.0/assert/assert_instance_of.ts": "09fd297352a5b5bbb16da2b5e1a0d8c6c44da5447772648622dcc7df7af1ddb8", + "https://deno.land/std@0.196.0/assert/assert_is_error.ts": "b4eae4e5d182272efc172bf28e2e30b86bb1650cd88aea059e5d2586d4160fb9", + "https://deno.land/std@0.196.0/assert/assert_match.ts": "c4083f80600bc190309903c95e397a7c9257ff8b5ae5c7ef91e834704e672e9b", + "https://deno.land/std@0.196.0/assert/assert_not_equals.ts": "9f1acab95bd1f5fc9a1b17b8027d894509a745d91bac1718fdab51dc76831754", + "https://deno.land/std@0.196.0/assert/assert_not_instance_of.ts": "0c14d3dfd9ab7a5276ed8ed0b18c703d79a3d106102077ec437bfe7ed912bd22", + "https://deno.land/std@0.196.0/assert/assert_not_match.ts": "3796a5b0c57a1ce6c1c57883dd4286be13a26f715ea662318ab43a8491a13ab0", + "https://deno.land/std@0.196.0/assert/assert_not_strict_equals.ts": "ca6c6d645e95fbc873d25320efeb8c4c6089a9a5e09f92d7c1c4b6e935c2a6ad", + "https://deno.land/std@0.196.0/assert/assert_object_match.ts": "27439c4f41dce099317566144299468ca822f556f1cc697f4dc8ed61fe9fee4c", + "https://deno.land/std@0.196.0/assert/assert_rejects.ts": "45c59724de2701e3b1f67c391d6c71c392363635aad3f68a1b3408f9efca0057", + "https://deno.land/std@0.196.0/assert/assert_strict_equals.ts": "5cf29b38b3f8dece95287325723272aa04e04dbf158d886d662fa594fddc9ed3", + "https://deno.land/std@0.196.0/assert/assert_string_includes.ts": "b821d39ebf5cb0200a348863c86d8c4c4b398e02012ce74ad15666fc4b631b0c", + "https://deno.land/std@0.196.0/assert/assert_throws.ts": "63784e951475cb7bdfd59878cd25a0931e18f6dc32a6077c454b2cd94f4f4bcd", + "https://deno.land/std@0.196.0/assert/assertion_error.ts": "4d0bde9b374dfbcbe8ac23f54f567b77024fb67dbb1906a852d67fe050d42f56", + "https://deno.land/std@0.196.0/assert/equal.ts": "9f1a46d5993966d2596c44e5858eec821859b45f783a5ee2f7a695dfc12d8ece", + "https://deno.land/std@0.196.0/assert/fail.ts": "c36353d7ae6e1f7933d45f8ea51e358c8c4b67d7e7502028598fe1fea062e278", + "https://deno.land/std@0.196.0/assert/mod.ts": "08d55a652c22c5da0215054b21085cec25a5da47ce4a6f9de7d9ad36df35bdee", + "https://deno.land/std@0.196.0/assert/unimplemented.ts": "d56fbeecb1f108331a380f72e3e010a1f161baa6956fd0f7cf3e095ae1a4c75a", + "https://deno.land/std@0.196.0/assert/unreachable.ts": "4600dc0baf7d9c15a7f7d234f00c23bca8f3eba8b140286aaca7aa998cf9a536", + "https://deno.land/std@0.196.0/fmt/colors.ts": "a7eecffdf3d1d54db890723b303847b6e0a1ab4b528ba6958b8f2e754cf1b3bc", + "https://deno.land/std@0.196.0/testing/asserts.ts": "b4e4b1359393aeff09e853e27901a982c685cb630df30426ed75496961931946" + } +} diff --git a/public/common/crel.js b/public/common/crel.js new file mode 100644 index 0000000..a100541 --- /dev/null +++ b/public/common/crel.js @@ -0,0 +1,22 @@ +// @ts-check + +/** + * Create a DOM element. Inspired by [Crel][0]. + * [0]: https://npm.im/crel + * + * @param {string} tagName + * @param {object} [attributes={}] + * @param {...(string | Node)} children + * @returns {HTMLElement} + */ +export default function crel(tagName, attributes = {}, ...children) { + const el = document.createElement(tagName); + + for (const [key, value] of Object.entries(attributes)) { + el.setAttribute(key, value); + } + + el.append(...children); + + return el; +} diff --git a/public/common/global.css b/public/common/global.css new file mode 100644 index 0000000..ea4dda8 --- /dev/null +++ b/public/common/global.css @@ -0,0 +1,17 @@ +* { + box-sizing: border-box; +} + +html { + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; + font-size: 18pt; + line-height: 1.5em; +} + +a { + color: mediumblue; +} + +input, textarea { + font: inherit; +} \ No newline at end of file diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..0b20671 --- /dev/null +++ b/public/index.html @@ -0,0 +1,25 @@ + + + + + formats.exposed + + + + + +

formats.exposed

+ +

Learn what makes up your files.

+ +
+ + +

+ Created by + Evan Hahn + for + Handmade Network. +

+ + diff --git a/public/index/constants.js b/public/index/constants.js new file mode 100644 index 0000000..73e38d6 --- /dev/null +++ b/public/index/constants.js @@ -0,0 +1,23 @@ +// @ts-check + +/** + * @typedef {object} SupportedFileType + * @prop {string} name The English name of a file type. + * @prop {string[]} extensions File extension for this type, with the leading dot. + * @prop {string} mimeType MIME type for this file type. + * @prop {string} route The route to the file type's page. + * @prop {ArrayLike} mimeSniffBytePattern The byte pattern to use for MIME sniffing, lifted from the "MIME Sniffing" spec. + * @prop {ArrayLike} mimeSniffPatternMask The pattern mask to use for MIME sniffing, lifted from the "MIME Sniffing" spec. + */ + +/** @type {SupportedFileType[]} */ +export const SUPPORTED_FILE_TYPES = [ + { + name: "PNG", + extensions: [".png"], + mimeType: "image/png", + route: "/png", + mimeSniffBytePattern: [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A], + mimeSniffPatternMask: [0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF], + }, +]; diff --git a/public/index/fileRouter.js b/public/index/fileRouter.js new file mode 100644 index 0000000..4d175f1 --- /dev/null +++ b/public/index/fileRouter.js @@ -0,0 +1,67 @@ +// @ts-check + +import { SUPPORTED_FILE_TYPES } from "./constants.js"; + +/** + * Figure out the route for a given file. + * + * For example, PNG files should get routed to `/png`. + * + * @param {File} file + * @returns {Promise} The route for the file, or null if the file is not supported. + */ +export const routeFile = async (file) => { + const fileType = findFileTypeUsingMimeType(file.type) ?? + fineFileTypeUsingFileName(file.name) ?? + (await findFileTypeWithMimeSniffing(file)); + return fileType ? fileType.route : null; +}; + +/** + * @param {string} mimeType + */ +const findFileTypeUsingMimeType = (mimeType) => + SUPPORTED_FILE_TYPES.find((t) => t.mimeType === mimeType); + +/** + * @param {string} name + */ +const fineFileTypeUsingFileName = (name) => { + if (!name.includes(".")) return; + return SUPPORTED_FILE_TYPES.find((fileType) => + fileType.extensions.some((extension) => name.endsWith(extension)) + ); +}; + +/** + * See the ["MIME Sniffing" spec][0]. + * [0]: https://mimesniff.spec.whatwg.org + * @param {Blob} file + */ +const findFileTypeWithMimeSniffing = async (file) => { + // We only need to sample the first few bytes. + const sampleLength = SUPPORTED_FILE_TYPES + .map((t) => t.mimeSniffBytePattern.length) + .reduce((a, b) => Math.max(a, b), 0); + const sampleAsBlob = file.slice(0, sampleLength); + const sampleAsBuffer = await sampleAsBlob.arrayBuffer(); + const sample = new Uint8Array(sampleAsBuffer); + + return SUPPORTED_FILE_TYPES.find((fileType) => { + // Roughly follows the [pattern matching algorithm from the spec][1]. + // [1]: https://mimesniff.spec.whatwg.org/#pattern-matching-algorithm + + const { mimeSniffBytePattern, mimeSniffPatternMask } = fileType; + + console.assert(mimeSniffBytePattern.length === mimeSniffPatternMask.length); + + if (sample.length < mimeSniffBytePattern.length) return false; + + for (let p = 0; p < mimeSniffBytePattern.length; p++) { + const maskedData = sample[p] & mimeSniffPatternMask[p]; + if (maskedData !== mimeSniffBytePattern[p]) return false; + } + + return true; + }); +}; diff --git a/public/index/index.css b/public/index/index.css new file mode 100644 index 0000000..67bb1e4 --- /dev/null +++ b/public/index/index.css @@ -0,0 +1,9 @@ +body { + margin: 0 auto; + padding: 1rem; + width: 600px; +} + +noscript { + font-weight: bold; +} \ No newline at end of file diff --git a/public/index/index.js b/public/index/index.js new file mode 100644 index 0000000..5e25790 --- /dev/null +++ b/public/index/index.js @@ -0,0 +1,85 @@ +// @ts-check + +import { SUPPORTED_FILE_TYPES } from "./constants.js"; +import crel from "../common/crel.js"; +import { routeFile } from "./fileRouter.js"; + +const accept = SUPPORTED_FILE_TYPES + .flatMap((t) => [t.mimeType, ...t.extensions]) + .join(","); +const inputEl = /** @type {HTMLInputElement} */ ( + crel("input", { + type: "file", + id: "file-input", + accept, + }) +); + +const supportedFileTypeNameString = SUPPORTED_FILE_TYPES + .map((t, index, array) => ( + (array.length > 1 && (index === array.length - 1)) + ? `and ${t.name}` + : t.name + )) + .join(", "); +const labelParagraphEl = crel( + "p", + {}, + crel( + "label", + { "for": "file-input" }, + `Upload something! Supports ${supportedFileTypeNameString} files, with more on the way.`, + ), +); + +const disclaimerParagraphEl = crel( + "p", + {}, + crel("small", {}, "Your files do not leave your computer."), +); + +/** + * @param {Blob} blob + * @returns {Promise} + */ +const formatBytes = async (blob) => { + const arrayBuffer = await blob.arrayBuffer(); + const bytes = new Uint8Array(arrayBuffer); + const string = String.fromCharCode(...bytes); + const base64 = btoa(string); + return base64 + .replaceAll("+", "-") + .replaceAll("/", "_") + .replaceAll("=", ""); +}; + +const main = () => { + const appEl = document.getElementById("app"); + if (!appEl) throw new Error("HTML is not set up correctly"); + + inputEl.addEventListener("change", async () => { + const file = inputEl.files?.[0]; + if (!file) return; + + // TODO: Prevent large files. + + const route = await routeFile(file); + if (!route) { + console.warn( + "Uploaded a file that was accepted but not routed. This may indicate a bug.", + ); + // TODO: Show something better than this. + alert("Unsupported file type."); + return; + } + + location.href = route + "#" + JSON.stringify({ + name: file.name, + bytes: await formatBytes(file), + }); + }); + + appEl.append(labelParagraphEl, inputEl, disclaimerParagraphEl); +}; + +main(); diff --git a/test/index/constants.test.ts b/test/index/constants.test.ts new file mode 100644 index 0000000..6c981b6 --- /dev/null +++ b/test/index/constants.test.ts @@ -0,0 +1,22 @@ +import { assertEquals } from "assert"; +import { SUPPORTED_FILE_TYPES } from "../../public/index/constants.js"; + +Deno.test("no duplicate extensions in supported file types", () => { + const allExtensions = SUPPORTED_FILE_TYPES.flatMap((t) => t.extensions); + assertEquals( + allExtensions.length, + new Set(allExtensions).size, + "Duplicate extensions found in SUPPORTED_FILE_TYPES", + ); +}); + +Deno.test("MIME sniffing patterns are the same size as their masks", () => { + for (const fileType of SUPPORTED_FILE_TYPES) { + const { name, mimeSniffBytePattern, mimeSniffPatternMask } = fileType; + assertEquals( + mimeSniffBytePattern.length, + mimeSniffPatternMask.length, + `Pattern and mask for ${name} are not the same size`, + ); + } +}); diff --git a/test/index/fileRouter.test.ts b/test/index/fileRouter.test.ts new file mode 100644 index 0000000..eb9c919 --- /dev/null +++ b/test/index/fileRouter.test.ts @@ -0,0 +1,29 @@ +import { assertEquals } from "assert"; +import { routeFile } from "../../public/index/fileRouter.js"; + +Deno.test("routes files correctly", async () => { + const PNG_SIGNATURE = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]); + + const testCases = new Map([ + // No matching type + [new File([], "foo"), null], + [new File([], "foo.txt"), null], + [new File([], "foo.txt", { type: "text/plain" }), null], + // PNG + [new File([], "foo", { type: "image/png" }), "/png"], + [new File([], "foo.png"), "/png"], + [new File([], "foo.png", { type: "text/plain" }), "/png"], + [new File([PNG_SIGNATURE], "foo"), "/png"], + ]); + + for (const [file, expected] of testCases) { + const actual = await routeFile(file); + assertEquals( + actual, + expected, + `Expected file (${JSON.stringify(file.name)}, type ${ + JSON.stringify(file.type) + }, sized ${file.size}b) to route to ${expected} (got ${actual})`, + ); + } +});