restructure
This commit is contained in:
parent
468878bdaf
commit
c46f5ee1eb
130
src/activity.ts
130
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<string, any>;
|
||||
try {
|
||||
activity = JSON.parse(body);
|
||||
}
|
||||
catch (e) {
|
||||
console.warn("body json parse failed");
|
||||
return;
|
||||
}
|
||||
|
||||
const flattenHeaders = (headers: Record<string, string>) =>
|
||||
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<string, string> = {
|
||||
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<str
|
|||
context: CONTEXT,
|
||||
id,
|
||||
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
|
||||
}
|
||||
};
|
||||
};
|
35
src/index.ts
35
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");
|
||||
|
|
|
@ -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);
|
||||
};
|
12
src/user.ts
12
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<string | null> =>
|
||||
db("users")
|
||||
.select("nickname")
|
||||
|
|
|
@ -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);
|
||||
};
|
Loading…
Reference in New Issue