From 2c5037a4916a1842aea7a8568e66b4b17d2f7db1 Mon Sep 17 00:00:00 2001
From: nostrdev-com <support@nostrdev.com>
Date: Wed, 2 Apr 2025 10:11:37 +0300
Subject: [PATCH] feat(users): added validation to the POST route

---
 package-lock.json             | 183 +++++++++++++++++++++++++++++++++-
 package.json                  |   4 +-
 src/models/user.ts            |   2 +-
 src/routes/users.router.ts    |  34 ++++++-
 src/utils/index.ts            |   2 +
 src/utils/nostr.ts            |  34 +++++++
 src/utils/validation/index.ts |   1 +
 src/utils/validation/user.ts  |  29 ++++++
 8 files changed, 284 insertions(+), 5 deletions(-)
 create mode 100644 src/utils/index.ts
 create mode 100644 src/utils/nostr.ts
 create mode 100644 src/utils/validation/index.ts
 create mode 100644 src/utils/validation/user.ts

diff --git a/package-lock.json b/package-lock.json
index 524634c..8ca4c74 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,7 +13,9 @@
         "dotenv": "^16.4.7",
         "express": "^4.21.2",
         "i18n-iso-countries": "^7.14.0",
-        "mongodb": "^6.15.0"
+        "joi": "^17.13.3",
+        "mongodb": "^6.15.0",
+        "nostr-tools": "^2.11.1"
       },
       "devDependencies": {
         "@eslint/js": "^9.23.0",
@@ -301,6 +303,21 @@
         "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
       }
     },
+    "node_modules/@hapi/hoek": {
+      "version": "9.3.0",
+      "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
+      "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==",
+      "license": "BSD-3-Clause"
+    },
+    "node_modules/@hapi/topo": {
+      "version": "5.1.0",
+      "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz",
+      "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "@hapi/hoek": "^9.0.0"
+      }
+    },
     "node_modules/@humanfs/core": {
       "version": "0.19.1",
       "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -404,6 +421,51 @@
         "sparse-bitfield": "^3.0.3"
       }
     },
+    "node_modules/@noble/ciphers": {
+      "version": "0.5.3",
+      "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz",
+      "integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://paulmillr.com/funding/"
+      }
+    },
+    "node_modules/@noble/curves": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
+      "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==",
+      "license": "MIT",
+      "dependencies": {
+        "@noble/hashes": "1.3.2"
+      },
+      "funding": {
+        "url": "https://paulmillr.com/funding/"
+      }
+    },
+    "node_modules/@noble/curves/node_modules/@noble/hashes": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz",
+      "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 16"
+      },
+      "funding": {
+        "url": "https://paulmillr.com/funding/"
+      }
+    },
+    "node_modules/@noble/hashes": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz",
+      "integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 16"
+      },
+      "funding": {
+        "url": "https://paulmillr.com/funding/"
+      }
+    },
     "node_modules/@nodelib/fs.scandir": {
       "version": "2.1.5",
       "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -702,6 +764,57 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/@scure/base": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz",
+      "integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==",
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://paulmillr.com/funding/"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/@scure/bip32": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.1.tgz",
+      "integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==",
+      "license": "MIT",
+      "dependencies": {
+        "@noble/curves": "~1.1.0",
+        "@noble/hashes": "~1.3.1",
+        "@scure/base": "~1.1.0"
+      },
+      "funding": {
+        "url": "https://paulmillr.com/funding/"
+      }
+    },
+    "node_modules/@scure/bip32/node_modules/@noble/curves": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz",
+      "integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==",
+      "license": "MIT",
+      "dependencies": {
+        "@noble/hashes": "1.3.1"
+      },
+      "funding": {
+        "url": "https://paulmillr.com/funding/"
+      }
+    },
+    "node_modules/@scure/bip39": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.1.tgz",
+      "integrity": "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==",
+      "license": "MIT",
+      "dependencies": {
+        "@noble/hashes": "~1.3.0",
+        "@scure/base": "~1.1.0"
+      },
+      "funding": {
+        "url": "https://paulmillr.com/funding/"
+      }
+    },
     "node_modules/@sec-ant/readable-stream": {
       "version": "0.4.1",
       "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz",
@@ -1435,6 +1548,27 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/@sideway/address": {
+      "version": "4.1.5",
+      "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz",
+      "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "@hapi/hoek": "^9.0.0"
+      }
+    },
+    "node_modules/@sideway/formula": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz",
+      "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==",
+      "license": "BSD-3-Clause"
+    },
+    "node_modules/@sideway/pinpoint": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz",
+      "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==",
+      "license": "BSD-3-Clause"
+    },
     "node_modules/@sindresorhus/is": {
       "version": "2.1.1",
       "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-2.1.1.tgz",
@@ -4947,6 +5081,19 @@
         "node": ">= 0.6.0"
       }
     },
+    "node_modules/joi": {
+      "version": "17.13.3",
+      "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz",
+      "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "@hapi/hoek": "^9.3.0",
+        "@hapi/topo": "^5.1.0",
+        "@sideway/address": "^4.1.5",
+        "@sideway/formula": "^3.0.1",
+        "@sideway/pinpoint": "^2.0.0"
+      }
+    },
     "node_modules/js-tokens": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -6262,6 +6409,38 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/nostr-tools": {
+      "version": "2.11.1",
+      "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.11.1.tgz",
+      "integrity": "sha512-+Oj5t+behIkU9kh3go5wg8Aa5oR7euBU9gOItUNapJe5Gaa+KPzMuTIN+rMRK3DaZ4Zt6RM4kR/ddwstzGKf7g==",
+      "license": "Unlicense",
+      "dependencies": {
+        "@noble/ciphers": "^0.5.1",
+        "@noble/curves": "1.2.0",
+        "@noble/hashes": "1.3.1",
+        "@scure/base": "1.1.1",
+        "@scure/bip32": "1.3.1",
+        "@scure/bip39": "1.2.1"
+      },
+      "optionalDependencies": {
+        "nostr-wasm": "0.1.0"
+      },
+      "peerDependencies": {
+        "typescript": ">=5.0.0"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/nostr-wasm": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz",
+      "integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==",
+      "license": "MIT",
+      "optional": true
+    },
     "node_modules/npm": {
       "version": "10.9.2",
       "resolved": "https://registry.npmjs.org/npm/-/npm-10.9.2.tgz",
@@ -11277,7 +11456,7 @@
       "version": "5.8.2",
       "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz",
       "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==",
-      "dev": true,
+      "devOptional": true,
       "license": "Apache-2.0",
       "bin": {
         "tsc": "bin/tsc",
diff --git a/package.json b/package.json
index 211a380..96099ab 100644
--- a/package.json
+++ b/package.json
@@ -30,7 +30,9 @@
     "dotenv": "^16.4.7",
     "express": "^4.21.2",
     "i18n-iso-countries": "^7.14.0",
-    "mongodb": "^6.15.0"
+    "joi": "^17.13.3",
+    "mongodb": "^6.15.0",
+    "nostr-tools": "^2.11.1"
   },
   "devDependencies": {
     "@eslint/js": "^9.23.0",
diff --git a/src/models/user.ts b/src/models/user.ts
index 1b64321..93fe1ca 100644
--- a/src/models/user.ts
+++ b/src/models/user.ts
@@ -4,7 +4,7 @@ import { UserRole } from '../types'
 export class User {
   constructor(
     public name: string,
-    public npub: string[],
+    public npub: string | string[],
     public role: UserRole,
     public id?: ObjectId
   ) {}
diff --git a/src/routes/users.router.ts b/src/routes/users.router.ts
index 6da619a..c62a48d 100644
--- a/src/routes/users.router.ts
+++ b/src/routes/users.router.ts
@@ -2,6 +2,8 @@
 import express, { Request, Response } from 'express'
 import { collections } from '../services/database.service'
 import { User } from '../models'
+import { userValidation } from '../utils'
+import Joi from 'joi'
 
 // Global Config
 export const usersRouter = express.Router()
@@ -26,7 +28,35 @@ usersRouter.get('/', async (_req: Request, res: Response) => {
 // POST
 usersRouter.post('/', async (req: Request, res: Response) => {
   try {
-    const newUser = req.body as User
+    const {
+      error,
+      value: newUser
+    }: { error: Joi.ValidationError | undefined; value: User } = userValidation(
+      req.body
+    )
+
+    if (error) {
+      throw error.details[0].message
+    }
+
+    if (typeof newUser.npub === 'string') {
+      const users = await collections.users
+        ?.find({ npub: newUser.npub })
+        .toArray()
+
+      if (users?.length) {
+        throw new Error('user with provided "npub" exists')
+      }
+    } else {
+      for (const npub of newUser.npub) {
+        const users = await collections.users?.find({ npub }).toArray()
+
+        if (users?.length) {
+          throw new Error('user with provided "npub" exists')
+        }
+      }
+    }
+
     const result = await collections.users?.insertOne(newUser)
 
     if (result) {
@@ -41,6 +71,8 @@ usersRouter.post('/', async (req: Request, res: Response) => {
 
     if (error instanceof Error) {
       res.status(400).send(error.message)
+    } else if (typeof error === 'string') {
+      res.status(400).send(error)
     }
   }
 })
diff --git a/src/utils/index.ts b/src/utils/index.ts
new file mode 100644
index 0000000..b46597d
--- /dev/null
+++ b/src/utils/index.ts
@@ -0,0 +1,2 @@
+export * from './validation'
+export * from './nostr'
diff --git a/src/utils/nostr.ts b/src/utils/nostr.ts
new file mode 100644
index 0000000..9e16cda
--- /dev/null
+++ b/src/utils/nostr.ts
@@ -0,0 +1,34 @@
+import { nip19 } from 'nostr-tools'
+
+/**
+ * NPUB provided - it will convert NPUB to HEX
+ * HEX provided - it will return HEX
+ *
+ * @param pubKey in NPUB, HEX format
+ * @returns HEX format
+ */
+export const npubToHex = (pubKey: string): string | null => {
+  // If key is NPUB
+  if (pubKey.startsWith('npub1')) {
+    try {
+      return nip19.decode(pubKey).data as string
+    } catch (err) {
+      console.log(Error(`Error converting npub to hex. Error: ${err}`))
+      return null
+    }
+  }
+
+  // valid hex key
+  if (validateHex(pubKey)) return pubKey
+
+  // Not a valid hex key
+  return null
+}
+
+/**
+ * @param hexKey hex private or public key
+ * @returns whether or not is key valid
+ */
+export const validateHex = (hexKey: string) => {
+  return hexKey.match(/^[a-f0-9]{64}$/)
+}
diff --git a/src/utils/validation/index.ts b/src/utils/validation/index.ts
new file mode 100644
index 0000000..c3a9c65
--- /dev/null
+++ b/src/utils/validation/index.ts
@@ -0,0 +1 @@
+export * from './user'
diff --git a/src/utils/validation/user.ts b/src/utils/validation/user.ts
new file mode 100644
index 0000000..e022e7c
--- /dev/null
+++ b/src/utils/validation/user.ts
@@ -0,0 +1,29 @@
+import Joi from 'joi'
+import { UserRole } from '../../types'
+import { npubToHex, validateHex } from '../nostr'
+
+const npubValidation = (value: unknown, helper: Joi.CustomHelpers<unknown>) => {
+  const hex = npubToHex(value as string)
+
+  if (!hex || !validateHex(hex)) {
+    return helper.message({
+      custom: Joi.expression('"npub" contains an invalid value')
+    })
+  }
+
+  return hex
+}
+
+export const userValidation = (data: unknown): Joi.ValidationResult =>
+  Joi.object({
+    name: Joi.string().not('').required(),
+    npub: Joi.alternatives()
+      .try(
+        Joi.array().items(Joi.string().custom(npubValidation)),
+        Joi.string().not('').custom(npubValidation)
+      )
+      .required(),
+    role: Joi.string()
+      .valid(...Object.values(UserRole))
+      .required()
+  }).validate(data)
-- 
2.43.0