From b9000c154fc675c0d4145030bb367b8ab740c22b Mon Sep 17 00:00:00 2001
From: Cory McWilliams <cory@unprompted.com>
Date: Wed, 2 Apr 2025 18:07:25 -0400
Subject: [PATCH] apps: Add a very wip web app.

---
 apps/web.json       |  5 +++
 apps/web/app.js     | 85 +++++++++++++++++++++++++++++++++++++++++++++
 apps/web/handler.js | 59 +++++++++++++++++++++++++++++++
 3 files changed, 149 insertions(+)
 create mode 100644 apps/web.json
 create mode 100644 apps/web/app.js
 create mode 100644 apps/web/handler.js

diff --git a/apps/web.json b/apps/web.json
new file mode 100644
index 000000000..a86958a73
--- /dev/null
+++ b/apps/web.json
@@ -0,0 +1,5 @@
+{
+	"type": "tildefriends-app",
+	"emoji": "🕸",
+	"previous": "&n7hu5b8/TsfiG6FDlCRG5nPCrIdCr96+xpIJ/aQT/uM=.sha256"
+}
diff --git a/apps/web/app.js b/apps/web/app.js
new file mode 100644
index 000000000..31f5e0c97
--- /dev/null
+++ b/apps/web/app.js
@@ -0,0 +1,85 @@
+let g_hash;
+
+async function query(sql, params) {
+	let results = [];
+	await ssb.sqlAsync(sql, params, function(row) {
+		results.push(row);
+	});
+	return results;
+}
+
+async function resolve(id) {
+	try {
+		let blob = await ssb.blobGet(id);
+		if (blob) {
+			let json;
+			try {
+				json = JSON.parse(utf8Decode(blob));
+			} catch {
+				return {id: utf8Decode(blob)};
+			}
+			if (json?.links) {
+				for (let [key, value] of Object.entries(json.links)) {
+					json.links[key] = await resolve(value);
+				}
+				return json;
+			} else {
+				return 'huh?' + json;
+			}
+		} else {
+			return `missing<${id}>`;
+		}
+	} catch (e) {
+		return id + ': ' + e.message;
+	}
+}
+
+async function get_names(identities) {
+	return Object.fromEntries((await query(`
+		SELECT author, name FROM (
+			SELECT
+				messages.author,
+				RANK() OVER (PARTITION BY messages.author ORDER BY messages.sequence DESC) AS author_rank,
+				messages.content ->> 'name' AS name
+			FROM messages
+			JOIN json_each(?) AS identities ON identities.value = messages.author
+			WHERE
+				json_extract(messages.content, '$.type') = 'about' AND
+				content ->> 'about' = messages.author AND name IS NOT NULL)
+		WHERE author_rank = 1
+	`, [JSON.stringify(identities)])).map(x => [x.author, x.name]));
+}
+
+async function render(hash) {
+	g_hash = hash;
+	if (!hash) {
+		let sites = (await query(`
+			SELECT site.author, site.id
+			FROM messages site
+			WHERE site.content ->> 'type' = 'web-init'
+		`, []));
+		let names = await get_names(sites.map(x => x.author));
+		if (hash === g_hash) {
+			await app.setDocument(`<ul style="background-color: #ddd">${sites.map(x => `<li><a target="_top" href="#${encodeURIComponent(x.id)}">${names[x.author] ?? x.author} - ${x.id}</a></li>`).join('\n')}</ul>`);
+		}
+	} else {
+		let site_id = hash.charAt(0) == '#' ? decodeURIComponent(hash.substring(1)) : decodeURIComponent(hash);
+		await app.setDocument(`<html style="margin: 0; padding: 0; width: 100vw; height: 100vh; margin: 0; padding: 0">
+			<body style="display: flex; flex-direction: column; width: 100vw; height: 100vh">
+				<iframe src="${encodeURIComponent(site_id)}/index.html" style="flex: 1 1; border: 0; background-color: #fff"></iframe>
+			</body>
+		</html>`);
+	}
+}
+
+core.register('message', async function message_handler(message) {
+	if (message.event == 'hashChange') {
+		await render(message.hash);
+	}
+});
+
+async function main() {
+	render(null);
+}
+
+main();
\ No newline at end of file
diff --git a/apps/web/handler.js b/apps/web/handler.js
new file mode 100644
index 000000000..ab1d92ca3
--- /dev/null
+++ b/apps/web/handler.js
@@ -0,0 +1,59 @@
+async function query(sql, params) {
+	let results = [];
+	await ssb.sqlAsync(sql, params, function(row) {
+		results.push(row);
+	});
+	return results;
+}
+
+function guess_content_type(name) {
+	if (name.endsWith('.html')) {
+		return 'text/html; charset=UTF-8';
+	} else if (name.endsWith('.js') || name.endsWith('.mjs')) {
+		return 'text/javascript; charset=UTF-8';
+	} else if (name.endsWith('.css')) {
+		return 'text/stylesheet; charset=UTF-8';
+	} else {
+		return 'application/binary';
+	}
+}
+
+async function main() {
+	let path = request.path.replaceAll(/(%[0-9a-fA-F]{2})/g, x => String.fromCharCode(parseInt(x.substring(1), 16)));
+	let match = path.match(/^(%.{44}\.sha256)(?:\/)?(.*)$/);
+
+	let content_type = guess_content_type(request.path);
+	let root = await query(`
+		SELECT root.content ->> 'root' AS root
+		FROM messages site
+		JOIN messages root
+		ON site.id = ? AND root.author = site.author AND root.content ->> 'site' = site.id
+		ORDER BY root.sequence DESC LIMIT 1
+	`, [match[1]]);
+	let root_id = root[0]['root'];
+	let last_id = root_id;
+	let blob = await ssb.blobGet(root_id);
+	try {
+		for (let part of match[2]?.split('/')) {
+			let dir = JSON.parse(utf8Decode(blob));
+			last_id = dir?.links[part];
+			blob = await ssb.blobGet(dir?.links[part]);
+			content_type = guess_content_type(part);
+		}
+	} catch {
+	}
+
+	respond({
+		status_code: 200,
+		data: blob ? utf8Decode(blob) : `${last_id} not found`,
+		content_type: content_type,
+	});
+}
+
+main().catch(function(e) {
+	respond({
+		status_code: 200,
+		data: `${e.message}\n${e.stack}`,
+		content_type: 'text/plain',
+	});
+});
\ No newline at end of file