From 1b1b71c8b311514c0bbad73c0a1b936d2269ee05 Mon Sep 17 00:00:00 2001 From: Claas Date: Sat, 17 May 2025 00:26:36 +0200 Subject: [PATCH] Save game --- app.config.ts | 1 + package.json | 1 + pnpm-lock.yaml | 16 ++++ src/app.css | 2 +- src/components/UrlForm.tsx | 92 +++++++++++++++++++++ src/entry-server.tsx | 51 ++++++++---- src/middleware/contentSecurityPolicy.ts | 35 ++++++++ src/routes/index.tsx | 104 ++++++++++++++++-------- 8 files changed, 251 insertions(+), 51 deletions(-) create mode 100644 src/components/UrlForm.tsx create mode 100644 src/middleware/contentSecurityPolicy.ts diff --git a/app.config.ts b/app.config.ts index 76ea633..bb9d278 100644 --- a/app.config.ts +++ b/app.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from "@solidjs/start/config"; import tailwindcss from "@tailwindcss/vite"; export default defineConfig({ + middleware: "src/middleware/contentSecurityPolicy.ts", vite: { plugins: [tailwindcss()], }, diff --git a/package.json b/package.json index 139851c..eac8f12 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "dependencies": { "@solidjs/router": "^0.15.0", "@solidjs/start": "^1.1.0", + "dompurify": "^3.2.5", "lucide-solid": "^0.511.0", "solid-js": "^1.9.5", "vinxi": "^0.5.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6960775..7e742f4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@solidjs/start': specifier: ^1.1.0 version: 1.1.4(@types/node@22.15.18)(jiti@2.4.2)(lightningcss@1.30.1)(solid-js@1.9.6)(terser@5.39.2)(vinxi@0.5.6(@types/node@22.15.18)(db0@0.3.2)(ioredis@5.6.1)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.39.2))(vite@6.3.5(@types/node@22.15.18)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.39.2)) + dompurify: + specifier: ^3.2.5 + version: 3.2.5 lucide-solid: specifier: ^0.511.0 version: 0.511.0(solid-js@1.9.6) @@ -1053,6 +1056,9 @@ packages: '@types/triple-beam@1.3.5': resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} @@ -1647,6 +1653,9 @@ packages: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} + dompurify@3.2.5: + resolution: {integrity: sha512-mLPd29uoRe9HpvwP2TxClGQBzGXeEC/we/q+bFlmPPmj2p2Ugl3r6ATu/UU1v77DXNcehiBg9zsr1dREyA/dJQ==} + dot-prop@9.0.0: resolution: {integrity: sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==} engines: {node: '>=18'} @@ -4601,6 +4610,9 @@ snapshots: '@types/triple-beam@1.3.5': {} + '@types/trusted-types@2.0.7': + optional: true + '@types/unist@3.0.3': {} '@types/yauzl@2.10.3': @@ -5244,6 +5256,10 @@ snapshots: dependencies: path-type: 4.0.0 + dompurify@3.2.5: + optionalDependencies: + '@types/trusted-types': 2.0.7 + dot-prop@9.0.0: dependencies: type-fest: 4.41.0 diff --git a/src/app.css b/src/app.css index 0cf85fc..78aafab 100644 --- a/src/app.css +++ b/src/app.css @@ -1,5 +1,5 @@ @import "tailwindcss"; :root { - @apply bg-stone-100 text-stone-900; + @apply bg-stone-100 text-stone-900; } diff --git a/src/components/UrlForm.tsx b/src/components/UrlForm.tsx new file mode 100644 index 0000000..c3cb638 --- /dev/null +++ b/src/components/UrlForm.tsx @@ -0,0 +1,92 @@ +import { createResource, type Resource } from "solid-js"; +import Plus from "lucide-solid/icons/plus"; +import { Properties } from "solid-js/web/types/server.js"; + +const URL_INPUT = "url"; + +export type Entry = { + url: URL; + title: Resource; +}; + +export type UrlFormProperties = { + onSubmit(entry: Entry): void; +}; + +async function getIcon(url: URL) { + console.debug("Getting icon"); + const document = await getDocument(url); + + // A link can have multiple rel + const iconLinks = document.querySelectorAll( + 'link[rel~="icon"], link[rel~="apple-touch-icon"]', + ); + + // Select best icon + for (const link of iconLinks) { + if (!(link instanceof HTMLLinkElement)) continue; + + if (link.sizes.contains("any")) { + } + } +} + +const parser = new DOMParser(); +async function getDocument(url: URL) { + const response = await fetch(url); + // Do not care about status code. A bad request might still contain icon metadata + const text = await response.text(); + const document = parser.parseFromString(text, "text/html"); + return document; +} + +export default function UrlForm({ onSubmit }: UrlFormProperties) { + function handleSubmit(event: SubmitEvent) { + event.preventDefault(); + + const form = event.currentTarget; + if (!(form instanceof HTMLFormElement)) + throw new Error("Expected to handle form submit event"); + + const input = form.elements.namedItem(URL_INPUT); + if (!(input instanceof HTMLInputElement)) + throw new Error("Expected form to have url input"); + + const url = URL.parse(input.value); + if (url === null) { + input.setCustomValidity("Invalid url. Url is not parsable."); + return; + } + + const [document] = createResource(url, getDocument); + //TODO load best available icon from document link elements + const [title] = createResource(document, (document) => document.title); + + // Using hostname for title until document fetch and parse is implemented + onSubmit({ url, title: title }); + } + + return ( +
+ + + +
+ ); +} diff --git a/src/entry-server.tsx b/src/entry-server.tsx index b0ece1f..42cc625 100644 --- a/src/entry-server.tsx +++ b/src/entry-server.tsx @@ -1,21 +1,36 @@ // @refresh reload import { createHandler, StartServer } from "@solidjs/start/server"; -export default createHandler(() => ( - ( - - - - - - {assets} - - - {children} - {scripts} - - - )} - /> -)); +export default createHandler( + () => ( + ( + + + + + + {assets} + + + {children} + {scripts} + + + )} + /> + ), + (event) => { + if (!("nonce" in event.locals)) + throw new Error( + "Expected nonce to be defined by Content Security Policy middleware", + ); + + // Based on https://docs.solidjs.com/solid-start/guides/security + console.debug("NONCE", event.locals.nonce); + return { nonce: event.locals.nonce }; + }, +); diff --git a/src/middleware/contentSecurityPolicy.ts b/src/middleware/contentSecurityPolicy.ts new file mode 100644 index 0000000..6801f2b --- /dev/null +++ b/src/middleware/contentSecurityPolicy.ts @@ -0,0 +1,35 @@ +import { createMiddleware } from "@solidjs/start/middleware"; +import { randomBytes } from "node:crypto"; + +/** + * Based on https://docs.solidjs.com/solid-start/guides/security + */ +export default createMiddleware({ + onRequest: (event) => { + if (import.meta.env.DEV) { + // I really want to but could not figure out how to get vite dev stuff to load with nonce + console.warn("Not setting Content Security Policy for development"); + event.locals.nonce = "UNUSED_DEVELOPMENT_NONCE"; + return; + } + + const nonce = randomBytes(16).toString("base64"); + + // Set nonce to be picked up by server entry point + event.locals.nonce = nonce; + + const contentSecurityPolicy = ` + default-src 'self'; + script-src 'nonce-${nonce}' 'strict-dynamic' 'unsafe-eval'; + object-src 'none'; + base-uri 'none'; + frame-ancestors 'none'; + form-action 'self'; + `.replace(/\s+/g, " "); + + event.response.headers.set( + "Content-Security-Policy", + contentSecurityPolicy, + ); + }, +}); diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 420763a..d16ee92 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -1,47 +1,87 @@ -import { A } from "@solidjs/router"; -import { Plus } from "lucide-solid"; -import { For } from "solid-js"; +import { createEffect, createSignal, For } from "solid-js"; +import { clientOnly } from "@solidjs/start"; +import type { Entry } from "~/components/UrlForm"; + +const UrlForm = clientOnly(() => import("~/components/UrlForm")); + +const URI_LIST = "text/uri-list"; export default function Home() { - const destinations = [ - { - title: "claas.dev", - href: "https://claas.dev", - }, - ]; + //TODO handle this more elegantly + + const [destinations, setDestinations] = createSignal(new Array()); + + function handleDragEnter(event: DragEvent) { + const data = event.dataTransfer; + if (!data) return; + + const isLink = data.types.includes(URI_LIST); + if (!isLink) return; + event.preventDefault(); + } + + function handleDrop(event: DragEvent) { + const data = event.dataTransfer; + if (!data) return; + event.preventDefault(); + const lines = data.getData(URI_LIST); + + if (lines.length === 0) return; + const urls = lines.split("\n").reduce((urls, line) => { + if (line.startsWith("#")) return urls; + + // console.debug("url line", line); + const url = URL.parse(line); + if (url !== null) urls.push(url); + + return urls; + }, new Array()); + + console.debug("Dropped urls", urls); + } + + function handleUrlSubmit(entry: Entry) { + //TODO use map to deduplicate and reduce garbage + setDestinations((destinations) => { + const index = destinations.findIndex( + (destination) => destination.url.href === entry.url.href, + ); + + if (index === -1) return destinations; + + return [...destinations, entry]; + }); + } return ( -
+

Shore

Add Destination

-
- - - -
+

Destinations