Refactored to store actor info in a separate file

This makes it more easy to modify for future users
This commit is contained in:
Gordon Pedersen 2023-09-28 11:15:25 +10:00
parent 6647dabdc8
commit 6f2e122dd6
9 changed files with 247 additions and 161 deletions

88
actor.ts Normal file
View file

@ -0,0 +1,88 @@
import { BASE_URL, PUBLIC_KEY } from "./src/env"
// change "activitypub" to whatever you want your account name to be
export const preferredUsername:string = process.env.ACCOUNT || "activitypub"
export const name = process.env.REAL_NAME || preferredUsername
export const summary = ""
// avatar image
const icon:{ type:"Image", mediaType:string, url:string } = {
type: "Image",
mediaType: "image/svg+xml",
url: BASE_URL + "/assets/img/avatar-tt.svg"
}
// banner image
const image:{ type:"Image", mediaType:string, url:string } = {
type: "Image",
mediaType: "image/jpeg",
url: BASE_URL + "/assets/img/banner-1500x500.jpg"
}
// This is a list of other actor ids you identify as
const alsoKnownAs:string[] = []
// I'm not exactly sure what this time represents.
// The date your website got published?
const published:string = "2023-09-14T00:00:00Z"
// customize this to add or remove PropertyValues
// this is the table that appears on Mastodon, etc, profile pages
const attachment:{ type:"PropertyValue", name:string, value:string }[] = [
{
type: "PropertyValue",
name: "Pronouns",
value: "they/them"
},
{
type: "PropertyValue",
name: "Website",
value: `<a href="${BASE_URL}" target="_blank" rel="nofollow noopener noreferrer me">${BASE_URL}</a>`
}
]
export default {
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{
manuallyApprovesFollowers: "as:manuallyApprovesFollowers",
toot: "http://joinmastodon.org/ns#",
alsoKnownAs: {
"@id": "as:alsoKnownAs",
"@type": "@id"
},
schema: "http://schema.org#",
PropertyValue: "schema:PropertyValue",
value: "schema:value",
discoverable: "toot:discoverable",
Ed25519Signature: "toot:Ed25519Signature",
Ed25519Key: "toot:Ed25519Key",
Curve25519Key: "toot:Curve25519Key",
EncryptedMessage: "toot:EncryptedMessage",
publicKeyBase64: "toot:publicKeyBase64"
}
],
id: BASE_URL,
type: "Person",
following: `${BASE_URL}/following`,
followers: `${BASE_URL}/followers`,
inbox: `${BASE_URL}/inbox`,
outbox: `${BASE_URL}/outbox`,
preferredUsername,
name,
summary,
url: BASE_URL,
manuallyApprovesFollowers: false,
discoverable: true,
published,
alsoKnownAs,
publicKey: {
id: `${BASE_URL}#main-key`,
owner: BASE_URL,
publicKeyPem: PUBLIC_KEY,
},
attachment,
icon,
image
}

View file

@ -1,33 +1,23 @@
import { ACCOUNT, ACTOR, ACTOR_OBJ, HOSTNAME, PUBLIC_KEY } from "./env"
import * as db from "./db" import * as db from "./db"
import { reqIsActivityPub, send, verify } from "./request" import { reqIsActivityPub, verify } from "./request"
import outbox from "./outbox" import outbox from "./outbox"
import inbox from "./inbox" import inbox from "./inbox"
import ACTOR from "../actor"
export default (req: Request): Response | Promise<Response> | undefined => { export default (req: Request): Response | Promise<Response> | undefined => {
const url = new URL(req.url) const url = new URL(req.url)
let match let match
if(!reqIsActivityPub(req)) return undefined if(!reqIsActivityPub(req)) return undefined
else if(req.method == "GET" && (match = url.pathname.match(/^\/?$/i))) return getActor(req)
// else if(req.method == "GET" && (match = url.pathname.match(/^\/([^\/]+)\/?$/i))) return getActor(req, match[1]) else if(req.method == "GET" && (match = url.pathname.match(/^\/outbox\/?$/i))) return getOutbox(req)
// else if(req.method == "GET" && (match = url.pathname.match(/^\/([^\/]+)\/outbox\/?$/i))) return getOutbox(req, match[1]) else if(req.method == "POST" && (match = url.pathname.match(/^\/inbox\/?$/i))) return postInbox(req)
// 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)
// 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(/^\/followers\/?$/i))) return getFollowers(req)
// 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)
// else if(req.method == "GET" && (match = url.pathname.match(/^\/([^\/]+)\/following\/?$/i))) return getFollowing(req, match[1]) else if(req.method == "GET" && (match = url.pathname.match(/^\/posts\/([^\/]+)\/?$/i))) return getPost(req, match[1])
// else if(req.method == "GET" && (match = url.pathname.match(/^\/([^\/]+)\/posts\/([^\/]+)\/?$/i))) return getPost(req, match[1], match[2]) else if(req.method == "GET" && (match = url.pathname.match(/^\/posts\/([^\/]+)\/activity\/?$/i))) return getOutboxActivity(req, match[1])
// else if(req.method == "GET" && (match = url.pathname.match(/^\/([^\/]+)\/posts\/([^\/]+)\/activity\/?$/i))) return getActivity(req, match[1], match[2]) else if(req.method == "GET" && (match = url.pathname.match(/^\/outbox\/([^\/]+)\/?$/i))) return getOutboxActivity(req, match[1])
else if(req.method == "GET" && (match = url.pathname.match(/^\/?$/i))) return getActor(req, ACCOUNT)
else if(req.method == "GET" && (match = url.pathname.match(/^\/outbox\/?$/i))) return getOutbox(req, ACCOUNT)
else if(req.method == "POST" && (match = url.pathname.match(/^\/inbox\/?$/i))) return postInbox(req, ACCOUNT)
else if(req.method == "POST" && (match = url.pathname.match(/^\/outbox\/?$/i))) return postOutbox(req, ACCOUNT)
else if(req.method == "GET" && (match = url.pathname.match(/^\/followers\/?$/i))) return getFollowers(req, ACCOUNT)
else if(req.method == "GET" && (match = url.pathname.match(/^\/following\/?$/i))) return getFollowing(req, ACCOUNT)
else if(req.method == "GET" && (match = url.pathname.match(/^\/posts\/([^\/]+)\/?$/i))) return getPost(req, ACCOUNT, match[1])
else if(req.method == "GET" && (match = url.pathname.match(/^\/posts\/([^\/]+)\/activity\/?$/i))) return getOutboxActivity(req, ACCOUNT, match[1])
else if(req.method == "GET" && (match = url.pathname.match(/^\/outbox\/([^\/]+)\/?$/i))) return getOutboxActivity(req, ACCOUNT, match[1])
return undefined return undefined
} }
@ -41,9 +31,8 @@ export function idsFromValue(value:any):string[] {
return [] return []
} }
const postOutbox = async (req:Request, account:string):Promise<Response> => { const postOutbox = async (req:Request):Promise<Response> => {
console.log("PostOutbox", account) console.log("PostOutbox")
if (ACCOUNT !== account) return new Response("", { status: 404 })
const bodyText = await req.text() const bodyText = await req.text()
@ -51,14 +40,13 @@ const postOutbox = async (req:Request, account:string):Promise<Response> => {
const body = JSON.parse(bodyText) const body = JSON.parse(bodyText)
// ensure that the verified actor matches the actor in the request body // ensure that the verified actor matches the actor in the request body
if (ACTOR !== body.actor) return new Response("", { status: 401 }) if (ACTOR.id !== body.actor) return new Response("", { status: 401 })
return await outbox(body) return await outbox(body)
} }
const postInbox = async (req:Request, account:string):Promise<Response> => { const postInbox = async (req:Request):Promise<Response> => {
console.log("PostInbox", account) console.log("PostInbox")
if (ACCOUNT !== account) return new Response("", { status: 404 })
const bodyText = await req.text() const bodyText = await req.text()
@ -77,40 +65,28 @@ const postInbox = async (req:Request, account:string):Promise<Response> => {
if (from !== body.actor) return new Response("", { status: 401 }) if (from !== body.actor) return new Response("", { status: 401 })
return await inbox(body) return await inbox(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 })
} }
const getOutbox = async (req:Request, account:string):Promise<Response> => { const getOutbox = async (req:Request):Promise<Response> => {
console.log("GetOutbox", account) console.log("GetOutbox")
if (ACCOUNT !== account) return new Response("", { status: 404 })
// TODO: Paging? // TODO: Paging?
const posts = await db.listOutboxActivities() const posts = await db.listOutboxActivities()
return Response.json({ return Response.json({
"@context": "https://www.w3.org/ns/activitystreams", "@context": "https://www.w3.org/ns/activitystreams",
id: `${ACTOR}/outbox`, id: ACTOR.outbox,
type: "OrderedCollection", type: "OrderedCollection",
totalItems: posts.length, totalItems: posts.length,
orderedItems: posts.map((post) => ({ orderedItems: posts.map((post) => ({
...post, ...post,
actor: ACTOR actor: ACTOR.id
})).sort( (a,b) => new Date(b.published).getTime() - new Date(a.published).getTime()) })).sort( (a,b) => new Date(b.published).getTime() - new Date(a.published).getTime())
}, { headers: { "Content-Type": "application/activity+json"} }) }, { headers: { "Content-Type": "application/activity+json"} })
} }
const getFollowers = async (req:Request, account:String):Promise<Response> => { const getFollowers = async (req:Request):Promise<Response> => {
console.log("GetFollowers", account) console.log("GetFollowers")
if (ACCOUNT !== account) return new Response("", { status: 404 })
const url = new URL(req.url) const url = new URL(req.url)
const page = url.searchParams.get("page") const page = url.searchParams.get("page")
@ -118,24 +94,23 @@ const getFollowers = async (req:Request, account:String):Promise<Response> => {
if(!page) return Response.json({ if(!page) return Response.json({
"@context": "https://www.w3.org/ns/activitystreams", "@context": "https://www.w3.org/ns/activitystreams",
id: `${ACTOR}/followers`, id: ACTOR.followers,
type: "OrderedCollection", type: "OrderedCollection",
totalItems: followers.length, totalItems: followers.length,
first: `${ACTOR}/followers?page=1`, first: `${ACTOR.followers}?page=1`,
}) })
else return Response.json({ else return Response.json({
"@context": "https://www.w3.org/ns/activitystreams", "@context": "https://www.w3.org/ns/activitystreams",
id: `${ACTOR}/followers?page=${page}`, id: `${ACTOR.followers}?page=${page}`,
type: "OrderedCollectionPage", type: "OrderedCollectionPage",
partOf: `${ACTOR}/followers`, partOf: ACTOR.followers,
totalItems: followers.length, totalItems: followers.length,
orderedItems: followers.map(follower => follower.actor) orderedItems: followers.map(follower => follower.actor)
}) })
} }
const getFollowing = async (req:Request, account:String):Promise<Response> => { const getFollowing = async (req:Request):Promise<Response> => {
console.log("GetFollowing", account) console.log("GetFollowing")
if (ACCOUNT !== account) return new Response("", { status: 404 })
const url = new URL(req.url) const url = new URL(req.url)
const page = url.searchParams.get("page") const page = url.searchParams.get("page")
@ -143,40 +118,37 @@ const getFollowing = async (req:Request, account:String):Promise<Response> => {
if(!page) return Response.json({ if(!page) return Response.json({
"@context": "https://www.w3.org/ns/activitystreams", "@context": "https://www.w3.org/ns/activitystreams",
id: `${ACTOR}/following`, id: ACTOR.following,
type: "OrderedCollection", type: "OrderedCollection",
totalItems: following.length, totalItems: following.length,
first: `${ACTOR}/following?page=1`, first: `${ACTOR.following}?page=1`,
}) })
else return Response.json({ else return Response.json({
"@context": "https://www.w3.org/ns/activitystreams", "@context": "https://www.w3.org/ns/activitystreams",
id: `${ACTOR}/following?page=${page}`, id: `${ACTOR.following}?page=${page}`,
type: "OrderedCollectionPage", type: "OrderedCollectionPage",
partOf: `${ACTOR}/following`, partOf: ACTOR.following,
totalItems: following.length, totalItems: following.length,
orderedItems: following.map(follow => follow.actor) orderedItems: following.map(follow => follow.actor)
}) })
} }
const getActor = async (req:Request, account:string):Promise<Response> => { const getActor = async (req:Request):Promise<Response> => {
console.log("GetActor", account) console.log("GetActor")
if (ACCOUNT !== account) return new Response("", { status: 404 })
if(reqIsActivityPub(req)) return Response.json(ACTOR_OBJ, { headers: { "Content-Type": "application/activity+json"}}) if(reqIsActivityPub(req)) return Response.json(ACTOR, { headers: { "Content-Type": "application/activity+json"}})
else return Response.json(await db.listPosts()) else return Response.json(await db.listPosts())
} }
const getPost = async (req:Request, account:string, id:string):Promise<Response> => { const getPost = async (req:Request, id:string):Promise<Response> => {
console.log("GetPost", account, id) console.log("GetPost", id)
if (ACCOUNT !== account) return new Response("", { status: 404 })
if(reqIsActivityPub(req)) return Response.json((await db.getOutboxActivity(id)).object, { headers: { "Content-Type": "application/activity+json"}}) if(reqIsActivityPub(req)) return Response.json((await db.getOutboxActivity(id)).object, { headers: { "Content-Type": "application/activity+json"}})
else return Response.json(await db.getPost(id)) else return Response.json(await db.getPost(id))
} }
const getOutboxActivity = async (req:Request, account:string, id:string):Promise<Response> => { const getOutboxActivity = async (req:Request, id:string):Promise<Response> => {
console.log("GetOutboxActivity", account, id) console.log("GetOutboxActivity", id)
if (ACCOUNT !== account) return new Response("", { status: 404 })
return Response.json((await db.getOutboxActivity(id)), { headers: { "Content-Type": "application/activity+json"}}) return Response.json((await db.getOutboxActivity(id)), { headers: { "Content-Type": "application/activity+json"}})
} }

View file

@ -1,8 +1,9 @@
import { idsFromValue } from "./activitypub" import { idsFromValue } from "./activitypub"
import * as db from "./db" import * as db from "./db"
import { ACTOR, ADMIN_PASSWORD, ADMIN_USERNAME, BASE_URL, CONTENT_PATH, STATIC_PATH } from "./env" import { ADMIN_PASSWORD, ADMIN_USERNAME } from "./env"
import outbox from "./outbox" import outbox from "./outbox"
import { activityMimeTypes, fetchObject } from "./request" import { activityMimeTypes, fetchObject } from "./request"
import ACTOR from "../actor"
export default (req: Request): Response | Promise<Response> | undefined => { export default (req: Request): Response | Promise<Response> | undefined => {
const url = new URL(req.url) const url = new URL(req.url)
@ -54,15 +55,15 @@ const create = async (req:Request, inReplyTo:string|null = null):Promise<Respons
if(!inReplyTo && body.object.inReplyTo) inReplyTo = body.object.inReplyTo if(!inReplyTo && body.object.inReplyTo) inReplyTo = body.object.inReplyTo
const original = inReplyTo ? await (await fetchObject(ACTOR, inReplyTo)).json() : null const original = inReplyTo ? await (await fetchObject(inReplyTo)).json() : null
// create the object, merging in supplied data // create the object, merging in supplied data
const date = new Date() const date = new Date()
const object = { const object = {
attributedTo: ACTOR, attributedTo: ACTOR.id,
published: date.toISOString(), published: date.toISOString(),
to: ["https://www.w3.org/ns/activitystreams#Public"], to: ["https://www.w3.org/ns/activitystreams#Public"],
cc: [`${ACTOR}/followers`], cc: [ACTOR.followers],
...body.object ...body.object
} }
if(inReplyTo){ if(inReplyTo){
@ -74,7 +75,7 @@ const create = async (req:Request, inReplyTo:string|null = null):Promise<Respons
"@context": "https://www.w3.org/ns/activitystreams", "@context": "https://www.w3.org/ns/activitystreams",
type: "Create", type: "Create",
published: date.toISOString(), published: date.toISOString(),
actor: ACTOR, actor: ACTOR.id,
to: object.to, to: object.to,
cc: object.cc, cc: object.cc,
...body, ...body,
@ -92,7 +93,7 @@ const follow = async (req:Request, handle:string):Promise<Response> => {
} }
catch { catch {
// this is not a valid url. Probably a someone@domain.tld format // this is not a valid url. Probably a someone@domain.tld format
const [user, host] = handle.split('@') const [_, host] = handle.split('@')
const res = await fetch(`https://${host}/.well-known/webfinger/?resource=acct:${handle}`) const res = await fetch(`https://${host}/.well-known/webfinger/?resource=acct:${handle}`)
const webfinger = await res.json() const webfinger = await res.json()
if(!webfinger.links) return new Response("", { status: 404 }) if(!webfinger.links) return new Response("", { status: 404 })
@ -107,7 +108,7 @@ const follow = async (req:Request, handle:string):Promise<Response> => {
return await outbox({ return await outbox({
"@context": "https://www.w3.org/ns/activitystreams", "@context": "https://www.w3.org/ns/activitystreams",
type: "Follow", type: "Follow",
actor: ACTOR, actor: ACTOR.id,
object: url, object: url,
to: [url, "https://www.w3.org/ns/activitystreams#Public"] to: [url, "https://www.w3.org/ns/activitystreams#Public"]
}) })
@ -122,22 +123,22 @@ const unfollow = async (req:Request, handle:string):Promise<Response> => {
return await outbox({ return await outbox({
"@context": "https://www.w3.org/ns/activitystreams", "@context": "https://www.w3.org/ns/activitystreams",
type: "Undo", type: "Undo",
actor: ACTOR, actor: ACTOR.id,
object: activity, object: activity,
to: activity.to to: activity.to
}) })
} }
const like = async (req:Request, object_url:string):Promise<Response> => { const like = async (req:Request, object_url:string):Promise<Response> => {
const object = await (await fetchObject(ACTOR, object_url)).json() const object = await (await fetchObject(object_url)).json()
return await outbox({ return await outbox({
"@context": "https://www.w3.org/ns/activitystreams", "@context": "https://www.w3.org/ns/activitystreams",
type: "Like", type: "Like",
actor: ACTOR, actor: ACTOR.id,
object: object, object: object,
to: [...idsFromValue(object.attributedTo), "https://www.w3.org/ns/activitystreams#Public"], to: [...idsFromValue(object.attributedTo), "https://www.w3.org/ns/activitystreams#Public"],
cc: [ACTOR + 'followers'] cc: [ACTOR.followers]
}) })
} }
@ -146,7 +147,7 @@ const unlike = async (req:Request, object_id:string):Promise<Response> => {
const liked = await db.listLiked() const liked = await db.listLiked()
let existing = liked.find(o => o.object_id === object_id) let existing = liked.find(o => o.object_id === object_id)
if (!existing){ if (!existing){
const object = await (await fetchObject(ACTOR, object_id)).json() const object = await (await fetchObject(object_id)).json()
idsFromValue(object).forEach(id => { idsFromValue(object).forEach(id => {
const e = liked.find(o => o.object_id === id) const e = liked.find(o => o.object_id === id)
if(e) existing = e if(e) existing = e
@ -158,15 +159,15 @@ const unlike = async (req:Request, object_id:string):Promise<Response> => {
} }
const dislike = async (req:Request, object_url:string):Promise<Response> => { const dislike = async (req:Request, object_url:string):Promise<Response> => {
const object = await (await fetchObject(ACTOR, object_url)).json() const object = await (await fetchObject(object_url)).json()
return await outbox({ return await outbox({
"@context": "https://www.w3.org/ns/activitystreams", "@context": "https://www.w3.org/ns/activitystreams",
type: "Dislike", type: "Dislike",
actor: ACTOR, actor: ACTOR.id,
object: object, object: object,
to: [...idsFromValue(object.attributedTo), "https://www.w3.org/ns/activitystreams#Public"], to: [...idsFromValue(object.attributedTo), "https://www.w3.org/ns/activitystreams#Public"],
cc: [ACTOR + 'followers'] cc: [ACTOR.followers]
}) })
} }
@ -175,7 +176,7 @@ const undislike = async (req:Request, object_id:string):Promise<Response> => {
const disliked = await db.listDisliked() const disliked = await db.listDisliked()
let existing = disliked.find(o => o.object_id === object_id) let existing = disliked.find(o => o.object_id === object_id)
if (!existing){ if (!existing){
const object = await (await fetchObject(ACTOR, object_id)).json() const object = await (await fetchObject(object_id)).json()
idsFromValue(object).forEach(id => { idsFromValue(object).forEach(id => {
const e = disliked.find(o => o.object_id === id) const e = disliked.find(o => o.object_id === id)
if(e) existing = e if(e) existing = e
@ -187,15 +188,15 @@ const undislike = async (req:Request, object_id:string):Promise<Response> => {
} }
const share = async (req:Request, object_url:string):Promise<Response> => { const share = async (req:Request, object_url:string):Promise<Response> => {
const object = await (await fetchObject(ACTOR, object_url)).json() const object = await (await fetchObject(object_url)).json()
return await outbox({ return await outbox({
"@context": "https://www.w3.org/ns/activitystreams", "@context": "https://www.w3.org/ns/activitystreams",
type: "Announce", type: "Announce",
actor: ACTOR, actor: ACTOR.id,
object: object, object: object,
to: [...idsFromValue(object.attributedTo), "https://www.w3.org/ns/activitystreams#Public"], to: [...idsFromValue(object.attributedTo), "https://www.w3.org/ns/activitystreams#Public"],
cc: [ACTOR + 'followers'] cc: [ACTOR.followers]
}) })
} }
@ -204,7 +205,7 @@ const unshare = async (req:Request, object_id:string):Promise<Response> => {
const shared = await db.listShared() const shared = await db.listShared()
let existing = shared.find(o => o.object_id === object_id) let existing = shared.find(o => o.object_id === object_id)
if (!existing){ if (!existing){
const object = await (await fetchObject(ACTOR, object_id)).json() const object = await (await fetchObject(object_id)).json()
idsFromValue(object).forEach(id => { idsFromValue(object).forEach(id => {
const e = shared.find(o => o.object_id === id) const e = shared.find(o => o.object_id === id)
if(e) existing = e if(e) existing = e
@ -220,7 +221,7 @@ const undo = async(activity:any):Promise<Response> => {
return await outbox({ return await outbox({
"@context": "https://www.w3.org/ns/activitystreams", "@context": "https://www.w3.org/ns/activitystreams",
type: "Undo", type: "Undo",
actor: ACTOR, actor: ACTOR.id,
object: activity, object: activity,
to: activity.to, to: activity.to,
cc: activity.cc cc: activity.cc
@ -234,7 +235,7 @@ const deletePost = async (req:Request, id:string):Promise<Response> => {
return await outbox({ return await outbox({
"@context": "https://www.w3.org/ns/activitystreams", "@context": "https://www.w3.org/ns/activitystreams",
type: "Delete", type: "Delete",
actor: ACTOR, actor: ACTOR.id,
to: activity.to, to: activity.to,
cc: activity.cc, cc: activity.cc,
audience: activity.audience, audience: activity.audience,

View file

@ -1,9 +1,10 @@
import { ACCOUNT, ACTIVITY_INBOX_PATH, ACTIVITY_OUTBOX_PATH, ACTOR, CONTENT_PATH, DATA_PATH, POSTS_PATH, PUBLIC_KEY, STATIC_PATH } from "./env"; import { ACTIVITY_INBOX_PATH, ACTIVITY_OUTBOX_PATH, CONTENT_PATH, DATA_PATH, POSTS_PATH, STATIC_PATH } from "./env"
import path from "path" import path from "path"
import { readdir } from "fs/promises" import { readdir } from "fs/promises"
import { unlinkSync } from "node:fs" import { unlinkSync } from "node:fs"
import { fetchObject } from "./request"; import { fetchObject } from "./request"
import { idsFromValue } from "./activitypub"; import { idsFromValue } from "./activitypub"
import ACTOR from "../actor"
const matter = require('gray-matter') const matter = require('gray-matter')
const Eleventy = require("@11ty/eleventy") const Eleventy = require("@11ty/eleventy")
@ -50,7 +51,7 @@ export async function listOutboxActivities() {
export async function createPost(post_object:any, object_id:string) { export async function createPost(post_object:any, object_id:string) {
const file = Bun.file(path.join(POSTS_PATH, `${object_id}.md`)) const file = Bun.file(path.join(POSTS_PATH, `${object_id}.md`))
let {type, object, inReplyTo} = post_object let {type, object, inReplyTo} = post_object
if(inReplyTo && typeof inReplyTo === 'string') inReplyTo = await fetchObject(ACTOR, inReplyTo) if(inReplyTo && typeof inReplyTo === 'string') inReplyTo = await fetchObject(inReplyTo)
if(object){ if(object){
let { content, published, id, attributedTo } = object let { content, published, id, attributedTo } = object
@ -91,7 +92,7 @@ export async function getPost(id:string) {
} }
export async function getPostByURL(url_id:string) { export async function getPostByURL(url_id:string) {
if(!url_id || !url_id.startsWith(ACTOR + '/posts/')) return null if(!url_id || !url_id.startsWith(ACTOR.url + '/posts/')) return null
const match = url_id.match(/\/([0-9a-f]+)\/?$/) const match = url_id.match(/\/([0-9a-f]+)\/?$/)
const local_id = match ? match[1] : url_id const local_id = match ? match[1] : url_id
return await getPost(local_id) return await getPost(local_id)

View file

@ -1,9 +1,6 @@
import forge from "node-forge" // import crypto from "node:crypto" import forge from "node-forge" // import crypto from "node:crypto"
import path from "path" import path from "path"
// change "activitypub" to whatever you want your account name to be
export const ACCOUNT = process.env.ACCOUNT || "activitypub"
// set up username and password for admin actions // set up username and password for admin actions
export const ADMIN_USERNAME = process.env.ADMIN_USERNAME || ""; export const ADMIN_USERNAME = process.env.ADMIN_USERNAME || "";
export const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || ""; export const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || "";
@ -15,8 +12,6 @@ export const PORT = process.env.PORT || "3000"
export const BASE_URL = (HOSTNAME === "localhost" ? "http://" : "https://") + HOSTNAME export const BASE_URL = (HOSTNAME === "localhost" ? "http://" : "https://") + HOSTNAME
export const ACTOR = BASE_URL //+ '/' + ACCOUNT
// in development, generate a key pair to make it easier to get started // in development, generate a key pair to make it easier to get started
const keypair = const keypair =
NODE_ENV === "development" NODE_ENV === "development"
@ -50,30 +45,57 @@ export const DEFAULT_DOCUMENTS = process.env.DEFAULT_DOCUMENTS || [
'default.htm' 'default.htm'
] ]
export const ACTOR_OBJ = { // export const ACTOR_OBJ = {
"@context": [ // "@context": [
"https://www.w3.org/ns/activitystreams", // "https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1", // "https://w3id.org/security/v1",
], // ],
id: ACTOR, // id: ACTOR,
type: "Person", // type: "Person",
preferredUsername: ACCOUNT, // following: `${ACTOR}/following`,
url: ACTOR, // followers: `${ACTOR}/followers`,
manuallyApprovesFollowers: false, // inbox: `${ACTOR}/inbox`,
discoverable: true, // outbox: `${ACTOR}/outbox`,
published: "2023-09-14T00:00:00Z", // //featured: `${ACTOR}/featured`,
inbox: `${ACTOR}/inbox`, // //featuredTags: `${ACTOR}/tags`,
outbox: `${ACTOR}/outbox`, // preferredUsername: ACCOUNT,
followers: `${ACTOR}/followers`, // name: REAL_NAME,
following: `${ACTOR}/following`, // summary: SUMMARY,
publicKey: { // url: ACTOR,
id: `${ACTOR}#main-key`, // manuallyApprovesFollowers: false,
owner: ACTOR, // discoverable: true,
publicKeyPem: PUBLIC_KEY, // published: "2023-09-14T00:00:00Z",
}, // //devices: `${ACTOR}/devices`
icon: { // alsoKnownAs: ALSO_KNOWN_AS,
type: "Image", // publicKey: {
mediaType: "image/png", // id: `${ACTOR}#main-key`,
url: BASE_URL + "/assets/img/avatar-tt@800.png" // owner: ACTOR,
}, // publicKeyPem: PUBLIC_KEY,
} // },
// // attachment: [
// // {
// // "type": "PropertyValue",
// // "name": "Pronouns",
// // "value": "he/him"
// // },
// // {
// // "type": "PropertyValue",
// // "name": "Website",
// // "value": "<a href=\"https://death.id.au\" target=\"_blank\" rel=\"nofollow noopener noreferrer me\"><span class=\"invisible\">https://</span>death.id.au</a>"
// // }
// // ],
// // tag: [],
// // endpoints: {
// // sharedInbox: N/A
// // },
// icon: {
// type: "Image",
// mediaType: "image/svg+xml",
// url: BASE_URL + "/assets/img/avatar-tt.svg"
// },
// image: {
// type: "Image",
// mediaType: "image/jpeg",
// url: BASE_URL + "/assets/img/banner-1500x500.jpg"
// }
// }

View file

@ -1,8 +1,8 @@
import { idsFromValue } from "./activitypub"; import { idsFromValue } from "./activitypub"
import * as db from "./db"; import * as db from "./db"
import { ACTOR, BASE_URL } from "./env"; import outbox from "./outbox"
import outbox from "./outbox"; import { send } from "./request"
import { send } from "./request"; import ACTOR from "../actor"
export default async function inbox(activity:any) { export default async function inbox(activity:any) {
const date = new Date() const date = new Date()
@ -10,8 +10,8 @@ export default async function inbox(activity:any) {
const recipientList = [...new Set([...idsFromValue(activity.to), ...idsFromValue(activity.cc), ...idsFromValue(activity.audience)])] const recipientList = [...new Set([...idsFromValue(activity.to), ...idsFromValue(activity.cc), ...idsFromValue(activity.audience)])]
// if my list of followers in the list of recipients, then forward to them as well // if my list of followers in the list of recipients, then forward to them as well
if(recipientList.includes(ACTOR + "/followers")) { if(recipientList.includes(ACTOR.url + "/followers")) {
(await db.listFollowers()).forEach(f => send(activity.attributedTo, f, activity)) (await db.listFollowers()).forEach(f => send(f, activity, activity.attributedTo))
} }
// save this activity to my inbox // save this activity to my inbox
@ -37,7 +37,7 @@ const follow = async (activity:any, id:string) => {
await outbox({ await outbox({
"@context": "https://www.w3.org/ns/activitystreams", "@context": "https://www.w3.org/ns/activitystreams",
type: "Accept", type: "Accept",
actor: ACTOR, actor: ACTOR.id,
to: [activity.actor], to: [activity.actor],
object: activity, object: activity,
}); });

View file

@ -1,10 +1,11 @@
import { ACCOUNT, ACTOR, DEFAULT_DOCUMENTS, HOSTNAME, STATIC_PATH } from "./env"; import { DEFAULT_DOCUMENTS, HOSTNAME, STATIC_PATH } from "./env"
import admin from './admin' import admin from './admin'
import activitypub from "./activitypub"; import activitypub from "./activitypub"
import { fetchObject } from "./request"; import { fetchObject } from "./request"
import path from "path" import path from "path"
import { BunFile } from "bun"; import { BunFile } from "bun"
import { rebuild } from "./db"; import { rebuild } from "./db"
import ACTOR from "../actor"
rebuild() rebuild()
@ -22,7 +23,7 @@ const server = Bun.serve({
const object_url = url.searchParams.get('url') const object_url = url.searchParams.get('url')
if(!object_url) return new Response("No url supplied", { status: 400}) if(!object_url) return new Response("No url supplied", { status: 400})
return fetchObject(ACTOR, object_url) return fetchObject(object_url)
} }
return admin(req) || activitypub(req) || staticFile(req) return admin(req) || activitypub(req) || staticFile(req)
@ -31,21 +32,21 @@ const server = Bun.serve({
const webfinger = async (req: Request, resource: string | null) => { const webfinger = async (req: Request, resource: string | null) => {
if(resource !== `acct:${ACCOUNT}@${HOSTNAME}`) return new Response("", { status: 404 }) if(resource !== `acct:${ACTOR.preferredUsername}@${HOSTNAME}`) return new Response("", { status: 404 })
return Response.json({ return Response.json({
subject: `acct:${ACCOUNT}@${HOSTNAME}`, subject: `acct:${ACTOR.preferredUsername}@${HOSTNAME}`,
aliases: [ACTOR], aliases: [ACTOR.id],
links: [ links: [
{ {
"rel": "http://webfinger.net/rel/profile-page", "rel": "http://webfinger.net/rel/profile-page",
"type": "text/html", "type": "text/html",
"href": ACTOR "href": ACTOR.url
}, },
{ {
rel: "self", rel: "self",
type: "application/activity+json", type: "application/activity+json",
href: ACTOR, href: ACTOR.id,
}, },
], ],
}, { headers: { "content-type": "application/activity+json" }}) }, { headers: { "content-type": "application/activity+json" }})

View file

@ -1,7 +1,7 @@
import { idsFromValue } from "./activitypub" import { idsFromValue } from "./activitypub"
import * as db from "./db"; import * as db from "./db"
import { ACTOR } from "./env" import { fetchObject, send } from "./request"
import { fetchObject, send } from "./request"; import ACTOR from "../actor"
export default async function outbox(activity:any):Promise<Response> { export default async function outbox(activity:any):Promise<Response> {
const date = new Date() const date = new Date()
@ -14,7 +14,7 @@ export default async function outbox(activity:any):Promise<Response> {
activity = { activity = {
"@context": "https://www.w3.org/ns/activitystreams", "@context": "https://www.w3.org/ns/activitystreams",
type: "Create", type: "Create",
actor: ACTOR, actor: ACTOR.id,
object object
} }
const { to, bto, cc, bcc, audience } = object const { to, bto, cc, bcc, audience } = object
@ -25,7 +25,7 @@ export default async function outbox(activity:any):Promise<Response> {
if(audience) activity.audience = audience if(audience) activity.audience = audience
} }
activity.id = `${ACTOR}/outbox/${id}` activity.id = `${ACTOR.url}/outbox/${id}`
if(!activity.published) activity.published = date.toISOString() if(!activity.published) activity.published = date.toISOString()
if(activity.type === 'Create' && activity.object && Object(activity.object) === activity.object) { if(activity.type === 'Create' && activity.object && Object(activity.object) === activity.object) {
@ -59,16 +59,16 @@ export default async function outbox(activity:any):Promise<Response> {
// send to the appropriate recipients // send to the appropriate recipients
finalRecipientList.forEach((to) => { finalRecipientList.forEach((to) => {
if (to.startsWith(ACTOR + "/followers")) db.listFollowers().then(followers => followers.forEach(f => send(ACTOR, f.actor, activity))) if (to.startsWith(ACTOR.url + "/followers")) db.listFollowers().then(followers => followers.forEach(f => send(f.actor, activity)))
else if (to === "https://www.w3.org/ns/activitystreams#Public") return // there's nothing to "send" to here 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) else if (to) send(to, activity)
}) })
return new Response("", { status: 201, headers: { location: activity.id } }) return new Response("", { status: 201, headers: { location: activity.id } })
} }
async function create(activity:any, id:string) { async function create(activity:any, id:string) {
activity.object.id = activity.object.url = `${ACTOR}/posts/${id}` activity.object.id = activity.object.url = `${ACTOR.url}/posts/${id}`
await db.createPost(activity.object, id) await db.createPost(activity.object, id)
return true return true
} }
@ -85,7 +85,7 @@ async function follow(activity:any, id:string) {
async function like(activity:any, id:string) { async function like(activity:any, id:string) {
if(typeof activity.object === 'string'){ if(typeof activity.object === 'string'){
await db.createLiked(activity.object, id) await db.createLiked(activity.object, id)
activity.object = await fetchObject(ACTOR, activity.object) activity.object = await fetchObject(activity.object)
} }
else { else {
const liked = await idsFromValue(activity.object) const liked = await idsFromValue(activity.object)
@ -98,7 +98,7 @@ async function like(activity:any, id:string) {
async function dislike(activity:any, id:string) { async function dislike(activity:any, id:string) {
if(typeof activity.object === 'string'){ if(typeof activity.object === 'string'){
await db.createDisliked(activity.object, id) await db.createDisliked(activity.object, id)
activity.object = await fetchObject(ACTOR, activity.object) activity.object = await fetchObject(activity.object)
} }
else { else {
const disliked = await idsFromValue(activity.object) const disliked = await idsFromValue(activity.object)
@ -111,7 +111,7 @@ async function dislike(activity:any, id:string) {
async function announce(activity:any, id:string) { async function announce(activity:any, id:string) {
if(typeof activity.object === 'string'){ if(typeof activity.object === 'string'){
await db.createShared(activity.object, id) await db.createShared(activity.object, id)
activity.object = await fetchObject(ACTOR, activity.object) activity.object = await fetchObject(activity.object)
} }
else { else {
const shared = await idsFromValue(activity.object) const shared = await idsFromValue(activity.object)

View file

@ -1,5 +1,6 @@
import forge from "node-forge" // import crypto from "node:crypto" import forge from "node-forge" // import crypto from "node:crypto"
import { ACTOR, PRIVATE_KEY } from "./env"; import { PRIVATE_KEY } from "./env";
import ACTOR from "../actor"
export const activityMimeTypes:string[] = ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'] export const activityMimeTypes:string[] = ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"']
@ -39,7 +40,7 @@ export function signedFetch(url: string | URL | Request, init?: FetchRequestInit
const data = Object.entries(dataObj).map(([key, value]) => `${key}: ${value}`).join('\n') const data = Object.entries(dataObj).map(([key, value]) => `${key}: ${value}`).join('\n')
const signature = forge.util.encode64(key.sign(forge.md.sha256.create().update(data))) const signature = forge.util.encode64(key.sign(forge.md.sha256.create().update(data)))
const signatureObj = { const signatureObj = {
keyId: `${ACTOR}#main-key`, keyId: `${ACTOR.id}#main-key`,
headers: Object.keys(dataObj).join(' '), headers: Object.keys(dataObj).join(' '),
signature: signature signature: signature
} }
@ -56,11 +57,11 @@ export function signedFetch(url: string | URL | Request, init?: FetchRequestInit
/** Fetches and returns an actor at a URL. */ /** Fetches and returns an actor at a URL. */
async function fetchActor(url:string) { async function fetchActor(url:string) {
return await (await fetchObject(ACTOR, url)).json() return await (await fetchObject(url)).json()
} }
/** Fetches and returns an object at a URL. */ /** Fetches and returns an object at a URL. */
export async function fetchObject(sender:string, object_url:string) { export async function fetchObject(object_url:string) {
console.log(`fetch ${object_url}`) console.log(`fetch ${object_url}`)
const res = await fetch(object_url); const res = await fetch(object_url);
@ -77,7 +78,7 @@ export async function fetchObject(sender:string, object_url:string) {
* @param recipient The recipient's actor URL. * @param recipient The recipient's actor URL.
* @param message the body of the request to send. * @param message the body of the request to send.
*/ */
export async function send(sender:string, recipient:string, message:any) { export async function send(recipient:string, message:any, from:string=ACTOR.id) {
console.log(`Sending to ${recipient}`, message) console.log(`Sending to ${recipient}`, message)
// TODO: revisit fetch actor to use webfinger to get the inbox maybe? // TODO: revisit fetch actor to use webfinger to get the inbox maybe?
const actor = await fetchActor(recipient) const actor = await fetchActor(recipient)