diff --git a/src/app.ts b/src/app.ts index 175eb57..414ab9a 100644 --- a/src/app.ts +++ b/src/app.ts @@ -28,6 +28,7 @@ import { import { streamingController } from './controllers/api/streaming.ts'; import { indexController } from './controllers/site.ts'; import { nostrController } from './controllers/well-known/nostr.ts'; +import { hostMetaController, webfingerController } from './controllers/well-known/webfinger.ts'; import { auth19, requireAuth } from './middleware/auth19.ts'; import { auth98 } from './middleware/auth98.ts'; @@ -57,6 +58,8 @@ app.get('/api/v1/streaming/', streamingController); app.use('*', cors({ origin: '*', exposeHeaders: ['link'] }), auth19, auth98()); +app.get('/.well-known/webfinger', webfingerController); +app.get('/.well-known/host-meta', hostMetaController); app.get('/.well-known/nostr.json', nostrController); app.get('/api/v1/instance', instanceController); diff --git a/src/controllers/well-known/nostr.ts b/src/controllers/well-known/nostr.ts index ae24895..db46d55 100644 --- a/src/controllers/well-known/nostr.ts +++ b/src/controllers/well-known/nostr.ts @@ -1,8 +1,8 @@ +import { Conf } from '@/config.ts'; import { db } from '@/db.ts'; import { z } from '@/deps.ts'; import type { AppController } from '@/app.ts'; -import { Conf } from '../../config.ts'; const nameSchema = z.string().min(1).regex(/^\w+$/); diff --git a/src/controllers/well-known/webfinger.ts b/src/controllers/well-known/webfinger.ts new file mode 100644 index 0000000..901720f --- /dev/null +++ b/src/controllers/well-known/webfinger.ts @@ -0,0 +1,75 @@ +import { Conf } from '@/config.ts'; +import { db } from '@/db.ts'; +import { nip19, z } from '@/deps.ts'; +import { urlTransformSchema } from '@/schema.ts'; + +import type { AppController } from '@/app.ts'; +import type { Webfinger } from '@/schemas/webfinger.ts'; + +const webfingerController: AppController = async (c) => { + const { hostname } = new URL(Conf.localDomain); + + /** Transforms the resource URI into a `[username, domain]` tuple. */ + const acctSchema = urlTransformSchema + .refine((uri) => uri.protocol === 'acct:', 'Protocol must be `acct:`') + .refine((uri) => z.string().email().safeParse(uri.pathname).success, 'Invalid acct') + .transform((uri) => uri.pathname.split('@') as [username: string, host: string]) + .refine(([_username, host]) => host === hostname, 'Host must be local'); + + const result = acctSchema.safeParse(c.req.query('resource')); + if (!result.success) { + return c.json({ error: 'Bad request', schema: result.error }, 400); + } + + try { + const user = await db.users.findFirst({ where: { username: result.data[0] } }); + c.header('content-type', 'application/jrd+json'); + return c.body(JSON.stringify(renderWebfinger(user))); + } catch (_e) { + return c.json({ error: 'Not found' }, 404); + } +}; + +const hostMetaController: AppController = (c) => { + const template = Conf.url('/.well-known/webfinger?resource={uri}'); + + c.header('content-type', 'application/xrd+xml'); + return c.body( + ``, + ); +}; + +interface RenderWebfingerOpts { + pubkey: string; + username: string; +} + +/** Present Nostr user on Webfinger. */ +function renderWebfinger({ pubkey, username }: RenderWebfingerOpts): Webfinger { + const { host } = new URL(Conf.localDomain); + const apId = Conf.url(`/users/${username}`); + + return { + subject: `acct:${username}@${host}`, + aliases: [apId], + links: [ + { + rel: 'self', + type: 'application/activity+json', + href: apId, + }, + { + rel: 'self', + type: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', + href: apId, + }, + { + rel: 'self', + type: 'application/nostr+json', + href: `nostr:${nip19.npubEncode(pubkey)}`, + }, + ], + }; +} + +export { hostMetaController, webfingerController }; diff --git a/src/schema.ts b/src/schema.ts index 8027023..76eb764 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -95,6 +95,20 @@ const decode64Schema = z.string().transform((value, ctx) => { } }); +/** Transforms a string into a `URL` object. */ +const urlTransformSchema = z.string().transform((val, ctx) => { + try { + return new URL(val); + } catch (_e) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Invalid URI', + fatal: true, + }); + return z.NEVER; + } +}); + export { decode64Schema, emojiTagSchema, @@ -106,4 +120,5 @@ export { parseRelay, relaySchema, signedEventSchema, + urlTransformSchema, }; diff --git a/src/schemas/webfinger.ts b/src/schemas/webfinger.ts new file mode 100644 index 0000000..2372cf8 --- /dev/null +++ b/src/schemas/webfinger.ts @@ -0,0 +1,19 @@ +import { z } from '@/deps.ts'; + +const linkSchema = z.object({ + rel: z.string().optional(), + type: z.string().optional(), + href: z.string().optional(), + template: z.string().optional(), +}); + +const webfingerSchema = z.object({ + subject: z.string(), + aliases: z.array(z.string()).catch([]), + links: z.array(linkSchema), +}); + +type Webfinger = z.infer; + +export { webfingerSchema }; +export type { Webfinger };