Lindenii Project Forge
Login

server

Lindenii Forge’s main backend daemon
Commit info
ID
2d0ea5ba085f9caa84a7e7b1fd55f653834009aa
Author
Runxi Yu <me@runxiyu.org>
Author date
Sat, 05 Apr 2025 20:37:03 +0800
Committer
Runxi Yu <me@runxiyu.org>
Committer date
Sat, 05 Apr 2025 20:37:03 +0800
Actions
sshPubkey* shall no longer be global variables
// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>

package main

import (
	"bufio"
	"context"
	"errors"
	"os"

	"codeberg.org/emersion/go-scfg"
	"github.com/jackc/pgx/v5/pgxpool"
)

// config holds the global configuration used by this instance. There is
// currently no synchronization mechanism, so it must not be modified after
// request handlers are spawned.
type Config struct {
	HTTP struct {
		Net          string `scfg:"net"`
		Addr         string `scfg:"addr"`
		CookieExpiry int    `scfg:"cookie_expiry"`
		Root         string `scfg:"root"`
		ReadTimeout  uint32 `scfg:"read_timeout"`
		WriteTimeout uint32 `scfg:"write_timeout"`
		IdleTimeout  uint32 `scfg:"idle_timeout"`
		ReverseProxy bool   `scfg:"reverse_proxy"`
	} `scfg:"http"`
	Hooks struct {
		Socket string `scfg:"socket"`
		Execs  string `scfg:"execs"`
	} `scfg:"hooks"`
	LMTP struct {
		Socket       string `scfg:"socket"`
		Domain       string `scfg:"domain"`
		MaxSize      int64  `scfg:"max_size"`
		WriteTimeout uint32 `scfg:"write_timeout"`
		ReadTimeout  uint32 `scfg:"read_timeout"`
	} `scfg:"lmtp"`
	Git struct {
		RepoDir    string `scfg:"repo_dir"`
		Socket     string `scfg:"socket"`
		DaemonPath string `scfg:"daemon_path"`
	} `scfg:"git"`
	SSH struct {
		Net  string `scfg:"net"`
		Addr string `scfg:"addr"`
		Key  string `scfg:"key"`
		Root string `scfg:"root"`
	} `scfg:"ssh"`
	IRC struct {
		Net   string `scfg:"net"`
		Addr  string `scfg:"addr"`
		TLS   bool   `scfg:"tls"`
		SendQ uint   `scfg:"sendq"`
		Nick  string `scfg:"nick"`
		User  string `scfg:"user"`
		Gecos string `scfg:"gecos"`
	} `scfg:"irc"`
	General struct {
		Title string `scfg:"title"`
	} `scfg:"general"`
	DB struct {
		Type string `scfg:"type"`
		Conn string `scfg:"conn"`
	} `scfg:"db"`
}

// loadConfig loads a configuration file from the specified path and unmarshals
// it to the global [config] struct. This may race with concurrent reads from
// [config]; additional synchronization is necessary if the configuration is to
// be made reloadable.
//
// TODO: Currently, it returns an error when the user specifies any unknown
// configuration patterns, but silently ignores fields in the [config] struct
// that is not present in the user's configuration file. We would prefer the
// exact opposite behavior.
func (s *server) loadConfig(path string) (err error) {
	var configFile *os.File
	if configFile, err = os.Open(path); err != nil {
		return err
	}
	defer configFile.Close()

	decoder := scfg.NewDecoder(bufio.NewReader(configFile))
	if err = decoder.Decode(&s.config); err != nil {
		return err
	}

	if s.config.DB.Type != "postgres" {
		return errors.New("unsupported database type")
	}

	if s.database, err = pgxpool.New(context.Background(), s.config.DB.Conn); err != nil {
		return err
	}

	globalData["forge_title"] = s.config.General.Title
	s.globalData["forge_title"] = s.config.General.Title

	return nil
}
// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>

package main

import (
	"context"
	"errors"
	"io"
	"iter"
	"os"
	"strings"

	"github.com/go-git/go-git/v5"
	"github.com/go-git/go-git/v5/plumbing"
	"github.com/go-git/go-git/v5/plumbing/object"
	"github.com/jackc/pgx/v5/pgtype"
)

// openRepo opens a git repository by group and repo name.
//
// TODO: This should be deprecated in favor of doing it in the relevant
// request/router context in the future, as it cannot cover the nuance of
// fields needed.
func (s *server) openRepo(ctx context.Context, groupPath []string, repoName string) (repo *git.Repository, description string, repoID int, fsPath string, err error) {
	err = s.database.QueryRow(ctx, `
WITH RECURSIVE group_path_cte AS (
	-- Start: match the first name in the path where parent_group IS NULL
	SELECT
		id,
		parent_group,
		name,
		1 AS depth
	FROM groups
	WHERE name = ($1::text[])[1]
		AND parent_group IS NULL

	UNION ALL

	-- Recurse: join next segment of the path
	SELECT
		g.id,
		g.parent_group,
		g.name,
		group_path_cte.depth + 1
	FROM groups g
	JOIN group_path_cte ON g.parent_group = group_path_cte.id
	WHERE g.name = ($1::text[])[group_path_cte.depth + 1]
		AND group_path_cte.depth + 1 <= cardinality($1::text[])
)
SELECT
	r.filesystem_path,
	COALESCE(r.description, ''),
	r.id
FROM group_path_cte g
JOIN repos r ON r.group_id = g.id
WHERE g.depth = cardinality($1::text[])
	AND r.name = $2
	`, pgtype.FlatArray[string](groupPath), repoName).Scan(&fsPath, &description, &repoID)
	if err != nil {
		return
	}

	repo, err = git.PlainOpen(fsPath)
	return
}

// go-git's tree entries are not friendly for use in HTML templates.
// This struct is a wrapper that is friendlier for use in templating.
type displayTreeEntry struct {
	Name      string
	Mode      string
	Size      uint64
	IsFile    bool
	IsSubtree bool
}

// makeDisplayTree takes git trees of form [object.Tree] and creates a slice of
// [displayTreeEntry] for easier templating.
func makeDisplayTree(tree *object.Tree) (displayTree []displayTreeEntry) {
	for _, entry := range tree.Entries {
		displayEntry := displayTreeEntry{} //exhaustruct:ignore
		var err error
		var osMode os.FileMode

		if osMode, err = entry.Mode.ToOSFileMode(); err != nil {
			displayEntry.Mode = "x---------"
		} else {
			displayEntry.Mode = osMode.String()
		}

		displayEntry.IsFile = entry.Mode.IsFile()

		size, _ := tree.Size(entry.Name)
		displayEntry.Size = uint64(size) //#nosec G115

		displayEntry.Name = strings.TrimPrefix(entry.Name, "/")

		displayTree = append(displayTree, displayEntry)
	}
	return displayTree
}

// commitIterSeqErr creates an [iter.Seq[*object.Commit]] from an
// [object.CommitIter], and additionally returns a pointer to error.
// The pointer to error is guaranteed to be populated with either nil or the
// error returned by the commit iterator after the returned iterator is
// finished.
func commitIterSeqErr(commitIter object.CommitIter) (iter.Seq[*object.Commit], *error) {
	var err error
	return func(yield func(*object.Commit) bool) {
		for {
			commit, err2 := commitIter.Next()
			if err2 != nil {
				if errors.Is(err2, io.EOF) {
					return
				}
				err = err2
				return
			}
			if !yield(commit) {
				return
			}
		}
	}, &err
}

// getRecentCommits fetches numCommits commits, starting from the headHash in a
// repo.
func getRecentCommits(repo *git.Repository, headHash plumbing.Hash, numCommits int) (recentCommits []*object.Commit, err error) {
	var commitIter object.CommitIter
	var thisCommit *object.Commit

	commitIter, err = repo.Log(&git.LogOptions{From: headHash}) //exhaustruct:ignore
	if err != nil {
		return nil, err
	}
	recentCommits = make([]*object.Commit, 0)
	defer commitIter.Close()
	if numCommits < 0 {
		for {
			thisCommit, err = commitIter.Next()
			if errors.Is(err, io.EOF) {
				return recentCommits, nil
			} else if err != nil {
				return nil, err
			}
			recentCommits = append(recentCommits, thisCommit)
		}
	} else {
		for range numCommits {
			thisCommit, err = commitIter.Next()
			if errors.Is(err, io.EOF) {
				return recentCommits, nil
			} else if err != nil {
				return nil, err
			}
			recentCommits = append(recentCommits, thisCommit)
		}
	}
	return recentCommits, err
}

// getRecentCommitsDisplay generates a slice of [commitDisplay] friendly for
// use in HTML templates, consisting of numCommits commits from headhash in the
// repo.
func getRecentCommitsDisplay(repo *git.Repository, headHash plumbing.Hash, numCommits int) (recentCommits []commitDisplayOld, err error) {
	var commitIter object.CommitIter
	var thisCommit *object.Commit

	commitIter, err = repo.Log(&git.LogOptions{From: headHash}) //exhaustruct:ignore
	if err != nil {
		return nil, err
	}
	recentCommits = make([]commitDisplayOld, 0)
	defer commitIter.Close()
	if numCommits < 0 {
		for {
			thisCommit, err = commitIter.Next()
			if errors.Is(err, io.EOF) {
				return recentCommits, nil
			} else if err != nil {
				return nil, err
			}
			recentCommits = append(recentCommits, commitDisplayOld{
				thisCommit.Hash,
				thisCommit.Author,
				thisCommit.Committer,
				thisCommit.Message,
				thisCommit.TreeHash,
			})
		}
	} else {
		for range numCommits {
			thisCommit, err = commitIter.Next()
			if errors.Is(err, io.EOF) {
				return recentCommits, nil
			} else if err != nil {
				return nil, err
			}
			recentCommits = append(recentCommits, commitDisplayOld{
				thisCommit.Hash,
				thisCommit.Author,
				thisCommit.Committer,
				thisCommit.Message,
				thisCommit.TreeHash,
			})
		}
	}
	return recentCommits, err
}

type commitDisplayOld struct {
	Hash      plumbing.Hash
	Author    object.Signature
	Committer object.Signature
	Message   string
	TreeHash  plumbing.Hash
}

// commitToPatch creates an [object.Patch] from the first parent of a given
// [object.Commit].
//
// TODO: This function should be deprecated as it only diffs with the first
// parent and does not correctly handle merge commits.
func commitToPatch(commit *object.Commit) (parentCommitHash plumbing.Hash, patch *object.Patch, err error) {
	var parentCommit *object.Commit
	var commitTree *object.Tree

	parentCommit, err = commit.Parent(0)
	switch {
	case errors.Is(err, object.ErrParentNotFound):
		if commitTree, err = commit.Tree(); err != nil {
			return
		}
		if patch, err = nullTree.Patch(commitTree); err != nil {
			return
		}
	case err != nil:
		return
	default:
		parentCommitHash = parentCommit.Hash
		if patch, err = parentCommit.Patch(commit); err != nil {
			return
		}
	}
	return
}

var nullTree object.Tree
var nullTree object.Tree //nolint:gochecknoglobals
// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>

package main

// globalData is passed as "global" when rendering HTML templates and contains
// global data that should stay constant throughout an execution of Lindenii
// Forge as no synchronization mechanism is provided for updating it.
var globalData = map[string]any{
	"server_public_key_string":      &serverPubkeyString,
	"server_public_key_fingerprint": &serverPubkeyFP,
	"forge_version":                 VERSION,
	// Some other ones are populated after config parsing
}
// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>

package main

import (
	"fmt"
	"net/http"
	"strings"

	"github.com/go-git/go-git/v5"
	"github.com/go-git/go-git/v5/plumbing"
	"github.com/go-git/go-git/v5/plumbing/filemode"
	"github.com/go-git/go-git/v5/plumbing/format/diff"
	"github.com/go-git/go-git/v5/plumbing/object"
	"go.lindenii.runxiyu.org/forge/misc"
)

// usableFilePatch is a [diff.FilePatch] that is structured in a way more
// friendly for use in HTML templates.
type usableFilePatch struct {
	From   diff.File
	To     diff.File
	Chunks []usableChunk
}

// usableChunk is a [diff.Chunk] that is structured in a way more friendly for
// use in HTML templates.
type usableChunk struct {
	Operation diff.Operation
	Content   string
}

func httpHandleRepoCommit(writer http.ResponseWriter, request *http.Request, params map[string]any) {
	var repo *git.Repository
	var commitIDStrSpec, commitIDStrSpecNoSuffix string
	var commitID plumbing.Hash
	var parentCommitHash plumbing.Hash
	var commitObj *object.Commit
	var commitIDStr string
	var err error
	var patch *object.Patch

	repo, commitIDStrSpec = params["repo"].(*git.Repository), params["commit_id"].(string)

	commitIDStrSpecNoSuffix = strings.TrimSuffix(commitIDStrSpec, ".patch")
	commitID = plumbing.NewHash(commitIDStrSpecNoSuffix)
	if commitObj, err = repo.CommitObject(commitID); err != nil {
		errorPage500(writer, params, "Error getting commit object: "+err.Error())
		return
	}
	if commitIDStrSpecNoSuffix != commitIDStrSpec {
		var patchStr string
		if patchStr, err = fmtCommitPatch(commitObj); err != nil {
			errorPage500(writer, params, "Error formatting patch: "+err.Error())
			return
		}
		fmt.Fprintln(writer, patchStr)
		return
	}
	commitIDStr = commitObj.Hash.String()

	if commitIDStr != commitIDStrSpec {
		http.Redirect(writer, request, commitIDStr, http.StatusSeeOther)
		return
	}

	params["commit_object"] = commitObj
	params["commit_id"] = commitIDStr

	parentCommitHash, patch, err = commitToPatch(commitObj)
	if err != nil {
		errorPage500(writer, params, "Error getting patch from commit: "+err.Error())
		return
	}
	params["parent_commit_hash"] = parentCommitHash.String()
	params["patch"] = patch

	params["file_patches"] = makeUsableFilePatches(patch)

	renderTemplate(writer, "repo_commit", params)
}

type fakeDiffFile struct {
	hash plumbing.Hash
	mode filemode.FileMode
	path string
}

func (f fakeDiffFile) Hash() plumbing.Hash {
	return f.hash
}

func (f fakeDiffFile) Mode() filemode.FileMode {
	return f.mode
}

func (f fakeDiffFile) Path() string {
	return f.path
}

var nullFakeDiffFile = fakeDiffFile{
var nullFakeDiffFile = fakeDiffFile{ //nolint:gochecknoglobals
	hash: plumbing.NewHash("0000000000000000000000000000000000000000"),
	mode: misc.FirstOrPanic(filemode.New("100644")),
	path: "",
}

func makeUsableFilePatches(patch diff.Patch) (usableFilePatches []usableFilePatch) {
	// TODO: Remove unnecessary context
	// TODO: Prepend "+"/"-"/" " instead of solely distinguishing based on color

	for _, filePatch := range patch.FilePatches() {
		var fromFile, toFile diff.File
		var ufp usableFilePatch
		chunks := []usableChunk{}

		fromFile, toFile = filePatch.Files()
		if fromFile == nil {
			fromFile = nullFakeDiffFile
		}
		if toFile == nil {
			toFile = nullFakeDiffFile
		}
		for _, chunk := range filePatch.Chunks() {
			var content string

			content = chunk.Content()
			if len(content) > 0 && content[0] == '\n' {
				content = "\n" + content
			} // Horrible hack to fix how browsers newlines that immediately proceed <pre>
			chunks = append(chunks, usableChunk{
				Operation: chunk.Type(),
				Content:   content,
			})
		}
		ufp = usableFilePatch{
			Chunks: chunks,
			From:   fromFile,
			To:     toFile,
		}
		usableFilePatches = append(usableFilePatches, ufp)
	}
	return
}
// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>

package main

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

	"github.com/jackc/pgx/v5"
	"go.lindenii.runxiyu.org/forge/misc"
)

// ServeHTTP handles all incoming HTTP requests and routes them to the correct
// location.
//
// TODO: This function is way too large.
func (s *server) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
	var remoteAddr string
	if s.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
	}
	slog.Info("incoming http", "addr", remoteAddr, "method", request.Method, "uri", request.RequestURI)

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

	if segments, _, err = misc.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
	params["global"] = s.globalData
	var userID int // 0 for none
	userID, params["username"], err = s.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 {
		s.httpHandleIndex(writer, request, params)
		return
	}

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

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

	if segments[0] == "-" {
		switch segments[1] {
		case "login":
			s.httpHandleLogin(writer, request, params)
			return
		case "users":
			httpHandleUsers(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 misc.RedirectDir(writer, request) {
			return
		}
		s.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 = s.httpHandleRepoInfo(writer, request, params); err != nil {
						errorPage500(writer, params, err.Error())
					}
					return
				case "git-upload-pack":
					if err = s.httpHandleUploadPack(writer, request, params); err != nil {
						errorPage500(writer, params, err.Error())
					}
					return
				}
			}

			if params["ref_type"], params["ref_name"], err = misc.GetParamRefTypeName(request); err != nil {
				if errors.Is(err, misc.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 = s.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] + "@" + s.config.LMTP.Domain
			params["http_clone_url"] = s.genHTTPRemoteURL(groupPath, moduleName)
			params["ssh_clone_url"] = s.genSSHRemoteURL(groupPath, moduleName)

			if len(segments) == sepIndex+3 {
				if misc.RedirectDir(writer, request) {
					return
				}
				s.httpHandleRepoIndex(writer, request, params)
				return
			}

			repoFeature := segments[sepIndex+3]
			switch repoFeature {
			case "tree":
				if misc.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 && misc.RedirectDir(writer, request) {
					return
				}
				s.httpHandleRepoTree(writer, request, params)
			case "branches":
				if misc.RedirectDir(writer, request) {
					return
				}
				s.httpHandleRepoBranches(writer, request, params)
				return
			case "raw":
				if misc.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 && misc.RedirectDir(writer, request) {
					return
				}
				s.httpHandleRepoRaw(writer, request, params)
			case "log":
				if len(segments) > sepIndex+4 {
					errorPage400(writer, params, "Too many parameters")
					return
				}
				if misc.RedirectDir(writer, request) {
					return
				}
				httpHandleRepoLog(writer, request, params)
			case "commit":
				if len(segments) != sepIndex+5 {
					errorPage400(writer, params, "Incorrect number of parameters")
					return
				}
				if misc.RedirectNoDir(writer, request) {
					return
				}
				params["commit_id"] = segments[sepIndex+4]
				httpHandleRepoCommit(writer, request, params)
			case "contrib":
				if misc.RedirectDir(writer, request) {
					return
				}
				switch len(segments) {
				case sepIndex + 4:
					s.httpHandleRepoContribIndex(writer, request, params)
				case sepIndex + 5:
					params["mr_id"] = segments[sepIndex+4]
					s.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>

package main

import (
	"errors"
	"flag"
	"io/fs"
	"log"
	"log/slog"
	"net"
	"net/http"
	"os"
	"os/exec"
	"syscall"
	"time"
)

func main() {
	configPath := flag.String(
		"config",
		"/etc/lindenii/forge.scfg",
		"path to configuration file",
	)
	flag.Parse()

	s := server{}

	s.sourceHandler = http.StripPrefix(
		"/-/source/",
		http.FileServer(http.FS(embeddedSourceFS)),
	)
	staticFS, err := fs.Sub(embeddedResourcesFS, "static")
	if err != nil {
		panic(err)
	}
	s.staticHandler = http.StripPrefix("/-/static/", http.FileServer(http.FS(staticFS)))
	s.globalData = map[string]any{
		"server_public_key_string":      &s.serverPubkeyString,
		"server_public_key_fingerprint": &s.serverPubkeyFP,
		"forge_version":                 VERSION,
		// Some other ones are populated after config parsing
	}

	if err := s.loadConfig(*configPath); err != nil {
		slog.Error("loading configuration", "error", err)
		os.Exit(1)
	}
	if err := s.deployHooks(); err != nil {
		slog.Error("deploying hooks", "error", err)
		os.Exit(1)
	}
	if err := loadTemplates(); err != nil {
		slog.Error("loading templates", "error", err)
		os.Exit(1)
	}
	if err := s.deployGit2D(); err != nil {
		slog.Error("deploying git2d", "error", err)
		os.Exit(1)
	}

	// Launch Git2D
	go func() {
		cmd := exec.Command(s.config.Git.DaemonPath, s.config.Git.Socket) //#nosec G204
		cmd.Stderr = log.Writer()
		cmd.Stdout = log.Writer()
		if err := cmd.Run(); err != nil {
			panic(err)
		}
	}()

	// UNIX socket listener for hooks
	{
		hooksListener, err := net.Listen("unix", s.config.Hooks.Socket)
		if errors.Is(err, syscall.EADDRINUSE) {
			slog.Warn("removing existing socket", "path", s.config.Hooks.Socket)
			if err = syscall.Unlink(s.config.Hooks.Socket); err != nil {
				slog.Error("removing existing socket", "path", s.config.Hooks.Socket, "error", err)
				os.Exit(1)
			}
			if hooksListener, err = net.Listen("unix", s.config.Hooks.Socket); err != nil {
				slog.Error("listening hooks", "error", err)
				os.Exit(1)
			}
		} else if err != nil {
			slog.Error("listening hooks", "error", err)
			os.Exit(1)
		}
		slog.Info("listening hooks on unix", "path", s.config.Hooks.Socket)
		go func() {
			if err = s.serveGitHooks(hooksListener); err != nil {
				slog.Error("serving hooks", "error", err)
				os.Exit(1)
			}
		}()
	}

	// UNIX socket listener for LMTP
	{
		lmtpListener, err := net.Listen("unix", s.config.LMTP.Socket)
		if errors.Is(err, syscall.EADDRINUSE) {
			slog.Warn("removing existing socket", "path", s.config.LMTP.Socket)
			if err = syscall.Unlink(s.config.LMTP.Socket); err != nil {
				slog.Error("removing existing socket", "path", s.config.LMTP.Socket, "error", err)
				os.Exit(1)
			}
			if lmtpListener, err = net.Listen("unix", s.config.LMTP.Socket); err != nil {
				slog.Error("listening LMTP", "error", err)
				os.Exit(1)
			}
		} else if err != nil {
			slog.Error("listening LMTP", "error", err)
			os.Exit(1)
		}
		slog.Info("listening LMTP on unix", "path", s.config.LMTP.Socket)
		go func() {
			if err = s.serveLMTP(lmtpListener); err != nil {
				slog.Error("serving LMTP", "error", err)
				os.Exit(1)
			}
		}()
	}

	// SSH listener
	{
		sshListener, err := net.Listen(s.config.SSH.Net, s.config.SSH.Addr)
		if errors.Is(err, syscall.EADDRINUSE) && s.config.SSH.Net == "unix" {
			slog.Warn("removing existing socket", "path", s.config.SSH.Addr)
			if err = syscall.Unlink(s.config.SSH.Addr); err != nil {
				slog.Error("removing existing socket", "path", s.config.SSH.Addr, "error", err)
				os.Exit(1)
			}
			if sshListener, err = net.Listen(s.config.SSH.Net, s.config.SSH.Addr); err != nil {
				slog.Error("listening SSH", "error", err)
				os.Exit(1)
			}
		} else if err != nil {
			slog.Error("listening SSH", "error", err)
			os.Exit(1)
		}
		slog.Info("listening SSH on", "net", s.config.SSH.Net, "addr", s.config.SSH.Addr)
		go func() {
			if err = s.serveSSH(sshListener); err != nil {
				slog.Error("serving SSH", "error", err)
				os.Exit(1)
			}
		}()
	}

	// HTTP listener
	{
		httpListener, err := net.Listen(s.config.HTTP.Net, s.config.HTTP.Addr)
		if errors.Is(err, syscall.EADDRINUSE) && s.config.HTTP.Net == "unix" {
			slog.Warn("removing existing socket", "path", s.config.HTTP.Addr)
			if err = syscall.Unlink(s.config.HTTP.Addr); err != nil {
				slog.Error("removing existing socket", "path", s.config.HTTP.Addr, "error", err)
				os.Exit(1)
			}
			if httpListener, err = net.Listen(s.config.HTTP.Net, s.config.HTTP.Addr); err != nil {
				slog.Error("listening HTTP", "error", err)
				os.Exit(1)
			}
		} else if err != nil {
			slog.Error("listening HTTP", "error", err)
			os.Exit(1)
		}
		server := http.Server{
			Handler:      &s,
			ReadTimeout:  time.Duration(s.config.HTTP.ReadTimeout) * time.Second,
			WriteTimeout: time.Duration(s.config.HTTP.ReadTimeout) * time.Second,
			IdleTimeout:  time.Duration(s.config.HTTP.ReadTimeout) * time.Second,
		} //exhaustruct:ignore
		slog.Info("listening HTTP on", "net", s.config.HTTP.Net, "addr", s.config.HTTP.Addr)
		go func() {
			if err = server.Serve(httpListener); err != nil && !errors.Is(err, http.ErrServerClosed) {
				slog.Error("serving HTTP", "error", err)
				os.Exit(1)
			}
		}()
	}

	// IRC bot
	go s.ircBotLoop()

	select {}
}
// 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"

	"github.com/tdewolff/minify/v2"
	"github.com/tdewolff/minify/v2/html"
	"go.lindenii.runxiyu.org/forge/misc"
)

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

//go:embed templates/* static/*
//go:embed hookc/hookc git2d/git2d
var embeddedResourcesFS embed.FS

var templates *template.Template
var templates *template.Template //nolint:gochecknoglobals

// 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(embeddedResourcesFS, "templates", func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if !d.IsDir() {
			content, err := fs.ReadFile(embeddedResourcesFS, path)
			if err != nil {
				return err
			}

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

			_, err = templates.Parse(misc.BytesToString(minified))
			if err != nil {
				return err
			}
		}
		return nil
	})
	return err
}
package main

import (
	"net/http"

	"github.com/jackc/pgx/v5/pgxpool"
	goSSH "golang.org/x/crypto/ssh"
)

type server struct {
	config Config

	// database serves as the primary database handle for this entire application.
	// Transactions or single reads may be used from it. A [pgxpool.Pool] is
	// necessary to safely use pgx concurrently; pgx.Conn, etc. are insufficient.
	database *pgxpool.Pool

	sourceHandler http.Handler
	staticHandler http.Handler

	ircSendBuffered   chan string
	ircSendDirectChan chan errorBack[string]

	// globalData is passed as "global" when rendering HTML templates.
	globalData map[string]any

	serverPubkeyString string
	serverPubkeyFP     string
	serverPubkey       goSSH.PublicKey
}
// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>

package main

import (
	"fmt"
	"log/slog"
	"net"
	"os"
	"strings"

	gliderSSH "github.com/gliderlabs/ssh"
	"go.lindenii.runxiyu.org/forge/ansiec"
	"go.lindenii.runxiyu.org/forge/misc"
	goSSH "golang.org/x/crypto/ssh"
)

var (
	serverPubkeyString string
	serverPubkeyFP     string
	serverPubkey       goSSH.PublicKey
)

// serveSSH serves SSH on a [net.Listener]. The listener should generally be a
// TCP listener, although AF_UNIX SOCK_STREAM listeners may be appropriate in
// rare cases.
func (s *server) serveSSH(listener net.Listener) error {
	var hostKeyBytes []byte
	var hostKey goSSH.Signer
	var err error
	var server *gliderSSH.Server

	if hostKeyBytes, err = os.ReadFile(s.config.SSH.Key); err != nil {
		return err
	}

	if hostKey, err = goSSH.ParsePrivateKey(hostKeyBytes); err != nil {
		return err
	}

	serverPubkey = hostKey.PublicKey()
	serverPubkeyString = misc.BytesToString(goSSH.MarshalAuthorizedKey(serverPubkey))
	serverPubkeyFP = goSSH.FingerprintSHA256(serverPubkey)
	s.serverPubkey = hostKey.PublicKey()
	s.serverPubkeyString = misc.BytesToString(goSSH.MarshalAuthorizedKey(s.serverPubkey))
	s.serverPubkeyFP = goSSH.FingerprintSHA256(s.serverPubkey)

	server = &gliderSSH.Server{
		Handler: func(session gliderSSH.Session) {
			clientPubkey := session.PublicKey()
			var clientPubkeyStr string
			if clientPubkey != nil {
				clientPubkeyStr = strings.TrimSuffix(misc.BytesToString(goSSH.MarshalAuthorizedKey(clientPubkey)), "\n")
			}

			slog.Info("incoming ssh", "addr", session.RemoteAddr().String(), "key", clientPubkeyStr, "command", session.RawCommand())
			fmt.Fprintln(session.Stderr(), ansiec.Blue+"Lindenii Forge "+VERSION+", source at "+strings.TrimSuffix(s.config.HTTP.Root, "/")+"/-/source/"+ansiec.Reset+"\r")

			cmd := session.Command()

			if len(cmd) < 2 {
				fmt.Fprintln(session.Stderr(), "Insufficient arguments\r")
				return
			}

			switch cmd[0] {
			case "git-upload-pack":
				if len(cmd) > 2 {
					fmt.Fprintln(session.Stderr(), "Too many arguments\r")
					return
				}
				err = s.sshHandleUploadPack(session, clientPubkeyStr, cmd[1])
			case "git-receive-pack":
				if len(cmd) > 2 {
					fmt.Fprintln(session.Stderr(), "Too many arguments\r")
					return
				}
				err = s.sshHandleRecvPack(session, clientPubkeyStr, cmd[1])
			default:
				fmt.Fprintln(session.Stderr(), "Unsupported command: "+cmd[0]+"\r")
				return
			}
			if err != nil {
				fmt.Fprintln(session.Stderr(), err.Error())
				return
			}
		},
		PublicKeyHandler:           func(_ gliderSSH.Context, _ gliderSSH.PublicKey) bool { return true },
		KeyboardInteractiveHandler: func(_ gliderSSH.Context, _ goSSH.KeyboardInteractiveChallenge) bool { return true },
		// It is intentional that we do not check any credentials and accept all connections.
		// This allows all users to connect and clone repositories. However, the public key
		// is passed to handlers, so e.g. the push handler could check the key and reject the
		// push if it needs to.
	} //exhaustruct:ignore

	server.AddHostKey(hostKey)

	if err = server.Serve(listener); err != nil {
		slog.Error("error serving SSH", "error", err.Error())
		os.Exit(1)
	}

	return nil
}
package main

var VERSION = "unknown"
var VERSION = "unknown" //nolint:gochecknoglobals