Lindenii Project Forge
Login

server

Lindenii Forge’s main backend daemon
Commit info
ID
79a85ae7cbec23d9590566a3e770d8e216ea3af3
Author
Runxi Yu <me@runxiyu.org>
Author date
Sat, 05 Apr 2025 13:47:00 +0800
Committer
Runxi Yu <me@runxiyu.org>
Committer date
Sat, 05 Apr 2025 13:47:00 +0800
Actions
Be a bit more careful handling size integer overflows and such
// 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 openRepo(ctx context.Context, groupPath []string, repoName string) (repo *git.Repository, description string, repoID int, fsPath string, err error) {
	err = 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      int64
	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()

		if displayEntry.Size, err = tree.Size(entry.Name); err != nil {
			displayEntry.Size = 0
		}
		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
// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>

package main

import (
	"errors"
	"fmt"
	"html/template"
	"io"
	"net"
	"net/http"
	"strings"

	"git.sr.ht/~sircmpwn/go-bare"
)

// httpHandleRepoRaw serves raw files, or directory listings that point to raw
// files.
func httpHandleRepoRaw(writer http.ResponseWriter, request *http.Request, params map[string]any) {
	repoName := params["repo_name"].(string)
	groupPath := params["group_path"].([]string)
	rawPathSpec := params["rest"].(string)
	pathSpec := strings.TrimSuffix(rawPathSpec, "/")
	params["path_spec"] = pathSpec

	_, repoPath, _, _, _, _, _ := getRepoInfo(request.Context(), groupPath, repoName, "")

	conn, err := net.Dial("unix", config.Git.Socket)
	if err != nil {
		errorPage500(writer, params, "git2d connection failed: "+err.Error())
		return
	}
	defer conn.Close()

	brWriter := bare.NewWriter(conn)
	brReader := bare.NewReader(conn)

	if err := brWriter.WriteData([]byte(repoPath)); err != nil {
		errorPage500(writer, params, "sending repo path failed: "+err.Error())
		return
	}
	if err := brWriter.WriteUint(2); err != nil {
		errorPage500(writer, params, "sending command failed: "+err.Error())
		return
	}
	if err := brWriter.WriteData([]byte(pathSpec)); err != nil {
		errorPage500(writer, params, "sending path failed: "+err.Error())
		return
	}

	status, err := brReader.ReadUint()
	if err != nil {
		errorPage500(writer, params, "reading status failed: "+err.Error())
		return
	}

	switch status {
	case 0:
		kind, err := brReader.ReadUint()
		if err != nil {
			errorPage500(writer, params, "reading object kind failed: "+err.Error())
			return
		}

		switch kind {
		case 1:
			// Tree
			if redirectDir(writer, request) {
				return
			}
			count, err := brReader.ReadUint()
			if err != nil {
				errorPage500(writer, params, "reading entry count failed: "+err.Error())
				return
			}

			files := make([]displayTreeEntry, 0, count)
			for range count {
				typeCode, err := brReader.ReadUint()
				if err != nil {
					errorPage500(writer, params, "error reading entry type: "+err.Error())
					return
				}
				mode, err := brReader.ReadUint()
				if err != nil {
					errorPage500(writer, params, "error reading entry mode: "+err.Error())
					return
				}
				size, err := brReader.ReadUint()
				if err != nil {
					errorPage500(writer, params, "error reading entry size: "+err.Error())
					return
				}
				name, err := brReader.ReadData()
				if err != nil {
					errorPage500(writer, params, "error reading entry name: "+err.Error())
					return
				}
				files = append(files, displayTreeEntry{
					Name:      string(name),
					Mode:      fmt.Sprintf("%06o", mode),
					Size:      int64(size),
					Size:      size,
					IsFile:    typeCode == 2,
					IsSubtree: typeCode == 1,
				})
			}

			params["files"] = files
			params["readme_filename"] = "README.md"
			params["readme"] = template.HTML("<p>README rendering here is WIP again</p>") // TODO

			renderTemplate(writer, "repo_raw_dir", params)

		case 2:
			// Blob
			if redirectNoDir(writer, request) {
				return
			}
			content, err := brReader.ReadData()
			if err != nil && !errors.Is(err, io.EOF) {
				errorPage500(writer, params, "error reading blob content: "+err.Error())
				return
			}
			writer.Header().Set("Content-Type", "application/octet-stream")
			fmt.Fprint(writer, string(content))

		default:
			errorPage500(writer, params, fmt.Sprintf("unknown object kind: %d", kind))
		}

	case 3:
		errorPage500(writer, params, "path not found: "+pathSpec)

	default:
		errorPage500(writer, params, fmt.Sprintf("unknown status code: %d", status))
	}
}
// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>

package main

import (
	"errors"
	"fmt"
	"html/template"
	"io"
	"net"
	"net/http"
	"strings"

	"git.sr.ht/~sircmpwn/go-bare"
)

// httpHandleRepoTree provides a friendly, syntax-highlighted view of
// individual files, and provides directory views that link to these files.
//
// TODO: Do not highlight files that are too large.
func httpHandleRepoTree(writer http.ResponseWriter, request *http.Request, params map[string]any) {
	repoName := params["repo_name"].(string)
	groupPath := params["group_path"].([]string)
	rawPathSpec := params["rest"].(string)
	pathSpec := strings.TrimSuffix(rawPathSpec, "/")
	params["path_spec"] = pathSpec

	_, repoPath, _, _, _, _, _ := getRepoInfo(request.Context(), groupPath, repoName, "")

	conn, err := net.Dial("unix", config.Git.Socket)
	if err != nil {
		errorPage500(writer, params, "git2d connection failed: "+err.Error())
		return
	}
	defer conn.Close()

	brWriter := bare.NewWriter(conn)
	brReader := bare.NewReader(conn)

	if err := brWriter.WriteData([]byte(repoPath)); err != nil {
		errorPage500(writer, params, "sending repo path failed: "+err.Error())
		return
	}
	if err := brWriter.WriteUint(2); err != nil {
		errorPage500(writer, params, "sending command failed: "+err.Error())
		return
	}
	if err := brWriter.WriteData([]byte(pathSpec)); err != nil {
		errorPage500(writer, params, "sending path failed: "+err.Error())
		return
	}

	status, err := brReader.ReadUint()
	if err != nil {
		errorPage500(writer, params, "reading status failed: "+err.Error())
		return
	}

	switch status {
	case 0:
		kind, err := brReader.ReadUint()
		if err != nil {
			errorPage500(writer, params, "reading object kind failed: "+err.Error())
			return
		}

		switch kind {
		case 1:
			// Tree
			count, err := brReader.ReadUint()
			if err != nil {
				errorPage500(writer, params, "reading entry count failed: "+err.Error())
				return
			}
			files := make([]displayTreeEntry, 0, count)
			for range count {
				typeCode, err := brReader.ReadUint()
				if err != nil {
					errorPage500(writer, params, "error reading entry type: "+err.Error())
					return
				}
				mode, err := brReader.ReadUint()
				if err != nil {
					errorPage500(writer, params, "error reading entry mode: "+err.Error())
					return
				}
				size, err := brReader.ReadUint()
				if err != nil {
					errorPage500(writer, params, "error reading entry size: "+err.Error())
					return
				}
				name, err := brReader.ReadData()
				if err != nil {
					errorPage500(writer, params, "error reading entry name: "+err.Error())
					return
				}

				files = append(files, displayTreeEntry{
					Name:      string(name),
					Mode:      fmt.Sprintf("%06o", mode),
					Size:      int64(size),
					Size:      size,
					IsFile:    typeCode == 2,
					IsSubtree: typeCode == 1,
				})
			}
			params["files"] = files
			params["readme_filename"] = "README.md"
			params["readme"] = template.HTML("<p>README rendering here is WIP again</p>") // TODO
			renderTemplate(writer, "repo_tree_dir", params)

		case 2:
			// Blob
			content, err := brReader.ReadData()
			if err != nil && !errors.Is(err, io.EOF) {
				errorPage500(writer, params, "error reading file content: "+err.Error())
				return
			}
			rendered := renderHighlightedFile(pathSpec, string(content))
			params["file_contents"] = rendered
			renderTemplate(writer, "repo_tree_file", params)

		default:
			errorPage500(writer, params, fmt.Sprintf("unknown kind: %d", kind))
			return
		}

	case 3:
		errorPage500(writer, params, "path not found: "+pathSpec)
		return

	default:
		errorPage500(writer, params, fmt.Sprintf("unknown status code: %d", status))
	}
}