diff --git a/.vscode/settings.json b/.vscode/settings.json index 09042f2..fe42d53 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,7 @@ { "standard.enable": true, "standard.autoFixOnSave": true, + "standard.engine": "ts-standard", "[typescript]": { "editor.defaultFormatter": "" } diff --git a/@types/http-signed-fetch.d.ts b/@types/http-signed-fetch.d.ts index 17a4c95..37047cd 100644 --- a/@types/http-signed-fetch.d.ts +++ b/@types/http-signed-fetch.d.ts @@ -4,6 +4,5 @@ declare module 'http-signed-fetch' { export function generateKeypair (): { publicKeyPem: string privateKeyPem: string - publicKeyId: string } } diff --git a/package.json b/package.json index d56916b..590a6e9 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "generate-identity": "ts-node-esm src/scripts/generate-identity.ts", "import-blocklist": "ts-node-esm src/scripts/import-blocklist.ts", "import-admins": "ts-node-esm src/scripts/import-admins.ts", + "client-cli": "ts-node-esm src/client/cli.ts", "lint": "ts-standard --fix && tsc --noEmit", "dev": "ts-node-esm src/bin.ts run | pino-pretty -c -t", "start": "node dist/bin.js run | pino-pretty -c -t", diff --git a/src/client/index.test.ts b/src/client/index.test.ts index 41965a0..4bcc496 100644 --- a/src/client/index.test.ts +++ b/src/client/index.test.ts @@ -33,7 +33,7 @@ test('Local Server Communication', async t => { const client = new SocialInboxClient({ instance: `http://localhost:${port}`, account: 'testAccount', - keypair: generateKeypair(), + keypair: { ...generateKeypair(), publicKeyId: 'testAccount#main-key' }, fetch: globalThis.fetch }) diff --git a/src/client/index.ts b/src/client/index.ts index e1e4045..0477b77 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -9,10 +9,14 @@ export type SignedFetchLike = ( init?: RequestInit & { publicKeyId: string, keypair: KeyPair } ) => Promise +type Keypair = ReturnType & { + publicKeyId: string +} + export interface SocialInboxOptions { instance: string account: string - keypair: ReturnType + keypair: Keypair fetch?: SignedFetchLike } @@ -40,7 +44,7 @@ type VALID_TYPES = typeof TYPE_TEXT | typeof TYPE_JSON | typeof TYPE_LDJSON | un export class SocialInboxClient { instance: string account: string - keypair: ReturnType + keypair: Keypair fetch: SignedFetchLike constructor (options: SocialInboxOptions) { @@ -83,7 +87,7 @@ export class SocialInboxClient { // Actorinfo async setActorInfo (info: ActorInfo, actor: string = this.account): Promise { - await this.sendRequest(POST, `/${actor}/`, TYPE_JSON, info) + await this.sendRequest(POST, `/${actor}`, TYPE_JSON, info) } async getActorInfo (actor: string = this.account): Promise { diff --git a/src/schemas.ts b/src/schemas.ts index 58f12fd..a438efe 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -1,11 +1,22 @@ import { Type, Static } from '@sinclair/typebox' import { KeyPairSchema } from './keypair.js' +import { APActor } from 'activitypub-types' export const ActorInfoSchema = Type.Object({ // The actor for the domain inbox actorUrl: Type.String(), publicKeyId: Type.String(), - keypair: KeyPairSchema + keypair: KeyPairSchema, + announce: Type.Optional(Type.Boolean({ default: false })), + manuallyApprovesFollowers: Type.Optional(Type.Boolean({ default: false })) }) export type ActorInfo = Static + +export type APActorNonStandard = APActor & { + publicKey: { + id: string + owner: string + publicKeyPem: string + } +} diff --git a/src/server/announcements.test.ts b/src/server/announcements.test.ts new file mode 100644 index 0000000..140885f --- /dev/null +++ b/src/server/announcements.test.ts @@ -0,0 +1,48 @@ +import test from 'ava' +import sinon from 'sinon' +import ActivityPubSystem, { FetchLike } from './apsystem' +import Store from './store/index.js' +import { ModerationChecker } from './moderation.js' +import HookSystem from './hooksystem' +import { MemoryLevel } from 'memory-level' + +// Create some mock dependencies +const mockStore = new Store(new MemoryLevel()) + +const mockModCheck = new ModerationChecker(mockStore) +const mockFetch: FetchLike = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + return new Response(JSON.stringify({}), { status: 200 }) +} +const mockHooks = new HookSystem(mockStore, mockFetch) + +const aps = new ActivityPubSystem('http://localhost', mockStore, mockModCheck, mockHooks) + +test.beforeEach(async () => { + // Restore stubs before setting them up again + sinon.restore() + + sinon.stub(aps, 'mentionToActor').returns(Promise.resolve('http://actor.url')) + + await aps.announcements.init() +}) + +const keypair = { + publicKeyPem: 'mockPublicKey', + privateKeyPem: 'mockPrivateKey', + publicKeyId: 'mockPublicKeyId' +} + +test('actor gets announced on .announce', async t => { + const fakeActor = '@test@url' + const actorInfo = { + actorUrl: 'https://url/@test', + announce: true, + keypair, + publicKeyId: keypair.publicKeyId + } + await aps.announcements.announce(fakeActor) + await aps.store.forActor(fakeActor).setInfo(actorInfo) + + const outbox = await aps.announcements.getOutbox() + t.is(outbox.totalItems, 1, 'something is in outbox') +}) diff --git a/src/server/announcements.ts b/src/server/announcements.ts new file mode 100644 index 0000000..1dd0cdc --- /dev/null +++ b/src/server/announcements.ts @@ -0,0 +1,141 @@ +import { nanoid } from 'nanoid' +import ActivityPubSystem, { DEFAULT_PUBLIC_KEY_FIELD } from './apsystem' +import { generateKeypair } from 'http-signed-fetch' +import { APOrderedCollection } from 'activitypub-types' +import { ActorStore } from './store/ActorStore' +import { APActorNonStandard } from '../schemas' + +export class Announcements { + apsystem: ActivityPubSystem + publicURL: string + + constructor (apsystem: ActivityPubSystem, publicURL: string) { + this.apsystem = apsystem + this.publicURL = publicURL + } + + get actorUrl (): string { + return `${this.publicURL}/v1/${this.mention}/` + } + + get outboxUrl (): string { + return `${this.actorUrl}outbox` + } + + get mention (): string { + const url = new URL(this.publicURL) + return `@announcements@${url.hostname}` + } + + get store (): ActorStore { + return this.apsystem.store.forActor(this.mention) + } + + async getActor (): Promise { + const actorInfo = await this.store.getInfo() + return { + '@context': [ + // TODO: I copied this from Mastodon, is this correct? + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1' + ], + // https://www.w3.org/TR/activitystreams-vocabulary/#actor-types + id: this.actorUrl, + type: 'Person', + name: 'Announcements', + summary: `Announcements for ${new URL(this.actorUrl).hostname}`, + preferredUsername: 'announcements', + following: `${actorInfo.actorUrl}following`, + followers: `${actorInfo.actorUrl}followers`, + inbox: `${actorInfo.actorUrl}inbox`, + outbox: `${actorInfo.actorUrl}outbox`, + publicKey: { + id: `${actorInfo.actorUrl}#main-key`, + owner: actorInfo.actorUrl, + publicKeyPem: actorInfo.keypair.publicKeyPem + } + } + } + + async init (): Promise { + const actorUrl = this.actorUrl + const actor = this.store + + try { + const prev = await actor.getInfo() + if (prev.actorUrl !== actorUrl) { + await actor.setInfo({ + ...prev, + actorUrl + }) + } + } catch { + const { privateKeyPem, publicKeyPem } = generateKeypair() + await actor.setInfo({ + actorUrl, + publicKeyId: `${actorUrl}#${DEFAULT_PUBLIC_KEY_FIELD}`, + keypair: { + privateKeyPem, + publicKeyPem + }, + announce: false, + manuallyApprovesFollowers: false + }) + } + } + + async announce (actor: string): Promise { + const actorUrl = await this.apsystem.mentionToActor(actor) + const published = new Date().toUTCString() + const to = ['https://www.w3.org/ns/activitystreams#Public'] + const cc = [`${this.actorUrl}followers`, actorUrl] + + const mentionText = `${actor}` + + const note = { + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'Note', + id: `${this.outboxUrl}/${nanoid()}`, + attributedTo: this.actorUrl, + published, + to, + cc, + // TODO: add a template in config + content: `A wild ${mentionText} appears!`, + tag: [{ type: 'Mention', href: actorUrl, name: actor }] + } + const activity = { + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'Create', + id: `${this.outboxUrl}/${nanoid()}`, + actor: this.actorUrl, + published, + to, + cc, + object: note + } + await this.store.outbox.add(activity) + await this.store.outbox.add(note) + await this.apsystem.notifyFollowers(this.mention, activity) + } + + async getOutbox (): Promise { + const actor = this.store + const activities = await actor.outbox.list() + const orderedItems = activities + .filter(a => a.type !== 'Note') + // XXX: maybe `new Date()` doesn't correctly parse possible dates? + .map(a => ({ ...a, published: typeof a.published === 'string' ? new Date(a.published) : a.published })) + .sort((a, b) => +(b.published ?? 0) - +(a.published ?? 0)) + .map(a => a.id) + .filter((id): id is string => id !== undefined) + + return { + '@context': 'https://www.w3.org/ns/activitystreams', + id: `${this.actorUrl}outbox`, + type: 'OrderedCollection', + totalItems: orderedItems.length, + orderedItems + } + } +} diff --git a/src/server/api/announcements.ts b/src/server/api/announcements.ts new file mode 100644 index 0000000..4391620 --- /dev/null +++ b/src/server/api/announcements.ts @@ -0,0 +1,69 @@ +import type { APIConfig, FastifyTypebox } from '.' +import Store from '../store' +import type ActivityPubSystem from '../apsystem' +import type { APActorNonStandard } from '../../schemas' +import { Type } from '@sinclair/typebox' + +export const announcementsRoutes = (cfg: APIConfig, store: Store, apsystem: ActivityPubSystem) => async (server: FastifyTypebox): Promise => { + server.get<{ + Reply: APActorNonStandard + }>(`/${apsystem.announcements.mention}/`, { + schema: { + params: {}, + // XXX: even with Type.Any(), the endpoint returns `{}` :/ + // response: { + // // TODO: typebox APActor + // 200: Type.Any() + // }, + description: 'Announcements ActivityPub actor', + tags: ['ActivityPub'] + } + }, async (request, reply) => { + const actor = await apsystem.announcements.getActor() + + return await reply.send(actor) + }) + + server.get<{ + // TODO: typebox APOrderedCollection + Reply: any + }>(`/${apsystem.announcements.mention}/outbox`, { + schema: { + params: {}, + // XXX: even with Type.Any(), the endpoint returns `{}` :/ + // response: { + // // TODO: typebox APOrderedCollection + // 200: Type.Any() + // }, + description: 'Announcements ActivityPub outbox', + tags: ['ActivityPub'] + } + }, async (request, reply) => { + return await reply.send(await apsystem.announcements.getOutbox()) + }) + + server.get<{ + Params: { + id: string + } + // TODO: typebox APOrderedCollection + Reply: any + }>(`/${apsystem.announcements.mention}/outbox/:id`, { + schema: { + params: Type.Object({ + id: Type.String() + }), + // XXX: even with Type.Any(), the endpoint returns `{}` :/ + // response: { + // // TODO: typebox APOrderedCollection + // 200: Type.Any() + // }, + description: 'Announcements ActivityPub get activity', + tags: ['ActivityPub'] + } + }, async (request, reply) => { + const actorUrl = apsystem.announcements.actorUrl + const activity = await apsystem.announcements.store.outbox.get(`${actorUrl}outbox/${request.params.id}`) + return await reply.send(activity) + }) +} diff --git a/src/server/api/creation.ts b/src/server/api/creation.ts index d216ce1..12cedb5 100644 --- a/src/server/api/creation.ts +++ b/src/server/api/creation.ts @@ -33,8 +33,22 @@ export const creationRoutes = (cfg: APIConfig, store: Store, apsystem: ActivityP return await reply.code(403).send('Not Allowed') } + let existedAlready = false + try { + const existingActor = await apsystem.store.forActor(actor).getInfo() + if (existingActor !== undefined) existedAlready = true + } catch (err) { + if (!(err as { notFound: boolean }).notFound) { + throw err + } + } + const info = request.body await store.forActor(actor).setInfo(info) + + const shouldAnnounce = !existedAlready && info.announce === true + if (shouldAnnounce) await apsystem.announcements.announce(actor) + return await reply.send(info) }) diff --git a/src/server/api/index.ts b/src/server/api/index.ts index c908491..839cba0 100644 --- a/src/server/api/index.ts +++ b/src/server/api/index.ts @@ -27,6 +27,8 @@ import { blockAllowListRoutes } from './blockallowlist.js' import { adminRoutes } from './admins.js' import { followerRoutes } from './followers.js' import { hookRoutes } from './hooks.js' +import { announcementsRoutes } from './announcements.js' +import { wellKnownRoutes } from './wellKnown.js' export const paths = envPaths('distributed-press') @@ -94,7 +96,10 @@ async function apiBuilder (cfg: APIConfig): Promise { return 'ok\n' }) + await apsystem.announcements.init() + await server.register(v1Routes(cfg, store, apsystem, hookSystem), { prefix: '/v1' }) + await server.register(wellKnownRoutes(cfg, store, apsystem)) await server.ready() @@ -123,6 +128,7 @@ const v1Routes = (cfg: APIConfig, store: Store, apsystem: ActivityPubSystem, hoo }) } + await server.register(announcementsRoutes(cfg, store, apsystem)) await server.register(creationRoutes(cfg, store, apsystem)) await server.register(inboxRoutes(cfg, store, apsystem)) await server.register(outboxRoutes(cfg, store, apsystem)) diff --git a/src/server/api/wellKnown.ts b/src/server/api/wellKnown.ts new file mode 100644 index 0000000..1e39653 --- /dev/null +++ b/src/server/api/wellKnown.ts @@ -0,0 +1,31 @@ +import { APIConfig, FastifyTypebox } from '.' +import ActivityPubSystem from '../apsystem' +import Store from '../store' + +export const wellKnownRoutes = (cfg: APIConfig, store: Store, apsystem: ActivityPubSystem) => async (server: FastifyTypebox): Promise => { + server.get<{ + Reply: any + }>('/.well-known/webfinger', { + schema: { + description: 'ActivityPub WebFinger', + tags: ['ActivityPub'] + } + }, async (request, reply) => { + // https://docs.joinmastodon.org/spec/webfinger/ + return await reply + .headers({ + 'Content-Type': 'application/jrd+json' + }) + .send({ + subject: `acct:${apsystem.announcements.mention.slice(1)}`, + aliases: [apsystem.announcements.actorUrl], + links: [ + { + rel: 'self', + type: 'application/activity+json', + href: apsystem.announcements.actorUrl + } + ] + }) + }) +} diff --git a/src/server/apsystem.ts b/src/server/apsystem.ts index 51f476d..78f8f47 100644 --- a/src/server/apsystem.ts +++ b/src/server/apsystem.ts @@ -15,6 +15,7 @@ import { import type Store from './store/index.js' import { makeSigner } from '../keypair.js' +import { Announcements } from './announcements.js' export const DEFAULT_PUBLIC_KEY_FIELD = 'publicKey' @@ -45,6 +46,7 @@ export default class ActivityPubSystem { modCheck: ModerationChecker fetch: FetchLike hookSystem: HookSystem + announcements: Announcements constructor ( publicURL: string, @@ -58,6 +60,7 @@ export default class ActivityPubSystem { this.modCheck = modCheck this.fetch = fetch this.hookSystem = hookSystem + this.announcements = new Announcements(this, publicURL) } makeURL (path: string): string { @@ -339,9 +342,15 @@ export default class ActivityPubSystem { const actorStore = this.store.forActor(fromActor) + const { manuallyApprovesFollowers } = await actorStore.getInfo() + + const autoApproveFollow = manuallyApprovesFollowers !== undefined && manuallyApprovesFollowers + await actorStore.inbox.add(activity) - if (activityType === 'Undo') { + if (activityType === 'Follow' && autoApproveFollow) { + await this.approveActivity(fromActor, activityId) + } else if (activityType === 'Undo') { await this.performUndo(fromActor, activity) } else if (moderationState === BLOCKED) { // TODO: Notify of blocks? diff --git a/src/server/store/ActorStore.ts b/src/server/store/ActorStore.ts index 4c6520b..39d2e3b 100644 --- a/src/server/store/ActorStore.ts +++ b/src/server/store/ActorStore.ts @@ -48,6 +48,6 @@ export class ActorStore { } async delete (): Promise { - // TODO: delete all keys within the db + await this.db.clear() } }