Lindenii Project Forge
Login

server

Lindenii Forge’s main backend daemon
Commit info
ID
ed069956471b57d408ecfe2b415eb9dcf08e6c53
Author
Runxi Yu <me@runxiyu.org>
Author date
Sat, 05 Apr 2025 21:23:00 +0800
Committer
Runxi Yu <me@runxiyu.org>
Committer date
Sat, 05 Apr 2025 21:23:00 +0800
Actions
Export symbols from database.go
// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>

package forge

import (
	"context"

	"github.com/jackc/pgx/v5"
)

// TODO: All database handling logic in all request handlers must be revamped.
// We must ensure that each request has all logic in one transaction (subject
// to exceptions if appropriate) so they get a consistent view of the database
// at a single point. A failure to do so may cause things as serious as
// privilege escalation.

// queryNameDesc is a helper function that executes a query and returns a
// QueryNameDesc is a helper function that executes a query and returns a
// list of nameDesc results. The query must return two string arguments, i.e. a
// name and a description.
func (s *Server) queryNameDesc(ctx context.Context, query string, args ...any) (result []nameDesc, err error) {
func (s *Server) QueryNameDesc(ctx context.Context, query string, args ...any) (result []NameDesc, err error) {
	var rows pgx.Rows

	if rows, err = s.Database.Query(ctx, query, args...); err != nil {
		return nil, err
	}
	defer rows.Close()

	for rows.Next() {
		var name, description string
		if err = rows.Scan(&name, &description); err != nil {
			return nil, err
		}
		result = append(result, nameDesc{name, description})
		result = append(result, NameDesc{name, description})
	}
	return result, rows.Err()
}

// nameDesc holds a name and a description.
type nameDesc struct {
// NameDesc holds a name and a description.
type NameDesc struct {
	Name        string
	Description string
}
// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>

package forge

import (
	"errors"
	"net/http"
	"path/filepath"
	"strconv"

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

// httpHandleGroupIndex provides index pages for groups, which includes a list
// of its subgroups and repos, as well as a form for group maintainers to
// create repos.
func (s *Server) httpHandleGroupIndex(writer http.ResponseWriter, request *http.Request, params map[string]any) {
	var groupPath []string
	var repos []nameDesc
	var subgroups []nameDesc
	var repos []NameDesc
	var subgroups []NameDesc
	var err error
	var groupID int
	var groupDesc string

	groupPath = params["group_path"].([]string)

	// The group itself
	err = s.Database.QueryRow(request.Context(), `
		WITH RECURSIVE group_path_cte AS (
			SELECT
				id,
				parent_group,
				name,
				1 AS depth
			FROM groups
			WHERE name = ($1::text[])[1]
				AND parent_group IS NULL

			UNION ALL

			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 c.id, COALESCE(g.description, '')
		FROM group_path_cte c
		JOIN groups g ON g.id = c.id
		WHERE c.depth = cardinality($1::text[])
	`,
		pgtype.FlatArray[string](groupPath),
	).Scan(&groupID, &groupDesc)

	if errors.Is(err, pgx.ErrNoRows) {
		errorPage404(writer, params)
		return
	} else if err != nil {
		errorPage500(writer, params, "Error getting group: "+err.Error())
		return
	}

	// ACL
	var count int
	err = s.Database.QueryRow(request.Context(), `
		SELECT COUNT(*)
		FROM user_group_roles
		WHERE user_id = $1
			AND group_id = $2
	`, params["user_id"].(int), groupID).Scan(&count)
	if err != nil {
		errorPage500(writer, params, "Error checking access: "+err.Error())
		return
	}
	directAccess := (count > 0)

	if request.Method == http.MethodPost {
		if !directAccess {
			errorPage403(writer, params, "You do not have direct access to this group")
			return
		}

		repoName := request.FormValue("repo_name")
		repoDesc := request.FormValue("repo_desc")
		contribReq := request.FormValue("repo_contrib")
		if repoName == "" {
			errorPage400(writer, params, "Repo name is required")
			return
		}

		var newRepoID int
		err := s.Database.QueryRow(
			request.Context(),
			`INSERT INTO repos (name, description, group_id, contrib_requirements)
	 VALUES ($1, $2, $3, $4)
	 RETURNING id`,
			repoName,
			repoDesc,
			groupID,
			contribReq,
		).Scan(&newRepoID)
		if err != nil {
			errorPage500(writer, params, "Error creating repo: "+err.Error())
			return
		}

		filePath := filepath.Join(s.Config.Git.RepoDir, strconv.Itoa(newRepoID)+".git")

		_, err = s.Database.Exec(
			request.Context(),
			`UPDATE repos
	 SET filesystem_path = $1
	 WHERE id = $2`,
			filePath,
			newRepoID,
		)
		if err != nil {
			errorPage500(writer, params, "Error updating repo path: "+err.Error())
			return
		}

		if err = s.gitInit(filePath); err != nil {
			errorPage500(writer, params, "Error initializing repo: "+err.Error())
			return
		}

		misc.RedirectUnconditionally(writer, request)
		return
	}

	// Repos
	var rows pgx.Rows
	rows, err = s.Database.Query(request.Context(), `
		SELECT name, COALESCE(description, '')
		FROM repos
		WHERE group_id = $1
	`, groupID)
	if err != nil {
		errorPage500(writer, params, "Error getting repos: "+err.Error())
		return
	}
	defer rows.Close()

	for rows.Next() {
		var name, description string
		if err = rows.Scan(&name, &description); err != nil {
			errorPage500(writer, params, "Error getting repos: "+err.Error())
			return
		}
		repos = append(repos, nameDesc{name, description})
		repos = append(repos, NameDesc{name, description})
	}
	if err = rows.Err(); err != nil {
		errorPage500(writer, params, "Error getting repos: "+err.Error())
		return
	}

	// Subgroups
	rows, err = s.Database.Query(request.Context(), `
		SELECT name, COALESCE(description, '')
		FROM groups
		WHERE parent_group = $1
	`, groupID)
	if err != nil {
		errorPage500(writer, params, "Error getting subgroups: "+err.Error())
		return
	}
	defer rows.Close()

	for rows.Next() {
		var name, description string
		if err = rows.Scan(&name, &description); err != nil {
			errorPage500(writer, params, "Error getting subgroups: "+err.Error())
			return
		}
		subgroups = append(subgroups, nameDesc{name, description})
		subgroups = append(subgroups, NameDesc{name, description})
	}
	if err = rows.Err(); err != nil {
		errorPage500(writer, params, "Error getting subgroups: "+err.Error())
		return
	}

	params["repos"] = repos
	params["subgroups"] = subgroups
	params["description"] = groupDesc
	params["direct_access"] = directAccess

	renderTemplate(writer, "group", params)
}
// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>

package forge

import (
	"net/http"
	"runtime"

	"github.com/dustin/go-humanize"
)

// httpHandleIndex provides the main index page which includes a list of groups
// and some global information such as SSH keys.
func (s *Server) httpHandleIndex(writer http.ResponseWriter, request *http.Request, params map[string]any) {
	var err error
	var groups []nameDesc
	var groups []NameDesc

	groups, err = s.queryNameDesc(request.Context(), "SELECT name, COALESCE(description, '') FROM groups WHERE parent_group IS NULL")
	groups, err = s.QueryNameDesc(request.Context(), "SELECT name, COALESCE(description, '') FROM groups WHERE parent_group IS NULL")
	if err != nil {
		errorPage500(writer, params, "Error querying groups: "+err.Error())
		return
	}
	params["groups"] = groups

	// Memory currently allocated
	memstats := runtime.MemStats{} //exhaustruct:ignore
	runtime.ReadMemStats(&memstats)
	params["mem"] = humanize.IBytes(memstats.Alloc)
	renderTemplate(writer, "index", params)
}