Lindenii Project Forge
Login

server

Lindenii Forge’s main backend daemon
Commit info
ID
ab4f8ebdcbc13ab7556023a212e067d472d22069
Author
Runxi Yu <me@runxiyu.org>
Author date
Sat, 05 Apr 2025 14:47:52 +0800
Committer
Runxi Yu <me@runxiyu.org>
Committer date
Sat, 05 Apr 2025 16:59:25 +0800
Actions
Remove man pages

They're better documented on the Web
# SPDX-License-Identifier: AGPL-3.0-only
# SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
#
# TODO: This Makefile utilizes a lot of GNU extensions. Some of them are
# unfortunately difficult to avoid as POSIX Make's pattern rules are not
# sufficiently expressive. This needs to be fixed sometime (or we might move to
# some other build system).
#

.PHONY: clean

CFLAGS = -Wall -Wextra -pedantic -std=c99 -D_GNU_SOURCE
MAN_PAGES = lindenii-forge.5 lindenii-forge-hookc.1 lindenii-forge.1 lindenii-forge-mail.5

VERSION = $(shell git describe --tags --always --dirty)
SOURCE_FILES = $(shell git ls-files)

forge: source.tar.gz hookc/hookc git2d/git2d $(MAN_PAGES:%=man/%.html) $(MAN_PAGES:%=man/%.txt) $(SOURCE_FILES)
forge: source.tar.gz hookc/hookc git2d/git2d $(SOURCE_FILES)
	CGO_ENABLED=0 go build -o forge -ldflags '-extldflags "-f no-PIC -static" -X "main.VERSION=$(VERSION)"' -tags 'osusergo netgo static_build'

man/%.html: man/%
	mandoc -Thtml -O style=./mandoc.css $< > $@

man/%.txt: man/% utils/colb
	mandoc $< | ./utils/colb > $@

utils/colb:

hookc/hookc:

git2d/git2d: git2d/*.c
	$(CC) $(CFLAGS) -o git2d/git2d $^ $(shell pkg-config --cflags --libs libgit2) -lpthread

clean:
	rm -rf forge vendor man/*.html man/*.txt utils/colb hookc/hookc git2d/git2d source.tar.gz */*.o
	rm -rf forge vendor utils/colb hookc/hookc git2d/git2d source.tar.gz */*.o

source.tar.gz: $(SOURCE_FILES)
	rm -f source.tar.gz
	go mod vendor
	git ls-files -z | xargs -0 tar -czf source.tar.gz vendor
# Lindenii Forge

**Work in progress.**

Lindenii Forge aims to be an uncomplicated yet featured software forge,
primarily designed for self-hosting by small organizations and individuals.

* [Upstream source repository](https://forge.lindenii.runxiyu.org/forge/-/repos/server/)
  ([backup](https://git.lindenii.runxiyu.org/forge.git/))
* [Website and documentation](https://lindenii.runxiyu.org/forge/)
* [Manual pages](https://forge.lindenii.runxiyu.org/-/man/)
* [Temporary issue tracker](https://todo.sr.ht/~runxiyu/forge)
* IRC [`#lindenii`](https://webirc.runxiyu.org/kiwiirc/#lindenii)
  on [irc.runxiyu.org](https://irc.runxiyu.org)\
  and [`#lindenii`](https://web.libera.chat/#lindenii)
  on [Libera.Chat](https://libera.chat)

## Implemented features

* Umambiguously parsable URL
* Groups and subgroups
* Repo hosting
* Push to `contrib/` branches to automatically create merge requests
* Basic federated authentication
* Converting mailed patches to branches

## Planned features

* Further Integration with mailing list workflows
* Ticket trackers and discussions
  * Web interface
  * Email integration with IMAP archives
* SSH API
* Email access

## License

We are currently using the
[GNU Affero General Public License version 3](https://www.gnu.org/licenses/agpl-3.0.html).

The forge software serves its own source at `/-/source/`.

## Contribute

Please submit patches by pushing to `contrib/...` in the official repo.

Alternatively, send email to
[`forge/-/repos/server@forge.lindenii.runxiyu.org`](mailto:forge%2F-%2Frepos%2Fserver@forge.lindenii.runxiyu.org).
Note that emailing patches is still experimental.

## Mirrors

We have several repo mirrors:

* [Official repo on our own instance of Lindenii Forge](https://forge.lindenii.runxiyu.org/forge/-/repos/server/)
* [The Lindenii Project's backup cgit](https://git.lindenii.runxiyu.org/forge.git/)
* [SourceHut](https://git.sr.ht/~runxiyu/forge/)
* [Codeberg](https://codeberg.org/lindenii/forge/)
* [GitHub](https://github.com/runxiyu/forge/)
// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>

package main

import (
	"errors"
	"net/http"
	"net/url"
	"strconv"
	"strings"

	"github.com/jackc/pgx/v5"
	"go.lindenii.runxiyu.org/lindenii-common/clog"
)

type forgeHTTPRouter struct{}

// ServeHTTP handles all incoming HTTP requests and routes them to the correct
// location.
//
// TODO: This function is way too large.
func (router *forgeHTTPRouter) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
	var remoteAddr string
	if config.HTTP.ReverseProxy {
		remoteAddrs, ok := request.Header["X-Forwarded-For"]
		if ok && len(remoteAddrs) == 1 {
			remoteAddr = remoteAddrs[0]
		} else {
			remoteAddr = request.RemoteAddr
		}
	} else {
		remoteAddr = request.RemoteAddr
	}
	clog.Info("Incoming HTTP: " + remoteAddr + " " + request.Method + " " + request.RequestURI)

	var segments []string
	var err error
	var sepIndex int
	params := make(map[string]any)

	if segments, _, err = parseReqURI(request.RequestURI); err != nil {
		errorPage400(writer, params, "Error parsing request URI: "+err.Error())
		return
	}
	dirMode := false
	if segments[len(segments)-1] == "" {
		dirMode = true
		segments = segments[:len(segments)-1]
	}

	params["url_segments"] = segments
	params["dir_mode"] = dirMode
	params["global"] = globalData
	var userID int // 0 for none
	userID, params["username"], err = getUserFromRequest(request)
	params["user_id"] = userID
	if err != nil && !errors.Is(err, http.ErrNoCookie) && !errors.Is(err, pgx.ErrNoRows) {
		errorPage500(writer, params, "Error getting user info from request: "+err.Error())
		return
	}

	if userID == 0 {
		params["user_id_string"] = ""
	} else {
		params["user_id_string"] = strconv.Itoa(userID)
	}

	for _, v := range segments {
		if strings.Contains(v, ":") {
			errorPage400Colon(writer, params)
			return
		}
	}

	if len(segments) == 0 {
		httpHandleIndex(writer, request, params)
		return
	}

	if segments[0] == "-" {
		if len(segments) < 2 {
			errorPage404(writer, params)
			return
		} else if len(segments) == 2 && redirectDir(writer, request) {
			return
		}

		switch segments[1] {
		case "man":
			manHandler.ServeHTTP(writer, request)
			return
		case "static":
			staticHandler.ServeHTTP(writer, request)
			return
		case "source":
			sourceHandler.ServeHTTP(writer, request)
			return
		}
	}

	if segments[0] == "-" {
		switch segments[1] {
		case "login":
			httpHandleLogin(writer, request, params)
			return
		case "users":
			httpHandleUsers(writer, request, params)
			return
		case "gc":
			httpHandleGC(writer, request, params)
			return
		default:
			errorPage404(writer, params)
			return
		}
	}

	sepIndex = -1
	for i, part := range segments {
		if part == "-" {
			sepIndex = i
			break
		}
	}

	params["separator_index"] = sepIndex

	var groupPath []string
	var moduleType string
	var moduleName string

	if sepIndex > 0 {
		groupPath = segments[:sepIndex]
	} else {
		groupPath = segments
	}
	params["group_path"] = groupPath

	switch {
	case sepIndex == -1:
		if redirectDir(writer, request) {
			return
		}
		httpHandleGroupIndex(writer, request, params)
	case len(segments) == sepIndex+1:
		errorPage404(writer, params)
		return
	case len(segments) == sepIndex+2:
		errorPage404(writer, params)
		return
	default:
		moduleType = segments[sepIndex+1]
		moduleName = segments[sepIndex+2]
		switch moduleType {
		case "repos":
			params["repo_name"] = moduleName

			if len(segments) > sepIndex+3 {
				switch segments[sepIndex+3] {
				case "info":
					if err = httpHandleRepoInfo(writer, request, params); err != nil {
						errorPage500(writer, params, err.Error())
					}
					return
				case "git-upload-pack":
					if err = httpHandleUploadPack(writer, request, params); err != nil {
						errorPage500(writer, params, err.Error())
					}
					return
				}
			}

			if params["ref_type"], params["ref_name"], err = getParamRefTypeName(request); err != nil {
				if errors.Is(err, errNoRefSpec) {
					params["ref_type"] = ""
				} else {
					errorPage400(writer, params, "Error querying ref type: "+err.Error())
					return
				}
			}

			if params["repo"], params["repo_description"], params["repo_id"], _, err = openRepo(request.Context(), groupPath, moduleName); err != nil {
				errorPage500(writer, params, "Error opening repo: "+err.Error())
				return
			}

			repoURLRoot := "/"
			for _, part := range segments[:sepIndex+3] {
				repoURLRoot = repoURLRoot + url.PathEscape(part) + "/"
			}
			params["repo_url_root"] = repoURLRoot
			params["repo_patch_mailing_list"] = repoURLRoot[1:len(repoURLRoot)-1] + "@" + config.LMTP.Domain
			params["http_clone_url"] = genHTTPRemoteURL(groupPath, moduleName)
			params["ssh_clone_url"] = genSSHRemoteURL(groupPath, moduleName)

			if len(segments) == sepIndex+3 {
				if redirectDir(writer, request) {
					return
				}
				httpHandleRepoIndex(writer, request, params)
				return
			}

			repoFeature := segments[sepIndex+3]
			switch repoFeature {
			case "tree":
				if anyContain(segments[sepIndex+4:], "/") {
					errorPage400(writer, params, "Repo tree paths may not contain slashes in any segments")
					return
				}
				if dirMode {
					params["rest"] = strings.Join(segments[sepIndex+4:], "/") + "/"
				} else {
					params["rest"] = strings.Join(segments[sepIndex+4:], "/")
				}
				if len(segments) < sepIndex+5 && redirectDir(writer, request) {
					return
				}
				httpHandleRepoTree(writer, request, params)
			case "branches":
				if redirectDir(writer, request) {
					return
				}
				httpHandleRepoBranches(writer, request, params)
				return
			case "raw":
				if anyContain(segments[sepIndex+4:], "/") {
					errorPage400(writer, params, "Repo tree paths may not contain slashes in any segments")
					return
				}
				if dirMode {
					params["rest"] = strings.Join(segments[sepIndex+4:], "/") + "/"
				} else {
					params["rest"] = strings.Join(segments[sepIndex+4:], "/")
				}
				if len(segments) < sepIndex+5 && redirectDir(writer, request) {
					return
				}
				httpHandleRepoRaw(writer, request, params)
			case "log":
				if len(segments) > sepIndex+4 {
					errorPage400(writer, params, "Too many parameters")
					return
				}
				if redirectDir(writer, request) {
					return
				}
				httpHandleRepoLog(writer, request, params)
			case "commit":
				if len(segments) != sepIndex+5 {
					errorPage400(writer, params, "Incorrect number of parameters")
					return
				}
				if redirectNoDir(writer, request) {
					return
				}
				params["commit_id"] = segments[sepIndex+4]
				httpHandleRepoCommit(writer, request, params)
			case "contrib":
				if redirectDir(writer, request) {
					return
				}
				switch len(segments) {
				case sepIndex + 4:
					httpHandleRepoContribIndex(writer, request, params)
				case sepIndex + 5:
					params["mr_id"] = segments[sepIndex+4]
					httpHandleRepoContribOne(writer, request, params)
				default:
					errorPage400(writer, params, "Too many parameters")
				}
			default:
				errorPage404(writer, params)
				return
			}
		default:
			errorPage404(writer, params)
			return
		}
	}
}
.\" SPDX-License-Identifier: AGPL-3.0-only
.\" SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
.Dd March 30, 2025
.Dt LINDENII-FORGE-HOOKC 1
.Os Lindenii Forge
.Sh NAME
.Nm lindenii-forge-hookc
.Nd helper binary to delegate Git hook behavior to the forge daemon
.Sh SYNOPSIS
.Nm
.Op Ar argument ...
.Sh DESCRIPTION
.Nm
is a helper binary for Git server-side hooks that relays the hook's context to
a persistent daemon via a UNIX domain socket and communicates back any relevant
responses.
.Pp
It is intended to be invoked by
.Xr git-receive-pack 1
for hooks such as
.Pa pre-receive ,
.Pa update ,
and
.Pa post-receive .
.Sh ENVIRONMENT
.Bl -tag -width Ds
.It Ev LINDENII_FORGE_HOOKS_SOCKET_PATH
Absolute path to the UNIX domain socket on which the daemon is listening.
.It Ev LINDENII_FORGE_HOOKS_COOKIE
64-character authentication cookie used to validate the hook client to the daemon.
.El
.Sh OPERATION
.Nm
collects the following information and sends it to the daemon:
.Bl -bullet
.It
All command-line arguments
.It
All
.Ev GIT_*
environment variables
.It
The raw hook
.Pa stdin
(e.g., old/new ref triplets for
.Pa pre-receive )
.El
.Pp
After sending this data, it waits for a one-byte status code from the daemon,
which becomes
.Nm Ns 's
own exit status.
.Pp
If the daemon sends any output afterward, it is forwarded to standard error
and will appear as
.Dq remote:
output to the user.
.Sh BUGS
.Bl -bullet
.It
The status byte from the daemon currently must be sent before any stderr output.
.It
Currently assumes
.Pa stdin
and
.Pa stderr
are pipes, which is not guaranteed in future versions of Git.
.El
.Sh AUTHORS
.An Runxi Yu Aq Mt https://runxiyu.org
.An Test_User Aq Mt hax@runxiyu.org
.Sh SEE ALSO
.Xr git-receive-pack 1 ,
.Xr lindenii-forge 1
.\" SPDX-License-Identifier: AGPL-3.0-only
.\" SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
.Dd March 30, 2025
.Dt LINDENII-FORGE-MAIL 5
.Os Lindenii Forge
.Sh NAME
.Nm lindenii-forge-mail
.Nd configuring Lindenii Forge email integration
.Sh DESCRIPTION
.Nm
is a guide to configuring Lindenii Forge for email integration.
.Pp
This is currently a stub. Here is a working configuration that works
for the Lindenii Project itself, though.
.Sh /etc/smtpd/smtpd.conf
.Bd -literal
table forge file:/etc/smtpd/forge
action "FORGE" lmtp "/srv/forge/lmtp.sock" rcpt-to virtual <forge>
match from any for domain "forge.lindenii.runxiyu.org" action "FORGE"
.Ed
.Sh /etc/smtpd/forge
.Bd -literal
@ forge
.Ed
.Sh SEE ALSO
.Xr lindenii-forge 1 ,
.Xr lindenii-forge 5 ,
.Xr smtpd.conf 5
.Sh AUTHORS
.An Runxi Yu Aq Mt https://runxiyu.org
.An Test_User Aq Mt hax@runxiyu.org
.\" SPDX-License-Identifier: AGPL-3.0-only
.\" SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
.Dd March 30, 2025
.Dt LINDENII-FORGE 1
.Os Lindenii Forge
.Sh NAME
.Nm lindenii-forge
.Nd Lindenii Forge server daemon
.Sh SYNOPSIS
.Nm
.Op Fl config Ar path
.Sh DESCRIPTION
.Nm
is the main server daemon for Lindenii Forge.
.Pp
All configuration is loaded from a configuration file; see
.Xr lindenii-forge 5 .
.Pp
All listeners are long-lived; the process runs until interrupted.
.Sh OPTIONS
.Bl -tag -width Ds
.It Fl config Ar path
The path to the configuration file. Defaults to
.Pa /etc/lindenii/forge.scfg .
.El
.Sh FILES
.Bl -tag -width Ds
.It Pa /etc/lindenii/forge.scfg
Default configuration file.
.El
.Sh SEE ALSO
.Xr lindenii-forge 5
.Sh AUTHORS
.An Runxi Yu Aq Mt https://runxiyu.org
.An Test_User Aq Mt hax@runxiyu.org
.\" SPDX-License-Identifier: AGPL-3.0-only
.\" SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
.Dd March 30, 2025
.Dt LINDENII-FORGE 5
.Os Lindenii Forge
.Sh NAME
.Nm lindenii-forge.scfg
.Nd configuration file for Lindenii Forge
.Sh DESCRIPTION
.Nm
describes the configuration for
.Xr lindenii-forge 1
instance using the
scfg
format.
.Pp
Each directive consists of a name followed by zero or more parameters. Directives may also introduce blocks of subdirectives using braces.
.Pp
Comments begin with
.Sq #
and extend to the end of the line.
.Sh DIRECTIVES
.Bl -tag -width Ds
.It Ic http
Configures the ingress HTTP server.
.Bl -tag -width Ds
.It Ic net
Network type to listen on (e.g., 
.Dq tcp ,
.Dq tcp4 ,
.Dq unix ) .
.It Ic addr
Address to listen on (e.g., 
.Dq :8080
or
.Dq /var/run/lindenii/forge/http.sock ) .
.It Ic cookie_expiry
How long (in seconds) to keep session cookies.
.It Ic root
Canonical root URL of the web interface (e.g.,
.Dq https://forge.example.org ) .
.It Ic read_timeout , write_timeout , idle_timeout
Timeouts, in seconds, for the general HTTP server context.
.It Ic reverse_proxy
Boolean indicating whether to trust X-Forwarded-For headers.
.El
.It Ic ssh
Configures the SSH server.
.Bl -tag -width Ds
.It Ic net
Network type to listen on
.Dq ( tcp
is recommended).
.It Ic addr
Address to listen on (e.g.,
.Dq :22 ) .
.It Ic key
Path to the SSH host key (must be passwordless).
.It Ic root
Canonical SSH URL prefix (e.g.,
.Dq ssh://forge.example.org ) .
.El
.It Ic git
Configures Git repository storage.
.Bl -tag -width Ds
.It Ic repo_dir
Filesystem path under which new repositories are stored.
.It Ic socket
Filesystem path for the socket listened on by
.Xr lindenii-forge-git2d 1 .
.It Ic daemon_path
Where
.Xr lindenii-forge-git2d 1
should be deployed to and run from.
.El
.It Ic db
Configures database connection.
.Bl -tag -width Ds
.It Ic type
Database type (currently must be
.Dq postgres ) .
.It Ic conn
Connection string, e.g.,
.Dq postgresql:///lindenii-forge?host=/var/run/postgresql .
.El
.It Ic general
Miscellaneous settings.
.Bl -tag -width Ds
.It Ic title
A user-facing name for the instance.
.El
.It Ic hooks
Configures Git hook communication with the forge daemon.
.Bl -tag -width Ds
.It Ic socket
Path to a UNIX domain socket for receiving hook events.
.It Ic execs
Directory where Git hook executables are stored.
.El
.It Ic irc
Optional configuration for IRC presence.
.Bl -tag -width Ds
.It Ic tls
Boolean indicating whether to use TLS.
.It Ic net , addr
Network type and address (e.g.,
.Dq tcp ,
.Dq irc.example.org:6697 ) .
.It Ic sendq
Maximum send queue size.
.It Ic nick , user , gecos
Identity fields for the IRC connection.
.El
.It Ic lmtp
Configuration for the LMTP/MX component. You may wish to refer to
.Xr forge-mail 5
for information on configuring your SMTP server.
.Bl -tag -width Ds
.It Ic socket
The path to the UNIX domain socket to listen on.
.It Ic max_size
The maximum acceptable ingress message size.
.It Ic domain
The domain-part of our LMTP server.
.It Ic read_timeout , write_timeout
General timeouts for LMTP connections.
.El
.El
.Sh FILES
.Bl -tag -width Ds
.It Pa /etc/lindenii/forge.scfg
Default path to the configuration file.
.El
.Sh SEE ALSO
.Xr lindenii-forge 1 ,
.Xr lindenii-forge-hookc 1 ,
.Lk https://git.sr.ht/~emersion/scfg scfg
.Sh AUTHORS
.An Runxi Yu Aq Mt https://runxiyu.org
.An Test_User Aq Mt hax@runxiyu.org
/* $OpenBSD: mandoc.css,v 1.41 2025/01/25 03:17:11 schwarze Exp $ */
/*
 * Standard style sheet for mandoc(1) -Thtml and man.cgi(8).
 *
 * Written by Ingo Schwarze <schwarze@openbsd.org>.
 * I place this file into the public domain.
 * Permission to use, copy, modify, and distribute it for any purpose
 * with or without fee is hereby granted, without any conditions.
 */

/* Global defaults. */

html {		max-width: 65em;
		--bg: #FFFFFF;
		--fg: #000000; }
body {		background: var(--bg);
		color: var(--fg);
		font-family: Helvetica,Arial,sans-serif; }
h1, h2 {	font-size: 110%; }
table {		margin-top: 0em;
		margin-bottom: 0em;
		border-collapse: collapse; }
/* Some browsers set border-color in a browser style for tbody,
 * but not for table, resulting in inconsistent border styling. */
tbody {		border-color: inherit; }
tr {		border-color: inherit; }
td {		vertical-align: top;
		padding-left: 0.2em;
		padding-right: 0.2em;
		border-color: inherit; }
ul, ol, dl {	margin-top: 0em;
		margin-bottom: 0em; }
li, dt {	margin-top: 1em; }
pre {		font-family: inherit; }

.permalink {	border-bottom: thin dotted;
		color: inherit;
		font: inherit;
		text-decoration: inherit; }
* {		clear: both }

/* Search form and search results. */

fieldset {	border: thin solid silver;
		border-radius: 1em;
		text-align: center; }
input[name=expr] {
		width: 25%; }

table.results {	margin-top: 1em;
		margin-left: 2em;
		font-size: smaller; }

/* Header and footer lines. */

div[role=doc-pageheader] {
		display: flex;
		border-bottom: 1px dotted #808080;
		margin-bottom: 1em;
		font-size: smaller; }
.head-ltitle {	flex: 1; }
.head-vol {	flex: 0 1 auto;
		text-align: center; }
.head-rtitle {	flex: 1;
		text-align: right; }

div[role=doc-pagefooter] {
		display: flex;
		justify-content: space-between;
		border-top: 1px dotted #808080;
		margin-top: 1em;
		font-size: smaller; }
.foot-left {	flex: 1; }
.foot-date {	flex: 0 1 auto;
		text-align: center; }
.foot-os {	flex: 1;
		text-align: right; }

/* Sections and paragraphs. */

main {		margin-left: 3.8em; }
.Nd { }
section.Sh { }
h2.Sh {		margin-top: 1.2em;
		margin-bottom: 0.6em;
		margin-left: -3.2em; }
section.Ss { }
h3.Ss {		margin-top: 1.2em;
		margin-bottom: 0.6em;
		margin-left: -1.2em;
		font-size: 105%; }
.Pp {		margin: 0.6em 0em; }
.Sx { }
.Xr { }

/* Displays and lists. */

.Bd { }
.Bd-indent {	margin-left: 3.8em; }

.Bl-bullet {	list-style-type: disc;
		padding-left: 1em; }
.Bl-bullet > li { }
.Bl-dash {	list-style-type: none;
		padding-left: 0em; }
.Bl-dash > li:before {
		content: "\2014  "; }
.Bl-item {	list-style-type: none;
		padding-left: 0em; }
.Bl-item > li { }
.Bl-compact > li {
		margin-top: 0em; }

.Bl-enum {	padding-left: 2em; }
.Bl-enum > li { }
.Bl-compact > li {
		margin-top: 0em; }

.Bl-diag { }
.Bl-diag > dt {
		font-style: normal;
		font-weight: bold; }
.Bl-diag > dd {
		margin-left: 0em; }
.Bl-hang { }
.Bl-hang > dt { }
.Bl-hang > dd {
		margin-left: 5.5em; }
.Bl-inset { }
.Bl-inset > dt { }
.Bl-inset > dd {
		margin-left: 0em; }
.Bl-ohang { }
.Bl-ohang > dt { }
.Bl-ohang > dd {
		margin-left: 0em; }
.Bl-tag {	margin-top: 0.6em;
		margin-left: 5.5em; }
.Bl-tag > dt {
		float: left;
		margin-top: 0em;
		margin-left: -5.5em;
		padding-right: 0.5em;
		vertical-align: top; }
.Bl-tag > dd {
		clear: right;
		column-count: 1;  /* Force block formatting context. */
		width: 100%;
		margin-top: 0em;
		margin-left: 0em;
		margin-bottom: 0.6em;
		vertical-align: top; }
.Bl-compact {	margin-top: 0em; }
.Bl-compact > dd {
		margin-bottom: 0em; }
.Bl-compact > dt {
		margin-top: 0em; }

.Bl-column { }
.Bl-column > tbody > tr { }
.Bl-column > tbody > tr > td {
		margin-top: 1em; }
.Bl-compact > tbody > tr > td {
		margin-top: 0em; }

.Rs {		font-style: normal;
		font-weight: normal; }
.RsA { }
.RsB {		font-style: italic;
		font-weight: normal; }
.RsC { }
.RsD { }
.RsI {		font-style: italic;
		font-weight: normal; }
.RsJ {		font-style: italic;
		font-weight: normal; }
.RsN { }
.RsO { }
.RsP { }
.RsQ { }
.RsR { }
.RsT {		font-style: normal;
		font-weight: normal; }
.RsU { }
.RsV { }

.eqn { }
.tbl td {	vertical-align: middle; }

.HP {		margin-left: 3.8em;
		text-indent: -3.8em; }

/* Semantic markup for command line utilities. */

table.Nm { }
code.Nm {	font-style: normal;
		font-weight: bold;
		font-family: inherit; }
.Fl {		font-style: normal;
		font-weight: bold;
		font-family: inherit; }
.Cm {		font-style: normal;
		font-weight: bold;
		font-family: inherit; }
.Ar {		font-style: italic;
		font-weight: normal; }
.Op {		display: inline flow; }
.Ic {		font-style: normal;
		font-weight: bold;
		font-family: inherit; }
.Ev {		font-style: normal;
		font-weight: normal;
		font-family: monospace; }
.Pa {		font-style: italic;
		font-weight: normal; }

/* Semantic markup for function libraries. */

.Lb { }
code.In {	font-style: normal;
		font-weight: bold;
		font-family: inherit; }
a.In { }
.Fd {		font-style: normal;
		font-weight: bold;
		font-family: inherit; }
.Ft {		font-style: italic;
		font-weight: normal; }
.Fn {		font-style: normal;
		font-weight: bold;
		font-family: inherit; }
.Fa {		font-style: italic;
		font-weight: normal; }
.Vt {		font-style: italic;
		font-weight: normal; }
.Va {		font-style: italic;
		font-weight: normal; }
.Dv {		font-style: normal;
		font-weight: normal;
		font-family: monospace; }
.Er {		font-style: normal;
		font-weight: normal;
		font-family: monospace; }

/* Various semantic markup. */

.An { }
.Lk { }
.Mt { }
.Cd {		font-style: normal;
		font-weight: bold;
		font-family: inherit; }
.Ad {		font-style: italic;
		font-weight: normal; }
.Ms {		font-style: normal;
		font-weight: bold; }
.St { }
.Ux { }

/* Physical markup. */

.Bf {		display: inline flow; }
.No {		font-style: normal;
		font-weight: normal; }
.Em {		font-style: italic;
		font-weight: normal; }
.Sy {		font-style: normal;
		font-weight: bold; }
.Li {		font-style: normal;
		font-weight: normal;
		font-family: monospace; }

/* Tooltip support. */

h2.Sh, h3.Ss {	position: relative; }
.An, .Ar, .Cd, .Cm, .Dv, .Em, .Er, .Ev, .Fa, .Fd, .Fl, .Fn, .Ft,
.Ic, code.In, .Lb, .Lk, .Ms, .Mt, .Nd, code.Nm, .Pa, .Rs,
.St, .Sx, .Sy, .Va, .Vt, .Xr {
		display: inline flow;
		position: relative; }

.An::before {	content: "An"; }
.Ar::before {	content: "Ar"; }
.Cd::before {	content: "Cd"; }
.Cm::before {	content: "Cm"; }
.Dv::before {	content: "Dv"; }
.Em::before {	content: "Em"; }
.Er::before {	content: "Er"; }
.Ev::before {	content: "Ev"; }
.Fa::before {	content: "Fa"; }
.Fd::before {	content: "Fd"; }
.Fl::before {	content: "Fl"; }
.Fn::before {	content: "Fn"; }
.Ft::before {	content: "Ft"; }
.Ic::before {	content: "Ic"; }
code.In::before { content: "In"; }
.Lb::before {	content: "Lb"; }
.Lk::before {	content: "Lk"; }
.Ms::before {	content: "Ms"; }
.Mt::before {	content: "Mt"; }
.Nd::before {	content: "Nd"; }
code.Nm::before { content: "Nm"; }
.Pa::before {	content: "Pa"; }
.Rs::before {	content: "Rs"; }
h2.Sh::before {	content: "Sh"; }
h3.Ss::before {	content: "Ss"; }
.St::before {	content: "St"; }
.Sx::before {	content: "Sx"; }
.Sy::before {	content: "Sy"; }
.Va::before {	content: "Va"; }
.Vt::before {	content: "Vt"; }
.Xr::before {	content: "Xr"; }

.An::before, .Ar::before, .Cd::before, .Cm::before,
.Dv::before, .Em::before, .Er::before, .Ev::before,
.Fa::before, .Fd::before, .Fl::before, .Fn::before, .Ft::before,
.Ic::before, code.In::before, .Lb::before, .Lk::before,
.Ms::before, .Mt::before, .Nd::before, code.Nm::before,
.Pa::before, .Rs::before,
h2.Sh::before, h3.Ss::before, .St::before, .Sx::before, .Sy::before,
.Va::before, .Vt::before, .Xr::before {
		opacity: 0;
		transition: .15s ease opacity;
		pointer-events: none;
		position: absolute;
		bottom: 100%;
		box-shadow: 0 0 .35em var(--fg);
		padding: .15em .25em;
		white-space: nowrap;
		font-family: Helvetica,Arial,sans-serif;
		font-style: normal;
		font-weight: bold;
		background: var(--bg);
		color: var(--fg); }
.An:hover::before, .Ar:hover::before, .Cd:hover::before, .Cm:hover::before,
.Dv:hover::before, .Em:hover::before, .Er:hover::before, .Ev:hover::before,
.Fa:hover::before, .Fd:hover::before, .Fl:hover::before, .Fn:hover::before,
.Ft:hover::before, .Ic:hover::before, code.In:hover::before,
.Lb:hover::before, .Lk:hover::before, .Ms:hover::before, .Mt:hover::before,
.Nd:hover::before, code.Nm:hover::before, .Pa:hover::before,
.Rs:hover::before, h2.Sh:hover::before, h3.Ss:hover::before, .St:hover::before,
.Sx:hover::before, .Sy:hover::before, .Va:hover::before, .Vt:hover::before,
.Xr:hover::before {
		opacity: 1;
		pointer-events: inherit; }

/* Overrides to avoid excessive margins on small devices. */

@media (max-width: 37.5em) {
main {		margin-left: 0.5em; }
h2.Sh, h3.Ss {	margin-left: 0em; }
.Bd-indent {	margin-left: 2em; }
.Bl-hang > dd {
		margin-left: 2em; }
.Bl-tag {	margin-left: 2em; }
.Bl-tag > dt {
		margin-left: -2em; }
.HP {		margin-left: 2em;
		text-indent: -2em; }
}

/* Overrides for a dark color scheme for accessibility. */

@media (prefers-color-scheme: dark) {
html {		--bg: #1E1F21;
		--fg: #EEEFF1; }
:link {		color: #BAD7FF; }
:visited {	color: #F6BAFF; }
}
// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>

package main

import (
	"embed"
	"html/template"
	"io/fs"
	"net/http"

	"github.com/tdewolff/minify/v2"
	"github.com/tdewolff/minify/v2/html"
)

//go:embed LICENSE source.tar.gz
var sourceFS embed.FS

var sourceHandler = http.StripPrefix(
	"/-/source/",
	http.FileServer(http.FS(sourceFS)),
)

//go:embed templates/* static/*
//go:embed man/*.html man/*.txt man/*.css
//go:embed hookc/hookc git2d/git2d
var resourcesFS embed.FS

var templates *template.Template

// loadTemplates minifies and loads HTML templates.
func loadTemplates() (err error) {
	minifier := minify.New()
	minifierOptions := html.Minifier{
		TemplateDelims:      [2]string{"{{", "}}"},
		KeepDefaultAttrVals: true,
	} //exhaustruct:ignore
	minifier.Add("text/html", &minifierOptions)

	templates = template.New("templates").Funcs(template.FuncMap{
		"first_line":        firstLine,
		"path_escape":       pathEscape,
		"query_escape":      queryEscape,
		"dereference_error": dereferenceOrZero[error],
		"minus":             minus,
	})

	err = fs.WalkDir(resourcesFS, "templates", func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if !d.IsDir() {
			content, err := fs.ReadFile(resourcesFS, path)
			if err != nil {
				return err
			}

			minified, err := minifier.Bytes("text/html", content)
			if err != nil {
				return err
			}

			_, err = templates.Parse(bytesToString(minified))
			if err != nil {
				return err
			}
		}
		return nil
	})
	return err
}

var (
	staticHandler http.Handler
	manHandler    http.Handler
)

// This init sets up static and man handlers. The resulting handlers must be
// This init sets up static handlers. The resulting handlers must be
// used in the HTTP router, and do nothing unless called from elsewhere.
func init() {
	staticFS, err := fs.Sub(resourcesFS, "static")
	if err != nil {
		panic(err)
	}
	staticHandler = http.StripPrefix("/-/static/", http.FileServer(http.FS(staticFS)))
	manFS, err := fs.Sub(resourcesFS, "man")
	if err != nil {
		panic(err)
	}
	manHandler = http.StripPrefix("/-/man/", http.FileServer(http.FS(manFS)))
}