restructure
This commit is contained in:
parent
468878bdaf
commit
c46f5ee1eb
126
src/activity.ts
126
src/activity.ts
|
@ -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);
|
||||||
|
@ -182,3 +156,25 @@ export const orderedCollection = (id: string, orderedItems: (string | Record<str
|
||||||
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
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
35
src/index.ts
35
src/index.ts
|
@ -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");
|
||||||
|
|
|
@ -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 { 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")
|
||||||
|
|
|
@ -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