diff --git a/README.md b/README.md index d25b9d30d232514cfa01bfc38e539a87cf495f4d..2ea63123ff7ac5dff4adfc13e2a37698e1c0db41 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,16 @@ Mostr is built with TypeScript and [Deno](https://deno.land/). It uses an SQLite To get up and running, install deno and run `deno task dev`. +#### Configuration + +To map nostr keys to a friendly username on the ActivityPub side, you can configure a +single alias: + +```sh +ALIAS_NAME=mostr +ALIAS_PUBKEY=6be38f8c63df7dbf84db7ec4a6e6fbbd8d19dca3b980efad18585c46f04b26f9 +``` + ## Why? See: https://soapbox.pub/blog/mostr-fediverse-nostr-bridge/ diff --git a/src/activitypub/controllers/actor.ts b/src/activitypub/controllers/actor.ts index a3b25252683cd4587baef8665916247067dc2de4..15d41ecbba1ce7afcae9e03930643388da357a27 100644 --- a/src/activitypub/controllers/actor.ts +++ b/src/activitypub/controllers/actor.ts @@ -1,3 +1,4 @@ +import { Conf } from '@/config.ts'; import { activityJson } from '@/activitypub/controllers/utils.ts'; import { toActor } from '@/activitypub/transmute.ts'; import { cipher } from '@/db.ts'; @@ -7,7 +8,8 @@ import { isProxyEvent } from '@/nostr/tags.ts'; import type { AppController } from '@/app.ts'; const actorController: AppController = async (c) => { - const pubkey = c.req.param('pubkey'); + const pubkey_or_alias = c.req.param('pubkey'); + const pubkey = pubkey_or_alias == Conf.aliasName ? Conf.aliasPubkey! : pubkey_or_alias; if (await cipher.getApId(pubkey).catch(() => undefined)) { return c.json({ error: 'Not found' }, 404); diff --git a/src/activitypub/controllers/followers.ts b/src/activitypub/controllers/followers.ts index 759db0441e0c854e39415dfd0506fa02b4c96b29..57577fe07f8392825ba28160c0e81dead6360508 100644 --- a/src/activitypub/controllers/followers.ts +++ b/src/activitypub/controllers/followers.ts @@ -1,3 +1,4 @@ +import { Conf } from '@/config.ts'; import { activityJson } from '@/activitypub/controllers/utils.ts'; import { followsDB } from '@/db.ts'; import { url } from '@/utils/parse.ts'; @@ -5,18 +6,19 @@ import { url } from '@/utils/parse.ts'; import type { AppController } from '@/app.ts'; const followersController: AppController = async (c) => { - const pubkey = c.req.param('pubkey'); + const pubkey_or_alias = c.req.param('pubkey'); + const pubkey = pubkey_or_alias == Conf.aliasName ? Conf.aliasPubkey! : pubkey_or_alias; const items = await followsDB.getFollowers(pubkey); return activityJson(c, { - id: url(`/${pubkey}/followers`), + id: url(`/${pubkey_or_alias}/followers`), type: 'OrderedCollection', totalItems: items.length, first: { - id: url(`/${pubkey}/followers?page=1`), + id: url(`/${pubkey_or_alias}/followers?page=1`), type: 'OrderedCollectionPage', orderedItems: items, - partOf: url(`/${pubkey}/followers`), + partOf: url(`/${pubkey_or_alias}/followers`), totalItems: items.length, }, }); diff --git a/src/activitypub/controllers/following.ts b/src/activitypub/controllers/following.ts index ae36edc72ecb3188597235704c953caac3253bda..322678662a1a560e47016be7252f84700fef7faa 100644 --- a/src/activitypub/controllers/following.ts +++ b/src/activitypub/controllers/following.ts @@ -1,3 +1,4 @@ +import { Conf } from '@/config.ts'; import { activityJson } from '@/activitypub/controllers/utils.ts'; import { cipher } from '@/db.ts'; import { fetchFollows } from '@/nostr/client.ts'; @@ -7,7 +8,8 @@ import { url } from '@/utils/parse.ts'; import type { AppController } from '@/app.ts'; const followingController: AppController = async (c) => { - const pubkey = c.req.param('pubkey'); + const pubkey_or_alias = c.req.param('pubkey'); + const pubkey = pubkey_or_alias == Conf.aliasName ? Conf.aliasPubkey! : pubkey_or_alias; const event = await fetchFollows(pubkey); const tags = event?.tags || []; @@ -19,14 +21,14 @@ const followingController: AppController = async (c) => { )).filter(Boolean) as string[]; return activityJson(c, { - id: url(`/${pubkey}/following`), + id: url(`/${pubkey_or_alias}/following`), type: 'OrderedCollection', totalItems: items.length, first: { - id: url(`/${pubkey}/following?page=1`), + id: url(`/${pubkey_or_alias}/following?page=1`), type: 'OrderedCollectionPage', orderedItems: items, - partOf: url(`/${pubkey}/following`), + partOf: url(`/${pubkey_or_alias}/following`), totalItems: items.length, }, }); diff --git a/src/activitypub/transmute.ts b/src/activitypub/transmute.ts index 8da64e06c6bff638e1745fa4d03f6f5908485676..c29e978551713073c3265598f35bf2fde25cbd07 100644 --- a/src/activitypub/transmute.ts +++ b/src/activitypub/transmute.ts @@ -59,15 +59,17 @@ const toActor = async (event: Event<0>): Promise => { .filter(isEmojiTag) .map(toEmoji); + const userName = event.pubkey == Conf.aliasPubkey ? Conf.aliasName! : event.pubkey; + return { type: 'Person', - id: url(`/users/${event.pubkey}`), + id: url(`/users/${userName}`), name: content?.name || '', - preferredUsername: event.pubkey, - inbox: url(`/users/${event.pubkey}/inbox`), - followers: url(`/users/${event.pubkey}/followers`), - following: url(`/users/${event.pubkey}/following`), - outbox: url(`/users/${event.pubkey}/outbox`), + preferredUsername: userName, + inbox: url(`/users/${userName}/inbox`), + followers: url(`/users/${userName}/followers`), + following: url(`/users/${userName}/following`), + outbox: url(`/users/${userName}/outbox`), icon: content.picture ? { type: 'Image', @@ -84,8 +86,8 @@ const toActor = async (event: Event<0>): Promise => { attachment: [], tag: emojis, publicKey: { - id: url(`/users/${event.pubkey}#main-key`), - owner: url(`/users/${event.pubkey}`), + id: url(`/users/${userName}#main-key`), + owner: url(`/users/${userName}`), publicKeyPem, }, endpoints: { @@ -186,16 +188,19 @@ const toNote = async (event: Event<1>): Promise => { .filter(isEmojiTag) .map(toEmoji); + const username = event.pubkey == Conf.aliasPubkey ? Conf.aliasName! : event.pubkey; + return { type: 'Note', id: url(`/objects/${event.id}`), - attributedTo: url(`/users/${event.pubkey}`), + + attributedTo: url(`/users/${username}`), content: cleanContent(parseMentions(linkified, imentions)), to: [ AP_PUBLIC_URI, ...mentions.map((m) => m.href), ], - cc: [url(`/users/${event.pubkey}/followers`)], + cc: [url(`/users/${username}/followers`)], tag: [...mentions, ...hashtags, ...emojis], attachment: mediaLinks.map(renderAttachment), sensitive: Boolean(cwTag), @@ -275,13 +280,15 @@ async function toAnnounce(event: Event<1> | Event<6>): Promise): Promise { if (!reactedEvent) return; const mentions = await getTaggedUsers(event); + const userName = event.pubkey == Conf.aliasPubkey ? Conf.aliasName! : event.pubkey; + return { type: 'Like', id: url(`/objects/${event.id}`), object: reactedEvent, - actor: url(`/users/${event.pubkey}`), + actor: url(`/users/${userName}`), to: [AP_PUBLIC_URI, ...mentions], - cc: [url(`/users/${event.pubkey}/followers`)], + cc: [url(`/users/${userName}/followers`)], proxyOf: [toNoteProxy(event)], }; } @@ -324,14 +333,16 @@ async function toEmojiReact(event: Event<7>): Promise { if (!reactedEvent) return; const mentions = await getTaggedUsers(event); + const userName = event.pubkey == Conf.aliasPubkey ? Conf.aliasName! : event.pubkey; + return { type: 'EmojiReact', id: url(`/objects/${event.id}`), object: reactedEvent, content: event.content, - actor: url(`/users/${event.pubkey}`), + actor: url(`/users/${userName}`), to: [AP_PUBLIC_URI, ...mentions], - cc: [url(`/users/${event.pubkey}/followers`)], + cc: [url(`/users/${userName}/followers`)], proxyOf: [toNoteProxy(event)], }; } diff --git a/src/config.ts b/src/config.ts index f6489639883fe201eb3abbd16e198d3bfcfb0ac8..e69fcbd560372618d6daec230ccd74b7a0a8686a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -51,6 +51,12 @@ const Conf = { get signFetch(): boolean { return Deno.env.get('SIGN_FETCH') !== 'false'; }, + get aliasName(): string | undefined { + return Deno.env.get('ALIAS_NAME'); + }, + get aliasPubkey(): string | undefined { + return Deno.env.get('ALIAS_PUBKEY'); + }, }; function deduplicate(items: string[]): string[] { diff --git a/src/middleware/nip19-redirect.ts b/src/middleware/nip19-redirect.ts index 81002783a738311c310a4699eef0bc359f839a07..c1e4a341bdff7b44d00e027f3fa3f59a6fcbbee0 100644 --- a/src/middleware/nip19-redirect.ts +++ b/src/middleware/nip19-redirect.ts @@ -1,3 +1,4 @@ +import { Conf } from '@/config.ts'; import { nip19, Stickynotes } from '@/deps.ts'; import type { AppMiddleware } from '@/app.ts'; @@ -6,7 +7,9 @@ export const nip19Redirect = (): AppMiddleware<'/users/:pubkey' | '/objects/:id' const console = new Stickynotes('middleware:nip19'); return async (c, next) => { - const nostrId: string = c.req.param('pubkey') || c.req.param('id'); + let nostrId: string = c.req.param('pubkey') || c.req.param('id'); + + nostrId = nostrId == Conf.aliasName ? Conf.aliasPubkey! : nostrId; try { const decoded = nip19.decode(nostrId); diff --git a/src/middleware/redirect-humans.ts b/src/middleware/redirect-humans.ts index c6aa42d41ac8b3672bfbc2f96a4752ec8d17ca1a..e7866e721f5662284d7074528494e0d1eb99787c 100644 --- a/src/middleware/redirect-humans.ts +++ b/src/middleware/redirect-humans.ts @@ -1,6 +1,7 @@ import { isActivityContentType, redirectExternal } from '@/activitypub/controllers/utils.ts'; import { nip19 } from '@/deps.ts'; +import { Conf } from '@/config.ts'; import type { AppMiddleware } from '@/app.ts'; /** Redirect to a human-readable page if visited from a web browser. */ @@ -12,8 +13,12 @@ export const redirectHumans = (): AppMiddleware<'/users/:pubkey' | '/objects/:id return next(); } + let pubkey = c.req.param('pubkey'); + pubkey = pubkey == Conf.aliasName ? Conf.aliasPubkey! : pubkey; + const note = maybeEncodeNote(c.req.param('id')); - const npub = maybeEncodeNpub(c.req.param('pubkey')); + const npub = maybeEncodeNpub(pubkey); + const result = note || npub; if (result) { diff --git a/src/nostr/handler.ts b/src/nostr/handler.ts index e62228c4e71103fdd501035bb6f4999ece269a98..cc33706a1d2d4e3e4ee081b9c221b69b3fe6464a 100644 --- a/src/nostr/handler.ts +++ b/src/nostr/handler.ts @@ -49,8 +49,9 @@ async function handleEvent(event: NostrEvent): Promise { } async function handleEvent0(event: Event<0>) { + const userName = event.pubkey == Conf.aliasPubkey ? Conf.aliasName! : event.pubkey; const cache = await caches.open('web'); - await cache.delete(new URL(`/users/${event.pubkey}`, Conf.localDomain)); + await cache.delete(new URL(`/users/${userName}`, Conf.localDomain)); return federate(wrapUpdate(await toActor(event))); } @@ -71,8 +72,10 @@ async function handleEvent3(event: Event<3>) { const diff = await getFollowsDiff(event); + const userName = event.pubkey == Conf.aliasPubkey ? Conf.aliasName! : event.pubkey; + for (const apId of diff.follow) { - const follow = buildFollow(url(`/users/${event.pubkey}`), apId); + const follow = buildFollow(url(`/users/${userName}`), apId); await Promise.all([ federate(follow), followsDB.addFollow(event.pubkey, apId).catch((e) => console.error(e)), diff --git a/src/nostr/transmute.ts b/src/nostr/transmute.ts index 38f1372b57af30d67c5971c22e4e8647316da8a3..e6f50a0af68df284e07d58ca011290533f07b464 100644 --- a/src/nostr/transmute.ts +++ b/src/nostr/transmute.ts @@ -257,8 +257,9 @@ async function getObjectAuthor(activity: Like | EmojiReact): Promise { return [...nodes].reduce>(async (result, node) => { const mention = node as Element; const apId = mention.getAttribute('href'); - const pubkey = apId ? await toPubkey(apId) : undefined; + let pubkey = apId ? await toPubkey(apId) : undefined; if (pubkey) { + pubkey = pubkey == Conf.aliasName ? Conf.aliasPubkey! : pubkey; mention.innerHTML = `nostr:${nip19.npubEncode(pubkey)}`; return [...await result, apId!]; } diff --git a/src/nostr/utils.ts b/src/nostr/utils.ts index 6cb9b865ed426b6e6ab2ed56e2e95477f5e4b423..f7e20e132d1a27e9a876ff079a9f27192a0473e3 100644 --- a/src/nostr/utils.ts +++ b/src/nostr/utils.ts @@ -4,7 +4,9 @@ import { UserSigner } from '@/nostr/UserSigner.ts'; /** Convert ActivityPub ID into Nostr pubkey. */ async function toPubkey(apId: string): Promise { if (apId.startsWith(`${Conf.localDomain}/users/`)) { - return new URL(apId).pathname.split('/')[2]; + const pubkey_or_alias = new URL(apId).pathname.split('/')[2]; + const pubkey = pubkey_or_alias == Conf.aliasName ? Conf.aliasPubkey! : pubkey_or_alias; + return pubkey; } else { return await new UserSigner(apId).getPublicKey(); } diff --git a/src/utils/parse.ts b/src/utils/parse.ts index 6513189c36ddcd904809fe0ee1ff06f98cde0e69..4a3fb5b693b158cc357f49cf9737343221fd976a 100644 --- a/src/utils/parse.ts +++ b/src/utils/parse.ts @@ -22,7 +22,9 @@ const apIdToPubkey = (apId: string): string => { if (!apId.startsWith(baseUrl)) throw apId; const { pathname } = new URL(apId); - return pathname.split('/')[2]; + const pubkey_or_alias = pathname.split('/')[2]; + const pubkey = pubkey_or_alias == Conf.aliasName ? Conf.aliasPubkey! : pubkey_or_alias; + return pubkey; }; function isURL(value: unknown): value is string { diff --git a/src/webfinger/controller.ts b/src/webfinger/controller.ts index 059c0ef59ef51a3a8b45b3dd9015ad054b242623..7df540ee19b1f3665d67392a04549375818f8ebc 100644 --- a/src/webfinger/controller.ts +++ b/src/webfinger/controller.ts @@ -14,7 +14,7 @@ async function webfingerController(c: Context) { reqUrl.protocol = 'https:'; const { pathname: acct } = new URL(c.req.query('resource')!); - const [username, host] = acct.split('@'); + let [username, host] = acct.split('@'); // Service actor if (username === host && [localDomainHost, serviceActorHost].includes(host)) { @@ -26,6 +26,8 @@ async function webfingerController(c: Context) { return c.json({ error: 'Invalid host' }, 400); } + username = username == Conf.aliasName ? Conf.aliasPubkey! : username; + let pubkey: string | undefined; if (n.id().safeParse(username).success) { diff --git a/src/webfinger/transmute.ts b/src/webfinger/transmute.ts index 90f9305d42c565dc5d97f150b3fbce5950b8a63a..6988e3ae43ccf7046a3e842482863098f2098bd3 100644 --- a/src/webfinger/transmute.ts +++ b/src/webfinger/transmute.ts @@ -7,10 +7,11 @@ import type { Webfinger } from './webfingerSchema.ts'; /** Present Nostr user on Webfinger. */ const toWebfinger = (event: Event<0>): Webfinger => { const { host } = new URL(Conf.localDomain); - const apId = url(`/users/${event.pubkey}`); + const username = event.pubkey == Conf.aliasPubkey ? Conf.aliasName! : event.pubkey; + const apId = url(`/users/${username}`); return { - subject: `acct:${event.pubkey}@${host}`, + subject: `acct:${username}@${host}`, aliases: [apId], links: [ {