diff --git a/drizzle.config.ts b/drizzle.config.ts index a930ff2..5fd61bb 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,5 +1,11 @@ import { defineConfig } from "drizzle-kit"; -import { DATABASE_URL } from "./src/constants.ts"; +const DATABASE_URL = Deno.env.get("DATABASE_URL"); + +if (!DATABASE_URL) { + console.log("no DATABASE_URL provided, exiting"); + Deno.exit(1); +} + export default defineConfig({ dialect: "postgresql", schema: "./src/db/schema.ts", diff --git a/drizzle/0002_green_frightful_four.sql b/drizzle/0002_green_frightful_four.sql new file mode 100644 index 0000000..84a2bf4 --- /dev/null +++ b/drizzle/0002_green_frightful_four.sql @@ -0,0 +1 @@ +ALTER TABLE "users" ADD COLUMN "nostr_pubkey" text NOT NULL; \ No newline at end of file diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..4e67679 --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,202 @@ +{ + "id": "cc14ef05-cccd-4838-94d5-31521a8d5625", + "prevId": "52607bd8-7a1a-4a34-ad27-91b78733e85c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.invoices": { + "name": "invoices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payment_request": { + "name": "payment_request", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payment_hash": { + "name": "payment_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "preimage": { + "name": "preimage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "settled_at": { + "name": "settled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_id_idx": { + "name": "user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_payment_hash_idx": { + "name": "user_payment_hash_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "payment_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoices_user_id_users_id_fk": { + "name": "invoices_user_id_users_id_fk", + "tableFrom": "invoices", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invoices_payment_request_unique": { + "name": "invoices_payment_request_unique", + "nullsNotDistinct": false, + "columns": [ + "payment_request" + ] + }, + "invoices_payment_hash_unique": { + "name": "invoices_payment_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "payment_hash" + ] + } + } + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "connection_secret": { + "name": "connection_secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "nostr_pubkey": { + "name": "nostr_pubkey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + } + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 8c625fe..34b1dba 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1733813329314, "tag": "0001_white_prism", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1735020701926, + "tag": "0002_green_frightful_four", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/db.ts b/src/db/db.ts index c76ca63..7ea8bad 100644 --- a/src/db/db.ts +++ b/src/db/db.ts @@ -28,7 +28,8 @@ export class DB { async createUser( connectionSecret: string, - username?: string + username?: string, + nostrPubkey?: string ) { const parsed = nwc.NWCClient.parseWalletConnectUrl(connectionSecret); if (!parsed.secret) { @@ -43,7 +44,8 @@ export class DB { const [newUser] = await this._db.insert(users).values({ encryptedConnectionSecret, username, - }).returning({ id: users.id, username: users.username }); + nostrPubkey + }).returning({ id: users.id, username: users.username, nostrPubkey: users.nostrPubkey }); return newUser; } @@ -62,6 +64,7 @@ export class DB { const connectionSecret = await decrypt(result.encryptedConnectionSecret); return { id: result.id, + nostrPubkey: result.nostrPubkey, connectionSecret }; } diff --git a/src/db/schema.ts b/src/db/schema.ts index cea1949..56ac19c 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -4,6 +4,7 @@ export const users = pgTable("users", { id: serial("id").primaryKey(), encryptedConnectionSecret: text("connection_secret").notNull(), username: text("username").unique().notNull(), + nostrPubkey: text("nostr_pubkey").notNull(), createdAt: timestamp("created_at").notNull().defaultNow(), }); diff --git a/src/lnurlp.ts b/src/lnurlp.ts index 6b12024..955e4a4 100644 --- a/src/lnurlp.ts +++ b/src/lnurlp.ts @@ -3,61 +3,8 @@ import { validateZapRequest } from "@nostr/tools/nip57"; import { Hono } from "hono"; import { nwc } from "npm:@getalby/sdk"; import { logger } from "../src/logger.ts"; -import { BASE_URL, DOMAIN, NOSTR_NIP57_PUBLIC_KEY } from "./constants.ts"; +import { BASE_URL } from "./constants.ts"; import { DB } from "./db/db.ts"; -import "./nwc/nwcPool.ts"; - -function getLnurlMetadata(username: string): string { - return JSON.stringify([ - ["text/identifier", `${username}@${DOMAIN}`], - ["text/plain", `Sats for ${username}`], - ]) -} - -export function createLnurlWellKnownApp(db: DB) { - const hono = new Hono(); - - hono.get("/:username", async (c) => { - try { - const username = c.req.param("username"); - - logger.debug("LNURLp request", { username }); - - // check the user exists - await db.findUser(username); - - // TODO: zapper support - - return c.json({ - tag: "payRequest", - commentAllowed: 255, - callback: `${BASE_URL}/lnurlp/${username}/callback`, - minSendable: 1000, - maxSendable: 10000000000, - metadata: getLnurlMetadata(username), - payerData: { - name: { - mandatory: false - }, - email: { - mandatory: false - }, - pubkey: { - mandatory: false - } - }, - ...(NOSTR_NIP57_PUBLIC_KEY ? { - nostrPubkey: NOSTR_NIP57_PUBLIC_KEY, - allowsNostr: true, - } : {}) - }); - } catch (error) { - return c.json({ status: "ERROR", reason: "" + error }); - } - }); - - return hono; -} export function createLnurlApp(db: DB) { const hono = new Hono(); diff --git a/src/main.ts b/src/main.ts index 126168e..62d97d5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,10 +5,11 @@ import { secureHeaders } from "hono/secure-headers"; //import { sentry } from "npm:@hono/sentry"; import { PORT } from "./constants.ts"; import { DB, runMigration } from "./db/db.ts"; -import { createLnurlApp, createLnurlWellKnownApp } from "./lnurlp.ts"; +import { createLnurlApp } from "./lnurlp.ts"; import { LOG_LEVEL, logger, loggerMiddleware } from "./logger.ts"; import { NWCPool } from "./nwc/nwcPool.ts"; import { createUsersApp } from "./users.ts"; +import { createLnurlWellKnownApp, createNostrWellKnownApp } from "./well-known/index.ts"; await runMigration(); @@ -29,6 +30,7 @@ hono.use(cors()); }*/ hono.route("/.well-known/lnurlp", createLnurlWellKnownApp(db)); +hono.route("/.well-known/nostr.json", createNostrWellKnownApp(db)); hono.route("/lnurlp", createLnurlApp(db)); hono.route("/users", createUsersApp(db, nwcPool)); diff --git a/src/users.ts b/src/users.ts index 34e6ea1..ce6e583 100644 --- a/src/users.ts +++ b/src/users.ts @@ -1,34 +1,59 @@ +import { nip19 } from "@nostr/tools"; import { Hono } from "hono"; +import postgres from "postgres"; import { DOMAIN } from "./constants.ts"; import { DB } from "./db/db.ts"; import { logger } from "./logger.ts"; import { NWCPool } from "./nwc/nwcPool.ts"; +import { isValid32ByteHex } from "./utils.ts"; export function createUsersApp(db: DB, nwcPool: NWCPool) { const hono = new Hono(); hono.post("/", async (c) => { - logger.debug("create user", {}); - - const createUserRequest: { connectionSecret: string; username?: string } = - await c.req.json(); - - if (!createUserRequest.connectionSecret) { - return c.text("no connection secret provided", 400); + try { + logger.debug("create user", {}); + + const createUserRequest: { connectionSecret: string; username?: string, nostrPubkey: string } = + await c.req.json(); + + if (!createUserRequest.connectionSecret) { + return c.text("no connection secret provided", 400); + } + + let nostrPubkey = createUserRequest.nostrPubkey + if (!nostrPubkey) { + return c.text("no nostr pubkey provided", 400); + } + + if (nostrPubkey.startsWith("npub")) { + nostrPubkey = nip19.decode(nostrPubkey).data as string + } + + if (!isValid32ByteHex(nostrPubkey)) { + return c.text("invalid nostr pubkey provided", 400); + } + + const user = await db.createUser( + createUserRequest.connectionSecret, + createUserRequest.username, + nostrPubkey + ); + + const lightningAddress = user.username + "@" + DOMAIN; + + nwcPool.subscribeUser(createUserRequest.connectionSecret, user.id); + + return c.json({ + lightningAddress, + }); + } catch (error) { + let reason = "" + error + if (error instanceof postgres.PostgresError && error.constraint_name === "users_username_unique") { + reason = "Username has already been taken" + } + return c.json({ status: "ERROR", reason }); } - - const user = await db.createUser( - createUserRequest.connectionSecret, - createUserRequest.username - ); - - const lightningAddress = user.username + "@" + DOMAIN; - - nwcPool.subscribeUser(createUserRequest.connectionSecret, user.id); - - return c.json({ - lightningAddress, - }); }); return hono; } diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..082454a --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,4 @@ +export function isValid32ByteHex(input: string): boolean { + const hexRegex = /^[a-fA-F0-9]{64}$/; + return hexRegex.test(input); +} diff --git a/src/well-known/index.ts b/src/well-known/index.ts new file mode 100644 index 0000000..535e53c --- /dev/null +++ b/src/well-known/index.ts @@ -0,0 +1,7 @@ +import { createLnurlWellKnownApp } from "./lnurlp.ts"; +import { createNostrWellKnownApp } from "./nostr.ts"; + +export { + createLnurlWellKnownApp, + createNostrWellKnownApp +}; diff --git a/src/well-known/lnurlp.ts b/src/well-known/lnurlp.ts new file mode 100644 index 0000000..a1c9c1b --- /dev/null +++ b/src/well-known/lnurlp.ts @@ -0,0 +1,54 @@ +import { Hono } from "hono"; +import { BASE_URL, DOMAIN, NOSTR_NIP57_PUBLIC_KEY } from "../constants.ts"; +import { DB } from "../db/db.ts"; +import { logger } from "../logger.ts"; + +function getLnurlMetadata(username: string): string { + return JSON.stringify([ + ["text/identifier", `${username}@${DOMAIN}`], + ["text/plain", `Sats for ${username}`], + ]) +} + +export function createLnurlWellKnownApp(db: DB) { + const hono = new Hono(); + + hono.get("/:username", async (c) => { + try { + const username = c.req.param("username"); + + logger.debug("LNURLp request", { username }); + + // check the user exists + await db.findUser(username); + + return c.json({ + tag: "payRequest", + commentAllowed: 255, + callback: `${BASE_URL}/lnurlp/${username}/callback`, + minSendable: 1000, + maxSendable: 10000000000, + metadata: getLnurlMetadata(username), + payerData: { + name: { + mandatory: false + }, + email: { + mandatory: false + }, + pubkey: { + mandatory: false + } + }, + ...(NOSTR_NIP57_PUBLIC_KEY ? { + nostrPubkey: NOSTR_NIP57_PUBLIC_KEY, + allowsNostr: true, + } : {}) + }); + } catch (error) { + return c.json({ status: "ERROR", reason: "" + error }); + } + }); + + return hono; +} diff --git a/src/well-known/nostr.ts b/src/well-known/nostr.ts new file mode 100644 index 0000000..6e5a64b --- /dev/null +++ b/src/well-known/nostr.ts @@ -0,0 +1,31 @@ +import { Hono } from "hono"; +import { DB } from "../db/db.ts"; +import { logger } from "../logger.ts"; + +export function createNostrWellKnownApp(db: DB) { + const hono = new Hono(); + + hono.get("/", async (c) => { + try { + const username = c.req.query("name"); + + logger.debug("NIP05 request", { username }); + + if (!username) { + throw new Error("No username provided"); + } + + const user = await db.findUser(username); + + return c.json({ + names: { + [username]: user.nostrPubkey + } + }); + } catch (error) { + return c.json({ status: "ERROR", reason: "" + error }); + } + }); + + return hono; +}