Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Implement Host-Meta Lookup for Social Inbox (#15) #22

Merged
merged 5 commits into from
Nov 16, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Lint
name: Lint & Test

on: [ push, pull_request ]

Expand All @@ -17,4 +17,5 @@ jobs:
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm run lint
- run: npm run lint
- run: npm run test
27 changes: 27 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"env-paths": "^3.0.0",
"express": "^4.17.1",
"fast-jwt": "^2.0.2",
"fast-xml-parser": "^4.3.2",
"fastify": "^4.10.2",
"fastify-metrics": "^10.0.0",
"fastify-plugin": "^4.4.0",
Expand Down
33 changes: 33 additions & 0 deletions src/server/apsystem.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,39 @@ test('getActor uses regular fetch when fromActor is not provided', async t => {
}, 'getActor should use regular fetch when fromActor is not provided')
})

// Test for successful Webfinger fetch with fallback to Host-Meta
test('mentionToActor fetches from Webfinger and falls back to Host-Meta on 404', async t => {
const mention = '@[email protected]'
const hostMetaXML = `<?xml version="1.0" encoding="UTF-8"?>
<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">
<Link rel="lrdd" template="https://domain.com/.well-known/webfinge/{uri}"/>
</XRD>`

// Create a single stub for the fetch method
const fetchStub = sinon.stub(aps, 'fetch')

// Configure responses for different URLs
fetchStub
.withArgs(sinon.match(/webfinger/))
.returns(Promise.resolve(new Response(null, { status: 404 }))) // 404 for Webfinger

fetchStub
.withArgs(sinon.match(/host-meta/))
.returns(Promise.resolve(new Response(hostMetaXML, { status: 200 }))) // Success for Host-Meta

fetchStub
.withArgs(sinon.match(/webfinge/))
.returns(Promise.resolve(
new Response(JSON.stringify({
subject: 'acct:[email protected]',
links: [{ rel: 'self', href: 'http://actor.url' }]
}), { status: 200 })
)) // Success for the actual webmention URL

const result = await aps.mentionToActor(mention)
t.is(result, 'http://actor.url', 'should fetch from Webfinger and fallback to Host-Meta on 404')
})

// After all tests, restore all sinon mocks
test.afterEach(() => {
// Restore all sinon mocks
Expand Down
69 changes: 48 additions & 21 deletions src/server/apsystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import signatureParser from 'activitypub-http-signatures'
import * as httpDigest from '@digitalbazaar/http-digest-header'
import { nanoid } from 'nanoid'
import HookSystem from './hooksystem.js'
import { XMLParser } from 'fast-xml-parser'

import type { FastifyRequest } from 'fastify'
import {
Expand All @@ -23,6 +24,18 @@ export interface BasicFetchParams {
body?: string
}

export interface HostMetaLink {
rel: string
template?: string
href?: string
}

export interface HostMeta {
XRD: {
Link: HostMetaLink[]
}
}

export type FetchLike = typeof globalThis.fetch

export default class ActivityPubSystem {
Expand Down Expand Up @@ -257,37 +270,51 @@ export default class ActivityPubSystem {

async mentionToActor (mention: string): Promise<string> {
const { username, domain } = parseMention(mention)
const acct = `acct:${username}@${domain}`
// TODO: dynamically determine the parameter name from the host-meta file
const mentionURL = `https://${domain}/.well-known/webfinger?resource=${acct}`
let webfingerURL = `https://${domain}/.well-known/webfinger?resource=acct:${username}@${domain}`

const response = await this.fetch(mentionURL)
let response = await this.fetch(webfingerURL)

// throq if response not ok or if inbox isn't a string
if (!response.ok) {
throw new Error(`Cannot fetch webmention data from ${mentionURL}: http status ${response.status} - ${await response.text()}`)
if (!response.ok && response.status === 404) {
const hostMetaURL = `https://${domain}/.well-known/host-meta`
const hostMetaResponse = await this.signedFetch(this.publicURL, {
url: hostMetaURL,
method: 'GET',
headers: {}
})

if (!hostMetaResponse.ok) {
throw new Error(`Cannot fetch host-meta data from ${hostMetaURL}: http status ${hostMetaResponse.status}`)
}

const hostMetaText = await hostMetaResponse.text()
const parser = new XMLParser()
const hostMeta: HostMeta = parser.parse(hostMetaText)

const webfingerTemplate = hostMeta.XRD.Link.find((link: HostMetaLink) => link.rel === 'lrdd' && link.template)?.template

if (typeof webfingerTemplate !== 'string' || webfingerTemplate.length === 0) {
throw new Error(`Webfinger template not found in host-meta data at ${hostMetaURL}`)
}

webfingerURL = webfingerTemplate.replace('{uri}', `acct:${username}@${domain}`)
response = await this.fetch(webfingerURL)
}

const { subject, links } = await response.json().catch((cause) => {
throw new Error(`Unable to parse webmention JSON at ${mentionURL}`, { cause })
})
if (subject !== acct) {
throw new Error(`Webmention endpoint returned invalid subject. Extepcted ${acct} at ${mentionURL}, got ${subject as string}`)
if (!response.ok) {
throw new Error(`Cannot fetch webmention data from ${webfingerURL}: http status ${response.status}`)
}

if (!Array.isArray(links)) {
throw new Error(`Expected links array in webmention endpoint for ${mentionURL}`)
const { subject, links } = await response.json()
if (subject !== `acct:${username}@${domain}`) {
throw new Error(`Webmention endpoint returned invalid subject for ${webfingerURL}`)
}

for (const { rel, type, href } of links) {
if (rel !== 'self') continue
// TODO: Throw an error?
if (typeof type !== 'string') continue
if (!type.includes('application/activity+json') && !type.includes('application/ld+json')) continue
return href
const actorLink = links.find((link: HostMetaLink) => link.rel === 'self')
if (typeof actorLink?.href !== 'string' || actorLink.href.trim().length === 0) {
throw new Error(`Unable to find actor link from webmention at ${webfingerURL}`)
}

throw new Error(`Unable to find ActivityPub link from webmentions at ${mentionURL}`)
return actorLink.href
}

async ingestActivity (fromActor: string, activity: APActivity): Promise<void> {
Expand Down