867 lines
28 KiB
JavaScript
867 lines
28 KiB
JavaScript
import { string, ValidationError, number, object, array, addMethod, 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,
|
|
TERRITORY_BILLING_TYPES, MAX_COMMENT_TEXT_LENGTH, MAX_POST_TEXT_LENGTH, MIN_TITLE_LENGTH, BOUNTY_MIN, BOUNTY_MAX, BALANCE_LIMIT_MSATS
|
|
} 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 * 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) {
|
|
try {
|
|
if (typeof schema === 'function') {
|
|
return await schema(args).validate(data)
|
|
} else {
|
|
return await schema.validate(data)
|
|
}
|
|
} catch (e) {
|
|
if (e instanceof ValidationError) {
|
|
throw new Error(`${e.path}: ${e.message}`)
|
|
}
|
|
throw e
|
|
}
|
|
}
|
|
|
|
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 (wallet, data) {
|
|
if (typeof wallet.def.fieldValidation === 'function') {
|
|
return await formikValidate(wallet.def.fieldValidation, data)
|
|
} else {
|
|
return await ssValidate(wallet.def.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`
|
|
).min(MIN_TITLE_LENGTH, `must be at least ${MIN_TITLE_LENGTH} characters`)
|
|
|
|
const textValidator = (max) => string().trim().max(
|
|
max,
|
|
({ max, value }) => `-${Math.abs(max - value.length)} characters remaining`
|
|
)
|
|
const nameValidator = string()
|
|
.required('required')
|
|
.matches(/^[\w_]+$/, 'only letters, numbers, and _')
|
|
.max(32, 'too long')
|
|
|
|
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'
|
|
? 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')
|
|
}
|
|
// apollo client
|
|
if (client) {
|
|
const { data } = await client.query({ query: NAME_QUERY, variables: { name } })
|
|
return !data.nameAvailable
|
|
}
|
|
|
|
// prisma client
|
|
const user = await models.user.findUnique({ where: { name } })
|
|
return !!user
|
|
}
|
|
|
|
async function subExists (name, { client, models, me, filter }) {
|
|
if (!client && !models) {
|
|
throw new Error('cannot check for territory')
|
|
}
|
|
|
|
let sub
|
|
// apollo client
|
|
if (client) {
|
|
const { data } = await client.query({ query: SUB, variables: { sub: name }, fetchPolicy: 'no-cache' })
|
|
sub = data?.sub
|
|
} else {
|
|
sub = await models.sub.findUnique({ where: { name } })
|
|
}
|
|
|
|
return !!sub && (!filter || filter(sub))
|
|
}
|
|
|
|
async function subActive (name, { client, models, me }) {
|
|
if (!client && !models) {
|
|
throw new Error('cannot check if territory is active')
|
|
}
|
|
|
|
let sub
|
|
// apollo client
|
|
if (client) {
|
|
const { data } = await client.query({ query: SUB, variables: { sub: name } })
|
|
sub = data?.sub
|
|
} else {
|
|
sub = await models.sub.findUnique({ where: { name } })
|
|
}
|
|
|
|
return sub ? sub.status !== 'STOPPED' : undefined
|
|
}
|
|
|
|
async function subHasPostType (name, type, { client, models }) {
|
|
if (!client && !models) {
|
|
throw new Error('cannot check for territory')
|
|
}
|
|
// apollo client
|
|
if (client) {
|
|
const { data } = await client.query({ query: SUB, variables: { name } })
|
|
return !!(data?.sub?.postTypes?.includes(type))
|
|
}
|
|
|
|
// prisma client
|
|
const sub = await models.sub.findUnique({ where: { name } })
|
|
return !!(sub?.postTypes?.includes(type))
|
|
}
|
|
|
|
export function advPostSchemaMembers ({ me, existingBoost = 0, ...args }) {
|
|
const boostMin = existingBoost || BOOST_MIN
|
|
return {
|
|
boost: intValidator
|
|
.min(boostMin, `must be ${existingBoost ? '' : 'blank or '}at least ${boostMin}`).test({
|
|
name: 'boost',
|
|
test: async boost => (!existingBoost && !boost) || boost % BOOST_MULT === 0,
|
|
message: `must be divisble be ${BOOST_MULT}`
|
|
}),
|
|
forward: array()
|
|
.max(MAX_FORWARDS, `you can only configure ${MAX_FORWARDS} forward recipients`)
|
|
.of(object().shape({
|
|
nym: string().required('must specify a stacker')
|
|
.test({
|
|
name: 'nym',
|
|
test: async name => {
|
|
if (!name || !name.length) return false
|
|
return await usernameExists(name, args)
|
|
},
|
|
message: 'stacker does not exist'
|
|
})
|
|
.test({
|
|
name: 'self',
|
|
test: async name => {
|
|
return me?.name !== name
|
|
},
|
|
message: 'cannot forward to yourself'
|
|
}),
|
|
pct: intValidator.required('must specify a percentage').min(1, 'percentage must be at least 1').max(100, 'percentage must not exceed 100')
|
|
}))
|
|
.compact((v) => !v.nym && !v.pct)
|
|
.test({
|
|
name: 'sum',
|
|
test: forwards => forwards ? forwards.map(fwd => Number(fwd.pct)).reduce((sum, cur) => sum + cur, 0) <= 100 : true,
|
|
message: 'the total forward percentage exceeds 100%'
|
|
})
|
|
.test({
|
|
name: 'uniqueStackers',
|
|
test: forwards => forwards ? new Set(forwards.map(fwd => fwd.nym)).size === forwards.length : true,
|
|
message: 'duplicate stackers cannot be specified to receive forwarded sats'
|
|
})
|
|
}
|
|
}
|
|
|
|
export function subSelectSchemaMembers (args) {
|
|
return {
|
|
sub: string().required('required').test({
|
|
name: 'sub',
|
|
test: async sub => {
|
|
if (!sub || !sub.length) return false
|
|
return await subExists(sub, args)
|
|
},
|
|
message: 'pick valid territory'
|
|
}).test({
|
|
name: 'sub',
|
|
test: async sub => {
|
|
if (!sub || !sub.length) return false
|
|
return await subActive(sub, args)
|
|
},
|
|
message: 'territory is not active'
|
|
})
|
|
}
|
|
}
|
|
// for testing advPostSchemaMembers in isolation
|
|
export function advSchema (args) {
|
|
return object({
|
|
...advPostSchemaMembers(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 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 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
|
|
}
|
|
}),
|
|
cert: hexOrBase64Validator,
|
|
...autowithdrawSchemaMembers
|
|
})
|
|
|
|
export function bountySchema (args) {
|
|
return object({
|
|
title: titleValidator,
|
|
text: textValidator(MAX_POST_TEXT_LENGTH),
|
|
bounty: intValidator
|
|
.min(BOUNTY_MIN, `must be at least ${numWithUnits(BOUNTY_MIN)}`)
|
|
.max(BOUNTY_MAX, `must be at most ${numWithUnits(BOUNTY_MAX)}`),
|
|
...advPostSchemaMembers(args),
|
|
...subSelectSchemaMembers(args)
|
|
}).test({
|
|
name: 'post-type-supported',
|
|
test: ({ sub }) => subHasPostType(sub, 'BOUNTY', args),
|
|
message: 'territory does not support bounties'
|
|
})
|
|
}
|
|
|
|
export function discussionSchema (args) {
|
|
return object({
|
|
title: titleValidator,
|
|
text: textValidator(MAX_POST_TEXT_LENGTH),
|
|
...advPostSchemaMembers(args),
|
|
...subSelectSchemaMembers(args)
|
|
}).test({
|
|
name: 'post-type-supported',
|
|
test: ({ sub }) => subHasPostType(sub, 'DISCUSSION', args),
|
|
message: 'territory does not support discussions'
|
|
})
|
|
}
|
|
|
|
export function linkSchema (args) {
|
|
return object({
|
|
title: titleValidator,
|
|
text: textValidator(MAX_POST_TEXT_LENGTH),
|
|
url: string().url().required('required'),
|
|
...advPostSchemaMembers(args),
|
|
...subSelectSchemaMembers(args)
|
|
}).test({
|
|
name: 'post-type-supported',
|
|
test: ({ sub }) => subHasPostType(sub, 'LINK', args),
|
|
message: 'territory does not support links'
|
|
})
|
|
}
|
|
|
|
export function pollSchema ({ numExistingChoices = 0, ...args }) {
|
|
return object({
|
|
title: titleValidator,
|
|
text: textValidator(MAX_POST_TEXT_LENGTH),
|
|
options: array().of(
|
|
string().trim().test('my-test', 'required', function (value) {
|
|
return (this.path !== 'options[0]' && this.path !== 'options[1]') || value
|
|
}).max(MAX_POLL_CHOICE_LENGTH,
|
|
({ max, value }) => `-${Math.abs(max - value.length)} characters remaining`
|
|
)
|
|
).test({
|
|
message: `at most ${MAX_POLL_NUM_CHOICES} choices`,
|
|
test: arr => arr.length <= MAX_POLL_NUM_CHOICES - numExistingChoices
|
|
}).test({
|
|
message: `at least ${MIN_POLL_NUM_CHOICES} choices required`,
|
|
test: arr => arr.length >= MIN_POLL_NUM_CHOICES - numExistingChoices
|
|
}),
|
|
pollExpiresAt: date().nullable().min(datePivot(new Date(), { days: 1 }), 'Expiration must be at least 1 day in the future'),
|
|
...advPostSchemaMembers(args),
|
|
...subSelectSchemaMembers(args)
|
|
}).test({
|
|
name: 'post-type-supported',
|
|
test: ({ sub }) => subHasPostType(sub, 'POLL', args),
|
|
message: 'territory does not support polls'
|
|
})
|
|
}
|
|
|
|
export function territorySchema (args) {
|
|
return object({
|
|
name: nameValidator
|
|
.test({
|
|
name: 'name',
|
|
test: async name => {
|
|
if (!name || !name.length) return false
|
|
const editing = !!args.sub?.name
|
|
// don't block submission on edits or unarchival
|
|
const isEdit = sub => sub.name === args.sub.name
|
|
const isArchived = sub => sub.status === 'STOPPED'
|
|
const filter = sub => editing ? !isEdit(sub) : !isArchived(sub)
|
|
const exists = await subExists(name, { ...args, filter })
|
|
return !exists
|
|
},
|
|
message: 'taken'
|
|
}),
|
|
desc: string().required('required').trim().max(
|
|
MAX_TERRITORY_DESC_LENGTH,
|
|
({ max, value }) => `-${Math.abs(max - value.length)} characters remaining`
|
|
),
|
|
baseCost: intValidator
|
|
.min(1, 'must be at least 1')
|
|
.max(100000, 'must be at most 100k'),
|
|
postTypes: array().of(string().oneOf(POST_TYPES)).min(1, 'must support at least one post type'),
|
|
billingType: string().required('required').oneOf(TERRITORY_BILLING_TYPES, 'required'),
|
|
nsfw: boolean()
|
|
})
|
|
}
|
|
|
|
export function territoryTransferSchema ({ me, ...args }) {
|
|
return object({
|
|
userName: nameValidator
|
|
.test({
|
|
name: 'name',
|
|
test: async name => {
|
|
if (!name || !name.length) return false
|
|
return await usernameExists(name, args)
|
|
},
|
|
message: 'user does not exist'
|
|
})
|
|
.test({
|
|
name: 'name',
|
|
test: name => !me || me.name !== name,
|
|
message: 'cannot transfer to yourself'
|
|
})
|
|
})
|
|
}
|
|
|
|
export function userSchema (args) {
|
|
return object({
|
|
name: nameValidator
|
|
.test({
|
|
name: 'name',
|
|
test: async name => {
|
|
if (!name || !name.length) return false
|
|
return !(await usernameExists(name, args))
|
|
},
|
|
message: 'taken'
|
|
})
|
|
})
|
|
}
|
|
|
|
export const commentSchema = object({
|
|
text: textValidator(MAX_COMMENT_TEXT_LENGTH).required('required')
|
|
})
|
|
|
|
export const jobSchema = args => object({
|
|
title: titleValidator,
|
|
company: string().required('required').trim(),
|
|
text: textValidator(MAX_POST_TEXT_LENGTH).required('required'),
|
|
url: string()
|
|
.or([string().email(), string().url()], 'invalid url or email')
|
|
.required('required'),
|
|
location: string().test(
|
|
'no-remote',
|
|
"don't write remote, just check the box",
|
|
v => !v?.match(/\bremote\b/gi))
|
|
.when('remote', {
|
|
is: (value) => !value,
|
|
then: schema => schema.required('required').trim()
|
|
}),
|
|
...advPostSchemaMembers(args)
|
|
})
|
|
|
|
export const emailSchema = object({
|
|
email: string().email('email is no good').required('required')
|
|
})
|
|
|
|
export const urlSchema = object({
|
|
url: string().url().required('required')
|
|
})
|
|
|
|
export const namedUrlSchema = object({
|
|
text: string().required('required').trim(),
|
|
url: string().url().required('required')
|
|
})
|
|
|
|
export const amountSchema = object({
|
|
amount: intValidator.required('required').positive('must be positive')
|
|
})
|
|
|
|
export const boostValidator = intValidator
|
|
.min(BOOST_MULT, `must be at least ${BOOST_MULT}`).test({
|
|
name: 'boost',
|
|
test: async boost => boost % BOOST_MULT === 0,
|
|
message: `must be divisble be ${BOOST_MULT}`
|
|
})
|
|
|
|
export const boostSchema = object({
|
|
amount: boostValidator.required('required').positive('must be positive')
|
|
})
|
|
|
|
export const actSchema = object({
|
|
sats: intValidator.required('required').positive('must be positive')
|
|
.when(['act'], ([act], schema) => {
|
|
if (act === 'BOOST') {
|
|
return boostValidator
|
|
}
|
|
return schema
|
|
}),
|
|
act: string().required('required').oneOf(['TIP', 'DONT_LIKE_THIS', 'BOOST'])
|
|
})
|
|
|
|
export const settingsSchema = object().shape({
|
|
tipDefault: intValidator.required('required').positive('must be positive'),
|
|
tipRandom: boolean(),
|
|
tipRandomMin: intValidator.nullable().positive('must be positive')
|
|
.when(['tipRandom', 'tipRandomMax'], ([enabled, max], schema) => {
|
|
let res = schema
|
|
if (!enabled) return res
|
|
if (max) {
|
|
res = schema.required('minimum and maximum must either both be omitted or specified').nonNullable()
|
|
}
|
|
return res.lessThan(max, 'must be less than maximum')
|
|
}),
|
|
tipRandomMax: intValidator.nullable().positive('must be positive')
|
|
.when(['tipRandom', 'tipRandomMin'], ([enabled, min], schema) => {
|
|
let res = schema
|
|
if (!enabled) return res
|
|
if (min) {
|
|
res = schema.required('minimum and maximum must either both be omitted or specified').nonNullable()
|
|
}
|
|
return res.moreThan(min, 'must be more than minimum')
|
|
}),
|
|
fiatCurrency: string().required('required').oneOf(SUPPORTED_CURRENCIES),
|
|
withdrawMaxFeeDefault: intValidator.required('required').positive('must be positive'),
|
|
nostrPubkey: string().nullable()
|
|
.or([
|
|
string().nullable().matches(NOSTR_PUBKEY_HEX, 'must be 64 hex chars'),
|
|
string().nullable().matches(NOSTR_PUBKEY_BECH32, 'invalid bech32 encoding')], 'invalid pubkey'),
|
|
nostrRelays: array().of(
|
|
string().ws().transform(relay => relay.startsWith('wss://') ? relay : `wss://${relay}`)
|
|
).max(NOSTR_MAX_RELAY_NUM,
|
|
({ max, value }) => `${Math.abs(max - value.length)} too many`),
|
|
hideBookmarks: boolean(),
|
|
hideGithub: boolean(),
|
|
hideNostr: boolean(),
|
|
hideTwitter: boolean(),
|
|
hideWalletBalance: boolean(),
|
|
diagnostics: boolean(),
|
|
noReferralLinks: boolean(),
|
|
hideIsContributor: boolean(),
|
|
disableFreebies: boolean().nullable(),
|
|
satsFilter: intValidator.required('required').min(0, 'must be at least 0').max(1000, 'must be at most 1000'),
|
|
zapUndos: intValidator.nullable().min(0, 'must be greater or equal to 0')
|
|
// exclude from cyclic analysis. see https://github.com/jquense/yup/issues/720
|
|
}, [['tipRandomMax', 'tipRandomMin']])
|
|
|
|
const warningMessage = 'If I logout, even accidentally, I will never be able to access my account again'
|
|
export const lastAuthRemovalSchema = object({
|
|
warning: string().matches(warningMessage, 'does not match').required('required')
|
|
})
|
|
|
|
export const withdrawlSchema = object({
|
|
invoice: string().required('required').trim(),
|
|
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()
|
|
})
|
|
|
|
export const inviteSchema = object({
|
|
gift: intValidator.positive('must be greater than 0').required('required'),
|
|
limit: intValidator.positive('must be positive')
|
|
})
|
|
|
|
export const pushSubscriptionSchema = object({
|
|
endpoint: string().url().required('required').trim(),
|
|
p256dh: string().required('required').trim(),
|
|
auth: string().required('required').trim()
|
|
})
|
|
|
|
export const lud18PayerDataSchema = (k1) => object({
|
|
name: string(),
|
|
pubkey: string(),
|
|
email: string().email('bad email address'),
|
|
identifier: string()
|
|
})
|
|
|
|
// check if something is _really_ a number.
|
|
// returns true for every number in this range: [-Infinity, ..., 0, ..., Infinity]
|
|
export const isNumber = x => typeof x === 'number' && !Number.isNaN(x)
|
|
|
|
export const toNumber = (x, min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER) => {
|
|
if (typeof x === 'undefined') {
|
|
throw new Error('value is required')
|
|
}
|
|
const n = Number(x)
|
|
if (isNumber(n)) {
|
|
if (x < min || x > max) {
|
|
throw new Error(`value ${x} must be between ${min} and ${max}`)
|
|
}
|
|
return n
|
|
}
|
|
throw new Error(`value ${x} is not a number`)
|
|
}
|
|
|
|
export const toPositiveNumber = (x) => toNumber(x, 0)
|
|
|
|
export const deviceSyncSchema = object().shape({
|
|
passphrase: string().required('required')
|
|
.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 < 12) {
|
|
return context.createError({ message: 'needs at least 12 words' })
|
|
}
|
|
|
|
return true
|
|
})
|
|
})
|