qwgit / lib / webapi.js
http{/,s} git server
git clone http://git.nthia.dev/qwgit

const path = require('path')
const { pipeline } = require('stream')
const parseParams = require('./parse-params.js')
const checkHeaders = ['origin','referrer']

module.exports = function (qwgit, req, res, ids) {
  let m = null
  if (!req.url.startsWith('/api/v1/')) return error(res, 404, 'not found')
  let u = req.url.slice('/api/v1'.length)
  let ct = req.headers['content-type']?.trim()?.toLowerCase()
  if (req.method === 'POST' && ct !== 'application/x-www-form-urlencoded') {
    return error(res, 400, `expected content-type: application/x-www-form-urlencoded, found: ${ct}`)
  }

  // don't allow api access originating from other domains
  let host = req.headers.host
  if (host === undefined) return error(res, 400, `host header required for api access`)
  for (let i = 0; i < checkHeaders.length; i++) {
    let key = checkHeaders[i]
    if (Object.hasOwnProperty.call(req.headers,key) && req.headers[key] !== host) {
      let u = null
      try { u = new URL(req.headers[key]) }
      catch (err) {
        return error(res, 400, `${key} header is not a valid url`)
      }
      if (host !== u.host) {
        return error(res, 400, `host header does not match ${key} header`)
      }
    }
  }

  if (req.method === 'POST' && u === '/repo/create') {
    if (ids.length === 0) return error(res, 401, 'must authenticate to create repos')
    pipeline(req, parseParams(4096, (err,params) => {
      if (err) return error(res, 400, err)
      let r = {
        paths: params.getAll('path'),
        list: params.getAll('list'),
        push: params.getAll('push'),
        pull: params.getAll('pull'),
        name: params.get('name'),
        description: params.get('description'),
        tags: params.getAll('tag'),
      }
      if (r.paths.length === 0) return error(res, 400, 'a new repo must have one or more paths')
      for (let i = 0; i < r.paths.length; i++) {
        if (!qwgit.repos.isAuthorized('create', r.paths[i], ids)) {
          return error(res, 403, 'not authorized to create a repo at path: ' + r.paths[i])
        }
      }
      qwgit.repos.create(r, (err,repo) => {
        if (err) return error(res, 400, `failed to create repo directory: ${err}`)
        else json(res, 200, { id: repo.id })
      })
    }), err => {})
  } else if (req.method === 'POST' && (m = /^\/repo\/([^\/]+)\/update$/.exec(u))) {
    let rid = m[1]
    pipeline(req, parseParams(4096, (err,params) => {
      if (err) return error(res, 400, err)
      let repo = qwgit.repos.get(rid)
      if (repo === null) error(res, 404, 'repo not found')
      else {
        try { handleUpdate(qwgit, res, rid, repo, params) }
        catch (err) { error(res, 500, err) }
      }
    }), err => {})
  } else if (req.method === 'POST' && (m = /^\/repo-from-path\/(.+)\/update$/.exec(u))) {
    let rpath = m[1]
    pipeline(req, parseParams(4096, (err,params) => {
      if (err) return error(res, 400, err)
      let repo = qwgit.repos.getFromPath(rpath)
      if (repo === null) error(res, 404, 'repo not found')
      else {
        try { handleUpdate(qwgit, res, repo.id, repo, params) }
        catch (err) { error(res, 500, err) }
      }
    }), err => {})
  } else if (req.method === 'POST' && (m = /^\/repo\/([^\/]+)\/remove$/.exec(u))) {
    let rid = m[1]
    pipeline(req, parseParams(4096, (err,params) => {
      if (err) return error(res, 400, err)
      qwgit.repos.remove(rid, err => {
        if (err) error(res, 500, err)
        else json(res, 200, { status: 'ok', info: `successfully removed git repo ${rid}` })
      })
    }), err => {})
  } else if (req.method === 'POST' && (m = /^\/repo-from-path\/([^\/]+)\/remove$/.exec(u))) {
    let rpath = m[1]
    pipeline(req, parseParams(4096, (err,params) => {
      if (err) return error(res, 400, err)
      let repo = qwgit.repos.getFromPath(rpath)
      if (repo === null) error(res, 404, 'repo not found')
      qwgit.repos.remove(repo.id, err => {
        if (err) error(res, 500, err)
        else json(res, 200, { status: 'ok', info: `successfully removed git repo ${repo.id}` })
      })
    }), err => {})
  } else if (req.method === 'GET' && (m = /^\/repo\/([^\/]+)\/info$/.exec(u))) {
    let repo = qwgit.repos.get(m[1])
    if (repo === null) error(res, 404, 'repo not found')
    else json(res, 200, repo)
  } else if (req.method === 'GET' && (m = /^\/repo-from-path\/([^\/]+)\/info$/.exec(u))) {
    let repo = qwgit.repos.getFromPath(m[1])
    if (repo === null) error(res, 404, 'repo not found')
    else json(res, 200, repo)
  } else if (req.method === 'GET' && u === '/repo/list') {
    let list = qwgit.repos.listAuthorized(ids)
    json(res, 200, list)
  } else if (req.method === 'POST' && u === '/config/reload') {
    pipeline(req, parseParams(4096, (err,params) => {
      if (err) return error(res, 400, err)
      qwgit.emit('reloadConfig')
      json(res, 200, {})
    }), (err) => {})
  } else {
    error(res, 404, 'not found')
  }
}

function error(res, code, err) {
  res.writeHead(code, { 'content-type': 'text/plain; charset=utf-8' })
  return res.end(String(err?.message ?? err) + '\n')
}
function json(res, code, obj) {
  res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' })
  res.end(JSON.stringify(obj,null,2)+'\n')
}

function handleUpdate(qwgit, res, rid, repo, params) {
  let r = Object.assign({}, repo, {
    id: params.get('id') ?? rid,
  })
  console.log('handle r=',r)
  let plural = { list: 'lists', push: 'pushes', pull: 'pulls', tag: 'tags' }
  let mods = [['paths','path'],['list'],['push'],['pull'],['tag']]
  mods.forEach(ms => {
    if (!Array.isArray(r[ms[0]])) r[ms[0]] = []
    let xs = ms.flatMap(m => params.getAll(m) ?? [])
    if (xs.length === 1 && xs[0] === '') r[xs[0]] = []
    else if (xs.length > 0) r[ms[0]] = xs

    let rs = ms.flatMap(m => params.getAll(m + ':remove') ?? [])
    if (rs.length > 0 && Array.isArray(r[ms[0]])) {
      r[ms[0]] = r[ms[0]].filter(x => !rs.includes(x))
    }
    let as = ms.flatMap(m => params.getAll(m + ':add') ?? [])
    if (as.length > 0 && Array.isArray(r[ms[0]])) {
      r[ms[0]] = Array.from(new Set(r[ms[0]].concat(as)))
    }
    if (params.has(ms[0] + ':clear')) r[ms[0]] = []
    if (plural.hasOwnProperty(ms[0]) && params.has(plural[ms[0]] + ':clear')) r[ms[0]] = []
  })
  let name = params.get('name')
  if (name !== undefined) r.name = name
  let description = params.get('description')
  if (description !== undefined) r.description = description

  qwgit.repos.update(rid, r, (err,repo) => {
    if (err) return error(res, 400, err)
    if (r.id !== undefined && r.id !== rid) {
      json(res, 200, { status: 'ok', info: `successfully updated git repo ${rid} => ${r.id}` })
    } else {
      json(res, 200, { status: 'ok', info: `successfully updated git repo ${rid}` })
    }
  })
}