import { promises as fs } from "fs"; import crypto from "crypto"; import uuidv4 from "uuid/v4"; import request from "request"; import { Activity, ArticleObject, ActorObject, CreateActivity, NoteObject } from "./activity"; import { URL } from "url"; import { getConnection } from "typeorm"; import Actor from "../entity/Actor"; import Article from "../entity/Article"; import Note from "../entity/Note"; const domain = process.env.DOMAIN; export function createActivity(object: ArticleObject | NoteObject): CreateActivity { 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": object.to, "cc": object.cc, "object": object }; return createObject; } export async function getActor(url: string, forceUpdate: boolean = false): Promise { if (!forceUpdate) { try { const cached = await getCachedActor(url); if (cached) return cached; } catch (err) { console.error(`Encountered error getting cached actor ${url}`, err); } } const remote = await fetchActor(url); if (remote) { cacheActor(remote); } else { getConnection().createQueryBuilder() .delete() .from(Actor, "actor") .where("actor.id = :id", { id: url }) .execute(); getConnection().createQueryBuilder() .delete() .from(Note, "note") .where("note.\"actorId\" = :actor", { actor: url }) .execute(); } return remote; } export async function getCachedActor(url: string): Promise { const result = await getConnection().manager.findByIds(Actor, [url]); if (result.length > 0) { const actor = result[0]; return actor.actorObject; } else { return null; } } async function cacheActor(actorObject: ActorObject) { function getIconUrl(icon: string | object): string { return icon instanceof String ? icon : (icon as any).url; } const iconURL = !actorObject.icon ? null : actorObject.icon instanceof Array ? getIconUrl(actorObject.icon[0]) : getIconUrl(actorObject.icon); const actor = new Actor(); actor.id = actorObject.id; actor.actorObject = actorObject; actor.displayName = actorObject.name; actor.inbox = actorObject.inbox; actor.iconURL = iconURL; if (actorObject.publicKey && actorObject.publicKey.publicKeyPem) { actor.publicKeyPem = actorObject.publicKey.publicKeyPem; } actor.isFollower = false; await getConnection().manager.save(actor); } async function fetchActor(url?: string): Promise { if (!url) { return Promise.resolve(null); } return new Promise((resolve, reject) => { request({ url, headers: { "Accept": "application/activity+json" }, method: "GET", json: true }, (err, res) => { if (err) reject(err); else if (!res.body || res.body.error || Object.keys(res.body).length === 0) resolve(null); else resolve(res.body as ActorObject); }); }); } 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 bodyDigest = crypto.createHash("sha256").update(JSON.stringify(activity)).digest("base64"); const stringToSign = `(request-target): post ${inboxFragment}\nhost: ${targetDomain}\ndate: ${date.toUTCString()}\ndigest: SHA-256=${bodyDigest}`; const signer = crypto.createSign("sha256"); 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 digest",signature="${signature}"`; console.log("Sending:", activity); console.log("stringToSign:", stringToSign); console.log("Signature: " + header); request({ url: inbox, headers: { "Host": targetDomain, "Date": date.toUTCString(), "Digest": `SHA-256=${bodyDigest}`, "Signature": header, "Accept": "application/activity+json, application/json" }, method: "POST", json: true, body: activity, }, (err, res) => { console.log("Sent message to inbox at", targetDomain); if (err) console.log("Error:", err, res); if (res) { console.log("Response status code", res.statusCode); console.log(res.body); } }); } async function sendToFollowers(activity: CreateActivity) { const followers = await getConnection().getRepository(Actor).find({where: {isFollower: true}}); const inboxes = followers.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 async function federate(toFederate: [string, ArticleObject][]) { for (const [id, article] of toFederate) { sendToFollowers(createActivity(article)); await getConnection().manager.update(Article, id, { hasFederated: true }); } }