OurBigBook logoOurBigBook Docs OurBigBook logoOurBigBook.comSite Source code
web_api.js
// https://docs.ourbigbook.com#ourbigbook-web-directory-structure
//
// Plus some other random stuff that has to be able to run on frontend, thus no backend stuff here.

const crypto = require('crypto')

const axios = require('axios')

const ourbigbook = require('./index')

function articleHash(opts={}) {
  return hashToHex(JSON.stringify(Object.fromEntries(Object.entries(opts).sort())))
}

// https://stackoverflow.com/questions/8135132/how-to-encode-url-parameters/50288717#50288717
function encodeGetParams(p) {
  const params = []
  for (const key of Object.keys(p).sort()) {
    const val = p[key]
    if (val !== undefined) {
      params.push(`${encodeURIComponent(key)}=${encodeURIComponent(val)}`)
    }
  }
  let ret = params.join('&')
  if (ret) {
    ret = '?' + ret
  }
  return ret
}

const encodeGetParamsWithOffset = (opts) => {
  opts = Object.assign({}, opts)
  if (opts.page !== undefined && opts.limit !== undefined) {
    opts.offset = opts.page * opts.limit
  }
  delete opts.page
  return encodeGetParams(opts)
}

/* bytes -> hex string representing the hash */
function hashToHex(s) {
  return crypto.createHash('sha256').update(s).digest('hex')
}

function read_include({exists, read, path_sep, ext}) {
  function join(...parts) {
    return parts.join(path_sep)
  }
  const exts = ext === undefined
    ? [`.${ourbigbook.OURBIGBOOK_EXT}`, '.md', '.markdown']
    : [ext]
  return async (id, input_dir) => {
    let found = undefined
    for (const candidateExt of exts) {
      let test
      const basename = id + candidateExt
      if (basename[0] === path_sep) {
        test = basename.substring(1)
        if (await exists(test)) {
          found = test
        }
      } else {
        const input_dir_with_sep = input_dir + path_sep
        for (let i = input_dir_with_sep.length - 1; i > 0; i--) {
          if (input_dir_with_sep[i] === path_sep) {
            test = input_dir_with_sep.slice(0, i + 1) + basename
            if (await exists(test)) {
              found = test
              break
            }
          }
        }
        if (found === undefined && await exists(basename)) {
          found = basename
        }
      }
      if (found === undefined) {
        test = join(id, ourbigbook.INDEX_BASENAME_NOEXT + candidateExt)
        if (input_dir !=='') {
          test = join(input_dir, test)
        }
        if (await exists(test)) {
          found = test
        }
        if (found === undefined) {
          const [dir, idBasename] = ourbigbook.pathSplit(id, path_sep)
          const [basename_noext, idExt] = ourbigbook.pathSplitext(idBasename)
          if (basename_noext === ourbigbook.INDEX_BASENAME_NOEXT) {
            for (let index_basename_noext of ourbigbook.INDEX_FILE_BASENAMES_NOEXT) {
              test = join(dir, index_basename_noext + idExt)
              if (await exists(test)) {
                found = test
                break
              }
            }
          }
        }
      }
      if (found !== undefined) break
    }
    if (found !== undefined) {
      return [found, await read(found)]
    }
    return undefined
  }
}

class WebApi {
  constructor(opts) {
    this.opts = opts
  }

  async req(method, path, opts={}) {
    return sendJsonHttp(
      method,
      `/${ourbigbook.WEB_API_PATH}/${path}`,
      Object.assign(
        {},
        this.opts,
        opts
      )
    )
  }

  async article(slug, opts={}, reqOpts={}) {
    const { data, status } = await this.articles(Object.assign({ id: slug }, opts), reqOpts)
    return { data: data.articles[0], status }
  }

  async articles(opts={}, reqOpts={}) {
    return this.req('get', `articles${encodeGetParamsWithOffset(opts)}`, reqOpts)
  }

  async articleAnnounce(slug, message, opts={}, reqOpts={}) {
    const body = {}
    if (message) {
      body.message = message
    }
    return this.req(
      'post',
      `articles/announce?id=${slug}${encodeGetParamsWithOffset(opts)}`,
      { body, ...reqOpts },
    )
  }

  async articlesHash(opts={}, reqOpts={}) {
    return this.req('get', `articles/hash${encodeGetParamsWithOffset(opts)}`, reqOpts)
  }

  async articlesBulkUpdate(where, what, reqOpts={}) {
    return this.req('put',
      `articles/bulk-update`,
      { body: { where, what }, ...reqOpts },
    )
  }

  async articleCreate(article, opts={}, reqOpts={}) {
    const { path, parentId, previousSiblingId, render } = opts
    return this.req('post',
      `articles`,
      { body: Object.assign({ article }, opts), ...reqOpts },
    )
  }

  async articleCreateOrUpdate(article, opts={}, reqOpts={}) {
    return this.req('put',
      `articles`,
      { body: Object.assign({ article }, opts), ...reqOpts },
    )
  }

  async articleDelete(slug, reqOpts={}) {
    return this.req('delete', `articles?id=${slug}`, reqOpts)
  }

  async articleLike(slug, reqOpts={}) {
    return this.req('post', `articles/like?id=${slug}`, reqOpts)
  }

  async articleFeed(opts={}, reqOpts={}) {
    return this.req('get', `articles/feed${encodeGetParamsWithOffset(opts)}`, reqOpts)
  }

  async articleFollow(slug, reqOpts={}) {
    return this.req('post', `articles/follow?id=${slug}`, reqOpts)
  }

  async articleRedirects(opts={}, reqOpts={}) {
    return this.req('get', `articles/redirects${encodeGetParamsWithOffset(opts)}`, reqOpts)
  }

  async articleUnfollow(slug, reqOpts={}) {
    return this.req('delete', `articles/follow?id=${slug}`, reqOpts)
  }

  async articleUnlike(slug, reqOpts={}) {
    return this.req('delete', `articles/like?id=${encodeURIComponent(slug)}`, reqOpts)
  }

  async articleUpdatedNestedSet(user, reqOpts={}) {
    return this.req('put', `articles/update-nested-set/${encodeURIComponent(user)}`, reqOpts)
  }

  async editorFetchFiles(paths, reqOpts={}) {
    return this.req('post',
      `editor/fetch-files`,
      {
        body: {
          paths,
        },
        ...reqOpts,
      },
    )
  }

  async editorGetNoscopesBaseFetch(ids, ignore_paths_set, reqOpts={}) {
    return this.req('post',
      `editor/get-noscopes-base-fetch`,
      {
        body: {
          ids,
          ignore_paths_set
        },
        ...reqOpts,
      },
    )
  }

  async editorIdExists(idid, reqOpts={}) {
    const ret = await this.req('post',
      `editor/id-exists`,
      {
        body: {
          idid,
        },
        ...reqOpts
      },
    )
    return ret.data.exists
  }

  async editorIdCompletions(query, username, reqOpts={}) {
    const { data, status } = await this.req('post', 'editor/id-completions', {
      body: { query, username },
      ...reqOpts,
    })
    if (status !== 200) throw new Error('Could not fetch ID completions')
    return data.ids.map(id => ({ id, title: data.titles[id] }))
  }

  async issue(slug, number, reqOpts={}) {
    const { data, status } = await this.issues({ id: slug, number })
    return { data: data.issues[0], status }
  }

  async issues(opts, reqOpts={}) {
    return this.req('get',
      `issues${encodeGetParamsWithOffset(opts)}`,
      reqOpts
    )
  }

  async issueCreate(slug, issue, reqOpts={}) {
    return this.req('post',
      `issues?id=${encodeURIComponent(slug)}`,
      {
        body: { issue },
        ...reqOpts,
      },
    )
  }

  async issueDelete(slug, issueNumber, reqOpts={}) {
    return this.req('delete', `issues/${issueNumber}?id=${encodeURIComponent(slug)}`, reqOpts)
  }

  async issueEdit(slug, issueNumber, issue, reqOpts={}) {
    return this.req('put',
      `issues/${issueNumber}?id=${encodeURIComponent(slug)}`,
      {
        body: { issue },
        ...reqOpts,
      },
    )
  }

  async issueFollow(slug, issueNumber, reqOpts={}) {
    return this.req('post', `issues/${issueNumber}/follow?id=${slug}`, reqOpts)
  }

  async issueUnfollow(slug, issueNumber, reqOpts={}) {
    return this.req('delete', `issues/${issueNumber}/follow?id=${encodeURIComponent(slug)}`, reqOpts)
  }

  async issueLike(slug, issueNumber, reqOpts={}) {
    return this.req('post', `issues/${issueNumber}/like?id=${slug}`, reqOpts)
  }

  async issueUnlike(slug, issueNumber, reqOpts={}) {
    return this.req('delete', `issues/${issueNumber}/like?id=${encodeURIComponent(slug)}`, reqOpts)
  }

  async comments(slug, issueNumber, reqOpts={}) {
    return this.req('get',
      `issues/${issueNumber}/comments?id=${encodeURIComponent(slug)}`,
      reqOpts,
    )
  }

  async comment(slug, issueNumber, commentNumber, reqOpts={}) {
    return this.req('get',
      `issues/${issueNumber}/comment/${commentNumber}?id=${encodeURIComponent(slug)}`,
      reqOpts,
    )
  }

  async commentCreate(slug, issueNumber, source, reqOpts={}) {
    return this.req('post',
      `issues/${issueNumber}/comments?id=${encodeURIComponent(slug)}`,
      {
        body: { comment: { source } },
        ...reqOpts
      },
    )
  }

  async commentUpdate(slug, issueNumber, commentNumber, comment, reqOpts={}) {
    return this.req('put',
      `issues/${issueNumber}/comments/${commentNumber}?id=${encodeURIComponent(slug)}`,
      {
        body: { comment },
        ...reqOpts
      },
    )
  }

  async commentDelete(slug, issueNumber, commentNumber, reqOpts={}) {
    return this.req('delete', `issues/${issueNumber}/comments/${commentNumber}?id=${encodeURIComponent(slug)}`, reqOpts)
  }

  async min(opts={}, reqOpts={}) {
    return this.req('get', `min${encodeGetParams(opts)}`, reqOpts)
  }

  async siteSettingsGet(opts={}, reqOpts={}) {
    return this.req('get', `site`, { body: opts, ...reqOpts })
  }

  async siteSettingsUpdate(opts={}, reqOpts={}) {
    return this.req('put', `site`, { body: opts, ...reqOpts })
  }

  async siteSettingsBlacklistSignupIpGet(opts={}, reqOpts={}) {
    return this.req('get', `site/blacklist-signup-ip`, Object.assign({ body: opts }, reqOpts))
  }

  async siteSettingsBlacklistSignupIpCreate(opts={}, reqOpts={}) {
    return this.req('put', `site/blacklist-signup-ip`, Object.assign({ body: opts }, reqOpts))
  }

  async siteSettingsBlacklistSignupIpDelete(opts={}, reqOpts={}) {
    return this.req('delete', `site/blacklist-signup-ip`, Object.assign({ body: opts }, reqOpts))
  }

  async topics(opts={}, reqOpts={}) {
    return this.req('get', `topics${encodeGetParamsWithOffset(opts)}`, reqOpts)
  }

  async resetPassword(email, password, code, reqOpts={}) {
    return this.req(
      'post',
      `reset-password`,
      { body: { email, password, code }, ...reqOpts }
    )
  }

  async resetPasswordRequest(emailOrUsername, recaptchaToken, reqOpts={}) {
    return this.req(
      'post',
      `reset-password-request`,
      { body: { emailOrUsername, recaptchaToken }, ...reqOpts }
    )
  }

  async upload(path, reqOpts={}) {
    return this.req(
      'get',
      `uploads${encodeGetParamsWithOffset({ path })}`,
      Object.assign({}, { responseType: 'arraybuffer' }, reqOpts),
    )
  }

  async uploadCreateOrUpdate(path, bytes, reqOpts={}) {
    return this.req(
      'put',
      `uploads${encodeGetParamsWithOffset({ path })}`,
      {
        body: bytes,
        contentType: 'application/octet-stream',
        ...reqOpts
      }
    )
  }

  async uploadDelete(path, reqOpts={}) {
    return this.req('delete', `uploads${encodeGetParamsWithOffset({ path })}`, { ...reqOpts })
  }

  async uploadUpdate(path, attrs, reqOpts={}) {
    return this.req('patch', `uploads${encodeGetParamsWithOffset({ path })}`, { body: attrs, ...reqOpts })
  }

  async uploadHash(opts={}, reqOpts={}) {
    return this.req('get', `uploads/hash${encodeGetParamsWithOffset(opts)}`, reqOpts)
  }

  async uploadImages(opts={}, reqOpts={}) {
    return this.req('get', `uploads/images${encodeGetParamsWithOffset(opts)}`, reqOpts)
  }

  async uploadMetadata(path, reqOpts={}) {
    const { username, ...requestOptions } = reqOpts
    return this.req('get', `uploads/metadata${encodeGetParamsWithOffset({ path, username })}`, requestOptions)
  }

  async users(opts, reqOpts={}) {
    return this.req('get', `users${encodeGetParamsWithOffset(opts)}`)
  }

  async userCreate(attrs, recaptchaToken, reqOpts={}) {
    return this.req('post',
      `users`,
      { body: { user: attrs, recaptchaToken }, ...reqOpts },
    )
  }

  async userFollow(username, reqOpts={}){
    return this.req('post',
      `users/${username}/follow`,
      reqOpts
    )
  }

  async user(username, reqOpts={}) {
    const { data, status } = await this.users({ username }, reqOpts)
    return { data: data.users[0], status }
  }

  async userLogin(attrs, reqOpts={}) {
    return this.req('post',
      `login`,
      { body: { user: attrs }, ...reqOpts },
    )
  }

  async userUpdate(username, user, reqOpts={}) {
    return this.req('put',
      `users/${username}`,
      { body: { user }, ...reqOpts },
    )
  }

  async userCheckEmail(username, email, reqOpts={}) {
    return this.req('post', `users/${username}/email-check`, { body: { email }, ...reqOpts })
  }

  async userRequestEmailChange(username, email, reqOpts={}) {
    return this.req('post', `users/${username}/email-change`, { body: { email }, ...reqOpts })
  }

  async userUnlistContent(username, reqOpts={}) {
    return this.req('put', `users/${username}/unlist-content`, reqOpts)
  }

  async userMarkSpammer(username, reqOpts={}) {
    return this.req('put', `users/${username}/spammer`, reqOpts)
  }

  async userUpdateProfilePicture(username, bytes, reqOpts={}) {
    return this.req('put',
      `users/${username}/profile-picture`,
      { body: { bytes }, ...reqOpts },
    )
  }

  async userUnfollow(username, reqOpts={}) {
    return this.req('delete',
      `users/${username}/follow`,
      reqOpts
    )
  }
}

// https://stackoverflow.com/questions/6048504/synchronous-request-in-node-js/53338670#53338670
async function sendJsonHttp(method, path, opts={}) {
  let {
    body,
    contentType,
    expectStatus,
    getToken,
    headers,
    https,
    hostname,
    port,
    responseType,
    validateStatus
  } = opts
  let http
  if (https) {
    http = 'https'
  } else {
    http = 'http'
  }
  if (headers) {
    headers = Object.assign({}, headers)
  } else {
    headers = {}
  }
  headers['Content-Type'] = contentType || "application/json"
  if (getToken) {
    const token = getToken()
    if (token) {
      headers['Authorization'] = `Token ${token}`
    }
  }
  let url
  if (hostname) {
    const portStr = port ? `:${port}` : ''
    url = `${http}://${hostname}${portStr}${path}`
  } else {
    url = path
  }
  const response = await axios({
    data: body,
    headers,
    maxRedirects: 0,
    method,
    responseType,
    url,
    validateStatus,
  })
  const status = response.status
  if (
    expectStatus !== undefined &&
    status !== expectStatus
  ) {
    throw new Error(`status=${status} expected=${expectStatus} data=${JSON.stringify(response.data)}`)
  }
  return {
    data: response.data,
    headers: response.headers,
    status,
  }
}

// Non-API stuff.

class DbProviderBase extends ourbigbook.DbProvider {
  constructor(opts={}) {
    super()
    // IDs known to have come from persistent storage. Keep this separate from
    // id_cache because live conversions also inject their local header ASTs
    // into that cache while building the header tree.
    this.db_ids = new Set()
    this.id_cache = {}
    this.ref_cache = {
      from_id: {},
      to_id: {},
    }
    this.path_to_file_cache = {}
  }

  add_file_row_to_cache(row, context) {
    this.path_to_file_cache[row.path] = row
    const toplevelId = row.toplevelId
    if (
      // Happens on some unminimized condition when converting
      // cirosantilli.github.io @ 04f0f5bc03b9071f82b706b3481c09d616d44d7b + 1
      // twice with ourbigbook -S ., no patience to minimize and test now.
      toplevelId !== null
    ) {
      if (
        // We have to do this if here because otherwise it would overwrite the reconciled header
        // we have stitched into the tree with Include.
        !this.id_cache[toplevelId.idid]
      ) {
        this.add_row_to_id_cache(toplevelId, context)
      }
    }
  }

  add_ref_row_to_cache(row, to_id_key, include_key, context) {
    let to_id_key_dict = this.ref_cache[to_id_key][row[to_id_key]]
    if (to_id_key_dict === undefined) {
      to_id_key_dict = {}
      this.ref_cache[to_id_key][row[to_id_key]] = to_id_key_dict
    }
    let to_id_key_dict_type = to_id_key_dict[row.type]
    if (to_id_key_dict_type === undefined) {
      to_id_key_dict_type = []
      to_id_key_dict[row.type] = to_id_key_dict_type
    }
    to_id_key_dict_type.push(row)
    this.add_row_to_id_cache(row[include_key], context)
  }

  add_row_to_id_cache(row, context) {
    if (row !== null) {
      this.db_ids.add(row.idid)
      const ast = this.row_to_ast(row, context)
      const oldCache = this.id_cache[ast.id]
      if (
        // This is not just an optimization, we actually had a case that broke because
        // this was overwriting the value from the parsed tree, which contained more
        // information about the header tree not present in the new ast and which was required.
        oldCache
      ) {
        return oldCache
      } else {
        if (
          // Possible on reference to ID that does not exist and some other
          // non error cases I didn't bother to investigate.
          row.to !== undefined
        ) {
          ast.header_parent_ids = row.to.map(to => to.from_id)
        }
        this.id_cache[ast.id] = ast
        return ast
      }
    }
  }

  has_db_id(id) {
    return this.db_ids.has(id)
  }

  get_noscopes_base(ids, ignore_paths_set) {
    const cached_asts = []
    for (const id of ids) {
      if (id in this.id_cache) {
        const ast = this.id_cache[id]
        if (
          ignore_paths_set === undefined ||
          !ignore_paths_set.has(ast.input_path)
        ) {
          cached_asts.push(ast)
        }
      }
    }
    return cached_asts
  }

  get_file(path) {
    return this.path_to_file_cache[path]
  }

  /** Convert a Id DB row to a JavaScript AstNode object.
   *
   * @param row: a row from the Ids database
   * @return {AstNode}
   **/
  row_to_ast(row, context) {
    const ast = ourbigbook.AstNode.fromJSON(row.ast_json, context)
    ast.input_path = row.path
    ast.id = row.idid
    ast.toplevel_id = row.toplevel_id
    return ast
  }

  rows_to_asts(rows, context) {
    const asts = []
    for (const row of rows) {
      asts.push(this.add_row_to_id_cache(row, context))
      for (const row_title_title of row.from) {
        if (
          // We need this check because the version of the header it fetches does not have .to
          // so it could override one that did have the .to, and then other things could blow up.
          !(row_title_title.to && row_title_title.to.idid in this.id_cache)
        ) {
          const id2 = row_title_title.to
          if (id2) {
            const ret = this.add_row_to_id_cache(id2, context)
            if (ret !== undefined) {
              asts.push(ret)
            }
            // Get synonym of title title.
            for (const synonymRef of id2.from) {
              const ret = this.add_row_to_id_cache(synonymRef.to, context)
              if (ret !== undefined) {
                asts.push(ret)
              }
            }
          }
        }
      }
    }
    return asts
  }
}

const QUERY_TRUE_VAL = 'true'
const QUERY_FALSE_VAL = 'false'

function boolToQueryVal(b) {
  return b ? QUERY_TRUE_VAL : QUERY_FALSE_VAL
}

function queryValToBool(s) {
  return s === QUERY_TRUE_VAL
}

module.exports = {
  ARTICLE_HASH_LIMIT_MAX: 10000,
  DbProviderBase,
  QUERY_FALSE_VAL,
  QUERY_TRUE_VAL,
  WebApi,
  articleHash,
  boolToQueryVal,
  encodeGetParams,
  hashToHex,
  queryValToBool,
  read_include,
  sendJsonHttp,
}