Add auth token verification, minor fixes and docker
This commit is contained in:
parent
fd8c2073f0
commit
41c2e27f5e
16 changed files with 197 additions and 116 deletions
5
.dockerignore
Normal file
5
.dockerignore
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
README.md
|
||||||
|
db.sqlite*
|
||||||
|
_data
|
||||||
|
.vscode
|
||||||
|
node_modules
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -173,4 +173,5 @@ _content/_data/_inbox
|
||||||
_content/_data/_outbox
|
_content/_data/_outbox
|
||||||
_content/posts
|
_content/posts
|
||||||
_site
|
_site
|
||||||
|
_data
|
||||||
db.sqlite*
|
db.sqlite*
|
||||||
|
|
38
.vscode/tasks.json
vendored
Normal file
38
.vscode/tasks.json
vendored
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
{
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"type": "shell",
|
||||||
|
"command": "ngrok tunnel --label edge=edghts_2VNJvaPttrFlAPWxrGyVKu0s3ad http://localhost:3000",
|
||||||
|
"windows":{
|
||||||
|
"command": "C:\\Users\\death\\AppData\\Local\\Microsoft\\WinGet\\Links\\ngrok tunnel --label edge=edghts_2VNJvaPttrFlAPWxrGyVKu0s3ad http://localhost:3000"
|
||||||
|
},
|
||||||
|
"label": "ngrok tunnel",
|
||||||
|
"detail": "ngrok tunnel --label edge=edghts_2VNJvaPttrFlAPWxrGyVKu0s3ad http://localhost:3000",
|
||||||
|
"presentation": {
|
||||||
|
"reveal": "always",
|
||||||
|
"panel": "dedicated"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "shell",
|
||||||
|
"command": "bun run --watch src/index.ts",
|
||||||
|
"label": "bun run",
|
||||||
|
"detail": "bun run --watch src/index.ts",
|
||||||
|
"presentation": {
|
||||||
|
"reveal": "always",
|
||||||
|
"panel": "dedicated"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "shell",
|
||||||
|
"command": "docker compose up",
|
||||||
|
"label": "docker compose up",
|
||||||
|
"detail": "docker compose up",
|
||||||
|
"presentation": {
|
||||||
|
"reveal": "always",
|
||||||
|
"panel": "dedicated"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
1
CNAME
1
CNAME
|
@ -1 +0,0 @@
|
||||||
death.id.au
|
|
11
Dockerfile
Normal file
11
Dockerfile
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
FROM oven/bun:latest
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY ./package.json /app
|
||||||
|
COPY ./bun.lockb /app
|
||||||
|
RUN bun install
|
||||||
|
|
||||||
|
COPY . /app
|
||||||
|
|
||||||
|
CMD [ "bun", "run", "/app/src/index.ts"]
|
BIN
bun.lockb
BIN
bun.lockb
Binary file not shown.
19
docker-compose.yaml
Normal file
19
docker-compose.yaml
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
version: '3'
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
container_name: bun-activitypub
|
||||||
|
command: bun run --watch /app/src/index.ts
|
||||||
|
environment:
|
||||||
|
- ACCOUNT=death.au
|
||||||
|
- REAL_NAME=Gordon Pedersen
|
||||||
|
- HOSTNAME=death.id.au
|
||||||
|
- TOKEN_ENDPOINT=https://deathau-cellar-door.glitch.me/token
|
||||||
|
- WEB_SITE_HOSTNAME=www.death.id.au
|
||||||
|
- DATA_PATH=/data
|
||||||
|
ports:
|
||||||
|
- 3000:3000
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
- ./_data:/data
|
|
@ -3,12 +3,11 @@
|
||||||
"module": "index.ts",
|
"module": "index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "bun run --watch src/index.ts",
|
"start": "bun run --watch src/index.ts"
|
||||||
"ngrok": "ngrok tunnel --label edge=edghts_2VNJvaPttrFlAPWxrGyVKu0s3ad http://localhost:3000"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node-forge": "^1.3.5",
|
"@types/node-forge": "^1.3.5",
|
||||||
"bun-types": "^1.0.3"
|
"bun-types": "^1.0.5"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "^5.0.0"
|
"typescript": "^5.0.0"
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { verify } from "./request"
|
import { authorized, verify } from "./request"
|
||||||
import outbox from "./outbox"
|
import outbox from "./outbox"
|
||||||
import inbox from "./inbox"
|
import inbox from "./inbox"
|
||||||
import ACTOR from "../actor"
|
import ACTOR from "../actor"
|
||||||
|
@ -31,20 +31,12 @@ export function reqIsActivityPub(req:Request) {
|
||||||
return activityPubTypes.some(t => contentType?.includes(t))
|
return activityPubTypes.some(t => contentType?.includes(t))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function idsFromValue(value:any):string[] {
|
|
||||||
if (!value) return []
|
|
||||||
else if (typeof value === 'string') return [value]
|
|
||||||
else if (value.id) return [value.id]
|
|
||||||
else if (Array.isArray(value)) return value.map(v => idsFromValue(v)).flat(Infinity) as string[]
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
const postOutbox = async (req:Request):Promise<Response> => {
|
const postOutbox = async (req:Request):Promise<Response> => {
|
||||||
console.log("PostOutbox")
|
console.log("PostOutbox")
|
||||||
|
|
||||||
const bodyText = await req.text()
|
if(!authorized(req)) return new Response('', { status: 401 })
|
||||||
|
|
||||||
// TODO: verify calls to the outbox, whether that be by basic authentication, bearer, or otherwise.
|
const bodyText = await req.text()
|
||||||
|
|
||||||
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
|
||||||
|
|
43
src/admin.ts
43
src/admin.ts
|
@ -1,20 +1,19 @@
|
||||||
import { idsFromValue } from "./activitypub"
|
import { activityPubTypes } from "./env"
|
||||||
import { ADMIN_PASSWORD, ADMIN_USERNAME, activityPubTypes } from "./env"
|
|
||||||
import outbox from "./outbox"
|
import outbox from "./outbox"
|
||||||
import { fetchObject } from "./request"
|
import { authorized, fetchObject, idsFromValue } from "./request"
|
||||||
import ACTOR from "../actor"
|
import ACTOR from "../actor"
|
||||||
import ActivityPubDB from "./db"
|
import ActivityPubDB, { Activity } from "./db"
|
||||||
|
|
||||||
let db:ActivityPubDB
|
let db:ActivityPubDB
|
||||||
|
|
||||||
export default (req: Request, database: ActivityPubDB): Response | Promise<Response> | undefined => {
|
export default async (req: Request, database: ActivityPubDB): Promise<Response | Promise<Response> | undefined> => {
|
||||||
db = database
|
db = database
|
||||||
const url = new URL(req.url)
|
const url = new URL(req.url)
|
||||||
|
|
||||||
if(!url.pathname.startsWith('/admin')) return undefined
|
if(!url.pathname.startsWith('/admin')) return undefined
|
||||||
url.pathname = url.pathname.substring(6)
|
url.pathname = url.pathname.substring(6)
|
||||||
|
|
||||||
if(ADMIN_USERNAME && ADMIN_PASSWORD && !checkAuth(req.headers)) return new Response("", { status: 401 })
|
if(!(await authorized(req))) return new Response("", { status: 401 })
|
||||||
|
|
||||||
let match
|
let match
|
||||||
if(req.method === "GET" && (match = url.pathname.match(/^\/test\/?$/i))) return new Response("", { status: 204 })
|
if(req.method === "GET" && (match = url.pathname.match(/^\/test\/?$/i))) return new Response("", { status: 204 })
|
||||||
|
@ -31,30 +30,14 @@ export default (req: Request, database: ActivityPubDB): Response | Promise<Respo
|
||||||
else if(req.method == "POST" && (match = url.pathname.match(/^\/reply\/(.+)\/?$/i))) return create(req, match[1])
|
else if(req.method == "POST" && (match = url.pathname.match(/^\/reply\/(.+)\/?$/i))) return create(req, match[1])
|
||||||
else if(req.method == "DELETE" && (match = url.pathname.match(/^\/delete\/(.+)\/?$/i))) return deletePost(req, match[1])
|
else if(req.method == "DELETE" && (match = url.pathname.match(/^\/delete\/(.+)\/?$/i))) return deletePost(req, match[1])
|
||||||
|
|
||||||
console.log(`Couldn't match admin path ${req.method} "${url.pathname}"`)
|
console.info(`Couldn't match admin path ${req.method} "${url.pathname}"`)
|
||||||
|
|
||||||
return undefined
|
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// // rebuild the 11ty static pages
|
|
||||||
// export const rebuild = async(req:Request):Promise<Response> => {
|
|
||||||
// db.rebuild()
|
|
||||||
// return new Response("", { status: 201 })
|
|
||||||
// }
|
|
||||||
|
|
||||||
// create an activity
|
// create an activity
|
||||||
const create = async (req:Request, inReplyTo:string|null = null):Promise<Response> => {
|
const create = async (req:Request, inReplyTo:string|null = null):Promise<Response> => {
|
||||||
const body = await req.json()
|
const body = await req.json() as any
|
||||||
|
|
||||||
if(!inReplyTo && body.object.inReplyTo) inReplyTo = body.object.inReplyTo
|
if(!inReplyTo && body.object.inReplyTo) inReplyTo = body.object.inReplyTo
|
||||||
|
|
||||||
|
@ -100,7 +83,7 @@ const idFromHandle = async(handle:string) => {
|
||||||
if(!host) return new Response('account not url or name@domain.tld', { status: 400 })
|
if(!host) return new Response('account not url or name@domain.tld', { status: 400 })
|
||||||
|
|
||||||
const res = await fetch(`https://${host}/.well-known/webfinger/?resource=acct:${handle}`, { headers: { 'accept': 'application/jrd+json'}})
|
const res = await fetch(`https://${host}/.well-known/webfinger/?resource=acct:${handle}`, { headers: { 'accept': 'application/jrd+json'}})
|
||||||
const webfinger = await res.json()
|
const webfinger = await res.json() as any
|
||||||
if(!webfinger.links) return new Response("", { status: 404 })
|
if(!webfinger.links) return new Response("", { status: 404 })
|
||||||
|
|
||||||
const links:any[] = webfinger.links
|
const links:any[] = webfinger.links
|
||||||
|
@ -114,7 +97,7 @@ const idFromHandle = async(handle:string) => {
|
||||||
|
|
||||||
const follow = async (req:Request, handle:string):Promise<Response> => {
|
const follow = async (req:Request, handle:string):Promise<Response> => {
|
||||||
const url = await idFromHandle(handle)
|
const url = await idFromHandle(handle)
|
||||||
console.log(`Following ${url}`)
|
console.info(`Following ${url}`)
|
||||||
|
|
||||||
// send the follow request to the supplied actor
|
// send the follow request to the supplied actor
|
||||||
return await outbox({
|
return await outbox({
|
||||||
|
@ -143,7 +126,7 @@ const unfollow = async (req:Request, handle:string):Promise<Response> => {
|
||||||
}
|
}
|
||||||
|
|
||||||
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(object_url)).json()
|
const object = await (await fetchObject(object_url)).json() as any
|
||||||
|
|
||||||
return await outbox({
|
return await outbox({
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
"@context": "https://www.w3.org/ns/activitystreams",
|
||||||
|
@ -160,7 +143,7 @@ const unlike = async (req:Request, activity_id:string):Promise<Response> => {
|
||||||
const liked = db.listLiked()
|
const liked = db.listLiked()
|
||||||
let existing = liked?.find(o => o?.activity_id === activity_id)
|
let existing = liked?.find(o => o?.activity_id === activity_id)
|
||||||
if (!existing){
|
if (!existing){
|
||||||
const object = await (await fetchObject(activity_id)).json()
|
const object = await (await fetchObject(activity_id)).json() as any
|
||||||
idsFromValue(object).forEach(id => {
|
idsFromValue(object).forEach(id => {
|
||||||
const e = liked?.find(o => o.activity_id === id)
|
const e = liked?.find(o => o.activity_id === id)
|
||||||
if(e) existing = e
|
if(e) existing = e
|
||||||
|
@ -172,7 +155,7 @@ const unlike = async (req:Request, activity_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(object_url)).json()
|
const object = await (await fetchObject(object_url)).json() as any
|
||||||
|
|
||||||
return await outbox({
|
return await outbox({
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
"@context": "https://www.w3.org/ns/activitystreams",
|
||||||
|
@ -201,7 +184,7 @@ 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(object_url)).json()
|
const object = await (await fetchObject(object_url)).json() as any
|
||||||
|
|
||||||
return await outbox({
|
return await outbox({
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
"@context": "https://www.w3.org/ns/activitystreams",
|
||||||
|
|
27
src/db.ts
27
src/db.ts
|
@ -1,12 +1,15 @@
|
||||||
import { Database, Statement } from "bun:sqlite"
|
import { Database, Statement } from "bun:sqlite"
|
||||||
|
import { existsSync, mkdirSync } from "fs"
|
||||||
|
import { dirname } from "path"
|
||||||
|
import { DB_PATH } from "./env"
|
||||||
|
|
||||||
export interface ActivityObject {
|
export type ActivityObject = {
|
||||||
activity_id: string
|
activity_id: string
|
||||||
id: string
|
id: string
|
||||||
published: string
|
published: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Activity extends ActivityObject {
|
export type Activity = ActivityObject & {
|
||||||
type: string
|
type: string
|
||||||
actor: string
|
actor: string
|
||||||
published: string
|
published: string
|
||||||
|
@ -15,11 +18,11 @@ export interface Activity extends ActivityObject {
|
||||||
object?: any
|
object?: any
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Following extends ActivityObject {
|
export type Following = ActivityObject & {
|
||||||
accepted?: boolean
|
accepted?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Post extends ActivityObject {
|
export type Post = ActivityObject & {
|
||||||
attributedTo: string
|
attributedTo: string
|
||||||
type: string
|
type: string
|
||||||
content?: string
|
content?: string
|
||||||
|
@ -32,7 +35,9 @@ export default class ActivityPubDB {
|
||||||
// Cached Statements
|
// Cached Statements
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.db = new Database('./db.sqlite')
|
const dir = dirname(DB_PATH)
|
||||||
|
if(!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
||||||
|
this.db = new Database(DB_PATH, {create:true, readwrite:true})
|
||||||
this.migrate()
|
this.migrate()
|
||||||
this.cacheQueries()
|
this.cacheQueries()
|
||||||
}
|
}
|
||||||
|
@ -43,13 +48,11 @@ export default class ActivityPubDB {
|
||||||
|
|
||||||
migrate() {
|
migrate() {
|
||||||
let version = (this.db.query("PRAGMA user_version;").get() as {user_version:number})?.user_version
|
let version = (this.db.query("PRAGMA user_version;").get() as {user_version:number})?.user_version
|
||||||
console.log(`Hi from migrate! User version: ${version}`)
|
|
||||||
|
|
||||||
const statements:Statement[] = []
|
const statements:Statement[] = []
|
||||||
|
|
||||||
switch(version) {
|
switch(version) {
|
||||||
case 0:
|
case 0:
|
||||||
console.log("migrating db version 1")
|
|
||||||
this.db.exec("PRAGMA journal_mode = WAL;")
|
this.db.exec("PRAGMA journal_mode = WAL;")
|
||||||
|
|
||||||
// Create the inbox table
|
// Create the inbox table
|
||||||
|
@ -59,7 +62,7 @@ export default class ActivityPubDB {
|
||||||
[type] TEXT NOT NULL,
|
[type] TEXT NOT NULL,
|
||||||
[actor] TEXT NOT NULL,
|
[actor] TEXT NOT NULL,
|
||||||
[published] TEXT NOT NULL,
|
[published] TEXT NOT NULL,
|
||||||
[to] TEXT NOT NULL,
|
[to] TEXT,
|
||||||
[cc] TEXT,
|
[cc] TEXT,
|
||||||
[activity] TEXT NOT NULL
|
[activity] TEXT NOT NULL
|
||||||
)`))
|
)`))
|
||||||
|
@ -71,7 +74,7 @@ export default class ActivityPubDB {
|
||||||
[type] TEXT NOT NULL,
|
[type] TEXT NOT NULL,
|
||||||
[actor] TEXT NOT NULL,
|
[actor] TEXT NOT NULL,
|
||||||
[published] TEXT NOT NULL,
|
[published] TEXT NOT NULL,
|
||||||
[to] TEXT NOT NULL,
|
[to] TEXT,
|
||||||
[cc] TEXT,
|
[cc] TEXT,
|
||||||
[activity] TEXT NOT NULL
|
[activity] TEXT NOT NULL
|
||||||
)`))
|
)`))
|
||||||
|
@ -130,8 +133,7 @@ export default class ActivityPubDB {
|
||||||
}
|
}
|
||||||
|
|
||||||
const migration = this.db.transaction((statements:Statement[]) => {
|
const migration = this.db.transaction((statements:Statement[]) => {
|
||||||
statements.forEach(s => {
|
statements.forEach(s => {
|
||||||
console.log(s.toString());
|
|
||||||
s.run();
|
s.run();
|
||||||
s.finalize()
|
s.finalize()
|
||||||
})
|
})
|
||||||
|
@ -271,7 +273,7 @@ export default class ActivityPubDB {
|
||||||
$id: activity.id,
|
$id: activity.id,
|
||||||
$type: activity.type,
|
$type: activity.type,
|
||||||
$actor: activity.actor,
|
$actor: activity.actor,
|
||||||
$published: activity.published,
|
$published: activity.published || new Date().toISOString(),
|
||||||
$to: JSON.stringify(activity.to),
|
$to: JSON.stringify(activity.to),
|
||||||
$cc: JSON.stringify(activity.cc),
|
$cc: JSON.stringify(activity.cc),
|
||||||
$activity: JSON.stringify(activity)
|
$activity: JSON.stringify(activity)
|
||||||
|
@ -325,7 +327,6 @@ export default class ActivityPubDB {
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteFollower(id:string) {
|
deleteFollower(id:string) {
|
||||||
console.log("! DELETE Follower ", id)
|
|
||||||
return this.queries.deleteFollower?.run(id)
|
return this.queries.deleteFollower?.run(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
42
src/env.ts
42
src/env.ts
|
@ -1,53 +1,37 @@
|
||||||
import forge from "node-forge" // import crypto from "node:crypto"
|
import forge from "node-forge" // Bun does not implement the required node:crypto functions
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
|
||||||
// set up username and password for admin actions
|
// the endpoint for auth token validation
|
||||||
export const ADMIN_USERNAME = process.env.ADMIN_USERNAME || "";
|
export const TOKEN_ENDPOINT = process.env.TOKEN_ENDPOINT
|
||||||
export const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || "";
|
export const WEB_SITE_HOSTNAME = process.env.WEB_SITE_HOSTNAME
|
||||||
|
|
||||||
// get the hostname (`PROJECT_DOMAIN` is set via glitch, but since we're using Bun now, this won't matter)
|
export const HOSTNAME = process.env.HOSTNAME || "localhost"
|
||||||
export const HOSTNAME = /*(process.env.PROJECT_DOMAIN && `${process.env.PROJECT_DOMAIN}.glitch.me`) ||*/ process.env.HOSTNAME || "localhost"
|
|
||||||
export const NODE_ENV = process.env.NODE_ENV || "development"
|
|
||||||
export const PORT = process.env.PORT || "3000"
|
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 NODE_ENV = process.env.NODE_ENV || "production"
|
||||||
|
|
||||||
// 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"
|
||||||
? forge.pki.rsa.generateKeyPair({bits: 4096}) //crypto.generateKeyPairSync("rsa", { modulusLength: 4096 })
|
? forge.pki.rsa.generateKeyPair({bits: 4096})
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
export const PUBLIC_KEY =
|
export const PUBLIC_KEY =
|
||||||
process.env.PUBLIC_KEY ||
|
process.env.PUBLIC_KEY ||
|
||||||
(keypair && forge.pki.publicKeyToPem(keypair.publicKey)) || //keypair?.publicKey.export({ type: "spki", format: "pem" }) ||
|
(keypair && forge.pki.publicKeyToPem(keypair.publicKey)) ||
|
||||||
""
|
""
|
||||||
export const PRIVATE_KEY =
|
export const PRIVATE_KEY =
|
||||||
process.env.PRIVATE_KEY ||
|
process.env.PRIVATE_KEY ||
|
||||||
(keypair && forge.pki.privateKeyToPem(keypair.privateKey)) || //keypair?.privateKey.export({ type: "pkcs8", format: "pem" }) ||
|
(keypair && forge.pki.privateKeyToPem(keypair.privateKey)) ||
|
||||||
""
|
""
|
||||||
|
|
||||||
export const STATIC_PATH = path.join('.', '_site')
|
export const DATA_PATH = process.env.DATA_PATH || "/data"
|
||||||
export const CONTENT_PATH = path.join('.', '_content')
|
export const DB_PATH = process.env.DB_PATH || path.join(DATA_PATH, "db.sqlite")
|
||||||
export const POSTS_PATH = path.join(CONTENT_PATH, "posts")
|
|
||||||
export const DATA_PATH = path.join(CONTENT_PATH, "_data")
|
|
||||||
export const DB_PATH = path.join(DATA_PATH, "db.sqlite")
|
|
||||||
export const ACTIVITY_INBOX_PATH = path.join(DATA_PATH, "_inbox")
|
|
||||||
export const ACTIVITY_OUTBOX_PATH = path.join(DATA_PATH, "_outbox")
|
|
||||||
|
|
||||||
export const DEFAULT_DOCUMENTS = process.env.DEFAULT_DOCUMENTS || [
|
|
||||||
'index.html',
|
|
||||||
'index.shtml',
|
|
||||||
'index.htm',
|
|
||||||
'Index.html',
|
|
||||||
'Index.shtml',
|
|
||||||
'Index.htm',
|
|
||||||
'default.html',
|
|
||||||
'default.htm'
|
|
||||||
]
|
|
||||||
|
|
||||||
export const activityPubTypes = [
|
export const activityPubTypes = [
|
||||||
'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
|
'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
|
||||||
|
'application/ld+json; profile="http://www.w3.org/ns/activitystreams"',
|
||||||
'application/activity+json'
|
'application/activity+json'
|
||||||
]
|
]
|
||||||
export const contentTypeHeader = { 'Content-Type': activityPubTypes[0] }
|
export const contentTypeHeader = { 'Content-Type': activityPubTypes[0] }
|
|
@ -1,6 +1,5 @@
|
||||||
import { idsFromValue } from "./activitypub"
|
|
||||||
import outbox from "./outbox"
|
import outbox from "./outbox"
|
||||||
import { send } from "./request"
|
import { idsFromValue, send } from "./request"
|
||||||
import ACTOR from "../actor"
|
import ACTOR from "../actor"
|
||||||
import ActivityPubDB from "./db"
|
import ActivityPubDB from "./db"
|
||||||
|
|
||||||
|
@ -18,7 +17,7 @@ export default async function inbox(activity:any, database:ActivityPubDB) {
|
||||||
|
|
||||||
// save this activity to my inbox
|
// save this activity to my inbox
|
||||||
const activity_id = `${date.getTime().toString(32)}`
|
const activity_id = `${date.getTime().toString(32)}`
|
||||||
console.log(`New inbox activity ${activity_id} (${activity.type})`, activity)
|
console.info(`New inbox activity ${activity_id} (${activity.type})`, activity)
|
||||||
db.createInboxActivity(activity_id, activity)
|
db.createInboxActivity(activity_id, activity)
|
||||||
|
|
||||||
// TODO: process the activity and update local data
|
// TODO: process the activity and update local data
|
||||||
|
|
33
src/index.ts
33
src/index.ts
|
@ -3,12 +3,14 @@ import activitypub, { reqIsActivityPub } from "./activitypub"
|
||||||
import { fetchObject } from "./request"
|
import { fetchObject } from "./request"
|
||||||
import { handle, webfinger } from "../actor"
|
import { handle, webfinger } from "../actor"
|
||||||
import ActivityPubDB from './db'
|
import ActivityPubDB from './db'
|
||||||
|
import { HOSTNAME, PORT, WEB_SITE_HOSTNAME } from './env'
|
||||||
|
|
||||||
const db = new ActivityPubDB()
|
const db = new ActivityPubDB()
|
||||||
|
|
||||||
const server = Bun.serve({
|
const server = Bun.serve({
|
||||||
port: 3000,
|
port: PORT,
|
||||||
fetch(req: Request): Response | Promise<Response> {
|
// hostname: HOSTNAME,
|
||||||
|
async fetch(req: Request): Promise<Response> {
|
||||||
const url = new URL(req.url)
|
const url = new URL(req.url)
|
||||||
|
|
||||||
// log the incoming request info
|
// log the incoming request info
|
||||||
|
@ -41,9 +43,30 @@ const server = Bun.serve({
|
||||||
return fetchObject(object_url)
|
return fetchObject(object_url)
|
||||||
}
|
}
|
||||||
|
|
||||||
// admin and activitypub routes
|
// admin and activitypub routes, plus a fallback
|
||||||
return admin(req, db) || activitypub(req, db) || new Response("", { status: 404 })
|
return await admin(req, db) || activitypub(req, db) || fallback(req)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log(`Listening on http://localhost:${server.port} ...`)
|
const fallback = async (req: Request) => {
|
||||||
|
// if we haven't a fallback hostname defined, just return 404
|
||||||
|
if(!WEB_SITE_HOSTNAME) return new Response('', { status: 404 })
|
||||||
|
|
||||||
|
// swap the hostname in the current request
|
||||||
|
const url = new URL(req.url)
|
||||||
|
url.hostname = WEB_SITE_HOSTNAME
|
||||||
|
console.info(`➡️ Forwarding ${req.method} request to ${url}`)
|
||||||
|
// override the host header, because this can cause problems if wrong
|
||||||
|
if(req.headers.get("host")) req.headers.set('host', WEB_SITE_HOSTNAME)
|
||||||
|
|
||||||
|
// const body = ['GET', 'HEAD'].includes(req.method) ? undefined : await req.blob()
|
||||||
|
// // just use *all the things* from the request object (body is handled separately)
|
||||||
|
|
||||||
|
// TODO: possibly not forwarding the body correctly.
|
||||||
|
// My current website is static, but probably want to test and/or fix this in the future.
|
||||||
|
// @ts-ignore typescript doesn't think Request is compatible with RequestInit
|
||||||
|
const newRequest = new Request(url, req)
|
||||||
|
return await fetch(newRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info(`📡 Listening on http://${server.hostname}:${server.port} ...`)
|
|
@ -1,7 +1,6 @@
|
||||||
import { idsFromValue } from "./activitypub"
|
import { fetchObject, idsFromValue, send } from "./request"
|
||||||
import { fetchObject, send } from "./request"
|
|
||||||
import ACTOR from "../actor"
|
import ACTOR from "../actor"
|
||||||
import ActivityPubDB from "./db"
|
import ActivityPubDB, { Activity } from "./db"
|
||||||
|
|
||||||
let db:ActivityPubDB
|
let db:ActivityPubDB
|
||||||
|
|
||||||
|
@ -9,7 +8,7 @@ export default async function outbox(activity:any, database:ActivityPubDB):Promi
|
||||||
db = database
|
db = database
|
||||||
const date = new Date()
|
const date = new Date()
|
||||||
const activity_id = `${date.getTime().toString(32)}`
|
const activity_id = `${date.getTime().toString(32)}`
|
||||||
console.log('outbox', activity_id, activity)
|
console.info('outbox', activity_id, activity)
|
||||||
|
|
||||||
// https://www.w3.org/TR/activitypub/#object-without-create
|
// https://www.w3.org/TR/activitypub/#object-without-create
|
||||||
if(!activity.actor && !(activity.object || activity.target || activity.result || activity.origin || activity.instrument)) {
|
if(!activity.actor && !(activity.object || activity.target || activity.result || activity.origin || activity.instrument)) {
|
||||||
|
@ -46,17 +45,11 @@ export default async function outbox(activity:any, database:ActivityPubDB):Promi
|
||||||
delete activity.bcc
|
delete activity.bcc
|
||||||
|
|
||||||
// now that has been taken care of, it's time to update our local data, depending on the contents of the activity
|
// now that has been taken care of, it's time to update our local data, depending on the contents of the activity
|
||||||
switch(activity.type) {
|
// aka "side effects"
|
||||||
case "Accept": await accept(activity, activity_id); break;
|
// Note: I do this *before* adding the activity to the database so I can cache specific objects
|
||||||
case "Follow": await follow(activity, activity_id); break;
|
// for example, for likes / dislikes / shares, the object probably only exists as the url,
|
||||||
case "Like": await like(activity, activity_id); break;
|
// but I want to fetch it and store it so I can use that data later.
|
||||||
case "Dislike": await dislike(activity, activity_id); break;
|
await outboxSideEffect(activity_id, activity)
|
||||||
case "Annouce": await announce(activity, activity_id); break;
|
|
||||||
case "Create": await create(activity, activity_id); break;
|
|
||||||
case "Undo": await undo(activity); break;
|
|
||||||
case "Delete": await deletePost(activity); break;
|
|
||||||
// TODO: case "Anncounce": return await share(activity)
|
|
||||||
}
|
|
||||||
// save the activity data for the outbox
|
// save the activity data for the outbox
|
||||||
db.createOutboxActivity(activity_id, activity)
|
db.createOutboxActivity(activity_id, activity)
|
||||||
|
|
||||||
|
@ -70,6 +63,20 @@ export default async function outbox(activity:any, database:ActivityPubDB):Promi
|
||||||
return new Response("", { status: 201, headers: { location: activity.id } })
|
return new Response("", { status: 201, headers: { location: activity.id } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function outboxSideEffect(activity_id:string, activity:Activity) {
|
||||||
|
switch(activity.type) {
|
||||||
|
case "Accept": await accept(activity, activity_id); break;
|
||||||
|
case "Follow": await follow(activity, activity_id); break;
|
||||||
|
case "Like": await like(activity, activity_id); break;
|
||||||
|
case "Dislike": await dislike(activity, activity_id); break;
|
||||||
|
case "Annouce": await announce(activity, activity_id); break;
|
||||||
|
case "Create": await create(activity, activity_id); break;
|
||||||
|
case "Undo": await undo(activity); break;
|
||||||
|
case "Delete": await deletePost(activity); break;
|
||||||
|
// TODO: case "Anncounce": return await share(activity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function create(activity:any, activity_id:string) {
|
async function create(activity:any, activity_id:string) {
|
||||||
activity.object.id = activity.object.url = `${ACTOR.url}/posts/${activity_id}`
|
activity.object.id = activity.object.url = `${ACTOR.url}/posts/${activity_id}`
|
||||||
db.createPost(activity_id, activity.object)
|
db.createPost(activity_id, activity.object)
|
||||||
|
@ -143,7 +150,7 @@ async function undo(activity:any) {
|
||||||
if (!id) return true
|
if (!id) return true
|
||||||
const match = id.match(/\/([0-9a-z]+)\/?$/)
|
const match = id.match(/\/([0-9a-z]+)\/?$/)
|
||||||
const activity_id = match ? match[1] : id
|
const activity_id = match ? match[1] : id
|
||||||
console.log('undo', activity_id)
|
console.info('undo', activity_id)
|
||||||
try{
|
try{
|
||||||
const existing = db.getOutboxActivity(activity_id)
|
const existing = db.getOutboxActivity(activity_id)
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,15 @@
|
||||||
import forge from "node-forge" // import crypto from "node:crypto"
|
import forge from "node-forge" // import crypto from "node:crypto"
|
||||||
import { PRIVATE_KEY } from "./env";
|
import { PRIVATE_KEY, TOKEN_ENDPOINT } from "./env";
|
||||||
import ACTOR from "../actor"
|
import ACTOR from "../actor"
|
||||||
|
|
||||||
|
export function idsFromValue(value:any):string[] {
|
||||||
|
if (!value) return []
|
||||||
|
else if (typeof value === 'string') return [value]
|
||||||
|
else if (value.id) return [value.id]
|
||||||
|
else if (Array.isArray(value)) return value.map(v => idsFromValue(v)).flat(Infinity) as string[]
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
// this function adds / modifies the appropriate headers for signing a request, then calls fetch
|
// this function adds / modifies the appropriate headers for signing a request, then calls fetch
|
||||||
export function signedFetch(url: string | URL | Request, init?: FetchRequestInit): Promise<Response>
|
export function signedFetch(url: string | URL | Request, init?: FetchRequestInit): Promise<Response>
|
||||||
{
|
{
|
||||||
|
@ -52,7 +60,6 @@ async function fetchActor(url:string) {
|
||||||
|
|
||||||
/** Fetches and returns an object at a URL. */
|
/** Fetches and returns an object at a URL. */
|
||||||
export async function fetchObject(object_url:string) {
|
export async function fetchObject(object_url:string) {
|
||||||
console.log(`fetch ${object_url}`)
|
|
||||||
|
|
||||||
const res = await signedFetch(object_url);
|
const res = await signedFetch(object_url);
|
||||||
|
|
||||||
|
@ -69,9 +76,9 @@ export async function fetchObject(object_url:string) {
|
||||||
* @param message the body of the request to send.
|
* @param message the body of the request to send.
|
||||||
*/
|
*/
|
||||||
export async function send(recipient:string, message:any, from:string=ACTOR.id) {
|
export async function send(recipient:string, message:any, from:string=ACTOR.id) {
|
||||||
console.log(`Sending to ${recipient}`, message)
|
console.info(`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) as any
|
||||||
|
|
||||||
const body = JSON.stringify(message)
|
const body = JSON.stringify(message)
|
||||||
|
|
||||||
|
@ -122,7 +129,7 @@ export async function verify(req:Request, body:string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// get the actor's public key
|
// get the actor's public key
|
||||||
const actor = await fetchActor(keyId);
|
const actor = await fetchActor(keyId) as any
|
||||||
if (!actor.publicKey) throw new Error("No public key found.")
|
if (!actor.publicKey) throw new Error("No public key found.")
|
||||||
const key = forge.pki.publicKeyFromPem(actor.publicKey.publicKeyPem)
|
const key = forge.pki.publicKeyFromPem(actor.publicKey.publicKeyPem)
|
||||||
|
|
||||||
|
@ -149,4 +156,17 @@ export async function verify(req:Request, body:string) {
|
||||||
throw new Error("Request date too old.");
|
throw new Error("Request date too old.");
|
||||||
|
|
||||||
return actor.id;
|
return actor.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* verifies that the request is authorized
|
||||||
|
* (i.e. has a valid auth token) */
|
||||||
|
export async function authorized(req:Request) {
|
||||||
|
if(!TOKEN_ENDPOINT) return false
|
||||||
|
const response = await fetch(TOKEN_ENDPOINT, { headers: {"authorization": req.headers.get("authorization") || ''} })
|
||||||
|
if(!response.ok) return false
|
||||||
|
|
||||||
|
const json = await response.json() as any
|
||||||
|
if(new URL(json.me).hostname !== new URL(ACTOR.id).hostname) return false
|
||||||
|
|
||||||
|
return true
|
||||||
}
|
}
|
Loading…
Reference in a new issue