activitypub

This commit is contained in:
Deva Midhun 2025-02-08 17:54:06 +00:00
parent fe42562591
commit 54a7d8f235
12 changed files with 657 additions and 46 deletions

View File

@ -1,37 +0,0 @@
{
"aliases": [
"https://coderrrrr.site/"
],
"links": [
{
"href": "https://coderrrrr.site/",
"rel": "http://webfinger.net/rel/profile-page",
"type": "text/html"
},
{
"href": "https://web.brid.gy/coderrrrr.site",
"rel": "self",
"type": "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""
},
{
"href": "https://web.brid.gy/coderrrrr.site",
"rel": "self",
"type": "application/activity+json"
},
{
"href": "https://web.brid.gy/coderrrrr.site/inbox",
"rel": "inbox",
"type": "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""
},
{
"href": "https://web.brid.gy/ap/sharedInbox",
"rel": "sharedInbox",
"type": "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""
},
{
"rel": "http://ostatus.org/schema/1.0/subscribe",
"template": "https://fed.brid.gy/web/coderrrrr.site?url={uri}"
}
],
"subject": "acct:coderrrrr.site@coderrrrr.site"
}

View File

@ -1 +0,0 @@
{"@context":["https://www.w3.org/ns/activitystreams","https://w3id.org/security/v1",{"manuallyApprovesFollowers":"as:manuallyApprovesFollowers","toot":"http://joinmastodon.org/ns#","featured":{"@id":"toot:featured","@type":"@id"},"featuredTags":{"@id":"toot:featuredTags","@type":"@id"},"alsoKnownAs":{"@id":"as:alsoKnownAs","@type":"@id"},"movedTo":{"@id":"as:movedTo","@type":"@id"},"schema":"http://schema.org#","PropertyValue":"schema:PropertyValue","value":"schema:value","discoverable":"toot:discoverable","suspended":"toot:suspended","memorial":"toot:memorial","indexable":"toot:indexable","attributionDomains":{"@id":"toot:attributionDomains","@type":"@id"},"focalPoint":{"@container":"@list","@id":"toot:focalPoint"}}],"id":"https://usr.cloud/users/coder","type":"Person","following":"https://usr.cloud/users/coder/following","followers":"https://usr.cloud/users/coder/followers","inbox":"https://usr.cloud/users/coder/inbox","outbox":"https://usr.cloud/users/coder/outbox","featured":"https://usr.cloud/users/coder/collections/featured","featuredTags":"https://usr.cloud/users/coder/collections/tags","preferredUsername":"coder","name":"Deva Midhun ☁️","summary":"\u003cp\u003eHI!!! I am Deva!!\u003c/p\u003e","url":"https://usr.cloud/@coder","manuallyApprovesFollowers":false,"discoverable":false,"indexable":false,"published":"2025-01-13T00:00:00Z","memorial":false,"attributionDomains":["coderrrrr.site","blog.coderrrrr.site"],"publicKey":{"id":"https://usr.cloud/users/coder#main-key","owner":"https://usr.cloud/users/coder","publicKeyPem":"-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA9nvxsmoBuFlxmNxi7u/1\nhdsy6cnjmoeVH4gQYOVs13mUcttVV02Q57wocqa8wdArYzDJ7ld5gI1ySwdwpj+H\nl879lq1DZOjNCTjLDr8MLQ3cy3dm2hn6s7jA8hIw/CbED3YEGB/G8y776gXTFEhg\nu+6+Pckx0QKzrzwIJPWbVRN4eprciM4GVGPwkAMPal3EakxRmHbtvnCwF62yAZhW\noudNi41G2KOS+zwHnhxP3AunsrFtEf3Djr3S0+3N1sEOtEjSJ/ck42cvhvMmlact\n+HpBlfZWyuJ4vlhsvcwWqSQlzJ2JC5CGr4ugLqAM6E8DaNJAtdv+Nyw1srjePoWs\n3wIDAQAB\n-----END PUBLIC KEY-----\n"},"tag":[],"attachment":[{"type":"PropertyValue","name":"OpenPGP","value":"openpgp4fpr:6389542B98CB868DAC73A373ED1190B780583CF6"},{"type":"PropertyValue","name":"Website","value":"\u003ca href=\"https://coderrrrr.site\" target=\"_blank\" rel=\"nofollow noopener noreferrer me\" translate=\"no\"\u003e\u003cspan class=\"invisible\"\u003ehttps://\u003c/span\u003e\u003cspan class=\"\"\u003ecoderrrrr.site\u003c/span\u003e\u003cspan class=\"invisible\"\u003e\u003c/span\u003e\u003c/a\u003e"}],"endpoints":{"sharedInbox":"https://usr.cloud/inbox"},"icon":{"type":"Image","mediaType":"image/png","url":"https://files.usr.cloud/v1/AUTH_f22cbcf5b3904990be9696691ff73fc6/files.usr.cloud/accounts/avatars/113/822/701/816/479/941/original/7d7f6088ba1ddd57.png"},"image":{"type":"Image","mediaType":"image/png","url":"https://files.usr.cloud/v1/AUTH_f22cbcf5b3904990be9696691ff73fc6/files.usr.cloud/accounts/headers/113/822/701/816/479/941/original/e0ebedf722cf0a39.png"}}

39
api/activitypub/actor.ts Normal file
View File

@ -0,0 +1,39 @@
import type { VercelRequest, VercelResponse } from '@vercel/node';
export default function (req: VercelRequest, res: VercelResponse) {
const { headers } = req;
if ("accept" in headers) {
const accept = headers["accept"];
if (accept != null && accept.split(",").indexOf("text/html") > -1) {
return res.redirect(302, "https://coderrrrr.site/").end();
}
}
res.statusCode = 200;
res.setHeader("Content-Type", `application/activity+json`);
res.json({
"@context": ["https://www.w3.org/ns/activitystreams", { "@language": "en- US" }],
"type": "Person",
"id": "https://coderrrrr.site/coder",
"outbox": "https://coderrrrr.site/outbox",
"following": "https://coderrrrr.site/following",
"followers": "https://coderrrrr.site/followers",
"inbox": "https://coderrrrr.site/inbox",
"preferredUsername": "coder",
"name": "Deva Midhun's blog",
"summary": "",
"icon": {
"type": "Image",
"mediaType": "image/png",
"url": "https://i.ibb.co/N6J5b8WS/download20250102015611.png"
},
"publicKey": {
"@context": "https://w3id.org/security/v1",
"@type": "Key",
"id": "https://coderrrrr.site/coder#main-key",
"owner": "https://coderrrrr.site/coder",
"publicKeyPem": process.env.ACTIVITYPUB_PUBLIC_KEY
}
});
}

View File

@ -0,0 +1,7 @@
import type { VercelRequest, VercelResponse } from '@vercel/node';
export default function (req: VercelRequest, res: VercelResponse) {
res.statusCode = 200;
res.setHeader("Content-Type", `application/jrd+json`);
res.end('ok');
};

View File

@ -0,0 +1,31 @@
import type { VercelRequest, VercelResponse } from '@vercel/node';
import admin from 'firebase-admin';
if (!admin.apps.length) {
admin.initializeApp({
credential: admin.credential.cert({
projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n')
})
});
}
const db = admin.firestore();
export default async function (req: VercelRequest, res: VercelResponse) {
const collection = db.collection('followers');
const actors = await collection.select("actor").get();
const output = {
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://status.kinlan.me/users/coder/following?page=1",
"type": "OrderedCollectionPage",
"totalItems": actors.docs.length,
"orderedItems": actors.docs.map(item=>item.get("actor"))
}
res.statusCode = 200;
res.setHeader("Content-Type", `application/activity+json`);
res.json(output);
};

View File

@ -0,0 +1,7 @@
import type { VercelRequest, VercelResponse } from '@vercel/node';
export default function (req: VercelRequest, res: VercelResponse) {
res.statusCode = 200;
res.setHeader("Content-Type", `application/activity+json`);
res.end('ok');
};

347
api/activitypub/inbox.ts Normal file
View File

@ -0,0 +1,347 @@
import type { VercelRequest, VercelResponse } from '@vercel/node';
import { AP } from 'activitypub-core-types';
import { CoreObject, EntityReference } from 'activitypub-core-types/lib/activitypub/index.js';
import admin from 'firebase-admin';
import type { Readable } from 'node:stream';
import { v4 as uuid } from 'uuid';
import { fetchActorInformation } from '../../lib/activitypub/utils/fetchActorInformation.js';
import { parseSignature } from '../../lib/activitypub/utils/parseSignature.js';
import { sendSignedRequest } from '../../lib/activitypub/utils/sendSignedRequest.js';
import { verifySignature } from '../../lib/activitypub/utils/verifySignature.js';
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
if (!admin.apps.length) {
admin.initializeApp({
credential: admin.credential.cert({
projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n')
})
});
}
const db = admin.firestore();
async function buffer(readable: Readable) {
const chunks = [];
for await (const chunk of readable) {
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
}
return Buffer.concat(chunks);
}
export default async function (req: VercelRequest, res: VercelResponse) {
const { body, query, method, url, headers } = req;
res.statusCode = 200;
res.setHeader("Content-Type", `application/activity+json`);
// Verify the message some how.
const buf = await buffer(req);
const rawBody = buf.toString('utf8');
const message = <AP.Activity>JSON.parse(rawBody);
if (message.type == "Delete") {
// Ignore deletes for now.
return res.end("delete")
}
console.log(message);
const signature = parseSignature(req);
const actorInformation = await fetchActorInformation(signature.keyId);
if(actorInformation == undefined || actorInformation == null) {
console.log("Actor information is null");
res.end('actor information is null');
return;
}
const signatureValid = verifySignature(signature, actorInformation.publicKey);
if (signatureValid == null || signatureValid == false) {
console.log("invalid signature");
res.end('invalid signature');
return;
}
if (actorInformation != null) {
await saveActor(actorInformation);
// Add the actor information to the message so that it's saved directly.
message.actor = actorInformation;
}
console.log(message.type);
// We should check the digest.
if (message.type == "Follow" && actorInformation != null) {
// We are following.
await saveFollow(<AP.Follow>message, actorInformation);
}
if (message.type == "Like") {
await saveLike(<AP.Like>message);
}
if (message.type == "Announce") {
await saveAnnounce(<AP.Announce>message);
}
if (message.type == "Create") {
console.log("Message type Create")
// Someone is sending us a message.
const createMessage = <AP.Create>message;
if (createMessage == null || createMessage.id == null) return;
if (createMessage.object == null) return;
// We only interested in Replies - that is a "note" with a "replyTo"
const createObject = <CoreObject>createMessage.object
if (createObject.type == "Note" && createObject.inReplyTo != undefined) {
await saveReply(<AP.Create>createMessage);
}
}
if (message.type == "Undo") {
// Undo a follow.
const undoObject: AP.Undo = <AP.Undo>message;
if (undoObject == null || undoObject.id == null) return;
if (undoObject.object == null) return;
if ("actor" in undoObject.object == false) return;
if ((<CoreObject>undoObject.object).type == "Follow") {
await removeFollow(<AP.Follow>undoObject);
}
if ((<CoreObject>undoObject.object).type == "Like") {
await removeLike(<AP.Like>undoObject);
}
if ((<CoreObject>undoObject.object).type == "Announce") {
await removeAnnounce(<AP.Announce>undoObject);
}
}
if (message.type == "Update") {
// TODO: We need to update the messages
console.log("Update message", message);
}
res.end("ok");
};
const getActorId = (actor: AP.EntityReference): string => {
if (typeof actor == "string") {
return actor;
}
else if (actor instanceof URL) {
return actor.toString()
}
else {
return (actor.id || "").toString();
}
}
async function removeFollow(message: AP.Follow) {
// If from Mastodon - someone unfollowed me, we need to delete it from the store.
const docId = getActorId(<EntityReference>message.actor).replace(/\//g, "_");
console.log("DocId to delete", docId);
const res = await db.collection('followers').doc(docId).delete();
console.log("Deleted", res);
}
async function removeLike(message: AP.Like) {
// If from Mastodon - someone un-liked the post. We need to delete it from the store.
/*
{
'@context': 'https://www.w3.org/ns/activitystreams',
id: 'https://coderrrrr.site/coder#likes/854/undo',
type: 'Undo',
actor: 'https://coderrrrr.site/coder',
object: {
id: 'https://coderrrrr.site/coder#likes/854',
type: 'Like',
actor: 'https://coderrrrr.site/coder',
object: 'https://coderrrrr.site/thoughts-on-web-follow/'
}
}
*/
const doc = message.object?.object.toString().replace(/\//g, "_");
const actorId = message.object?.id.toString().replace(/\//g, "_");
console.log(`Attempting to delete Like ${actorId} on ${doc}`);
const res = await db.collection('likes').doc(doc).collection('messages').doc(actorId).delete();
console.log(`Deleted Like ${actorId} on ${doc}`, res);
}
async function removeAnnounce(message: AP.Announce) {
// If from Mastodon - someone un-liked the post. We need to delete it from the store.
const object = message.object as EntityReference;
const doc = object?.object.toString().replace(/\//g, "_");
const actorId = object?.id.toString().replace(/\//g, "_");
console.log(`Attempting to delete Announce ${actorId} on ${doc}`);
const res = await db.collection('announces').doc(doc).collection('messages').doc(actorId).delete();
console.log(`Deleted Announce ${actorId} on ${doc}`, res);
}
// Save the Actor objects so that we have a cache of them.
// Note: We will be embeddeding the actor information in the messages for saving directly so we can do less reads.
async function saveActor(message: AP.Actor) {
if (message.id == null) return;
const collection = db.collection('actors');
const actorId = message.id.toString().replace(/\//g, "_");
const actorDocRef = collection.doc(actorId);
// Create the follow;
await actorDocRef.set(message); // Always update the actor.
}
async function saveFollow(message: AP.Follow, actorInformation: AP.Actor) {
if (message.id == null) return;
const collection = db.collection('followers');
const actorID = getActorId(<EntityReference>message.actor).toString();
const followDocRef = collection.doc(actorID.replace(/\//g, "_"));
const followDoc = await followDocRef.get();
if (followDoc.exists) {
console.log("Already Following");
return;
}
// Create the follow;
await followDocRef.set(message);
const guid = uuid();
const domain = 'coderrrrr.site';
const acceptRequest: AP.Accept = <AP.Accept><unknown>{
"@context": "https://www.w3.org/ns/activitystreams",
'id': `https://${domain}/${guid}`,
'type': 'Accept',
'actor': "https://coderrrrr.site/coder",
'object': (message.actor as CoreObject).id
};
const actorInbox = new URL(<URL>actorInformation.inbox);
const response = await sendSignedRequest(actorInbox, acceptRequest);
console.log("Following result", response.status, response.statusText, await response.text());
}
async function saveLike(message: AP.Like) {
// If from Mastodon - someone liked the post.
const collection = db.collection('likes');
// We should do some checks
// 1. TODO: in reply to is against a post that I made.
console.log("Save Like", message);
/*
We store likes as a collection of collections.
Root key is the url of my messages
Each object has a sub-collection of the specific message made by someone.
*/
const id = (<URL>message.id).toString();
const objectId = (<URL>message.object).toString();
const rootDocRef = collection.doc(objectId.replace(/\//g, "_"));
const rootDoc = await rootDocRef.get();
if (rootDoc.exists == false) {
console.log("Root doesn't exists, make it so.");
await rootDocRef.set({});
}
const messagesCollection = rootDocRef.collection('messages');
const messageDocRef = messagesCollection.doc(id.replace(/\//g, "_"));
const messageDoc = await messageDocRef.get();
if (messageDoc.exists == false) {
console.log(`Adding message "${id}" to ${objectId}`);
await messageDocRef.set(message);
}
}
async function saveAnnounce(message: AP.Announce) {
// If from Mastodon - someone boosted the post.
const collection = db.collection('announces');
// We should do some checks
// 1. TODO: in reply to is against a post that I made.
console.log("Save Announce", message);
/*
We store announces as a collection of collections.
Root key is the url of my messages
Each object has a sub-collection of the specific message made by someone.
*/
const id = (<URL>message.id).toString();
const objectId = (<URL>message.object).toString();
const rootDocRef = collection.doc(objectId.replace(/\//g, "_"));
const rootDoc = await rootDocRef.get();
if (rootDoc.exists == false) {
await rootDocRef.set({});
}
const messagesCollection = rootDocRef.collection('messages');
const messageDocRef = messagesCollection.doc(id.replace(/\//g, "_"));
const messageDoc = await messageDocRef.get();
if (messageDoc.exists == false) {
console.log(`Adding message "${id}" to ${objectId}`);
await messageDocRef.set(message);
}
}
async function saveReply(message: AP.Create) {
// If from Mastodon - someone boosted the post.
const collection = db.collection('replies');
if (message.object == undefined) return;
// We should do some checks
// 1. TODO: in reply to is against a post that I made.
console.log("Save Reply", message);
/*
We store announces as a collection of collections.
Root key is the url of my messages
Each object has a sub-collection of the specific message made by someone.
*/
const objectId = (<URL>(<CoreObject>message.object).inReplyTo).toString();
const rootDocRef = collection.doc(objectId.replace(/\//g, "_"));
const rootDoc = await rootDocRef.get();
if (rootDoc.exists == false) {
await rootDocRef.set({});
}
const messagesCollection = rootDocRef.collection('messages');
const id = (<URL>message.id).toString();
const messageDocRef = messagesCollection.doc(id.replace(/\//g, "_"));
const messageDoc = await messageDocRef.get();
if (messageDoc.exists == false) {
console.log(`Adding message "${id}" to ${objectId}`);
await messageDocRef.set(message);
}
}

20
api/activitypub/outbox.ts Normal file
View File

@ -0,0 +1,20 @@
import type { VercelRequest, VercelResponse } from '@vercel/node';
import { join } from 'path';
import { cwd } from 'process';
import { readFileSync } from 'fs';
/*
This returns a list of posts for the single user 'coder'.
It's a GET request. This doesn't post it to anyone's timeline.
*/
export default function (req: VercelRequest, res: VercelResponse) {
// All of the outbox data is generated at build time, so just return that static file.
const file = join(cwd(), 'public', 'outbox.ajson');
const stringified = readFileSync(file, 'utf8');
res.statusCode = 200;
res.setHeader("Content-Type", `application/activity+json`);
return res.end(stringified);
};

120
api/activitypub/sendNote.ts Normal file
View File

@ -0,0 +1,120 @@
import type { VercelRequest, VercelResponse } from '@vercel/node';
import { AP } from 'activitypub-core-types';
import admin from 'firebase-admin';
import { OrderedCollection } from 'activitypub-core-types/lib/activitypub/index.js';
import { sendSignedRequest } from '../../lib/activitypub/utils/sendSignedRequest.js';
import { fetchActorInformation } from '../../lib/activitypub/utils/fetchActorInformation.js';
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
if (!admin.apps.length) {
admin.initializeApp({
credential: admin.credential.cert({
projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n')
})
});
}
const db = admin.firestore();
/*
Sends the latest not that hasn't yet been sent.
*/
export default async function (req: VercelRequest, res: VercelResponse) {
const { body, query, method, url, headers } = req;
if (
headers.authorization !== `Bearer ${process.env.CRON_SECRET}`
) {
return res.status(401).end("Unauthorized");
}
const configCollection = db.collection('config');
const configRef = configCollection.doc("config");
const config = await configRef.get();
if (config.exists == false) {
// Config doesn't exist, make something
configRef.set({
"lastId": ""
});
}
const configData = config.data();
let lastId = "";
if (configData != undefined) {
lastId = configData.lastId;
}
// Get my outbox because it contains all my notes.
const outboxResponse = await fetch('https://coderrrrr.site/outbox');
const outbox = <OrderedCollection>(await outboxResponse.json());
const followersCollection = db.collection('followers');
const followersQuerySnapshot = await followersCollection.get();
let lastSuccessfulSentId = "";
for (const followerDoc of followersQuerySnapshot.docs) {
const follower = followerDoc.data();
try {
const actorUrl = (typeof follower.actor == "string") ? follower.actor : follower.actor.id;
console.log(`Fetching actor information for ${actorUrl}`)
const actorInformation = await fetchActorInformation(actorUrl);
if (actorInformation == undefined) {
// We can't send to this actor, so skip the actor. We should log it.
continue;
}
if (actorInformation.inbox == undefined) {
console.log(
`Actor ${actorUrl} doesn't have an inbox, so we can't send to them. ${actorInformation}`
);
}
const actorInbox = new URL(actorInformation.inbox.toString());
for (const iteIdx in (<AP.EntityReference[]>outbox.orderedItems)) {
// We have to break somewhere... do it after the first.
const item = (<AP.EntityReference[]>outbox.orderedItems)[iteIdx];
console.log(`Checking ID ${item.id}, ${lastId}`);
if (item.id == `${lastId}`) {
lastSuccessfulSentId = item.id;
// We've already posted this, don't try and send it again.
console.log(`${item.id} has already been posted - don't attempt`)
break;
}
if (item.object != undefined) {
// We might not need this.
item.object.published = (new Date()).toISOString();
}
// Item will be an entity, i.e, { Create { Note } }
try {
console.log(`Sending to ${actorInbox}`);
const response = await sendSignedRequest(actorInbox, <AP.Activity> item);
console.log(`Send result: ${actorInbox}`, response.status, response.statusText, await response.text());
// It's not been sent.
lastSuccessfulSentId = item.id; // we shouldn't really set this every time.
} catch (sendSignedError) {
console.log("Error sending signed request", sendSignedError)
}
break; // At some point we might want to post more than one post, so remove this.
}
} catch (ex) {
console.log("Error", ex);
}
}
configRef.set({
"lastId": lastSuccessfulSentId
});
res.status(200).end("ok");
};

View File

@ -0,0 +1,20 @@
import type { VercelRequest, VercelResponse } from '@vercel/node';
export default function (req: VercelRequest, res: VercelResponse) {
const { resource } = req.query;
res.statusCode = 200;
res.setHeader("Content-Type", `application/jrd+json`);
res.end(`{
"subject": "acct:coder@coderrrrr.site",
"aliases": [
"https://coderrrrr.site/@coder"
],
"links": [
{
"rel": "self",
"type": "application/activity+json",
"href": "https://coderrrrr.site/coder"
}
]
}`);
}

View File

@ -1,8 +0,0 @@
[[redirects]]
from = "/.well-known/host-meta*"
to = "https://fed.brid.gy/.well-known/host-meta:splat"
status = 302
[[redirects]]
from = "/.well-known/webfinger*"
to = "https://fed.brid.gy/.well-known/webfinger"
status = 302

66
vercel.json Normal file
View File

@ -0,0 +1,66 @@
{
"functions": {
"api/**/*": {
"maxDuration": 240
}
},
"crons": [{
"path": "/api/activitypub/sendNote.ts",
"schedule": "0 * * * *"
}],
"redirects": [
{
"source": "/amp/(.+)/",
"destination": "/$1/"
},
{
"source": "/(\\d+)/(\\d+)/(.+).html",
"destination": "/$3/"
}
],
"rewrites": [
{
"source": "/(.+)/$",
"destination": "/$1/index.html"
},
{
"source": "/.well-known/(.*)",
"destination": "/api/well-known/$1"
},
{
"source": "/authorize_interaction",
"destination": "/api/activitypub/authorize_interaction.ts"
},
{
"source": "/coder",
"destination": "/api/activitypub/actor.js"
},
{
"source": "/followers",
"destination": "/api/activitypub/followers.js"
},
{
"source": "/following",
"destination": "/api/activitypub/following.js"
},
{
"source": "/inbox",
"destination": "/api/activitypub/inbox.js"
},
{
"source": "/outbox",
"destination": "/api/activitypub/outbox.js"
}
],
"headers": [
{
"source": "/(.*).ajson",
"headers": [
{
"key": "content-type",
"value": "application/activity+json"
}
]
}
]
}