deletes in outbox now.

This commit is contained in:
Moon Man 2023-12-26 06:44:45 -05:00
parent 22ed2a16d6
commit 6f2f5843c0
7 changed files with 224 additions and 130 deletions

View File

@ -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<string, string>) =>
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<string, string> = {
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<string, string>;
"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<string, any> = {
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<string, any> = {
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<string, any>, 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, any> | 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<string, any>)[]) => ({
type: "OrderedCollectionPage",
context: CONTEXT,
id,
orderedItems
});

View File

@ -30,6 +30,11 @@ export const getById = async (articleId: number): Promise<Article | null> => {
.then((rec) => !!rec ? rec : null)
};
export const getByUserId = async (userId: number): Promise<Article[]> =>
db<Article>("articles")
.where("users_id", userId)
.orderBy("created_at", "desc");
export const insert = async (userId: number, slug: string, title: string): Promise<Article> => {
const data: Record<string, any> = {
users_id: userId,

View File

@ -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);

View File

@ -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");
}
});

View File

@ -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<typeof zRawOutboxRecord>;
export const get = async (nickname: string): Promise<Record<string, any> | null> => {
const userId = await getUserId(nickname);
export const toCollection = async (nickname: string): Promise<Record<string, any> | null> => {
const user = await getUserByNickname(nickname);
if (userId) {
const actor = getActor(nickname);
if (user) {
const articles = await get(user.id)
const ret = db<RawOutboxRecord>("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<string, any>[] = 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<string, any> = {
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<number> => {
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<ArticleActivity[]> =>
db<ArticleActivity>("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")
;

27
src/router.ts Normal file
View File

@ -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)
;

View File

@ -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<string | null> =>
db("users")
.select("nickname")