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