Repositories / gitweb2.git
gitweb2.git
Clone (read-only): git clone http://git.guha-anderson.com/git/gitweb2.git
@@ -0,0 +1,5 @@ +.build/ +build/ +*.kexe +*.klib +.kotlin/
@@ -0,0 +1,58 @@ +KOTLIN_HOME ?= /home/arjun/.local/opt/kotlin +KONANC := $(KOTLIN_HOME)/bin/konanc +CINTEROP := $(KOTLIN_HOME)/bin/cinterop +PKG_CONFIG ?= pkg-config +TARGET ?= linux_x64 + +KOTLIN_GIT_DIR ?= /home/arjun/repos/homebox/kotlin-git +KOTLIN_GIT_KLIB := $(KOTLIN_GIT_DIR)/.build/klib/kotlin-git.klib +LIBGIT2_KLIB := $(KOTLIN_GIT_DIR)/.build/klib/libgit2.klib + +BUILD_DIR := .build +LIBEVENT_KLIB := $(BUILD_DIR)/klib/libevent.klib +APP_BIN := $(BUILD_DIR)/bin/gitweb2.kexe + +LIBGIT2_LIB_DIR := $(shell $(PKG_CONFIG) --variable=libdir libgit2 2>/dev/null) +LIBGIT2_LIBS := $(shell $(PKG_CONFIG) --libs libgit2 2>/dev/null) +LIBEVENT_INCLUDE_DIR := $(shell $(PKG_CONFIG) --variable=includedir libevent 2>/dev/null) +LIBEVENT_LIB_DIR := $(shell $(PKG_CONFIG) --variable=libdir libevent 2>/dev/null) +LIBEVENT_CFLAGS := $(shell $(PKG_CONFIG) --cflags libevent 2>/dev/null) +LIBEVENT_LIBS := $(shell $(PKG_CONFIG) --libs libevent 2>/dev/null) + +SOURCES := $(shell find src/main/kotlin -name '*.kt' | sort) + +.PHONY: all check-system-libevent check-system-libgit2 kotlin-git run clean + +all: $(APP_BIN) + +check-system-libgit2: + $(PKG_CONFIG) --exists libgit2 + +check-system-libevent: + $(PKG_CONFIG) --exists libevent + +kotlin-git: | check-system-libgit2 + $(MAKE) -C $(KOTLIN_GIT_DIR) + +$(LIBEVENT_KLIB): src/nativeInterop/cinterop/libevent.def | check-system-libevent + mkdir -p $(dir $@) + $(CINTEROP) -target $(TARGET) -def $< -o $(BUILD_DIR)/klib/libevent \ + -libraryPath $(LIBEVENT_LIB_DIR) \ + -compiler-option -I$(LIBEVENT_INCLUDE_DIR) \ + $(foreach flag,$(LIBEVENT_CFLAGS),-compiler-option $(flag)) + +$(APP_BIN): $(SOURCES) $(LIBEVENT_KLIB) kotlin-git + mkdir -p $(dir $@) + $(KONANC) -target $(TARGET) -o $(BUILD_DIR)/bin/gitweb2 \ + -library $(LIBGIT2_KLIB) -library $(KOTLIN_GIT_KLIB) -library $(LIBEVENT_KLIB) $(SOURCES) \ + -linker-option -L$(LIBGIT2_LIB_DIR) \ + -linker-option -L$(LIBEVENT_LIB_DIR) \ + -linker-option --allow-shlib-undefined \ + $(foreach flag,$(LIBGIT2_LIBS),-linker-option $(flag)) \ + $(foreach flag,$(LIBEVENT_LIBS),-linker-option $(flag)) + +run: $(APP_BIN) + $(APP_BIN) ~/repos + +clean: + rm -rf $(BUILD_DIR)
@@ -0,0 +1,40 @@ +# gitweb2 + +A Kotlin/Native, read-only web viewer for local Git repositories. + +The app links against the `kotlin-git` library in +`/home/arjun/repos/homebox/kotlin-git` and uses libevent's native HTTP server +through cinterop. It does not use the JVM. + +## Requirements + +- Kotlin/Native installed at `/home/arjun/.local/opt/kotlin` +- system `libgit2` and `libevent` visible through `pkg-config` +- `make` + +## Build + +```sh +make +``` + +## Run + +```sh +.build/bin/gitweb2.kexe ~/repos +``` + +Options: + +```sh +.build/bin/gitweb2.kexe ~/repos --host 127.0.0.1 --port 8080 +``` + +The server scans nested directories for Git repositories and renders +repository, branch, file, directory, and commit history pages. Nested repository +paths and branch names that contain slashes are percent-encoded in URLs: + +```text +/repo/homebox%2Fkotlin-git/main/README.md +/repo/homebox%2Fkotlin-git/main/-/commits +```
@@ -0,0 +1,3 @@ +fun main(args: Array<String>) { + gitweb.runGitWeb2(args) +}
@@ -0,0 +1,480 @@ +@file:OptIn(ExperimentalForeignApi::class) + +package gitweb + +import gitweb.internal.event.evbuffer_add +import gitweb.internal.event.evbuffer_free +import gitweb.internal.event.evbuffer_new +import gitweb.internal.event.event_base_dispatch +import gitweb.internal.event.event_base_free +import gitweb.internal.event.event_base_new +import gitweb.internal.event.evhttp_add_header +import gitweb.internal.event.evhttp_bind_socket +import gitweb.internal.event.evhttp_free +import gitweb.internal.event.evhttp_new +import gitweb.internal.event.evhttp_request_get_output_headers +import gitweb.internal.event.evhttp_request_get_uri +import gitweb.internal.event.evhttp_send_reply +import gitweb.internal.event.evhttp_set_gencb +import kotlinx.cinterop.COpaquePointer +import kotlinx.cinterop.CPointer +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.StableRef +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.asStableRef +import kotlinx.cinterop.convert +import kotlinx.cinterop.pointed +import kotlinx.cinterop.staticCFunction +import kotlinx.cinterop.toKString +import kotlinx.cinterop.usePinned +import kotlinx.git.Blob +import kotlinx.git.Branch +import kotlinx.git.BranchType +import kotlinx.git.Git +import kotlinx.git.GitException +import kotlinx.git.Repository +import kotlinx.git.TreeEntryKind +import platform.posix.F_OK +import platform.posix.access +import platform.posix.closedir +import platform.posix.errno +import platform.posix.getenv +import platform.posix.opendir +import platform.posix.readdir +import platform.posix.strerror +import kotlin.system.exitProcess + +private const val DefaultHost = "127.0.0.1" +private const val DefaultPort = 8080 +private const val MaxScanDepth = 5 + +fun runGitWeb2(args: Array<String>) { + val config = parseArgs(args) ?: return + GitWebServer(config.root, config.host, config.port).start() +} + +private data class Config(val root: String, val host: String, val port: Int) + +private data class RepoInfo(val key: String, val name: String, val path: String) + +private data class Response(val status: Int, val body: String) + +private class GitWebServer( + private val root: String, + private val host: String, + private val port: Int, +) { + private val repos: List<RepoInfo> = discoverRepos(root) + private val reposByKey: Map<String, RepoInfo> = repos.associateBy { it.key } + + fun start() { + val base = event_base_new() ?: error("event_base_new failed") + val http = evhttp_new(base) ?: error("evhttp_new failed") + val self = StableRef.create(this) + try { + evhttp_set_gencb(http, staticCFunction(::handleRequest), self.asCPointer()) + if (evhttp_bind_socket(http, host, port.toUShort()) != 0) { + error("failed to bind http server to $host:$port: ${posixError()}") + } + println("gitweb2 serving ${repos.size} repositories from $root at http://$host:$port/") + event_base_dispatch(base) + } finally { + self.dispose() + evhttp_free(http) + event_base_free(base) + Git.shutdown() + } + } + + fun route(rawUri: String): Response { + val rawPath = rawUri.substringBefore('?') + val parts = rawPath.split('/').filter { it.isNotEmpty() } + return try { + when { + parts.isEmpty() -> index() + parts.size >= 2 && parts[0] == "repo" -> repo(parts.drop(1)) + else -> notFound("No route for ${html(rawPath)}") + } + } catch (error: GitException) { + Response(404, page("Not found", "<p class=\"notice\">${html(error.message ?: "Git error")}</p>")) + } catch (error: Throwable) { + Response(500, page("Server error", "<p class=\"notice\">${html(error.message ?: "Unexpected error")}</p>")) + } + } + + private fun index(): Response { + val body = buildString { + append("<section class=\"hero\"><p class=\"eyebrow\">${html(root)}</p><h1>Repositories</h1></section>") + if (repos.isEmpty()) { + append("<p class=\"notice\">No Git repositories were found under this path.</p>") + return@buildString + } + append("<ol class=\"repo-list\">") + for (repo in repos) { + val summary = repoSummary(repo) + append("<li><a href=\"${repoUrl(repo, summary.branch)}\"><strong>${html(repo.name)}</strong>") + append("<span>${html(summary.branch)}") + if (summary.shortId.isNotEmpty()) append(" · ${html(summary.shortId)}") + append("</span><small>${html(summary.summary)}</small></a></li>") + } + append("</ol>") + } + return Response(200, page("Repositories", body)) + } + + private fun repoSummary(repo: RepoInfo): RepoSummary = + runCatching { + openRepo(repo) { git -> + val branches = git.localBranches() + val head = branches.firstOrNull { it.isHead }?.name ?: branches.firstOrNull()?.name ?: "main" + val commit = runCatching { git.branchCommit(head) }.getOrNull() + RepoSummary(head, commit?.summary ?: "No commits", commit?.id?.value?.take(12) ?: "") + } + }.getOrElse { + RepoSummary("main", "Unavailable", "") + } + + private fun repo(parts: List<String>): Response { + val repoKey = urlDecode(parts[0]) + val repo = reposByKey[repoKey] ?: return notFound("Unknown repository ${html(repoKey)}") + if (parts.size == 1) { + val branch = openRepo(repo) { git -> + val branches = git.localBranches() + branches.firstOrNull { it.isHead }?.name ?: branches.firstOrNull()?.name ?: "main" + } + return Response(200, repoPage(repo, branch, "")) + } + val branch = urlDecode(parts[1]) + val pathParts = parts.drop(2) + if (pathParts.size == 2 && pathParts[0] == "-" && pathParts[1] == "commits") { + return Response(200, commitsPage(repo, branch)) + } + val path = pathParts.joinToString("/") { urlDecode(it) } + return Response(200, repoPage(repo, branch, path)) + } + + private fun repoPage(repo: RepoInfo, branch: String, path: String): String = openRepo(repo) { git -> + val branches = git.localBranches() + val commit = git.branchCommit(branch) + val entry = if (path.isBlank()) null else runCatching { git.treeEntry(path, commit) }.getOrNull() + val content = if (entry == null || entry.kind == TreeEntryKind.Tree) { + treeView(repo, git, branch, path, branches) + } else { + blobView(repo, git.blob(path, commit), branch, path, branches) + } + page(repo.name, content) + } + + private fun treeView( + repo: RepoInfo, + git: Repository, + branch: String, + path: String, + branches: List<Branch>, + ): String { + val listing = git.tree(path, git.branchCommit(branch)) + return buildString { + append(repoHeader(repo, branch, path, branches, "code")) + append("<div class=\"panel\"><table><tbody>") + val parent = path.substringBeforeLast('/', "") + if (path.isNotBlank()) { + append("<tr><td><a href=\"${repoUrl(repo, branch, parent)}\">..</a></td><td class=\"kind\">tree</td></tr>") + } + for (entry in listing.entries) { + val child = joinPath(path, entry.name) + append("<tr><td><a href=\"${repoUrl(repo, branch, child)}\">${html(entry.name)}</a></td>") + append("<td class=\"kind\">${entry.kind.name.lowercase()}</td></tr>") + } + append("</tbody></table></div>") + } + } + + private fun blobView( + repo: RepoInfo, + blob: Blob, + branch: String, + path: String, + branches: List<Branch>, + ): String = buildString { + append(repoHeader(repo, branch, path, branches, "code")) + append("<div class=\"panel file\">") + append("<div class=\"file-meta\">${blob.bytes.size} bytes · ${html(blob.id.value.take(12))}</div>") + if (blob.isBinary) { + append("<p class=\"notice\">Binary file</p>") + } else { + append("<pre><code>${html(blob.text())}</code></pre>") + } + append("</div>") + } + + private fun commitsPage(repo: RepoInfo, branch: String): String = openRepo(repo) { git -> + buildString { + append(repoHeader(repo, branch, "", git.localBranches(), "history")) + append("<ol class=\"commits\">") + for (commit in git.commits(branch, 100)) { + append("<li><strong>${html(commit.summary)}</strong>") + append("<span>${html(commit.id.value.take(12))} · ${html(commit.author.name)}</span>") + append("</li>") + } + append("</ol>") + }.let { page("$branch history", it) } + } + + private fun repoHeader( + repo: RepoInfo, + branch: String, + path: String, + branches: List<Branch>, + selected: String, + ): String = buildString { + append("<section class=\"repo-head\"><p><a href=\"/\">Repositories</a> / ${html(repo.name)}</p>") + append("<h1>${html(if (path.isBlank()) repo.name else path)}</h1>") + append("<div class=\"toolbar\"><span>Branch</span><nav>") + for (item in branches) { + val active = if (item.name == branch) " class=\"active\"" else "" + append("<a$active href=\"${repoUrl(repo, item.name, path)}\">${html(item.name)}</a>") + } + append("</nav></div><div class=\"tabs\">") + tab("code", "Code", repoUrl(repo, branch, path), selected) + tab("history", "History", "/repo/${urlEncode(repo.key)}/${urlEncode(branch)}/-/commits", selected) + append("</div></section>") + } + + private fun StringBuilder.tab(id: String, label: String, href: String, selected: String) { + val active = if (id == selected) " class=\"active\"" else "" + append("<a$active href=\"$href\">$label</a>") + } + + private fun <T> openRepo(repo: RepoInfo, block: (Repository) -> T): T = + Git.open(repo.path).use(block) + + private fun notFound(message: String): Response = + Response(404, page("Not found", "<p class=\"notice\">$message</p>")) + + private data class RepoSummary(val branch: String, val summary: String, val shortId: String) +} + +private fun handleRequest(request: CPointer<cnames.structs.evhttp_request>?, context: COpaquePointer?) { + if (request == null || context == null) return + val server = context.asStableRef<GitWebServer>().get() + val uri = evhttp_request_get_uri(request)?.toKString() ?: "/" + send(request, server.route(uri)) +} + +private fun send(request: CPointer<cnames.structs.evhttp_request>, response: Response) { + val bytes = response.body.encodeToByteArray() + val buffer = evbuffer_new() ?: return + try { + bytes.usePinned { pinned -> + evbuffer_add(buffer, pinned.addressOf(0), bytes.size.convert()) + } + val headers = evhttp_request_get_output_headers(request) + evhttp_add_header(headers, "Content-Type", "text/html; charset=utf-8") + evhttp_add_header(headers, "Cache-Control", "no-store") + evhttp_send_reply(request, response.status, statusText(response.status), buffer) + } finally { + evbuffer_free(buffer) + } +} + +private fun page(title: String, body: String): String = """ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>${html(title)} · gitweb2</title> +<style> +:root { color-scheme: light; --ink: #1f2428; --muted: #586069; --line: #d0d7de; --wash: #f6f8fa; --link: #0969da; --accent: #1a7f37; } +* { box-sizing: border-box; } +body { margin: 0; font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: var(--ink); background: #fff; } +a { color: var(--link); text-decoration: none; } +a:hover { text-decoration: underline; } +main { max-width: 1180px; margin: 0 auto; padding: 28px 20px 56px; } +.hero, .repo-head { border-bottom: 1px solid var(--line); margin-bottom: 20px; padding-bottom: 16px; } +.eyebrow, .repo-head p, .file-meta, .kind, small, .commits span, .repo-list span { color: var(--muted); } +h1 { margin: 4px 0 8px; font-size: 28px; line-height: 1.2; overflow-wrap: anywhere; } +.repo-list, .commits { list-style: none; margin: 0; padding: 0; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; } +.repo-list li, .commits li { border-top: 1px solid var(--line); } +.repo-list li:first-child, .commits li:first-child { border-top: 0; } +.repo-list a, .commits li { display: grid; gap: 3px; padding: 14px 16px; } +.repo-list strong, .commits strong { font-size: 16px; overflow-wrap: anywhere; } +.repo-list small, .commits span { display: block; overflow-wrap: anywhere; } +.panel { border: 1px solid var(--line); border-radius: 8px; overflow: hidden; } +table { width: 100%; border-collapse: collapse; } +td { padding: 10px 12px; border-top: 1px solid var(--line); vertical-align: top; overflow-wrap: anywhere; } +tr:first-child td { border-top: 0; } +.kind { width: 120px; text-align: right; } +.toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 12px 0; } +.toolbar nav, .tabs { display: flex; gap: 8px; flex-wrap: wrap; } +.toolbar a, .tabs a { border: 1px solid var(--line); border-radius: 8px; padding: 5px 9px; color: var(--ink); background: #fff; } +.toolbar a.active, .tabs a.active { border-color: var(--accent); color: var(--accent); font-weight: 600; } +.file pre { margin: 0; padding: 16px; overflow: auto; background: var(--wash); } +.file code { font: 13px/1.45 ui-monospace, SFMono-Regular, Consolas, monospace; white-space: pre; } +.file-meta { padding: 10px 12px; border-bottom: 1px solid var(--line); background: #fff; } +.notice { padding: 16px; border: 1px solid var(--line); border-radius: 8px; background: var(--wash); } +@media (max-width: 640px) { + main { padding: 20px 12px 40px; } + h1 { font-size: 23px; } + .kind { width: 78px; } +} +</style> +</head> +<body><main>$body</main></body> +</html> +""".trimIndent() + +private fun discoverRepos(root: String): List<RepoInfo> { + val found = mutableListOf<RepoInfo>() + fun scan(path: String, relative: String, depth: Int) { + if (isGitRepo(path)) { + if (isOpenableGitRepo(path)) { + val key = relative.ifBlank { path.trimEnd('/').substringAfterLast('/') } + found += RepoInfo(key = key, name = key, path = path) + } + return + } + if (depth >= MaxScanDepth) return + val dir = opendir(path) ?: return + try { + while (true) { + val entry = readdir(dir) ?: break + val name = entry.pointed.d_name.toKString() + if (name == "." || name == ".." || name == ".git") continue + val child = joinPath(path, name) + val childRelative = if (relative.isBlank()) name else "$relative/$name" + scan(child, childRelative, depth + 1) + } + } finally { + closedir(dir) + } + } + scan(expandHome(root).trimEnd('/'), "", 0) + return found.sortedBy { it.name } +} + +private fun Repository.localBranches(): List<Branch> = + branches().filter { it.type == BranchType.Local } + +private fun isGitRepo(path: String): Boolean = + access(joinPath(path, ".git"), F_OK) == 0 || + access(joinPath(path, "HEAD"), F_OK) == 0 && + access(joinPath(path, "objects"), F_OK) == 0 && + access(joinPath(path, "refs"), F_OK) == 0 + +private fun isOpenableGitRepo(path: String): Boolean = + runCatching { Git.open(path).use { } }.isSuccess + +private fun repoUrl(repo: RepoInfo, branch: String, path: String = ""): String { + val prefix = "/repo/${urlEncode(repo.key)}/${urlEncode(branch)}" + return if (path.isBlank()) prefix else "$prefix/${path.split('/').joinToString("/") { urlEncode(it) }}" +} + +private fun joinPath(base: String, child: String): String = + when { + base.isBlank() -> child + child.isBlank() -> base + base.endsWith("/") -> base + child + else -> "$base/$child" + } + +private fun html(value: String): String = buildString(value.length) { + for (char in value) { + when (char) { + '&' -> append("&") + '<' -> append("<") + '>' -> append(">") + '"' -> append(""") + '\'' -> append("'") + else -> append(char) + } + } +} + +private fun urlEncode(value: String): String { + val bytes = value.encodeToByteArray() + val hex = "0123456789ABCDEF" + return buildString { + for (byte in bytes) { + val int = byte.toInt() and 0xff + val char = int.toChar() + if (char in 'A'..'Z' || char in 'a'..'z' || char in '0'..'9' || char == '-' || char == '_' || char == '.' || char == '~') { + append(char) + } else { + append('%') + append(hex[int shr 4]) + append(hex[int and 15]) + } + } + } +} + +private fun urlDecode(value: String): String { + val bytes = mutableListOf<Byte>() + var index = 0 + while (index < value.length) { + val char = value[index] + if (char == '%' && index + 2 < value.length) { + val hex = value.substring(index + 1, index + 3).toIntOrNull(16) + if (hex != null) { + bytes += hex.toByte() + index += 3 + continue + } + } + bytes += if (char == '+') ' '.code.toByte() else char.code.toByte() + index++ + } + return bytes.toByteArray().decodeToString() +} + +private fun expandHome(path: String): String { + if (path == "~") return getenv("HOME")?.toKString() ?: path + if (path.startsWith("~/")) return "${getenv("HOME")?.toKString() ?: ""}/${path.drop(2)}" + return path +} + +private fun statusText(status: Int): String = when (status) { + 200 -> "OK" + 404 -> "Not Found" + 500 -> "Server Error" + else -> "OK" +} + +private fun posixError(): String { + val code = errno + val message = strerror(code)?.toKString() + return if (message.isNullOrBlank()) "errno $code" else "$message (errno $code)" +} + +private fun parseArgs(args: Array<String>): Config? { + if (args.isEmpty() || args[0] == "-h" || args[0] == "--help") { + println("usage: gitweb2 <repo-root> [--host 127.0.0.1] [--port 8080]") + return null + } + var host = DefaultHost + var port = DefaultPort + var index = 1 + while (index < args.size) { + when (args[index]) { + "--host" -> host = args.getOrNull(++index) ?: die("missing --host value") + "--port" -> port = args.getOrNull(++index)?.toIntOrNull() ?: die("missing or invalid --port value") + else -> die("unknown argument ${args[index]}") + } + index++ + } + return Config(root = args[0], host = host, port = port) +} + +private fun die(message: String): Nothing { + println(message) + exitProcess(2) +} + +private inline fun <T : AutoCloseable, R> T.use(block: (T) -> R): R { + try { + return block(this) + } finally { + close() + } +}
@@ -0,0 +1,3 @@ +package = gitweb.internal.event +headers = event2/event.h event2/http.h event2/buffer.h event2/keyvalq_struct.h +linkerOpts.linux = -levent