arcnyxx.net.git
stoat.c

/* stoat - git cgi
* Copyright (C) 2024-2025 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 <limits.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 DIE(msg) { fprintf(stderr, msg); \
if (msg[strlen(msg) - 1] != '\n') perror(NULL); exit(1); }
#define LOG(msg, ...) { fprintf(stderr, msg, __VA_ARGS__); \
if (msg[strlen(msg) - 1] != '\n') perror(NULL); return 500; }
#define GIT(msg, ...) LOG(msg ": %s\n", __VA_ARGS__, \
git_error_last()->message)
#define APPFMT(fmt) if (appfmt(str, fmt) == -1) return 500;
#define _APPFMT(fmt, ...) if (appfmt(str, fmt, __VA_ARGS__) == -1) return 500
#define APPSTR(fmt, len) if (appstr(str, fmt, len) == -1) return 500
#define TIME(fmt) char timebuf[256]; { \
struct tm tm; \
strftime(timebuf, sizeof(timebuf), fmt, gmtime_r((time_t *)&time, &tm)); }
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;
typedef enum url {
URL_HOME, URL_REPO, URL_FILE
} url_t;
static const char head[] = {
#embed "head2.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 int
appfmt(str_t *str, const char *fmt, ...)
{
va_list ap;
va_start(ap, fmt);
int add = vsnprintf(NULL, 0, fmt, ap);
va_end(ap);
/* vsnprintf writes an extraneous null byte after what is desired */
if (str->alloc <= str->len + add)
if ((str->str = realloc(str->str,
str->alloc = str->len + add + 1)) == NULL)
return -1;
va_start(ap, fmt);
vsnprintf(str->str + str->len, add + 1, fmt, ap);
va_end(ap);
str->len += add;
return 0;
}
static int
appstr(str_t *str, const char *fmt, size_t len)
{
size_t add = 0;
for (size_t i = 0; i < len; ++i) {
switch (fmt[i]) {
case '&': add += 5; break;
case '>': /* FALLTHROUGH */
case '<': add += 4; break;
default: ++add;
}
}
if (str->alloc < str->len + add)
if ((str->str = realloc(str->str,
str->alloc = str->len + add)) == NULL)
return -1;
for (size_t i = 0; i < len; ++i) {
switch (fmt[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++] = fmt[i];
}
}
return 0;
}
/* 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)
{
char *dot;
return (dot = strrchr(dir->d_name, '.')) != NULL && !strcmp(dot, ".git");
}
static int
homepage(str_t *str, const char *git)
{
int ent;
struct dirent **list;
if ((ent = scandir(git, &list, filter, alphasort)) == -1)
LOG("stoat: unable to scan dir: %s: ", git);
APPFMT("Content-Type: text/html\r\n\r\n");
_APPFMT(head, "my git site", "git dot arcnyxx dot net >;3",
"git dot arcnyxx dot net >;3", "i love backend!!! "
"which means i'm forced to do frontend :⁠(", "");
char file[PATH_MAX];
int baselen = snprintf(file, PATH_MAX, "%s/", git);
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)) == -1)
LOG("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);
TIME("%F");
_APPFMT(homeinfo, list[i]->d_name, desc, timebuf);
free(list[i]);
}
free(list);
APPFMT(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];
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));
TIME("%F");
_APPFMT(repoinfo, reponame, git_tree_entry_name(entry),
modebuf, sizebuf, timebuf);
return 0;
}
static int
repopage(str_t *str, const char *git, 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)) == -1)
LOG("stoat: unable to read file: %s: ", file);
desc[desclen] = '\0';
close(fd);
} else {
snprintf(desc, sizeof(desc), "%s has no description.", reponame);
}
git_time_t time = git_commit_time(commit);
TIME("<p><time>%F</time></p>\n");
APPFMT("Content-Type: text/html\r\n\r\n");
_APPFMT(head, desc, reponame, reponame, desc, timebuf);
APPFMT("<table>\n");
pass_t pass = { repo, reponame, str };
if (git_tree_walk(tree, GIT_TREEWALK_POST, repowalk, &pass))
GIT("stoat: unable to walk tree: %s", reponame);
APPFMT("</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);
APPFMT("<pre><code>");
APPSTR(git_blob_rawcontent(blob), git_blob_rawsize(blob));
APPFMT("</code></pre>\n");
git_blob_free(blob);
}
git_tree_free(tree);
git_commit_free(commit);
git_repository_free(repo);
APPFMT(foot);
return 0;
}
static int
filepage(str_t *str, const char *git, 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;
git_time_t time = moddate(repo, reponame, git_tree_entry_name(entry),
git_tree_entry_id(entry));
TIME("<p><time>%F</time></p>\n");
APPFMT("Content-Type: text/html\r\n\r\n");
_APPFMT(head, blobname, 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)) {
_APPFMT("<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;
_APPFMT("<pre><code%s>", type != '\0' ? class : "");
APPSTR(git_blob_rawcontent(blob), git_blob_rawsize(blob));
APPFMT("</code></pre>\n");
}
git_blob_free(blob);
git_tree_free(tree);
git_commit_free(commit);
git_repository_free(repo);
APPFMT(foot);
return 0;
}
static int
parse(const char *url, char reponame[256], char blobname[256])
{
if (!strcmp(url, "/"))
return URL_HOME;
url_t type;
char *slash;
if ((slash = strchr(url + 1, '/')) == NULL) {
if (strlen(url + 1) > 255)
return 400;
strcpy(reponame, url + 1);
type = URL_REPO;
} 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);
type = URL_FILE;
}
char *dot;
if ((dot = strrchr(reponame, '.')) != NULL && !strcmp(dot, ".git"))
return type;
return 404;
}
int
main(void)
{
const char *dir;
if ((dir = 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: ");
str_t str = { 0 };
while (FCGX_Accept_r(&req) == 0) {
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 URL_HOME:
code = homepage(&str, dir);
break;
case URL_REPO:
code = repopage(&str, dir, reponame);
break;
case URL_FILE:
code = filepage(&str, dir, reponame, blobname);
break;
}
if (code != 0)
FCGX_FPrintF(req.out, "Status: %d\r\n\r\n", code);
else
FCGX_PutStr(str.str, str.len, req.out);
str.len = 0;
}
FCGX_Free(&req, true);
git_libgit2_shutdown();
}