Avatar

@tmcw

27 likes111 public vals
Joined August 31, 2022
👷 Building Val Town

Val Town API: Find a val by handle and name

This val fetches itself via the Val Town API! Weird, right?

Readme
1
2
3
4
5
6
let valTownApiExampleVal = (async () => {
const response = await fetch(
`https://api.val.town/v1/alias/tmcw/valTownApiExampleVal`,
);
return await response.json();
})();
0
0

TODO List

Every web project needs a todo list, right? This is val.town's first (maybe?) With a healthy dose of htmx and web fundamentals, we're packing the essentials of a TODO list with server persistence and animations into about 60 lines. The data is stored in the @tmcw.todos val, for now. Try it out.

Readme
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
let todo_list = (async (req, res) => {
const { default: htm } = await import("npm:htm");
const { default: vhtml } = await import("npm:vhtml");
const html = htm.bind(vhtml);
const renderTodo = (todo) => {
return html`<li data-id=${todo.id}>
<label>
<input hx-delete="/" hx-target="[data-id='${todo.id}']" hx-swap="delete" style='display:inline;margin-right:0.5rem;' type='checkbox' name="id" value=${todo.id} />
${todo.name}
</label>
</li>`;
};
switch (req.method) {
case "POST": {
const name = req.body.name.substring(0, 256).trim();
if (!name)
return res.end();
const todo = {
id: crypto.randomUUID(),
name,
};
@tmcw.todos = [...@tmcw.todos, todo];
if (@tmcw.todos.length > 10) {
@tmcw.todos = @tmcw.todos.slice(-10);
res.set("HX-Trigger", JSON.stringify({ reload: true }));
}
return res.send(renderTodo(todo));
}
case "DELETE": {
@tmcw.todos = @tmcw.todos.filter((todo) =>
todo.id !== req.body.id
);
return res.end();
}
}
if (req.path === "/list") {
return res.send(html`<>${@tmcw.todos.map(renderTodo)}</>`);
}
return res.send(html`<html><head><title>TODO</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="https://the.missing.style" />
<script src="https://unpkg.com/htmx.org@1.9.2"></script>
</head>
<body>
<main>
<h2>Todo list</h2>
<ul style='list-style-type:none;padding-left:0;' hx-trigger="reload from:body" hx-get="/list">
${@tmcw.todos.map(renderTodo)}
</ul>
<form method="post" class='f-row' hx-post="/" hx-target="ul" hx-swap="beforeend settle:1s">
<input required name="name" type="text" value="" /><button type="submit">Add</button>
</form>
</main>
<footer>
<p>Powered by Val Town: <a href='https://www.val.town/v/tmcw.todo_list'>tmcw.todo_list</a></p>
</footer>
<style>
form.htmx-request { opacity: .5; transition: opacity 300ms linear; }
li { transition: background 1s ease-out; }
li.htmx-added { background: yellow; }
</style>
</body>
</html>`);
});
0
0

Get a DuckDB database

This method sets up a duckdb-wasm database that you can then use to store and retrieve data.

Readme
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let getDuckDB = (async () => {
async function createWorker(url: string) {
const workerScript = await fetch(url);
const workerURL = URL.createObjectURL(await workerScript.blob());
return new Worker(workerURL, { type: "module" });
}
const duckdb = await import(
"https://cdn.jsdelivr.net/npm/@duckdb/duckdb-wasm@1.17.0/+esm"
);
const bundles = duckdb.getJsDelivrBundles();
const bundle = await duckdb.selectBundle(bundles);
const logger = new duckdb.ConsoleLogger();
const worker = await createWorker(bundle.mainWorker);
const db = new duckdb.AsyncDuckDB(logger, worker);
await db.instantiate(bundle.mainModule);
return db;
});
1
5

Unfancy blocks

Screenshot

In the old days, there was a website called bl.ocks.org which re-hosted content on GitHub Gists and displayed visualizations made in those gists. It wasn't shiny but it was well-crafted and for the little community that used it, it was very nice.

This is a much worse, but very small, version of the same kind of program. This also shows you stuff from blocks. It displays readmes, with Titus's lovely micromark. It uses Ian's collected search index of bl.ocks.org to show you examples and thumbnails. It uses Alpine to pull together a little interface. And, of course, Val Town to make it all work.

Readme
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
let blocks = async (req, res) => {
const { micromark } = await import("npm:micromark");
const [_, user, id, ...file] = req.path.split("/");
if (!user) {
return res.send(`
<html>
<script src="https://cdn.tailwindcss.com?plugins=typography"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<body class='p-20 bg-stone-100'>
<div
x-data="{ page: 0, blocks: [],
get filteredBlocks() {
return this.blocks.filter(block => block.thumbnail)
.reverse()
.map(block => {
return {
...block,
href: '/' + block.userId + '/' + block.id
}
}).slice(this.page * 100, (this.page + 1) * 100)
}
}"
x-init="blocks = await (await fetch('https://storage.googleapis.com/bb-search-data/parsed/blocks-min.json')).json()"
>
<div class='grid gap-4 grid-cols-4 lg:grid-cols-8'>
<template x-for="block in filteredBlocks">
<a class="aspect-square p-4 block" x-bind:href="block.href">
<div>
<span x-text="block.description"></span>
by <span x-text="block.userId"></span>
</div>
<template x-if="block.thumbnail">
<img x-bind:src="block.thumbnail" class='w-full' />
</template>
</a>
</template>
</div>
<div class='flex items-center justify-center gap-x-2 pt-20'>
Page <div x-text="page"></div>
<template x-if="page > 0">
<button @click="page--">Previous page</button>
</template>
<button @click="page++">Next page</button>
</div>
</div>
</body>
</body>
</html>`);
}
const gistRes = await fetch(`https://api.github.com/gists/${id}`);
if (!gistRes.ok) {
return res.send("GitHub replied with a non-200 status.");
}
const gist = await gistRes.json();
const readme = gist.files["README.md"];
const blocks = gist.files[".block"];
let height = 700;
if (blocks) {
const { parse } = await import("npm:yaml");
try {
const v = parse(blocks.content);
if (v.height) {
height = +v.height;
}
}
catch (e) {
}
}
res.set("Cache-Control", "public,max-age=64800");
return res.send(`
<html>
<script src="https://cdn.tailwindcss.com?plugins=typography"></script>
<body class='bg-stone-100'>
<div class='max-w-5xl mx-auto py-10'>
<div class='prose'>
<a href='https://tmcw-blocks.express.val.run/'>unfancy blocks</a> /
<a href='https://gist.github.com/${user}/${id}'>gist: ${user}/${id}</a>
<hr />
${readme ? micromark(readme.content) : ""}
${
blocks?.content
? `<h4>.blocks</h4><pre><code>${blocks?.content.trim()}</code></pre>`
: ""
}
</div>
<iframe style='height:${height}px;' class='mt-10 w-full border border-black' src='https://tmcw-blocks_inner.express.val.run/${user}/${id}/'>
</iframe>
</div>
</body>
</html>`);
};
0
0

The Big Story

This val, along with @tmcw.big_story, which requests from the New York Times API, and @tmcw.big_stories_ranks, which contains the data, generates a visualization of top stories on the NYTimes homepage.

This is here just to ask the question – what happens to cover stories over time? Do they slowly drop down the page, or just get replaced by a fully new lede? So far it doesn't have quite enough data to answer that question.

But also, it might be neat because it'll show which kinds of stories make the front page - is it climate, war, politics, or something else?

👉 The Big Story (visualization)

Readme
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
let big_story_visualization = async (req, res) => {
const { default: htm } = await import("npm:htm");
const { default: vhtml } = await import("npm:vhtml");
const html = htm.bind(vhtml);
const raw = (html) =>
vhtml(null, {
dangerouslySetInnerHTML: { __html: html },
});
res.send(html`
<html>
<body style="font-family: sans-serif;padding:30px;">
<h1>Big stories on the New York Times</h1>
<p>Below is a chart of headlines from the New York Times homepage, ordered
by their rank over time. Most stories start near the top and then get lower-ranked
when other news breaks. Some stories pop back up the ranks.
</p>
<p>Check out <a href='https://www.val.town/v/tmcw.big_story_visualization'>the code that makes this work</a> on <a href='https://val.town/'>val.town</a>.
</p>
<p>Hover over a line to see the story title, click on a line to view the story.</p>
<div style='padding-top:40px' id='chart'></div>
<script type="module">
${
raw(`
import * as Plot from "https://cdn.jsdelivr.net/npm/@observablehq/plot@0.6.9/+esm";
import {debounce} from "https://cdn.jsdelivr.net/npm/lodash-es@4.17.21/+esm";
const ranks = await (await fetch('https://api.val.town/v1/run/tmcw.big_stories_ranks')).json();
let allDates = new Set();
for (let rank of Object.values(ranks)) {
for (let [date] of rank.ranks) {
allDates.add(date);
}
}
allDates = [...allDates.values()].sort((a, b) => a - b);
const normalized = Object.entries(ranks).flatMap(([url, data]) => {
return allDates.map(date => {
return {
...data,
ranks: undefined,
url: url,
rank: data.ranks.find(rank => rank[0] === date)?.[1],
date: new Date(date),
};
});
});
function render() {
const width = window.innerWidth;
const output = Plot.plot({
grid: true,
width,
height: 800,
color: {
legend: true,
},
y: {
reverse: true,
},
x: {
type: 'time'
},
marginLeft: 20,
marginRight: 10,
marks: [
Plot.lineY(normalized, {
x: "date",
y: "rank",
stroke: 'section',
strokeLinecap: 'butt',
strokeLinejoin: 'miter-clip',
z: 'url',
href: 'url',
strokeWidth: 10,
}),
Plot.tip(normalized, Plot.pointer({
x: "date",
y: "rank",
href: 'url',
frameAnchor: "top-left",
title: (d) => d.title + ' ' + d.section
}))
]});
chart.innerHTML = '';
output.addEventListener('click', (e) => {
if (e.target instanceof SVGPathElement && e.target.ariaDescription) {
window.open(e.target.ariaDescription);
}
});
chart.append(output);
}
render();
window.addEventListener('resize', debounce(() => {
render();
}, 200));
`)
}
</script>
</body>
</html>`);
};
0
0

Quick poll

https://tmcw-poll.express.val.run

This val, along with a val called poll_results and a val called poll_options, lets you conduct a little poll using just Val Town! With the express API, this exposes a web interface in which people can click an option and vote. We make sure everyone only votes once by setting a cookie in their browser.

This uses htmx to make the voting experience slick, Tailwind to style the UI, and htm to generate HTML for the pages.

If you want to use this yourself, fork this val, the poll_results val, and the poll_options val, and customize the options list. You can delete the existing results as well to clear the data.

Readme
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
let poll = async (req: express.Request, res: express.Response) => {
const { default: htm } = await import("npm:htm");
const { default: vhtml } = await import("npm:vhtml");
const cookies = await import("https://deno.land/std@0.193.0/http/cookie.ts");
const html = htm.bind(vhtml);
const head = html`<head>
<title>Poll</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@1.9.2"></script>
</head>`;
const ChartPie =
html`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4">
<path d="M12 9a1 1 0 01-1-1V3c0-.553.45-1.008.997-.93a7.004 7.004 0 015.933 5.933c.078.547-.378.997-.93.997h-5z" />
<path d="M8.003 4.07C8.55 3.992 9 4.447 9 5v5a1 1 0 001 1h5c.552 0 1.008.45.93.997A7.001 7.001 0 012 11a7.002 7.002 0 016.003-6.93z" />
</svg>
`;
const formClass = "p-2 group min-h-screen animate-all bg-pink-50";
function parseCookies(cookie: string) {
const out: Record<string, string> = {};
const c = cookie.split(";");
for (const kv of c) {
const [cookieKey, ...cookieVal] = kv.split("=");
const key = cookieKey.trim();
out[key] = cookieVal.join("=");
}
return out;
}
function getResults(hasVoted: boolean) {
return html`<html>
${head}
<body>
<form hx-post="/" class=${formClass}>
<h1 class="text-sm font-bold uppercase text-pink-900 pb-2 flex items-center gap-x-2">
${ChartPie}
poll results
</h1>
<div class='space-y-2'>
${
@me.poll_options.map((option, i) => {
const count = @me.poll_results.filter((result) => {
return result.value === option;
}).length;
const percentage = (count / @me.poll_results.length) *
100;
return html`<div class='p-2 rounded-md block text-sm
bg-pink-100 border border-pink-500
text-pink-700 overflow-hidden
relative flex justify-between'>
<div class='absolute top-0 left-0 bottom-0 bg-pink-200 z-0'
style=${`width: ${percentage}%`} />
<div class='z-1 relative'>${option}</div>
<div class='z-1 relative'>${count.toLocaleString("en-US")} / ${
(percentage).toFixed(0)
}%</div>
</div>`;
})
}</div>
<div class='text-center pt-2'>
${
hasVoted
? html`<div class='text-pink-300 cursor-not-allowed text-xs'>You've voted</div>`
: html`<a href='/' class='text-pink-500 underline hover:text-pink-700 text-xs'>Vote</a>`
}</div>
</form>
</body>
</html>`;
}
switch (req.method) {
case "POST": {
const value = req.body;
if (!("choice" in value)) {
return res.end("Bad input");
}
@me.poll_results.push({
value: @me.poll_options[value.choice],
time: (new Date()).toISOString(),
});
res.set("Set-Cookie", "voted=true; Path=/; HttpOnly");
res.end(getResults(true));
return;
}
default: {
const cookies = parseCookies(req.get("cookie"));
const hasVoted = "voted" in cookies;
if ("results" in req.query || hasVoted) {
return res.end(getResults(hasVoted));
}
else {
return res.end(html`<html>
${head}
<body>
<form hx-post="/"
class=${formClass}
hx-trigger="change">
<div class='flex justify-between items-center'>
<h1 class="text-sm font-bold uppercase text-pink-900 pb-2">
quick poll
</h1>
<div class='group-[&.htmx-request]:block hidden uppercase text-pink-500 text-sm'>
Loading…
</div>
</div>
<div class='space-y-2'>
${
@me.poll_options.map((option, i) => {
return html`<label class='p-2 rounded-md block text-sm
bg-pink-100 border border-pink-500
text-pink-900
hover:cursor-pointer hover:shadow-md hover:shadow-blue-200/500
'>
<input type='checkbox' name="choice" value=${i} class='hidden' />
${option}
</label>`;
})
}
</div>
<div class='text-center pt-2'>
<a href='/?results' class='text-pink-500 underline hover:text-pink-700 text-xs'>
View results
</a>
</div>
</form>
</body>
</html>`);
}
}
}
};
0
0

This tracks the data produced by my iPhone trade-in values val.

Readme
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
// set by tmcw.trackIphoneTradein at 2023-09-13T18:38:45.469Z
export let iphoneTradeInValues = [{
"date": "2023-09-13T18:38:18.512Z",
"rows": [{
"name": "iPhone 14 Pro Max",
"val": "Up to $650",
}, {
"name": "iPhone 14 Pro",
"val": "Up to $570",
}, {
"name": "iPhone 14 Plus",
"val": "Up to $470",
}, {
"name": "iPhone 14",
"val": "Up to $430",
}, {
"name": "iPhone SE (3rd generation)",
"val": "Up to $160",
}, {
"name": "iPhone 13 Pro Max",
"val": "Up to $580",
}, {
"name": "iPhone 13 Pro",
"val": "Up to $480",
}, {
"name": "iPhone 13",
"val": "Up to $370",
}, {
"name": "iPhone 13 mini",
"val": "Up to $320",
}, {
"name": "iPhone 12 Pro Max",
"val": "Up to $450",
}, {
"name": "iPhone 12 Pro",
"val": "Up to $360",
}, {
"name": "iPhone 12",
"val": "Up to $250",
}, {
"name": "iPhone 12 mini",
"val": "Up to $200",
}, {
"name": "iPhone SE (2nd generation)",
"val": "Up to $80",
}, {
"name": "iPhone 11 Pro Max",
"val": "Up to $300",
}, {
"name": "iPhone 11 Pro",
"val": "Up to $250",
}, {
"name": "iPhone 11",
"val": "Up to $200",
}, {
"name": "iPhone XS Max",
"val": "Up to $170",
}, {
"name": "iPhone XS",
"val": "Up to $140",
}, {
"name": "iPhone XR",
"val": "Up to $140",
}, {
"name": "iPhone X",
"val": "Up to $120",
}, {
"name": "iPhone 8 Plus",
"val": "Up to $90",
}, {
"name": "iPhone 8",
"val": "Up to $60",
}, {
"name": "iPhone 7 Plus",
"val": "Up to $50",
}, {
"name": "iPhone 7",
"val": "Up to $40",
}],
}];
0
2

Track iPhone trade-in prices

iPhone trade in values - to trade phones back to Apple themselves - fluctuate over time, and eventually they no longer accept certain kinds of phones. You can still trade in an iPhone 7 - released in 2016, about 6 years ago as of this writing. But no longer does an iPhone 6 have value back to Apple.

This tracks those values - it runs every month and records prices to iphoneTradeInValues, which I'll visualize once there's some data.

Readme
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
export const trackIphoneTradein = (async () => {
const cheerio = await import("npm:cheerio@1.0.0-rc.12");
const page = await fetch(
"https://www.apple.com/shop/browse/overlay/tradein_landing/iphone_values",
).then((r) => r.text());
const $ = cheerio.load(page);
const rows = $("tr").map((row, elem) => {
const [name, val] = $(elem).find("td").map((idx, elem) => $(elem).text())
.toArray();
return { name, val };
}).toArray().filter((row) => row.name && row.val);
@tmcw.iphoneTradeInValues.push({
date: new Date().toISOString(),
rows,
});
return rows;
});
0
0

The story behind HTTP 200 "OK"

What's in an HTTP response? I've been writing software for the web since the early 2000s and have incrementally learned things about HTTP. There are status codes, like "200" and "404". There are headers, for Content-Type and headers to control cache settings. There are different versions of HTTP itself, like 1.1, 2, and 3. HTTP requests and responses can contain data, in the message body.

hello.png

But there's one thing I didn't notice until yesterday. A quirk that was included in the HTTP 1.1 specification with an authors note that it's mostly there for historical reasons: the reason-phrase.

None of this information is useful. The reason-phrase is barely supported on the web and was always an oddity, but keep reading if you like oddities!

If you're used to JavaScript’s fetch() method to make HTTP requests, you've seen the reason-phrase under a different name: statusText:

(await fetch('https://example.com/')).statusText

What is statusText? I had assumed that it was something that JavaScript itself provides, by looking up the status code 200 and matching it with the text "OK". I was wrong!

When I look at a raw HTTP response, I see the first few lines are like this:

HTTP/1.1 200 OK
Date: Thu, 17 Aug 2023 15:16:42 GMT
Content-Type: text/plain;charset=UTF-8

The reason phrase

So what is that text? I dug around in the HTTP 1.0 specification and found the section Status Code and Reason Phrase.

The Status-Code element is a 3-digit integer result code of the attempt to understand and satisfy the request. The Reason-Phrase is intended to give a short textual description of the Status-Code. The Status-Code is intended for use by automata and the Reason-Phrase is intended for the human user. The client is not required to examine or display the Reason-Phrase.

That also lists recommended reason phrases, like OK for 200 and Not Found for 404. And notes that you can choose different phrases without affecting the protocol.

The HTTP 1.1 specification adds a little color about the reason-phrase:

quote.png

So, with a HTTP server, you can customize your reason phrase! Here's an example with a val on Val Town:

let customReason = (req) =>
  new Response("", {
    statusText: 'Hello world!',
  });

Unfortunately, this doesn't work! The response that Val Town produces is reorganized and optimized by Cloudflare, which upgrades requests and responses from HTTP 1.1 to HTTP 2. And sadly, HTTP 2 dropped support for the custom reason-phrase.

RIP the reason-phrase. It was present even in a 1992 draft of the HTTP specification, and was a weird and under-appreciated way to pilfer extra information in a response. Now, thanks to HTTP/2 and the commonplace use of proxies and CDNs like Cloudflare, it's no longer usable. It was fun while it lasted.

Readme
1
2
3
4
5
6
let reasonPhrase = () => {
return new Response("Hello world", {
status: 200,
statusText: "This can be customized!",
});
};
0
0

WebFinger

This is a not-quite-complete ActivityPub implementation based on my blog post about building an AP implementation. It includes enough to look up bot@tmcw-activitypub.web.val.run on Mastodon and get some basic information.

Readme
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
export const activitypub = async (req: Request) => {
const { Hono } = await import("npm:hono@3.4.3");
const app = new Hono();
const DOMAIN = "tmcw-activitypub.web.val.run";
const USERS = new Map([
[
`acct:bot@${DOMAIN}`,
{
subject: `acct:bot@${DOMAIN}`,
aliases: [],
links: [
{
rel: "http://webfinger.net/rel/profile-page",
type: "text/html",
href: `https://${DOMAIN}/profile`,
},
{
rel: "self",
type: "application/activity+json",
href: `https://${DOMAIN}/u/bot`,
},
],
},
],
]);
const publicKey = `-----BEGIN PUBLIC KEY-----
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA7LBs3Qyuh93lRboTNXLN
hj4n92oK5Qg4oS8Cc81AXh04hD7nQSSKBhtarbHy2yPXeiKA+H3EGbcflsLvZCo2
B3OPNo2nGTCMyJM8HWDf/7JCOHHcy4tZC1ldjrItkb1YDABWwfoXxyBiGTyTVXjL
sBX5ArTGUPwctMSOdxlJp0ttFn5WDIHiPzxbSaEX/fzTy+HKr9RvYPu/hWWpXA/W
8QQRacZjslweupZFGCGPX1zJ+P0FSe81uV6N5cOPpy+vFkBQrvApwCSIyp/n7Rfq
UtU+zi/ru+wSxyvnoZPZa+zOXst8+pk7lIbmI6dyJ2+wijkykAxKt2DnDXWOSUGM
R+aNjc6tt8xp2MwmhAz91f1rIt2+jOhkPZ0m6aLV3B86J3iI0BIHXzQNydDtz5/P
EOj79vnnDwjCrWmbsfsIVCmECQDS7EW6Lzdc98GyiD/vyA4Epg3QgpeN4r7fELZj
8IfJJ7J8Z8nYewRoCVNnfvXpR26y+CLftMUi9LtPP1N78er1IdvZEer/8RIAc58r
S5VmDYBBfEduxPh/l3tn4A5Ri8smue26yG+wPkBj3CSqaOaNFxxZPgXcbI2OePrH
81goKk17g+5O0sZJGv+EAeFM1OQPXKqyu0DLY6PHJKGSho/B/BNWQ34vZnQhQF1r
++VZAcLEeqny/Cn6CHoeu5cCAwEAAQ==
-----END PUBLIC KEY-----`;
app.get("/u/bot", (ctx) => {
return ctx.json({
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
],
id: `https://${DOMAIN}/u/bot`,
type: "Person",
preferredUsername: `bot`,
summary: `Photos from ${DOMAIN}.com`,
inbox: `https://${DOMAIN}/api/inbox`,
followers: `https://${DOMAIN}/u/bot/followers`,
icon: {
type: "Image",
mediaType: "image/jpeg",
url: "https://macwright.com/graphics/about.jpg",
},
publicKey: {
id: `https://${DOMAIN}/u/bot#main-key`,
owner: `https://${DOMAIN}/u/bot`,
publicKeyPem: publicKey,
},
});
});
app.get("/.well-known/webfinger", (ctx) => {
const u = new URL(ctx.req.url);
const resource = u.searchParams.get("resource");
if (resource && USERS.has(resource)) {
return new Response(JSON.stringify(USERS.get(resource)), {
status: 200,
headers: { "Content-Type": "application/jrd+json" },
});
}
return new Response("", {
status: 404,
headers: { "Content-Type": "text/html" },
});
});
return app.fetch(req);
};
0
0