From 6b4ea192e1c2040d9de73ca51d6b6a333b6c7986 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sun, 24 Feb 2019 10:21:14 -0500 Subject: [PATCH] Cache actors --- lib/activitypub/activity.ts | 5 ++ lib/activitypub/actor.ts | 2 +- lib/activitypub/federate.ts | 57 +++++++++++++++++++- lib/activitypub/inbox.ts | 10 ++-- lib/activitypub/middleware/http-signature.ts | 16 ++++-- lib/index.ts | 1 + 6 files changed, 80 insertions(+), 11 deletions(-) diff --git a/lib/activitypub/activity.ts b/lib/activitypub/activity.ts index dcfb31f..8f626ce 100644 --- a/lib/activitypub/activity.ts +++ b/lib/activitypub/activity.ts @@ -41,8 +41,13 @@ export interface Delete extends Activity { export interface Actor { id: string; + name: string; inbox: string; + endpoints?: { + sharedInbox?: string; + } publicKey: { publicKeyPem: string; }; + icon: string | object | (string | object)[]; } \ No newline at end of file diff --git a/lib/activitypub/actor.ts b/lib/activitypub/actor.ts index 7d193e8..cc9266f 100644 --- a/lib/activitypub/actor.ts +++ b/lib/activitypub/actor.ts @@ -12,7 +12,7 @@ export default async function actor(router: Router) { ], "type": "Person", "id": `https://${domain}/ap/actor`, - "preferredUsername": "shadowfacts", + "preferredUsername": "blog", "name": "shadowfacts' blog", "icon": { "type": "Image", diff --git a/lib/activitypub/federate.ts b/lib/activitypub/federate.ts index 5f45dc5..89be428 100644 --- a/lib/activitypub/federate.ts +++ b/lib/activitypub/federate.ts @@ -24,7 +24,59 @@ function createActivity(article: Article): Create { return createObject; } -export async function fetchActor(url: string): Promise { +export async function getActor(url: string, db: Database, forceUpdate: boolean = false): Promise { + if (forceUpdate) { + try { + const cached = await getCachedActor(url, db); + if (cached) return cached; + } catch (err) { + console.error(`Encountered error getting cached actor ${url}`, err); + } + } + const remote = await fetchActor(url); + cacheActor(remote, db); + return remote; +} + +export async function getCachedActor(url: string, db: Database): Promise { + return new Promise((resolve, reject) => { + db.get("SELECT * FROM actors WHERE id = $id", { + $id: url + }, (err, result) => { + if (err) { + reject(err); + } else { + resolve({ + id: result.id, + name: result.display_name, + inbox: result.inbox, + icon: result.icon_url, + publicKey: { + publicKeyPem: result.public_key_pem + } + } as Actor); + } + }); + }); +} + +async function cacheActor(actor: Actor, db: Database) { + function getIconUrl(icon: string | object): string { + return icon instanceof String ? icon : (icon as any).url; + } + const iconUrl: string = actor.icon instanceof Array ? getIconUrl(actor.icon[0]) : getIconUrl(actor.icon); + db.run("INSERT OR REPLACE INTO actors(id, display_name, inbox, icon_url, public_key_pem) VALUES($id, $display_name, $inbox, $icon_url, $public_key_pem)", { + $id: actor.id, + $display_name: actor.name, + $inbox: actor.inbox, + $icon_url: iconUrl, + $public_key_pem: actor.publicKey.publicKeyPem + }, (err) => { + if (err) console.error(`Encountered error caching actor ${actor.id}`, err); + }); +} + +async function fetchActor(url: string): Promise { return new Promise((resolve, reject) => { request({ url, @@ -35,7 +87,7 @@ export async function fetchActor(url: string): Promise { json: true }, (err, res) => { if (err) reject(err); - else resolve(res.body); + else resolve(res.body as Actor); }); }); } @@ -74,6 +126,7 @@ export async function signAndSend(activity: Activity, inbox: string) { } async function sendToFollowers(activity: Create, db: Database) { + // TODO: only send to unique inboxes db.each("SELECT inbox FROM followers", (err, result) => { if (err) { console.log("Error getting followers: ", err); diff --git a/lib/activitypub/inbox.ts b/lib/activitypub/inbox.ts index 2921a17..6a9a6c3 100644 --- a/lib/activitypub/inbox.ts +++ b/lib/activitypub/inbox.ts @@ -1,6 +1,6 @@ import { Router, Request, Response } from "express"; import uuidv4 from "uuid/v4"; -import { fetchActor, signAndSend } from "./federate"; +import { getActor, signAndSend } from "./federate"; import { Activity, Follow, Accept, Undo, Create, Note, Delete } from "./activity"; import { Database } from "sqlite3"; import { URL } from "url"; @@ -39,7 +39,8 @@ async function handleFollow(activity: Activity, req: Request, res: Response) { res.end(); return; } - const actor = await fetchActor(follow.actor); + const db = req.app.get("db") as Database; + const actor = await getActor(follow.actor, db, true); // always force re-fetch the actor on follow const acceptObject = { "@context": [ "https://www.w3.org/ns/activitystreams", @@ -51,8 +52,8 @@ async function handleFollow(activity: Activity, req: Request, res: Response) { "object": follow }; signAndSend(acceptObject, actor.inbox); - const db = req.app.get("db") as Database; - const serverInbox = new URL("/inbox", actor.inbox).toString(); + // prefer shared server inbox + const serverInbox = actor.endpoints && actor.endpoints.sharedInbox ? actor.endpoints.sharedInbox : actor.inbox; db.run("INSERT OR IGNORE INTO followers(id, inbox) VALUES($id, $inbox)", { $id: actor.id, $inbox: serverInbox @@ -87,6 +88,7 @@ function sanitizeStatus(content: string): string { async function handleCreateNote(create: Create, req: Request, res: Response) { const note = create.object as Note; const db = req.app.get("db") as Database; + getActor(note.attributedTo, db); // get and cache the actor if it's not already cached const sanitizedContent = sanitizeStatus(note.content); db.run("INSERT OR IGNORE INTO notes(id, content, attributed_to, in_reply_to, conversation, published) VALUES($id, $content, $attributed_to, $in_reply_to, $conversation, $published)", { $id: note.id, diff --git a/lib/activitypub/middleware/http-signature.ts b/lib/activitypub/middleware/http-signature.ts index bb534be..77020da 100644 --- a/lib/activitypub/middleware/http-signature.ts +++ b/lib/activitypub/middleware/http-signature.ts @@ -1,19 +1,27 @@ import crypto, { createVerify } from "crypto"; import { Request, Response, NextFunction } from "express"; -import { fetchActor } from "../federate"; +import { getActor } from "../federate"; import { IncomingHttpHeaders } from "http"; +import { Database } from "sqlite3"; export = async (req: Request, res: Response, next: NextFunction) => { if (req.method !== "POST") { next(); return; } - const actor = await fetchActor(req.body.actor as string); + const db = req.app.get("db") as Database; + const actor = await getActor(req.body.actor as string, db); if (validate(req, actor.publicKey.publicKeyPem)) { next(); } else { - console.log(`Could not validate HTTP signature for ${req.body.actor}`); - res.status(401).end("Could not validate HTTP signature"); + // if the first check fails, force re-fetch the actor and try again + const actor = await getActor(req.body.actor as string, db, true); + if (validate(req, actor.publicKey.publicKeyPem)) { + next(); + } else { + console.log(`Could not validate HTTP signature for ${req.body.actor}`); + res.status(401).end("Could not validate HTTP signature"); + } } }; diff --git a/lib/index.ts b/lib/index.ts index 04cd2e2..fee0fee 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -14,6 +14,7 @@ const db = new (sqlite3.verbose().Database)(process.env.DB_PATH!); db.run("CREATE TABLE IF NOT EXISTS followers (id TEXT PRIMARY KEY, inbox TEXT)"); db.run("CREATE TABLE IF NOT EXISTS articles (id TEXT PRIMARY KEY, article_doc TEXT, conversation TEXT, has_federated INT)"); db.run("CREATE TABLE IF NOT EXISTS notes (id TEXT PRIMARY KEY, content TEXT, attributed_to TEXT, in_reply_to TEXT, conversation TEXT, published TEXT)"); +db.run("CREATE TABLE IF NOT EXISTS actors (id TEXT PRIMARY KEY, display_name TEXT, inbox TEXT, icon_url TEXT, public_key_pem TEXT)") async function generate(): Promise { generators.copy();