import { Router, Request, Response } from "express"; import uuidv4 from "uuid/v4"; import {createActivity, getActor, signAndSend} from "./federate"; import { Activity, FollowActivity, AcceptActivity, UndoActivity, CreateActivity, NoteObject, DeleteActivity } from "./activity"; import { Database } from "sqlite3"; import { URL } from "url"; import sanitizeHtml from "sanitize-html"; import Note from "../entity/Note"; import { getConnection } from "typeorm"; import Actor from "../entity/Actor"; import Article from "../entity/Article"; const domain = process.env.DOMAIN; export default function inbox(router: Router) { router.post("/ap/inbox", handleInbox); router.post("/inbox", handleInbox); } function handleInbox(req: Request, res: Response) { console.log(req.body); const activity = req.body as Activity; if (activity.type === "Follow") { handleFollow(activity, req, res); } else if (activity.type === "Create") { handleCreate(activity, req, res); } else if (activity.type === "Undo") { handleUndo(activity, req, res); } else if (activity.type === "Delete") { handleDelete(activity, req, res); } else { res.end(); // TODO: handle this better } } async function handleFollow(activity: Activity, req: Request, res: Response) { if (typeof req.body.object !== "string") { res.end(); // TODO: handle this better return; } const follow = activity as FollowActivity; if (follow.object !== `https://${domain}/ap/actor`) { res.end(); return; } const actor = await getActor(follow.actor, true); // always force re-fetch the actor on follow if (!actor) { // if the actor ceases existing between the time the Follow is sent and when receive it, ignore it and end the request res.end(); return; } const acceptObject = { "@context": "https://www.w3.org/ns/activitystreams", "id": `https://${domain}/ap/${uuidv4()}`, "type": "Accept", "actor": `https://${domain}/ap/actor`, "object": follow } as AcceptActivity; try { await signAndSend(acceptObject, actor.inbox); // prefer shared server inbox const serverInbox = actor.endpoints && actor.endpoints.sharedInbox ? actor.endpoints.sharedInbox : actor.inbox; await getConnection().createQueryBuilder() .update(Actor) .set({ isFollower: true, inbox: serverInbox }) .where("id = :id", {id: actor.id}) .execute(); res.end(); } catch (err) { res.status(500).json({ message: "Encountered error handling follow.", error: err }).end(); } } async function handleCreate(activity: Activity, req: Request, res: Response) { const create = activity as CreateActivity; if (create.object.type == "Note") { handleCreateNote(create, req, res); } else { res.end(); } } function sanitizeStatus(content: string): string { return sanitizeHtml(content, { allowedTags: ["a", "span", "p", "br", "b", "strong", "i", "em", "s", "del", "u", "code", "pre", "ul", "ol", "li", "blockquote", "img"], allowedAttributes: { "a": ["href", "data-user"], "img": ["src"] }, transformTags: { "a": sanitizeHtml.simpleTransform("a", { rel: "noopener", target: "_blank" }) } }); } async function handleCreateNote(create: CreateActivity, req: Request, res: Response) { const noteObject = create.object as NoteObject; const actor = await getActor(noteObject.attributedTo); // get and cache the actor if it's not already cached const asPublic = "https://www.w3.org/ns/activitystreams#Public"; const article = await getConnection().getRepository(Article).findOne({ where: { conversation: noteObject.conversation } }); if (!article) { console.log(`Ignoring message not in response to an article: ${noteObject.id}`); res.end(); } else if (!process.env.ACCEPT_NON_PUBLIC_NOTES && !noteObject.to.includes(asPublic) && !noteObject.cc.includes(asPublic)) { console.log(`Ignoring non-public post: ${noteObject.id}`); try { const note: NoteObject = { type: "Note", id: `https://${domain}/ap/object/${uuidv4()}`, to: [actor.id], cc: [], directMessage: true, attributedTo: `https://${domain}/ap/actor`, content: `@${actor.preferredUsername} Non-public posts are not accepted. To respond to a blog post, use either Public or Unlisted.`, published: new Date().toISOString(), inReplyTo: noteObject.id, conversation: noteObject.conversation || `https://${domain}/ap/conversation/${uuidv4()}` }; const responseCreate = createActivity(note); signAndSend(responseCreate, actor.inbox); } catch (err) { console.error(`Couldn't send non-public reply Note to ${noteObject.id}`, err); } res.end(); } else { const sanitizedContent = sanitizeStatus(noteObject.content); const note = new Note(); note.id = noteObject.id; note.actor = await getConnection().getRepository(Actor).findOne(actor.id); note.content = sanitizedContent; note.attributedTo = noteObject.attributedTo; note.inReplyTo = noteObject.inReplyTo; note.conversation = noteObject.conversation; note.published = noteObject.published; try { await getConnection().getRepository(Note).save(note); res.end(); } catch (err) { console.error(`Encountered error storing reply ${noteObject.id}`, err); res.status(500).end(); } } } async function handleDelete(activity: Activity, req: Request, res: Response) { const deleteActivity = activity as DeleteActivity; try { await getConnection().getRepository(Note).createQueryBuilder() .delete() .from(Note, "note") .where("note.id = :id", { id: deleteActivity.object }) .andWhere("note.\"actorId\" = :actor", { actor: deleteActivity.actor }) .execute(); } catch (err) { console.error(`Encountered error deleting ${deleteActivity.object}`, err); } res.end(); } function handleUndo(activity: Activity, req: Request, res: Response) { const undo = activity as UndoActivity; if (undo.object.type === "Follow") { handleUndoFollow(undo, req, res); } else { res.end(); } } async function handleUndoFollow(undo: UndoActivity, req: Request, res: Response) { const follow = undo.object as FollowActivity; if (follow.object !== `https://${domain}/ap/actor` || undo.actor !== follow.actor) { res.end(); return; } try { await getConnection().createQueryBuilder() .update(Actor) .set({ isFollower: false }) .where("id = :id", { id: follow.actor }) .execute(); } catch (err) { console.error(`Error handling unfollow from ${follow.actor}`, err); } res.end(); }