restructure

This commit is contained in:
Moon Man 2023-12-26 08:06:38 -05:00
parent 468878bdaf
commit c46f5ee1eb
5 changed files with 159 additions and 93 deletions

View File

@ -1,79 +1,52 @@
import { Sha256Signer } from "activitypub-http-signatures"; import { readFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { Article } from "./article.js"; import { Article } from "./article.js";
import { User } from "./user.js"; import { User, getByActor } from "./user.js";
import { readFileSync } from "fs";
import { fillRoute } from "./router.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 CONTEXT = "https://www.w3.org/ns/activitystreams";
export const PUBLIC = CONTEXT + "#Public"; 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) => const body = await streamToString(req.body);
"sha-256=" let activity: Record<string, any>;
+ hasher try {
.update((Buffer.isBuffer(payload) ? payload : Buffer.from(payload)).toString("hex")) activity = JSON.parse(body);
.digest("base64"); }
catch (e) {
console.warn("body json parse failed");
return;
}
const flattenHeaders = (headers: Record<string, string>) => if (activity.type === "Follow") {
Object.entries(headers).map(([key, value]) => `${key}: ${value}`); const actor = await getByActor(activity.object);
interface SignedInit { if (actor) {
keyId: string; const follower = activity.actor;
privateKey: string; }
digest?: string; else {
}; console.warn("follow attempt on unknown user");
}
export const signedFetch = async (url: string, init: RequestInit, signedInit: SignedInit) => { }
const signedHeaders: HeadersInit = [ else if (activity.type === "Undo") {
["Date", new Date().toUTCString()],
["Host", new URL(url).host], }
["Content-Type", `application/ld+json; profile="${CONTEXT}"`] else {
]; console.warn("unsupported activity type");
}
if (signedInit.digest) { }
signedHeaders.push(["Digest", signedInit.digest]); finally { }
}
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) => { export const sendActivity = async (publicKeyId: string, privateKey: string, activity: string, hash: string, inbox: string) => {
const headers: Record<string, string> = { const headers: Record<string, string> = {
date: new Date().toUTCString(), date: new Date().toUTCString(),
@ -97,6 +70,7 @@ export const sendActivity = async (publicKeyId: string, privateKey: string, acti
body: activity body: activity
}); });
}; };
*/
export const deleteArticleActivity = (article: Article, user: User) => { export const deleteArticleActivity = (article: Article, user: User) => {
const actor = fillRoute("actor", user.nickname); 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 = typeof activity === "string"
? activity ? activity
: JSON.stringify(activity, null, 4); : JSON.stringify(activity, null, 4);
const hash = hashDigest(activity); const hash = hashDigest(activity);
const init: RequestInit = { method: "POST", body: activity }; const init: RequestInit = { method: "POST", body: activity };
@ -181,4 +155,26 @@ export const orderedCollection = (id: string, orderedItems: (string | Record<str
context: CONTEXT, context: CONTEXT,
id, id,
orderedItems orderedItems
}); });
export const userToPerson = (user: User) => {
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
}
};
};

View File

@ -1,10 +1,10 @@
import { readFileSync } from "fs";
import express from "express"; import express from "express";
import ActivitypubExpress from "activitypub-express"; import ActivitypubExpress from "activitypub-express";
import { get as getOutbox } from "./outbox.js"; import { get as getOutbox } from "./outbox.js";
import { get as getUserByNickname, getId as getUserId } from "./user.js"; import { get as getUserByNickname, getId as getUserId } from "./user.js";
import { Routes, fillRoute } from "./router.js"; import { Routes } from "./router.js";
import { getBySlug } from "./article.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 port = parseInt(process.env.port || "8080");
const app = express(); const app = express();
@ -42,7 +42,7 @@ app.get(Routes.outbox, async (req, res) => {
const userId = await getUserId(req.params.actor); const userId = await getUserId(req.params.actor);
if (userId) { if (userId) {
const body = await getOutbox(userId).then((out) => JSON.stringify(out, null, 4)); 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); res.send(body);
} }
else { else {
@ -56,28 +56,11 @@ app.get(Routes.actor, async (req, res) => {
if (actor) { if (actor) {
const accept = req.headers["accept"] || ""; const accept = req.headers["accept"] || "";
if (accept.startsWith("application/activity+json")) { if (accept.includes(ACTIVITYPUB_TYPE)) {
const id = fillRoute("actor", nickname); const obj = userToPerson(actor);
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
}
};
const body = JSON.stringify(obj, null, 4); const body = JSON.stringify(obj, null, 4);
res.append("Content-Type", "application/activity+json"); res.set("Content-Type", ACTIVITYPUB_TYPE);
res.send(body); res.send(body);
} }
else { else {
@ -91,7 +74,7 @@ app.get(Routes.actor, async (req, res) => {
}); });
app.get("/:slug.html", 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) { if (!article || article.deleted) {
res.status(404).send("not found"); res.status(404).send("not found");
@ -103,7 +86,7 @@ app.get("/:slug.html", async (req, res) => {
}); });
app.get("/:slug.md", 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) { if (!article || article.deleted) {
res.status(404).send("not found"); res.status(404).send("not found");

61
src/net.ts Normal file
View File

@ -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<string, string>) =>
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);
};

View File

@ -2,6 +2,7 @@ import { generateKeyPair } from "node:crypto";
import { z } from "zod"; import { z } from "zod";
import db from "./db.js"; import db from "./db.js";
import { getFollowers } from "./collection.js"; import { getFollowers } from "./collection.js";
import { fillRoute } from "./router.js";
export const nicknameRegex = /^[a-zA-Z0-9_]+$/; export const nicknameRegex = /^[a-zA-Z0-9_]+$/;
@ -25,6 +26,17 @@ export const get = async (nickname: string) =>
.first() .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<string | null> => export const getNickname = async (userId: number): Promise<string | null> =>
db("users") db("users")
.select("nickname") .select("nickname")

14
src/util.ts Normal file
View File

@ -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<Uint8Array>) => {
const arr = new Uint8Array(await new Response(stream).arrayBuffer());
return new TextDecoder("utf-8").decode(arr);
};