-
-
Save tmcw/7394bc8588a63399bea23d15a34fa2fa to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import base64 from "https://deno.land/x/b64@1.1.24/src/base64.js"; | |
import { z } from "https://esm.sh/zod@3.19.1"; | |
import { connect } from "https://esm.sh/@planetscale/database"; | |
import { Context } from "https://edge.netlify.com/"; | |
const DOMAIN = "macwright.com"; | |
const ALGORITHM = "RSASSA-PKCS1-v1_5"; | |
const HASH = "SHA-256"; | |
const Follow = z | |
.object({ | |
type: z.literal("Follow"), | |
object: z.string(), | |
actor: z.string().url(), | |
}) | |
.passthrough(); | |
type IFollow = z.infer<typeof Follow>; | |
interface AcceptMessage { | |
"@context": "https://www.w3.org/ns/activitystreams"; | |
id: string; | |
type: "Accept"; | |
actor: string; | |
object: IFollow; | |
} | |
/* | |
Convert a string into an ArrayBuffer | |
from https://developers.google.com/web/updates/2012/06/How-to-convert-ArrayBuffer-to-and-from-String | |
*/ | |
function str2ab(str: string) { | |
const buf = new ArrayBuffer(str.length); | |
const bufView = new Uint8Array(buf); | |
for (let i = 0, strLen = str.length; i < strLen; i++) { | |
bufView[i] = str.charCodeAt(i); | |
} | |
return buf; | |
} | |
/* | |
Import a PEM encoded RSA private key, to use for RSA-PSS signing. | |
Takes a string containing the PEM encoded key, and returns a Promise | |
that will resolve to a CryptoKey representing the private key. | |
TODO: this uses a bunch of bad, old techniques. | |
My first shot at translating it to "good" techniques | |
failed. Needs a second. | |
*/ | |
function importPrivateKey(pem: string) { | |
// fetch the part of the PEM string between header and footer | |
const pemHeader = "-----BEGIN PRIVATE KEY-----"; | |
const pemFooter = "-----END PRIVATE KEY-----"; | |
const pemContents = pem.substring( | |
pemHeader.length, | |
pem.length - pemFooter.length, | |
); | |
// base64 decode the string to get the binary data | |
const binaryDerString = window.atob(pemContents); | |
// convert from a binary string to an ArrayBuffer | |
const binaryDer = str2ab(binaryDerString); | |
return window.crypto.subtle.importKey( | |
"pkcs8", | |
binaryDer, | |
{ | |
name: ALGORITHM, | |
hash: HASH, | |
}, | |
true, | |
["sign"], | |
); | |
} | |
// Get that private key… | |
const privkey = ``; | |
function encodeText(text: string) { | |
const encoder = new TextEncoder(); | |
return encoder.encode(text); | |
} | |
async function digestMessage(message: string) { | |
const data = encodeText(message); | |
const hash = await crypto.subtle.digest("SHA-256", data); | |
return base64.fromArrayBuffer(hash); | |
} | |
async function signAndSend( | |
message: AcceptMessage, | |
name: string, | |
domain: string, | |
targetDomain: string, | |
) { | |
// get the URI of the actor object and append 'inbox' to it | |
const inbox = message.object.actor + "/inbox"; | |
const inboxFragment = inbox.replace("https://" + targetDomain, ""); | |
const digestHash = await digestMessage(JSON.stringify(message)); | |
const d = new Date(); | |
const stringToSign = encodeText( | |
`(request-target): post ${inboxFragment}\nhost: ${targetDomain}\ndate: ${d.toUTCString()}\ndigest: SHA-256=${digestHash}`, | |
); | |
const signature = base64.fromArrayBuffer( | |
await crypto.subtle.sign( | |
{ | |
name: ALGORITHM, | |
hash: { name: HASH }, | |
}, | |
await importPrivateKey(privkey), | |
stringToSign, | |
), | |
); | |
const header = | |
`keyId="https://${domain}/u/${name}",headers="(request-target) host date digest",signature="${signature}"`; | |
await fetch(inbox, { | |
headers: new Headers({ | |
Host: targetDomain, | |
Date: d.toUTCString(), | |
Digest: `SHA-256=${digestHash}`, | |
"Content-Type": "application/json", | |
Signature: header, | |
}), | |
method: "POST", | |
body: JSON.stringify(message), | |
}) | |
.then(async (res) => { | |
if (res.status >= 400) { | |
console.error("Failed request", { | |
res: await res.text(), | |
header, | |
signature, | |
targetDomain, | |
}); | |
return; | |
} | |
return res.text(); | |
}) | |
.then((res) => { | |
console.log("Response:", res); | |
}) | |
.catch((e) => console.error(e)); | |
} | |
function sendAcceptMessage( | |
thebody: IFollow, | |
name: string, | |
domain: string, | |
targetDomain: string, | |
) { | |
const guid = self.crypto.randomUUID(); | |
const message: AcceptMessage = { | |
"@context": "https://www.w3.org/ns/activitystreams", | |
id: `https://${domain}/${guid}`, | |
type: "Accept", | |
actor: `https://${domain}/u/${name}`, | |
object: thebody, | |
}; | |
return signAndSend(message, name, domain, targetDomain); | |
} | |
const config = { | |
url: Deno.env.get("DATABASE_URL"), | |
}; | |
export default async function handler(req: Request, _context: Context) { | |
if (req.method !== "POST") { | |
return new Response("", { | |
status: 404, | |
headers: { "Content-Type": "text/html" }, | |
}); | |
} | |
const followRequest = Follow.safeParse(await req.json()); | |
if (!followRequest.success) { | |
return new Response("", { | |
status: 200, | |
headers: { "Content-Type": "text/html" }, | |
}); | |
} | |
const f = followRequest.data; | |
// TODO: add "Undo" follow event | |
const myURL = new URL(f.actor); | |
const targetDomain = myURL.hostname; | |
const name = f.object.replace(`https://${DOMAIN}/u/`, ""); | |
if (name !== "photos") { | |
return new Response("", { | |
status: 404, | |
headers: { "Content-Type": "text/html" }, | |
}); | |
} | |
console.log("got follow request from ", f.actor); | |
await sendAcceptMessage(f, name, DOMAIN, targetDomain); | |
const conn = connect(config); | |
await conn.execute( | |
"insert ignore into followers (target, follower) values (?, ?)", | |
[ | |
name, | |
f.actor, | |
], | |
); | |
console.log("updated followers!", f.actor); | |
return new Response("", { | |
status: 200, | |
headers: { "Content-Type": "text/html" }, | |
}); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment