tons of additions.

This commit is contained in:
Moon Man 2023-12-27 09:18:26 -05:00
parent a3a569013e
commit 0c1d093c40
11 changed files with 654 additions and 54 deletions

View File

@ -41,6 +41,9 @@ export const up = async function (knex) {
table.index(["collection_types_id", "articles_id"]);
table.index(["collection_types_id", "users_id"]);
table.unique(["collection_types_id", "articles_id", "value"]);
table.unique(["collection_types_id", "users_id", "value"]);
})
.createTable("outboxes", (table) => {
table.increments("id");

5
module.d.ts vendored
View File

@ -3,10 +3,15 @@ declare module "activitypub-express" {
export default ActivitypubExpress;
}
// Only enough here for what I need.
declare module "activitypub-http-signatures" {
export class Sha256Signer {
constructor(options: { publicKeyId: string, privateKey: string });
sign: (options: { url: string, method: string, headers: any[] }) => string;
};
export const parse: (params: { url: string, method: string, headers: Record<string, string> }) => {
verify: (publicKey: string) => boolean;
};
}

View File

@ -3,16 +3,21 @@ import { Article } from "./article.js";
import { User, getByActor } from "./user.js";
import { fillRoute } from "./router.js";
import { streamToString, hashDigest } from "./util.js";
import { signedFetch } from "./net.js";
import { signedFetch, SignedInit, getActor} from "./net.js";
import { getById as getUserById, getKeyId } from "./user.js";
import { parse as parseSignedRequest } from "activitypub-http-signatures";
import type { Request, Response } from "express";
import { addFollower } from "./follower.js";
export const CONTEXT = "https://www.w3.org/ns/activitystreams";
export const PUBLIC = CONTEXT + "#Public";
export const TYPE = "application/activity+json";
export const handleInboxPost = async (req: Request) => {
export const handleInboxPost = async (req: Request, res: Response) => {
try {
if (!req.body) {
console.warn("no body");
res.status(403).end();
return;
}
@ -23,6 +28,7 @@ export const handleInboxPost = async (req: Request) => {
}
catch (e) {
console.warn("body json parse failed");
res.status(403).end();
return;
}
@ -30,10 +36,53 @@ export const handleInboxPost = async (req: Request) => {
const actor = await getByActor(activity.object);
if (actor) {
const follower = activity.actor;
const followerUrl: string = activity.actor;
const signer = await getUserById(1);
if (!signer) {
res.status(500).end();
return;
}
const init: SignedInit = {
keyId: getKeyId(signer.nickname),
privateKey: signer.private_key
};
const follower = await getActor(followerUrl, init);
if (!follower || !follower.publicKey?.publicKeyPem) {
console.warn("No public key for follow requester:", followerUrl);
res.status(403).send("no public key found for follow requester");
return;
}
// OK validate request signature
const signature = parseSignedRequest({
url: req.originalUrl,
method: "POST",
headers: req.headers as Record<string, string>
});
const validSignature = signature.verify(follower.publicKey.publicKeyPem);
if (!validSignature) {
console.warn("Signature validation failed.");
res.status(403).send("signature validation failed");
return;
}
await addFollower(actor.id, follower.id, follower.preferredUsername, follower.name, follower.inbox, follower.endpoints?.sharedInbox);
console.log("Done handling inbox POST follow request");
res.status(200);
return;
}
else {
console.warn("follow attempt on unknown user");
res.status(403).end();
return;
}
}
else if (activity.type === "Undo") {
@ -41,9 +90,13 @@ export const handleInboxPost = async (req: Request) => {
}
else {
console.warn("unsupported activity type");
res.status(200).end();
}
}
finally { }
catch(e) {
console.warn("Failed to handle inbox POST request.", e);
res.status(500).end();
}
};
export const deleteArticleActivity = (article: Article, user: User) => {
@ -104,6 +157,32 @@ export const createArticleActivity = (article: Article, user: User) => {
return activity;
};
export const createArticleObject = (article: Article, nickname: string) => {
const actor = fillRoute("actor", nickname);
const context = fillRoute("context", article.id);
const objectId = fillRoute("object", article.id);
const content = readFileSync(article.file as string, "utf-8");
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 followers = fillRoute("followers", nickname);
const obj: Record<string, any> = {
id: objectId,
actor,
attributedTo: actor,
type: "Article",
context,
content,
to: [PUBLIC],
cc: [followers],
url: canonicalUrl,
mediaType: "text/markdown",
published
}
return obj;
};
export const sendAll = async (keyId: string, privateKey: string, activity: Record<string, any> | string, inboxes: string[]) => {
activity = typeof activity === "string"
? activity
@ -124,13 +203,6 @@ export const sendAll = async (keyId: string, privateKey: string, activity: Recor
return !!!errors.length;
};
export const orderedCollection = (id: string, orderedItems: (string | Record<string, any>)[]) => ({
type: "OrderedCollectionPage",
context: CONTEXT,
id,
orderedItems
});
export const userToPerson = (user: User) => {
const id = fillRoute("actor", user.nickname);

308
src/activitypub_types.ts Normal file
View File

@ -0,0 +1,308 @@
// Shamelessly lifted from: https://gitlab.com/soapbox-pub/ditto/-/blob/main/src/schemas/activitypub.ts
import { z } from "zod";
const apId = z.string().url();
const recipients = z.array(z.string()).catch([]);
const published = () => z.string().datetime().catch(new Date().toISOString());
/** Validates individual items in an array, dropping any that aren"t valid. */
function filteredArray<T extends z.ZodTypeAny>(schema: T) {
return z.any().array()
.transform((arr) => (
arr.map((item) => {
const parsed = schema.safeParse(item);
return parsed.success ? parsed.data : undefined;
}).filter((item): item is z.infer<T> => Boolean(item))
));
}
const imageSchema = z.object({
type: z.literal("Image").catch("Image"),
url: z.string().url(),
});
const attachmentSchema = z.object({
type: z.literal("Document").catch("Document"),
mediaType: z.string().optional().catch(undefined),
url: z.string().url(),
});
const mentionSchema = z.object({
type: z.literal("Mention"),
href: z.string().url(),
name: z.string().optional().catch(undefined),
});
const hashtagSchema = z.object({
type: z.literal("Hashtag"),
href: z.string().url(),
name: z.string(),
});
const emojiSchema = z.object({
type: z.literal("Emoji"),
icon: imageSchema,
name: z.string(),
});
const tagSchema = z.discriminatedUnion("type", [
mentionSchema,
hashtagSchema,
emojiSchema,
]);
const propertyValueSchema = z.object({
type: z.literal("PropertyValue"),
name: z.string(),
value: z.string(),
verified_at: z.string().nullish(),
});
/** https://codeberg.org/fediverse/fep/src/branch/main/feps/fep-fffd.md */
const proxySchema = z.object({
protocol: z.string().url(),
proxied: z.string(),
authoritative: z.boolean().optional().catch(undefined),
});
const personSchema = z.object({
type: z.literal("Person"),
id: apId,
icon: imageSchema.optional().catch(undefined),
image: imageSchema.optional().catch(undefined),
name: z.string().catch(""),
preferredUsername: z.string(),
inbox: apId,
followers: apId.optional().catch(undefined),
following: apId.optional().catch(undefined),
outbox: apId.optional().catch(undefined),
summary: z.string().catch(""),
attachment: filteredArray(propertyValueSchema).catch([]),
tag: filteredArray(emojiSchema).catch([]),
endpoints: z.object({
sharedInbox: apId.optional(),
}).optional().catch({}),
publicKey: z.object({
id: apId,
owner: apId,
publicKeyPem: z.string(),
}).optional().catch(undefined),
proxyOf: z.array(proxySchema).optional().catch(undefined),
});
const applicationSchema = personSchema.merge(z.object({ type: z.literal("Application") }));
const groupSchema = personSchema.merge(z.object({ type: z.literal("Group") }));
const organizationSchema = personSchema.merge(z.object({ type: z.literal("Organization") }));
const serviceSchema = personSchema.merge(z.object({ type: z.literal("Service") }));
const actorSchema = z.discriminatedUnion("type", [
personSchema,
applicationSchema,
groupSchema,
organizationSchema,
serviceSchema,
]);
const noteSchema = z.object({
type: z.literal("Note"),
id: apId,
to: recipients,
cc: recipients,
content: z.string(),
attachment: z.array(attachmentSchema).optional().catch(undefined),
tag: filteredArray(tagSchema).catch([]),
inReplyTo: apId.optional().catch(undefined),
attributedTo: apId,
published: published(),
sensitive: z.boolean().optional().catch(undefined),
summary: z.string().nullish().catch(undefined),
quoteUrl: apId.optional().catch(undefined),
source: z.object({
content: z.string(),
mediaType: z.literal("text/markdown"),
}).optional().catch(undefined),
proxyOf: z.array(proxySchema).optional().catch(undefined),
});
const flexibleNoteSchema = noteSchema.extend({
quoteURL: apId.optional().catch(undefined),
quoteUri: apId.optional().catch(undefined),
_misskey_quote: apId.optional().catch(undefined),
}).transform((note) => {
const { quoteUrl, quoteUri, quoteURL, _misskey_quote, ...rest } = note;
return {
quoteUrl: quoteUrl || quoteUri || quoteURL || _misskey_quote,
...rest,
};
});
// https://github.com/colinhacks/zod/discussions/2100#discussioncomment-5109781
const objectSchema = z.union([
flexibleNoteSchema,
personSchema,
applicationSchema,
groupSchema,
organizationSchema,
serviceSchema,
]).pipe(
z.discriminatedUnion("type", [
noteSchema,
personSchema,
applicationSchema,
groupSchema,
organizationSchema,
serviceSchema,
]),
);
const createNoteSchema = z.object({
type: z.literal("Create"),
id: apId,
to: recipients,
cc: recipients,
actor: apId,
object: noteSchema,
published: published(),
proxyOf: z.array(proxySchema).optional().catch(undefined),
});
const announceNoteSchema = z.object({
type: z.literal("Announce"),
id: apId,
to: recipients,
cc: recipients,
actor: apId,
object: apId.or(noteSchema),
published: published(),
proxyOf: z.array(proxySchema).optional().catch(undefined),
});
const followSchema = z.object({
type: z.literal("Follow"),
id: apId,
to: recipients,
cc: recipients,
actor: apId,
object: apId,
proxyOf: z.array(proxySchema).optional().catch(undefined),
});
const acceptSchema = z.object({
type: z.literal("Accept"),
id: apId,
actor: apId,
to: recipients,
cc: recipients,
object: apId.or(followSchema),
});
const likeSchema = z.object({
type: z.literal("Like"),
id: apId,
actor: apId,
object: apId,
to: recipients,
cc: recipients,
proxyOf: z.array(proxySchema).optional().catch(undefined),
});
const emojiReactSchema = z.object({
type: z.literal("EmojiReact"),
id: apId,
actor: apId,
object: apId,
content: z.string().refine((v) => /\p{Extended_Pictographic}/u.test(v)),
to: recipients,
cc: recipients,
proxyOf: z.array(proxySchema).optional().catch(undefined),
});
const deleteSchema = z.object({
type: z.literal("Delete"),
id: apId,
actor: apId,
object: apId,
to: recipients,
cc: recipients,
proxyOf: z.array(proxySchema).optional().catch(undefined),
});
const updateActorSchema = z.object({
type: z.literal("Update"),
id: apId,
actor: apId,
to: recipients,
cc: recipients,
object: actorSchema,
proxyOf: z.array(proxySchema).optional().catch(undefined),
});
const activitySchema = z.discriminatedUnion("type", [
followSchema,
acceptSchema,
createNoteSchema,
announceNoteSchema,
updateActorSchema,
likeSchema,
emojiReactSchema,
deleteSchema,
]).refine((activity) => {
const ids: string[] = [activity.id];
if (activity.type === "Create") {
ids.push(
activity.object.id,
activity.object.attributedTo,
);
}
if (activity.type === "Update") {
ids.push(activity.object.id);
}
const { origin: actorOrigin } = new URL(activity.actor);
// Object containment
return ids.every((id) => {
const { origin: idOrigin } = new URL(id);
return idOrigin === actorOrigin;
});
});
type Activity = z.infer<typeof activitySchema>;
type CreateNote = z.infer<typeof createNoteSchema>;
type Announce = z.infer<typeof announceNoteSchema>;
type Update = z.infer<typeof updateActorSchema>;
type Object = z.infer<typeof objectSchema>;
type Follow = z.infer<typeof followSchema>;
type Accept = z.infer<typeof acceptSchema>;
type Actor = z.infer<typeof actorSchema>;
type Note = z.infer<typeof noteSchema>;
type Mention = z.infer<typeof mentionSchema>;
type Hashtag = z.infer<typeof hashtagSchema>;
type Emoji = z.infer<typeof emojiSchema>;
type Like = z.infer<typeof likeSchema>;
type EmojiReact = z.infer<typeof emojiReactSchema>;
type Delete = z.infer<typeof deleteSchema>;
type Proxy = z.infer<typeof proxySchema>;
export { acceptSchema, activitySchema, actorSchema, emojiSchema, followSchema, imageSchema, noteSchema, objectSchema };
export type {
Accept,
Activity,
Actor,
Announce,
CreateNote,
Delete,
Emoji,
EmojiReact,
Follow,
Hashtag,
Like,
Mention,
Note,
Object,
Proxy,
Update,
};

View File

@ -1,3 +1,4 @@
import { CONTEXT } from "./activity.js";
import db from "./db.js";
import { z } from "zod";
@ -11,19 +12,44 @@ export const zCollection = z.object({
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 orderedCollection = (id: string, orderedItems: (string | Record<string, any>)[], paged = false) => {
const collection: Record<string, any> = {
type: "OrderedCollection",
"@context": CONTEXT,
id,
totalItems: orderedItems.length
};
if (paged) {
collection.first = {
id: `${id}?page=1`,
next: `${id}?page=2`,
orderedItems,
partOf: id,
totalItems: orderedItems.length,
type: "OrderedCollectionPage"
};
}
else {
collection.orderedItems = orderedItems;
}
return collection;
};
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")
;
export const orderedCollectionPage = (collectionId: string, orderedItems: (string | Record<string, any>)[], page: number, pageLength: number, totalItems: number) => {
const collection: Record<string, any> = {
id: `${collectionId}?page=${page}`,
type: "OrderedCollectionPage",
"@context": CONTEXT,
partOf: collectionId,
totalItems,
orderedItems
};
const hasNext = totalItems > (((page - 1) * pageLength) + orderedItems.length);
if (hasNext) collection.next = `${collectionId}?page=${page + 1}`;
return collection;
};

62
src/follower.ts Normal file
View File

@ -0,0 +1,62 @@
import { TYPE } from "./activity.js";
import { CollectionEntry, orderedCollection } from "./collection.js";
import db from "./db.js";
import { fillRoute } from "./router.js";
import { get as getUserByNickname } from "./user.js";
import { Request, Response } from "express";
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")
;
export const addFollower = async (followeeUserId: number, actor: string, nickname: string, name: string, inbox?: string, sharedInbox?: string) => {
await db("remote_users")
.insert({
actor,
nickname,
name,
inbox,
shared_inbox: sharedInbox
})
.onConflict().ignore();
;
await db("collections")
.insert({
collection_types_id: 0,
users_id: followeeUserId,
value: actor,
created_at: new Date()
})
.onConflict().ignore()
;
console.log("Follower added");
};
export const handleFollowerGet = async (req: Request<{ actor: string}>, res: Response) => {
const user = await getUserByNickname(req.params.actor);
if (user) {
const followers = await getFollowers(user.id);
const payload = orderedCollection(fillRoute("followers", user.nickname), followers);
res.set("Content-Type", TYPE);
res.send(JSON.stringify(payload, null, 4));
}
else {
res.status(403).end();
}
};

View File

@ -1,10 +1,12 @@
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 { getById as getUserById, getNickname, get as getUserByNickname, getId as getUserId, User } from "./user.js";
import { Routes } from "./router.js";
import { getBySlug as getArticleBySlug } from "./article.js";
import { userToPerson, TYPE as ACTIVITYPUB_TYPE } from "./activity.js";
import { getBySlug as getArticleBySlug, getById } from "./article.js";
import { userToPerson, TYPE as ACTIVITYPUB_TYPE, handleInboxPost, createArticleObject, CONTEXT, createArticleActivity } from "./activity.js";
import { handleFollowerGet } from "./follower.js";
import { handleWebfingerGet } from "./net.js";
const port = parseInt(process.env.port || "8080");
const app = express();
@ -25,18 +27,51 @@ app.use(
SITE,
);
app.route(Routes.inbox).get(SITE.net.inbox.get).post(SITE.net.inbox.post);
app.get(Routes.followers, SITE.net.followers.get);
app.get(Routes.following, SITE.net.following.get);
app.get(Routes.liked, SITE.net.liked.get);
app.get(Routes.object, SITE.net.object.get);
app.get(Routes.activity, SITE.net.activityStream.get);
app.get(Routes.shares, SITE.net.shares.get);
app.get(Routes.likes, SITE.net.likes.get);
app.get("/.well-known/webfinger", SITE.net.webfinger.get);
app.get("/.well-known/nodeinfo", SITE.net.nodeInfoLocation.get);
app.get("/nodeinfo/:version", SITE.net.nodeInfo.get);
app.post("/proxy", SITE.net.proxy.post);
app.post(Routes.inbox, handleInboxPost);
app.get(Routes.followers, handleFollowerGet);
app.get(Routes.object, async (req, res) => {
const article = await getById(parseInt(req.params.id));
if (article) {
const nickname = await getNickname(article.users_id) as string;
const obj = createArticleObject(article, nickname);
obj["@context"] = CONTEXT;
res.append("Content-Type", ACTIVITYPUB_TYPE);
res.send(JSON.stringify(obj, null, 4));
}
else {
res.status(404).end();
}
});
app.get(Routes.activity, async (req, res) => {
let id = parseInt(req.params.id);
if (id >= 1_000_000_000) {
// it's a delete. TODO: implement.
res.status(501).end();
}
else {
const article = await getById(id);
if (article) {
const user = await getUserById(article.users_id) as User;
const activity = createArticleActivity(article, user);
res.append("Content-Type", ACTIVITYPUB_TYPE);
res.send(JSON.stringify(activity, null, 4));
}
else {
res.status(404).end();
}
}
});
app.get("/.well-known/webfinger", handleWebfingerGet);
// app.get("/.well-known/nodeinfo", SITE.net.nodeInfoLocation.get);
// app.get("/nodeinfo/:version", SITE.net.nodeInfo.get);
app.get(Routes.outbox, async (req, res) => {
const userId = await getUserId(req.params.actor);

View File

@ -1,6 +1,12 @@
import { CONTEXT } from "./activity.js";
import { hashDigest } from "./util.js";
import { z } from "zod";
import { CONTEXT, TYPE } from "./activity.js";
import { hashDigest, streamToString } from "./util.js";
import { Sha256Signer } from "activitypub-http-signatures";
import { actorSchema } from "./activitypub_types.js";
import type { Actor } from "./activitypub_types.js";
import type { Request, Response } from "express";
import { fillRoute } from "./router.js";
import { get as getUserByNickname } from "./user.js";
export const flattenHeaders = (headers: Record<string, string>) =>
Object.entries(headers).map(([key, value]) => `${key}: ${value}`);
@ -11,6 +17,7 @@ export interface SignedInit {
digest?: string;
};
// TODO: Handle redirects.
export const signedFetch = async (url: string, init: RequestInit, signedInit: SignedInit) => {
const signedHeaders: HeadersInit = [
["Date", new Date().toUTCString()],
@ -59,3 +66,84 @@ export const signedFetch = async (url: string, init: RequestInit, signedInit: Si
return fetch(url, init);
};
export const getActor = async (actorUrl: string, signedInit: SignedInit): Promise<Actor | null> => {
const init: RequestInit = {
method: "GET",
headers: [["Accept", TYPE]]
};
try {
const res = await signedFetch(actorUrl, init, signedInit);
if (res.status == 200 && res.body) {
const body = await streamToString(res.body);
const person = JSON.parse(body);
// this will throw if it's not valid
actorSchema.parse(person);
return person;
}
else return null;
}
catch (e) {
console.warn("Failed to get remote actor:", e);
return null;
}
};
export const getPublicKey = async (actorUrl: string, signedInit: SignedInit): Promise<string | null> => {
const actor = await getActor(actorUrl, signedInit);
if (actor?.type === "Person") {
return actor.publicKey?.publicKeyPem || null;
}
else return null;
};
const domainStripper = new RegExp("/@" + process.env.blog_url?.replace(".", "\\.") + "$/i");
export const handleWebfingerGet = async (req: Request, res: Response) => {
let nickname = req.query.resource;
if (nickname && typeof nickname === "string") {
nickname = nickname.replace(/^acct:/, "").replace(domainStripper, "");
const user = await getUserByNickname(nickname);
if (user) {
const actor = fillRoute("actor", nickname);
const payload = {
"subject": `acct:${nickname}@${process.env.blog_host}`,
"aliases": [
actor
],
"links": [
{
"rel": "http://webfinger.net/rel/profile-page",
"type": "text/html",
"href": actor
},
{
"rel": "self",
"type": "application/activity+json",
"href": actor
}
]
};
res.set("Content-Type", `application/jrd+json; charset=utf-8`);
res.send(JSON.stringify(payload, null, 4));
return;
}
else {
res.status(404).end();
return;
}
}
else {
res.status(404).end();
return;
}
};

View File

@ -3,7 +3,8 @@ import { z } from "zod";
import { get as getUserByNickname } from "./user.js";
import { fillRoute } from "./router.js";
import { getByUserId as getArticlesByUserId, getById as getArticleById, Article } from "./article.js";
import { createArticleActivity, deleteArticleActivity, orderedCollection } from "./activity.js";
import { createArticleActivity, deleteArticleActivity } from "./activity.js";
import { orderedCollection } from "./collection.js";
export const zRawOutboxRecord = z.object({

View File

@ -1,19 +1,11 @@
export const Routes = {
actor: "/author/:actor",
object: "/o/:id",
activity: "/a/:id",
inbox: "/author/:actor/inbox",
outbox: "/author/:actor/outbox",
followers: "/author/:actor/followers",
following: "/author/:actor/following",
liked: "/author/:actor/liked",
collections: "/author/:actor/c/:id",
blocked: "/author/:actor/blocked",
rejections: "/author/:actor/rejections",
rejected: "/author/:actor/rejected",
shares: "/a/:id/shares",
likes: "/a/:id/likes",
context: "/c/:id",
object: "/o/:id",
activity: "/a/:id",
} as const;
export type Route = keyof typeof Routes;

View File

@ -1,7 +1,7 @@
import { generateKeyPair } from "node:crypto";
import { z } from "zod";
import db from "./db.js";
import { getFollowers } from "./collection.js";
import { getFollowers } from "./follower.js";
import { fillRoute } from "./router.js";
export const nicknameRegex = /^[a-zA-Z0-9_]+$/;
@ -26,6 +26,12 @@ export const get = async (nickname: string) =>
.first()
;
export const getById = async (id: number) =>
db<User>("users")
.where("id", id)
.first()
;
const EXTRACT_NICKNAME = new RegExp("/" + fillRoute("actor", "(.+)").replaceAll("/", "\\/") + "/");
export const getByActor = async (actor: string) => {
@ -98,3 +104,5 @@ export const getFollowerInboxes = async (userId: number): Promise<string[]> =>
return [...inboxes];
});
export const getKeyId = (nickname: string) => fillRoute("actor", nickname) + "#main-key";