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

const https = require('https')
const { Writable } = require('stream')
const path = require('path')

module.exports = API

function API(remote, opts) {
  if (!(this instanceof API)) return new API(remote, opts)
  if (!opts) opts = {}
  this.remote = remote.replace(/\/?$/,'')
  this._tls = {}
  if (opts.cert) this._tls.cert = opts.cert
  if (opts.key) this._tls.key = opts.key
  if (opts.ca) this._tls.ca = opts.ca
  this._verify = opts.verify ?? function (hostname, fp, cb) { cb(null, false) }
  let u = new URL(this.remote)
  this._hostname = u.host
}

API.prototype._request = function (u, opts, cb) {
  cb = once(cb)
  if (typeof opts === 'function') {
    cb = opts
    opts = {}
  }
  let r = https.request(this.remote + '/api/v1/' + u, Object.assign({
    rejectUnauthorized: false,
  }, opts, this._tls))
  let res = null, trusted = false
  r.once('error', cb)
  r.once('response', onresponse)
  r.once('socket', c => {
    c.once('secure', () => {
      let cert = c.getPeerCertificate(true)
      let fp = cert?.fingerprint512.toLowerCase().replace(/:/g,'')
      this._verify(this._hostname, fp, (err,ok) => {
        if (err) {
          c.destroy()
          cb(err)
        } else if (!ok) {
          c.destroy()
          err = new Error('untrusted fingerprint for ' + this._hostname)
          err.code = 'UNTRUSTED_FINGERPRINT'
          err.certificate = cert
          err.hostname = this._hostname
          err.fingerprint512 = fp
          cb(err)
        } else {
          trusted = true
          if (res) onresponse(res)
        }
      })
    })
  })
  return r

  function onresponse(res_) {
    res = res_
    if (!trusted) return
    let buffers = []
    res.once('error', cb)
    res.pipe(new Writable({
      write(buf,enc,next) {
        buffers.push(buf)
        next()
      },
      final(next) {
        cb(null, res, Buffer.concat(buffers).toString())
        next()
      }
    }))
  }
}

API.prototype.listRepos = function (cb) {
  let r = this._request('repo/list', {}, (err,res,body) => {
    if (err) return cb(err)
    else handleJSON(res, body, cb)
  })
  r.end()
}

API.prototype.createRepo = function (opts, cb) {
  let params = new URLSearchParams(Object.keys(opts).flatMap(key => {
    if (Array.isArray(opts[key])) return opts[key].map(v => [key,v])
    else return [[key,opts[key]]]
  }))
  let ropts = {
    method: 'POST',
    headers: { 'content-type': 'application/x-www-form-urlencoded' },
  }
  let r = this._request('repo/create', ropts, (err,res,body) => {
    if (err) return cb(err)
    else handleJSON(res, body, cb)
  })
  r.end(params.toString())
}

API.prototype.updateRepo = function (rid, opts, cb) {
  let params = new URLSearchParams(Object.keys(opts).flatMap(key => {
    if (Array.isArray(opts[key])) return opts[key].map(v => [key,v])
    else return [[key,opts[key]]]
  }))
  let ropts = {
    method: 'POST',
    headers: { 'content-type': 'application/x-www-form-urlencoded' },
  }
  let r = this._request('repo/'+rid+'/update', ropts, (err,res,body) => {
    if (err) return cb(err)
    else handleJSON(res, body, cb)
  })
  r.end(params.toString())
}

API.prototype.updateRepoFromPath = function (rpath, opts, cb) {
  rpath = String(rpath).replace(/^\//,'')
  let params = new URLSearchParams(Object.keys(opts).flatMap(key => {
    if (Array.isArray(opts[key])) return opts[key].map(v => [key,v])
    else return [[key,opts[key]]]
  }))
  let ropts = {
    method: 'POST',
    headers: { 'content-type': 'application/x-www-form-urlencoded' },
  }
  let r = this._request('repo-from-path/'+rpath+'/update', ropts, (err,res,body) => {
    if (err) return cb(err)
    else handleJSON(res, body, cb)
  })
  r.end(params.toString())
}

API.prototype.removeRepo = function (id, cb) {
  let ropts = {
    method: 'POST',
    headers: { 'content-type': 'application/x-www-form-urlencoded' },
  }
  let r = this._request('repo/'+id+'/remove', ropts, (err,res,body) => {
    if (err) return cb(err)
    else handleJSON(res, body, cb)
  })
  r.end()
}

API.prototype.removeRepoFromPath = function (rpath, cb) {
  let ropts = {
    method: 'POST',
    headers: { 'content-type': 'application/x-www-form-urlencoded' },
  }
  let r = this._request('repo-from-path/'+rpath+'/remove', ropts, (err,res,body) => {
    if (err) return cb(err)
    else handleJSON(res, body, cb)
  })
  r.end()
}

API.prototype.getRepo = function (rid, cb) {
  let r = this._request(`repo/${rid}/info`, {}, (err,res,body) => {
    if (err) return cb(err)
    else handleJSON(res, body, cb)
  })
  r.end()
}

API.prototype.getRepoFromPath = function (rpath, cb) {
  let r = this._request(`repo-from-path/${rpath}/info`, {}, (err,res,body) => {
    if (err) return cb(err)
    else handleJSON(res, body, cb)
  })
  r.end()
}

API.prototype.reloadConfig = function (cb) {
  let opts = {
    method: 'POST',
    headers: { 'content-type': 'application/x-www-form-urlencoded' },
  }
  let r = this._request('config/reload', opts, (err,res,body) => {
    if (err) return cb(err)
    else handleJSON(res, body, cb)
  })
  r.end('')
}

function once(f) {
  return function () {
    let g = f
    f = null
    if (g) g.apply(null, arguments)
  }
}

function handleJSON(res, body, cb) {
  if (res.statusCode !== 200) return cb(new Error(body))
  if (!/(application|text)\/json\s*($|;)/.test(res.headers['content-type'])) {
    return cb(new Error(body))
  }
  let data = null
  try { data = JSON.parse(body) }
  catch (err) { return cb(err) }
  cb(null, data)
}