Initial commit
Code based on (but not identical to) https://github.com/jakelazaroff/activitypub-starter-kit Instead of using node/express, it's using Bun/bun's inbuilt server Right now it can follow/unfollow (be followed/unfollowed) and create notes which get sent to followers No UI yet Stores posts as markdown with YAML frontmatter Stores activities for creating those posts as json Stores following/followers in json files
This commit is contained in:
commit
6f3e2f65ad
15 changed files with 881 additions and 0 deletions
169
.gitignore
vendored
Normal file
169
.gitignore
vendored
Normal file
|
@ -0,0 +1,169 @@
|
|||
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
|
||||
|
||||
# Logs
|
||||
|
||||
logs
|
||||
_.log
|
||||
npm-debug.log_
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# Runtime data
|
||||
|
||||
pids
|
||||
_.pid
|
||||
_.seed
|
||||
\*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
|
||||
coverage
|
||||
\*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
|
||||
\*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
|
||||
\*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
|
||||
.cache/
|
||||
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.\*
|
53
.vscode/launch.json
vendored
Normal file
53
.vscode/launch.json
vendored
Normal file
|
@ -0,0 +1,53 @@
|
|||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "bun",
|
||||
"request": "launch",
|
||||
"name": "Debug Bun",
|
||||
|
||||
// The path to a JavaScript or TypeScript file to run.
|
||||
"program": "src/index.ts",
|
||||
|
||||
// The arguments to pass to the program, if any.
|
||||
"args": [],
|
||||
|
||||
// The working directory of the program.
|
||||
"cwd": "${workspaceFolder}",
|
||||
|
||||
// The environment variables to pass to the program.
|
||||
"env": {},
|
||||
|
||||
// If the environment variables should not be inherited from the parent process.
|
||||
"strictEnv": false,
|
||||
|
||||
// If the program should be run in watch mode.
|
||||
// This is equivalent to passing `--watch` to the `bun` executable.
|
||||
// You can also set this to "hot" to enable hot reloading using `--hot`.
|
||||
"watchMode": false,
|
||||
|
||||
// If the debugger should stop on the first line of the program.
|
||||
"stopOnEntry": false,
|
||||
|
||||
// If the debugger should be disabled. (for example, breakpoints will not be hit)
|
||||
"noDebug": false,
|
||||
|
||||
// The path to the `bun` executable, defaults to your `PATH` environment variable.
|
||||
"runtime": "bun",
|
||||
|
||||
// The arguments to pass to the `bun` executable, if any.
|
||||
// Unlike `args`, these are passed to the executable itself, not the program.
|
||||
"runtimeArgs": ["--watch"],
|
||||
},
|
||||
{
|
||||
"type": "bun",
|
||||
"request": "attach",
|
||||
"name": "Attach to Bun",
|
||||
|
||||
// The URL of the WebSocket inspector to attach to.
|
||||
// This value can be retreived by using `bun --inspect`.
|
||||
"url": "ws://localhost:6499/",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
15
README.md
Normal file
15
README.md
Normal file
|
@ -0,0 +1,15 @@
|
|||
# bun-activitypub
|
||||
|
||||
To install dependencies:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
To run:
|
||||
|
||||
```bash
|
||||
bun run index.ts
|
||||
```
|
||||
|
||||
This project was created using `bun init` in bun v1.0.0. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.
|
1
_content/_data/followers.json
Normal file
1
_content/_data/followers.json
Normal file
|
@ -0,0 +1 @@
|
|||
[]
|
1
_content/_data/following.json
Normal file
1
_content/_data/following.json
Normal file
|
@ -0,0 +1 @@
|
|||
[]
|
0
_content/posts/.gitkeep
Normal file
0
_content/posts/.gitkeep
Normal file
BIN
bun.lockb
Executable file
BIN
bun.lockb
Executable file
Binary file not shown.
21
package.json
Normal file
21
package.json
Normal file
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"name": "bun-activitypub",
|
||||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "bun run --watch src/index.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node-forge": "^1.3.5",
|
||||
"@types/yaml": "^1.9.7",
|
||||
"bun-types": "latest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"gray-matter": "^4.0.3",
|
||||
"node-forge": "^1.3.1",
|
||||
"yaml": "^2.3.2"
|
||||
}
|
||||
}
|
187
src/activitypub.ts
Normal file
187
src/activitypub.ts
Normal file
|
@ -0,0 +1,187 @@
|
|||
import { ACCOUNT, ACTOR, HOSTNAME, PUBLIC_KEY } from "./env"
|
||||
import * as db from "./db"
|
||||
import { reqIsActivityPub, send, verify } from "./request"
|
||||
|
||||
export default (req: Request): Response | Promise<Response> | undefined => {
|
||||
const url = new URL(req.url)
|
||||
let match
|
||||
|
||||
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 == "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])
|
||||
else if(req.method == "GET" && (match = url.pathname.match(/^\/([^\/]+)\/?$/i))) return getActor(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 getActivity(req, match[1], match[2])
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
const postInbox = async (req:Request, account:string):Promise<Response> => {
|
||||
console.log("PostInbox", account)
|
||||
if (ACCOUNT !== account) return new Response("", { status: 404 })
|
||||
|
||||
const bodyText = await req.text()
|
||||
|
||||
/** If the request successfully verifies against the public key, `from` is the actor who sent it. */
|
||||
let from = "";
|
||||
try {
|
||||
// verify the signed HTTP request
|
||||
from = await verify(req, bodyText);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return new Response("", { status: 401 })
|
||||
}
|
||||
|
||||
const body = JSON.parse(bodyText)
|
||||
|
||||
// ensure that the verified actor matches the actor in the request body
|
||||
if (from !== body.actor) return new Response("", { status: 401 })
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
return new Response("", { status: 204 })
|
||||
}
|
||||
|
||||
const follow = async (body:any) => {
|
||||
await send(ACTOR, body.actor, {
|
||||
"@context": "https://www.w3.org/ns/activitystreams",
|
||||
id: `https://${HOSTNAME}/${crypto.randomUUID()}`, // TODO: de-randomise this?
|
||||
type: "Accept",
|
||||
actor: ACTOR,
|
||||
object: body,
|
||||
});
|
||||
await db.createFollower(body.actor, body.id);
|
||||
}
|
||||
|
||||
const undo = async (body:any) => {
|
||||
switch (body.object.type) {
|
||||
case "Follow": await db.deleteFollower(body.actor); break
|
||||
}
|
||||
}
|
||||
|
||||
const accept = async (body:any) => {
|
||||
switch (body.object.type) {
|
||||
case "Follow": await db.acceptFollowing(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()
|
||||
|
||||
return Response.json({
|
||||
"@context": "https://www.w3.org/ns/activitystreams",
|
||||
id: `${ACTOR}/outbox`,
|
||||
type: "OrderedCollection",
|
||||
totalItems: posts.length,
|
||||
orderedItems: posts.map((post) => ({
|
||||
...post,
|
||||
actor: ACTOR
|
||||
})).sort( (a,b) => new Date(b.published).getTime() - new Date(a.published).getTime())
|
||||
}, { headers: { "Content-Type": "application/activity+json"} })
|
||||
}
|
||||
|
||||
const getFollowers = async (req:Request, account:String):Promise<Response> => {
|
||||
console.log("GetFollowers", account)
|
||||
if (ACCOUNT !== account) return new Response("", { status: 404 })
|
||||
|
||||
const url = new URL(req.url)
|
||||
const page = url.searchParams.get("page")
|
||||
const followers = await db.listFollowers()
|
||||
|
||||
if(!page) return Response.json({
|
||||
"@context": "https://www.w3.org/ns/activitystreams",
|
||||
id: `${ACTOR}/followers`,
|
||||
type: "OrderedCollection",
|
||||
totalItems: followers.length,
|
||||
first: `${ACTOR}/followers?page=1`,
|
||||
})
|
||||
else return Response.json({
|
||||
"@context": "https://www.w3.org/ns/activitystreams",
|
||||
id: `${ACTOR}/followers?page=${page}`,
|
||||
type: "OrderedCollectionPage",
|
||||
partOf: `${ACTOR}/followers`,
|
||||
totalItems: followers.length,
|
||||
orderedItems: followers.map(follower => follower.actor)
|
||||
})
|
||||
}
|
||||
|
||||
const getFollowing = async (req:Request, account:String):Promise<Response> => {
|
||||
console.log("GetFollowing", account)
|
||||
if (ACCOUNT !== account) return new Response("", { status: 404 })
|
||||
|
||||
const url = new URL(req.url)
|
||||
const page = url.searchParams.get("page")
|
||||
const following = await db.listFollowing()
|
||||
|
||||
if(!page) return Response.json({
|
||||
"@context": "https://www.w3.org/ns/activitystreams",
|
||||
id: `${ACTOR}/following`,
|
||||
type: "OrderedCollection",
|
||||
totalItems: following.length,
|
||||
first: `${ACTOR}/following?page=1`,
|
||||
})
|
||||
else return Response.json({
|
||||
"@context": "https://www.w3.org/ns/activitystreams",
|
||||
id: `${ACTOR}/following?page=${page}`,
|
||||
type: "OrderedCollectionPage",
|
||||
partOf: `${ACTOR}/following`,
|
||||
totalItems: following.length,
|
||||
orderedItems: following.map(follow => follow.actor)
|
||||
})
|
||||
}
|
||||
|
||||
const getActor = async (req:Request, account:string):Promise<Response> => {
|
||||
console.log("GetActor", account)
|
||||
if (ACCOUNT !== account) return new Response("", { status: 404 })
|
||||
|
||||
if(reqIsActivityPub(req)) return Response.json({
|
||||
"@context": [
|
||||
"https://www.w3.org/ns/activitystreams",
|
||||
"https://w3id.org/security/v1",
|
||||
],
|
||||
id: ACTOR,
|
||||
type: "Person",
|
||||
preferredUsername: ACCOUNT,
|
||||
url: ACTOR,
|
||||
manuallyApprovesFollowers: false,
|
||||
discoverable: true,
|
||||
published: "2023-09-14T00:00:00Z",
|
||||
inbox: `${ACTOR}/inbox`,
|
||||
outbox: `${ACTOR}/outbox`,
|
||||
followers: `${ACTOR}/followers`,
|
||||
following: `${ACTOR}/following`,
|
||||
publicKey: {
|
||||
id: `${ACTOR}#main-key`,
|
||||
owner: ACTOR,
|
||||
publicKeyPem: PUBLIC_KEY,
|
||||
},
|
||||
}, { headers: { "Content-Type": "application/activity+json"}})
|
||||
else return Response.json(await db.listPosts())
|
||||
}
|
||||
|
||||
const getPost = async (req:Request, account:string, id:string):Promise<Response> => {
|
||||
console.log("GetPost", account, id)
|
||||
if (ACCOUNT !== account) return new Response("", { status: 404 })
|
||||
|
||||
if(reqIsActivityPub(req)) return Response.json((await db.getActivity(id)).object, { headers: { "Content-Type": "application/activity+json"}})
|
||||
else return Response.json(await db.getPost(id))
|
||||
}
|
||||
|
||||
const getActivity = async (req:Request, account:string, id:string):Promise<Response> => {
|
||||
console.log("GetActivity", account, id)
|
||||
if (ACCOUNT !== account) return new Response("", { status: 404 })
|
||||
|
||||
return Response.json((await db.getActivity(id)), { headers: { "Content-Type": "application/activity+json"}})
|
||||
}
|
115
src/admin.ts
Normal file
115
src/admin.ts
Normal file
|
@ -0,0 +1,115 @@
|
|||
import { createFollowing, deleteFollowing, doActivity, getFollowing, listFollowers } from "./db"
|
||||
import { ACTOR, ADMIN_PASSWORD, ADMIN_USERNAME, BASE_URL } from "./env"
|
||||
import { send } from "./request"
|
||||
|
||||
export default (req: Request): Response | Promise<Response> | undefined => {
|
||||
const url = new URL(req.url)
|
||||
|
||||
if(!url.pathname.startsWith('/admin')) return undefined
|
||||
url.pathname = url.pathname.substring(6)
|
||||
|
||||
if(ADMIN_USERNAME && ADMIN_PASSWORD && !checkAuth(req.headers)) return new Response("", { status: 401 })
|
||||
|
||||
let match
|
||||
if(req.method === "GET" && (match = url.pathname.match(/^\/test\/?$/i))) return new Response("", { status: 204 })
|
||||
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])
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
const checkAuth = (headers: Headers): Boolean => {
|
||||
// check the basic auth header
|
||||
const auth = headers.get("Authorization")
|
||||
const split = auth?.split("")
|
||||
if(!split || split.length != 2 || split[0] !== "Basic") return false
|
||||
const decoded = atob(split[1])
|
||||
const [username, password] = decoded.split(":")
|
||||
return username === ADMIN_USERNAME && password === ADMIN_PASSWORD
|
||||
}
|
||||
|
||||
// create an activity
|
||||
const create = async (req:Request):Promise<Response> => {
|
||||
const body = await req.json()
|
||||
|
||||
// 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`,
|
||||
type: "Create",
|
||||
published: date.toISOString(),
|
||||
actor: ACTOR,
|
||||
to: ["https://www.w3.org/ns/activitystreams#Public"],
|
||||
cc: [`${ACTOR}/followers`],
|
||||
...body,
|
||||
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 })
|
||||
}
|
||||
|
||||
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, {
|
||||
"@context": "https://www.w3.org/ns/activitystreams",
|
||||
id,
|
||||
type: "Follow",
|
||||
actor: ACTOR,
|
||||
object: handle,
|
||||
});
|
||||
await createFollowing(handle, id)
|
||||
|
||||
return new Response("", { status: 204 })
|
||||
}
|
||||
|
||||
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, {
|
||||
"@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,
|
||||
},
|
||||
});
|
||||
|
||||
// delete the following reference from the database
|
||||
deleteFollowing(handle);
|
||||
|
||||
return new Response("", { status: 204 })
|
||||
}
|
94
src/db.ts
Normal file
94
src/db.ts
Normal file
|
@ -0,0 +1,94 @@
|
|||
import { ACTIVITY_PATH, ACTOR, BASE_URL, DATA_PATH, POSTS_PATH } from "./env";
|
||||
import path from "path"
|
||||
import { readdir } from "fs/promises"
|
||||
const matter = require('gray-matter')
|
||||
|
||||
export async function doActivity(activity:any, object_id:string|null|undefined) {
|
||||
if(activity.type === "Create" && activity.object) {
|
||||
if(!object_id) object_id = new Date(activity.object.published).getTime().toString(16)
|
||||
const file = Bun.file(path.join(POSTS_PATH, `${object_id}.md`))
|
||||
const { content, published, id, attributedTo } = activity.object
|
||||
//TODO: add appropriate content for different types (e.g. like-of, etc)
|
||||
await Bun.write(file, matter.stringify(content || "", { id, published, attributedTo }))
|
||||
const activityFile = Bun.file(path.join(ACTIVITY_PATH, `${object_id}.activity.json`))
|
||||
await Bun.write(activityFile, JSON.stringify(activity))
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPost(id:string) {
|
||||
const file = Bun.file(path.join(POSTS_PATH, `${id}.md`))
|
||||
const { data, content } = matter(await file.text())
|
||||
return {
|
||||
...data,
|
||||
content: content.trim()
|
||||
}
|
||||
}
|
||||
|
||||
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))))
|
||||
}
|
||||
|
||||
export async function getActivity(id:string) {
|
||||
const file = Bun.file(path.join(ACTIVITY_PATH, `${id}.activity.json`))
|
||||
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>
|
||||
if(!following_list.find(v => v.id === id || v.handle === handle)) following_list.push({id, handle, createdAt: new Date().toISOString()})
|
||||
await Bun.write(file, JSON.stringify(following_list))
|
||||
}
|
||||
|
||||
export async function deleteFollowing(handle:string) {
|
||||
const file = Bun.file(path.join(DATA_PATH, `following.json`))
|
||||
const following_list = await file.json() as Array<any>
|
||||
await Bun.write(file, JSON.stringify(following_list.filter(v => v.handle !== handle)))
|
||||
}
|
||||
|
||||
export async function getFollowing(handle:string) {
|
||||
const file = Bun.file(path.join(DATA_PATH, `following.json`))
|
||||
const following_list = await file.json() as Array<any>
|
||||
return following_list.find(v => v.handle === handle)
|
||||
}
|
||||
|
||||
export async function listFollowing() {
|
||||
const file = Bun.file(path.join(DATA_PATH, `following.json`))
|
||||
return await file.json() as Array<any>
|
||||
}
|
||||
|
||||
export async function acceptFollowing(handle:string) {
|
||||
const file = Bun.file(path.join(DATA_PATH, `following.json`))
|
||||
const following_list = await file.json() as Array<any>
|
||||
const following = following_list.find(v => v.handle === handle)
|
||||
if(following) following.accepted = new Date().toISOString()
|
||||
await Bun.write(file, JSON.stringify(following_list))
|
||||
}
|
||||
|
||||
export async function createFollower(actor:string, id:string) {
|
||||
const file = Bun.file(path.join(DATA_PATH, `followers.json`))
|
||||
const followers_list = await file.json() as Array<any>
|
||||
if(!followers_list.find(v => v.id === id || v.actor === actor)) followers_list.push({id, actor, createdAt: new Date().toISOString()})
|
||||
await Bun.write(file, JSON.stringify(followers_list))
|
||||
}
|
||||
|
||||
export async function deleteFollower(actor:string) {
|
||||
const file = Bun.file(path.join(DATA_PATH, `followers.json`))
|
||||
const followers_list = await file.json() as Array<any>
|
||||
await Bun.write(file, JSON.stringify(followers_list.filter(v => v.actor !== actor)))
|
||||
}
|
||||
|
||||
export async function getFollower(actor:string) {
|
||||
const file = Bun.file(path.join(DATA_PATH, `followers.json`))
|
||||
const followers_list = await file.json() as Array<any>
|
||||
return followers_list.find(v => v.actor === actor)
|
||||
}
|
||||
|
||||
export async function listFollowers() {
|
||||
const file = Bun.file(path.join(DATA_PATH, `followers.json`))
|
||||
return await file.json() as Array<any>
|
||||
}
|
38
src/env.ts
Normal file
38
src/env.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
import forge from "node-forge" // import crypto from "node:crypto"
|
||||
import path from "path"
|
||||
|
||||
// change "activitypub" to whatever you want your account name to be
|
||||
export const ACCOUNT = Bun.env.ACCOUNT || "activitypub"
|
||||
|
||||
// set up username and password for admin actions
|
||||
export const ADMIN_USERNAME = process.env.ADMIN_USERNAME || "";
|
||||
export const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || "";
|
||||
|
||||
// get the hostname (`PROJECT_DOMAIN` is set via glitch, but since we're using Bun now, this won't matter)
|
||||
export const HOSTNAME = /*(Bun.env.PROJECT_DOMAIN && `${Bun.env.PROJECT_DOMAIN}.glitch.me`) ||*/ Bun.env.HOSTNAME || "localhost"
|
||||
export const NODE_ENV = Bun.env.NODE_ENV || "development"
|
||||
export const PORT = Bun.env.PORT || "3000"
|
||||
|
||||
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
|
||||
const keypair =
|
||||
NODE_ENV === "development"
|
||||
? forge.pki.rsa.generateKeyPair({bits: 4096}) //crypto.generateKeyPairSync("rsa", { modulusLength: 4096 })
|
||||
: undefined
|
||||
|
||||
export const PUBLIC_KEY =
|
||||
process.env.PUBLIC_KEY ||
|
||||
(keypair && forge.pki.publicKeyToPem(keypair.publicKey)) || //keypair?.publicKey.export({ type: "spki", format: "pem" }) ||
|
||||
""
|
||||
export const PRIVATE_KEY =
|
||||
process.env.PRIVATE_KEY ||
|
||||
(keypair && forge.pki.privateKeyToPem(keypair.privateKey)) || //keypair?.privateKey.export({ type: "pkcs8", format: "pem" }) ||
|
||||
""
|
||||
|
||||
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")
|
33
src/index.ts
Normal file
33
src/index.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { ACCOUNT, ACTOR, HOSTNAME, PORT } from "./env";
|
||||
import admin from './admin'
|
||||
import activitypub from "./activitypub";
|
||||
|
||||
const server = Bun.serve({
|
||||
port: 3000,
|
||||
fetch(req): Response | Promise<Response> {
|
||||
const url = new URL(req.url)
|
||||
|
||||
console.log(`${new Date().toISOString()} ${req.method} ${req.url}`)
|
||||
|
||||
if(req.method === "GET" && url.pathname === "/.well-known/webfinger") {
|
||||
const resource = url.searchParams.get("resource")
|
||||
|
||||
if(resource !== `acct:${ACCOUNT}@${HOSTNAME}`) return new Response("", { status: 404 })
|
||||
|
||||
return Response.json({
|
||||
subject: `acct:${ACCOUNT}@${HOSTNAME}`,
|
||||
links: [
|
||||
{
|
||||
rel: "self",
|
||||
type: "application/activity+json",
|
||||
href: ACTOR,
|
||||
},
|
||||
],
|
||||
}, { headers: { "content-type": "application/activity+json" }})
|
||||
}
|
||||
|
||||
return admin(req) || activitypub(req) || new Response("How did we get here?", { status: 404 })
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`Listening on http://localhost:${server.port} ...`);
|
132
src/request.ts
Normal file
132
src/request.ts
Normal file
|
@ -0,0 +1,132 @@
|
|||
import forge from "node-forge" // import crypto from "node:crypto"
|
||||
import { PRIVATE_KEY } from "./env";
|
||||
|
||||
export function reqIsActivityPub(req:Request) {
|
||||
const contentType = req.headers.get("Accept")
|
||||
return contentType?.includes('application/activity+json')
|
||||
|| contentType?.includes('application/ld+json; profile="https://www.w3.org/ns/activitystreams"')
|
||||
|| contentType?.includes('application/ld+json')
|
||||
}
|
||||
|
||||
/** Fetches and returns an actor at a URL. */
|
||||
async function fetchActor(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 actor.`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Sends a signed message from the sender to the recipient.
|
||||
* @param sender The sender's actor URL.
|
||||
* @param recipient The recipient's actor URL.
|
||||
* @param message the body of the request to send.
|
||||
*/
|
||||
export async function send(sender:string, recipient:string, message:any) {
|
||||
const url = new URL(recipient)
|
||||
const actor = await fetchActor(recipient)
|
||||
const path = actor.inbox.replace("https://" + url.hostname, "")
|
||||
|
||||
const body = JSON.stringify(message)
|
||||
const digest = new Bun.CryptoHasher("sha256").update(body).digest("base64")
|
||||
const d = new Date();
|
||||
|
||||
const key = forge.pki.privateKeyFromPem(PRIVATE_KEY)
|
||||
|
||||
const data = [
|
||||
`(request-target): post ${path}`,
|
||||
`host: ${url.hostname}`,
|
||||
`date: ${d.toUTCString()}`,
|
||||
`digest: SHA-256=${digest}`,
|
||||
].join("\n")
|
||||
|
||||
|
||||
const signature = forge.util.encode64(key.sign(forge.md.sha256.create().update(data)))
|
||||
|
||||
const res = await fetch(actor.inbox, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
host: url.hostname,
|
||||
date: d.toUTCString(),
|
||||
digest: `SHA-256=${digest}`,
|
||||
"content-type": "application/json",
|
||||
signature: `keyId="${sender}#main-key",headers="(request-target) host date digest",signature="${signature}"`,
|
||||
accept: "application/json",
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
if (res.status < 200 || 299 < res.status) {
|
||||
throw new Error(res.statusText + ": " + (await res.text()));
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
/** Verifies that a request came from an actor.
|
||||
* Returns the actor's ID if the verification succeeds; throws otherwise.
|
||||
* @param req An Express request.
|
||||
* @returns The actor's ID. */
|
||||
export async function verify(req:Request, body:string) {
|
||||
// get headers included in signature
|
||||
const included:any = {}
|
||||
for (const header of req.headers.get("signature")?.split(",") ?? []) {
|
||||
const [key, value] = header.split("=")
|
||||
if (!key || !value) continue
|
||||
included[key] = value.replace(/^"|"$/g, "")
|
||||
}
|
||||
|
||||
/** the URL of the actor document containing the signature's public key */
|
||||
const keyId = included.keyId
|
||||
if (!keyId) throw new Error(`Missing "keyId" in signature header.`)
|
||||
|
||||
/** the signed request headers */
|
||||
const signedHeaders = included.headers
|
||||
if (!signedHeaders) throw new Error(`Missing "headers" in signature header.`)
|
||||
|
||||
/** the signature itself */
|
||||
const signature = Buffer.from(included.signature ?? "", "base64")
|
||||
if (!signature) throw new Error(`Missing "signature" in signature header.`)
|
||||
|
||||
// ensure that the digest header matches the digest of the body
|
||||
const digestHeader = req.headers.get("digest")
|
||||
if (digestHeader) {
|
||||
const digestBody = new Bun.CryptoHasher("sha256").update(body).digest("base64")
|
||||
if (digestHeader !== "SHA-256=" + digestBody) {
|
||||
throw new Error(`Incorrect digest header.`)
|
||||
}
|
||||
}
|
||||
|
||||
// get the actor's public key
|
||||
const actor = await fetchActor(keyId);
|
||||
if (!actor.publicKey) throw new Error("No public key found.")
|
||||
const key = forge.pki.publicKeyFromPem(actor.publicKey.publicKeyPem)
|
||||
|
||||
// reconstruct the signed header string
|
||||
const url = new URL(req.url)
|
||||
const comparison:string = signedHeaders
|
||||
.split(" ")
|
||||
.map((header:any) => {
|
||||
if (header === "(request-target)")
|
||||
return "(request-target): post " + url.pathname
|
||||
return `${header}: ${req.headers.get(header)}`
|
||||
})
|
||||
.join("\n")
|
||||
const data = Buffer.from(comparison);
|
||||
|
||||
// verify the signature against the headers using the actor's public key
|
||||
const verified = key.verify(forge.sha256.create().update(comparison).digest().bytes(), signature.toString('binary'))
|
||||
if (!verified) throw new Error("Invalid request signature.")
|
||||
|
||||
// ensure the request was made recently
|
||||
const now = new Date();
|
||||
const date = new Date(req.headers.get("date") ?? 0);
|
||||
if (now.getTime() - date.getTime() > 30_000)
|
||||
throw new Error("Request date too old.");
|
||||
|
||||
return actor.id;
|
||||
}
|
22
tsconfig.json
Normal file
22
tsconfig.json
Normal file
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext"],
|
||||
"module": "esnext",
|
||||
"target": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"moduleDetection": "force",
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"composite": true,
|
||||
"strict": true,
|
||||
"downlevelIteration": true,
|
||||
"skipLibCheck": true,
|
||||
"jsx": "preserve",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"allowJs": true,
|
||||
"types": [
|
||||
"bun-types" // add Bun global
|
||||
]
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue