gitdir

[root] / gitdir.rb

10.1KB

raw
#!/usr/bin/env ruby
# frozen_string_literal: true

require 'webrick'
require 'erb'
require 'open3'
require 'time'
require 'cgi'

module Config
  REPO_DIR  = File.expand_path('repos', __dir__)
  VIEW_DIR  = File.expand_path('views', __dir__)
  PUB_DIR   = File.expand_path('public', __dir__)
  BASE_URL  = ENV.fetch('BASE_URL', 'http://localhost:9393')
  BIND      = ENV.fetch('BIND', '0.0.0.0')
  PORT      = ENV.fetch('PORT', '9393').to_i
  SITE_NAME = ENV.fetch('SITE_NAME', 'git')
end

module Helpers
  def h(str)
    CGI.escapeHTML(str.to_s)
  end

  def human_size(bytes)
    return '0B' if bytes <= 0
    if bytes < 1024
      "#{bytes}B"
    elsif bytes < 1024 * 1024
      "#{(bytes / 1024.0).round(1)}KB"
    else
      "#{(bytes / (1024.0 * 1024)).round(1)}MB"
    end
  end

  def relative_time(time)
    diff = Time.now - time
    if diff < 60
      "#{diff.to_i}s ago"
    elsif diff < 3600
      "#{(diff / 60).to_i}m ago"
    elsif diff < 86400
      "#{(diff / 3600).to_i}h ago"
    elsif diff < 2592000
      "#{(diff / 86400).to_i}d ago"
    else
      time.strftime('%Y-%m-%d')
    end
  end

  def path_crumb(repo_name, ref, path)
    return '' if path.nil? || path.empty? || path == '.'
    parts = path.split('/')
    parts.each_with_index.map do |part, i|
      sub = parts[0..i].join('/')
      "<a href=\"/#{h repo_name}/tree/#{h ref}/#{h sub}\">#{h part}</a>"
    end.join(' / ')
  end

  def repo_nav(repo_name, ref, active)
    links = [
      ['files', "/#{h repo_name}/tree/#{h ref}"],
      ['log',   "/#{h repo_name}/log/#{h ref}"],
      ['refs',  "/#{h repo_name}/refs"]
    ]
    items = links.map do |name, url|
      name == active ? "<span>#{name}</span>" : "<a href=\"#{url}\">#{name}</a>"
    end
    "<div class=\"repo-nav\">#{items.join(' ')} <span class=\"ref-label\">#{h ref}</span></div>"
  end

  def format_diff(diff)
    return '' if diff.nil? || diff.empty?
    diff.lines.map do |line|
      esc = h(line)
      case line
      when /^diff /, /^index /, /^\+\+\+ /, /^--- /
        %(<span class="diff-head">#{esc}</span>)
      when /^@@/
        %(<span class="diff-hunk">#{esc}</span>)
      when /^\+/
        %(<span class="diff-add">#{esc}</span>)
      when /^-/
        %(<span class="diff-del">#{esc}</span>)
      else
        esc
      end
    end.join
  end
end

class Repo
  attr_reader :path, :name

  def initialize(path)
    @path = path
    @name = File.basename(path, '.git')
  end

  def self.all
    return [] unless File.directory?(Config::REPO_DIR)
    Dir.glob(File.join(Config::REPO_DIR, '*.git')).sort.map { |p| new(p) }
  end

  def self.find(name)
    path = File.join(Config::REPO_DIR, "#{name}.git")
    return nil unless File.directory?(path)
    new(path)
  end

  def description
    desc = File.read(File.join(@path, 'description')).strip
    desc.empty? ? 'no description' : desc
  rescue Errno::ENOENT
    'no description'
  end

  def empty?
    git('rev-parse', 'HEAD')
    false
  rescue StandardError
    true
  end

  def default_branch
    @default_branch ||= git('rev-parse', '--abbrev-ref', 'HEAD').strip
  end

  def branches
    git('branch', '--format=%(refname:short)').lines.map(&:strip).reject(&:empty?)
  end

  def tags
    git('tag', '--sort=-creatordate').lines.map(&:strip).reject(&:empty?)
  end

  def log(ref = nil, limit: 50)
    ref ||= default_branch
    raw = git('log', "--format=%H%x00%an%x00%ae%x00%at%x00%s", "-n#{limit}", ref)
    raw.lines(chomp: true).reject(&:empty?).map do |line|
      hash, author, email, ts, msg = line.split("\x00", 5)
      { hash: hash, author: author, email: email, time: Time.at(ts.to_i), message: msg }
    end
  end

  def tree(ref, path = '')
    tree_ish = path.empty? ? ref : git('rev-parse', "#{ref}:#{path}").strip
    raw = git('ls-tree', tree_ish)
    raw.lines(chomp: true).map do |line|
      meta, name = line.split("\t", 2)
      mode, type, hash = meta.split(' ', 3)
      { mode: mode, type: type, hash: hash, name: name }
    end
  end

  def blob(ref, path)
    git('show', "#{ref}:#{path}")
  end

  def blob_size(ref, path)
    git('cat-file', '-s', "#{ref}:#{path}").strip.to_i
  rescue StandardError
    0
  end

  def commit_info(hash)
    raw = git('log', '-1', "--format=%H%x00%an%x00%ae%x00%at%x00%s%x00%b", hash)
    c_hash, author, email, ts, subject, body = raw.split("\x00", 6)
    {
      hash: c_hash,
      author: author,
      email: email,
      time: Time.at(ts.to_i),
      subject: subject,
      body: body.to_s.strip
    }
  end

  def commit_diff(hash)
    raw = git('show', '--pretty=format:', '--stat', '-p', '--no-color', hash)
    raw.sub(/\A\n+/, '')
  rescue StandardError
    ''
  end

  def last_modified
    raw = git('log', '-1', '--format=%at', default_branch)
    Time.at(raw.strip.to_i)
  rescue StandardError
    Time.now
  end

  def clone_url
    "#{Config::BASE_URL}/#{name}.git"
  end

  private

  def git(*args)
    cmd = ['git', '--git-dir', @path] + args.map(&:to_s)
    stdout, stderr, status = Open3.capture3(*cmd)
    raise "git #{args.first}: #{stderr.strip}" unless status.success?
    stdout
  end
end

class TemplateScope
  include Helpers

  def initialize(locals)
    locals.each { |k, v| instance_variable_set("@#{k}", v) }
  end

  def get_binding
    binding
  end
end

module Templates
  def self.render(template, locals = {})
    body = partial(template, locals)
    locals[:body] = body
    partial('layout', locals)
  end

  def self.partial(name, locals)
    path = File.join(Config::VIEW_DIR, "#{name}.erb")
    erb = ERB.new(File.read(path), trim_mode: '-')
    scope = TemplateScope.new(locals)
    erb.result(scope.get_binding)
  end
end

class GitServlet < WEBrick::HTTPServlet::AbstractServlet
  def do_GET(req, res)
    path = CGI.unescape(req.path.sub(%r{/+$}, ''))

    case path
    when '', '/'
      index(res)
    when '/style.css'
      serve_file(res, File.join(Config::PUB_DIR, 'style.css'), 'text/css')
    when %r{^/([^/]+)$}
      repo_summary(res, $1)
    when %r{^/([^/]+)/log(?:/([^/]+))?$}
      repo_log(res, $1, $2)
    when %r{^/([^/]+)/tree/([^/]+)$}
      repo_tree(res, $1, $2, '')
    when %r{^/([^/]+)/tree/([^/]+)/(.+)$}
      repo_tree(res, $1, $2, $3)
    when %r{^/([^/]+)/blob/([^/]+)/(.+)$}
      repo_blob(res, $1, $2, $3)
    when %r{^/([^/]+)/raw/([^/]+)/(.+)$}
      repo_raw(res, $1, $2, $3)
    when %r{^/([^/]+)/commit/([0-9a-f]+)$}
      repo_commit(res, $1, $2)
    when %r{^/([^/]+)/refs$}
      repo_refs(res, $1)
    else
      not_found(res)
    end
  rescue StandardError => e
    $stderr.puts "#{e.class}: #{e.message}\n#{e.backtrace.first(5).join("\n")}"
    error_response(res, e)
  end

  private

  def render(res, template, locals)
    locals[:site_name] = Config::SITE_NAME
    res.status = 200
    res['Content-Type'] = 'text/html; charset=utf-8'
    res.body = Templates.render(template, locals)
  end

  def index(res)
    render(res, 'index', repos: Repo.all, title: Config::SITE_NAME)
  end

  def repo_summary(res, name)
    repo = Repo.find(name) or return not_found(res)
    if repo.empty?
      return render(res, 'repo_empty', repo: repo, title: repo.name)
    end
    ref = repo.default_branch
    tree = repo.tree(ref)
    log = repo.log(ref, limit: 10)
    readme = find_readme(repo, ref)
    render(res, 'repo', repo: repo, ref: ref, tree: tree, log: log,
           readme: readme, title: repo.name)
  end

  def repo_log(res, name, ref)
    repo = Repo.find(name) or return not_found(res)
    ref ||= repo.default_branch
    log = repo.log(ref, limit: 100)
    render(res, 'log', repo: repo, ref: ref, log: log, title: "log - #{repo.name}")
  end

  def repo_tree(res, name, ref, path)
    repo = Repo.find(name) or return not_found(res)
    tree = repo.tree(ref, path)
    render(res, 'tree', repo: repo, ref: ref, path: path, tree: tree,
           title: path.empty? ? repo.name : "#{path} - #{repo.name}")
  end

  def repo_blob(res, name, ref, path)
    repo = Repo.find(name) or return not_found(res)
    content = repo.blob(ref, path)
    size = repo.blob_size(ref, path)
    render(res, 'blob', repo: repo, ref: ref, path: path,
           content: content, size: size, title: "#{path} - #{repo.name}")
  end

  def repo_raw(res, name, ref, path)
    repo = Repo.find(name) or return not_found(res)
    content = repo.blob(ref, path)
    res.status = 200
    res['Content-Type'] = 'application/octet-stream'
    res['Content-Disposition'] = "inline; filename=\"#{File.basename(path)}\""
    res.body = content
  end

  def repo_commit(res, name, hash)
    repo = Repo.find(name) or return not_found(res)
    info = repo.commit_info(hash)
    diff = repo.commit_diff(hash)
    render(res, 'commit', repo: repo, info: info, diff: diff,
           title: "#{info[:subject][0..60]} - #{repo.name}")
  end

  def repo_refs(res, name)
    repo = Repo.find(name) or return not_found(res)
    render(res, 'refs', repo: repo, branches: repo.branches, tags: repo.tags,
           title: "refs - #{repo.name}")
  end

  def find_readme(repo, ref)
    tree = repo.tree(ref)
    entry = tree.find { |e| e[:name] =~ /\Areadme/i }
    repo.blob(ref, entry[:name]) if entry
  rescue StandardError
    nil
  end

  def not_found(res)
    res.status = 404
    res['Content-Type'] = 'text/html; charset=utf-8'
    res.body = Templates.render('404', title: 'Not Found', site_name: Config::SITE_NAME)
  rescue StandardError
    res.body = '<h1>404 Not Found</h1>'
  end

  def error_response(res, e)
    res.status = 500
    res['Content-Type'] = 'text/html; charset=utf-8'
    msg = CGI.escapeHTML("#{e.class}: #{e.message}\n#{e.backtrace.first(10).join("\n")}")
    res.body = "<h1>500</h1><pre>#{msg}</pre>"
  end

  def serve_file(res, path, type)
    if File.exist?(path)
      res.status = 200
      res['Content-Type'] = type
      res.body = File.read(path)
    else
      not_found(res)
    end
  end
end

server = WEBrick::HTTPServer.new(
  Port: Config::PORT,
  BindAddress: Config::BIND,
  Logger: WEBrick::Log.new($stderr, WEBrick::Log::INFO),
  AccessLog: [[$stderr, WEBrick::AccessLog::COMBINED_LOG_FORMAT]]
)
server.mount('/', GitServlet)

trap('INT')  { server.shutdown }
trap('TERM') { server.shutdown }

puts "gitdir: serving #{Config::REPO_DIR} on http://#{Config::BIND}:#{Config::PORT}"
server.start