tons of changes.
This commit is contained in:
parent
b19074b994
commit
135960e7cf
|
@ -3,3 +3,4 @@ node_modules/*
|
|||
pages/*
|
||||
*.sqlite3
|
||||
.env*
|
||||
*.pem
|
19
README.md
19
README.md
|
@ -1,14 +1,15 @@
|
|||
# How to use it
|
||||
|
||||
0. create a dotenv .env
|
||||
1. Create a directory "pages"
|
||||
2. npm install
|
||||
3. npx tsc
|
||||
4. node dist/command.js new-user nickname "fullname"
|
||||
5. create a file pages/whatever-page.md
|
||||
6. node dist/command.js new-article nickname whatever-page "Whatever Page Title"
|
||||
7. node --env-file=.env dist/index.js
|
||||
8. curl --verbose -H 'Accept: application/activity+json' http://127.0.0.1:8080/author/nickname/outbox
|
||||
1. openssl req -x509 -newkey rsa:2048 -keyout private.pem -out public.pem -sha256 -days 9999 -nodes -subj "/CN=blog.whatever.example.net"
|
||||
2. create a dotenv .env
|
||||
3. Create a directory "pages"
|
||||
4. npm install
|
||||
5. npx tsc
|
||||
6. node dist/command.js new-user nickname "fullname"
|
||||
7. create a file pages/whatever-page.md
|
||||
8. node dist/command.js new-article nickname whatever-page "Whatever Page Title"
|
||||
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:
|
||||
|
||||
|
|
|
@ -9,6 +9,8 @@ export const up = async function (knex) {
|
|||
table.string("name").notNullable();
|
||||
table.string("nickname").notNullable();
|
||||
table.string("bio");
|
||||
table.string("public_key").notNullable();
|
||||
table.string("private_key").notNullable();
|
||||
table.boolean("deleted").notNullable();
|
||||
table.timestamps(true, false, false);
|
||||
})
|
||||
|
@ -35,6 +37,10 @@ export const up = async function (knex) {
|
|||
table.integer("users_id");
|
||||
table.foreign("users_id").references("users.id").onDelete("CASCADE");
|
||||
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) => {
|
||||
table.increments("id");
|
||||
|
@ -45,6 +51,15 @@ export const up = async function (knex) {
|
|||
table.foreign("articles_id").references("articles.id").onDelete("CASCADE");
|
||||
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(() => {
|
||||
// Hardcoding these so they can be referenced by constants in code.
|
||||
knex("collection_types").insert([
|
||||
|
@ -71,6 +86,7 @@ export const up = async function (knex) {
|
|||
*/
|
||||
export const down = function (knex) {
|
||||
return knex.schema
|
||||
.dropTableIfExists("remote_users")
|
||||
.dropTableIfExists("outboxes")
|
||||
.dropTableIfExists("collections")
|
||||
.dropTableIfExists("collection_types")
|
||||
|
|
|
@ -2,3 +2,11 @@ declare module "activitypub-express" {
|
|||
function ActivitypubExpress(options: Record<string, any>);
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"activitypub-express": "^4.4.1",
|
||||
"activitypub-http-signatures": "^2.0.1",
|
||||
"better-sqlite3": "^9.2.2",
|
||||
"knex": "^3.1.0",
|
||||
"markdown-it": "^14.0.0",
|
||||
|
@ -2012,6 +2013,14 @@
|
|||
"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": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
|
|
|
@ -29,6 +29,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"activitypub-express": "^4.4.1",
|
||||
"activitypub-http-signatures": "^2.0.1",
|
||||
"better-sqlite3": "^9.2.2",
|
||||
"knex": "^3.1.0",
|
||||
"markdown-it": "^14.0.0",
|
||||
|
|
|
@ -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;
|
||||
};
|
|
@ -30,15 +30,20 @@ export const getById = async (articleId: number): Promise<Article | null> => {
|
|||
.then((rec) => !!rec ? rec : null)
|
||||
};
|
||||
|
||||
export const insert = async (userId: number, slug: string, title: string): Promise<number> => {
|
||||
return db("articles").insert({
|
||||
export const insert = async (userId: number, slug: string, title: string): Promise<Article> => {
|
||||
const data: Record<string, any> = {
|
||||
users_id: userId,
|
||||
slug,
|
||||
title,
|
||||
file: `pages/${slug}.md`,
|
||||
deleted: false,
|
||||
created_at: new Date()
|
||||
})
|
||||
};
|
||||
|
||||
return db("articles").insert(data)
|
||||
.returning("id")
|
||||
.then(([{ id: id }]: { id: number }[]) => id)
|
||||
.then(([{ id: id }]: { id: number }[]) => ({
|
||||
id,
|
||||
...data
|
||||
} as Article))
|
||||
};
|
||||
|
|
|
@ -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")
|
||||
;
|
|
@ -1,9 +1,11 @@
|
|||
import fs from "node:fs";
|
||||
import markdownit from 'markdown-it'
|
||||
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 { add as addToOutbox } from "./outbox.js";
|
||||
import { buildActivity, sendAll } from "./activity.js";
|
||||
import { routes } from "./index.js";
|
||||
|
||||
const md = markdownit();
|
||||
|
||||
|
@ -16,6 +18,7 @@ if (c === "new-user") {
|
|||
const bio = process.argv[5] || "";
|
||||
const userId = await newUser(nickname, name, bio);
|
||||
console.log(userId);
|
||||
process.exit(0);
|
||||
}
|
||||
else if (c === "new-article") {
|
||||
const nickname = process.argv[3];
|
||||
|
@ -23,8 +26,8 @@ else if (c === "new-article") {
|
|||
const title = process.argv[5];
|
||||
const images = process.argv.slice(6);
|
||||
|
||||
const userId = await getUserIdByNickname(nickname);
|
||||
if (!userId) {
|
||||
const user = await getUserByNickname(nickname);
|
||||
if (!user) {
|
||||
console.error("Nonexistent user");
|
||||
process.exit(1);
|
||||
}
|
||||
|
@ -46,10 +49,15 @@ else if (c === "new-article") {
|
|||
const html = md.render(markdown);
|
||||
fs.writeFileSync(htmlFilename, html);
|
||||
|
||||
const articleId = await insertArticle(userId, slug, title);
|
||||
console.log(articleId);
|
||||
const article = await insertArticle(user.id, slug, title);
|
||||
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);
|
||||
}
|
||||
|
|
50
src/index.ts
50
src/index.ts
|
@ -1,6 +1,7 @@
|
|||
import express from "express";
|
||||
import ActivitypubExpress from "activitypub-express";
|
||||
import { get as getOutbox } from "./outbox.js";
|
||||
import { get as getUserByNickname, getActor } from "./user.js";
|
||||
|
||||
const port = parseInt(process.env.port || "8080");
|
||||
const app = express();
|
||||
|
@ -23,16 +24,13 @@ export const routes = {
|
|||
} as const;
|
||||
|
||||
const SITE = ActivitypubExpress({
|
||||
name: process.env.BLOG_NAME,
|
||||
name: process.env.blog_name,
|
||||
version: "1.0.0",
|
||||
domain: process.env.BLOG_DOMAIN,
|
||||
domain: process.env.blog_host,
|
||||
actorParam: "actor",
|
||||
objectParam: "id",
|
||||
activityParam: "id",
|
||||
routes,
|
||||
endpoints: {
|
||||
proxyUrl: "https://localhost/proxy",
|
||||
},
|
||||
});
|
||||
|
||||
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.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.following, SITE.net.following.get);
|
||||
app.get(routes.liked, SITE.net.liked.get);
|
||||
|
@ -63,6 +59,46 @@ app.get(routes.outbox, async (req, res) => {
|
|||
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) => {
|
||||
});
|
||||
|
||||
|
|
43
src/user.ts
43
src/user.ts
|
@ -1,6 +1,8 @@
|
|||
import db from "./db.js";
|
||||
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_]+$/;
|
||||
|
||||
|
@ -9,6 +11,8 @@ export const zUser = z.object({
|
|||
name: z.string().min(1),
|
||||
nickname: z.string().regex(nicknameRegex),
|
||||
bio: z.string(),
|
||||
public_key: z.string(),
|
||||
private_key: z.string(),
|
||||
deleted: z.boolean(),
|
||||
created_at: z.date(),
|
||||
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 const get = async (nickname: string) =>
|
||||
db<User>("users")
|
||||
.where("nickname", nickname)
|
||||
.first()
|
||||
;
|
||||
|
||||
export const getActor = (nickname: string) =>
|
||||
"https://"
|
||||
+ 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> => {
|
||||
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")
|
||||
.insert({
|
||||
name,
|
||||
nickname,
|
||||
bio,
|
||||
public_key: pub,
|
||||
private_key: priv,
|
||||
deleted: false,
|
||||
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];
|
||||
});
|
||||
|
|
Loading…
Reference in New Issue