deletes in outbox now.
This commit is contained in:
parent
22ed2a16d6
commit
6f2f5843c0
142
src/activity.ts
142
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<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
|
||||
});
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
|
|
67
src/index.ts
67
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");
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -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")
|
||||
;
|
||||
|
|
|
@ -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)
|
||||
;
|
|
@ -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")
|
||||
|
|
Loading…
Reference in New Issue