From d1f8e3b92cacf4c25a4586f516258f7c68c5b432 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 17 Feb 2025 20:51:34 -0600 Subject: [PATCH] Make @ditto/auth and @ditto/storages packages --- deno.json | 2 + packages/api/DittoMiddleware.ts | 5 + packages/api/deno.json | 2 + packages/api/middleware/mod.ts | 1 + packages/api/middleware/userMiddleware.ts | 92 +++++++++++++++++++ packages/api/routes/mod.ts | 1 + packages/api/routes/timelinesRoute.ts | 7 +- packages/api/schema.test.ts | 13 +++ packages/api/schema.ts | 11 +++ packages/{utils => auth}/aes.bench.ts | 0 packages/{utils => auth}/aes.test.ts | 0 packages/{utils => auth}/aes.ts | 0 packages/auth/deno.json | 6 ++ packages/auth/mod.ts | 2 + .../auth.bench.ts => auth/token.bench.ts} | 2 +- .../auth.test.ts => auth/token.test.ts} | 2 +- packages/{utils/auth.ts => auth/token.ts} | 0 packages/ditto/schema.ts | 2 +- .../{ditto => }/storages/UserStore.test.ts | 8 +- packages/{ditto => }/storages/UserStore.ts | 10 +- packages/storages/deno.json | 6 ++ packages/storages/mod.ts | 1 + packages/utils/deno.json | 1 + 23 files changed, 158 insertions(+), 16 deletions(-) create mode 100644 packages/api/DittoMiddleware.ts create mode 100644 packages/api/middleware/mod.ts create mode 100644 packages/api/middleware/userMiddleware.ts create mode 100644 packages/api/routes/mod.ts create mode 100644 packages/api/schema.test.ts create mode 100644 packages/api/schema.ts rename packages/{utils => auth}/aes.bench.ts (100%) rename packages/{utils => auth}/aes.test.ts (100%) rename packages/{utils => auth}/aes.ts (100%) create mode 100644 packages/auth/deno.json create mode 100644 packages/auth/mod.ts rename packages/{utils/auth.bench.ts => auth/token.bench.ts} (77%) rename packages/{utils/auth.test.ts => auth/token.test.ts} (92%) rename packages/{utils/auth.ts => auth/token.ts} (100%) rename packages/{ditto => }/storages/UserStore.test.ts (89%) rename packages/{ditto => }/storages/UserStore.ts (80%) create mode 100644 packages/storages/deno.json create mode 100644 packages/storages/mod.ts diff --git a/deno.json b/deno.json index d23d2493..36b431a5 100644 --- a/deno.json +++ b/deno.json @@ -2,6 +2,7 @@ "version": "1.1.0", "workspace": [ "./packages/api", + "./packages/auth", "./packages/conf", "./packages/db", "./packages/ditto", @@ -10,6 +11,7 @@ "./packages/policies", "./packages/ratelimiter", "./packages/signers", + "./packages/storages", "./packages/translators", "./packages/uploaders", "./packages/utils" diff --git a/packages/api/DittoMiddleware.ts b/packages/api/DittoMiddleware.ts new file mode 100644 index 00000000..1483ca90 --- /dev/null +++ b/packages/api/DittoMiddleware.ts @@ -0,0 +1,5 @@ +import type { MiddlewareHandler } from '@hono/hono'; +import type { DittoEnv } from './DittoEnv.ts'; + +// deno-lint-ignore ban-types +export type DittoMiddleware = MiddlewareHandler; diff --git a/packages/api/deno.json b/packages/api/deno.json index 1e671c9e..9dc56068 100644 --- a/packages/api/deno.json +++ b/packages/api/deno.json @@ -4,6 +4,8 @@ "exports": { ".": "./mod.ts", "./middleware": "./middleware/mod.ts", + "./routes": "./routes/mod.ts", + "./schema": "./schema.ts", "./views": "./views/mod.ts" } } diff --git a/packages/api/middleware/mod.ts b/packages/api/middleware/mod.ts new file mode 100644 index 00000000..fc06aa58 --- /dev/null +++ b/packages/api/middleware/mod.ts @@ -0,0 +1 @@ +export { userMiddleware } from './userMiddleware.ts'; diff --git a/packages/api/middleware/userMiddleware.ts b/packages/api/middleware/userMiddleware.ts new file mode 100644 index 00000000..2eb5dd96 --- /dev/null +++ b/packages/api/middleware/userMiddleware.ts @@ -0,0 +1,92 @@ +import { aesDecrypt, getTokenHash } from '@ditto/auth'; +import { ConnectSigner, ReadOnlySigner } from '@ditto/signers'; +import { HTTPException } from '@hono/hono/http-exception'; +import { type NostrSigner, NSecSigner, type NStore } from '@nostrify/nostrify'; +import { nip19 } from 'nostr-tools'; + +import type { DittoMiddleware } from '../DittoMiddleware.ts'; + +interface User { + signer: NostrSigner; + store: NStore; +} + +interface UserMiddlewareOpts { + /** Returns a 401 response if no user can be determined. */ + required?: boolean; + /** Whether the user must prove themselves with a NIP-98 auth challenge. */ + privileged: boolean; +} + +// @ts-ignore The types are right. +export function userMiddleware(opts: { privileged: boolean; required: true }): DittoMiddleware<{ user: User }>; +export function userMiddleware(opts: { privileged: true; required?: boolean }): DittoMiddleware<{ user: User }>; +export function userMiddleware(opts: { privileged: false; required?: boolean }): DittoMiddleware<{ user?: User }>; +export function userMiddleware(opts: { privileged?: boolean; required?: boolean }): DittoMiddleware<{ user?: User }> { + /** We only accept "Bearer" type. */ + const BEARER_REGEX = new RegExp(`^Bearer (${nip19.BECH32_REGEX.source})$`); + + return async (c, next) => { + const { conf, db, store } = c.var; + + const header = c.req.header('authorization'); + const match = header?.match(BEARER_REGEX); + + let signer: NostrSigner | undefined; + + if (match) { + const [_, bech32] = match; + + if (bech32.startsWith('token1')) { + try { + const tokenHash = await getTokenHash(bech32 as `token1${string}`); + + const { pubkey: userPubkey, bunker_pubkey: bunkerPubkey, nip46_sk_enc, nip46_relays } = await db.kysely + .selectFrom('auth_tokens') + .select(['pubkey', 'bunker_pubkey', 'nip46_sk_enc', 'nip46_relays']) + .where('token_hash', '=', tokenHash) + .executeTakeFirstOrThrow(); + + const nep46Seckey = await aesDecrypt(conf.seckey, nip46_sk_enc); + + signer = new ConnectSigner({ + bunkerPubkey, + userPubkey, + signer: new NSecSigner(nep46Seckey), + relays: nip46_relays, + relay: store, + }); + } catch { + throw new HTTPException(401); + } + } else { + try { + const decoded = nip19.decode(bech32!); + + switch (decoded.type) { + case 'npub': + signer = new ReadOnlySigner(decoded.data); + break; + case 'nprofile': + signer = new ReadOnlySigner(decoded.data.pubkey); + break; + case 'nsec': + signer = new NSecSigner(decoded.data); + break; + } + } catch { + throw new HTTPException(401); + } + } + } + + if (signer) { + const user: User = { signer, store }; + c.set('user', user); + } else { + throw new HTTPException(401); + } + + await next(); + }; +} diff --git a/packages/api/routes/mod.ts b/packages/api/routes/mod.ts new file mode 100644 index 00000000..9085d8c0 --- /dev/null +++ b/packages/api/routes/mod.ts @@ -0,0 +1 @@ +export { timelinesRoute } from './timelinesRoute.ts'; diff --git a/packages/api/routes/timelinesRoute.ts b/packages/api/routes/timelinesRoute.ts index 4d46d844..be76563a 100644 --- a/packages/api/routes/timelinesRoute.ts +++ b/packages/api/routes/timelinesRoute.ts @@ -1,4 +1,6 @@ import { DittoRoute } from '@ditto/api'; +import { userMiddleware } from '@ditto/api/middleware'; +import { booleanParamSchema, languageSchema } from '@ditto/api/schema'; import { z } from 'zod'; import type { NostrFilter } from '@nostrify/nostrify'; @@ -11,10 +13,9 @@ const homeQuerySchema = z.object({ }); route.get('/home', async (c) => { - c.req.valid('json'); const { user, pagination } = c.var; - const pubkey = await user!.signer.getPublicKey()!; + const pubkey = await user.signer.getPublicKey()!; const result = homeQuerySchema.safeParse(c.req.query()); if (!result.success) { @@ -123,4 +124,4 @@ async function renderStatuses(c: AppContext, filters: NostrFilter[]) { return paginated(c, events, statuses); } -export { hashtagTimelineController, homeTimelineController, publicTimelineController, suggestedTimelineController }; +export { route as timelinesRoute }; diff --git a/packages/api/schema.test.ts b/packages/api/schema.test.ts new file mode 100644 index 00000000..04e13180 --- /dev/null +++ b/packages/api/schema.test.ts @@ -0,0 +1,13 @@ +import { assertEquals, assertThrows } from '@std/assert'; + +import { booleanParamSchema } from './schema.ts'; + +Deno.test('booleanParamSchema', () => { + assertEquals(booleanParamSchema.parse('true'), true); + assertEquals(booleanParamSchema.parse('false'), false); + + assertThrows(() => booleanParamSchema.parse('invalid')); + assertThrows(() => booleanParamSchema.parse('undefined')); + + assertEquals(booleanParamSchema.optional().parse(undefined), undefined); +}); diff --git a/packages/api/schema.ts b/packages/api/schema.ts new file mode 100644 index 00000000..0416a767 --- /dev/null +++ b/packages/api/schema.ts @@ -0,0 +1,11 @@ +import ISO6391 from 'iso-639-1'; +import { z } from 'zod'; + +/** https://github.com/colinhacks/zod/issues/1630#issuecomment-1365983831 */ +export const booleanParamSchema = z.enum(['true', 'false']).transform((value) => value === 'true'); + +/** Value is a ISO-639-1 language code. */ +export const languageSchema = z.string().refine( + (val) => ISO6391.validate(val), + { message: 'Not a valid language in ISO-639-1 format' }, +); diff --git a/packages/utils/aes.bench.ts b/packages/auth/aes.bench.ts similarity index 100% rename from packages/utils/aes.bench.ts rename to packages/auth/aes.bench.ts diff --git a/packages/utils/aes.test.ts b/packages/auth/aes.test.ts similarity index 100% rename from packages/utils/aes.test.ts rename to packages/auth/aes.test.ts diff --git a/packages/utils/aes.ts b/packages/auth/aes.ts similarity index 100% rename from packages/utils/aes.ts rename to packages/auth/aes.ts diff --git a/packages/auth/deno.json b/packages/auth/deno.json new file mode 100644 index 00000000..393aac1a --- /dev/null +++ b/packages/auth/deno.json @@ -0,0 +1,6 @@ +{ + "name": "@ditto/auth", + "exports": { + ".": "./mod.ts" + } +} diff --git a/packages/auth/mod.ts b/packages/auth/mod.ts new file mode 100644 index 00000000..eccc671d --- /dev/null +++ b/packages/auth/mod.ts @@ -0,0 +1,2 @@ +export { aesDecrypt, aesEncrypt } from './aes.ts'; +export { generateToken, getTokenHash } from './token.ts'; diff --git a/packages/utils/auth.bench.ts b/packages/auth/token.bench.ts similarity index 77% rename from packages/utils/auth.bench.ts rename to packages/auth/token.bench.ts index 1d23e91b..5df41d0f 100644 --- a/packages/utils/auth.bench.ts +++ b/packages/auth/token.bench.ts @@ -1,4 +1,4 @@ -import { generateToken, getTokenHash } from './auth.ts'; +import { generateToken, getTokenHash } from './token.ts'; Deno.bench('generateToken', async () => { await generateToken(); diff --git a/packages/utils/auth.test.ts b/packages/auth/token.test.ts similarity index 92% rename from packages/utils/auth.test.ts rename to packages/auth/token.test.ts index 27a4535d..6f002267 100644 --- a/packages/utils/auth.test.ts +++ b/packages/auth/token.test.ts @@ -1,7 +1,7 @@ import { assertEquals } from '@std/assert'; import { decodeHex, encodeHex } from '@std/encoding/hex'; -import { generateToken, getTokenHash } from './auth.ts'; +import { generateToken, getTokenHash } from './token.ts'; Deno.test('generateToken', async () => { const sk = decodeHex('a0968751df8fd42f362213f08751911672f2a037113b392403bbb7dd31b71c95'); diff --git a/packages/utils/auth.ts b/packages/auth/token.ts similarity index 100% rename from packages/utils/auth.ts rename to packages/auth/token.ts diff --git a/packages/ditto/schema.ts b/packages/ditto/schema.ts index 30b4520a..87df95d3 100644 --- a/packages/ditto/schema.ts +++ b/packages/ditto/schema.ts @@ -1,5 +1,5 @@ -import ISO6391, { LanguageCode } from 'iso-639-1'; import { NSchema as n } from '@nostrify/nostrify'; +import ISO6391, { LanguageCode } from 'iso-639-1'; import { z } from 'zod'; /** Validates individual items in an array, dropping any that aren't valid. */ diff --git a/packages/ditto/storages/UserStore.test.ts b/packages/storages/UserStore.test.ts similarity index 89% rename from packages/ditto/storages/UserStore.test.ts rename to packages/storages/UserStore.test.ts index d04ece07..97646cfe 100644 --- a/packages/ditto/storages/UserStore.test.ts +++ b/packages/storages/UserStore.test.ts @@ -1,7 +1,7 @@ import { MockRelay } from '@nostrify/nostrify/test'; - import { assertEquals } from '@std/assert'; -import { UserStore } from '@/storages/UserStore.ts'; + +import { UserStore } from './UserStore.ts'; import userBlack from '~/fixtures/events/kind-0-black.json' with { type: 'json' }; import userMe from '~/fixtures/events/event-0-makes-repost-with-quote-repost.json' with { type: 'json' }; @@ -23,7 +23,7 @@ Deno.test('query events of users that are not muted', async () => { await store.event(userMeCopy); await store.event(event1authorUserMeCopy); - assertEquals(await store.query([{ kinds: [1] }], { limit: 1 }), []); + assertEquals(await store.query([{ kinds: [1], limit: 1 }]), []); }); Deno.test('user never muted anyone', async () => { @@ -37,5 +37,5 @@ Deno.test('user never muted anyone', async () => { await store.event(userBlackCopy); await store.event(userMeCopy); - assertEquals(await store.query([{ kinds: [0], authors: [userMeCopy.pubkey] }], { limit: 1 }), [userMeCopy]); + assertEquals(await store.query([{ kinds: [0], authors: [userMeCopy.pubkey], limit: 1 }]), [userMeCopy]); }); diff --git a/packages/ditto/storages/UserStore.ts b/packages/storages/UserStore.ts similarity index 80% rename from packages/ditto/storages/UserStore.ts rename to packages/storages/UserStore.ts index 976294a8..37198ff9 100644 --- a/packages/ditto/storages/UserStore.ts +++ b/packages/storages/UserStore.ts @@ -1,10 +1,8 @@ +import { getTagSet } from '@ditto/utils/tags'; import { NostrEvent, NostrFilter, NStore } from '@nostrify/nostrify'; -import { DittoEvent } from '@/interfaces/DittoEvent.ts'; -import { getTagSet } from '../../utils/tags.ts'; - export class UserStore implements NStore { - private promise: Promise | undefined; + private promise: Promise | undefined; constructor(private pubkey: string, private store: NStore) {} @@ -16,7 +14,7 @@ export class UserStore implements NStore { * Query events that `pubkey` did not mute * https://github.com/nostr-protocol/nips/blob/master/51.md#standard-lists */ - async query(filters: NostrFilter[], opts: { signal?: AbortSignal; limit?: number } = {}): Promise { + async query(filters: NostrFilter[], opts: { signal?: AbortSignal } = {}): Promise { const events = await this.store.query(filters, opts); const pubkeys = await this.getMutedPubkeys(); @@ -25,7 +23,7 @@ export class UserStore implements NStore { }); } - private async getMuteList(): Promise { + private async getMuteList(): Promise { if (!this.promise) { this.promise = this.store.query([{ authors: [this.pubkey], kinds: [10000], limit: 1 }]); } diff --git a/packages/storages/deno.json b/packages/storages/deno.json new file mode 100644 index 00000000..99963aaf --- /dev/null +++ b/packages/storages/deno.json @@ -0,0 +1,6 @@ +{ + "name": "@ditto/storages", + "exports": { + ".": "./mod.ts" + } +} diff --git a/packages/storages/mod.ts b/packages/storages/mod.ts new file mode 100644 index 00000000..139f2654 --- /dev/null +++ b/packages/storages/mod.ts @@ -0,0 +1 @@ +export { UserStore } from './UserStore.ts'; diff --git a/packages/utils/deno.json b/packages/utils/deno.json index 04f8bde0..53dddfa8 100644 --- a/packages/utils/deno.json +++ b/packages/utils/deno.json @@ -1,6 +1,7 @@ { "name": "@ditto/utils", "exports": { + "./auth": "./auth.ts", "./tags": "./tags.ts" } }