From 6f2f5843c0f59e6391c50e58d12dbd0f7a605ebe Mon Sep 17 00:00:00 2001 From: Moon Man Date: Tue, 26 Dec 2023 06:44:45 -0500 Subject: [PATCH] deletes in outbox now. --- src/activity.ts | 142 +++++++++++++++++++++++++++++++++++++++++------- src/article.ts | 5 ++ src/command.ts | 10 ++-- src/index.ts | 67 ++++++++++------------- src/outbox.ts | 97 +++++++++++++-------------------- src/router.ts | 27 +++++++++ src/user.ts | 6 -- 7 files changed, 224 insertions(+), 130 deletions(-) create mode 100644 src/router.ts diff --git a/src/activity.ts b/src/activity.ts index 0a4ae0b..110c4d7 100644 --- a/src/activity.ts +++ b/src/activity.ts @@ -1,25 +1,92 @@ import { Sha256Signer } from "activitypub-http-signatures"; import { createHash } from "node:crypto"; import { Article } from "./article.js"; -import { User, getActor } from "./user.js"; +import { User } from "./user.js"; import { readFileSync } from "fs"; -import { routes } from "./index.js"; +import { fillRoute } from "./router.js"; + +export const CONTEXT = "https://www.w3.org/ns/activitystreams"; +export const PUBLIC = CONTEXT + "#Public"; const hasher = createHash("sha256"); -export const send = async (publicKeyId: string, privateKey: string, activity: string, hash: string, inbox: string) => { - const headers = { +const hashDigest =(payload: string | Buffer) => + "sha-256=" + + hasher + .update((Buffer.isBuffer(payload) ? payload : Buffer.from(payload)).toString("hex")) + .digest("base64"); + +const flattenHeaders = (headers: Record) => + Object.entries(headers).map(([key, value]) => `${key}: ${value}`); + +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); +}; + +export const sendActivity = async (publicKeyId: string, privateKey: string, activity: string, hash: string, inbox: string) => { + const headers: Record = { date: new Date().toUTCString(), digest: hash, host: new URL(inbox).host, - "content-type": `application/ld+json; profile="http://www.w3.org/ns/activitystreams"` - } as Record; + "content-type": `application/ld+json; profile="${CONTEXT}"` + }; const signer = new Sha256Signer({ privateKey, publicKeyId }); const signature = signer.sign({ url: inbox, method: "POST", - headers: Object.entries(headers).map((pair) => `${pair[0]}: ${pair[1]}`) + headers: flattenHeaders(headers) }); headers.signature = signature; @@ -31,24 +98,46 @@ export const send = async (publicKeyId: string, privateKey: string, activity: st }); }; -export const buildActivity = (article: Article, user: User) => { - const actor = getActor(user.nickname); +export const deleteArticleActivity = (article: Article, user: User) => { + const actor = fillRoute("actor", user.nickname); + const published = typeof article.updated_at === "number" ? new Date(article.updated_at) : article.updated_at; + const context = fillRoute("context", article.id); + const objectId = fillRoute("object", article.id); + const canonicalUrl = `https://${process.env.blog_host}/${article.slug}.html`; + + const activity: Record = { + id: fillRoute("activity", article.id + 1_000_000_000), + "@context": CONTEXT, + summary: `${user.nickname} deleted article ${canonicalUrl}`, + type: "Delete", + actor, + context, + to: [PUBLIC], + published, + object: objectId + }; + + return activity; +}; + +export const createArticleActivity = (article: Article, user: User) => { + const actor = fillRoute("actor", user.nickname); const published = typeof article.created_at === "number" ? new Date(article.created_at) : article.created_at; const canonicalUrl = `https://${process.env.blog_host}/${article.slug}.html`; - const context = `https://${process.env.blog_host}/c/${article.id}`; - const followers = `https://${process.env.blog_host}${routes.followers.replace(":actor", user.nickname)}`; + const context = fillRoute("context", article.id); + const followers = fillRoute("followers", user.nickname); const activity: Record = { - id: `https://${process.env.blog_host}${routes.activity.replace(":id", article.id.toString())}`, - "@context": "https://www.w3.org/ns/activitystreams", + id: fillRoute("activity", article.id), + "@context": CONTEXT, type: "Create", actor, context, - to: ["https://www.w3.org/ns/activitystreams#Public"], + to: [PUBLIC], cc: [followers], published }; - const objectId = `https://${process.env.blog_host}${routes.object.replace(":id", article.id.toString())}`; + const objectId = fillRoute("object", article.id); const content = readFileSync(article.file as string, "utf-8"); activity.object = { id: objectId, @@ -57,7 +146,7 @@ export const buildActivity = (article: Article, user: User) => { type: "Article", context, content, - to: ["https://www.w3.org/ns/activitystreams#Public"], + to: [PUBLIC], cc: [followers], url: canonicalUrl, mediaType: "text/markdown", @@ -67,10 +156,16 @@ export const buildActivity = (article: Article, user: User) => { return activity; }; -export const sendAll = async (publicKeyId: string, privateKey: string, activity: Record, inboxes: string[]) => { - const activityStr = JSON.stringify(activity, null, 4); - const hash = "sha-256=" + hasher.update(Buffer.from(activityStr).toString("hex")).digest("base64"); - const promises = inboxes.map((inbox) => send(publicKeyId, privateKey, activityStr, hash, inbox)); +export const sendAll = async (keyId: string, privateKey: string, activity: Record | string, inboxes: string[]) => { + activity = typeof activity === "string" + ? activity + : JSON.stringify(activity, null, 4); + + const hash = hashDigest(activity); + + const init: RequestInit = { method: "POST", body: activity }; + const init2 = { hash, privateKey, keyId }; + const promises = inboxes.map((inboxUrl) => signedFetch(inboxUrl, init, init2)); const results = await Promise.allSettled(promises); @@ -80,3 +175,10 @@ export const sendAll = async (publicKeyId: string, privateKey: string, activity: return !!!errors.length; }; + +export const orderedCollection = (id: string, orderedItems: (string | Record)[]) => ({ + type: "OrderedCollectionPage", + context: CONTEXT, + id, + orderedItems +}); \ No newline at end of file diff --git a/src/article.ts b/src/article.ts index e63e47c..bc1874d 100644 --- a/src/article.ts +++ b/src/article.ts @@ -30,6 +30,11 @@ export const getById = async (articleId: number): Promise
=> { .then((rec) => !!rec ? rec : null) }; +export const getByUserId = async (userId: number): Promise => + db
("articles") + .where("users_id", userId) + .orderBy("created_at", "desc"); + export const insert = async (userId: number, slug: string, title: string): Promise
=> { const data: Record = { users_id: userId, diff --git a/src/command.ts b/src/command.ts index fbfeff9..456c3f8 100644 --- a/src/command.ts +++ b/src/command.ts @@ -1,11 +1,11 @@ import fs from "node:fs"; import markdownit from 'markdown-it' import { slugRegex } from "./article.js"; -import { newUser, get as getUserByNickname, getActor, getFollowerInboxes } from "./user.js"; +import { newUser, get as getUserByNickname, getFollowerInboxes } from "./user.js"; import { insert as insertArticle } from "./article.js"; import { add as addToOutbox } from "./outbox.js"; -import { buildActivity, sendAll } from "./activity.js"; -import { routes } from "./index.js"; +import { createArticleActivity, sendAll } from "./activity.js"; +import { fillRoute } from "./router.js"; const md = markdownit(); @@ -54,9 +54,9 @@ else if (c === "new-article") { await addToOutbox(article.id); - const keyId = `https://${process.env.blog_host}${getActor(user.nickname)}#main-key`; + const keyId = fillRoute("actor", user.nickname) + "#main-key"; const inboxes = await getFollowerInboxes(user.id); - const activity = buildActivity(article, user); + const activity = createArticleActivity(article, user); const success = await sendAll(keyId, user.private_key, activity, inboxes); process.exit(0); diff --git a/src/index.ts b/src/index.ts index f8642d3..ed2fc63 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,28 +1,12 @@ import express from "express"; import ActivitypubExpress from "activitypub-express"; import { get as getOutbox } from "./outbox.js"; -import { get as getUserByNickname, getActor } from "./user.js"; +import { get as getUserByNickname, getId as getUserId } from "./user.js"; +import { Routes, fillRoute } from "./router.js"; const port = parseInt(process.env.port || "8080"); const app = express(); -export const routes = { - actor: "/author/:actor", - object: "/o/:id", - activity: "/a/:id", - inbox: "/author/:actor/inbox", - outbox: "/author/:actor/outbox", - followers: "/author/:actor/followers", - following: "/author/:actor/following", - liked: "/author/:actor/liked", - collections: "/author/:actor/c/:id", - blocked: "/author/:actor/blocked", - rejections: "/author/:actor/rejections", - rejected: "/author/:actor/rejected", - shares: "/a/:id/shares", - likes: "/a/:id/likes", -} as const; - const SITE = ActivitypubExpress({ name: process.env.blog_name, version: "1.0.0", @@ -30,7 +14,7 @@ const SITE = ActivitypubExpress({ actorParam: "actor", objectParam: "id", activityParam: "id", - routes, + Routes, }); app.use( @@ -39,34 +23,39 @@ app.use( SITE, ); -app.route(routes.inbox).get(SITE.net.inbox.get).post(SITE.net.inbox.post); -app.get(routes.followers, SITE.net.followers.get); -app.get(routes.following, SITE.net.following.get); -app.get(routes.liked, SITE.net.liked.get); -app.get(routes.object, SITE.net.object.get); -app.get(routes.activity, SITE.net.activityStream.get); -app.get(routes.shares, SITE.net.shares.get); -app.get(routes.likes, SITE.net.likes.get); +app.route(Routes.inbox).get(SITE.net.inbox.get).post(SITE.net.inbox.post); +app.get(Routes.followers, SITE.net.followers.get); +app.get(Routes.following, SITE.net.following.get); +app.get(Routes.liked, SITE.net.liked.get); +app.get(Routes.object, SITE.net.object.get); +app.get(Routes.activity, SITE.net.activityStream.get); +app.get(Routes.shares, SITE.net.shares.get); +app.get(Routes.likes, SITE.net.likes.get); app.get("/.well-known/webfinger", SITE.net.webfinger.get); app.get("/.well-known/nodeinfo", SITE.net.nodeInfoLocation.get); app.get("/nodeinfo/:version", SITE.net.nodeInfo.get); app.post("/proxy", SITE.net.proxy.post); -app.get(routes.outbox, async (req, res) => { - const nickname = req.params.actor; - const body = await getOutbox(nickname).then((out) => JSON.stringify(out, null, 4)); - res.append("Content-Type", "application/activity+json"); - res.send(body); +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.send(body); + } + else { + res.status(404).send("not found"); + } }); -app.get(routes.actor, async (req, res) => { +app.get(Routes.actor, async (req, res) => { const nickname = req.params.actor; const actor = await getUserByNickname(nickname); if (actor) { const accept = req.headers["accept"] || ""; if (accept.startsWith("application/activity+json")) { - const id = getActor(nickname); + const id = fillRoute("actor", nickname); const obj = { id, type: "Person", @@ -76,10 +65,10 @@ app.get(routes.actor, async (req, res) => { name: actor.name, summary: actor.bio, url: id, - inbox: `https://${process.env.blog_host}` + routes.inbox.replace(":actor", actor.nickname), - outbox: `https://${process.env.blog_host}` + routes.outbox.replace(":actor", actor.nickname), + inbox: fillRoute("inbox", actor.nickname), + outbox: fillRoute("outbox", actor.nickname), publicKey: { - id: `${id}#main-key`, + id: id + "#main-key", owner: id, publicKeyPem: actor.public_key } @@ -91,11 +80,11 @@ app.get(routes.actor, async (req, res) => { } else { // TODO: html version. - res.status(403).end(); + res.status(403).send("todo"); } } else { - res.status(404).end(); + res.status(404).send("actor not found"); } }); diff --git a/src/outbox.ts b/src/outbox.ts index 5224841..a4a041f 100644 --- a/src/outbox.ts +++ b/src/outbox.ts @@ -1,9 +1,9 @@ -import { readFileSync } from "node:fs"; import db from "./db.js"; import { z } from "zod"; -import { getId as getUserId, getActor } from "./user.js"; -import { routes } from "./index.js"; -import { getById as getArticleById } from "./article.js"; +import { get as getUserByNickname } from "./user.js"; +import { fillRoute } from "./router.js"; +import { getByUserId as getArticlesByUserId, getById as getArticleById, Article } from "./article.js"; +import { createArticleActivity, deleteArticleActivity, orderedCollection } from "./activity.js"; export const zRawOutboxRecord = z.object({ @@ -20,64 +20,27 @@ export const zRawOutboxRecord = z.object({ export type RawOutboxRecord = z.infer; -export const get = async (nickname: string): Promise | null> => { - const userId = await getUserId(nickname); +export const toCollection = async (nickname: string): Promise | null> => { + const user = await getUserByNickname(nickname); - if (userId) { - const actor = getActor(nickname); + if (user) { + const articles = await get(user.id) - const ret = db("outboxes") - .select("outboxes.*", "slug", "file", "title") - .join("articles", "outboxes.articles_id", "=", "articles.id") - .where("outboxes.users_id", userId) - .orderBy("created_at", "desc") - .then((recs: RawOutboxRecord[]) => { - const activities: Record[] = recs.map((rec) => { - const published = typeof rec.created_at === "number" ? new Date(rec.created_at) : rec.created_at; - const canonicalUrl = `https://${process.env.blog_host}/${rec.slug}.html`; - const context = `https://${process.env.blog_host}/c/${rec.articles_id}`; - const followers = `https://${process.env.blog_host}${routes.followers.replace(":actor", nickname)}`; - const activity: Record = { - id: `https://${process.env.blog_host}${routes.activity.replace(":id", rec.id.toString())}`, - "@context": "https://www.w3.org/ns/activitystreams", - type: rec.verb, - actor, - context, - to: ["https://www.w3.org/ns/activitystreams#Public"], - cc: [followers], - published - }; + const activities = articles.map((a) => { + if (a.verb === "Create") { + return createArticleActivity(a, user); + } + else if (a.verb === "Delete") { + return deleteArticleActivity(a, user); + } + else { + throw "unexpected verb"; + } + }); - const objectId = `https://${process.env.blog_host}${routes.object.replace(":id", rec.articles_id.toString())}`; - const content = readFileSync(rec.file as string, "utf-8"); - activity.object = { - id: objectId, - actor, - attributedTo: actor, - type: "Article", - context, - content, - to: ["https://www.w3.org/ns/activitystreams#Public"], - cc: [followers], - url: canonicalUrl, - mediaType: "text/markdown", - published - }; + const collection = orderedCollection(fillRoute("outbox", user.nickname), activities); - return activity; - }); - - const collection = { - type: "OrderedCollectionPage", - context: "https://www.w3.org/ns/activitystreams", - id: `https://${process.env.blog_host}${routes.outbox.replace(":actor", nickname)}`, - orderedItems: activities - }; - - return collection; - }); - - return ret; + return collection; } else { return null; @@ -94,9 +57,23 @@ export const add = async (articleId: number): Promise => { articles_id: articleId, created_at: article.created_at }) - .returning("id") - .then(([{ id: id }]: { id: number }[]) => id) + .returning("id") + .then(([{ id: id }]: { id: number }[]) => id) } else throw "failed to add to outbox"; }; + +interface ArticleActivity extends Article { + verb: string; + activity_created_at: Date | number; +} + +export const get = async (userId: number): Promise => + db("articles") + .select("articles.*", "outboxes.verb", "outboxes.created_at as activity_created_at") + .join("outboxes", "articles.id", "outboxes.articles_id") + .where("users_id", userId) + .whereIn("outboxes.verb", ["Create", "Delete"]) + .orderBy("outboxes.created_at", "desc") + ; diff --git a/src/router.ts b/src/router.ts new file mode 100644 index 0000000..3e86a63 --- /dev/null +++ b/src/router.ts @@ -0,0 +1,27 @@ +export const Routes = { + actor: "/author/:actor", + object: "/o/:id", + activity: "/a/:id", + inbox: "/author/:actor/inbox", + outbox: "/author/:actor/outbox", + followers: "/author/:actor/followers", + following: "/author/:actor/following", + liked: "/author/:actor/liked", + collections: "/author/:actor/c/:id", + blocked: "/author/:actor/blocked", + rejections: "/author/:actor/rejections", + rejected: "/author/:actor/rejected", + shares: "/a/:id/shares", + likes: "/a/:id/likes", + context: "/c/:id", +} as const; + +export type Route = keyof typeof Routes; + +export const fillRoute = (route: Route, value: number | string) => + "https://" + + process.env.blog_host + + typeof value === "number" + ? Routes[route].replace(":id", value.toString()) + : Routes[route].replace(":actor", value as string) + ; \ No newline at end of file diff --git a/src/user.ts b/src/user.ts index f921dba..e3e058a 100644 --- a/src/user.ts +++ b/src/user.ts @@ -1,7 +1,6 @@ import { generateKeyPair } from "node:crypto"; import { z } from "zod"; import db from "./db.js"; -import { routes } from "./index.js"; import { getFollowers } from "./collection.js"; export const nicknameRegex = /^[a-zA-Z0-9_]+$/; @@ -26,11 +25,6 @@ export const get = async (nickname: string) => .first() ; -export const getActor = (nickname: string) => - "https://" - + process.env.blog_host - + routes.actor.replace(":actor", nickname); - export const getNickname = async (userId: number): Promise => db("users") .select("nickname")