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 };