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

const path = require('path')
let git = null
let mime = null
const entities = new Map([
  ['<','<'],
  ['>','>'],
  ['"','"'],
  ["'",'''],
])
const css = `<style>
  .page {
    max-width: 800px;
    overflow-x: auto;
    margin: auto;
  }
  .page .name {
    font-size: 1.5em;
  }
  .page .desc {
    margin-top: 0.5em;
  }
  .page .repo {
    margin-bottom: 1em;
  }
  .file-list .dir, .file-list .file {
    margin-bottom: 0.5em;
  }
  .page a:link, .page a:visited {
    text-decoration: none;
  }
  .page a:hover {
    text-decoration: underline;
  }
  .code {
    font-family: monospace;
    padding: 0.5em;
    background-color: #eee;
  }
  .page .clone {
    margin-top: 0.5em;
  }
  .page .repos-link {
    text-align: right;
    margin-bottom: -1em;
  }
  .source {
    font-family: monospace;
  }
</style>`

function header(req, m, pathParts) {
  let proto = req.socket?.ssl ? 'https:' : 'http:'
  return html`
    <div class="repos-link">[<a href="/">repos</a>]</div>
    <div class="name">
      <a href="/${m.outerPath}">${m.outerPath}</a> / ${[pathParts]}
    </div>
    <div class="desc">${m.repo.description || ''}</div>
    <div class="clone code">
      git clone <span id="proto">${proto}</span>//<span id="host">${req.headers.host}</span>/${m.outerPath}
    </div>
    <script>
      document.getElementById('proto').innerText = location.protocol
      document.getElementById('host').innerText = location.host
    </script>
    <hr>`
}

module.exports = function (opts) {
  let repodir = opts.repodir
  let repos = opts.repos
  return function (qwgit, req, res, ids) {
    if (!git) git = require('nodegit')
    if (!mime) mime = require('mime')
    let m = null
    if (req.url === '/') {
      let repos = qwgit.repos.listAuthorized(ids)
      res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
      res.end(html`
        ${[css]}
        <div class="page">
        ${repos.length === 0 ? '[ no repos ]' : ''}
        ${repos.map(repo => {
          return `<div class="repo">
            <div class="name"><a href="${repo.paths[0]}">${repo.name}</a></div>
            <div class="desc">${repo.description}</div>
            <div class="tags">${repo.tags}</div>
          </div>`
        })}
        </div>
      `.trim().replace(/^ +/mg,'') + '\n')
    } else if ((m = repos.match(req.url)) !== null) {
      let ipath = m.innerPath.replace(/\?.*/,'')
      let gitDir = qwgit.repos.getFilePath(m.repo.id)
      if (ipath === '/' || ipath === '') {
        showDir(req, res, git, gitDir, m, '/')
      } else if (/^\/d(?:\/|$)/.test(ipath)) {
        showDir(req, res, git, gitDir, m, ipath.slice('/d/'.length))
      } else if (ipath.startsWith('/f/')) {
        showFile(req, res, git, gitDir, m, ipath.slice('/f/'.length))
      } else if (ipath.startsWith('/r/')) {
        showRawFile(req, res, git, gitDir, m, ipath.slice('/r/'.length))
      } else if (ipath.startsWith('/c/')) {
        res.writeHead(404, { 'content-type': 'text/html; charset=utf-8' })
        res.end('TODO: commit\n')
      } else {
        res.writeHead(404, { 'content-type': 'text/html; charset=utf-8' })
        res.end('not found\n')
      }
    } else {
      res.writeHead(404, { 'content-type': 'text/html; charset=utf-8' })
      res.end('not found\n')
    }
  }
}

function error(res, code, msg) {
  res.writeHead(code, { 'content-type': 'text/plain; charset=utf-8' })
  res.end(msg + '\n')
}

function esc(s) {
  return String(s).replace(/[<>"']/g, s => entities.get(s) ?? '')
}

function html(strings, ...values) {
  return strings.flatMap((s,i) => {
    if (i === strings.length-1) return [s]
    let v = values[i]
    return Array.isArray(v) ? [s].concat(v) : [s,esc(values[i])]
  }).join('')
}

async function showDir(req, res, git, gitDir, m, dir) {
  let err = null, errCode = 500
  let c = null
  try {
    let r = await git.Repository.open(gitDir)
    let bs = await r.getReferences()
    c = await r.getBranchCommit(bs[0].name())
  } catch (e) {
    err = e
  }
  let t = null
  if (c) {
    try { t = await c.getTree() }
    catch (e) { err = e }
  }
  if (t && !/^[\/]*$/.test(dir)) {
    let te = null
    try { te = await t.getEntry(dir) }
    catch (e) {
      if (err.errno === -3) {
        err = 'directory not found'
        errCode = 404
      } else {
        err = e
      }
    }
    if (te && !te.isTree()) {
      errCode = 400
      err = 'not a directory'
    }
    try { t = await te.getTree() }
    catch (e) { err = e }
  }
  res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
  let idir = m.innerPath.replace(/^\/d(?:\/|$)/,'/') || '/'
  let updir = path.join(idir,'..')
  let up = idir === '/' ? '' : html`<div class="dir">
    <a href="/${updir === '/' ? m.outerPath : path.join(m.outerPath,'d',updir)}">../</a>
  </div>`
  let parts = dir.split('/').filter(Boolean)
  let pathParts = parts.map((_,i) => parts.slice(0,i+1).join('/'))
    .map((x,i) => {
      return html`<a href="/${path.join(m.outerPath,'d',x)}">${path.basename(x)}</a>`
    }).join(' / ')
  let body = null
  if (t) {
    body = t.entries().map(e => {
      let p = e.path()
      if (e.isDirectory()) {
        return html`<div class="dir">
          <a href="/${path.join(m.outerPath,'d',p)}">${path.basename(p)}/</a>
        </div>`
      } else {
        return html`<div class="file">
          <a href="/${path.join(m.outerPath,'f',p)}">${path.basename(p)}</a>
        </div>`
      }
    })
    let files = t.entries().map(e => path.basename(e.path()))
    let readme = files.find(x => /^readme\.(md|txt|markdown)$/i.test(x))
    if (readme) {
      let te = null
      try { te = await t.getEntry(readme) }
      catch (e) {
        if (e.errno === -3) {
          errCode = 404
          err = 'file not found'
        } else {
          err = e
        }
      }
      let b = null
      if (te && !te.isFile()) {
        err = 'not a file'
        errCode = 400
      } else if (te) {
        try { b = await te.getBlob() }
        catch (e) { err = e }
      }
      if (err) {
        body.push(`<hr>\n${esc(String(err.message ?? err))}`)
      } else if (b) {
        body.push(`<hr>\n<pre class="source">${esc(b.content())}</div>`)
      }
    }
  } else if (err) {
    body = `[ ${esc(String(err.message ?? err))} ]`
  } else {
    body = '[ no commit history ]'
  }
  res.end(html`
    ${[css]}
    <div class="page file-list">
      ${[header(req,m,pathParts)]}
      ${[up]}
      ${body}
    </div>\n`.replace(/^ {4}/mg,'')
  )
}

async function showRawFile(req, res, git, gitDir, m, file) {
  let err = null, errCode = 500
  let c = null
  try {
    let r = await git.Repository.open(gitDir)
    let bs = await r.getReferences()
    c = await r.getBranchCommit(bs[0].name())
  } catch (e) {
    err = e
  }
  let t = null, te = null
  if (c) {
    try { t = await c.getTree() }
    catch (e) { err = e }
  }
  if (t) {
    try { te = await t.getEntry(file) }
    catch (e) {
      if (e.errno === -3) {
        errCode = 404
        err = 'file not found'
      } else {
        err = e
      }
    }
  }
  let b = null
  if (te && !te.isFile()) {
    err = 'not a file'
    errCode = 400
  } else {
    b = await te.getBlob()
  }
  let type = mime.getType(file)
  if (err) {
    res.writeHead(errCode, { 'content-type': 'text/plain; charset=utf-8' })
    res.end(String(err.message ?? err) + '\n')
    return
  } else if (/^(?:text\/[^+]*|application\/(?:javascript|x?html))(?:\+|$)/.test(type)) {
    res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' })
  } else {
    res.writeHead(200, { 'content-type': type })
  }
  res.end(b.content())
}

async function showFile(req, res, git, gitDir, m, file) {
  let err = null, errCode = 500
  let c = null
  try {
    let r = await git.Repository.open(gitDir)
    let bs = await r.getReferences()
    c = await r.getBranchCommit(bs[0].name())
  } catch (e) {
    err = e
  }
  let t = null
  if (c) {
    try { t = await c.getTree() }
    catch (e) { err = e }
  }
  let te = null
  if (t) {
    try { te = await t.getEntry(file) }
    catch (e) {
      if (e.errno === -3) {
        errCode = 404
        err = 'file not found'
      } else {
        err = e
      }
    }
  }
  if (te && !te.isFile()) {
    err = 'not a file'
    errCode = 400
  }
  res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
  let type = mime.getType(file)
  let b = null
  if (te) {
    try { b = await te.getBlob() }
    catch (e) { err = e }
  }
  let source = ''
  if (err) {
    source = String(err.message ?? err)
  } else if (/^text\//.test(type) || /^application\/(?:json|javascript|x?html)\b/.test(type)) {
    source = esc(b.content())
  } else if (/^image\//.test(type)) {
    let src = '/' + path.join(m.outerPath,'r',file)
    source = html`<a href="${src}"><img src="${src}"></a>`
  } else if (/^makefile$/i.test(file)) {
    source = esc(b.content())
  } else {
    let src = '/' + path.join(m.outerPath,'r',file)
    source = html`[ <a href="${src}">load raw content</a> ]`
  }
  let parts = file.split('/')
  let pathParts = parts.map((_,i) => parts.slice(0,i+1).join('/'))
    .map((x,i) => {
      let df = i === parts.length-1 ? 'f' : 'd'
      return html`<a href="/${path.join(m.outerPath,df,x)}">${path.basename(x)}</a>`
    }).join(' / ')
  res.end(`${css}\n<div class="page">`
    + header(req,m,pathParts)
    + `<pre class="source">${source}</pre>`
    + '</div>')
}