import { promises as fs } from "fs"; import crypto from "crypto"; import uuidv4 from "uuid/v4"; import request from "request"; import { Database } from "sqlite3"; import { Activity, Article, Create, Actor, Follow, Accept } from "./activity"; import { URL } from "url"; const domain = process.env.DOMAIN; function createActivity(article: Article): Create { const uuid = uuidv4(); const createObject = { "@context": [ "https://www.w3.org/ns/activitystreams" ], "type": "Create", "id": `https://${domain}/ap/${uuid}`, "actor": `https://${domain}/ap/actor`, "to": article.to, "cc": article.cc, "object": article }; return createObject; } 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); if (remote) 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 { if (result) { resolve({ id: result.id, name: result.display_name, inbox: result.inbox, icon: result.icon_url, publicKey: { publicKeyPem: result.public_key_pem } } as Actor); } else { resolve(null); } } }); }); } 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, headers: { "Accept": "application/activity+json" }, method: "GET", json: true }, (err, res) => { if (err) reject(err); else resolve(res.body ? res.body as Actor : null); }); }); } export async function signAndSend(activity: Activity, inbox: string) { const targetDomain = new URL(inbox).hostname; const inboxFragment = inbox.replace("https://" + targetDomain, ""); const date = new Date(); const privKey = (await fs.readFile(process.env.PRIV_KEY_PEM!)).toString(); const signer = crypto.createSign("sha256"); const stringToSign = `(request-target): post ${inboxFragment}\nhost: ${targetDomain}\ndate: ${date.toUTCString()}`; signer.update(stringToSign); signer.end(); const signature = signer.sign(privKey, "base64"); const header = `keyId="https://${domain}/ap/actor#main-key",headers="(request-target) host date",signature="${signature}"`; console.log("Sending:", activity); console.log("stringToSign:", stringToSign); console.log("Signature: " + header); request({ url: inbox, headers: { "Host": targetDomain, "Date": date.toUTCString(), "Signature": header, "Accept": "application/activity+json, application/json" }, method: "POST", json: true, body: activity }, (err, res) => { console.log("Sent message to inbox at", targetDomain); console.log("Response status code", res.statusCode); console.log(res.body); if (err) console.log("Error:", err, res); }); } async function sendToFollowers(activity: Create, db: Database) { db.all("SELECT inbox FROM followers", (err, results) => { if (err) { console.log("Error getting followers: ", err); return; } const inboxes = results.map(it => "https://" + new URL(it.inbox).host + "/inbox"); // convert to a Set to deduplicate inboxes (new Set(inboxes)) .forEach(inbox => { console.log(`Federating ${activity.object.id} to ${inbox}`); signAndSend(activity, inbox); }); }); } export default function federate(toFederate: [string, Article][], db: Database) { for (const [id, article] of toFederate) { sendToFollowers(createActivity(article), db); db.run("UPDATE articles SET has_federated = 1 WHERE id = $id", { $id: id }); break; } }