diff --git a/api/resolvers/invite.js b/api/resolvers/invite.js index 579a6cc1..347d7152 100644 --- a/api/resolvers/invite.js +++ b/api/resolvers/invite.js @@ -1,4 +1,4 @@ -import { inviteSchema, ssValidate } from '@/lib/validate' +import { inviteSchema, validateSchema } from '@/lib/validate' import { msatsToSats } from '@/lib/format' import assertApiKeyNotPermitted from './apiKey' import { GqlAuthenticationError } from '@/lib/error' @@ -35,7 +35,7 @@ export default { } assertApiKeyNotPermitted({ me }) - await ssValidate(inviteSchema, { gift, limit }) + await validateSchema(inviteSchema, { gift, limit }) return await models.invite.create({ data: { gift, limit, userId: me.id } diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 06ddec3c..ae7b6dc5 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -13,7 +13,7 @@ import { import { msatsToSats } from '@/lib/format' import { parse } from 'tldts' import uu from 'url-unshort' -import { actSchema, advSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, ssValidate } from '@/lib/validate' +import { actSchema, advSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, validateSchema } from '@/lib/validate' import { defaultCommentSort, isJob, deleteItemByAuthor } from '@/lib/item' import { datePivot, whenRange } from '@/lib/time' import { uploadIdsFromText } from './upload' @@ -844,7 +844,7 @@ export default { return await deleteItemByAuthor({ models, id, item: old }) }, upsertLink: async (parent, { id, ...item }, { me, models, lnd }) => { - await ssValidate(linkSchema, item, { models, me }) + await validateSchema(linkSchema, item, { models, me }) if (id) { return await updateItem(parent, { id, ...item }, { me, models, lnd }) @@ -853,7 +853,7 @@ export default { } }, upsertDiscussion: async (parent, { id, ...item }, { me, models, lnd }) => { - await ssValidate(discussionSchema, item, { models, me }) + await validateSchema(discussionSchema, item, { models, me }) if (id) { return await updateItem(parent, { id, ...item }, { me, models, lnd }) @@ -862,7 +862,7 @@ export default { } }, upsertBounty: async (parent, { id, ...item }, { me, models, lnd }) => { - await ssValidate(bountySchema, item, { models, me }) + await validateSchema(bountySchema, item, { models, me }) if (id) { return await updateItem(parent, { id, ...item }, { me, models, lnd }) @@ -879,7 +879,7 @@ export default { }) : 0 - await ssValidate(pollSchema, item, { models, me, numExistingChoices }) + await validateSchema(pollSchema, item, { models, me, numExistingChoices }) if (id) { return await updateItem(parent, { id, ...item }, { me, models, lnd }) @@ -894,7 +894,7 @@ export default { } item.location = item.location?.toLowerCase() === 'remote' ? undefined : item.location - await ssValidate(jobSchema, item, { models }) + await validateSchema(jobSchema, item, { models }) if (item.logo !== undefined) { item.uploadId = item.logo delete item.logo @@ -907,7 +907,7 @@ export default { } }, upsertComment: async (parent, { id, ...item }, { me, models, lnd }) => { - await ssValidate(commentSchema, item) + await validateSchema(commentSchema, item) if (id) { return await updateItem(parent, { id, ...item }, { me, models, lnd }) @@ -937,7 +937,7 @@ export default { }, act: async (parent, { id, sats, act = 'TIP' }, { me, models, lnd, headers }) => { assertApiKeyNotPermitted({ me }) - await ssValidate(actSchema, { sats, act }) + await validateSchema(actSchema, { sats, act }) await assertGofacYourself({ models, headers }) const [item] = await models.$queryRawUnsafe(` @@ -1369,7 +1369,7 @@ export const updateItem = async (parent, { sub: subName, forward, hash, hmac, .. } // in case they lied about their existing boost - await ssValidate(advSchema, { boost: item.boost }, { models, me, existingBoost: old.boost }) + await validateSchema(advSchema, { boost: item.boost }, { models, me, existingBoost: old.boost }) const user = await models.user.findUnique({ where: { id: meId } }) diff --git a/api/resolvers/notifications.js b/api/resolvers/notifications.js index 20b6ceac..a934f64f 100644 --- a/api/resolvers/notifications.js +++ b/api/resolvers/notifications.js @@ -1,7 +1,7 @@ import { decodeCursor, LIMIT, nextNoteCursorEncoded } from '@/lib/cursor' import { getItem, filterClause, whereClause, muteClause, activeOrMine } from './item' import { getInvoice, getWithdrawl } from './wallet' -import { pushSubscriptionSchema, ssValidate } from '@/lib/validate' +import { pushSubscriptionSchema, validateSchema } from '@/lib/validate' import { replyToSubscription } from '@/lib/webPush' import { getSub } from './sub' import { GqlAuthenticationError, GqlInputError } from '@/lib/error' @@ -375,7 +375,7 @@ export default { throw new GqlAuthenticationError() } - await ssValidate(pushSubscriptionSchema, { endpoint, p256dh, auth }) + await validateSchema(pushSubscriptionSchema, { endpoint, p256dh, auth }) let dbPushSubscription if (oldEndpoint) { diff --git a/api/resolvers/rewards.js b/api/resolvers/rewards.js index ce476f35..3dcfd2c2 100644 --- a/api/resolvers/rewards.js +++ b/api/resolvers/rewards.js @@ -1,4 +1,4 @@ -import { amountSchema, ssValidate } from '@/lib/validate' +import { amountSchema, validateSchema } from '@/lib/validate' import { getAd, getItem } from './item' import { topUsers } from './user' import performPaidAction from '../paidAction' @@ -171,7 +171,7 @@ export default { }, Mutation: { donateToRewards: async (parent, { sats }, { me, models, lnd }) => { - await ssValidate(amountSchema, { amount: sats }) + await validateSchema(amountSchema, { amount: sats }) return await performPaidAction('DONATE', { sats }, { me, models, lnd }) } diff --git a/api/resolvers/sub.js b/api/resolvers/sub.js index 09279869..320670b6 100644 --- a/api/resolvers/sub.js +++ b/api/resolvers/sub.js @@ -1,5 +1,5 @@ import { whenRange } from '@/lib/time' -import { ssValidate, territorySchema } from '@/lib/validate' +import { validateSchema, territorySchema } from '@/lib/validate' import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor' import { viewGroup } from './growth' import { notifyTerritoryTransfer } from '@/lib/webPush' @@ -157,7 +157,7 @@ export default { throw new GqlAuthenticationError() } - await ssValidate(territorySchema, data, { models, me, sub: { name: data.oldName } }) + await validateSchema(territorySchema, data, { models, me, sub: { name: data.oldName } }) if (data.oldName) { return await updateSub(parent, data, { me, models, lnd }) @@ -260,7 +260,7 @@ export default { const { name } = data - await ssValidate(territorySchema, data, { models, me }) + await validateSchema(territorySchema, data, { models, me }) const oldSub = await models.sub.findUnique({ where: { name } }) if (!oldSub) { diff --git a/api/resolvers/user.js b/api/resolvers/user.js index 86e73e04..cd518e70 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -2,7 +2,7 @@ import { readFile } from 'fs/promises' import { join, resolve } from 'path' import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor' import { msatsToSats } from '@/lib/format' -import { bioSchema, emailSchema, settingsSchema, ssValidate, userSchema } from '@/lib/validate' +import { bioSchema, emailSchema, settingsSchema, validateSchema, userSchema } from '@/lib/validate' import { getItem, updateItem, filterClause, createItem, whereClause, muteClause, activeOrMine } from './item' import { USER_ID, RESERVED_MAX_USER_ID, SN_NO_REWARDS_IDS, INVOICE_ACTION_NOTIFICATION_TYPES } from '@/lib/constants' import { viewGroup } from './growth' @@ -632,7 +632,7 @@ export default { throw new GqlAuthenticationError() } - await ssValidate(userSchema, data, { models }) + await validateSchema(userSchema, data, { models }) try { await models.user.update({ where: { id: me.id }, data }) @@ -649,7 +649,7 @@ export default { throw new GqlAuthenticationError() } - await ssValidate(settingsSchema, { nostrRelays, ...data }) + await validateSchema(settingsSchema, { nostrRelays, ...data }) if (nostrRelays?.length) { const connectOrCreate = [] @@ -696,7 +696,7 @@ export default { throw new GqlAuthenticationError() } - await ssValidate(bioSchema, { text }) + await validateSchema(bioSchema, { text }) const user = await models.user.findUnique({ where: { id: me.id } }) @@ -770,7 +770,7 @@ export default { } assertApiKeyNotPermitted({ me }) - await ssValidate(emailSchema, { email }) + await validateSchema(emailSchema, { email }) try { await models.user.update({ diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index 05ac369e..6711b20b 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -12,7 +12,7 @@ import { ANON_BALANCE_LIMIT_MSATS, ANON_INV_PENDING_LIMIT, USER_ID, BALANCE_LIMIT_MSATS, INVOICE_RETENTION_DAYS, INV_PENDING_LIMIT, USER_IDS_BALANCE_NO_LIMIT, LND_PATHFINDING_TIMEOUT_MS } from '@/lib/constants' -import { amountSchema, ssValidate, withdrawlSchema, lnAddrSchema, walletValidate } from '@/lib/validate' +import { amountSchema, validateSchema, withdrawlSchema, lnAddrSchema } from '@/lib/validate' import { datePivot } from '@/lib/time' import assertGofacYourself from './ofac' import assertApiKeyNotPermitted from './apiKey' @@ -23,6 +23,7 @@ import { generateResolverName, generateTypeDefName } from '@/wallets/graphql' import { lnAddrOptions } from '@/lib/lnurl' import { GqlAuthenticationError, GqlAuthorizationError, GqlInputError } from '@/lib/error' import { getNodeSockets, getOurPubkey } from '../lnd' +import validateWallet from '@/wallets/validate' function injectResolvers (resolvers) { console.group('injected GraphQL resolvers:') @@ -32,7 +33,7 @@ function injectResolvers (resolvers) { resolvers.Mutation[resolverName] = async (parent, { settings, validateLightning, vaultEntries, ...data }, { me, models }) => { // allow transformation of the data on validation (this is optional ... won't do anything if not implemented) // TODO: our validation should be improved - const validData = await walletValidate(walletDef, { ...data, ...settings, vaultEntries }) + const validData = await validateWallet(walletDef, { ...data, ...settings, vaultEntries }) if (validData) { Object.keys(validData).filter(key => key in data).forEach(key => { data[key] = validData[key] }) Object.keys(validData).filter(key => key in settings).forEach(key => { settings[key] = validData[key] }) @@ -437,7 +438,7 @@ const resolvers = { }, Mutation: { createInvoice: async (parent, { amount, hodlInvoice = false, expireSecs = 3600 }, { me, models, lnd, headers }) => { - await ssValidate(amountSchema, { amount }) + await validateSchema(amountSchema, { amount }) await assertGofacYourself({ models, headers }) let expirePivot = { seconds: expireSecs } @@ -783,7 +784,7 @@ async function upsertWallet ( export async function createWithdrawal (parent, { invoice, maxFee }, { me, models, lnd, headers, walletId = null }) { assertApiKeyNotPermitted({ me }) - await ssValidate(withdrawlSchema, { invoice, maxFee }) + await validateSchema(withdrawlSchema, { invoice, maxFee }) await assertGofacYourself({ models, headers }) // remove 'lightning:' prefix if present @@ -867,7 +868,7 @@ export async function fetchLnAddrInvoice ( me, models, lnd, autoWithdraw = false }) { const options = await lnAddrOptions(addr) - await ssValidate(lnAddrSchema, { addr, amount, maxFee, comment, ...payer }, options) + await validateSchema(lnAddrSchema, { addr, amount, maxFee, comment, ...payer }, options) if (payer) { payer = { diff --git a/components/autowithdraw-shared.js b/components/autowithdraw-shared.js index 47f01bb8..6bdc4045 100644 --- a/components/autowithdraw-shared.js +++ b/components/autowithdraw-shared.js @@ -1,11 +1,9 @@ import { InputGroup } from 'react-bootstrap' -import { Checkbox, Input } from './form' +import { Input } from './form' import { useMe } from './me' import { useEffect, useState } from 'react' import { isNumber } from '@/lib/validate' -import { useIsClient } from './use-client' import Link from 'next/link' -import { isConfigured } from '@/wallets/common' function autoWithdrawThreshold ({ me }) { return isNumber(me?.privates?.autoWithdrawThreshold) ? me?.privates?.autoWithdrawThreshold : 10000 @@ -19,7 +17,7 @@ export function autowithdrawInitial ({ me }) { } } -export function AutowithdrawSettings ({ wallet }) { +export function AutowithdrawSettings () { const { me } = useMe() const threshold = autoWithdrawThreshold({ me }) @@ -29,16 +27,8 @@ export function AutowithdrawSettings ({ wallet }) { setSendThreshold(Math.max(Math.floor(threshold / 10), 1)) }, [autoWithdrawThreshold]) - const isClient = useIsClient() - return ( <> -

desired balance

diff --git a/lib/validate.js b/lib/validate.js index 96753c94..5e9b30c9 100644 --- a/lib/validate.js +++ b/lib/validate.js @@ -1,4 +1,4 @@ -import { string, ValidationError, number, object, array, addMethod, boolean, date } from 'yup' +import { string, ValidationError, number, object, array, boolean, date } from './yup' import { BOOST_MIN, MAX_POLL_CHOICE_LENGTH, MAX_TITLE_LENGTH, MAX_POLL_NUM_CHOICES, MIN_POLL_NUM_CHOICES, MAX_FORWARDS, BOOST_MULT, MAX_TERRITORY_DESC_LENGTH, POST_TYPES, @@ -6,19 +6,16 @@ import { } from './constants' import { SUPPORTED_CURRENCIES } from './currency' import { NOSTR_MAX_RELAY_NUM, NOSTR_PUBKEY_BECH32, NOSTR_PUBKEY_HEX } from './nostr' -import { msatsToSats, numWithUnits, abbrNum, ensureB64, B64_URL_REGEX, HEX_REGEX } from './format' +import { msatsToSats, numWithUnits, abbrNum } from './format' import * as usersFragments from '@/fragments/users' import * as subsFragments from '@/fragments/subs' -import { isInvoicableMacaroon, isInvoiceMacaroon } from './macaroon' -import { TOR_REGEXP, parseNwcUrl } from './url' import { datePivot } from './time' -import { decodeRune } from '@/lib/cln' import bip39Words from './bip39-words' const { SUB } = subsFragments const { NAME_QUERY } = usersFragments -export async function ssValidate (schema, data, args) { +export async function validateSchema (schema, data, args) { try { if (typeof schema === 'function') { return await schema(args).validate(data) @@ -33,159 +30,6 @@ export async function ssValidate (schema, data, args) { } } -export async function formikValidate (validate, data) { - const result = await validate(data) - if (Object.keys(result).length > 0) { - const [key, message] = Object.entries(result)[0] - throw new Error(`${key}: ${message}`) - } - return result -} - -export async function walletValidate (walletDef, data) { - if (typeof walletDef.fieldValidation === 'function') { - return await formikValidate(walletDef.fieldValidation, data) - } else { - return await ssValidate(walletDef.fieldValidation, data) - } -} - -addMethod(string, 'or', function (schemas, msg) { - return this.test({ - name: 'or', - message: msg, - test: value => { - if (Array.isArray(schemas) && schemas.length > 1) { - const resee = schemas.map(schema => schema.isValidSync(value)) - return resee.some(res => res) - } else { - throw new TypeError('Schemas is not correct array schema') - } - }, - exclusive: false - }) -}) - -addMethod(string, 'url', function (schemas, msg = 'invalid url') { - return this.test({ - name: 'url', - message: msg, - test: value => { - try { - // eslint-disable-next-line no-new - new URL(value) - return true - } catch (e) { - try { - // eslint-disable-next-line no-new - new URL(`http://${value}`) - return true - } catch (e) { - return false - } - } - }, - exclusive: false - }) -}) - -addMethod(string, 'ws', function (schemas, msg = 'invalid websocket') { - return this.test({ - name: 'ws', - message: msg, - test: value => { - if (typeof value === 'undefined') return true - try { - const url = new URL(value) - return url.protocol === 'ws:' || url.protocol === 'wss:' - } catch (e) { - return false - } - }, - exclusive: false - }) -}) - -addMethod(string, 'socket', function (schemas, msg = 'invalid socket') { - return this.test({ - name: 'socket', - message: msg, - test: value => { - try { - const url = new URL(`http://${value}`) - return url.hostname && url.port && !url.username && !url.password && - (!url.pathname || url.pathname === '/') && !url.search && !url.hash - } catch (e) { - return false - } - }, - exclusive: false - }) -}) - -addMethod(string, 'https', function () { - return this.test({ - name: 'https', - message: 'https required', - test: (url) => { - try { - return new URL(url).protocol === 'https:' - } catch { - return false - } - } - }) -}) - -addMethod(string, 'wss', function (msg) { - return this.test({ - name: 'wss', - message: msg || 'wss required', - test: (url) => { - try { - return new URL(url).protocol === 'wss:' - } catch { - return false - } - } - }) -}) - -addMethod(string, 'hex', function (msg) { - return this.test({ - name: 'hex', - message: msg || 'invalid hex encoding', - test: (value) => !value || HEX_REGEX.test(value) - }) -}) - -addMethod(string, 'nwcUrl', function () { - return this.test({ - test: async (nwcUrl, context) => { - if (!nwcUrl) return true - - // run validation in sequence to control order of errors - // inspired by https://github.com/jquense/yup/issues/851#issuecomment-1049705180 - try { - await string().matches(/^nostr\+?walletconnect:\/\//, { message: 'must start with nostr+walletconnect://' }).validate(nwcUrl) - let relayUrl, walletPubkey, secret - try { - ({ relayUrl, walletPubkey, secret } = parseNwcUrl(nwcUrl)) - } catch { - // invalid URL error. handle as if pubkey validation failed to not confuse user. - throw new Error('pubkey must be 64 hex chars') - } - await string().required('pubkey required').trim().matches(NOSTR_PUBKEY_HEX, 'pubkey must be 64 hex chars').validate(walletPubkey) - await string().required('relay url required').trim().wss('relay must use wss://').validate(relayUrl) - await string().required('secret required').trim().matches(/^[0-9a-fA-F]{64}$/, 'secret must be 64 hex chars').validate(secret) - } catch (err) { - return context.createError({ message: err.message }) - } - return true - } - }) -}) - const titleValidator = string().required('required').trim().max( MAX_TITLE_LENGTH, ({ max, value }) => `-${Math.abs(max - value.length)} characters remaining` @@ -203,32 +47,12 @@ const nameValidator = string() const intValidator = number().typeError('must be a number').integer('must be whole') const floatValidator = number().typeError('must be a number') -const lightningAddressValidator = process.env.NODE_ENV === 'development' +export const lightningAddressValidator = process.env.NODE_ENV === 'development' ? string().or( [string().matches(/^[\w_]+@localhost:\d+$/), string().matches(/^[\w_]+@app:\d+$/), string().email()], 'address is no good') : string().email('address is no good') -const hexOrBase64Validator = string().test({ - name: 'hex-or-base64', - message: 'invalid encoding', - test: (val) => { - if (typeof val === 'undefined') return true - try { - ensureB64(val) - return true - } catch { - return false - } - } -}).transform(val => { - try { - return ensureB64(val) - } catch { - return val - } -}) - async function usernameExists (name, { client, models }) { if (!client && !models) { throw new Error('cannot check for user') @@ -363,56 +187,59 @@ export function advSchema (args) { }) } -export const autowithdrawSchemaMembers = { - enabled: boolean(), - autoWithdrawThreshold: intValidator.required('required').min(0, 'must be at least 0').max(msatsToSats(BALANCE_LIMIT_MSATS), `must be at most ${abbrNum(msatsToSats(BALANCE_LIMIT_MSATS))}`), - autoWithdrawMaxFeePercent: floatValidator.required('required').min(0, 'must be at least 0').max(50, 'must not exceed 50'), - autoWithdrawMaxFeeTotal: intValidator.required('required').min(0, 'must be at least 0').max(1_000, 'must not exceed 1000') -} - -export const lnAddrAutowithdrawSchema = object({ - address: lightningAddressValidator.required('required').test({ - name: 'address', - test: addr => !addr.endsWith('@stacker.news'), - message: 'automated withdrawals must be external' - }), - ...autowithdrawSchemaMembers +export const autowithdrawSchemaMembers = object({ + autoWithdrawThreshold: intValidator.required('required').min(0, 'must be at least 0').max(msatsToSats(BALANCE_LIMIT_MSATS), `must be at most ${abbrNum(msatsToSats(BALANCE_LIMIT_MSATS))}`).transform(Number), + autoWithdrawMaxFeePercent: floatValidator.required('required').min(0, 'must be at least 0').max(50, 'must not exceed 50').transform(Number), + autoWithdrawMaxFeeTotal: intValidator.required('required').min(0, 'must be at least 0').max(1_000, 'must not exceed 1000').transform(Number) }) -export const LNDAutowithdrawSchema = object({ - socket: string().socket().required('required'), - macaroon: hexOrBase64Validator.required('required').test({ - name: 'macaroon', - test: v => isInvoiceMacaroon(v) || isInvoicableMacaroon(v), - message: 'not an invoice macaroon or an invoicable macaroon' - }), - cert: hexOrBase64Validator, - ...autowithdrawSchemaMembers -}) +export const lnAddrSchema = ({ payerData, min, max, commentAllowed } = {}) => + object({ + addr: lightningAddressValidator.required('required'), + amount: (() => { + const schema = intValidator.required('required').positive('must be positive').min( + min || 1, `must be at least ${min || 1}`) + return max ? schema.max(max, `must be at most ${max}`) : schema + })(), + maxFee: intValidator.required('required').min(0, 'must be at least 0'), + comment: commentAllowed + ? string().max(commentAllowed, `must be less than ${commentAllowed}`) + : string() + }).concat(object().shape(Object.keys(payerData || {}).reduce((accum, key) => { + const entry = payerData[key] + if (key === 'email') { + accum[key] = string().email() + } else if (key === 'identifier') { + accum[key] = boolean() + } else { + accum[key] = string() + } + if (entry?.mandatory) { + accum[key] = accum[key].required() + } + return accum + }, {}))) -export const CLNAutowithdrawSchema = object({ - socket: string().socket().required('required'), - rune: string().matches(B64_URL_REGEX, { message: 'invalid rune' }).required('required') - .test({ - name: 'rune', - test: (v, context) => { - const decoded = decodeRune(v) - if (!decoded) return context.createError({ message: 'invalid rune' }) - if (decoded.restrictions.length === 0) { - return context.createError({ message: 'rune must be restricted to method=invoice' }) - } - if (decoded.restrictions.length !== 1 || decoded.restrictions[0].alternatives.length !== 1) { - return context.createError({ message: 'rune must be restricted to method=invoice only' }) - } - if (decoded.restrictions[0].alternatives[0] !== 'method=invoice') { - return context.createError({ message: 'rune must be restricted to method=invoice only' }) - } - return true - } +export const phoenixdSchema = object().shape({ + url: string().url().required('required').trim(), + primaryPassword: string().length(64).hex() + .when(['secondaryPassword'], ([secondary], schema) => { + if (!secondary) return schema.required('required if secondary password not set') + return schema.test({ + test: primary => secondary !== primary, + message: 'primary password cannot be the same as secondary password' + }) + }), + secondaryPassword: string().length(64).hex() + .when(['primaryPassword'], ([primary], schema) => { + if (!primary) return schema.required('required if primary password not set') + return schema.test({ + test: secondary => primary !== secondary, + message: 'secondary password cannot be the same as primary password' + }) }), - cert: hexOrBase64Validator, ...autowithdrawSchemaMembers -}) +}, ['primaryPassword', 'secondaryPassword']) export function bountySchema (args) { return object({ @@ -663,146 +490,6 @@ export const withdrawlSchema = object({ maxFee: intValidator.required('required').min(0, 'must be at least 0') }) -export const lnAddrSchema = ({ payerData, min, max, commentAllowed } = {}) => - object({ - addr: lightningAddressValidator.required('required'), - amount: (() => { - const schema = intValidator.required('required').positive('must be positive').min( - min || 1, `must be at least ${min || 1}`) - return max ? schema.max(max, `must be at most ${max}`) : schema - })(), - maxFee: intValidator.required('required').min(0, 'must be at least 0'), - comment: commentAllowed - ? string().max(commentAllowed, `must be less than ${commentAllowed}`) - : string() - }).concat(object().shape(Object.keys(payerData || {}).reduce((accum, key) => { - const entry = payerData[key] - if (key === 'email') { - accum[key] = string().email() - } else if (key === 'identifier') { - accum[key] = boolean() - } else { - accum[key] = string() - } - if (entry?.mandatory) { - accum[key] = accum[key].required() - } - return accum - }, {}))) - -export const lnbitsSchema = object().shape({ - url: process.env.NODE_ENV === 'development' - ? string() - .or([string().matches(/^(http:\/\/)?localhost:\d+$/), string().url()], 'invalid url') - .required('required').trim() - : string().url().required('required').trim() - .test(async (url, context) => { - if (TOR_REGEXP.test(url)) { - // allow HTTP and HTTPS over Tor - if (!/^https?:\/\//.test(url)) { - return context.createError({ message: 'http or https required' }) - } - return true - } - try { - // force HTTPS over clearnet - await string().https().validate(url) - } catch (err) { - return context.createError({ message: err.message }) - } - return true - }), - adminKey: string().length(32).hex() - .when(['invoiceKey'], ([invoiceKey], schema) => { - if (!invoiceKey) return schema.required('required if invoice key not set') - return schema.test({ - test: adminKey => adminKey !== invoiceKey, - message: 'admin key cannot be the same as invoice key' - }) - }), - invoiceKey: string().length(32).hex() - .when(['adminKey'], ([adminKey], schema) => { - if (!adminKey) return schema.required('required if admin key not set') - return schema.test({ - test: invoiceKey => adminKey !== invoiceKey, - message: 'invoice key cannot be the same as admin key' - }) - }), - ...autowithdrawSchemaMembers - // need to set order to avoid cyclic dependencies in Yup schema - // see https://github.com/jquense/yup/issues/176#issuecomment-367352042 -}, ['adminKey', 'invoiceKey']) - -export const nwcSchema = object().shape({ - nwcUrl: string().nwcUrl().when(['nwcUrlRecv'], ([nwcUrlRecv], schema) => { - if (!nwcUrlRecv) return schema.required('required if connection for receiving not set') - return schema.test({ - test: nwcUrl => nwcUrl !== nwcUrlRecv, - message: 'connection for sending cannot be the same as for receiving' - }) - }), - nwcUrlRecv: string().nwcUrl().when(['nwcUrl'], ([nwcUrl], schema) => { - if (!nwcUrl) return schema.required('required if connection for sending not set') - return schema.test({ - test: nwcUrlRecv => nwcUrlRecv !== nwcUrl, - message: 'connection for receiving cannot be the same as for sending' - }) - }), - ...autowithdrawSchemaMembers -}, ['nwcUrl', 'nwcUrlRecv']) - -export const blinkSchema = object({ - apiKey: string() - .required('required') - .matches(/^blink_[A-Za-z0-9]+$/, { message: 'must match pattern blink_A-Za-z0-9' }), - currency: string() - .transform(value => value ? value.toUpperCase() : 'BTC') - .oneOf(['USD', 'BTC'], 'must be BTC or USD') -}) - -export const lncSchema = object({ - pairingPhrase: string() - .test(async (value, context) => { - const words = value ? value.trim().split(/[\s]+/) : [] - for (const w of words) { - try { - await string().oneOf(bip39Words).validate(w) - } catch { - return context.createError({ message: `'${w}' is not a valid pairing phrase word` }) - } - } - if (words.length < 2) { - return context.createError({ message: 'needs at least two words' }) - } - if (words.length > 10) { - return context.createError({ message: 'max 10 words' }) - } - return true - }) - .required('required') -}) - -export const phoenixdSchema = object().shape({ - url: string().url().required('required').trim(), - primaryPassword: string().length(64).hex() - .when(['secondaryPassword'], ([secondary], schema) => { - if (!secondary) return schema.required('required if secondary password not set') - return schema.test({ - test: primary => secondary !== primary, - message: 'primary password cannot be the same as secondary password' - }) - }), - secondaryPassword: string().length(64).hex() - .when(['primaryPassword'], ([primary], schema) => { - if (!primary) return schema.required('required if primary password not set') - return schema.test({ - test: secondary => primary !== secondary, - message: 'secondary password cannot be the same as primary password' - }) - }), - ...autowithdrawSchemaMembers -}, ['primaryPassword', 'secondaryPassword']) - export const bioSchema = object({ text: string().required('required').trim() }) diff --git a/lib/yup.js b/lib/yup.js new file mode 100644 index 00000000..6102eae4 --- /dev/null +++ b/lib/yup.js @@ -0,0 +1,167 @@ +import { addMethod, string, mixed } from 'yup' +import { parseNwcUrl } from './url' +import { NOSTR_PUBKEY_HEX } from './nostr' +import { ensureB64, HEX_REGEX } from './format' + +function orFunc (schemas, msg) { + return this.test({ + name: 'or', + message: msg, + test: value => { + if (Array.isArray(schemas) && schemas.length > 1) { + const resee = schemas.map(schema => schema.isValidSync(value)) + return resee.some(res => res) + } else { + throw new TypeError('Schemas is not correct array schema') + } + }, + exclusive: false + }) +} + +addMethod(mixed, 'or', orFunc) +addMethod(string, 'or', orFunc) + +addMethod(string, 'hexOrBase64', function (schemas, msg = 'invalid hex or base64 encoding') { + return this.test({ + name: 'hex-or-base64', + message: 'invalid encoding', + test: (val) => { + if (typeof val === 'undefined') return true + try { + ensureB64(val) + return true + } catch { + return false + } + } + }).transform(val => { + try { + return ensureB64(val) + } catch { + return val + } + }) +}) + +addMethod(string, 'url', function (schemas, msg = 'invalid url') { + return this.test({ + name: 'url', + message: msg, + test: value => { + try { + // eslint-disable-next-line no-new + new URL(value) + return true + } catch (e) { + try { + // eslint-disable-next-line no-new + new URL(`http://${value}`) + return true + } catch (e) { + return false + } + } + }, + exclusive: false + }) +}) + +addMethod(string, 'ws', function (schemas, msg = 'invalid websocket') { + return this.test({ + name: 'ws', + message: msg, + test: value => { + if (typeof value === 'undefined') return true + try { + const url = new URL(value) + return url.protocol === 'ws:' || url.protocol === 'wss:' + } catch (e) { + return false + } + }, + exclusive: false + }) +}) + +addMethod(string, 'socket', function (schemas, msg = 'invalid socket') { + return this.test({ + name: 'socket', + message: msg, + test: value => { + try { + const url = new URL(`http://${value}`) + return url.hostname && url.port && !url.username && !url.password && + (!url.pathname || url.pathname === '/') && !url.search && !url.hash + } catch (e) { + return false + } + }, + exclusive: false + }) +}) + +addMethod(string, 'https', function () { + return this.test({ + name: 'https', + message: 'https required', + test: (url) => { + try { + return new URL(url).protocol === 'https:' + } catch { + return false + } + } + }) +}) + +addMethod(string, 'wss', function (msg) { + return this.test({ + name: 'wss', + message: msg || 'wss required', + test: (url) => { + try { + return new URL(url).protocol === 'wss:' + } catch { + return false + } + } + }) +}) + +addMethod(string, 'hex', function (msg) { + return this.test({ + name: 'hex', + message: msg || 'invalid hex encoding', + test: (value) => !value || HEX_REGEX.test(value) + }) +}) + +addMethod(string, 'nwcUrl', function () { + return this.test({ + test: async (nwcUrl, context) => { + if (!nwcUrl) return true + + // run validation in sequence to control order of errors + // inspired by https://github.com/jquense/yup/issues/851#issuecomment-1049705180 + try { + await string().matches(/^nostr\+?walletconnect:\/\//, { message: 'must start with nostr+walletconnect://' }).validate(nwcUrl) + let relayUrl, walletPubkey, secret + try { + ({ relayUrl, walletPubkey, secret } = parseNwcUrl(nwcUrl)) + } catch { + // invalid URL error. handle as if pubkey validation failed to not confuse user. + throw new Error('pubkey must be 64 hex chars') + } + await string().required('pubkey required').trim().matches(NOSTR_PUBKEY_HEX, 'pubkey must be 64 hex chars').validate(walletPubkey) + await string().required('relay url required').trim().wss('relay must use wss://').validate(relayUrl) + await string().required('secret required').trim().matches(/^[0-9a-fA-F]{64}$/, 'secret must be 64 hex chars').validate(secret) + } catch (err) { + return context.createError({ message: err.message }) + } + return true + } + }) +}) + +export * from 'yup' diff --git a/pages/api/lnurlp/[username]/pay.js b/pages/api/lnurlp/[username]/pay.js index b153a2ee..6cfc9b44 100644 --- a/pages/api/lnurlp/[username]/pay.js +++ b/pages/api/lnurlp/[username]/pay.js @@ -7,7 +7,7 @@ import { schnorr } from '@noble/curves/secp256k1' import { createHash } from 'crypto' import { datePivot } from '@/lib/time' import { BALANCE_LIMIT_MSATS, INV_PENDING_LIMIT, LNURLP_COMMENT_MAX_LENGTH, USER_IDS_BALANCE_NO_LIMIT } from '@/lib/constants' -import { ssValidate, lud18PayerDataSchema } from '@/lib/validate' +import { validateSchema, lud18PayerDataSchema } from '@/lib/validate' import assertGofacYourself from '@/api/resolvers/ofac' export default async ({ query: { username, amount, nostr, comment, payerdata: payerData }, headers }, res) => { @@ -59,7 +59,7 @@ export default async ({ query: { username, amount, nostr, comment, payerdata: pa } try { - await ssValidate(lud18PayerDataSchema, parsedPayerData) + await validateSchema(lud18PayerDataSchema, parsedPayerData) } catch (err) { console.error('error validating payer data', err) return res.status(400).json({ status: 'ERROR', reason: err.toString() }) diff --git a/pages/settings/wallets/[wallet].js b/pages/settings/wallets/[wallet].js index 95de9f61..f563b13c 100644 --- a/pages/settings/wallets/[wallet].js +++ b/pages/settings/wallets/[wallet].js @@ -9,12 +9,15 @@ import { useWallet } from '@/wallets/index' import Info from '@/components/info' import Text from '@/components/text' import { autowithdrawInitial, AutowithdrawSettings } from '@/components/autowithdraw-shared' -import { canSend, isConfigured } from '@/wallets/common' +import { canReceive, canSend, isConfigured } from '@/wallets/common' import { SSR } from '@/lib/constants' import WalletButtonBar from '@/components/wallet-buttonbar' import { useWalletConfigurator } from '@/wallets/config' -import { useMemo } from 'react' +import { useCallback, useMemo } from 'react' import { useMe } from '@/components/me' +import validateWallet from '@/wallets/validate' +import { ValidationError } from 'yup' +import { useFormikContext } from 'formik' export const getServerSideProps = getGetServerSideProps({ authRequired: true }) @@ -47,10 +50,19 @@ export default function WalletSettings () { } }, [wallet, me]) - // check if wallet uses the form-level validation built into Formik or a Yup schema - const validateProps = typeof wallet?.fieldValidation === 'function' - ? { validate: wallet?.fieldValidation } - : { schema: wallet?.fieldValidation } + const validate = useCallback(async (data) => { + try { + await validateWallet(wallet.def, data, { abortEarly: false, topLevel: false }) + } catch (error) { + if (error instanceof ValidationError) { + return error.inner.reduce((acc, error) => { + acc[error.path] = error.message + return acc + }, {}) + } + throw error + } + }, [wallet.def]) return ( @@ -60,7 +72,7 @@ export default function WalletSettings () {
{ try { const newConfig = !isConfigured(wallet) @@ -81,18 +93,15 @@ export default function WalletSettings () { }} > {wallet && } - {wallet?.def.clientOnly - ? ( - - - - ) - : } + + + + { try { @@ -114,9 +123,14 @@ export default function WalletSettings () { ) } +function ReceiveSettings ({ walletDef }) { + const { values } = useFormikContext() + return canReceive({ def: walletDef, config: values }) && +} + function WalletFields ({ wallet }) { return wallet.def.fields - .map(({ name, label = '', type, help, optional, editable, clientOnly, serverOnly, ...props }, i) => { + .map(({ name, label = '', type, help, optional, editable, requiredWithout, validate, clientOnly, serverOnly, ...props }, i) => { const rawProps = { ...props, name, diff --git a/pages/settings/wallets/index.js b/pages/settings/wallets/index.js index fa6487f4..8549eee0 100644 --- a/pages/settings/wallets/index.js +++ b/pages/settings/wallets/index.js @@ -11,7 +11,7 @@ import { useToast } from '@/components/toast' export const getServerSideProps = getGetServerSideProps({ authRequired: true }) export default function Wallet ({ ssrData }) { - const { wallets, setPriorities, reloadLocalWallets } = useWallets() + const { wallets, setPriorities } = useWallets() const toast = useToast() const isClient = useIsClient() const [sourceIndex, setSourceIndex] = useState(null) @@ -28,8 +28,7 @@ export default function Wallet ({ ssrData }) { .map((w, i) => ({ wallet: w, priority: i })) await setPriorities(priorities) - reloadLocalWallets() - }, [setPriorities, reloadLocalWallets, wallets]) + }, [setPriorities, wallets]) const onDragStart = useCallback((i) => (e) => { // e.dataTransfer.dropEffect = 'move' diff --git a/wallets/blink/index.js b/wallets/blink/index.js index 10c97cfd..b10d8205 100644 --- a/wallets/blink/index.js +++ b/wallets/blink/index.js @@ -1,4 +1,5 @@ import { blinkSchema } from '@/lib/validate' +import { string } from '@/lib/yup' export const galoyBlinkUrl = 'https://api.blink.sv/graphql' export const galoyBlinkDashboardUrl = 'https://dashboard.blink.sv/' @@ -7,7 +8,6 @@ export const name = 'blink' export const walletType = 'BLINK' export const walletField = 'walletBlink' export const fieldValidation = blinkSchema -export const clientOnly = true export const fields = [ { @@ -15,7 +15,10 @@ export const fields = [ label: 'api key', type: 'password', help: `you can get an API key from [Blink Dashboard](${galoyBlinkDashboardUrl})`, - placeholder: 'blink_...' + placeholder: 'blink_...', + clientOnly: true, + validate: string() + .matches(/^blink_[A-Za-z0-9]+$/, { message: 'must match pattern blink_A-Za-z0-9' }) }, { name: 'currency', @@ -25,7 +28,11 @@ export const fields = [ placeholder: 'BTC', optional: true, clear: true, - autoComplete: 'off' + autoComplete: 'off', + clientOnly: true, + validate: string() + .transform(value => value ? value.toUpperCase() : 'BTC') + .oneOf(['USD', 'BTC'], 'must be BTC or USD') } ] diff --git a/wallets/cln/index.js b/wallets/cln/index.js index 3ee2013a..16c21cb7 100644 --- a/wallets/cln/index.js +++ b/wallets/cln/index.js @@ -1,4 +1,7 @@ +import { decodeRune } from '@/lib/cln' +import { B64_URL_REGEX } from '@/lib/format' import { CLNAutowithdrawSchema } from '@/lib/validate' +import { string } from '@/lib/yup' export const name = 'cln' export const walletType = 'CLN' @@ -13,7 +16,8 @@ export const fields = [ placeholder: '55.5.555.55:3010', hint: 'tor or clearnet', clear: true, - serverOnly: true + serverOnly: true, + validate: string().socket() }, { name: 'rune', @@ -25,7 +29,25 @@ export const fields = [ placeholder: 'S34KtUW-6gqS_hD_9cc_PNhfF-NinZyBOCgr1aIrark9NCZtZXRob2Q9aW52b2ljZQ==', hint: 'must be restricted to method=invoice', clear: true, - serverOnly: true + serverOnly: true, + validate: string().matches(B64_URL_REGEX, { message: 'invalid rune' }) + .test({ + name: 'rune', + test: (v, context) => { + const decoded = decodeRune(v) + if (!decoded) return context.createError({ message: 'invalid rune' }) + if (decoded.restrictions.length === 0) { + return context.createError({ message: 'rune must be restricted to method=invoice' }) + } + if (decoded.restrictions.length !== 1 || decoded.restrictions[0].alternatives.length !== 1) { + return context.createError({ message: 'rune must be restricted to method=invoice only' }) + } + if (decoded.restrictions[0].alternatives[0] !== 'method=invoice') { + return context.createError({ message: 'rune must be restricted to method=invoice only' }) + } + return true + } + }) }, { name: 'cert', @@ -35,7 +57,8 @@ export const fields = [ optional: 'optional if from [CA](https://en.wikipedia.org/wiki/Certificate_authority) (e.g. voltage)', hint: 'hex or base64 encoded', clear: true, - serverOnly: true + serverOnly: true, + validate: string().hexOrBase64() } ] diff --git a/wallets/common.js b/wallets/common.js index 76d33aa9..b3cb7843 100644 --- a/wallets/common.js +++ b/wallets/common.js @@ -62,7 +62,8 @@ export function isClientField (f) { function checkFields ({ fields, config }) { // a wallet is configured if all of its required fields are set let val = fields.every(f => { - return f.optional ? true : !!config?.[f.name] + if (f.optional && !f.requiredWithout) return true + return !!config?.[f.name] }) // however, a wallet is not configured if all fields are optional and none are set @@ -93,5 +94,5 @@ export function canSend ({ def, config }) { } export function canReceive ({ def, config }) { - return !def.clientOnly && isReceiveConfigured({ def, config }) + return def.fields.some(f => f.serverOnly) && isReceiveConfigured({ def, config }) } diff --git a/wallets/config.js b/wallets/config.js index d4312ed9..2c827ff5 100644 --- a/wallets/config.js +++ b/wallets/config.js @@ -5,9 +5,9 @@ import { canReceive, canSend, getStorageKey } from './common' import { useMutation } from '@apollo/client' import { generateMutation } from './graphql' import { REMOVE_WALLET } from '@/fragments/wallet' -import { walletValidate } from '@/lib/validate' import { useWalletLogger } from '@/components/wallet-logger' import { useWallets } from '.' +import validateWallet from './validate' export function useWalletConfigurator (wallet) { const { me } = useMe() @@ -20,11 +20,14 @@ export function useWalletConfigurator (wallet) { const _saveToServer = useCallback(async (serverConfig, clientConfig, validateLightning) => { const { serverWithShared, settings, clientOnly } = siftConfig(wallet.def.fields, { ...serverConfig, ...clientConfig }) const vaultEntries = [] - if (clientOnly) { + if (clientOnly && isActive) { for (const [key, value] of Object.entries(clientOnly)) { - vaultEntries.push({ key, value: encrypt(value) }) + if (value) { + vaultEntries.push({ key, value: encrypt(value) }) + } } } + await upsertWallet({ variables: { ...serverWithShared, settings, validateLightning, vaultEntries } }) }, [encrypt, isActive, wallet.def.fields]) @@ -40,7 +43,7 @@ export function useWalletConfigurator (wallet) { let serverConfig = serverWithShared if (canSend({ def: wallet.def, config: clientConfig })) { - let transformedConfig = await walletValidate(wallet.def, clientWithShared) + let transformedConfig = await validateWallet(wallet.def, clientWithShared) if (transformedConfig) { clientConfig = Object.assign(clientConfig, transformedConfig) } @@ -51,7 +54,7 @@ export function useWalletConfigurator (wallet) { } } } else if (canReceive({ def: wallet.def, config: serverConfig })) { - const transformedConfig = await walletValidate(wallet.def, serverConfig) + const transformedConfig = await validateWallet(wallet.def, serverConfig) if (transformedConfig) { serverConfig = Object.assign(serverConfig, transformedConfig) } @@ -62,6 +65,15 @@ export function useWalletConfigurator (wallet) { return { clientConfig, serverConfig } }, [wallet]) + const _detachFromServer = useCallback(async () => { + await removeWallet({ variables: { id: wallet.config.id } }) + }, [wallet.config?.id]) + + const _detachFromLocal = useCallback(async () => { + window.localStorage.removeItem(getStorageKey(wallet.def.name, me?.id)) + reloadLocalWallets() + }, [me?.id, wallet.def.name, reloadLocalWallets]) + const save = useCallback(async (newConfig, validateLightning = true) => { const { clientConfig, serverConfig } = await _validate(newConfig, validateLightning) @@ -71,20 +83,18 @@ export function useWalletConfigurator (wallet) { } else { if (canSend({ def: wallet.def, config: clientConfig })) { await _saveToLocal(clientConfig) + } else { + // if it previously had a client config, remove it + await _detachFromLocal() } if (canReceive({ def: wallet.def, config: serverConfig })) { await _saveToServer(serverConfig, clientConfig, validateLightning) + } else { + // if it previously had a server config, remove it + await _detachFromServer() } } - }, [isActive, _saveToServer, _saveToLocal, _validate]) - - const _detachFromServer = useCallback(async () => { - await removeWallet({ variables: { id: wallet.config.id } }) - }, [wallet.config?.id]) - - const _detachFromLocal = useCallback(async () => { - window.localStorage.removeItem(getStorageKey(wallet.def.name, me?.id)) - }, [me?.id, wallet.def.name]) + }, [isActive, _saveToServer, _saveToLocal, _validate, _detachFromLocal, _detachFromServer]) const detach = useCallback(async () => { if (isActive) { diff --git a/wallets/index.js b/wallets/index.js index 44b482ad..99eea902 100644 --- a/wallets/index.js +++ b/wallets/index.js @@ -18,7 +18,7 @@ function useLocalWallets () { const [wallets, setWallets] = useState([]) const loadWallets = useCallback(() => { - // form wallets into a list of { config, def } + // form wallets from local storage into a list of { config, def } const wallets = walletDefs.map(w => { try { const storageKey = getStorageKey(w.name, me?.id) @@ -66,15 +66,28 @@ export function WalletsProvider ({ children }) { } // the specific wallet config on the server is stored in wallet.wallet - // on the client, it's stored in unnested + // on the client, it's stored unnested return { config: { ...config, ...w.wallet }, def } }) ?? [] - // merge wallets on name + // merge wallets on name like: { ...unconfigured, ...localConfig, ...serverConfig } const merged = {} for (const wallet of [...walletDefsOnly, ...localWallets, ...wallets]) { - merged[wallet.def.name] = { ...merged[wallet.def.name], ...wallet } + merged[wallet.def.name] = { + def: wallet.def, + config: { + ...merged[wallet.def.name]?.config, + ...Object.fromEntries( + Object.entries(wallet.config ?? {}).map(([key, value]) => [ + key, + value ?? merged[wallet.def.name]?.config?.[key] + ]) + ) + } + } } + + // sort by priority, then add status field return Object.values(merged) .sort(walletPrioritySort) .map(w => ({ ...w, status: w.config?.enabled ? Status.Enabled : Status.Disabled })) @@ -87,6 +100,7 @@ export function WalletsProvider ({ children }) { } if (wallet.config?.id) { + // set priority on server if it has an id await setWalletPriority({ variables: { id: wallet.config.id, priority } }) } else { const storageKey = getStorageKey(wallet.def.name, me?.id) @@ -95,9 +109,14 @@ export function WalletsProvider ({ children }) { window.localStorage.setItem(storageKey, JSON.stringify(newConfig)) } } - }, [setWalletPriority, me?.id]) + // reload local wallets if any priorities were set + if (priorities.length > 0) { + reloadLocalWallets() + } + }, [setWalletPriority, me?.id, reloadLocalWallets]) - // provides priority sorted wallets to children + // provides priority sorted wallets to children, a function to reload local wallets, + // and a function to set priorities return ( {children} @@ -117,6 +136,7 @@ export function useWallet (name) { return wallets.find(w => w.def.name === name) } + // return the first enabled wallet that is available and can send return wallets .filter(w => !w.def.isAvailable || w.def.isAvailable()) .filter(w => w.config?.enabled && canSend(w))[0] diff --git a/wallets/lightning-address/index.js b/wallets/lightning-address/index.js index 73cf5165..bd4992e3 100644 --- a/wallets/lightning-address/index.js +++ b/wallets/lightning-address/index.js @@ -1,10 +1,9 @@ -import { lnAddrAutowithdrawSchema } from '@/lib/validate' +import { lightningAddressValidator } from '@/lib/validate' export const name = 'lightning-address' export const shortName = 'lnAddr' export const walletType = 'LIGHTNING_ADDRESS' export const walletField = 'walletLightningAddress' -export const fieldValidation = lnAddrAutowithdrawSchema export const fields = [ { @@ -12,7 +11,12 @@ export const fields = [ label: 'lightning address', type: 'text', autoComplete: 'off', - serverOnly: true + serverOnly: true, + validate: lightningAddressValidator.test({ + name: 'address', + test: addr => !addr.toLowerCase().endsWith('@stacker.news'), + message: 'automated withdrawals must be external' + }) } ] diff --git a/wallets/lnbits/index.js b/wallets/lnbits/index.js index fd772efd..008c072f 100644 --- a/wallets/lnbits/index.js +++ b/wallets/lnbits/index.js @@ -1,15 +1,37 @@ -import { lnbitsSchema } from '@/lib/validate' +import { TOR_REGEXP } from '@/lib/url' +import { string } from '@/lib/yup' export const name = 'lnbits' export const walletType = 'LNBITS' export const walletField = 'walletLNbits' -export const fieldValidation = lnbitsSchema export const fields = [ { name: 'url', label: 'lnbits url', - type: 'text' + type: 'text', + required: true, + validate: process.env.NODE_ENV === 'development' + ? string() + .or([string().matches(/^(http:\/\/)?localhost:\d+$/), string().url()], 'invalid url') + .trim() + : string().url().trim() + .test(async (url, context) => { + if (TOR_REGEXP.test(url)) { + // allow HTTP and HTTPS over Tor + if (!/^https?:\/\//.test(url)) { + return context.createError({ message: 'http or https required' }) + } + return true + } + try { + // force HTTPS over clearnet + await string().https().validate(url) + } catch (err) { + return context.createError({ message: err.message }) + } + return true + }) }, { name: 'invoiceKey', @@ -17,7 +39,8 @@ export const fields = [ type: 'password', optional: 'for receiving', serverOnly: true, - editable: false + requiredWithout: 'adminKey', + validate: string().hex().length(32) }, { name: 'adminKey', @@ -25,7 +48,8 @@ export const fields = [ type: 'password', optional: 'for sending', clientOnly: true, - editable: false + requiredWithout: 'invoiceKey', + validate: string().hex().length(32) } ] diff --git a/wallets/lnc/index.js b/wallets/lnc/index.js index 58afdac6..51d41ad9 100644 --- a/wallets/lnc/index.js +++ b/wallets/lnc/index.js @@ -1,10 +1,10 @@ -import { lncSchema } from '@/lib/validate' +import bip39Words from '@/lib/bip39-words' +import { string } from '@/lib/yup' export const name = 'lnc' export const walletType = 'LNC' export const walletField = 'walletLNC' export const clientOnly = true -export const fieldValidation = lncSchema export const fields = [ { @@ -13,7 +13,25 @@ export const fields = [ type: 'password', help: 'We only need permissions for the uri `/lnrpc.Lightning/SendPaymentSync`\n\nCreate a budgeted account with narrow permissions:\n\n```$ litcli accounts create --balance ```\n\n```$ litcli sessions add --type custom --label --account_id --uri /lnrpc.Lightning/SendPaymentSync```\n\nGrab the `pairing_secret_mnemonic` from the output and paste it here.', editable: false, - clientOnly: true + clientOnly: true, + validate: string() + .test(async (value, context) => { + const words = value ? value.trim().split(/[\s]+/) : [] + for (const w of words) { + try { + await string().oneOf(bip39Words).validate(w) + } catch { + return context.createError({ message: `'${w}' is not a valid pairing phrase word` }) + } + } + if (words.length < 2) { + return context.createError({ message: 'needs at least two words' }) + } + if (words.length > 10) { + return context.createError({ message: 'max 10 words' }) + } + return true + }) }, { name: 'localKey', diff --git a/wallets/lnd/index.js b/wallets/lnd/index.js index bed10d62..bbedb050 100644 --- a/wallets/lnd/index.js +++ b/wallets/lnd/index.js @@ -1,9 +1,9 @@ -import { LNDAutowithdrawSchema } from '@/lib/validate' +import { isInvoicableMacaroon, isInvoiceMacaroon } from '@/lib/macaroon' +import { string } from '@/lib/yup' export const name = 'lnd' export const walletType = 'LND' export const walletField = 'walletLND' -export const fieldValidation = LNDAutowithdrawSchema export const fields = [ { @@ -13,7 +13,8 @@ export const fields = [ placeholder: '55.5.555.55:10001', hint: 'tor or clearnet', clear: true, - serverOnly: true + serverOnly: true, + validate: string().socket() }, { name: 'macaroon', @@ -26,7 +27,12 @@ export const fields = [ placeholder: 'AgEDbG5kAlgDChCn7YgfWX7uTkQQgXZ2uahNEgEwGhYKB2FkZHJlc3MSBHJlYWQSBXdyaXRlGhcKCGludm9pY2VzEgRyZWFkEgV3cml0ZRoPCgdvbmNoYWluEgRyZWFkAAAGIJkMBrrDV0npU90JV0TGNJPrqUD8m2QYoTDjolaL6eBs', hint: 'hex or base64 encoded', clear: true, - serverOnly: true + serverOnly: true, + validate: string().hexOrBase64().test({ + name: 'macaroon', + test: v => isInvoiceMacaroon(v) || isInvoicableMacaroon(v), + message: 'not an invoice macaroon or an invoicable macaroon' + }) }, { name: 'cert', @@ -36,7 +42,8 @@ export const fields = [ optional: 'optional if from [CA](https://en.wikipedia.org/wiki/Certificate_authority) (e.g. voltage)', hint: 'hex or base64 encoded', clear: true, - serverOnly: true + serverOnly: true, + validate: string().hexOrBase64() } ] diff --git a/wallets/nwc/index.js b/wallets/nwc/index.js index 2df5e9ca..49b478f9 100644 --- a/wallets/nwc/index.js +++ b/wallets/nwc/index.js @@ -1,12 +1,11 @@ import { Relay } from '@/lib/nostr' import { parseNwcUrl } from '@/lib/url' -import { nwcSchema } from '@/lib/validate' +import { string } from '@/lib/yup' import { finalizeEvent, nip04, verifyEvent } from 'nostr-tools' export const name = 'nwc' export const walletType = 'NWC' export const walletField = 'walletNWC' -export const fieldValidation = nwcSchema export const fields = [ { @@ -15,7 +14,8 @@ export const fields = [ type: 'password', optional: 'for sending', clientOnly: true, - editable: false + requiredWithout: 'nwcUrlRecv', + validate: string().nwcUrl() }, { name: 'nwcUrlRecv', @@ -23,7 +23,8 @@ export const fields = [ type: 'password', optional: 'for receiving', serverOnly: true, - editable: false + requiredWithout: 'nwcUrl', + validate: string().nwcUrl() } ] diff --git a/wallets/phoenixd/index.js b/wallets/phoenixd/index.js index ac5b6959..51b60405 100644 --- a/wallets/phoenixd/index.js +++ b/wallets/phoenixd/index.js @@ -1,16 +1,16 @@ -import { phoenixdSchema } from '@/lib/validate' +import { string } from '@/lib/yup' export const name = 'phoenixd' export const walletType = 'PHOENIXD' export const walletField = 'walletPhoenixd' -export const fieldValidation = phoenixdSchema // configure wallet fields export const fields = [ { name: 'url', label: 'url', - type: 'text' + type: 'text', + validate: string().url().trim() }, { name: 'primaryPassword', @@ -19,7 +19,8 @@ export const fields = [ optional: 'for sending', help: 'You can find the primary password as `http-password` in your phoenixd configuration file as mentioned [here](https://phoenix.acinq.co/server/api#security).', clientOnly: true, - editable: false + requiredWithout: 'secondaryPassword', + validate: string().length(64).hex() }, { name: 'secondaryPassword', @@ -28,7 +29,8 @@ export const fields = [ optional: 'for receiving', help: 'You can find the secondary password as `http-password-limited-access` in your phoenixd configuration file as mentioned [here](https://phoenix.acinq.co/server/api#security).', serverOnly: true, - editable: false + requiredWithout: 'primaryPassword', + validate: string().length(64).hex() } ] @@ -38,6 +40,3 @@ export const card = { subtitle: 'use [phoenixd](https://phoenix.acinq.co/server) for payments', badges: ['send & receive'] } - -// phoenixd::TODO -// set validation schema diff --git a/wallets/server.js b/wallets/server.js index 7e004aa6..26894041 100644 --- a/wallets/server.js +++ b/wallets/server.js @@ -42,11 +42,12 @@ export async function createInvoice (userId, { msats, description, descriptionHa for (const wallet of wallets) { const w = walletDefs.find(w => w.walletType === wallet.def.walletType) try { - const { walletType, walletField, createInvoice } = w if (!canReceive({ def: w, config: wallet })) { continue } + const { walletType, walletField, createInvoice } = w + const walletFull = await models.wallet.findFirst({ where: { userId, diff --git a/wallets/validate.js b/wallets/validate.js new file mode 100644 index 00000000..4040c826 --- /dev/null +++ b/wallets/validate.js @@ -0,0 +1,91 @@ +/* + we want to take all the validate members from the provided wallet + and compose into a single yup schema for formik validation ... + the validate member can be on of: + - a yup schema + - a function that throws on an invalid value + - a regular expression that must match +*/ + +import { autowithdrawSchemaMembers } from '@/lib/validate' +import * as Yup from '@/lib/yup' +import { canReceive } from './common' + +export default async function validateWallet (walletDef, data, options = { abortEarly: true, topLevel: true }) { + let schema = composeWalletSchema(walletDef) + + if (canReceive({ def: walletDef, config: data })) { + schema = schema.concat(autowithdrawSchemaMembers) + } + + await schema.validate(data, options) + + const casted = schema.cast(data, { assert: false }) + if (options.topLevel && walletDef.validate) { + await walletDef.validate(casted) + } + + return casted +} + +function createFieldSchema (name, validate) { + if (!validate) { + throw new Error(`No validation provided for field ${name}`) + } + + if (Yup.isSchema(validate)) { + // If validate is already a Yup schema, return it directly + return validate + } else if (typeof validate === 'function') { + // If validate is a function, create a custom Yup test + return Yup.mixed().test({ + name, + test: (value, context) => { + try { + validate(value) + return true + } catch (error) { + return context.createError({ message: error.message }) + } + } + }) + } else if (validate instanceof RegExp) { + // If validate is a regular expression, use Yup.matches + return Yup.string().matches(validate, `${name} is invalid`) + } else { + throw new Error(`validate for ${name} must be a yup schema, function, or regular expression`) + } +} + +function composeWalletSchema (walletDef) { + const { fields } = walletDef + + const schemaShape = fields.reduce((acc, field) => { + const { name, validate, optional, requiredWithout } = field + + acc[name] = createFieldSchema(name, validate) + + if (!optional) { + acc[name] = acc[name].required('Required') + } else if (requiredWithout) { + acc[name] = acc[name].when([requiredWithout], ([pairSetting], schema) => { + if (!pairSetting) return schema.required(`required if ${requiredWithout} not set`) + return Yup.mixed().or([schema.test({ + test: value => value !== pairSetting, + message: `${name} cannot be the same as ${requiredWithout}` + }), Yup.mixed().notRequired()]) + }) + } + + return acc + }, {}) + + // we use Object.keys(schemaShape).reverse() to avoid cyclic dependencies in Yup schema + // see https://github.com/jquense/yup/issues/176#issuecomment-367352042 + const composedSchema = Yup.object().shape(schemaShape, Object.keys(schemaShape).reverse()).concat(Yup.object({ + enabled: Yup.boolean(), + priority: Yup.number().min(0, 'must be at least 0').max(100, 'must be at most 100') + })) + + return composedSchema +} diff --git a/wallets/webln/index.js b/wallets/webln/index.js index 04a01075..4fe2efba 100644 --- a/wallets/webln/index.js +++ b/wallets/webln/index.js @@ -1,22 +1,15 @@ export const name = 'webln' export const walletType = 'WEBLN' export const walletField = 'walletWebLN' -export const clientOnly = true + +export const validate = ({ enabled }) => { + if (enabled && typeof window?.webln === 'undefined') { + throw new Error('no WebLN provider found') + } +} export const fields = [] -export const fieldValidation = ({ enabled }) => { - if (typeof window?.webln === 'undefined') { - // don't prevent disabling WebLN if no WebLN provider found - if (enabled) { - return { - enabled: 'no WebLN provider found' - } - } - } - return {} -} - export const card = { title: 'WebLN', subtitle: 'use a [WebLN provider](https://www.webln.guide/ressources/webln-providers) for payments',