diff --git a/src/app.ts b/src/app.ts index af956564..dadfebfc 100644 --- a/src/app.ts +++ b/src/app.ts @@ -2,6 +2,7 @@ import { type Context, cors, type Handler, Hono, type HonoEnv, logger, type Midd import { type Event } from '@/event.ts'; import '@/loopback.ts'; +import { actorController } from './controllers/activitypub/actor.ts'; import { accountController, accountLookupController, @@ -67,6 +68,8 @@ app.get('/.well-known/host-meta', hostMetaController); app.get('/.well-known/nodeinfo', nodeInfoController); app.get('/.well-known/nostr.json', nostrController); +app.get('/users/:username', actorController); + app.get('/nodeinfo/:version', nodeInfoSchemaController); app.get('/api/v1/instance', instanceController); diff --git a/src/config.ts b/src/config.ts index ac6a2967..69d00573 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,7 +1,32 @@ +import { nip19, secp } from '@/deps.ts'; + /** Application-wide configuration. */ const Conf = { get nsec() { - return Deno.env.get('DITTO_NSEC'); + const value = Deno.env.get('DITTO_NSEC'); + if (!value) { + throw new Error('Missing DITTO_NSEC'); + } + if (!value.startsWith('nsec1')) { + throw new Error('Invalid DITTO_NSEC'); + } + return value as `nsec1${string}`; + }, + get seckey() { + const result = nip19.decode(Conf.nsec); + if (result.type !== 'nsec') { + throw new Error('Invalid DITTO_NSEC'); + } + return result.data; + }, + get cryptoKey() { + return crypto.subtle.importKey( + 'raw', + secp.utils.hexToBytes(Conf.seckey), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign', 'verify'], + ); }, get relay() { const value = Deno.env.get('DITTO_RELAY'); diff --git a/src/controllers/activitypub/actor.ts b/src/controllers/activitypub/actor.ts new file mode 100644 index 00000000..8d09255e --- /dev/null +++ b/src/controllers/activitypub/actor.ts @@ -0,0 +1,25 @@ +import { getAuthor } from '@/client.ts'; +import { db } from '@/db.ts'; +import { toActor } from '@/transformers/nostr-to-activitypub.ts'; +import { activityJson } from '@/utils.ts'; + +import type { AppContext, AppController } from '@/app.ts'; + +const actorController: AppController = async (c) => { + const username = c.req.param('username'); + const user = await db.users.findFirst({ where: { username } }); + + const event = await getAuthor(user.pubkey); + if (!event) return notFound(c); + + const actor = await toActor(event); + if (!actor) return notFound(c); + + return activityJson(c, actor); +}; + +function notFound(c: AppContext) { + return c.json({ error: 'Not found' }, 404); +} + +export { actorController }; diff --git a/src/controllers/api/accounts.ts b/src/controllers/api/accounts.ts index b45e0278..83816077 100644 --- a/src/controllers/api/accounts.ts +++ b/src/controllers/api/accounts.ts @@ -3,7 +3,7 @@ import { type Filter, findReplyTag, z } from '@/deps.ts'; import { getAuthor, getFilter, getFollows, publish } from '@/client.ts'; import { parseMetaContent } from '@/schema.ts'; import { signEvent } from '@/sign.ts'; -import { toAccount, toStatus } from '@/transmute.ts'; +import { toAccount, toStatus } from '@/transformers/nostr-to-mastoapi.ts'; import { buildLinkHeader, eventDateComparator, lookupAccount, nostrNow, paginationSchema, parseBody } from '@/utils.ts'; const createAccountController: AppController = (c) => { diff --git a/src/controllers/api/search.ts b/src/controllers/api/search.ts index d6988ea1..45193f3b 100644 --- a/src/controllers/api/search.ts +++ b/src/controllers/api/search.ts @@ -1,6 +1,6 @@ import { AppController } from '@/app.ts'; -import { lookupAccount } from '../../utils.ts'; -import { toAccount } from '../../transmute.ts'; +import { lookupAccount } from '@/utils.ts'; +import { toAccount } from '@/transformers/nostr-to-mastoapi.ts'; const searchController: AppController = async (c) => { const q = c.req.query('q'); diff --git a/src/controllers/api/statuses.ts b/src/controllers/api/statuses.ts index abbddb5b..88c3ab98 100644 --- a/src/controllers/api/statuses.ts +++ b/src/controllers/api/statuses.ts @@ -3,7 +3,7 @@ import { getAncestors, getDescendants, getEvent, publish } from '@/client.ts'; import { ISO6391, Kind, z } from '@/deps.ts'; import { type Event } from '@/event.ts'; import { signEvent } from '@/sign.ts'; -import { toStatus } from '@/transmute.ts'; +import { toStatus } from '@/transformers/nostr-to-mastoapi.ts'; import { nostrNow, parseBody } from '@/utils.ts'; const createStatusSchema = z.object({ diff --git a/src/controllers/api/timelines.ts b/src/controllers/api/timelines.ts index f7246624..260ee598 100644 --- a/src/controllers/api/timelines.ts +++ b/src/controllers/api/timelines.ts @@ -1,5 +1,5 @@ import { getFeed, getFollows, getPublicFeed } from '@/client.ts'; -import { toStatus } from '@/transmute.ts'; +import { toStatus } from '@/transformers/nostr-to-mastoapi.ts'; import { buildLinkHeader, paginationSchema } from '@/utils.ts'; import type { AppController } from '@/app.ts'; diff --git a/src/deps.ts b/src/deps.ts index 1f0bdfed..d3091e11 100644 --- a/src/deps.ts +++ b/src/deps.ts @@ -21,7 +21,7 @@ export { nip19, nip21, verifySignature, -} from 'npm:nostr-tools@^1.11.2'; +} from 'npm:nostr-tools@^1.12.1'; export { findReplyTag } from 'https://gitlab.com/soapbox-pub/mostr/-/raw/c67064aee5ade5e01597c6d23e22e53c628ef0e2/src/nostr/tags.ts'; export { parseFormData } from 'npm:formdata-helper@^0.3.0'; // @deno-types="npm:@types/lodash@4.14.194" @@ -39,5 +39,15 @@ export { default as sanitizeHtml } from 'npm:sanitize-html@^2.10.0'; export { default as ISO6391 } from 'npm:iso-639-1@2.1.15'; export { Dongoose } from 'https://raw.githubusercontent.com/alexgleason/dongoose/68b7ad9dd7b6ec0615e246a9f1603123c1709793/mod.ts'; export { createPentagon } from 'https://deno.land/x/pentagon@v0.1.1/mod.ts'; +export { + type ParsedSignature, + pemToPublicKey, + publicKeyToPem, + signRequest, + verifyRequest, +} from 'https://gitlab.com/soapbox-pub/fedisign/-/raw/v0.2.1/mod.ts'; +export { generateSeededRsa } from 'https://gitlab.com/soapbox-pub/seeded-rsa/-/raw/v1.0.0/mod.ts'; +export * as secp from 'npm:@noble/secp256k1@^1.7.1'; +export { LRUCache } from 'npm:lru-cache@^10.0.0'; export { DB as Sqlite } from 'https://deno.land/x/sqlite@v3.7.0/mod.ts'; export { serve } from 'https://deno.land/std@0.177.0/http/server.ts'; diff --git a/src/schemas/activitypub.ts b/src/schemas/activitypub.ts new file mode 100644 index 00000000..04fac39b --- /dev/null +++ b/src/schemas/activitypub.ts @@ -0,0 +1,323 @@ +import { z } from '@/deps.ts'; + +const apId = z.string().url(); +const recipients = z.array(z.string()).catch([]); +const published = () => z.string().datetime().catch(new Date().toISOString()); + +/** Validates individual items in an array, dropping any that aren't valid. */ +function filteredArray(schema: T) { + return z.any().array() + .transform((arr) => ( + arr.map((item) => { + const parsed = schema.safeParse(item); + return parsed.success ? parsed.data : undefined; + }).filter((item): item is z.infer => Boolean(item)) + )); +} + +const imageSchema = z.object({ + type: z.literal('Image').catch('Image'), + url: z.string().url(), +}); + +const attachmentSchema = z.object({ + type: z.literal('Document').catch('Document'), + mediaType: z.string().optional().catch(undefined), + url: z.string().url(), +}); + +const mentionSchema = z.object({ + type: z.literal('Mention'), + href: z.string().url(), + name: z.string().optional().catch(undefined), +}); + +const hashtagSchema = z.object({ + type: z.literal('Hashtag'), + href: z.string().url(), + name: z.string(), +}); + +const emojiSchema = z.object({ + type: z.literal('Emoji'), + icon: imageSchema, + name: z.string(), +}); + +const tagSchema = z.discriminatedUnion('type', [ + mentionSchema, + hashtagSchema, + emojiSchema, +]); + +const propertyValueSchema = z.object({ + type: z.literal('PropertyValue'), + name: z.string(), + value: z.string(), + verified_at: z.string().nullish(), +}); + +/** https://codeberg.org/fediverse/fep/src/branch/main/feps/fep-fffd.md */ +const proxySchema = z.object({ + protocol: z.string().url(), + proxied: z.string(), + authoritative: z.boolean().optional().catch(undefined), +}); + +const personSchema = z.object({ + type: z.literal('Person'), + id: apId, + icon: imageSchema.optional().catch(undefined), + image: imageSchema.optional().catch(undefined), + name: z.string().catch(''), + preferredUsername: z.string(), + inbox: apId, + followers: apId.optional().catch(undefined), + following: apId.optional().catch(undefined), + outbox: apId.optional().catch(undefined), + summary: z.string().catch(''), + attachment: filteredArray(propertyValueSchema).catch([]), + tag: filteredArray(emojiSchema).catch([]), + endpoints: z.object({ + sharedInbox: apId.optional(), + }).optional().catch({}), + publicKey: z.object({ + id: apId, + owner: apId, + publicKeyPem: z.string(), + }).optional().catch(undefined), + proxyOf: z.array(proxySchema).optional().catch(undefined), +}); + +const applicationSchema = personSchema.merge(z.object({ type: z.literal('Application') })); +const groupSchema = personSchema.merge(z.object({ type: z.literal('Group') })); +const organizationSchema = personSchema.merge(z.object({ type: z.literal('Organization') })); +const serviceSchema = personSchema.merge(z.object({ type: z.literal('Service') })); + +const actorSchema = z.discriminatedUnion('type', [ + personSchema, + applicationSchema, + groupSchema, + organizationSchema, + serviceSchema, +]); + +const noteSchema = z.object({ + type: z.literal('Note'), + id: apId, + to: recipients, + cc: recipients, + content: z.string(), + attachment: z.array(attachmentSchema).optional().catch(undefined), + tag: filteredArray(tagSchema).catch([]), + inReplyTo: apId.optional().catch(undefined), + attributedTo: apId, + published: published(), + sensitive: z.boolean().optional().catch(undefined), + summary: z.string().nullish().catch(undefined), + quoteUrl: apId.optional().catch(undefined), + source: z.object({ + content: z.string(), + mediaType: z.literal('text/markdown'), + }).optional().catch(undefined), + proxyOf: z.array(proxySchema).optional().catch(undefined), +}); + +const flexibleNoteSchema = noteSchema.extend({ + quoteURL: apId.optional().catch(undefined), + quoteUri: apId.optional().catch(undefined), + _misskey_quote: apId.optional().catch(undefined), +}).transform((note) => { + const { quoteUrl, quoteUri, quoteURL, _misskey_quote, ...rest } = note; + return { + quoteUrl: quoteUrl || quoteUri || quoteURL || _misskey_quote, + ...rest, + }; +}); + +// https://github.com/colinhacks/zod/discussions/2100#discussioncomment-5109781 +const objectSchema = z.union([ + flexibleNoteSchema, + personSchema, + applicationSchema, + groupSchema, + organizationSchema, + serviceSchema, +]).pipe( + z.discriminatedUnion('type', [ + noteSchema, + personSchema, + applicationSchema, + groupSchema, + organizationSchema, + serviceSchema, + ]), +); + +const createNoteSchema = z.object({ + type: z.literal('Create'), + id: apId, + to: recipients, + cc: recipients, + actor: apId, + object: noteSchema, + published: published(), + proxyOf: z.array(proxySchema).optional().catch(undefined), +}); + +const announceNoteSchema = z.object({ + type: z.literal('Announce'), + id: apId, + to: recipients, + cc: recipients, + actor: apId, + object: apId.or(noteSchema), + published: published(), + proxyOf: z.array(proxySchema).optional().catch(undefined), +}); + +const followSchema = z.object({ + type: z.literal('Follow'), + id: apId, + to: recipients, + cc: recipients, + actor: apId, + object: apId, + proxyOf: z.array(proxySchema).optional().catch(undefined), +}); + +const acceptSchema = z.object({ + type: z.literal('Accept'), + id: apId, + actor: apId, + to: recipients, + cc: recipients, + object: apId.or(followSchema), +}); + +const likeSchema = z.object({ + type: z.literal('Like'), + id: apId, + actor: apId, + object: apId, + to: recipients, + cc: recipients, + proxyOf: z.array(proxySchema).optional().catch(undefined), +}); + +const emojiReactSchema = z.object({ + type: z.literal('EmojiReact'), + id: apId, + actor: apId, + object: apId, + content: z.string().refine((v) => /\p{Extended_Pictographic}/u.test(v)), + to: recipients, + cc: recipients, + proxyOf: z.array(proxySchema).optional().catch(undefined), +}); + +const deleteSchema = z.object({ + type: z.literal('Delete'), + id: apId, + actor: apId, + object: apId, + to: recipients, + cc: recipients, + proxyOf: z.array(proxySchema).optional().catch(undefined), +}); + +const updateActorSchema = z.object({ + type: z.literal('Update'), + id: apId, + actor: apId, + to: recipients, + cc: recipients, + object: actorSchema, + proxyOf: z.array(proxySchema).optional().catch(undefined), +}); + +/** + * A custom Zap activity type we made up, based on: + * https://github.com/nostr-protocol/nips/blob/master/57.md + */ +const zapSchema = z.object({ + type: z.literal('Zap'), + id: apId, + actor: apId, + object: apId, + to: recipients, + cc: recipients, + proxyOf: z.array(proxySchema).optional().catch(undefined), +}); + +const activitySchema = z.discriminatedUnion('type', [ + followSchema, + acceptSchema, + createNoteSchema, + announceNoteSchema, + updateActorSchema, + likeSchema, + emojiReactSchema, + deleteSchema, + zapSchema, +]).refine((activity) => { + const ids: string[] = [activity.id]; + + if (activity.type === 'Create') { + ids.push( + activity.object.id, + activity.object.attributedTo, + ); + } + + if (activity.type === 'Update') { + ids.push(activity.object.id); + } + + const { origin: actorOrigin } = new URL(activity.actor); + + // Object containment + return ids.every((id) => { + const { origin: idOrigin } = new URL(id); + return idOrigin === actorOrigin; + }); +}); + +type Activity = z.infer; +type CreateNote = z.infer; +type Announce = z.infer; +type Update = z.infer; +type Object = z.infer; +type Follow = z.infer; +type Accept = z.infer; +type Actor = z.infer; +type Note = z.infer; +type Mention = z.infer; +type Hashtag = z.infer; +type Emoji = z.infer; +type Like = z.infer; +type EmojiReact = z.infer; +type Delete = z.infer; +type Zap = z.infer; +type Proxy = z.infer; + +export { acceptSchema, activitySchema, actorSchema, emojiSchema, followSchema, imageSchema, noteSchema, objectSchema }; +export type { + Accept, + Activity, + Actor, + Announce, + CreateNote, + Delete, + Emoji, + EmojiReact, + Follow, + Hashtag, + Like, + Mention, + Note, + Object, + Proxy, + Update, + Zap, +}; diff --git a/src/transformers/nostr-to-activitypub.ts b/src/transformers/nostr-to-activitypub.ts new file mode 100644 index 00000000..82f328d5 --- /dev/null +++ b/src/transformers/nostr-to-activitypub.ts @@ -0,0 +1,51 @@ +import { Conf } from '@/config.ts'; +import { parseMetaContent } from '@/schema.ts'; +import { getPublicKeyPem } from '@/utils/rsa.ts'; + +import type { Event } from '@/event.ts'; +import type { Actor } from '@/schemas/activitypub.ts'; + +/** Nostr metadata event to ActivityPub actor. */ +async function toActor(event: Event<0>): Promise { + const content = parseMetaContent(event); + + if (!content.nip05) return; + const [username, hostname] = content.nip05.split('@'); + if (hostname !== Conf.url.hostname) return; + + return { + type: 'Person', + id: Conf.local(`/users/${username}`), + name: content?.name || '', + preferredUsername: username, + inbox: Conf.local(`/users/${username}/inbox`), + followers: Conf.local(`/users/${username}/followers`), + following: Conf.local(`/users/${username}/following`), + outbox: Conf.local(`/users/${username}/outbox`), + icon: content.picture + ? { + type: 'Image', + url: content.picture, + } + : undefined, + image: content.banner + ? { + type: 'Image', + url: content.banner, + } + : undefined, + summary: content.about ?? '', + attachment: [], + tag: [], + publicKey: { + id: Conf.local(`/users/${username}#main-key`), + owner: Conf.local(`/users/${username}`), + publicKeyPem: await getPublicKeyPem(event.pubkey), + }, + endpoints: { + sharedInbox: Conf.local('/inbox'), + }, + }; +} + +export { toActor }; diff --git a/src/transmute.ts b/src/transformers/nostr-to-mastoapi.ts similarity index 97% rename from src/transmute.ts rename to src/transformers/nostr-to-mastoapi.ts index c157af28..ea70df51 100644 --- a/src/transmute.ts +++ b/src/transformers/nostr-to-mastoapi.ts @@ -1,13 +1,13 @@ +import { isCWTag } from 'https://gitlab.com/soapbox-pub/mostr/-/raw/c67064aee5ade5e01597c6d23e22e53c628ef0e2/src/nostr/tags.ts'; + +import { getAuthor } from '@/client.ts'; +import { Conf } from '@/config.ts'; import { findReplyTag, lodash, nip19, sanitizeHtml, TTLCache, unfurl, z } from '@/deps.ts'; import { type Event } from '@/event.ts'; +import { verifyNip05Cached } from '@/nip05.ts'; +import { getMediaLinks, type MediaLink, parseNoteContent } from '@/note.ts'; import { emojiTagSchema, filteredArray, type MetaContent, parseMetaContent } from '@/schema.ts'; - -import { Conf } from './config.ts'; -import { getAuthor } from './client.ts'; -import { verifyNip05Cached } from './nip05.ts'; -import { getMediaLinks, type MediaLink, parseNoteContent } from './note.ts'; -import { type Nip05, nostrDate, parseNip05, Time } from './utils.ts'; -import { isCWTag } from 'https://gitlab.com/soapbox-pub/mostr/-/raw/c67064aee5ade5e01597c6d23e22e53c628ef0e2/src/nostr/tags.ts'; +import { type Nip05, nostrDate, parseNip05, Time } from '@/utils.ts'; const DEFAULT_AVATAR = 'https://gleasonator.com/images/avi.png'; const DEFAULT_BANNER = 'https://gleasonator.com/images/banner.png'; diff --git a/src/utils.ts b/src/utils.ts index a81c0fd6..ed0a640f 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,6 @@ import { getAuthor } from '@/client.ts'; import { Conf } from '@/config.ts'; -import { nip19, parseFormData, z } from '@/deps.ts'; +import { type Context, nip19, parseFormData, z } from '@/deps.ts'; import { type Event } from '@/event.ts'; import { lookupNip05Cached } from '@/nip05.ts'; @@ -124,7 +124,26 @@ async function sha256(message: string): Promise { return hashHex; } +/** JSON-LD context. */ +type LDContext = (string | Record>)[]; + +/** Add a basic JSON-LD context to ActivityStreams object, if it doesn't already exist. */ +function maybeAddContext(object: T): T & { '@context': LDContext } { + return { + '@context': ['https://www.w3.org/ns/activitystreams'], + ...object, + }; +} + +/** Like hono's `c.json()` except returns JSON-LD. */ +function activityJson(c: Context, object: T) { + const response = c.json(maybeAddContext(object)); + response.headers.set('content-type', 'application/activity+json; charset=UTF-8'); + return response; +} + export { + activityJson, bech32ToPubkey, buildLinkHeader, eventAge, diff --git a/src/utils/rsa.ts b/src/utils/rsa.ts new file mode 100644 index 00000000..b6865b40 --- /dev/null +++ b/src/utils/rsa.ts @@ -0,0 +1,29 @@ +import { Conf } from '@/config.ts'; +import { generateSeededRsa, LRUCache, publicKeyToPem, secp } from '@/deps.ts'; + +const opts = { + bits: 2048, +}; + +const rsaCache = new LRUCache>({ max: 1000 }); + +async function buildSeed(pubkey: string): Promise { + const key = await Conf.cryptoKey; + const data = new TextEncoder().encode(pubkey); + const signature = await window.crypto.subtle.sign('HMAC', key, data); + return secp.utils.bytesToHex(new Uint8Array(signature)); +} + +async function getPublicKeyPem(pubkey: string): Promise { + const cached = await rsaCache.get(pubkey); + if (cached) return cached; + + const seed = await buildSeed(pubkey); + const { publicKey } = await generateSeededRsa(seed, opts); + const promise = publicKeyToPem(publicKey); + + rsaCache.set(pubkey, promise); + return promise; +} + +export { getPublicKeyPem };