arcnyxx.net.git

stoat.c

espurr
/* stoat - git cgi
 * Copyright (C) 2024 ArcNyxx <me@arcnyxx.net>
 * see LICENCE file for licensing information */

/* until c compilers decide to implement #embed */
#pragma Cedro 1.0 #embed

#include <dirent.h>
#include <errno.h>
#include <fcntl.h>
#include <stdarg.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <time.h>
#include <unistd.h>

#include <git2/blob.h>
#include <git2/errors.h>
#include <git2/global.h>
#include <git2/refs.h>
#include <git2/repository.h>
#include <git2/revwalk.h>
#include <git2/tree.h>
#include <fcgiapp.h>

#define GIT(msg) die(msg ": %s\n", git_error_last()->message)
#define _GIT(msg, ...) die(msg ": %s\n", __VA_ARGS__, git_error_last()->message)

typedef struct str {
	char *str;
	size_t alloc, len;
} str_t;

typedef struct pass {
	git_repository *repo;
	const char *reponame;
	str_t *str;
} pass_t;

static const char head[] = {
#embed "head.html"
	, '\0'
};
static const char foot[] = {
#embed "foot.html"
	, '\0'
};
static const char *homeinfo =
"<article>\n"
"<div>\n"
"<h1><a href='https://git.arcnyxx.net/%1$s'>%1$s</a></h1>\n"
"<span class='copy' data-url='https://git.arcnyxx.net/%1$s' title='copy https://git.arcnyxx.net/%1$s'><img src='https://arcnyxx.net/copy.svg' alt='copy' style='height:1.5rem;width:1.5rem;'></span>\n"
"</div>\n"
"<div>\n"
"<p>%2$s</p>\n"
"<p>(<time>%3$s</time>)</p>\n"
"</div>\n"
"</article>\n";
static const char *repoinfo =
"<tr>\n"
"<td><a href='https://git.arcnyxx.net/%1$s/%2$s'>%2$s</a></td>\n"
"<td><div>\n"
"<div>%3$s</div>\n"
"<div>%4$s</div>\n"
"<div>(<time>%5$s</time>)</div>\n"
"</div></td>\n"
"</tr>\n";

static const char *git;

static void die(const char *fmt, ...);
static void print(str_t *str, const char *fmt, ...);
static void eprint(str_t *str, const char *add, size_t len);
static git_time_t moddate(git_repository *repo, const char *reponame,
		const char *file, const git_oid *oid);

static void
die(const char *fmt, ...)
{
        va_list list;
        va_start(list, fmt);
        vfprintf(stderr, fmt, list);
        va_end(list);

        if (fmt[strlen(fmt) - 1] != '\n')
                perror(NULL);
        exit(1);
}

static void
print(str_t *str, const char *fmt, ...)
{
	va_list ap;
	va_start(ap, fmt);
	int add = vsnprintf(NULL, 0, fmt, ap);
	va_end(ap);

	if (str->alloc <= str->len + add) {
		while ((str->alloc *= 2) <= str->len + add);
		if ((str->str = realloc(str->str, str->alloc)) == NULL)
			die("stoat: unable to allocate memory: ");
	}

	va_start(ap, fmt);
	vsnprintf(str->str + str->len, add + 1, fmt, ap);
	va_end(ap);
	str->len += add;
}

static void
eprint(str_t *str, const char *add, size_t len)
{
	if (str->alloc <= str->len + len) {
		while ((str->alloc *= 2) <= str->len + len);
		if ((str->str = realloc(str->str, str->alloc)) == NULL)
			die("stoat: unable to allocate memory: ");
	}

	for (size_t i = 0; i < len; ++i) {
		switch (add[i]) {
		case '&':
			memcpy(str->str + str->len, "&amp;", 5);
			str->len += 5;
			break;
		case '>':
			memcpy(str->str + str->len, "&gt;", 4);
			str->len += 4;
			break;
		case '<':
			memcpy(str->str + str->len, "&lt;", 4);
			str->len += 4;
			break;
		default:
			str->str[str->len] = add[i];
			++str->len;
		}
		if (str->len == str->alloc)
			if ((str->str = realloc(str->str,
					str->alloc *= 2)) == NULL)
				die("stoat: unable to allocate memory: ");
	}
}

/* walk commit history until different oid */
static git_time_t
moddate(git_repository *repo, const char *reponame, const char *file,
		const git_oid *oid)
{
	git_revwalk *walker;
	if (git_revwalk_new(&walker, repo))
		_GIT("stoat: unable to allocate revwalker: %s", reponame);
	git_revwalk_push_head(walker);

	git_oid iter;
	bool same = true;
	git_time_t time = 0, newtime = 0;
	while (same && git_revwalk_next(&iter, walker) == 0) {
		git_tree *tree;
		git_commit *commit;
		if (git_commit_lookup(&commit, repo, &iter))
			_GIT("stoat: unable to get HEAD commit: %s", reponame);
		if (git_commit_tree(&tree, commit))
			_GIT("stoat: unable to get HEAD tree: %s", reponame);
		const git_tree_entry *entry = git_tree_entry_byname(tree, file);

		same = entry != NULL && !git_oid_cmp(oid,
				git_tree_entry_id(entry));
		time = newtime;
		newtime = git_commit_time(commit);

		git_tree_free(tree);
		git_commit_free(commit);
	}
	git_revwalk_free(walker);

	return time;
}

static int filter(const struct dirent *dir);
static int homepage(str_t *str);

static int
filter(const struct dirent *dir)
{
	char *dot;
	return (dot = strrchr(dir->d_name, '.')) != NULL && !strcmp(dot, ".git");
}

static int
homepage(str_t *str)
{
	int ent;
	struct dirent **list;
	if ((ent = scandir(git, &list, filter, alphasort)) == -1)
		die("stoat: unable to scan dir: %s: ", git);

	char file[PATH_MAX];
	int baselen = snprintf(file, PATH_MAX, "%s/", git);

	print(str, "Content-Type: text/html\r\n\r\n");
	print(str, head, "my git site", "git dot arcnyxx dot net &gt;;3",
			"git dot arcnyxx dot net &gt;;3", "i always "
			"observe coding best practices!!! (not really)", "");

	for (int i = 0; i < ent; ++i) {
		int len = snprintf(file + baselen, PATH_MAX - baselen,
				"%s/description", list[i]->d_name);

		int fd;
		char desc[256];
		ssize_t desclen;
		if ((fd = open(file, O_RDONLY)) == -1)
			continue;
		if ((desclen = read(fd, desc, sizeof(desc))) == -1)
			die("stoat: unable to read file: %s: ", file);
		desc[desclen] = '\0';
		close(fd);

		if (!strncmp(desc, "[ARCHIVED]", 10))
			continue; /* hide archived repositories */
		file[baselen + len - 12] = '\0'; /* git rid of '/description' */

		git_repository *repo;
		git_oid oid;
		git_commit *commit;
		if (git_repository_open_bare(&repo, file))
			_GIT("stoat: unable to open repository: %s", file);
		if (git_reference_name_to_id(&oid, repo, "HEAD"))
			_GIT("stoat: unable to get HEAD: %s", file);
		if (git_commit_lookup(&commit, repo, &oid))
			_GIT("stoat: unable to get HEAD commit: %s", file);
		git_time_t time = git_commit_time(commit);
		git_commit_free(commit);
		git_repository_free(repo);

		char timebuf[256];
		struct tm tm;
		gmtime_r((time_t *)&time, &tm);
		strftime(timebuf, sizeof(timebuf), "%F", &tm);

		print(str, homeinfo, list[i]->d_name, desc, timebuf);
		free(list[i]);
	}
	free(list);

	print(str, foot);
	return 0;
}

static int
repowalk(const char *root, const git_tree_entry *entry, void *payload)
{
	pass_t *pass = (pass_t *)payload;
	git_repository *repo = pass->repo;
	const char *reponame = pass->reponame;
	str_t *str = pass->str;

	if (git_tree_entry_type(entry) != GIT_OBJECT_BLOB)
		return 0;

	char modebuf[10], sizebuf[256], timebuf[256];

	switch (git_tree_entry_filemode(entry)) {
	case GIT_FILEMODE_BLOB:
		strcpy(modebuf, "rw-r--r--");
		break;
	case GIT_FILEMODE_BLOB_EXECUTABLE:
		strcpy(modebuf, "rwxr-xr-x");
		break;
	default:
		strcpy(modebuf, "---------");
	}

	/* measure size in bytes if binary, else lines */
	git_blob *blob;
	if (git_blob_lookup(&blob, repo, git_tree_entry_id(entry)))
		_GIT("stoat: unable to get blob: %s/%s", reponame,
				git_tree_entry_name(entry));
	if (git_blob_is_binary(blob)) {
		snprintf(sizebuf, sizeof(sizebuf), "%luB",
				git_blob_rawsize(blob));
	} else {
		size_t count = 0;
		const char *data = git_blob_rawcontent(blob);
		for (size_t i = 0; i < git_blob_rawsize(blob); ++i)
			if (data[i] == '\n')
				++count;
		snprintf(sizebuf, sizeof(sizebuf), "%luL", count);
	}

	git_time_t time = moddate(repo, reponame, git_tree_entry_name(entry),
			git_tree_entry_id(entry));
	struct tm tm;
	gmtime_r((time_t *)&time, &tm);
	strftime(timebuf, sizeof(timebuf), "%F", &tm);

	print(str, repoinfo, reponame, git_tree_entry_name(entry),
			modebuf, sizebuf, timebuf);
	return 0;
}

static int
repopage(str_t *str, const char *reponame)
{
	char file[PATH_MAX];
	int len = snprintf(file, PATH_MAX, "%s/%s/description", git, reponame);
	file[len - 12] = '\0';

	git_repository *repo;
	git_oid oid;
	git_commit *commit;
	git_tree *tree;
	if (git_repository_open_bare(&repo, file))
		return 404;
	if (git_reference_name_to_id(&oid, repo, "HEAD"))
		_GIT("stoat: unable to get HEAD: %s", file);
	if (git_commit_lookup(&commit, repo, &oid))
		_GIT("stoat: unable to get HEAD commit: %s", file);
	if (git_commit_tree(&tree, commit))
		_GIT("stoat: unable to get HEAD tree: %s", file);

	int fd;
	char desc[256];
	ssize_t desclen;
	file[len - 12] = '/';
	if ((fd = open(file, O_RDONLY)) != -1) {
		if ((desclen = read(fd, desc, sizeof(desc))) == -1)
			die("stoat: unable to read file: ");
		desc[desclen] = '\0';
		close(fd);
	} else {
		snprintf(desc, sizeof(desc), "%s has no description.", reponame);
	}

	git_time_t time = git_commit_time(commit);
	char timebuf[256];
	struct tm tm;
	gmtime_r((time_t *)&time, &tm);
	strftime(timebuf, sizeof(timebuf), "<p><time>%F</time></p>\n", &tm);

	print(str, "Content-Type: text/html\r\n\r\n");
	print(str, head, "my git site", reponame, reponame, desc, timebuf);

	pass_t pass = { repo, reponame, str };
	print(str, "<table>\n");
	if (git_tree_walk(tree, GIT_TREEWALK_POST, repowalk, &pass))
		_GIT("stoat: unable to walk tree: %s", reponame);
	print(str, "</table>\n");

	const git_tree_entry *entry;
	if ((entry = git_tree_entry_byname(tree, "README")) != NULL) {
		git_blob *blob;
		if (git_blob_lookup(&blob, repo, git_tree_entry_id(entry)))
			_GIT("stoat: unable to get blob: %s/README", reponame);
		print(str, "<pre><code>");
		eprint(str, git_blob_rawcontent(blob), git_blob_rawsize(blob));
		print(str, "</code></pre>\n");
		git_blob_free(blob);
	}

	git_tree_free(tree);
	git_commit_free(commit);
	git_repository_free(repo);

	print(str, foot);
	return 0;
}

static int filepage(str_t *str, const char *reponame, const char *blobname);

static int
filepage(str_t *str, const char *reponame, const char *blobname)
{
	char file[PATH_MAX];
	snprintf(file, sizeof(file), "%s/%s", git, reponame);

	git_repository *repo;
	git_oid oid;
	git_commit *commit;
	git_tree *tree;
	if (git_repository_open_bare(&repo, file))
		return 404;
	if (git_reference_name_to_id(&oid, repo, "HEAD"))
		_GIT("stoat: unable to get HEAD: %s", file);
	if (git_commit_lookup(&commit, repo, &oid))
		_GIT("stoat: unable to get HEAD commit: %s", file);
	if (git_commit_tree(&tree, commit))
		_GIT("stoat: unable to get HEAD tree: %s", file);
	const git_tree_entry *entry;
	if ((entry = git_tree_entry_byname(tree, blobname)) == NULL)
		return 404;

	char timebuf[256];
	git_time_t time = moddate(repo, reponame, git_tree_entry_name(entry),
			git_tree_entry_id(entry));
	struct tm tm;
	gmtime_r((time_t *)&time, &tm);
	strftime(timebuf, sizeof(timebuf), "<p><time>%F</time></p>\n", &tm);

	print(str, "Content-Type: text/html\r\n\r\n");
	print(str, head, "my git site", reponame, reponame, blobname, timebuf);

	char *dot, type = '\0';
	if ((dot = strrchr(blobname, '.')) != NULL) {
		if (!strcmp(dot, ".c") || !strcmp(dot, ".h"))
			type = 'c';
	}

	git_blob *blob;
	if (git_blob_lookup(&blob, repo, git_tree_entry_id(entry)))
		_GIT("stoat: unable to get blob: %s/%s", file, blobname);
	if (git_blob_is_binary(blob)) {
		print(str, "<a href='https://git.arcnyxx.net/%1$s/%2$s'>%2$s"
				"</a> is a binary file.", reponame, blobname);
	} else {
		char class[] = " class='\0'";
		if (type != '\0')
			class[8] = type;

		print(str, "<pre><code%s>", type != '\0' ? class : "");
		eprint(str, git_blob_rawcontent(blob), git_blob_rawsize(blob));
		print(str, "</code></pre>\n");
	}

	git_blob_free(blob);
	git_tree_free(tree);
	git_commit_free(commit);
	git_repository_free(repo);

	print(str, foot);
	return 0;
}

static int parse(const char *url, char *reponame, char *blobname);

static int
parse(const char *url, char *reponame, char *blobname)
{
	if (!strcmp(url, "/"))
		return 0;

	int code;
	char *slash;
	if ((slash = strchr(url + 1, '/')) == NULL) {
		if (strlen(url + 1) > 255)
			return 400;
		strcpy(reponame, url + 1);
		code = 1;
	} else {
		int len;
		if ((len = slash - url - 1) > 255)
			return 400;
		memcpy(reponame, url + 1, len);
		reponame[len] = '\0';
		if (strlen(slash + 1) > 255)
			return 400;
		strcpy(blobname, slash + 1);
		code = 2;
	}

	char *dot;
	if ((dot = strrchr(reponame, '.')) != NULL && !strcmp(dot, ".git"))
		return code;
	return 404;
}

int
main(void)
{
	if ((git = getenv("STOATDIR")) == NULL)
		die("stoat: $STOATDIR must be defined\n");

	git_libgit2_init();

	FCGX_Init();
	FCGX_Request req;
	FCGX_InitRequest(&req, FCGX_OpenSocket("/run/stoat", SOMAXCONN), 0);
	if (chmod("/run/stoat", 0666) == -1)
		die("stoat: unable to chmod: /run/stoat: ");

	while (FCGX_Accept_r(&req) == 0) {
		str_t str = { NULL, 4096, 0 };
		if ((str.str = malloc(str.alloc)) == NULL)
			die("stoat: unable to allocate memory: ");

		int code;
		char *url, reponame[256], blobname[256];
		if ((url = FCGX_GetParam("STOATURL", req.envp)) == NULL)
			die("stoat: $STOATURL must be defined\n");
		switch (code = parse(url, reponame, blobname)) {
		default: break;
		case 0:
			code = homepage(&str);
			break;
		case 1:
			code = repopage(&str, reponame);
			break;
		case 2:
			code = filepage(&str, reponame, blobname);
		}

		if (code != 0)
			FCGX_FPrintF(req.out, "Status: %d\r\n\r\n", code);
		else
			FCGX_PutStr(str.str, str.len, req.out);
		free(str.str);
	}

	FCGX_Free(&req, true);

	git_libgit2_shutdown();
}