Bit of a refactor to put inbox and outbox function in their own files

Also, added the ability to like and unlike stuff, so yay.
This commit is contained in:
Gordon Pedersen 2023-09-20 16:43:48 +10:00
parent 48081b63a0
commit 9481edbce6
12 changed files with 421 additions and 62 deletions

View file

View file

View file

@ -0,0 +1 @@
[]

View file

@ -3,11 +3,11 @@
"module": "index.ts",
"type": "module",
"scripts": {
"start": "bun run --watch src/index.ts"
"start": "bun run --watch src/index.ts",
"ngrok": "ngrok tunnel --label edge=edghts_2VNJvaPttrFlAPWxrGyVKu0s3ad http://localhost:3000"
},
"devDependencies": {
"@types/node-forge": "^1.3.5",
"@types/yaml": "^1.9.7"
"@types/node-forge": "^1.3.5"
},
"peerDependencies": {
"typescript": "^5.0.0"

View file

@ -1,6 +1,7 @@
import { ACCOUNT, ACTOR, HOSTNAME, PUBLIC_KEY } from "./env"
import * as db from "./db"
import { reqIsActivityPub, send, verify } from "./request"
import outbox from "./outbox"
export default (req: Request): Response | Promise<Response> | undefined => {
const url = new URL(req.url)
@ -8,6 +9,7 @@ export default (req: Request): Response | Promise<Response> | undefined => {
if(req.method === "GET" && url.pathname === "/test") return new Response("", { status: 204 })
else if(req.method == "POST" && (match = url.pathname.match(/^\/([^\/]+)\/inbox\/?$/i))) return postInbox(req, match[1])
else if(req.method == "POST" && (match = url.pathname.match(/^\/([^\/]+)\/outbox\/?$/i))) return postOutbox(req, match[1])
else if(req.method == "GET" && (match = url.pathname.match(/^\/([^\/]+)\/outbox\/?$/i))) return getOutbox(req, match[1])
else if(req.method == "GET" && (match = url.pathname.match(/^\/([^\/]+)\/followers\/?$/i))) return getFollowers(req, match[1])
else if(req.method == "GET" && (match = url.pathname.match(/^\/([^\/]+)\/following\/?$/i))) return getFollowing(req, match[1])
@ -18,6 +20,32 @@ export default (req: Request): Response | Promise<Response> | undefined => {
return undefined
}
export function idsFromValue(value:any):string[] {
if (!value) return []
else if (typeof value === 'string') return [value]
else if (value.id) return [value.id]
else if (Array.isArray(value)) return value.map(v => idsFromValue(v)).flat(Infinity) as string[]
return []
}
const postOutbox = async (req:Request, account:string):Promise<Response> => {
console.log("PostOutbox", account)
if (ACCOUNT !== account) return new Response("", { status: 404 })
const bodyText = await req.text()
// TODO: verify calls to the outbox, whether that be by basic authentication, bearer, or otherwise.
const body = JSON.parse(bodyText)
// ensure that the verified actor matches the actor in the request body
if (ACTOR !== body.actor) return new Response("", { status: 401 })
// console.log(body)
return await outbox(body)
}
const postInbox = async (req:Request, account:string):Promise<Response> => {
console.log("PostInbox", account)
if (ACCOUNT !== account) return new Response("", { status: 404 })
@ -30,7 +58,6 @@ const postInbox = async (req:Request, account:string):Promise<Response> => {
// verify the signed HTTP request
from = await verify(req, bodyText);
} catch (err) {
console.error(err);
return new Response("", { status: 401 })
}
@ -39,13 +66,14 @@ const postInbox = async (req:Request, account:string):Promise<Response> => {
// ensure that the verified actor matches the actor in the request body
if (from !== body.actor) return new Response("", { status: 401 })
console.log(body)
// console.log(body)
// TODO: add support for more types! we want replies, likes, boosts, etc!
switch (body.type) {
case "Follow": await follow(body);
case "Undo": await undo(body);
case "Accept": await accept(body);
case "Reject": await reject(body);
}
return new Response("", { status: 204 })
@ -74,11 +102,18 @@ const accept = async (body:any) => {
}
}
const reject = async (body:any) => {
switch (body.object.type) {
case "Follow": await db.deleteFollowing(body.actor); break
}
}
const getOutbox = async (req:Request, account:string):Promise<Response> => {
console.log("GetOutbox", account)
if (ACCOUNT !== account) return new Response("", { status: 404 })
const posts = await db.listActivities()
// TODO: Paging?
const posts = await db.listOutboxActivities()
return Response.json({
"@context": "https://www.w3.org/ns/activitystreams",

View file

@ -1,6 +1,8 @@
import { createFollowing, deleteFollowing, doActivity, getFollowing, listFollowers } from "./db"
import { idsFromValue } from "./activitypub"
import { getFollowing, getOutboxActivity, listLiked } from "./db"
import { ACTOR, ADMIN_PASSWORD, ADMIN_USERNAME, BASE_URL } from "./env"
import { send } from "./request"
import outbox from "./outbox"
import { fetchObject } from "./request"
export default (req: Request): Response | Promise<Response> | undefined => {
const url = new URL(req.url)
@ -15,6 +17,10 @@ export default (req: Request): Response | Promise<Response> | undefined => {
else if(req.method == "POST" && (match = url.pathname.match(/^\/create\/?$/i))) return create(req)
else if(req.method == "POST" && (match = url.pathname.match(/^\/follow\/([^\/]+)\/?$/i))) return follow(req, match[1])
else if(req.method == "DELETE" && (match = url.pathname.match(/^\/follow\/([^\/]+)\/?$/i))) return unfollow(req, match[1])
else if(req.method == "POST" && (match = url.pathname.match(/^\/like\/(.+)\/?$/i))) return like(req, match[1])
else if(req.method == "DELETE" && (match = url.pathname.match(/^\/like\/(.+)\/?$/i))) return unlike(req, match[1])
console.log(`Couldn't match admin path ${req.method} "${url.pathname}"`)
return undefined
}
@ -35,20 +41,16 @@ const create = async (req:Request):Promise<Response> => {
// create the object, merging in supplied data
const date = new Date()
const id = date.getTime().toString(16)
const object = {
attributedTo: ACTOR,
published: date.toISOString(),
to: ["https://www.w3.org/ns/activitystreams#Public"],
cc: [`${ACTOR}/followers`],
url: `${ACTOR}/post/${id}`,
id: `${ACTOR}/post/${id}`,
...body.object
}
const activity = {
"@context": "https://www.w3.org/ns/activitystreams",
id: `${ACTOR}/post/${id}/activity`,
"@context": "https://www.w3.org/ns/activitystreams",
type: "Create",
published: date.toISOString(),
actor: ACTOR,
@ -58,58 +60,66 @@ const create = async (req:Request):Promise<Response> => {
object: { ...object }
}
// TODO: actually create the object (and the activity??)
await doActivity(activity, id)
// loop through the list of followers
for (const follower of await listFollowers()) {
// send the activity to each follower
send(ACTOR, follower.actor, {
...activity,
cc: [follower.actor],
});
}
// return HTTP 204: no content (success)
return new Response("", { status: 204 })
return await outbox(activity)
}
const follow = async (req:Request, handle:string):Promise<Response> => {
const id = BASE_URL + '@' + handle
// send the follow request to the supplied actor
await send(ACTOR, handle, {
return await outbox({
"@context": "https://www.w3.org/ns/activitystreams",
id,
type: "Follow",
actor: ACTOR,
object: handle,
});
await createFollowing(handle, id)
return new Response("", { status: 204 })
to: [handle, "https://www.w3.org/ns/activitystreams#Public"]
})
}
const unfollow = async (req:Request, handle:string):Promise<Response> => {
// check to see if we are already following. If not, just return success
const existing = await getFollowing(handle)
if (!existing) return new Response("", { status: 204 })
// TODO: send the unfollow request (technically an undo follow activity)
await send(ACTOR, handle, {
const activity = await getOutboxActivity(existing.id)
// outbox will also take care of the deletion
return await outbox({
"@context": "https://www.w3.org/ns/activitystreams",
id: existing.id + "/undo",
type: "Undo",
actor: ACTOR,
object: {
id: existing.id,
type: "Follow",
actor: ACTOR,
object: handle,
},
});
object: activity,
to: activity.to
})
}
// delete the following reference from the database
deleteFollowing(handle);
const like = async (req:Request, object_url:string):Promise<Response> => {
const object = await (await fetchObject(ACTOR, object_url)).json()
return new Response("", { status: 204 })
return await outbox({
"@context": "https://www.w3.org/ns/activitystreams",
type: "Like",
actor: ACTOR,
object: object,
to: [...idsFromValue(object.attributedTo), "https://www.w3.org/ns/activitystreams#Public"]
})
}
const unlike = async (req:Request, object_id:string):Promise<Response> => {
// check to see if we are already following. If not, just return success
const liked = await listLiked()
let existing = liked.find(o => o.object_id === object_id)
if (!existing){
const object = await (await fetchObject(ACTOR, object_id)).json()
idsFromValue(object).forEach(id => {
const e = liked.find(o => o.object_id === id)
if(e) existing = e
})
}
if (!existing) return new Response("No like found to delete", { status: 204 })
const activity = await getOutboxActivity(existing.id)
// outbox will also take care of the deletion
return await outbox({
"@context": "https://www.w3.org/ns/activitystreams",
type: "Undo",
actor: ACTOR,
object: activity,
to: activity.to
})
}

View file

@ -1,6 +1,7 @@
import { ACTIVITY_PATH, ACTOR, BASE_URL, DATA_PATH, POSTS_PATH } from "./env";
import { ACTIVITY_INBOX_PATH, ACTIVITY_OUTBOX_PATH, ACTIVITY_PATH, ACTOR, BASE_URL, DATA_PATH, POSTS_PATH } from "./env";
import path from "path"
import { readdir } from "fs/promises"
import { unlinkSync } from "node:fs"
const matter = require('gray-matter')
export async function doActivity(activity:any, object_id:string|null|undefined) {
@ -15,6 +16,58 @@ export async function doActivity(activity:any, object_id:string|null|undefined)
}
}
export async function createInboxActivity(activity:any, object_id:any) {
const activityFile = Bun.file(path.join(ACTIVITY_INBOX_PATH, `${object_id}.activity.json`))
await Bun.write(activityFile, JSON.stringify(activity))
}
export async function createOutboxActivity(activity:any, object_id:any) {
const activityFile = Bun.file(path.join(ACTIVITY_OUTBOX_PATH, `${object_id}.activity.json`))
await Bun.write(activityFile, JSON.stringify(activity))
}
export async function getInboxActivity(id:string) {
const file = Bun.file(path.join(ACTIVITY_INBOX_PATH, `${id}.activity.json`))
return await file.json()
}
export async function getOutboxActivity(id:string) {
const file = Bun.file(path.join(ACTIVITY_OUTBOX_PATH, `${id}.activity.json`))
return await file.json()
}
export async function listInboxActivities() {
return await Promise.all(
(await readdir(ACTIVITY_INBOX_PATH)).filter(v => v.endsWith('.activity.json'))
.map(async filename => await Bun.file(path.join(ACTIVITY_INBOX_PATH, filename)).json())
)
}
export async function listOutboxActivities() {
return await Promise.all(
(await readdir(ACTIVITY_OUTBOX_PATH)).filter(v => v.endsWith('.activity.json'))
.map(async filename => await Bun.file(path.join(ACTIVITY_OUTBOX_PATH, filename)).json())
)
}
export async function createPost(post_object:any, object_id:string) {
const file = Bun.file(path.join(POSTS_PATH, `${object_id}.md`))
const {type, object} = post_object
if(object){
let { content, published, id, attributedTo } = object
if(content as string) content = '> ' + content.replace('\n', '\n> ') + '\n'
else content = ""
content += post_object.content || ""
//TODO: add appropriate content for different types (e.g. like, etc)
await Bun.write(file, matter.stringify(content, { id, published, attributedTo, type }))
}
else {
const { content, published, id, attributedTo } = post_object
await Bun.write(file, matter.stringify(content || "", { id, published, attributedTo, type }))
}
}
export async function getPost(id:string) {
const file = Bun.file(path.join(POSTS_PATH, `${id}.md`))
const { data, content } = matter(await file.text())
@ -24,6 +77,10 @@ export async function getPost(id:string) {
}
}
export async function deletePost(id:string) {
unlinkSync(path.join(POSTS_PATH, id + '.md'))
}
export async function listPosts() {
return await Promise.all((await readdir(POSTS_PATH)).filter(v => v.endsWith('.md')).map(async filename => await getPost(filename.slice(0, -3))))
}
@ -33,10 +90,6 @@ export async function getActivity(id:string) {
return await file.json()
}
export async function listActivities() {
return await Promise.all((await readdir(ACTIVITY_PATH)).filter(v => v.endsWith('.activity.json')).map(async filename => await Bun.file(path.join(ACTIVITY_PATH, filename)).json()))
}
export async function createFollowing(handle:string, id:string) {
const file = Bun.file(path.join(DATA_PATH, `following.json`))
const following_list = await file.json() as Array<any>
@ -56,9 +109,9 @@ export async function getFollowing(handle:string) {
return following_list.find(v => v.handle === handle)
}
export async function listFollowing() {
export async function listFollowing(onlyAccepted = true) {
const file = Bun.file(path.join(DATA_PATH, `following.json`))
return await file.json() as Array<any>
return ((await file.json()) as Array<any>).filter(f => !onlyAccepted || f.accepted)
}
export async function acceptFollowing(handle:string) {
@ -91,4 +144,22 @@ export async function getFollower(actor:string) {
export async function listFollowers() {
const file = Bun.file(path.join(DATA_PATH, `followers.json`))
return await file.json() as Array<any>
}
export async function createLiked(object_id:string, id:string) {
const file = Bun.file(path.join(DATA_PATH, `liked.json`))
const liked_list = await file.json() as Array<any>
if(!liked_list.find(v => v.object_id === object_id)) liked_list.push({id, object_id, createdAt: new Date().toISOString()})
await Bun.write(file, JSON.stringify(liked_list))
}
export async function deleteLiked(object_id:string) {
const file = Bun.file(path.join(DATA_PATH, `liked.json`))
const liked_list = await file.json() as Array<any>
await Bun.write(file, JSON.stringify(liked_list.filter(v => v.object_id !== object_id)))
}
export async function listLiked() {
const file = Bun.file(path.join(DATA_PATH, `liked.json`))
return await file.json() as Array<any>
}

View file

@ -35,4 +35,6 @@ export const PRIVATE_KEY =
export const CONTENT_PATH = path.join('.', '_content')
export const POSTS_PATH = path.join(CONTENT_PATH, "posts")
export const ACTIVITY_PATH = path.join(CONTENT_PATH, "posts")
export const DATA_PATH = path.join(CONTENT_PATH, "_data")
export const DATA_PATH = path.join(CONTENT_PATH, "_data")
export const ACTIVITY_INBOX_PATH = path.join(DATA_PATH, "_inbox")
export const ACTIVITY_OUTBOX_PATH = path.join(DATA_PATH, "_outbox")

63
src/inbox.ts Normal file
View file

@ -0,0 +1,63 @@
import { idsFromValue } from "./activitypub";
import * as db from "./db";
import { ACTOR, BASE_URL } from "./env";
import outbox from "./outbox";
import { send } from "./request";
export default async function inbox(activity:any) {
const date = new Date()
// get the main recipients ([...new Set()] is to dedupe)
const recipientList = [...new Set(idsFromValue(activity.to).concat(idsFromValue(activity.cc)).concat(idsFromValue(activity.audience)))]
// if my list of followers in the list of recipients, then forward to them as well
if(recipientList.includes(ACTOR + "/followers")) {
(await db.listFollowers()).forEach(f => send(activity.attributedTo, f, activity))
}
// save this activity to my inbox
const id = `${date.getTime().toString(16)}`
db.createInboxActivity(activity, id)
// TODO: process the activity and update local data
switch(activity.type) {
case "Follow": follow(activity, id); break;
case "Accept": accept(activity); break;
case "Reject": reject(activity); break;
case "Undo": undo(activity); break;
}
}
const follow = async (activity:any, id:string) => {
// someone is following me
// save this follower locally
db.createFollower(activity.actor, id)
// send an accept message to the outbox
await outbox({
"@context": "https://www.w3.org/ns/activitystreams",
type: "Accept",
actor: ACTOR,
to: [activity.actor],
object: activity,
});
}
const undo = async (activity:any) => {
switch (activity.object.type) {
// someone is undoing their follow of me
case "Follow": await db.deleteFollower(activity.actor); break
}
}
const accept = async (activity:any) => {
switch (activity.object.type) {
// someone accepted my follow of them
case "Follow": await db.acceptFollowing(activity.actor); break
}
}
const reject = async (activity:any) => {
switch (activity.object.type) {
// someone rejected my follow of them
case "Follow": await db.deleteFollowing(activity.actor); break
}
}

View file

@ -1,6 +1,7 @@
import { ACCOUNT, ACTOR, HOSTNAME, PORT } from "./env";
import admin from './admin'
import activitypub from "./activitypub";
import { fetchObject } from "./request";
const server = Bun.serve({
port: 3000,
@ -25,6 +26,12 @@ const server = Bun.serve({
],
}, { headers: { "content-type": "application/activity+json" }})
}
else if(req.method === "GET" && url.pathname === "/fetch") {
const object_url = url.searchParams.get('url')
if(!object_url) return new Response("No url supplied", { status: 400})
return fetchObject(ACTOR, object_url)
}
return admin(req) || activitypub(req) || new Response("How did we get here?", { status: 404 })
},

127
src/outbox.ts Normal file
View file

@ -0,0 +1,127 @@
import { idsFromValue } from "./activitypub"
import * as db from "./db";
import { ACTOR } from "./env"
import { fetchObject, send } from "./request";
export default async function outbox(activity:any):Promise<Response> {
const date = new Date()
const id = `${date.getTime().toString(16)}`
console.log('outbox', id, activity)
// https://www.w3.org/TR/activitypub/#object-without-create
if(!activity.actor && !(activity.object || activity.target || activity.result || activity.origin || activity.instrument)) {
const object = activity
activity = {
"@context": "https://www.w3.org/ns/activitystreams",
type: "Create",
actor: ACTOR,
object
}
const { to, bto, cc, bcc, audience } = object
if(to) activity.to = to
if(bto) activity.bto = bto
if(cc) activity.cc = cc
if(bcc) activity.bcc = bcc
if(audience) activity.audience = audience
}
activity.id = `${ACTOR}/outbox/${id}`
if(!activity.published) activity.published = date.toISOString()
if(activity.type === 'Create' && activity.object && Object(activity.object) === activity.object) {
// When a Create activity is posted, the actor of the activity SHOULD be copied onto the object's attributedTo field.
activity.object.attributedTo = activity.actor
if(!activity.object.published) activity.object.published = activity.published
}
// get the main recipients ([...new Set()] is to dedupe)
const recipientList = [...new Set(idsFromValue(activity.to).concat(idsFromValue(activity.cc)).concat(idsFromValue(activity.audience)))]
// add in the blind recipients
const finalRecipientList = [...new Set(recipientList.concat(idsFromValue(activity.bto)).concat(idsFromValue(activity.bcc)))]
// remove the blind recipients from the activity
delete activity.bto
delete activity.bcc
// now that has been taken care of, it's time to update our local data, depending on the contents of the activity
switch(activity.type) {
case "Accept": await accept(activity, id); break;
case "Follow": await follow(activity, id); break;
case "Like": await like(activity, id); break;
case "Create": await create(activity, id); break;
case "Undo": await undo(activity); break;
// TODO: case "Anncounce": return await share(activity)
}
// save the activity data for the outbox
await db.createOutboxActivity(activity, id)
// send to the appropriate recipients
finalRecipientList.forEach((to) => {
if (to.startsWith(ACTOR + "/followers")) db.listFollowers().then(followers => followers.forEach(f => send(ACTOR, f.actor, activity)))
else if (to === "https://www.w3.org/ns/activitystreams#Public") return // there's nothing to "send" to here
else if (to) send(ACTOR, to, activity)
})
return new Response("", { status: 201, headers: { location: activity.id } })
}
async function create(activity:any, id:string) {
activity.object.id = activity.object.url = `${ACTOR}/post/${id}`
await db.createPost(activity.object, id)
return true
}
async function accept(activity:any, id:string) {
return true
}
async function follow(activity:any, id:string) {
await db.createFollowing(activity.object , id)
return true
}
async function like(activity:any, id:string) {
if(typeof activity.object === 'string'){
await db.createLiked(activity.object, id)
activity.object = await fetchObject(ACTOR, activity.object)
}
else {
const liked = await idsFromValue(activity.object)
liked.forEach(l => db.createLiked(l, id))
}
await db.createPost(activity, id)
return true
}
// async function share(activity:any) {
// let object = activity.object
// if(typeof object === 'string') {
// try{
// object = await fetchObject(object)
// }
// catch { }
// }
// db.createShared(object)
// return true
// }
async function undo(activity:any) {
const id = await idsFromValue(activity.object).at(0)
if (!id) return true
const match = id.match(/\/([0-9a-f]+)\/?$/)
const local_id = match ? match[1] : id
console.log('undo', local_id)
try{
const existing = await db.getOutboxActivity(local_id)
switch(activity.object.type) {
case "Follow": await db.deleteFollowing(existing.object); break;
case "Like": idsFromValue(existing.object).forEach(async id => await db.deleteLiked(id)); await db.deletePost(local_id); break;
// case "Share": await db.deleteShared(existing.object)
}
}
catch {
return false
}
return true
}

View file

@ -1,5 +1,5 @@
import forge from "node-forge" // import crypto from "node:crypto"
import { PRIVATE_KEY } from "./env";
import { ACTOR, PRIVATE_KEY } from "./env";
export function reqIsActivityPub(req:Request) {
const contentType = req.headers.get("Accept")
@ -10,15 +10,57 @@ export function reqIsActivityPub(req:Request) {
/** Fetches and returns an actor at a URL. */
async function fetchActor(url:string) {
const res = await fetch(url, {
headers: { accept: "application/activity+json" },
return (await fetchObject(ACTOR, url)).json()
}
/** Fetches and returns an object at a URL. */
// export async function fetchObject(url:string) {
// const res = await fetch(url, {
// headers: { accept: "application/activity+json" },
// });
// if (res.status < 200 || 299 < res.status) {
// throw new Error(`Received ${res.status} fetching object.`);
// }
// return res.json();
// }
/** Fetches and returns an object at a URL. */
export async function fetchObject(sender:string, object_url:string) {
console.log(`fetch ${object_url}`)
const url = new URL(object_url)
const path = url.pathname
const d = new Date();
const key = forge.pki.privateKeyFromPem(PRIVATE_KEY)
const data = [
`(request-target): get ${path}`,
`host: ${url.hostname}`,
`date: ${d.toUTCString()}`
].join("\n")
const signature = forge.util.encode64(key.sign(forge.md.sha256.create().update(data)))
const res = await fetch(object_url, {
method: "GET",
headers: {
host: url.hostname,
date: d.toUTCString(),
"content-type": "application/json",
signature: `keyId="${sender}#main-key",headers="(request-target) host date",signature="${signature}"`,
accept: "application/json",
}
});
if (res.status < 200 || 299 < res.status) {
throw new Error(`Received ${res.status} fetching actor.`);
throw new Error(res.statusText + ": " + (await res.text()));
}
return res.json();
return res;
}
/** Sends a signed message from the sender to the recipient.
@ -27,6 +69,7 @@ async function fetchActor(url:string) {
* @param message the body of the request to send.
*/
export async function send(sender:string, recipient:string, message:any) {
console.log(`Sending to ${recipient}`, message)
const url = new URL(recipient)
const actor = await fetchActor(recipient)
const path = actor.inbox.replace("https://" + url.hostname, "")