diff --git a/src/activity.ts b/src/activity.ts index 110c4d7..2b96219 100644 --- a/src/activity.ts +++ b/src/activity.ts @@ -1,79 +1,52 @@ -import { Sha256Signer } from "activitypub-http-signatures"; -import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; import { Article } from "./article.js"; -import { User } from "./user.js"; -import { readFileSync } from "fs"; +import { User, getByActor } from "./user.js"; import { fillRoute } from "./router.js"; +import { streamToString, hashDigest } from "./util.js"; +import { signedFetch } from "./net.js"; export const CONTEXT = "https://www.w3.org/ns/activitystreams"; export const PUBLIC = CONTEXT + "#Public"; +export const TYPE = "application/activity+json"; -const hasher = createHash("sha256"); +export const handleInboxPost = async (req: Request) => { + try { + if (!req.body) { + console.warn("no body"); + return; + } -const hashDigest =(payload: string | Buffer) => - "sha-256=" - + hasher - .update((Buffer.isBuffer(payload) ? payload : Buffer.from(payload)).toString("hex")) - .digest("base64"); + const body = await streamToString(req.body); + let activity: Record; + try { + activity = JSON.parse(body); + } + catch (e) { + console.warn("body json parse failed"); + return; + } -const flattenHeaders = (headers: Record) => - Object.entries(headers).map(([key, value]) => `${key}: ${value}`); + if (activity.type === "Follow") { + const actor = await getByActor(activity.object); -interface SignedInit { - keyId: string; - privateKey: string; - digest?: string; -}; - -export const signedFetch = async (url: string, init: RequestInit, signedInit: SignedInit) => { - const signedHeaders: HeadersInit = [ - ["Date", new Date().toUTCString()], - ["Host", new URL(url).host], - ["Content-Type", `application/ld+json; profile="${CONTEXT}"`] - ]; - - if (signedInit.digest) { - signedHeaders.push(["Digest", signedInit.digest]); - } - else if (init.body) { - if (Buffer.isBuffer(init.body) || typeof init.body === "string") { - signedHeaders.push(["Digest", hashDigest(init.body)]); - } - else throw "unsupported body type"; - } - - const signer = new Sha256Signer({ - privateKey: signedInit.privateKey, - publicKeyId: signedInit.keyId - }); - - const signature = signer.sign({ - url, - method: init.method as string, - headers: signedHeaders - }); - - signedHeaders.push(["Signature", signature]); - - const newHeaders = new Headers(); - if (Array.isArray(init.headers)) { - for (const header of init.headers) { - if (Array.isArray(header)) - newHeaders.set(header[0], header[1]); - else throw "unsupported headers type"; // Lazy. - } - - for (const [key, value] of signedHeaders) { - newHeaders.set(key, value); - } - - init.headers = newHeaders; - } - else throw "unsupported headers type"; // Lazy. - - return fetch(url, init); + if (actor) { + const follower = activity.actor; + } + else { + console.warn("follow attempt on unknown user"); + } + } + else if (activity.type === "Undo") { + + } + else { + console.warn("unsupported activity type"); + } + } + finally { } }; +/* export const sendActivity = async (publicKeyId: string, privateKey: string, activity: string, hash: string, inbox: string) => { const headers: Record = { date: new Date().toUTCString(), @@ -97,6 +70,7 @@ export const sendActivity = async (publicKeyId: string, privateKey: string, acti body: activity }); }; +*/ export const deleteArticleActivity = (article: Article, user: User) => { const actor = fillRoute("actor", user.nickname); @@ -160,7 +134,7 @@ export const sendAll = async (keyId: string, privateKey: string, activity: Recor activity = typeof activity === "string" ? activity : JSON.stringify(activity, null, 4); - + const hash = hashDigest(activity); const init: RequestInit = { method: "POST", body: activity }; @@ -181,4 +155,26 @@ export const orderedCollection = (id: string, orderedItems: (string | Record { + const id = fillRoute("actor", user.nickname); + + return { + id, + type: "Person", + "@context": CONTEXT, + discoverable: true, + preferredUsername: user.nickname, + name: user.name, + summary: user.bio, + url: id, + inbox: fillRoute("inbox", user.nickname), + outbox: fillRoute("outbox", user.nickname), + publicKey: { + id: + "#main-key", + owner: id, + publicKeyPem: user.public_key + } + }; +}; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 75e29ed..3af7348 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,10 @@ -import { readFileSync } from "fs"; import express from "express"; import ActivitypubExpress from "activitypub-express"; import { get as getOutbox } from "./outbox.js"; import { get as getUserByNickname, getId as getUserId } from "./user.js"; -import { Routes, fillRoute } from "./router.js"; -import { getBySlug } from "./article.js"; +import { Routes } from "./router.js"; +import { getBySlug as getArticleBySlug } from "./article.js"; +import { userToPerson, TYPE as ACTIVITYPUB_TYPE } from "./activity.js"; const port = parseInt(process.env.port || "8080"); const app = express(); @@ -42,7 +42,7 @@ app.get(Routes.outbox, async (req, res) => { const userId = await getUserId(req.params.actor); if (userId) { const body = await getOutbox(userId).then((out) => JSON.stringify(out, null, 4)); - res.append("Content-Type", "application/activity+json"); + res.append("Content-Type", ACTIVITYPUB_TYPE); res.send(body); } else { @@ -56,28 +56,11 @@ app.get(Routes.actor, async (req, res) => { if (actor) { const accept = req.headers["accept"] || ""; - if (accept.startsWith("application/activity+json")) { - const id = fillRoute("actor", nickname); - const obj = { - id, - type: "Person", - "@context": "https://www.w3.org/ns/activitystreams", - discoverable: true, - preferredUsername: actor.nickname, - name: actor.name, - summary: actor.bio, - url: id, - inbox: fillRoute("inbox", actor.nickname), - outbox: fillRoute("outbox", actor.nickname), - publicKey: { - id: id + "#main-key", - owner: id, - publicKeyPem: actor.public_key - } - }; + if (accept.includes(ACTIVITYPUB_TYPE)) { + const obj = userToPerson(actor); const body = JSON.stringify(obj, null, 4); - res.append("Content-Type", "application/activity+json"); + res.set("Content-Type", ACTIVITYPUB_TYPE); res.send(body); } else { @@ -91,7 +74,7 @@ app.get(Routes.actor, async (req, res) => { }); app.get("/:slug.html", async (req, res) => { - const article = await getBySlug(req.params.slug); + const article = await getArticleBySlug(req.params.slug); if (!article || article.deleted) { res.status(404).send("not found"); @@ -103,7 +86,7 @@ app.get("/:slug.html", async (req, res) => { }); app.get("/:slug.md", async (req, res) => { - const article = await getBySlug(req.params.slug); + const article = await getArticleBySlug(req.params.slug); if (!article || article.deleted) { res.status(404).send("not found"); diff --git a/src/net.ts b/src/net.ts new file mode 100644 index 0000000..b802bd8 --- /dev/null +++ b/src/net.ts @@ -0,0 +1,61 @@ +import { CONTEXT } from "./activity.js"; +import { hashDigest } from "./util.js"; +import { Sha256Signer } from "activitypub-http-signatures"; + +export const flattenHeaders = (headers: Record) => + Object.entries(headers).map(([key, value]) => `${key}: ${value}`); + +export interface SignedInit { + keyId: string; + privateKey: string; + digest?: string; +}; + +export const signedFetch = async (url: string, init: RequestInit, signedInit: SignedInit) => { + const signedHeaders: HeadersInit = [ + ["Date", new Date().toUTCString()], + ["Host", new URL(url).host], + ["Content-Type", `application/ld+json; profile="${CONTEXT}"`] + ]; + + if (signedInit.digest) { + signedHeaders.push(["Digest", signedInit.digest]); + } + else if (init.body) { + if (Buffer.isBuffer(init.body) || typeof init.body === "string") { + signedHeaders.push(["Digest", hashDigest(init.body)]); + } + else throw "unsupported body type"; + } + + const signer = new Sha256Signer({ + privateKey: signedInit.privateKey, + publicKeyId: signedInit.keyId + }); + + const signature = signer.sign({ + url, + method: init.method as string, + headers: signedHeaders + }); + + signedHeaders.push(["Signature", signature]); + + const newHeaders = new Headers(); + if (Array.isArray(init.headers)) { + for (const header of init.headers) { + if (Array.isArray(header)) + newHeaders.set(header[0], header[1]); + else throw "unsupported headers type"; // Lazy. + } + + for (const [key, value] of signedHeaders) { + newHeaders.set(key, value); + } + + init.headers = newHeaders; + } + else throw "unsupported headers type"; // Lazy. + + return fetch(url, init); +}; diff --git a/src/user.ts b/src/user.ts index e3e058a..e89a6ef 100644 --- a/src/user.ts +++ b/src/user.ts @@ -2,6 +2,7 @@ import { generateKeyPair } from "node:crypto"; import { z } from "zod"; import db from "./db.js"; import { getFollowers } from "./collection.js"; +import { fillRoute } from "./router.js"; export const nicknameRegex = /^[a-zA-Z0-9_]+$/; @@ -25,6 +26,17 @@ export const get = async (nickname: string) => .first() ; +const EXTRACT_NICKNAME = new RegExp("/" + fillRoute("actor", "(.+)").replaceAll("/", "\\/") + "/"); + +export const getByActor = async (actor: string) => { + const matchArray = actor.match(EXTRACT_NICKNAME); + if (matchArray) { + const nickname = matchArray[1]; + return get(nickname); + } + else return null; +}; + export const getNickname = async (userId: number): Promise => db("users") .select("nickname") diff --git a/src/util.ts b/src/util.ts new file mode 100644 index 0000000..99bf59d --- /dev/null +++ b/src/util.ts @@ -0,0 +1,14 @@ +import { createHash } from "node:crypto"; + +const hasher = createHash("sha256"); + +export const hashDigest = (payload: string | Buffer) => + "sha-256=" + + hasher + .update((Buffer.isBuffer(payload) ? payload : Buffer.from(payload)).toString("hex")) + .digest("base64"); + +export const streamToString = async (stream: ReadableStream) => { + const arr = new Uint8Array(await new Response(stream).arrayBuffer()); + return new TextDecoder("utf-8").decode(arr); +};