diff --git a/apps/web.json b/apps/web.json
new file mode 100644
index 00000000..a86958a7
--- /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 00000000..31f5e0c9
--- /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(`
`);
+ }
+ } else {
+ let site_id = hash.charAt(0) == '#' ? decodeURIComponent(hash.substring(1)) : decodeURIComponent(hash);
+ await app.setDocument(`
+
+
+
+ `);
+ }
+}
+
+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 00000000..ab1d92ca
--- /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