OurBigBook logoOurBigBook Docs OurBigBook logoOurBigBook.comSite Source code
web/front/js.js
// Random stuff shared between front and backend.
// Has to be .js until we port backend to TypeScript..
// Maybe this should just be merged with ./config.js

const ourbigbook = require('ourbigbook')
const { titleToId } = ourbigbook

const config = require('./config')
const { convertContext } = config

const web_api = require('ourbigbook/web_api');
const { QUERY_FALSE_VAL, QUERY_TRUE_VAL } = web_api

const TRI_FALSE = '0'
const TRI_TRUE = '1'
const TRI_ALL = '2'

// https://stackoverflow.com/questions/14382725/how-to-get-the-correct-ip-address-of-a-client-into-a-node-socket-io-app-hosted-o/14382990#14382990
// Works on Heroku 2021.
function getClientIp(req) {
  if (!config.isProduction && config.devIp) {
    return config.devIp
  } else {
    return req.header('x-forwarded-for')
  }
}

function getCommentSlug(comment) {
  return `${comment.issue.article.slug}#${comment.issue.number}#${comment.number}`
}

function getTri(req, res, param, defaultValue) {
  const value = req.query[param]
  if (value === undefined) {
    return defaultValue
  } else if (value === TRI_FALSE) {
    return false
  } else if (value === TRI_TRUE) {
    return true
  } else if (value === TRI_ALL) {
    return undefined
  }
  res.statusCode = 422
  return defaultValue
}

function getList(req, res) {
  return getTri(req, res, 'listed', true)
}

function getLocked(req, res) {
  return getTri(req, res, 'locked', false)
}

function getVerified(req, res) {
  return getTri(req, res, 'verified', true)
}

function getOrderAndPage(req, page, opts={}) {
  const [order, orderErr, ascDesc] = getOrder(req, opts)
  const [pageNum, pageErr] = getPage(page)
  let errs = []
  if (orderErr) {
    errs.push(orderErr)
  }
  if (pageErr) {
    errs.push(pageErr)
  }
  return {
    ascDesc,
    err: errs.length ? errs : undefined,
    order,
    page: pageNum,
  }
}

function orderAscDescNullsLast(orderAscDesc) {
  return orderAscDesc.replace(/\s+NULLS\s+(FIRST|LAST)$/i, '') + ' NULLS LAST'
}

/** GET param -> DB order map. undefined means both are the same. */
const ALLOWED_SORTS_DEFAULT = {
  created: 'createdAt',
  updated: 'updatedAt',
}

/** By default we order by DESC because it works well with createdAt/updatedAt and score.
 * But for these things, notably alphabetical listings, we want to sort alphabetically instead by default. */
const SORT_WITH_DEFAULT_ASC = new Set([
  'path',
  'nestedSetIndex',
  'slug',
  'topicId',
  'username',
])
const ASC_GET_SUFFIX  = '-asc'
const DESC_GET_SUFFIX  = '-desc'

function getOrder(req, opts={}) {
  let sort = req.query.sort
  let ascDesc
  let {
    allowedSorts,
    allowedSortsExtra,
    defaultOrder: default_,
  } = opts
  if (allowedSorts === undefined) {
    allowedSorts = ALLOWED_SORTS_DEFAULT
  }
  if (allowedSortsExtra === undefined) {
    allowedSortsExtra = {}
  }
  if (default_ === undefined) {
    default_ = 'createdAt'
  }
  let ret, err
  if (sort) {
    if (sort.endsWith(ASC_GET_SUFFIX)) {
      ascDesc = 'asc'
      sort = sort.substring(0, sort.length - ASC_GET_SUFFIX.length)
    } else if (sort.endsWith(DESC_GET_SUFFIX)) {
      ascDesc = 'desc'
      sort = sort.substring(0, sort.length - DESC_GET_SUFFIX.length)
    }
    const allowedSortsEff = Object.assign(
      {},
      allowedSorts,
      allowedSortsExtra
    )
    if (sort in allowedSortsEff) {
      const order = allowedSortsEff[sort]
      if (order) {
        ret = order
      } else {
        ret = sort
      }
    } else {
      if (default_ in allowedSortsEff) {
        ret = default_
      } else {
        // Return one arbitrary sort.
        ret = Object.values(allowedSortsEff)[0]
      }
      err = `Invalid sort value: '${sort}'`
    }
  } else {
    ret = default_
  }
  return [
    ret, 
    err,
    (ascDesc ? ascDesc : SORT_WITH_DEFAULT_ASC.has(ret) ? 'ASC' : 'DESC') + ' NULLS LAST'
  ]
}

/**
 * @param {string|string[]} page - 1-based.
 * @returns {[number, string|undefined]} 0-based return, error string if any.
 */
function getPage(page='1') {
  const pageString = typeof page === 'string' ? page : page[0]
  const [pageNum, ok] = typecastInteger(page)
  if (ok) {
    if (pageNum <= 0) {
      return [0, 'The page must be positive']
    } else {
      return [pageNum - 1,]
    }
  } else {
    return [0, 'Invalid page number']
  }
}

function hasReachedMaxItemCount(loggedInUser, itemCount, itemType) {
  if (!loggedInUser.admin && itemCount >= loggedInUser.maxArticles) {
    return `You have reached your maximum number of ${itemType}: ${loggedInUser.maxArticles}. ` +
           `Please ask an admin to raise it for you: ${config.contactUrl}`
  }
}

function typecastBoolean(s) {
  let b
  let ok = true
  if (s === QUERY_TRUE_VAL) {
    b = true
  } else if (s === QUERY_FALSE_VAL) {
    b = false
  } else {
    ok = false
  }
  return [b, ok]
}

/**
 * Typecast string to integer. Typically used to typecast
 * URL GET parameters to types with error checking. This is unlike
 * JSON bodies which are clearly typed already.
 * 
 * @param {string} s
 * @returns {[number, boolean]}
 */
function typecastInteger(s) {
  const i = Number(s)
  let ok = s !== '' && Number.isInteger(i)
  return [i, ok]
}

function isNonNegativeInteger(i) {
  return Number.isInteger(i) && i >= 0
}

function isPositiveInteger(i) {
  return Number.isInteger(i) && i > 0
}

// Elements either match cb, or is an array where each type matches cb.
function isTypeOrArrayOf(cb) {
  return (a) => {
    if (cb(a)) return true
    return isArrayOf(cb)(a)
  }
}

function ipBlockedForSignupMessage(ip, ipBlockPrefix) {
  return `You cannot sign up from your current IP ${ip} because ` +
  `the IP prefix ${ipBlockPrefix} was blocked by admins due to abuse`
}

function isArrayOf(cb) {
  return (a) => {
    if (!(a instanceof Array)) {
      return false
    }
    for (const elem of a) {
      if (!cb(elem)) {
        return false
      }
    }
    return true
  }
}

function isBoolean(tf) {
  return typeof tf === 'boolean'
}

/** Is it an email? And not a username. */
function isEmail(s) {
  return s.indexOf('@') !== -1
}

function isSmallerOrEqualTo(max) {
  return (n) => n <= max
}

function isLengthSmallerOrEqualTo(max) {
  return (s) => s.length <= max
}

function isString(s) {
  return typeof s === 'string'
}

function isTruthy(s) {
  return !!s
}

// ID, slug and topic conversions

function slugToTopic(slug) {
  return slug.split(ourbigbook.Macro.HEADER_SCOPE_SEPARATOR).slice(1).join(ourbigbook.Macro.HEADER_SCOPE_SEPARATOR)
}

function idToSlug(id) {
  return id.slice(ourbigbook.AT_MENTION_CHAR.length)
}

function idToTopic(id) {
  return slugToTopic(idToSlug(id))
}

function uidTopicIdToId(uid, topicId) {
  return ourbigbook.AT_MENTION_CHAR + uidTopicIdToSlug(uid, topicId)
}

function uidTopicIdToSlug(uid, topicId) {
  let ret =  `${uid}`
  if (topicId) {
    ret = `${ret}${ourbigbook.Macro.HEADER_SCOPE_SEPARATOR}${topicId}`
  }
  return ret
}

function slugToId(slug) {
  return ourbigbook.AT_MENTION_CHAR + slug
}

function querySearchToTopicId(search) {
  return search === undefined ? undefined : titleToId(search, undefined, convertContext)
}

function openUserTabs(users, openWindow) {
  // Open newest first: browsers normally place subsequent related tabs after
  // the earlier ones. Sort a copy so the displayed table stays unchanged.
  const ordered = [...users].sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt) || b.id - a.id)
  let blocked = 0
  for (const user of ordered) {
    const tab = openWindow(require('./routes').user(user.username), '_blank')
    if (tab) tab.opener = null
    else blocked++
  }
  return blocked
}

module.exports = {
  AUTH_COOKIE_NAME: 'auth',
  TRI_ALL,
  TRI_FALSE,
  TRI_TRUE,
  getClientIp,
  getCommentSlug,
  getList,
  getLocked,
  getVerified,
  getOrder,
  getOrderAndPage,
  orderAscDescNullsLast,
  openUserTabs,
  getPage,
  getTri,
  hasReachedMaxItemCount,
  idToSlug,
  idToTopic,
  ipBlockedForSignupMessage,
  isArrayOf,
  isBoolean,
  isEmail,
  isLengthSmallerOrEqualTo,
  isNonNegativeInteger,
  isPositiveInteger,
  isSmallerOrEqualTo,
  isString,
  isTruthy,
  isTypeOrArrayOf,
  querySearchToTopicId,
  slugToId,
  slugToTopic,
  typecastBoolean,
  typecastInteger,
  uidTopicIdToId,
  uidTopicIdToSlug,
}