tons of changes.

This commit is contained in:
Moon Man 2023-12-25 21:33:18 -05:00
parent b19074b994
commit 135960e7cf
12 changed files with 275 additions and 36 deletions

1
.gitignore vendored
View File

@ -3,3 +3,4 @@ node_modules/*
pages/* pages/*
*.sqlite3 *.sqlite3
.env* .env*
*.pem

View File

@ -1,14 +1,15 @@
# How to use it # How to use it
0. create a dotenv .env 1. openssl req -x509 -newkey rsa:2048 -keyout private.pem -out public.pem -sha256 -days 9999 -nodes -subj "/CN=blog.whatever.example.net"
1. Create a directory "pages" 2. create a dotenv .env
2. npm install 3. Create a directory "pages"
3. npx tsc 4. npm install
4. node dist/command.js new-user nickname "fullname" 5. npx tsc
5. create a file pages/whatever-page.md 6. node dist/command.js new-user nickname "fullname"
6. node dist/command.js new-article nickname whatever-page "Whatever Page Title" 7. create a file pages/whatever-page.md
7. node --env-file=.env dist/index.js 8. node dist/command.js new-article nickname whatever-page "Whatever Page Title"
8. curl --verbose -H 'Accept: application/activity+json' http://127.0.0.1:8080/author/nickname/outbox 9. node --env-file=.env dist/index.js
10. curl --verbose -H 'Accept: application/activity+json' http://127.0.0.1:8080/author/nickname/outbox
## dotenv contents: ## dotenv contents:

View File

@ -9,6 +9,8 @@ export const up = async function (knex) {
table.string("name").notNullable(); table.string("name").notNullable();
table.string("nickname").notNullable(); table.string("nickname").notNullable();
table.string("bio"); table.string("bio");
table.string("public_key").notNullable();
table.string("private_key").notNullable();
table.boolean("deleted").notNullable(); table.boolean("deleted").notNullable();
table.timestamps(true, false, false); table.timestamps(true, false, false);
}) })
@ -35,6 +37,10 @@ export const up = async function (knex) {
table.integer("users_id"); table.integer("users_id");
table.foreign("users_id").references("users.id").onDelete("CASCADE"); table.foreign("users_id").references("users.id").onDelete("CASCADE");
table.string("value").notNullable(); table.string("value").notNullable();
table.timestamps(true, false, false);
table.index(["collection_types_id", "articles_id"]);
table.index(["collection_types_id", "users_id"]);
}) })
.createTable("outboxes", (table) => { .createTable("outboxes", (table) => {
table.increments("id"); table.increments("id");
@ -45,6 +51,15 @@ export const up = async function (knex) {
table.foreign("articles_id").references("articles.id").onDelete("CASCADE"); table.foreign("articles_id").references("articles.id").onDelete("CASCADE");
table.timestamps(true, false, false); table.timestamps(true, false, false);
}) })
.createTable("remote_users", (table) => {
table.increments("id");
table.string("actor").notNullable().unique();
table.string("nickname").notNullable();
table.string("name");
table.string("inbox");
table.string("shared_inbox");
table.timestamps(true, false, false);
})
.then(() => { .then(() => {
// Hardcoding these so they can be referenced by constants in code. // Hardcoding these so they can be referenced by constants in code.
knex("collection_types").insert([ knex("collection_types").insert([
@ -71,6 +86,7 @@ export const up = async function (knex) {
*/ */
export const down = function (knex) { export const down = function (knex) {
return knex.schema return knex.schema
.dropTableIfExists("remote_users")
.dropTableIfExists("outboxes") .dropTableIfExists("outboxes")
.dropTableIfExists("collections") .dropTableIfExists("collections")
.dropTableIfExists("collection_types") .dropTableIfExists("collection_types")

8
module.d.ts vendored
View File

@ -2,3 +2,11 @@ declare module "activitypub-express" {
function ActivitypubExpress(options: Record<string, any>); function ActivitypubExpress(options: Record<string, any>);
export default ActivitypubExpress; export default ActivitypubExpress;
} }
declare module "activitypub-http-signatures" {
export class Sha256Signer {
constructor(options: { publicKeyId: string, privateKey: string });
sign: (options: { url: string, method: string, headers: any[] }) => string;
};
}

9
package-lock.json generated
View File

@ -10,6 +10,7 @@
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {
"activitypub-express": "^4.4.1", "activitypub-express": "^4.4.1",
"activitypub-http-signatures": "^2.0.1",
"better-sqlite3": "^9.2.2", "better-sqlite3": "^9.2.2",
"knex": "^3.1.0", "knex": "^3.1.0",
"markdown-it": "^14.0.0", "markdown-it": "^14.0.0",
@ -2012,6 +2013,14 @@
"npm": ">=7" "npm": ">=7"
} }
}, },
"node_modules/activitypub-http-signatures": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/activitypub-http-signatures/-/activitypub-http-signatures-2.0.1.tgz",
"integrity": "sha512-Yiko/1xGQVUzFkrx90nuRrJUPZm2uzqaU6os2PkOXLPv122xGBvESz1z2H2zxdyMCfqCuOp9dQzrAatMmCeCHg==",
"engines": {
"node": "^16.8.0"
}
},
"node_modules/agent-base": { "node_modules/agent-base": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",

View File

@ -29,6 +29,7 @@
}, },
"dependencies": { "dependencies": {
"activitypub-express": "^4.4.1", "activitypub-express": "^4.4.1",
"activitypub-http-signatures": "^2.0.1",
"better-sqlite3": "^9.2.2", "better-sqlite3": "^9.2.2",
"knex": "^3.1.0", "knex": "^3.1.0",
"markdown-it": "^14.0.0", "markdown-it": "^14.0.0",

84
src/activity.ts Normal file
View File

@ -0,0 +1,84 @@
import { Sha256Signer } from "activitypub-http-signatures";
import { createHash } from "node:crypto";
import { Article } from "./article.js";
import { User, getActor } from "./user.js";
import { readFileSync } from "fs";
import { routes } from "./index.js";
const hasher = createHash("sha256");
export const send = async (publicKeyId: string, privateKey: string, activity: string, hash: string, inbox: string) => {
activity = typeof activity === "string" ? activity : JSON.stringify(activity, null, 4);
const headers = {
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>;
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.signature = signature;
return fetch(inbox, {
method: "POST",
headers,
body: activity
});
};
export const buildActivity = (article: Article, user: User) => {
const actor = getActor(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 activity: Record<string, any> = {
id: `https://${process.env.blog_host}${routes.activity.replace(":id", article.id.toString())}`,
"@context": "https://www.w3.org/ns/activitystreams",
type: "Create",
actor,
context,
to: ["https://www.w3.org/ns/activitystreams#Public"],
cc: [followers],
published
};
const objectId = `https://${process.env.blog_host}${routes.object.replace(":id", article.id.toString())}`;
const content = readFileSync(article.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
};
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));
const results = await Promise.allSettled(promises);
const errors = results.filter((s) => s.status === "rejected").map((s: any) => (s as unknown as PromiseRejectedResult).reason);
errors.forEach((e) => console.error("inbox post failure", e));
return !!!errors.length;
};

View File

@ -30,15 +30,20 @@ export const getById = async (articleId: number): Promise<Article | null> => {
.then((rec) => !!rec ? rec : null) .then((rec) => !!rec ? rec : null)
}; };
export const insert = async (userId: number, slug: string, title: string): Promise<number> => { export const insert = async (userId: number, slug: string, title: string): Promise<Article> => {
return db("articles").insert({ const data: Record<string, any> = {
users_id: userId, users_id: userId,
slug, slug,
title, title,
file: `pages/${slug}.md`, file: `pages/${slug}.md`,
deleted: false, deleted: false,
created_at: new Date() created_at: new Date()
}) };
return db("articles").insert(data)
.returning("id") .returning("id")
.then(([{ id: id }]: { id: number }[]) => id) .then(([{ id: id }]: { id: number }[]) => ({
id,
...data
} as Article))
}; };

29
src/collection.ts Normal file
View File

@ -0,0 +1,29 @@
import db from "./db.js";
import { z } from "zod";
export const zCollection = z.object({
id: z.number().min(0),
collection_types_id: z.number().min(0),
articles_id: z.optional(z.number().min(0)),
users_id: z.optional(z.number().min(0)),
value: z.string().min(1)
});
export type CollectionEntry = z.infer<typeof zCollection>;
export interface Follower {
actor: string,
nickname: string,
name: string,
inbox: string | null,
shared_inbox: string | null
};
export const getFollowers = async (userId: number): Promise<Follower[]> =>
db<CollectionEntry>("collections")
.select("remote_users.*", "collections.value")
.join("remote_users", "remote_users.actor", "=", "collections.value")
.where("collection_types_id", 0)
.where("users_id", userId)
.orderBy("collections.created_at", "desc")
;

View File

@ -1,9 +1,11 @@
import fs from "node:fs"; import fs from "node:fs";
import markdownit from 'markdown-it' import markdownit from 'markdown-it'
import { slugRegex } from "./article.js"; import { slugRegex } from "./article.js";
import { newUser, getId as getUserIdByNickname } from "./user.js"; import { newUser, get as getUserByNickname, getActor, getFollowerInboxes } from "./user.js";
import { insert as insertArticle } from "./article.js"; import { insert as insertArticle } from "./article.js";
import { add as addToOutbox } from "./outbox.js"; import { add as addToOutbox } from "./outbox.js";
import { buildActivity, sendAll } from "./activity.js";
import { routes } from "./index.js";
const md = markdownit(); const md = markdownit();
@ -16,6 +18,7 @@ if (c === "new-user") {
const bio = process.argv[5] || ""; const bio = process.argv[5] || "";
const userId = await newUser(nickname, name, bio); const userId = await newUser(nickname, name, bio);
console.log(userId); console.log(userId);
process.exit(0);
} }
else if (c === "new-article") { else if (c === "new-article") {
const nickname = process.argv[3]; const nickname = process.argv[3];
@ -23,8 +26,8 @@ else if (c === "new-article") {
const title = process.argv[5]; const title = process.argv[5];
const images = process.argv.slice(6); const images = process.argv.slice(6);
const userId = await getUserIdByNickname(nickname); const user = await getUserByNickname(nickname);
if (!userId) { if (!user) {
console.error("Nonexistent user"); console.error("Nonexistent user");
process.exit(1); process.exit(1);
} }
@ -46,10 +49,15 @@ else if (c === "new-article") {
const html = md.render(markdown); const html = md.render(markdown);
fs.writeFileSync(htmlFilename, html); fs.writeFileSync(htmlFilename, html);
const articleId = await insertArticle(userId, slug, title); const article = await insertArticle(user.id, slug, title);
console.log(articleId); console.log(article.id);
await addToOutbox(articleId); await addToOutbox(article.id);
const keyId = `https://${process.env.blog_host}${getActor(user.nickname)}#main-key`;
const inboxes = await getFollowerInboxes(user.id);
const activity = buildActivity(article, user);
const success = await sendAll(keyId, user.private_key, activity, inboxes);
process.exit(0); process.exit(0);
} }

View File

@ -1,6 +1,7 @@
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, getActor } from "./user.js";
const port = parseInt(process.env.port || "8080"); const port = parseInt(process.env.port || "8080");
const app = express(); const app = express();
@ -23,16 +24,13 @@ export const routes = {
} as const; } as const;
const SITE = ActivitypubExpress({ const SITE = ActivitypubExpress({
name: process.env.BLOG_NAME, name: process.env.blog_name,
version: "1.0.0", version: "1.0.0",
domain: process.env.BLOG_DOMAIN, domain: process.env.blog_host,
actorParam: "actor", actorParam: "actor",
objectParam: "id", objectParam: "id",
activityParam: "id", activityParam: "id",
routes, routes,
endpoints: {
proxyUrl: "https://localhost/proxy",
},
}); });
app.use( app.use(
@ -42,8 +40,6 @@ app.use(
); );
app.route(routes.inbox).get(SITE.net.inbox.get).post(SITE.net.inbox.post); app.route(routes.inbox).get(SITE.net.inbox.get).post(SITE.net.inbox.post);
// app.route(routes.outbox).get(SITE.net.outbox.get).post(SITE.net.outbox.post);
app.get(routes.actor, SITE.net.actor.get);
app.get(routes.followers, SITE.net.followers.get); app.get(routes.followers, SITE.net.followers.get);
app.get(routes.following, SITE.net.following.get); app.get(routes.following, SITE.net.following.get);
app.get(routes.liked, SITE.net.liked.get); app.get(routes.liked, SITE.net.liked.get);
@ -63,6 +59,46 @@ app.get(routes.outbox, async (req, res) => {
res.send(body); res.send(body);
}); });
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 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: `https://${process.env.blog_host}` + routes.inbox.replace(":actor", actor.nickname),
outbox: `https://${process.env.blog_host}` + routes.outbox.replace(":actor", actor.nickname),
publicKey: {
id: `${id}#main-key`,
owner: id,
publicKeyPem: actor.public_key
}
};
const body = JSON.stringify(obj, null, 4);
res.append("Content-Type", "application/activity+json");
res.send(body);
}
else {
// TODO: html version.
res.status(403).end();
}
}
else {
res.status(404).end();
}
});
app.get("/:slug", (req, res) => { app.get("/:slug", (req, res) => {
}); });

View File

@ -1,6 +1,8 @@
import db from "./db.js"; import { generateKeyPair } from "node:crypto";
import { z } from "zod"; import { z } from "zod";
import db from "./db.js";
import { routes } from "./index.js"; import { routes } from "./index.js";
import { getFollowers } from "./collection.js";
export const nicknameRegex = /^[a-zA-Z0-9_]+$/; export const nicknameRegex = /^[a-zA-Z0-9_]+$/;
@ -9,6 +11,8 @@ export const zUser = z.object({
name: z.string().min(1), name: z.string().min(1),
nickname: z.string().regex(nicknameRegex), nickname: z.string().regex(nicknameRegex),
bio: z.string(), bio: z.string(),
public_key: z.string(),
private_key: z.string(),
deleted: z.boolean(), deleted: z.boolean(),
created_at: z.date(), created_at: z.date(),
updated_at: z.union([z.date(), z.null()]) updated_at: z.union([z.date(), z.null()])
@ -16,6 +20,12 @@ export const zUser = z.object({
export type User = z.infer<typeof zUser>; export type User = z.infer<typeof zUser>;
export const get = async (nickname: string) =>
db<User>("users")
.where("nickname", nickname)
.first()
;
export const getActor = (nickname: string) => export const getActor = (nickname: string) =>
"https://" "https://"
+ process.env.blog_host + process.env.blog_host
@ -38,11 +48,30 @@ export const getId = async (nickname: string): Promise<number | null> =>
; ;
export const newUser = async (nickname: string, name: string, bio: string): Promise<number> => { export const newUser = async (nickname: string, name: string, bio: string): Promise<number> => {
const { pub, priv } = await (new Promise((resolve, reject) => {
generateKeyPair("rsa", {
modulusLength: 2048,
publicKeyEncoding: {
type: "spki",
format: "pem"
},
privateKeyEncoding: {
type: "pkcs8",
format: "pem"
}
}, (err, pub, priv) => {
if (err) reject(err);
else resolve({ pub, priv });
})
}) as Promise<{ pub: string, priv: string }>);
return db("users") return db("users")
.insert({ .insert({
name, name,
nickname, nickname,
bio, bio,
public_key: pub,
private_key: priv,
deleted: false, deleted: false,
created_at: new Date() created_at: new Date()
}) })
@ -51,3 +80,15 @@ export const newUser = async (nickname: string, name: string, bio: string): Prom
; ;
}; };
export const getFollowerInboxes = async (userId: number): Promise<string[]> =>
getFollowers(userId).then((followers) => {
const inboxes: Set<string> = new Set();
for (const { inbox, shared_inbox } of followers) {
const res = shared_inbox || inbox;
if (res) inboxes.add(res);
}
return [...inboxes];
});