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)