"GOT", but the "O" is a cute, smiling pufferfish. Index | Thread | Search

From:
Stefan Sperling <stsp@stsp.name>
Subject:
gotwebd website support
To:
gameoftrees@openbsd.org
Date:
Sat, 29 Nov 2025 18:46:06 +0100

Download raw body.

Thread
Add a new feature to gotwebd which allows serving static websites
straight out of Git repositories.

In the simplest case, declaring a website in gotwebd.conf works as follows:

server "www.example.com" {
	# Serve the www.git repository as a web site when
	# the browser visits "www.example.com".
	website "/" {
		repository "www"
	}
}

See the docs added to gotwebd.conf.5 for details.

A tricky part of this is requesting routing. We can now have URL paths
which map to repositories and may require authentication, and URL paths
which map to websites which never require authentication (similar to how
static gotwebd assets are not protected by authentication). It took me
some time to get this right. We must have good test coverage for this to
avoid introducing authentication bypass bugs later.

For now, I am adding some basic tests to the gotsysd regression test suite.
The tests should be extended later to cover more cases. Adding more tests
will be much easier gone gotsysd has learned to reconfigure gotwebd on the fly.
Until gotsysd can reconfigure gotwebd, test cases which require a unique
gotwebd.conf file need to be start in separate scripts.

ok?


M  gotwebd/auth.c                             |   41+   8-
M  gotwebd/config.c                           |   65+   0-
M  gotwebd/fcgi.c                             |    2+   0-
M  gotwebd/gotweb.c                           |  486+  43-
M  gotwebd/gotwebd.c                          |   13+   0-
M  gotwebd/gotwebd.conf.5                     |  183+   1-
M  gotwebd/gotwebd.h                          |   21+   1-
M  gotwebd/login.c                            |    2+   0-
M  gotwebd/pages.tmpl                         |    2+   1-
M  gotwebd/parse.y                            |  244+   0-
M  gotwebd/sockets.c                          |    2+   0-
M  regress/gotsysd/.gitignore                 |    1+   0-
M  regress/gotsysd/Makefile                   |  115+   6-
A  regress/gotsysd/test_gotwebd_repos_www.sh  |  203+   0-
A  regress/gotsysd/test_gotwebd_www.sh        |  172+   0-

15 files changed, 1552 insertions(+), 60 deletions(-)

commit - 72d0793cf749207e9b2a9056b0cc856f16fc27a7
commit + 9362ba094edef407488a2143f35d32cdd7249392
blob - cd3697aa4826e66a9c32ff9bb1685f03c0d92a63
blob + 9282838c15b7ace89c1eb4581c04faef543d6c00
--- gotwebd/auth.c
+++ gotwebd/auth.c
@@ -67,6 +67,7 @@ auth_shutdown(void)
 
 		config_free_access_rules(&srv->access_rules);
 		config_free_repos(&srv->repos);
+		config_free_websites(&srv->websites);
 		free(srv);
 	}
 
@@ -237,7 +238,9 @@ render_error(struct request *c, const struct got_error
 
 	c->t->error = error;
 
-	if (error->code == GOT_ERR_LOGIN_FAILED)
+	if (error->code == GOT_ERR_NOT_FOUND)
+		status = 404;
+	else if (error->code == GOT_ERR_LOGIN_FAILED)
 		status = 401;
 	else
 		status = 400;
@@ -441,10 +444,13 @@ process_request(struct request *c)
 	struct gotwebd *env = gotwebd_env;
 	uid_t uid;
 	struct server *srv;
+	struct website *site;
 	struct gotwebd_repo *repo = NULL;
 	enum gotwebd_auth_config auth_config;
 	char *hostname = NULL;
 	const char *identifier = NULL;
+	char *request_path = NULL;
+	int is_repository_request = 0;
 
 	srv = gotweb_get_server(c->fcgi_params.server_name);
 	if (srv == NULL) {
@@ -453,24 +459,42 @@ process_request(struct request *c)
 		goto done;
 	}
 
+	error = gotweb_route_request(&is_repository_request, &site,
+	    &request_path, c);
+	if (error)
+		goto done;
+
 	/*
 	 * Static gotwebd assets (images, CSS, ...) are not protected
 	 * by authentication.
 	 */
-	if (got_path_cmp(srv->gotweb_url_root, c->fcgi_params.document_uri,
-	    strlen(srv->gotweb_url_root),
-	    strlen(c->fcgi_params.document_uri)) != 0) {
+	if (is_repository_request && !got_path_is_root_dir(request_path)) {
 		forward_request(c);
+		free(request_path);
 		return;
 	}
 
+	/* Web site content is not protected by authentication either. */
+	if (site) {
+		/* Ignore the querystring while serving web sites. */
+		fcgi_init_querystring(&c->fcgi_params.qs);
+
+		forward_request(c);
+		free(request_path);
+		return;
+	}
+
+	free(request_path);
+	request_path = NULL;
+
 	auth_config = srv->auth_config;
-	if (c->fcgi_params.qs.path[0] != '\0') {
+
+	if (c->fcgi_params.qs.path[0] != '\0')
 		repo = gotweb_get_repository(srv, c->fcgi_params.qs.path);
-		if (repo)
-			auth_config = repo->auth_config;
-	}
 
+	if (repo)
+		auth_config = repo->auth_config;
+
 	switch (auth_config) {
 	case GOTWEBD_AUTH_SECURE:
 	case GOTWEBD_AUTH_INSECURE:
@@ -603,6 +627,7 @@ permitted:
 	}
 done:
 	free(hostname);
+	free(request_path);
 	if (error)
 		render_error(c, error);
 	else
@@ -943,6 +968,14 @@ auth_dispatch_main(int fd, short event, void *arg)
 			srv = TAILQ_LAST(&env->servers, serverlist);
 			config_get_repository(&srv->repos, &imsg);
 			break;
+		case GOTWEBD_IMSG_CFG_WEBSITE:
+			if (TAILQ_EMPTY(&env->servers)) {
+				fatalx("%s: unexpected CFG_WEBSITE msg",
+				    __func__);
+			}
+			srv = TAILQ_LAST(&env->servers, serverlist);
+			config_get_website(&srv->websites, &imsg);
+			break;
 		case GOTWEBD_IMSG_CTL_PIPE:
 			if (env->iev_sockets == NULL)
 				recv_sockets_pipe(env, &imsg);
blob - e8c48e960e86b8d7fe58b0f342eef37fefe5c533
blob + 254bf8dccfe73be47d35b6bd7293c29d8f912e4c
--- gotwebd/config.c
+++ gotwebd/config.c
@@ -17,6 +17,7 @@
 
 #include <sys/types.h>
 #include <sys/queue.h>
+#include <sys/tree.h>
 #include <sys/time.h>
 #include <sys/uio.h>
 #include <sys/socket.h>
@@ -41,6 +42,8 @@
 #include "got_opentemp.h"
 #include "got_reference.h"
 #include "got_object.h"
+#include "got_path.h"
+#include "got_error.h"
 
 #include "gotwebd.h"
 #include "log.h"
@@ -94,6 +97,7 @@ config_getserver(struct gotwebd *env, struct imsg *ims
 	memcpy(srv, p, sizeof(*srv));
 	STAILQ_INIT(&srv->access_rules);
 	TAILQ_INIT(&srv->repos);
+	RB_INIT(&srv->websites);
 
 	/* log server info */
 	log_debug("%s: server=%s", __func__, srv->name);
@@ -331,3 +335,64 @@ config_get_repository(struct gotwebd_repolist *repos, 
 
 	TAILQ_INSERT_TAIL(repos, repo, entry);
 }
+
+void
+config_free_websites(struct got_pathlist_head *websites)
+{
+	got_pathlist_free(websites, GOT_PATHLIST_FREE_DATA);
+}
+
+void
+config_set_website(struct imsgev *iev, struct website *website)
+{
+	if (imsg_compose_event(iev,
+	    GOTWEBD_IMSG_CFG_WEBSITE, 0, -1, -1,
+	    website, sizeof(*website)) == -1)
+		fatal("imsg_compose_event GOTWEBD_IMSG_CFG_WEBSITE");
+}
+
+void
+config_get_website(struct got_pathlist_head *websites, struct imsg *imsg)
+{
+	const struct got_error *error;
+	struct website *site;
+	struct got_pathlist_entry *new;
+	size_t len;
+
+	site = calloc(1, sizeof(*site));
+	if (site == NULL)
+		fatal("malloc");
+
+	if (imsg_get_data(imsg, site, sizeof(*site)))
+		fatalx("%s: invalid CFG_WEBSITE message", __func__);
+	
+	len = strnlen(site->repo_name, sizeof(site->repo_name));
+	if (len == 0 || len >= sizeof(site->repo_name))
+		fatalx("%s: invalid CFG_WEBSITE message", __func__);
+
+	if (strchr(site->repo_name, '/') != NULL) {
+		fatalx("repository names must not contain slashes: %s",
+		    site->repo_name);
+	}
+
+	if (strchr(site->repo_name, '\n') != NULL) {
+		fatalx("repository names must not contain linefeeds: %s",
+		    site->repo_name);
+	}
+
+	if (strchr(site->url_path, '\n') != NULL) {
+		fatalx("URL paths must not contain linefeeds: %s",
+		    site->url_path);
+	}
+
+	if (!got_path_is_absolute(site->url_path)) {
+		fatalx("URL paths must be absolute paths: %s",
+		    site->url_path);
+	}
+
+	error = got_pathlist_insert(&new, websites, site->url_path, site);
+	if (error)
+		fatalx("%s: %s", __func__, error->msg);
+	if (new == NULL)
+		fatalx("%s: duplicate web site '%s'", __func__, site->url_path);
+}
blob - 72d224172bff2531ebcbbdeb3c28089e8392c9d7
blob + ff4e53e93502e25453a36bd7a656041afa2649af
--- gotwebd/fcgi.c
+++ gotwebd/fcgi.c
@@ -19,6 +19,7 @@
 
 #include <arpa/inet.h>
 #include <sys/queue.h>
+#include <sys/tree.h>
 #include <sys/socket.h>
 #include <sys/types.h>
 #include <sys/uio.h>
@@ -40,6 +41,7 @@
 #include "got_error.h"
 #include "got_reference.h"
 #include "got_object.h"
+#include "got_path.h"
 
 #include "got_lib_poll.h"
 
blob - ab011444f5fd3d10a47a59ee42390f24281009f2
blob + 6cb8c9a15bd4034ae7586a166448313c03891714
--- gotwebd/gotweb.c
+++ gotwebd/gotweb.c
@@ -56,7 +56,7 @@
 
 static int gotweb_render_index(struct template *);
 static const struct got_error *gotweb_load_got_path(struct repo_dir **,
-    const char *, struct request *);
+    const char *, struct request *, int);
 static const struct got_error *gotweb_load_file(char **, const char *,
     const char *, int);
 static const struct got_error *gotweb_get_repo_description(char **,
@@ -260,40 +260,17 @@ gotweb_log_request(struct request *c)
 }
 
 const struct got_error *
-gotweb_serve_htdocs(struct request *c)
+gotweb_serve_htdocs(struct request *c, const char *request_path)
 {
 	const struct got_error *error = NULL;
 	struct server *srv = c->srv;;
-	struct gotwebd_fcgi_params *p = &c->fcgi_params;
-	char *document_uri = p->document_uri;
-	char *request_path = NULL;
-	char *child_path = NULL;
 	char *ondisk_path = NULL;
 	int fd = -1;
 	const char *mime_type = "application/octet-stream";
 	char *ext;
 
-	while (document_uri[0] == '/')
-		document_uri++;
-	if (asprintf(&request_path, "/%s", document_uri) == -1)
-		return got_error_from_errno("asprintf");
-
-	if (!got_path_is_child(request_path,
-	    srv->gotweb_url_root, strlen(srv->gotweb_url_root))) {
-		error = got_error(GOT_ERR_NOT_FOUND);
-		goto done;
-	}
-
-	error = got_path_skip_common_ancestor(&child_path,
-	    srv->gotweb_url_root, request_path);
-	if (error) {
-		if (error->code == GOT_ERR_BAD_PATH)
-			error = got_error(GOT_ERR_NOT_FOUND);
-		goto done;
-	}
-
 	if (asprintf(&ondisk_path, "%s/%s/%s", gotwebd_env->httpd_chroot,
-	    srv->htdocs_path, child_path) == -1) {
+	    srv->htdocs_path, request_path) == -1) {
 		error = got_error_from_errno("asprintf");
 		goto done;
 	}
@@ -362,18 +339,411 @@ gotweb_serve_htdocs(struct request *c)
 done:
 	if (fd != -1 && close(fd) == -1 && error == NULL)
 		error = got_error_from_errno("close");
-	free(request_path);
-	free(child_path);
 	free(ondisk_path);
 	return error;
 }
 
+static const struct got_error *
+serve_blob(int *response_code, struct request *c, struct got_repository *repo,
+    struct got_object_id *obj_id, const char *basename)
+{
+	const struct got_error *error = NULL;
+	struct got_blob_object *blob = NULL;
+	int binary;
+	const char *mime_type = "application/octet-stream";
+
+	c->t->fd = dup(c->priv_fd[BLOB_FD_1]);
+	if (c->t->fd == -1) {
+		error = got_error_from_errno("dup");
+		goto done;
+	}
+
+	error = got_object_open_as_blob(&blob, repo, obj_id, BUF, c->t->fd);
+	if (error)
+		goto done;
+
+	error = got_object_blob_is_binary(&binary, blob);
+	if (error)
+		goto done;
+
+	if (binary) {
+		if (gotweb_reply_file(c, "application/octet-stream",
+		    basename, NULL) == -1) {
+			error = got_error(GOT_ERR_IO);
+			*response_code = 500;
+			goto done;
+		}
+	} else {
+		char *ext;
+
+		/* TODO: Port generic mime-type handling from httpd. */
+		ext = strrchr(basename, '.');
+		if (ext) {
+			if (strcmp(ext, ".css") == 0)
+				mime_type = "text/css";
+			if (strcmp(ext, ".html") == 0)
+				mime_type = "text/html";
+			if (strcmp(ext, ".ico") == 0)
+				mime_type = "image/x-icon";
+			if (strcmp(ext, ".png") == 0)
+				mime_type = "image/png";
+			if (strcmp(ext, ".svg") == 0)
+				mime_type = "image/svg+xml";
+			if (strcmp(ext, ".txt") == 0)
+				mime_type = "text/plain";
+			if (strcmp(ext, ".webmanifest") == 0)
+				mime_type = "application/manifest+json";
+			if (strcmp(ext, ".xml") == 0)
+				mime_type = "text/xml";
+		}
+
+		if (gotweb_reply(c, 200, mime_type, NULL) == -1) {
+			error = got_error(GOT_ERR_IO);
+			*response_code = 500;
+			goto done;
+		}
+	}
+
+	if (template_flush(c->tp) == -1) {
+		error = got_error(GOT_ERR_IO);
+		*response_code = 500;
+		goto done;
+	}
+
+	for (;;) {
+		const uint8_t *buf;
+		size_t len;
+
+		error = got_object_blob_read_block(&len, blob);
+		if (error)
+			goto done;
+		if (len == 0)
+			break;
+
+		buf = got_object_blob_get_read_buf(blob);
+		if (fcgi_write(c, buf, len) == -1) {
+			error = got_error(GOT_ERR_IO);
+			*response_code = 500;
+			goto done;
+		}
+	}
+
+done:
+	if (blob)
+		got_object_blob_close(blob);
+	return error;
+}
+
+static int
+gotweb_serve_website(struct request *c, struct website *site,
+    struct repo_dir *repo_dir, const char *request_path)
+{
+	const struct got_error *error = NULL;
+	struct transport *t = c->t;
+	struct got_repository *repo = t->repo;
+	char *refname = NULL;
+	struct got_reference *ref = NULL;
+	struct got_object_id *id = NULL, *obj_id = NULL;
+	struct got_commit_object *commit = NULL;
+	struct got_tree_object *tree = NULL;
+	char *basename = NULL;
+	int response_code = 404;
+	char *in_repo_child = NULL, *in_repo_path = NULL;
+	int obj_type;
+
+	if (got_path_is_root_dir(request_path)) {
+		in_repo_child = strdup(request_path);
+		if (in_repo_child == NULL) {
+			error = got_error_from_errno("strdup");
+			goto done;
+		}
+	} else {
+		if (got_path_is_root_dir(site->url_path)) {
+			in_repo_child = strdup(request_path);
+			if (in_repo_child == NULL) {
+				error = got_error_from_errno("strdup");
+				goto done;
+			}
+		} else if (got_path_cmp(site->url_path, request_path,
+		    strlen(site->url_path), strlen(request_path)) == 0) {
+			in_repo_child = strdup("/");
+			if (in_repo_child == NULL) {
+				error = got_error_from_errno("strdup");
+				goto done;
+			}
+		} else {
+			error = got_path_skip_common_ancestor(&in_repo_child,
+			   site->url_path, request_path);
+			if (error)
+				goto done;
+		}
+	}
+
+	if (site->path[0] != '\0') {
+		char *s = in_repo_child;
+
+		while (*s == '/')
+			s++;
+
+		if (asprintf(&in_repo_path, "%s/%s", site->path, s) == -1) {
+			error = got_error_from_errno("asprintf");
+			goto done;
+		}
+
+		got_path_strip_trailing_slashes(in_repo_path);
+	} else {
+		in_repo_path = in_repo_child;
+		in_repo_child = NULL;
+	}
+
+	if (site->branch_name[0] != 0) {
+		const char *branch = site->branch_name;
+
+		if (strncmp("refs/", branch, 5) != 0) {
+			branch += 5;
+			if (asprintf(&refname, "refs/heads/%s", branch) == -1) {
+				error = got_error_from_errno("asprintf");
+				goto done;
+			}
+		} else {
+			refname = strdup(branch);
+			if (refname == NULL) {
+				error = got_error_from_errno("strdup");
+				goto done;
+			}
+		}
+
+	} else {
+		refname = strdup(GOT_REF_HEAD);
+		if (refname == NULL) {
+			error = got_error_from_errno("strdup");
+			goto done;
+		}
+	}
+
+	error = got_ref_open(&ref, repo, refname, 0);
+	if (error)
+		goto done;
+
+	error = got_ref_resolve(&id, repo, ref);
+	if (error)
+		goto done;
+
+	error = got_object_open_as_commit(&commit, repo, id);
+	if (error)
+		goto done;
+
+	error = got_object_id_by_path(&obj_id, repo, commit, in_repo_path);
+	if (error)
+		goto done;
+
+	error = got_object_get_type(&obj_type, repo, obj_id);
+	if (error)
+		goto done;
+
+	switch (obj_type) {
+	case GOT_OBJ_TYPE_BLOB:
+		error = got_path_basename(&basename, in_repo_path);
+		if (error)
+			goto done;
+		error = serve_blob(&response_code, c, repo, obj_id,
+		    basename);
+		if (error)
+			goto done;
+		break;
+	case GOT_OBJ_TYPE_TREE: {
+		struct got_tree_entry *te;
+		int nentries, i;
+		const char *name;
+		mode_t mode;
+
+		error = got_object_open_as_tree(&tree, repo, obj_id);
+		if (error)
+			goto done;
+		nentries = got_object_tree_get_nentries(tree);
+
+		for (i = 0; i < nentries; i++) {
+			struct gotweb_url url = {
+				.index_page = -1,
+				.action = -1,
+			};
+
+			te = got_object_tree_get_entry(tree, i);
+
+			name = got_tree_entry_get_name(te);
+			mode = got_tree_entry_get_mode(te);
+			if (!S_ISREG(mode) ||
+			    strcasecmp(name, "index.html") != 0)
+				continue;
+
+			/* XXX gotweb_reply uses request struct field */
+			if (strlcat(c->fcgi_params.document_uri, "/index.html",
+			    sizeof(c->fcgi_params.document_uri)) >=
+			    sizeof(c->fcgi_params.document_uri)) {
+				error = got_error(GOT_ERR_NO_SPACE);
+				goto done;
+			}
+
+			if (gotweb_reply(c, 302, NULL, &url) == -1) {
+				error = got_error(GOT_ERR_IO);
+				goto done;
+			}
+			break;
+		}
+		break;
+	}
+	default:
+		error = got_error(GOT_ERR_NOT_FOUND);
+		goto done;
+	}
+done:
+	free(in_repo_child);
+	free(in_repo_path);
+	free(refname);
+	free(basename);
+	free(id);
+	free(obj_id);
+	if (ref)
+		got_ref_close(ref);
+	if (commit)
+		got_object_commit_close(commit);
+	if (tree)
+		got_object_tree_close(tree);
+
+	if (error) {
+		char *safe_path = NULL;
+
+		if (stravis(&safe_path, request_path, VIS_SAFE) == -1) {
+			log_warn("stravis");
+			safe_path = NULL;
+		}
+		log_warnx("%s: %s: %d: %s", __func__,
+		    safe_path ? safe_path : "?", response_code, error->msg);
+		free(safe_path);
+
+		if (response_code == 404)
+			c->t->error = got_error(GOT_ERR_NOT_FOUND);
+		else
+			c->t->error = error;
+
+		if (gotweb_reply(c, response_code, "text/html", NULL) == -1)
+			return -1;
+		return gotweb_render_page(c->tp, gotweb_render_error);
+	}
+
+	return 0;
+}
+
+const struct got_error *
+gotweb_route_request(int *is_repository_request, struct website **site,
+	char **request_path, struct request *c)
+{
+	const struct got_error *error = NULL;
+	struct gotwebd_fcgi_params *p = &c->fcgi_params;
+	struct server *srv = c->srv;;
+	char *child_path = NULL;
+
+	*is_repository_request = 0;
+	*site = NULL;
+	*request_path = NULL;
+
+	if (got_path_cmp(srv->full_repos_url_path, p->document_uri,
+	    strlen(srv->full_repos_url_path),
+	    strlen(p->document_uri)) == 0) {
+		/* 
+		 * Requesting / in repository url path space.
+		 * We will be rendering Git repository data.
+		 */
+		*request_path = strdup("/");
+		if (*request_path == NULL) {
+			error = got_error_from_errno("strdup");
+			goto done;
+		}
+		*is_repository_request = 1;
+	} else if (got_path_is_child(p->document_uri,
+	    srv->full_repos_url_path, strlen(srv->full_repos_url_path))) {
+		/*
+		 * Requesting something within repository url path space.
+		 * We will be returning a static asset for a repository page.
+		 */
+		error = got_path_skip_common_ancestor(&child_path,
+		    srv->full_repos_url_path, p->document_uri);
+		if (error)
+			goto done;
+		*is_repository_request = 1;
+
+		if (asprintf(request_path, "/%s", child_path) == -1) {
+			error = got_error_from_errno("asprintf");
+			goto done;
+		}
+	} else if (got_path_is_child(p->document_uri,
+	    srv->gotweb_url_root, strlen(srv->gotweb_url_root))) {
+		/*
+		 * Requesting something outside repository url path space.
+		 * This will result in a 404 error unless the request is
+		 * matched by a website.
+		 */
+		if (got_path_is_root_dir(p->document_uri)) {
+			*request_path = strdup("/");
+			if (*request_path == NULL) {
+				error = got_error_from_errno("strdup");
+				goto done;
+			}
+		} else {
+			error = got_path_skip_common_ancestor(&child_path,
+			    srv->gotweb_url_root, p->document_uri);
+			if (error)
+				goto done;
+			if (asprintf(request_path, "/%s", child_path) == -1) {
+				error = got_error_from_errno("asprintf");
+				goto done;
+			}
+		}
+	} else {
+		/*
+		 * Requesting something outside gotweb url path space.
+		 * This will result in a 404 error.
+		 */
+		*request_path = strdup(p->document_uri);
+		if (*request_path == NULL) {
+			error = got_error_from_errno("strdup");
+			goto done;
+		}
+	}
+
+	if (!got_path_is_root_dir(*request_path))
+		got_path_strip_trailing_slashes(*request_path);
+
+	*site = gotweb_get_website(srv, *request_path);
+
+	/*
+	 * If the target of the request is ambiguous, the longer path wins.
+	 */
+	if (*is_repository_request && *site) {
+		if (got_path_cmp(srv->repos_url_path, (*site)->url_path,
+		    strlen(srv->repos_url_path),
+		    strlen((*site)->url_path)) <= 0)
+			*is_repository_request = 0;
+		else
+			*site = NULL;
+	}
+done:
+	free(child_path);
+	if (error) {
+		free(*request_path);
+		*request_path = NULL;
+		*site = NULL;
+	}
+
+	return error;
+}
+
 int
 gotweb_process_request(struct request *c)
 {
 	const struct got_error *error = NULL;
 	struct server *srv = c->srv;;
-	struct gotwebd_fcgi_params *p = &c->fcgi_params;
+	struct website *site;
 	struct querystring *qs = NULL;
 	struct repo_dir *repo_dir = NULL;
 	struct repo_commit *commit;
@@ -381,6 +751,8 @@ gotweb_process_request(struct request *c)
 	const uint8_t *buf;
 	size_t len;
 	int r, binary = 0;
+	char *request_path = NULL;
+	int is_repository_request = 0;
 
 	/* querystring */
 	qs = &c->fcgi_params.qs;
@@ -388,15 +760,34 @@ gotweb_process_request(struct request *c)
 
 	gotweb_log_request(c);
 
-	if (got_path_cmp(srv->gotweb_url_root, p->document_uri,
-	    strlen(srv->gotweb_url_root), strlen(p->document_uri)) != 0) {
-		error = gotweb_serve_htdocs(c);
+	error = gotweb_route_request(&is_repository_request, &site,
+	    &request_path, c);
+	if (error)
+		goto err;
+
+	if (is_repository_request && !got_path_is_root_dir(request_path)) {
+		error = gotweb_serve_htdocs(c, request_path);
 		if (error)
 			goto err;
 
+		free(request_path);
 		return 0;
 	}
 
+	if (site) {
+		error = gotweb_load_got_path(&repo_dir, site->repo_name, c, -1);
+		c->t->repo_dir = repo_dir;
+		if (error)
+			goto err;
+
+		r = gotweb_serve_website(c, site, repo_dir, request_path);
+		free(request_path);
+		return r;
+	}
+
+	free(request_path);
+	request_path = NULL;
+
 	/*
 	 * certain actions require a commit id in the querystring. this stops
 	 * bad actors from exploiting this by manually manipulating the
@@ -418,7 +809,7 @@ gotweb_process_request(struct request *c)
 			goto err;
 		}
 
-		error = gotweb_load_got_path(&repo_dir, qs->path, c);
+		error = gotweb_load_got_path(&repo_dir, qs->path, c, 0);
 		c->t->repo_dir = repo_dir;
 		if (error)
 			goto err;
@@ -627,6 +1018,7 @@ gotweb_process_request(struct request *c)
 	}
 
 err:
+	free(request_path);
 	c->t->error = error;
 	if (error->code == GOT_ERR_NOT_FOUND) {
 		if (gotweb_reply(c, 404, "text/html", NULL) == -1)
@@ -654,6 +1046,36 @@ gotweb_get_server(const char *server_name)
 	return TAILQ_FIRST(&gotwebd_env->servers);
 };
 
+struct website *
+gotweb_get_website(struct server *srv, const char *url_path)
+{
+	struct got_pathlist_entry *pe;
+	size_t url_path_len = strlen(url_path);
+	struct website *site = NULL;
+
+	if (RB_EMPTY(&srv->websites))
+		return NULL;
+
+	/*
+	 * Parent paths are sorted before children so we can match the
+	 * provided URL path against the most specific web site URL path
+	 * by walking the list backwards.
+	 */
+	pe = RB_MAX(got_pathlist_head, &srv->websites);
+	while (pe) {
+		if (got_path_cmp(url_path, pe->path,
+		    url_path_len, pe->path_len) == 0 ||
+		    got_path_is_child(url_path, pe->path, pe->path_len)) {
+			site = pe->data;
+			break;
+		}
+
+		pe = RB_PREV(got_pathlist_head, &srv->websites, pe);
+	}
+
+	return site;
+}
+
 const struct got_error *
 gotweb_init_transport(struct transport **t)
 {
@@ -845,7 +1267,7 @@ gotweb_render_index(struct template *tp)
 		}
 
 		error = gotweb_load_got_path(&repo_dir, sd_dent[d_i]->d_name,
-		    c);
+		    c, 0);
 		if (error) {
 			if (error->code != GOT_ERR_NOT_GIT_REPO)
 				log_warnx("%s: %s: %s", __func__,
@@ -1143,7 +1565,7 @@ auth_check(struct request *c, struct gotwebd_access_ru
 
 static const struct got_error *
 gotweb_load_got_path(struct repo_dir **rp, const char *dir,
-    struct request *c)
+    struct request *c, int requesting_website)
 {
 	const struct got_error *error = NULL;
 	struct gotwebd *env = gotwebd_env;
@@ -1209,6 +1631,10 @@ gotweb_load_got_path(struct repo_dir **rp, const char 
 			break;
 		case GOTWEBD_AUTH_SECURE:
 		case GOTWEBD_AUTH_INSECURE:
+			if (requesting_website) {
+				access = GOTWEBD_ACCESS_PERMITTED;
+				break;
+			}
 			access = auth_check(c, &repo->access_rules);
 			if (access == GOTWEBD_ACCESS_NO_MATCH)
 				access = auth_check(c, &srv->access_rules);
@@ -1227,6 +1653,10 @@ gotweb_load_got_path(struct repo_dir **rp, const char 
 			break;
 		case GOTWEBD_AUTH_SECURE:
 		case GOTWEBD_AUTH_INSECURE:
+			if (requesting_website) {
+				access = GOTWEBD_ACCESS_PERMITTED;
+				break;
+			}
 			access = auth_check(c, &srv->access_rules);
 			if (access == GOTWEBD_ACCESS_NO_MATCH)
 				access = auth_check(c, &env->access_rules);
@@ -1245,15 +1675,19 @@ gotweb_load_got_path(struct repo_dir **rp, const char 
 		goto err;
 	}
 
-	if (repo_is_hidden) {
-		error = got_error_path(repo_dir->name, GOT_ERR_NOT_GIT_REPO);
-		goto err;
-	}
+	if (!requesting_website) {
+		if (repo_is_hidden) {
+			error = got_error_path(repo_dir->name,
+			    GOT_ERR_NOT_GIT_REPO);
+			goto err;
+		}
 
-	if (srv->respect_exportok &&
-	    faccessat(dirfd(dt), "git-daemon-export-ok", F_OK, 0) == -1) {
-		error = got_error_path(repo_dir->name, GOT_ERR_NOT_GIT_REPO);
-		goto err;
+		if (srv->respect_exportok && faccessat(dirfd(dt),
+		    "git-daemon-export-ok", F_OK, 0) == -1) {
+			error = got_error_path(repo_dir->name,
+			    GOT_ERR_NOT_GIT_REPO);
+			goto err;
+		}
 	}
 
 	error = got_repo_open(&t->repo, repo_dir->path, NULL,
@@ -1421,6 +1855,7 @@ gotweb_shutdown(void)
 
 		config_free_access_rules(&srv->access_rules);
 		config_free_repos(&srv->repos);
+		config_free_websites(&srv->websites);
 		free(srv);
 	}
 
@@ -1682,6 +2117,14 @@ gotweb_dispatch_main(int fd, short event, void *arg)
 			srv = TAILQ_LAST(&env->servers, serverlist);
 			config_get_repository(&srv->repos, &imsg);
 			break;
+		case GOTWEBD_IMSG_CFG_WEBSITE:
+			if (TAILQ_EMPTY(&env->servers)) {
+				fatalx("%s: unexpected CFG_WEBSITE msg",
+				    __func__);
+			}
+			srv = TAILQ_LAST(&env->servers, serverlist);
+			config_get_website(&srv->websites, &imsg);
+			break;
 		case GOTWEBD_IMSG_CFG_FD:
 			config_getfd(env, &imsg);
 			break;
blob - cf2987ed090a63f0c3bfe94f264c4b8c4afa1fe3
blob + 589946d8f305607c70ec6615af063261cd63768b
--- gotwebd/gotwebd.c
+++ gotwebd/gotwebd.c
@@ -17,6 +17,7 @@
 
 #include <sys/param.h>
 #include <sys/queue.h>
+#include <sys/tree.h>
 #include <sys/socket.h>
 #include <sys/wait.h>
 
@@ -44,6 +45,7 @@
 #include "got_opentemp.h"
 #include "got_reference.h"
 #include "got_object.h"
+#include "got_path.h"
 
 #include "gotwebd.h"
 #include "log.h"
@@ -917,6 +919,7 @@ gotwebd_configure(struct gotwebd *env, uid_t uid, gid_
 	struct server *srv;
 	struct socket *sock;
 	struct gotwebd_repo *repo;
+	struct got_pathlist_entry *pe;
 	char secret[32];
 	int i;
 
@@ -955,6 +958,16 @@ gotwebd_configure(struct gotwebd *env, uid_t uid, gid_
 			    &srv->access_rules);
 		}
 
+		/* send web sites */
+		RB_FOREACH(pe, got_pathlist_head, &srv->websites) {
+			struct website *site = pe->data;
+
+			for (i = 0; i < env->prefork; i++) {
+				config_set_website(&env->iev_auth[i], site);
+				config_set_website(&env->iev_gotweb[i], site);
+			}
+		}
+
 		/* send repositories and per-repository access rules */
 		TAILQ_FOREACH(repo, &srv->repos, entry) {
 			for (i = 0; i < env->prefork; i++) {
blob - 1859bfdc440fab4f2e62f6aef97053da48819414
blob + 1a19c85d1b044541bb933e61910161081f1a0883
--- gotwebd/gotwebd.conf.5
+++ gotwebd/gotwebd.conf.5
@@ -1,4 +1,4 @@
-.\"
+\"
 .\" Copyright (c) 2020 Tracey Emery <tracey@traceyemery.net>
 .\"
 .\" Permission to use, copy, modify, and distribute this software for any
@@ -111,6 +111,27 @@ are served directly from
 .Xr httpd 8 .
 .Pp
 This setting can also be configured on a per-server basis.
+.It Ic repos_url_path Ar path
+Sets the URL path under which Git repositories will be displayed by
+.Xr gotwebd 8 .
+The
+.Ar path
+specified here is relative to
+.Ic gotweb_url_root .
+.Pp
+Defaults to
+.Dq / .
+This default should only be changed if a
+.Ic website
+is configured as the landing page instead of the usual index page shown by
+.Xr gotwebd 8 .
+Otherwise, changing
+.Ic repos_url_path
+will result in
+.Dq not found
+errors when browsers attempt to visit the gotweb root URL path.
+.Pp
+This setting can also be configured on a per-server basis.
 .It Ic disable authentication
 Disable authentication, allowing any browser to view any repository
 not hidden via the
@@ -314,6 +335,29 @@ are served directly from
 If not set then the global
 .Ic gotweb_url_root
 setting will be used.
+.It Ic repos_url_path Ar path
+Sets the URL path under which Git repositories will be displayed by
+.Xr gotwebd 8 .
+The
+.Ar path
+specified here is relative to
+.Ic gotweb_url_root .
+.Pp
+Defaults to
+.Dq / .
+This default should only be changed if a
+.Ic website
+is configured as the landing page instead of the usual index page shown by
+.Xr gotwebd 8 .
+Otherwise, changing
+.Ic repos_url_path
+will result in
+.Dq not found
+errors when browsers attempt to visit the gotweb root URL path.
+.Pp
+If not set then the global
+.Ic repos_url_path
+setting will be used.
 .It Ic disable authentication
 Disable authentication for this server, allowing any browser to view any
 repository not hidden via the
@@ -463,6 +507,56 @@ parameter determines whether
 .Xr gotwebd 8
 will display the repository.
 .El
+.It Ic website Ar url-path Brq ...
+Show a web site when the browser visits the given
+.Ar url-path .
+The web site's content is composed of files in a Git repository.
+.Pp
+While the underlying repository is subject to authentication as usual,
+web site content is always public, and cannot be hidden via the
+.Ic hide repository
+or
+.Ic respect_exportok
+directives.
+.Pp
+The available
+.Ic website
+configuration paramters are as follows:
+.Pp
+.Bl -tag -width Ds
+.It Ic repository Ar name
+Serve web site content from the specified Git repository.
+The repository will be looked up within the server's
+.Ar repos_path ,
+where the directory
+.Ar name
+can exist with or without a
+.Dq .git
+suffix.
+.Pp
+The
+.Ic repository
+parameter is mandatory.
+.It Ic branch Ar name
+Look up files to serve as web site content on the specified branch
+in the repository.
+By default, the branch resolved via the repository's HEAD reference is used.
+.Pp
+If the
+.Ar name
+does not begin with
+.Dq refs/heads
+then the
+.Ar name
+is searched in the
+.Dq refs/heads
+reference namespace.
+.It Ic path Ar path
+Look up files to serve as web site content at the specified path
+in the repository.
+Defaults to the root directory, 
+.Dq / .
+.El
 .It Ic respect_exportok Ar on | off
 Set whether to display the repository only if it contains the magic
 .Pa git-daemon-export-ok
@@ -565,6 +659,94 @@ server "localhost" {
 }
 .Ed
 .Pp
+This example illustrates how
+.Xr gotwebd 8
+can serve static web sites out of Git repositories:
+.Bd -literal -offset indent
+# This server displays just one web site, no Git repositories.
+server "www.example.com" {
+	# Do not display any Git repository data.
+	hide repositories on
+
+	# Serve the www.git repository as a web site when
+	# the browser visits "www.example.com".
+	website "/" {
+		repository "www"  # /var/www/got/public/www.git
+
+		# Any child URL paths the browser wants to
+		# visit will be looked up in www.git, on
+		# the branch which the symbolic HEAD
+		# reference points to.
+	}
+}
+
+# This server displays both web sites and Git repositories.
+server "project.example.com" {
+	repos_path	"/var/git"
+
+	# Display Git repositories when the URL path "/gotweb/" is
+	# is visited by the browser.
+	gotweb url_path "/gotweb"
+
+	website "/" {
+		# Display the "www" repository as a web site when
+		# the browser visits "project.example.com".
+		repository "www"  # /var/git/www or /var/git/www.git
+
+		# Any child URL path the browser wants to visit will
+		# be looked up in www.git, except for "gotweb/" which
+		# maps to repos_path, and "docs/" which is used by
+		# other repositories defined below.
+	}
+
+	repository "www.git" {	# /var/git/www or /var/git/www.git
+		# Otherwise, hide data in this repository. It will
+		# not be shown under the "/gotweb" URL path.
+		hide repository on
+	}
+
+	# Display the src.git repository's "docs/html" directory
+	# as found on the main branch as a web site at the
+	# URL "project.example.com/docs/".
+	website "/docs" {
+		repository "src"
+		path "/docs/html"
+		branch "main"  # same as "refs/heads/main"
+
+		# Any child URL paths the browser wants to visit will
+		# be looked up in src.git, except for "gotweb/"
+		# which maps to repos_path, and "docs/api/" which is
+		# used by another web site defined below.
+	}
+
+	repository "src" {	# /var/git/src or /var/git/src.git
+		# Otherwise, hide data in this repository. It will
+		# not be shown under the "/gotweb" URL path.
+		hide repository on
+	}
+
+	# Display the "main" branch of api-docs.git repository
+	# as a web site at the URL
+	# "project.example.com/docs/api/latest/".
+	website "/docs/api/latest" {
+		repository "api-docs.git"
+		branch "main"
+	}
+
+	# Display the "1.x-stable" branch of the api-docs.git
+	# as a web site at the URL
+	# "project.example.com/docs/api/1.x/".
+	website "/docs/api/1.x" {
+		repository "api-docs.git"
+		branch "1.x-stable"
+	}
+
+	# The api-docs repository will also be shown under "/gotweb"
+	# because the hide repositories and hide repository
+	# setings default to "off".
+}
+.Ed
+.Pp
 The following example illustrates the use of directives related to
 authentication:
 .Bd -literal -offset indent
blob - 7e2e0ab861ef74a5a904a144605d36d05a6540ec
blob + 76014c6e0170b7e9ca7cbcaf2d2878d193fe1b42
--- gotwebd/gotwebd.h
+++ gotwebd/gotwebd.h
@@ -61,6 +61,7 @@
 #define MAX_SERVER_NAME		 255
 #define MAX_AUTH_COOKIE		 255
 #define MAX_IDENTIFIER_SIZE	 32
+#define MAX_BRANCH_NAME		 255
 
 #define GOTWEB_GIT_DIR		 ".git"
 
@@ -143,6 +144,7 @@ enum imsg_type {
 	GOTWEBD_IMSG_CFG_FD,
 	GOTWEBD_IMSG_CFG_ACCESS_RULE,
 	GOTWEBD_IMSG_CFG_REPO,
+	GOTWEBD_IMSG_CFG_WEBSITE,
 	GOTWEBD_IMSG_CFG_DONE,
 	GOTWEBD_IMSG_CTL_PIPE,
 	GOTWEBD_IMSG_CTL_START,
@@ -391,6 +393,14 @@ struct gotwebd_repo {
 };
 TAILQ_HEAD(gotwebd_repolist, gotwebd_repo);
 
+struct website {
+	STAILQ_ENTRY(website) entry;
+	char repo_name[NAME_MAX];
+	char url_path[MAX_DOCUMENT_URI];
+	char branch_name[MAX_BRANCH_NAME];
+	char path[PATH_MAX];
+};
+
 struct server {
 	TAILQ_ENTRY(server)	 entry;
 
@@ -399,6 +409,8 @@ struct server {
 	char		 gotweb_url_root[MAX_DOCUMENT_URI];
 
 	char		 repos_path[PATH_MAX];
+	char		 repos_url_path[MAX_DOCUMENT_URI];
+	char		 full_repos_url_path[MAX_DOCUMENT_URI * 2 + 2];
 	char		 site_name[GOTWEBD_MAXNAME];
 	char		 site_owner[GOTWEBD_MAXNAME];
 	char		 site_link[GOTWEBD_MAXTEXT];
@@ -424,6 +436,7 @@ struct server {
 	struct gotwebd_access_rule_list access_rules;
 
 	struct gotwebd_repolist	 repos;
+	struct got_pathlist_head websites;
 };
 TAILQ_HEAD(serverlist, server);
 
@@ -494,6 +507,7 @@ struct gotwebd {
 	char		 httpd_chroot[PATH_MAX];
 	char		 htdocs_path[PATH_MAX];
 	char		 gotweb_url_root[MAX_DOCUMENT_URI];
+	char		 repos_url_path[MAX_DOCUMENT_URI];
 	uid_t		 www_uid;
 
 	char		 login_hint_user[MAX_IDENTIFIER_SIZE];
@@ -575,6 +589,7 @@ void gotwebd_auth(struct gotwebd *, int);
 
 /* gotweb.c */
 struct server *gotweb_get_server(const char *);
+struct website *gotweb_get_website(struct server *, const char *);
 struct gotwebd_repo * gotweb_get_repository(struct server *, const char *);
 int gotweb_reply(struct request *c, int status, const char *ctype,
     struct gotweb_url *);
@@ -588,7 +603,9 @@ int gotweb_render_absolute_url(struct request *, struc
 void gotweb_free_repo_commit(struct repo_commit *);
 void gotweb_free_repo_tag(struct repo_tag *);
 void gotweb_log_request(struct request *);
-const struct got_error *gotweb_serve_htdocs(struct request *);
+const struct got_error *gotweb_serve_htdocs(struct request *, const char *);
+const struct got_error *gotweb_route_request(int *, struct website **,
+    char **, struct request *);
 int gotweb_process_request(struct request *);
 void gotweb_free_transport(struct transport *);
 void gotweb(struct gotwebd *, int);
@@ -658,4 +675,7 @@ void config_free_access_rules(struct gotwebd_access_ru
 void config_set_repository(struct imsgev *, struct gotwebd_repo *);
 void config_get_repository(struct gotwebd_repolist *, struct imsg *);
 void config_free_repos(struct gotwebd_repolist *);
+void config_free_websites(struct got_pathlist_head *);
+void config_set_website(struct imsgev *, struct website *);
+void config_get_website(struct got_pathlist_head *, struct imsg *);
 int config_init(struct gotwebd *);
blob - badfb813912d0706c15044d339362ce437b9bd56
blob + 05a894bc5a6b1fdebdf747acaf7d215b24352cbd
--- gotwebd/login.c
+++ gotwebd/login.c
@@ -16,6 +16,7 @@
  */
 
 #include <sys/queue.h>
+#include <sys/tree.h>
 #include <sys/stat.h>
 
 #include <errno.h>
@@ -38,6 +39,7 @@
 #include "got_error.h"
 #include "got_reference.h"
 #include "got_object.h"
+#include "got_path.h"
 
 #include "gotwebd.h"
 #include "log.h"
blob - 568ef2685b1fd3d82078302d5d5c4eb687a4c6b2
blob + 119c384edf7ec7e2066f6e84b02c2cba0fed4f52
--- gotwebd/pages.tmpl
+++ gotwebd/pages.tmpl
@@ -169,10 +169,11 @@ nextsep(char *s, char **t)
 	struct server		*srv = c->srv;
 	const struct querystring *qs = c->t->qs;
 	struct gotweb_url	 u_path;
-	char			 prefix[MAX_DOCUMENT_URI];
+	char			 prefix[MAX_DOCUMENT_URI * 2];
 	const char		*css = srv->custom_css;
 
 	strlcpy(prefix, srv->gotweb_url_root, sizeof(prefix));
+	strlcat(prefix, srv->repos_url_path, sizeof(prefix));
 	got_path_strip_trailing_slashes(prefix);
 
 	memset(&u_path, 0, sizeof(u_path));
blob - 6c5defc898c84d1d497ce9239c2741a045bea0e0
blob + 0804f9b818380c437ebb197db76175e8c24dbf02
--- gotwebd/parse.y
+++ gotwebd/parse.y
@@ -27,6 +27,7 @@
 #include <sys/socket.h>
 #include <sys/stat.h>
 #include <sys/un.h>
+#include <sys/tree.h>
 
 #include <net/if.h>
 #include <netinet/in.h>
@@ -52,6 +53,8 @@
 
 #include "got_reference.h"
 #include "got_object.h"
+#include "got_path.h"
+#include "got_error.h"
 
 #include "gotwebd.h"
 #include "log.h"
@@ -108,6 +111,9 @@ struct address *get_unix_addr(const char *);
 int		 addr_dup_check(struct addresslist *, struct address *);
 void		 add_addr(struct address *);
 
+static struct website	*new_website;
+static struct website	*conf_new_website(struct server *, const char *);
+
 static struct gotwebd_repo	*new_repo;
 static struct gotwebd_repo	*conf_new_repo(struct server *, const char *);
 static void			 conf_new_access_rule(
@@ -132,6 +138,7 @@ typedef struct {
 %token	SERVER CHROOT CUSTOM_CSS SOCKET HINT HTDOCS GOTWEB_URL_ROOT
 %token	SUMMARY_COMMITS_DISPLAY SUMMARY_TAGS_DISPLAY USER AUTHENTICATION
 %token	ENABLE DISABLE INSECURE REPOSITORY REPOSITORIES PERMIT DENY HIDE
+%token	WEBSITE PATH BRANCH REPOS_URL_PATH
 
 %token	<v.string>	STRING
 %token	<v.number>	NUMBER
@@ -367,6 +374,9 @@ main		: PREFORK NUMBER {
 				YYERROR;
 			}
 
+			if (!got_path_is_root_dir($2))
+				got_path_strip_trailing_slashes($2);
+
 			n = strlcpy(gotwebd->gotweb_url_root, $2,
 			    sizeof(gotwebd->gotweb_url_root));
 			if (n >= sizeof(gotwebd->gotweb_url_root)) {
@@ -384,8 +394,39 @@ main		: PREFORK NUMBER {
 				YYERROR;
 			}
 
+
 			free($2);
 		}
+		| REPOS_URL_PATH STRING {
+			if (*$2 == '\0') {
+				yyerror("repos_url_path can't be an empty"
+				    " string");
+				free($2);
+				YYERROR;
+			}
+
+			if (!got_path_is_root_dir($2))
+				got_path_strip_trailing_slashes($2);
+
+			n = strlcpy(gotwebd->repos_url_path, $2,
+			    sizeof(gotwebd->repos_url_path));
+			if (n >= sizeof(gotwebd->repos_url_path)) {
+				yyerror("repos_url_path too long, exceeds "
+				    "%zd bytes: %s",
+				    sizeof(gotwebd->repos_url_path), $2);
+				free($2);
+				YYERROR;
+			}
+
+			if (gotwebd->repos_url_path[0] != '/') {
+				yyerror("repos_url_path must be an absolute "
+				    "path: bad path %s", $2);
+				free($2);
+				YYERROR;
+			}
+
+			free($2);
+		}
 		;
 
 server		: SERVER STRING {
@@ -615,6 +656,9 @@ serveropts1	: REPOS_PATH STRING {
 				YYERROR;
 			}
 
+			if (!got_path_is_root_dir($2))
+				got_path_strip_trailing_slashes($2);
+
 			n = strlcpy(new_srv->gotweb_url_root, $2,
 			    sizeof(new_srv->gotweb_url_root));
 			if (n >= sizeof(new_srv->gotweb_url_root)) {
@@ -634,13 +678,100 @@ serveropts1	: REPOS_PATH STRING {
 
 			free($2);
 		}
+		| REPOS_URL_PATH STRING {
+			if (*$2 == '\0') {
+				yyerror("repos_url_path can't be an empty"
+				    " string");
+				free($2);
+				YYERROR;
+			}
+
+			if (!got_path_is_root_dir($2))
+				got_path_strip_trailing_slashes($2);
+
+			n = strlcpy(new_srv->repos_url_path, $2,
+			    sizeof(new_srv->repos_url_path));
+			if (n >= sizeof(new_srv->repos_url_path)) {
+				yyerror("repos_url_path too long, exceeds "
+				    "%zd bytes: %s",
+				    sizeof(new_srv->repos_url_path), $2);
+				free($2);
+				YYERROR;
+			}
+
+			if (new_srv->repos_url_path[0] != '/') {
+				yyerror("repos_url_path must be an absolute "
+				    "path: bad path %s", $2);
+				free($2);
+				YYERROR;
+			}
+
+			free($2);
+		}
 		| repository
+		| website
 		;
 
 serveropts2	: serveropts2 serveropts1 nl
 		| serveropts1 optnl
 		;
 
+websiteopts2	: websiteopts2 websiteopts1 nl
+		| websiteopts1 optnl
+
+websiteopts1	: REPOSITORY STRING {
+			n = strlcpy(new_website->repo_name, $2,
+			    sizeof(new_website->repo_name));
+			if (n >= sizeof(new_website->repo_name)) {
+				yyerror("website repository name too long, "
+				    "exceeds %zd bytes",
+				    sizeof(new_website->repo_name) - 1);
+				free($2);
+				YYERROR;
+			}
+			free($2);
+		}
+		| PATH STRING {
+			n = strlcpy(new_website->path, $2,
+			    sizeof(new_website->path));
+			if (n >= sizeof(new_website->path)) {
+				yyerror("website in-repository path too long, "
+				    "exceeds %zd bytes",
+				    sizeof(new_website->path) - 1);
+				free($2);
+				YYERROR;
+			}
+
+			if (new_website->path[0] != '/') {
+				yyerror("a website path must be an absolute "
+				    "path: bad path %s", $2);
+				free($2);
+				YYERROR;
+			}
+
+			free($2);
+		}
+		| BRANCH STRING {
+			n = strlcpy(new_website->branch_name, $2,
+			    sizeof(new_website->branch_name));
+			if (n >= sizeof(new_website->branch_name)) {
+				yyerror("website branch name too long, "
+				    "exceeds %zd bytes",
+				    sizeof(new_website->branch_name) - 1);
+				free($2);
+				YYERROR;
+			}
+			free($2);
+		}
+		;
+
+website		: WEBSITE STRING {
+			new_website = conf_new_website(new_srv, $2);
+			free($2);
+		} '{' optnl websiteopts2 '}' {
+		}
+		;
+
 repository	: REPOSITORY STRING {
 			struct gotwebd_repo *repo;
 
@@ -746,6 +877,7 @@ lookup(char *s)
 	/* This has to be sorted always. */
 	static const struct keywords keywords[] = {
 		{ "authentication",		AUTHENTICATION },
+		{ "branch",			BRANCH },
 		{ "chroot",			CHROOT },
 		{ "custom_css",			CUSTOM_CSS },
 		{ "deny",			DENY },
@@ -763,10 +895,12 @@ lookup(char *s)
 		{ "max_commits_display",	MAX_COMMITS_DISPLAY },
 		{ "max_repos_display",		MAX_REPOS_DISPLAY },
 		{ "on",				ON },
+		{ "path",			PATH },
 		{ "permit",			PERMIT },
 		{ "port",			PORT },
 		{ "prefork",			PREFORK },
 		{ "repos_path",			REPOS_PATH },
+		{ "repos_url_path",		REPOS_URL_PATH },
 		{ "repositories",		REPOSITORIES },
 		{ "repository",			REPOSITORY },
 		{ "respect_exportok",		RESPECT_EXPORTOK },
@@ -783,6 +917,7 @@ lookup(char *s)
 		{ "summary_commits_display",	SUMMARY_COMMITS_DISPLAY },
 		{ "summary_tags_display",	SUMMARY_TAGS_DISPLAY },
 		{ "user",			USER },
+		{ "website",			WEBSITE },
 		{ "www",			WWW },
 	};
 	const struct keywords *p;
@@ -1281,8 +1416,66 @@ parse_config(const char *filename, struct gotwebd *env
 				    sizeof(srv->gotweb_url_root) - 1);
 			}
 		}
+
+		if (srv->repos_url_path[0] == '\0') {
+			if (strlcpy(srv->repos_url_path,
+			    env->repos_url_path,
+			    sizeof(srv->repos_url_path)) >=
+			    sizeof(srv->repos_url_path)) {
+				yyerror("repos_url_path too long, "
+				    "exceeds %zd bytes",
+				    sizeof(srv->repos_url_path) - 1);
+			}
+		}
 	}
 
+	TAILQ_FOREACH(srv, &env->servers, entry) {
+		const char *gotweb_url_root = srv->gotweb_url_root;
+		const char *repos_url_path = srv->repos_url_path;
+		struct got_pathlist_entry *pe;
+		int ret;
+
+		while (gotweb_url_root[0] == '/')
+			gotweb_url_root++;
+
+		while (repos_url_path[0] == '/')
+			repos_url_path++;
+
+		if (gotweb_url_root[0] == '\0' && repos_url_path[0] == '\0') {
+			srv->full_repos_url_path[0] = '/';
+			srv->full_repos_url_path[1] = '\0';
+		} else {
+			ret = snprintf(srv->full_repos_url_path,
+			    sizeof(srv->full_repos_url_path),
+			    "/%s%s%s", gotweb_url_root,
+			    gotweb_url_root[0] ? "/" : "",
+			    repos_url_path);
+			if (ret == -1) {
+				yyerror("snprintf");
+			}
+			if ((size_t)ret >= sizeof(srv->full_repos_url_path)) {
+				yyerror("gotweb_url_root and "
+				"repos_url_path too long, exceed %zd bytes",
+				    sizeof(srv->full_repos_url_path) - 1);
+			}
+		}
+
+		if (!got_path_is_root_dir(srv->full_repos_url_path)) {
+			got_path_strip_trailing_slashes(
+			    srv->full_repos_url_path);
+		}
+
+		RB_FOREACH(pe, got_pathlist_head, &srv->websites) {
+			const char *url_path = pe->path;
+			struct website *site = pe->data;
+
+			if (site->repo_name[0] == '\0') {
+				yyerror("no repository defined for website "
+				    "'%s' on server %s", url_path, srv->name);
+			}
+		}
+	}
+
 	return (0);
 }
 
@@ -1348,6 +1541,7 @@ conf_new_server(const char *name)
 
 	STAILQ_INIT(&srv->access_rules);
 	TAILQ_INIT(&srv->repos);
+	RB_INIT(&srv->websites);
 
 	TAILQ_INSERT_TAIL(&gotwebd->servers, srv, entry);
 
@@ -1545,7 +1739,57 @@ add_addr(struct address *h)
 	free(h);
 }
 
+static struct website *
+conf_new_website(struct server *server, const char *url_path)
+{
+	const struct got_error *error;
+	struct website *site;
+	struct got_pathlist_entry *new;
 
+	if (url_path[0] == '\0') {
+		fatalx("syntax error: empty URL path found in %s",
+		    file->name);
+	}
+
+	if (strchr(url_path, '\n') != NULL)
+		fatalx("URL path must not contain linefeeds: %s", url_path);
+	
+	site = calloc(1, sizeof(*site));
+	if (site == NULL)
+		fatal("calloc");
+
+	if (!got_path_is_absolute(url_path)) {
+		int ret;
+
+		ret = snprintf(site->url_path, sizeof(site->url_path),
+		    "/%s", url_path);
+		if (ret == -1)
+			fatal("snprintf");
+		if ((size_t)ret >= sizeof(site->url_path)) {
+			fatalx("URL path too long (exceeds %zd bytes): %s",
+			    sizeof(site->url_path) - 1, url_path);
+		}
+	} else {
+		if (strlcpy(site->url_path, url_path,
+		    sizeof(site->url_path)) >=
+		    sizeof(site->url_path)) {
+			fatalx("URL path too long (exceeds %zd bytes): %s",
+			    sizeof(site->url_path) - 1, url_path);
+		}
+	}
+
+	error = got_pathlist_insert(&new, &server->websites,
+	    site->url_path, site);
+	if (error)
+		fatalx("%s: %s", __func__, error->msg);
+	if (new == NULL) {
+		fatalx("duplicate web site '%s' in server '%s'",
+		    url_path, server->name);
+	}
+
+	return site;
+}
+
 struct gotwebd_repo *
 gotwebd_new_repo(const char *name)
 {
blob - 12439a75c47c732ae30b0e45a6d5ba8d5efb2cac
blob + f70495430f50e31b79ea07f70a6d4ee6f1447c05
--- gotwebd/sockets.c
+++ gotwebd/sockets.c
@@ -20,6 +20,7 @@
 #include <sys/param.h>
 #include <sys/ioctl.h>
 #include <sys/queue.h>
+#include <sys/tree.h>
 #include <sys/wait.h>
 #include <sys/uio.h>
 #include <sys/resource.h>
@@ -54,6 +55,7 @@
 
 #include "got_reference.h"
 #include "got_object.h"
+#include "got_path.h"
 
 #include "gotwebd.h"
 #include "log.h"
blob - beacbf8ebd70eb677fe66c20e7b50f3d3af5617b
blob + ddcc9391f5551474c92849b0d86201fb1cbc586a
--- regress/gotsysd/.gitignore
+++ regress/gotsysd/.gitignore
@@ -5,6 +5,7 @@ bsd.rd.fs
 got.conf
 gotd.conf
 gotwebd.conf
+gotwebd-www.conf
 gotsys.conf
 gotsysd.conf
 gotsysd_bsd.rd
blob - 70204cccd779939249ca70e2db5c63c629d0676c
blob + 52abf86340197bda89a7e44126522b65004402f8
--- regress/gotsysd/Makefile
+++ regress/gotsysd/Makefile
@@ -1,6 +1,7 @@
 .include "../../got-version.mk"
 
-REGRESS_TARGETS=test_gotsysd test_gotwebd
+REGRESS_TARGETS=test_gotsysd test_gotwebd test_gotwebd_www \
+	test_gotwebd_repos_www
 
 REGRESS_SETUP_ONCE=setup_test_vm
 REGRESS_CLEANUP=stop_test_vm
@@ -10,7 +11,7 @@ CLEANFILES= SHA256.sig bsd.rd bsd.rd.fs bsd.rd.decomp 
 	${GOTSYSD_BSD_RD} ${GOTSYSD_SSH_KEY} ${GOTSYSD_SSH_PUBKEY} \
 	${GOTSYSD_TEST_VM_BASE_IMAGE} ${GOTSYSD_VM_PASSWD_FILE} ${GOTD_CONF} \
 	${GOTSYSD_CONF} ${GOTSYS_CONF} ${GOT_CONF} ${INSTALL_SITE} \
-	${HTTPD_CONF} ${GOTWEBD_CONF}
+	${HTTPD_CONF} ${GOTWEBD_CONF} ${GOTWEBD_WWW_CONF}
 
 .PHONY: ensure_root vm start_test_vm build_got
 
@@ -40,6 +41,8 @@ GOTSYSD_TEST_HTTP_PORT=8000
 GOTSYSD_TEST_HMAC_SECRET!=openssl rand -base64 32
 HTTPD_CONF=httpd.conf
 GOTWEBD_CONF=gotwebd.conf
+GOTWEBD_WWW_CONF=gotwebd-www.conf
+GOTWEBD_REPOS_WWW_CONF=gotwebd-repos-www.conf
 
 GOTSYSD_TEST_USER?=${DOAS_USER}
 .if empty(GOTSYSD_TEST_USER)
@@ -216,9 +219,7 @@ $(HTTPD_CONF):
 	@${UNPRIV} 'echo server "VMIP" { > $@'
 	@${UNPRIV} 'echo \ \ listen on VMIP port http >> $@'
 	@${UNPRIV} 'echo \ \ root \"/htdocs/gotwebd\" >> $@'
-	@${UNPRIV} 'echo \ \ location \"/\" { >> $@'
-	@${UNPRIV} 'echo \ \ \ \ fastcgi socket \"/run/gotweb.sock\" >> $@'
-	@${UNPRIV} 'echo \ \ } >> $@'
+	@${UNPRIV} 'echo \ \ fastcgi socket \"/run/gotweb.sock\" >> $@'
 	@${UNPRIV} 'echo } >> $@'
 
 $(GOTWEBD_CONF):
@@ -257,6 +258,47 @@ $(GOTWEBD_CONF):
 	@${UNPRIV} 'echo \ \ } >> $@'
 	@${UNPRIV} 'echo } >> $@'
 
+
+$(GOTWEBD_WWW_CONF):
+	@${UNPRIV} 'echo prefork 1 > $@'
+	@${UNPRIV} 'echo enable authentication insecure >> $@'
+	@${UNPRIV} 'echo permit ${GOTSYSD_TEST_USER} >> $@'
+	@${UNPRIV} 'echo deny ${GOTSYSD_DEV_USER} >> $@'
+	@${UNPRIV} 'echo server \"VMIP\" { >> $@'
+	@${UNPRIV} 'echo \ \ repos_path \"/git\" >> $@'
+	@${UNPRIV} 'echo \ \ hide repositories on >> $@'
+	@${UNPRIV} 'echo \ \ login hint user anonymous >> $@'
+	@${UNPRIV} 'echo \ \ show_repo_age off >> $@'
+	@${UNPRIV} 'echo \ \ show_repo_description off >> $@'
+	@${UNPRIV} 'echo \ \ show_repo_owner off >> $@'
+	@${UNPRIV} 'echo \ \ show_site_owner off >> $@'
+	@${UNPRIV} 'echo \ \ repos_url_path \"/repos\" >> $@'
+	@${UNPRIV} 'echo \ \ website \"/\" { >> $@'
+	@${UNPRIV} 'echo \ \ \ \ repository \"www\" >> $@'
+	@${UNPRIV} 'echo \ \ } >> $@'
+	@${UNPRIV} 'echo } >> $@'
+
+$(GOTWEBD_REPOS_WWW_CONF):
+	@${UNPRIV} 'echo prefork 1 > $@'
+	@${UNPRIV} 'echo enable authentication insecure >> $@'
+	@${UNPRIV} 'echo permit ${GOTSYSD_TEST_USER} >> $@'
+	@${UNPRIV} 'echo deny ${GOTSYSD_DEV_USER} >> $@'
+	@${UNPRIV} 'echo server \"VMIP\" { >> $@'
+	@${UNPRIV} 'echo \ \ repos_path \"/git\" >> $@'
+	@${UNPRIV} 'echo \ \ repository \"gotsys\" { >> $@'
+	@${UNPRIV} 'echo \ \ \ \ hide repository on >> $@'
+	@${UNPRIV} 'echo \ \ } >> $@'
+	@${UNPRIV} 'echo \ \ login hint user anonymous >> $@'
+	@${UNPRIV} 'echo \ \ show_repo_age off >> $@'
+	@${UNPRIV} 'echo \ \ show_repo_description off >> $@'
+	@${UNPRIV} 'echo \ \ show_repo_owner off >> $@'
+	@${UNPRIV} 'echo \ \ show_site_owner off >> $@'
+	@${UNPRIV} 'echo \ \ repos_url_path \"/\" >> $@'
+	@${UNPRIV} 'echo \ \ website \"/website\" { >> $@'
+	@${UNPRIV} 'echo \ \ \ \ repository \"www\" >> $@'
+	@${UNPRIV} 'echo \ \ } >> $@'
+	@${UNPRIV} 'echo } >> $@'
+
 build_got:
 	@set -e; \
 	VMID=`vmctl status ${GOTSYSD_VM_NAME} | tail -n1 | \
@@ -346,7 +388,8 @@ test_gotsysd: 
 	GWIP="100.64.$$VMID.2"; \
 	${UNPRIV} "env ${GOTSYSD_TEST_ENV} VMIP=$${VMIP} GWIP=$${GWIP} \
 		sh ./test_gotsysd.sh"
-test_gotwebd: 
+
+test_gotwebd:
 	@set -e; \
 	VMID=`vmctl status ${GOTSYSD_VM_NAME} | tail -n1 | \
 		awk '{print $$1}'`; \
@@ -373,4 +416,70 @@ test_gotwebd: 
 	${UNPRIV} "env ${GOTSYSD_TEST_ENV} VMIP=$${VMIP} GWIP=$${GWIP} \
 		sh ./test_gotwebd.sh"
 
+test_gotwebd_www: ${GOTWEBD_WWW_CONF}
+	@set -e; \
+	VMID=`vmctl status ${GOTSYSD_VM_NAME} | tail -n1 | \
+		awk '{print $$1}'`; \
+	VMIP="100.64.$$VMID.3"; \
+	GWIP="100.64.$$VMID.2"; \
+	${UNPRIV} "${GOTSYSD_SSH_CMD} root@$${VMIP} \
+		'rm -rf /git/*.git /tmp/gotsys'"; \
+	${UNPRIV} "${GOTSYSD_SSH_CMD} root@$${VMIP} \
+		got init /git/${GOTSYS_REPO}"; \
+	${UNPRIV} "${GOTSYSD_SCP_CMD} \
+		${GOT_CONF} root@$${VMIP}:/git/${GOTSYS_REPO}/got.conf"; \
+	${UNPRIV} "${GOTSYSD_SSH_CMD} root@$${VMIP} mkdir /tmp/gotsys "; \
+	${UNPRIV} "${GOTSYSD_SCP_CMD} \
+		${GOTSYS_CONF} root@$${VMIP}:/tmp/gotsys/"; \
+	${UNPRIV} "${GOTSYSD_SSH_CMD} root@$${VMIP} \
+		got import -m init -r /git/${GOTSYS_REPO} \
+		/tmp/gotsys >/dev/null"; \
+	${UNPRIV} "${GOTSYSD_SSH_CMD} root@$${VMIP} \
+		chown -R _gotd:_gotd /git"; \
+	${UNPRIV} "${GOTSYSD_SSH_CMD} root@$${VMIP} gotctl reload"; \
+	${UNPRIV} "${GOTSYSD_SSH_CMD} root@$${VMIP} \
+		gotsys apply -w > /dev/null"; \
+	${UNPRIV} "${GOTSYSD_SCP_CMD} \
+		${GOTWEBD_WWW_CONF} root@$${VMIP}:/etc/gotwebd.conf"; \
+	${UNPRIV} "${GOTSYSD_SSH_CMD} root@$${VMIP} \
+		sed -i s/VMIP/$${VMIP}/ /etc/httpd.conf /etc/gotwebd.conf"; \
+	${UNPRIV} "${GOTSYSD_SSH_CMD} root@$${VMIP} pkill -x gotwebd || true"; \
+	${UNPRIV} "${GOTSYSD_SSH_CMD} root@$${VMIP} \
+		/usr/local/sbin/gotwebd -v"; \
+	${UNPRIV} "env ${GOTSYSD_TEST_ENV} VMIP=$${VMIP} GWIP=$${GWIP} \
+		sh ./test_gotwebd_www.sh"
+
+test_gotwebd_repos_www: ${GOTWEBD_REPOS_WWW_CONF}
+	@set -e; \
+	VMID=`vmctl status ${GOTSYSD_VM_NAME} | tail -n1 | \
+		awk '{print $$1}'`; \
+	VMIP="100.64.$$VMID.3"; \
+	GWIP="100.64.$$VMID.2"; \
+	${UNPRIV} "${GOTSYSD_SSH_CMD} root@$${VMIP} \
+		'rm -rf /git/*.git /tmp/gotsys'"; \
+	${UNPRIV} "${GOTSYSD_SSH_CMD} root@$${VMIP} \
+		got init /git/${GOTSYS_REPO}"; \
+	${UNPRIV} "${GOTSYSD_SCP_CMD} \
+		${GOT_CONF} root@$${VMIP}:/git/${GOTSYS_REPO}/got.conf"; \
+	${UNPRIV} "${GOTSYSD_SSH_CMD} root@$${VMIP} mkdir /tmp/gotsys "; \
+	${UNPRIV} "${GOTSYSD_SCP_CMD} \
+		${GOTSYS_CONF} root@$${VMIP}:/tmp/gotsys/"; \
+	${UNPRIV} "${GOTSYSD_SSH_CMD} root@$${VMIP} \
+		got import -m init -r /git/${GOTSYS_REPO} \
+		/tmp/gotsys >/dev/null"; \
+	${UNPRIV} "${GOTSYSD_SSH_CMD} root@$${VMIP} \
+		chown -R _gotd:_gotd /git"; \
+	${UNPRIV} "${GOTSYSD_SSH_CMD} root@$${VMIP} gotctl reload"; \
+	${UNPRIV} "${GOTSYSD_SSH_CMD} root@$${VMIP} \
+		gotsys apply -w > /dev/null"; \
+	${UNPRIV} "${GOTSYSD_SCP_CMD} \
+		${GOTWEBD_REPOS_WWW_CONF} root@$${VMIP}:/etc/gotwebd.conf"; \
+	${UNPRIV} "${GOTSYSD_SSH_CMD} root@$${VMIP} \
+		sed -i s/VMIP/$${VMIP}/ /etc/httpd.conf /etc/gotwebd.conf"; \
+	${UNPRIV} "${GOTSYSD_SSH_CMD} root@$${VMIP} pkill -x gotwebd || true"; \
+	${UNPRIV} "${GOTSYSD_SSH_CMD} root@$${VMIP} \
+		/usr/local/sbin/gotwebd -v"; \
+	${UNPRIV} "env ${GOTSYSD_TEST_ENV} VMIP=$${VMIP} GWIP=$${GWIP} \
+		sh ./test_gotwebd_repos_www.sh"
+
 .include <bsd.regress.mk>
blob - /dev/null
blob + cb2da971e563e78a73126214458997e020722312 (mode 755)
--- /dev/null
+++ regress/gotsysd/test_gotwebd_repos_www.sh
@@ -0,0 +1,203 @@
+#!/bin/sh
+#
+# Copyright (c) 2025 Stefan Sperling <stsp@openbsd.org>
+#
+# Permission to use, copy, modify, and distribute this software for any
+# purpose with or without fee is hereby granted, provided that the above
+# copyright notice and this permission notice appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+. ../cmdline/common.sh
+. ./common.sh
+
+
+test_repos_at_root_path_and_website() {
+	local testroot=`test_init repos_at_root_path_and_website 1`
+
+	got checkout -q $testroot/${GOTSYS_REPO} $testroot/wt >/dev/null
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got checkout failed unexpectedly" >&2
+		test_done "$testroot" 1
+		return 1
+	fi
+
+	crypted_vm_pw=`echo ${GOTSYSD_VM_PASSWORD} | encrypt | tr -d '\n'`
+	crypted_pw=`echo ${GOTSYSD_DEV_PASSWORD} | encrypt | tr -d '\n'`
+	sshkey=`cat ${GOTSYSD_SSH_PUBKEY}`
+	cat > ${testroot}/wt/gotsys.conf <<EOF
+user ${GOTSYSD_TEST_USER} {
+	password "${crypted_vm_pw}" 
+	authorized key ${sshkey}
+}
+user ${GOTSYSD_DEV_USER} {
+	password "${crypted_pw}" 
+	authorized key ${sshkey}
+}
+repository gotsys {
+	permit rw ${GOTSYSD_TEST_USER}
+	permit rw ${GOTSYSD_DEV_USER}
+}
+repository www {
+	permit rw ${GOTSYSD_TEST_USER}
+	permit rw ${GOTSYSD_DEV_USER}
+}
+EOF
+	(cd ${testroot}/wt && got commit  -m "add www.git" >/dev/null)
+	local commit_id=`git_show_head $testroot/${GOTSYS_REPO}`
+
+	got send -q -i ${GOTSYSD_SSH_KEY} -r ${testroot}/${GOTSYS_REPO}
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got send failed unexpectedly" >&2
+		test_done "$testroot" 1
+		return 1
+	fi
+
+	# Wait for gotsysd to apply the new configuration.
+	echo "$commit_id" > $testroot/stdout.expected
+	for i in 1 2 3 4 5; do
+		sleep 1
+		ssh -i ${GOTSYSD_SSH_KEY} root@${VMIP} \
+			cat /var/db/gotsysd/commit > $testroot/stdout
+		if cmp -s $testroot/stdout.expected $testroot/stdout; then
+			break;
+		fi
+	done
+	cmp -s $testroot/stdout.expected $testroot/stdout
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "gotsysd failed to apply configuration" >&2
+		diff -u $testroot/stdout.expected $testroot/stdout
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	got init $testroot/www.git > /dev/null
+	mkdir -p $testroot/www
+
+	cat > $testroot/www/index.html <<EOF
+<!DOCTYPE html>
+<html>
+<head>
+    <meta charset="utf-8">
+    <title>testing gotwebd</title>
+</head>
+<body>
+<h1>testing gotwebd</h1>
+<p>Testing the web site feature of gotwebd.</p>
+</body>
+</html>
+EOF
+	got import -m init -r $testroot/www.git $testroot/www > /dev/null
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got import failed unexpectedly" >&2
+		return 1
+	fi
+
+	cat > $testroot/www.git/got.conf <<EOF
+remote "origin" {
+	server "${GOTSYSD_DEV_USER}@$VMIP"
+	protocol ssh
+	repository "www"
+}
+EOF
+	got send -q -i ${GOTSYSD_SSH_KEY} -b main -r $testroot/www.git
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got send failed unexpectedly" >&2
+		return 1
+	fi
+
+	# Attempt website access.
+	w3m "http://${VMIP}/website" -dump > $testroot/stdout
+	cat > $testroot/stdout.expected <<EOF
+testing gotwebd
+
+Testing the web site feature of gotwebd.
+
+EOF
+	cmp -s $testroot/stdout.expected $testroot/stdout
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		diff -u $testroot/stdout.expected $testroot/stdout
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	# Attempt to access a non-existent location.
+	w3m "http://${VMIP}/nonexistent" -dump > $testroot/stdout
+	cat > $testroot/stdout.expected <<EOF
+[got]
+Repos
+not found
+
+EOF
+	cmp -s $testroot/stdout.expected $testroot/stdout
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		diff -u $testroot/stdout.expected $testroot/stdout
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	# Attempt unauthenticated access to repositories.
+	w3m "http://${VMIP}/" -dump > $testroot/stdout
+	cat > $testroot/stdout.expected <<EOF
+[got]
+Repos
+Log in by running: ssh anonymous@${VMIP} "weblogin ${VMIP}"
+
+EOF
+	cmp -s $testroot/stdout.expected $testroot/stdout
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		diff -u $testroot/stdout.expected $testroot/stdout
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	# Obtain a login token over ssh.
+	ssh -q -i ${GOTSYSD_SSH_KEY} ${GOTSYSD_TEST_USER}@${VMIP} \
+		'gotsh -c weblogin' > $testroot/stdout
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "ssh login failed failed unexpectedly" >&2
+		test_done "$testroot" 1
+		return 1
+	fi
+
+	# Request the index page again using the login token and
+	# storing the cookie sent by gotwebd.
+	url=$(cut -d: -f 2,3 < $testroot/stdout | sed -e 's/ https:/http:/')
+	w3m -cookie-jar "$testroot/cookies" "$url" -dump > $testroot/stdout
+	cat > $testroot/stdout.expected <<EOF
+[got]
+Repos
+Project
+www.git
+summary | briefs | commits | tags | tree | rss
+-------------------------------------------------------------------------------
+
+EOF
+	cmp -s $testroot/stdout.expected $testroot/stdout
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		diff -u $testroot/stdout.expected $testroot/stdout
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	test_done "$testroot" "$ret"
+}
+
+test_parseargs "$@"
+run_test test_repos_at_root_path_and_website
blob - /dev/null
blob + c6da01addd1f761ae9c1cf7fad8d0612b0aa0347 (mode 755)
--- /dev/null
+++ regress/gotsysd/test_gotwebd_www.sh
@@ -0,0 +1,172 @@
+#!/bin/sh
+#
+# Copyright (c) 2025 Stefan Sperling <stsp@openbsd.org>
+#
+# Permission to use, copy, modify, and distribute this software for any
+# purpose with or without fee is hereby granted, provided that the above
+# copyright notice and this permission notice appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+. ../cmdline/common.sh
+. ./common.sh
+
+
+test_website_at_root_path() {
+	local testroot=`test_init website_at_root_path 1`
+
+	got checkout -q $testroot/${GOTSYS_REPO} $testroot/wt >/dev/null
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got checkout failed unexpectedly" >&2
+		test_done "$testroot" 1
+		return 1
+	fi
+
+	crypted_vm_pw=`echo ${GOTSYSD_VM_PASSWORD} | encrypt | tr -d '\n'`
+	crypted_pw=`echo ${GOTSYSD_DEV_PASSWORD} | encrypt | tr -d '\n'`
+	sshkey=`cat ${GOTSYSD_SSH_PUBKEY}`
+	cat > ${testroot}/wt/gotsys.conf <<EOF
+user ${GOTSYSD_TEST_USER} {
+	password "${crypted_vm_pw}" 
+	authorized key ${sshkey}
+}
+user ${GOTSYSD_DEV_USER} {
+	password "${crypted_pw}" 
+	authorized key ${sshkey}
+}
+repository gotsys {
+	permit rw ${GOTSYSD_TEST_USER}
+	permit rw ${GOTSYSD_DEV_USER}
+}
+repository www {
+	permit rw ${GOTSYSD_TEST_USER}
+	permit rw ${GOTSYSD_DEV_USER}
+}
+EOF
+	(cd ${testroot}/wt && got commit  -m "add www.git" >/dev/null)
+	local commit_id=`git_show_head $testroot/${GOTSYS_REPO}`
+
+	got send -q -i ${GOTSYSD_SSH_KEY} -r ${testroot}/${GOTSYS_REPO}
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got send failed unexpectedly" >&2
+		test_done "$testroot" 1
+		return 1
+	fi
+
+	# Wait for gotsysd to apply the new configuration.
+	echo "$commit_id" > $testroot/stdout.expected
+	for i in 1 2 3 4 5; do
+		sleep 1
+		ssh -i ${GOTSYSD_SSH_KEY} root@${VMIP} \
+			cat /var/db/gotsysd/commit > $testroot/stdout
+		if cmp -s $testroot/stdout.expected $testroot/stdout; then
+			break;
+		fi
+	done
+	cmp -s $testroot/stdout.expected $testroot/stdout
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "gotsysd failed to apply configuration" >&2
+		diff -u $testroot/stdout.expected $testroot/stdout
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	got init $testroot/www.git > /dev/null
+	mkdir -p $testroot/www
+
+	cat > $testroot/www/index.html <<EOF
+<!DOCTYPE html>
+<html>
+<head>
+    <meta charset="utf-8">
+    <title>testing gotwebd</title>
+</head>
+<body>
+<h1>testing gotwebd</h1>
+<p>Testing the web site feature of gotwebd.</p>
+</body>
+</html>
+EOF
+	got import -m init -r $testroot/www.git $testroot/www > /dev/null
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got import failed unexpectedly" >&2
+		return 1
+	fi
+
+	cat > $testroot/www.git/got.conf <<EOF
+remote "origin" {
+	server "${GOTSYSD_DEV_USER}@$VMIP"
+	protocol ssh
+	repository "www"
+}
+EOF
+	got send -q -i ${GOTSYSD_SSH_KEY} -b main -r $testroot/www.git
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got send failed unexpectedly" >&2
+		return 1
+	fi
+
+	# Attempt website access.
+	w3m "http://${VMIP}/" -dump > $testroot/stdout
+	cat > $testroot/stdout.expected <<EOF
+testing gotwebd
+
+Testing the web site feature of gotwebd.
+
+EOF
+	cmp -s $testroot/stdout.expected $testroot/stdout
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		diff -u $testroot/stdout.expected $testroot/stdout
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	# Attempt to access a non-existent location.
+	w3m "http://${VMIP}/nonexistent" -dump > $testroot/stdout
+	cat > $testroot/stdout.expected <<EOF
+[got]
+Repos / no action /
+not found
+
+EOF
+	cmp -s $testroot/stdout.expected $testroot/stdout
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		diff -u $testroot/stdout.expected $testroot/stdout
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	# Attempt unauthenticated access to repositories.
+	w3m "http://${VMIP}/repos" -dump > $testroot/stdout
+	cat > $testroot/stdout.expected <<EOF
+[got]
+Repos
+Log in by running: ssh anonymous@${VMIP} "weblogin ${VMIP}"
+
+EOF
+	cmp -s $testroot/stdout.expected $testroot/stdout
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		diff -u $testroot/stdout.expected $testroot/stdout
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	test_done "$testroot" "$ret"
+}
+
+test_parseargs "$@"
+run_test test_website_at_root_path