arcnyxx.net.git
stoat.c
/* 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, "&", 5);
str->len += 5;
break;
case '>':
memcpy(str->str + str->len, ">", 4);
str->len += 4;
break;
case '<':
memcpy(str->str + str->len, "<", 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 >;3",
"git dot arcnyxx dot net >;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();
}