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:
parent
48081b63a0
commit
9481edbce6
12 changed files with 421 additions and 62 deletions
0
_content/_data/_inbox/.gitkeep
Normal file
0
_content/_data/_inbox/.gitkeep
Normal file
0
_content/_data/_outbox/.gitkeep
Normal file
0
_content/_data/_outbox/.gitkeep
Normal file
1
_content/_data/liked.json
Normal file
1
_content/_data/liked.json
Normal file
|
@ -0,0 +1 @@
|
|||
[]
|
|
@ -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"
|
||||
|
|
|
@ -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",
|
||||
|
|
94
src/admin.ts
94
src/admin.ts
|
@ -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
|
||||
})
|
||||
}
|
85
src/db.ts
85
src/db.ts
|
@ -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>
|
||||
}
|
|
@ -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
63
src/inbox.ts
Normal 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
|
||||
}
|
||||
}
|
|
@ -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
127
src/outbox.ts
Normal 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
|
||||
}
|
|
@ -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, "")
|
||||
|
|
Loading…
Reference in a new issue