Lindenii Project Forge
Login

server

Lindenii Forge’s main backend daemon
Commit info
ID
01e4fd482ebcc827d3c76c00910529abbf666454
Author
Runxi Yu <me@runxiyu.org>
Author date
Sun, 14 Sep 2025 22:28:12 +0800
Committer
Runxi Yu <me@runxiyu.org>
Committer date
Sun, 14 Sep 2025 22:28:12 +0800
Actions
Add basic mailing lists
http {
	# What network transport should we listen on?
	# Examples: tcp tcp4 tcp6 unix
	net tcp

	# What address to listen on?
	# Examples for net tcp*: 127.0.0.1:8080 :80
	# Example for unix: /var/run/lindenii/forge/http.sock
	addr :8080

	# How many seconds should cookies be remembered before they are purged?
	cookie_expiry 604800

	# What is the canonical URL of the web root?
	root https://forge.example.org

	# General HTTP server context timeout settings. It's recommended to
	# set them slightly higher than usual as Git operations over large
	# repos may take a long time.
	read_timeout 120
	write_timeout 1800
	idle_timeout 120

	# Are we running behind a reverse proxy? If so, we will trust
	# X-Forwarded-For headers.
	reverse_proxy true
}

irc {
	tls true
	net tcp
	addr irc.runxiyu.org:6697
	sendq 6000
	nick forge-test
	user forge
	gecos "Lindenii Forge Test"
}

git {
	# Where should newly-created Git repositories be stored?
	repo_dir /var/lib/lindenii/forge/repos

	# Where should git2d listen on?
	socket /var/run/lindenii/forge/git2d.sock

	# Where should we put git2d?
	daemon_path /usr/libexec/lindenii/forge/git2d
}

ssh {
	# What network transport should we listen on?
	# This should be "tcp" in almost all cases.
	net tcp

	# What address to listen on?
	addr :22

	# What is the path to the SSH host key? Generate it with ssh-keygen.
	# The key must have an empty password.
	key /etc/lindenii/ssh_host_ed25519_key

	# What is the canonical SSH URL?
	root ssh://forge.example.org
}

general {
	title "Test Forge"
}

db {
	# What type of database are we connecting to?
	# Currently only "postgres" is supported.
	type postgres

	# What is the connection string?
	conn postgresql:///lindenii-forge?host=/var/run/postgresql
}

hooks {
	# On which UNIX domain socket should we listen for hook callbacks on?
	socket /var/run/lindenii/forge/hooks.sock

	# Where should hook executables be put?
	execs /usr/libexec/lindenii/forge/hooks
}

lmtp {
	# On which UNIX domain socket should we listen for LMTP on?
	socket /var/run/lindenii/forge/lmtp.sock

	# What's the maximum acceptable message size?
	max_size 1000000

	# What is our domainpart?
	domain forge.example.org

	# General timeouts
	read_timeout 300
	write_timeout 300
}

smtp {
	# Outbound SMTP relay configuration for mailing list delivery

	# What network transport to use (e.g. tcp, tcp4, tcp6)?
	net tcp

	# Relay address
	addr 127.0.0.1:25

	hello_name forge.example.org

	# One of "plain", "tls", "starttls".
	transport plain

	# Allow invalid certs
	tls_insecure false

	# SMTP auth credentials
	username ""
	password ""
}

pprof {
	# What network to listen on for pprof?
	net tcp

	# What address to listen on?
	addr localhost:28471
}
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright (c) 2025 Drew Devault <https://drewdevault.com>

package bare

import (
	"fmt"
	"reflect"
)

// Any type which is a union member must implement this interface. You must
// also call RegisterUnion for go-bare to marshal or unmarshal messages which
// utilize your union type.
type Union interface {
	IsUnion()
}

type UnionTags struct {
	iface reflect.Type
	tags  map[reflect.Type]uint64
	types map[uint64]reflect.Type
}

var unionInterface = reflect.TypeOf((*Union)(nil)).Elem()
var unionRegistry map[reflect.Type]*UnionTags
var (
	unionInterface = reflect.TypeOf((*Union)(nil)).Elem()
	unionRegistry  map[reflect.Type]*UnionTags
)

func init() {
	unionRegistry = make(map[reflect.Type]*UnionTags)
}

// Registers a union type in this context. Pass the union interface and the
// list of types associated with it, sorted ascending by their union tag.
func RegisterUnion(iface interface{}) *UnionTags {
	ity := reflect.TypeOf(iface).Elem()
	if _, ok := unionRegistry[ity]; ok {
		panic(fmt.Errorf("Type %s has already been registered", ity.Name()))
	}

	if !ity.Implements(reflect.TypeOf((*Union)(nil)).Elem()) {
		panic(fmt.Errorf("Type %s does not implement bare.Union", ity.Name()))
	}

	utypes := &UnionTags{
		iface: ity,
		tags:  make(map[reflect.Type]uint64),
		types: make(map[uint64]reflect.Type),
	}
	unionRegistry[ity] = utypes
	return utypes
}

func (ut *UnionTags) Member(t interface{}, tag uint64) *UnionTags {
	ty := reflect.TypeOf(t)
	if !ty.AssignableTo(ut.iface) {
		panic(fmt.Errorf("Type %s does not implement interface %s",
			ty.Name(), ut.iface.Name()))
	}
	if _, ok := ut.tags[ty]; ok {
		panic(fmt.Errorf("Type %s is already registered for union %s",
			ty.Name(), ut.iface.Name()))
	}
	if _, ok := ut.types[tag]; ok {
		panic(fmt.Errorf("Tag %d is already registered for union %s",
			tag, ut.iface.Name()))
	}
	ut.tags[ty] = tag
	ut.types[tag] = ty
	return ut
}

func (ut *UnionTags) TagFor(v interface{}) (uint64, bool) {
	tag, ok := ut.tags[reflect.TypeOf(v)]
	return tag, ok
}

func (ut *UnionTags) TypeFor(tag uint64) (reflect.Type, bool) {
	t, ok := ut.types[tag]
	return t, ok
}
// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>

package unsorted

import (
	"bufio"
	"errors"
	"log/slog"
	"os"

	"go.lindenii.runxiyu.org/forge/forged/internal/database"
	"go.lindenii.runxiyu.org/forge/forged/internal/irc"
	"go.lindenii.runxiyu.org/forge/forged/internal/scfg"
)

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"`
	SMTP struct {
		Net         string `scfg:"net"`
		Addr        string `scfg:"addr"`
		HelloName   string `scfg:"hello_name"`
		Transport   string `scfg:"transport"` // plain, tls, starttls
		TLSInsecure bool   `scfg:"tls_insecure"`
		Username    string `scfg:"username"`
		Password    string `scfg:"password"`
	} `scfg:"smtp"`
	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     irc.Config `scfg:"irc"`
	General struct {
		Title string `scfg:"title"`
	} `scfg:"general"`
	DB struct {
		Type string `scfg:"type"`
		Conn string `scfg:"conn"`
	} `scfg:"db"`
	Pprof struct {
		Net  string `scfg:"net"`
		Addr string `scfg:"addr"`
	} `scfg:"pprof"`
}

// 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.
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
	}
	for _, u := range decoder.UnknownDirectives() {
		slog.Warn("unknown configuration directive", "directive", u)
	}

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

	if s.database, err = database.Open(s.config.DB.Conn); err != nil {
		return err
	}

	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 unsorted

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

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

// 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 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) {
		web.ErrorPage404(s.templates, writer, params)
		return
	} else if err != nil {
		web.ErrorPage500(s.templates, 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 {
		web.ErrorPage500(s.templates, writer, params, "Error checking access: "+err.Error())
		return
	}
	directAccess := (count > 0)

	if request.Method == http.MethodPost {
		if !directAccess {
			web.ErrorPage403(s.templates, 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 == "" {
			web.ErrorPage400(s.templates, writer, params, "Repo name is required")
			return
		}
		switch request.FormValue("op") {
		case "create_repo":
			repoName := request.FormValue("repo_name")
			repoDesc := request.FormValue("repo_desc")
			contribReq := request.FormValue("repo_contrib")
			if repoName == "" {
				web.ErrorPage400(s.templates, 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 {
			web.ErrorPage500(s.templates, writer, params, "Error creating repo: "+err.Error())
			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 {
				web.ErrorPage500(s.templates, writer, params, "Error creating repo: "+err.Error())
				return
			}

		filePath := filepath.Join(s.config.Git.RepoDir, strconv.Itoa(newRepoID)+".git")
			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 {
				web.ErrorPage500(s.templates, writer, params, "Error updating repo path: "+err.Error())
				return
			}

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

		_, err = s.database.Exec(
			request.Context(),
			`UPDATE repos
	 SET filesystem_path = $1
	 WHERE id = $2`,
			filePath,
			newRepoID,
		)
		if err != nil {
			web.ErrorPage500(s.templates, writer, params, "Error updating repo path: "+err.Error())
			misc.RedirectUnconditionally(writer, request)
			return
		}
		case "create_list":
			listName := request.FormValue("list_name")
			listDesc := request.FormValue("list_desc")
			if listName == "" {
				web.ErrorPage400(s.templates, writer, params, "List name is required")
				return
			}

		if err = s.gitInit(filePath); err != nil {
			web.ErrorPage500(s.templates, writer, params, "Error initializing repo: "+err.Error())
			if _, err := s.database.Exec(
				request.Context(),
				`INSERT INTO mailing_lists (name, description, group_id) VALUES ($1, $2, $3)`,
				listName, listDesc, groupID,
			); err != nil {
				web.ErrorPage500(s.templates, writer, params, "Error creating mailing list: "+err.Error())
				return
			}
			misc.RedirectUnconditionally(writer, request)
			return
		default:
			web.ErrorPage400(s.templates, writer, params, "Unknown operation")
			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 {
		web.ErrorPage500(s.templates, 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 {
			web.ErrorPage500(s.templates, writer, params, "Error getting repos: "+err.Error())
			return
		}
		repos = append(repos, nameDesc{name, description})
	}
	if err = rows.Err(); err != nil {
		web.ErrorPage500(s.templates, 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 {
		web.ErrorPage500(s.templates, 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 {
			web.ErrorPage500(s.templates, writer, params, "Error getting subgroups: "+err.Error())
			return
		}
		subgroups = append(subgroups, nameDesc{name, description})
	}
	if err = rows.Err(); err != nil {
		web.ErrorPage500(s.templates, writer, params, "Error getting subgroups: "+err.Error())
		return
	}

	// Mailing lists
	var lists []nameDesc
	{
		var rows2 pgx.Rows
		rows2, err = s.database.Query(request.Context(), `SELECT name, COALESCE(description, '') FROM mailing_lists WHERE group_id = $1`, groupID)
		if err != nil {
			web.ErrorPage500(s.templates, writer, params, "Error getting mailing lists: "+err.Error())
			return
		}
		defer rows2.Close()
		for rows2.Next() {
			var name, description string
			if err = rows2.Scan(&name, &description); err != nil {
				web.ErrorPage500(s.templates, writer, params, "Error getting mailing lists: "+err.Error())
				return
			}
			lists = append(lists, nameDesc{name, description})
		}
		if err = rows2.Err(); err != nil {
			web.ErrorPage500(s.templates, writer, params, "Error getting mailing lists: "+err.Error())
			return
		}
	}

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

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

package unsorted

import (
	"bytes"
	"errors"
	"io"
	"mime"
	"mime/multipart"
	"net/http"
	"net/mail"
	"strconv"
	"strings"
	"time"

	"github.com/emersion/go-message"
	"github.com/jackc/pgx/v5"
	"github.com/microcosm-cc/bluemonday"
	"go.lindenii.runxiyu.org/forge/forged/internal/misc"
	"go.lindenii.runxiyu.org/forge/forged/internal/render"
	"go.lindenii.runxiyu.org/forge/forged/internal/web"
)

// httpHandleMailingListIndex renders the page for a single mailing list.
func (s *Server) httpHandleMailingListIndex(writer http.ResponseWriter, request *http.Request, params map[string]any) {
	groupPath := params["group_path"].([]string)
	listName := params["list_name"].(string)

	groupID, err := s.resolveGroupPath(request.Context(), groupPath)
	if errors.Is(err, pgx.ErrNoRows) {
		web.ErrorPage404(s.templates, writer, params)
		return
	} else if err != nil {
		web.ErrorPage500(s.templates, writer, params, "Error resolving group: "+err.Error())
		return
	}

	var (
		listID    int
		listDesc  string
		emailRows pgx.Rows
		emails    []map[string]any
	)

	if err := s.database.QueryRow(request.Context(),
		`SELECT id, COALESCE(description, '') FROM mailing_lists WHERE group_id = $1 AND name = $2`,
		groupID, listName,
	).Scan(&listID, &listDesc); errors.Is(err, pgx.ErrNoRows) {
		web.ErrorPage404(s.templates, writer, params)
		return
	} else if err != nil {
		web.ErrorPage500(s.templates, writer, params, "Error loading mailing list: "+err.Error())
		return
	}

	emailRows, err = s.database.Query(request.Context(), `SELECT id, title, sender, date FROM mailing_list_emails WHERE list_id = $1 ORDER BY date DESC, id DESC LIMIT 200`, listID)
	if err != nil {
		web.ErrorPage500(s.templates, writer, params, "Error loading list emails: "+err.Error())
		return
	}
	defer emailRows.Close()

	for emailRows.Next() {
		var (
			id            int
			title, sender string
			dateVal       time.Time
		)
		if err := emailRows.Scan(&id, &title, &sender, &dateVal); err != nil {
			web.ErrorPage500(s.templates, writer, params, "Error scanning list emails: "+err.Error())
			return
		}
		emails = append(emails, map[string]any{
			"id":     id,
			"title":  title,
			"sender": sender,
			"date":   dateVal,
		})
	}
	if err := emailRows.Err(); err != nil {
		web.ErrorPage500(s.templates, writer, params, "Error iterating list emails: "+err.Error())
		return
	}

	params["list_name"] = listName
	params["list_description"] = listDesc
	params["list_emails"] = emails

	listURLRoot := "/"
	segments := params["url_segments"].([]string)
	for _, part := range segments[:params["separator_index"].(int)+3] {
		listURLRoot += part + "/"
	}
	params["list_email_address"] = listURLRoot[1:len(listURLRoot)-1] + "@" + s.config.LMTP.Domain

	var count int
	if 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); err != nil {
		web.ErrorPage500(s.templates, writer, params, "Error checking access: "+err.Error())
		return
	}
	params["direct_access"] = (count > 0)

	s.renderTemplate(writer, "mailing_list", params)
}

// httpHandleMailingListRaw serves a raw email by ID from a list.
func (s *Server) httpHandleMailingListRaw(writer http.ResponseWriter, request *http.Request, params map[string]any) {
	groupPath := params["group_path"].([]string)
	listName := params["list_name"].(string)
	idStr := params["email_id"].(string)
	id, err := strconv.Atoi(idStr)
	if err != nil || id <= 0 {
		web.ErrorPage400(s.templates, writer, params, "Invalid email id")
		return
	}

	groupID, err := s.resolveGroupPath(request.Context(), groupPath)
	if errors.Is(err, pgx.ErrNoRows) {
		web.ErrorPage404(s.templates, writer, params)
		return
	} else if err != nil {
		web.ErrorPage500(s.templates, writer, params, "Error resolving group: "+err.Error())
		return
	}

	var listID int
	if err := s.database.QueryRow(request.Context(),
		`SELECT id FROM mailing_lists WHERE group_id = $1 AND name = $2`,
		groupID, listName,
	).Scan(&listID); errors.Is(err, pgx.ErrNoRows) {
		web.ErrorPage404(s.templates, writer, params)
		return
	} else if err != nil {
		web.ErrorPage500(s.templates, writer, params, "Error loading mailing list: "+err.Error())
		return
	}

	var content []byte
	if err := s.database.QueryRow(request.Context(),
		`SELECT content FROM mailing_list_emails WHERE id = $1 AND list_id = $2`, id, listID,
	).Scan(&content); errors.Is(err, pgx.ErrNoRows) {
		web.ErrorPage404(s.templates, writer, params)
		return
	} else if err != nil {
		web.ErrorPage500(s.templates, writer, params, "Error loading email content: "+err.Error())
		return
	}

	writer.Header().Set("Content-Type", "message/rfc822")
	writer.WriteHeader(http.StatusOK)
	_, _ = writer.Write(content)
}

// httpHandleMailingListSubscribers lists and manages the subscribers for a mailing list.
func (s *Server) httpHandleMailingListSubscribers(writer http.ResponseWriter, request *http.Request, params map[string]any) {
	groupPath := params["group_path"].([]string)
	listName := params["list_name"].(string)

	groupID, err := s.resolveGroupPath(request.Context(), groupPath)
	if errors.Is(err, pgx.ErrNoRows) {
		web.ErrorPage404(s.templates, writer, params)
		return
	} else if err != nil {
		web.ErrorPage500(s.templates, writer, params, "Error resolving group: "+err.Error())
		return
	}

	var listID int
	if err := s.database.QueryRow(request.Context(),
		`SELECT id FROM mailing_lists WHERE group_id = $1 AND name = $2`,
		groupID, listName,
	).Scan(&listID); errors.Is(err, pgx.ErrNoRows) {
		web.ErrorPage404(s.templates, writer, params)
		return
	} else if err != nil {
		web.ErrorPage500(s.templates, writer, params, "Error loading mailing list: "+err.Error())
		return
	}

	var count int
	if 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); err != nil {
		web.ErrorPage500(s.templates, writer, params, "Error checking access: "+err.Error())
		return
	}
	directAccess := (count > 0)
	if request.Method == http.MethodPost {
		if !directAccess {
			web.ErrorPage403(s.templates, writer, params, "You do not have direct access to this list")
			return
		}
		switch request.FormValue("op") {
		case "add":
			email := strings.TrimSpace(request.FormValue("email"))
			if email == "" || !strings.Contains(email, "@") {
				web.ErrorPage400(s.templates, writer, params, "Valid email is required")
				return
			}
			if _, err := s.database.Exec(request.Context(),
				`INSERT INTO mailing_list_subscribers (list_id, email) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
				listID, email,
			); err != nil {
				web.ErrorPage500(s.templates, writer, params, "Error adding subscriber: "+err.Error())
				return
			}
			misc.RedirectUnconditionally(writer, request)
			return
		case "remove":
			idStr := request.FormValue("id")
			id, err := strconv.Atoi(idStr)
			if err != nil || id <= 0 {
				web.ErrorPage400(s.templates, writer, params, "Invalid id")
				return
			}
			if _, err := s.database.Exec(request.Context(),
				`DELETE FROM mailing_list_subscribers WHERE id = $1 AND list_id = $2`, id, listID,
			); err != nil {
				web.ErrorPage500(s.templates, writer, params, "Error removing subscriber: "+err.Error())
				return
			}
			misc.RedirectUnconditionally(writer, request)
			return
		default:
			web.ErrorPage400(s.templates, writer, params, "Unknown operation")
			return
		}
	}

	rows, err := s.database.Query(request.Context(), `SELECT id, email FROM mailing_list_subscribers WHERE list_id = $1 ORDER BY email ASC`, listID)
	if err != nil {
		web.ErrorPage500(s.templates, writer, params, "Error loading subscribers: "+err.Error())
		return
	}
	defer rows.Close()
	var subs []map[string]any
	for rows.Next() {
		var id int
		var email string
		if err := rows.Scan(&id, &email); err != nil {
			web.ErrorPage500(s.templates, writer, params, "Error scanning subscribers: "+err.Error())
			return
		}
		subs = append(subs, map[string]any{"id": id, "email": email})
	}
	if err := rows.Err(); err != nil {
		web.ErrorPage500(s.templates, writer, params, "Error iterating subscribers: "+err.Error())
		return
	}

	params["list_name"] = listName
	params["subscribers"] = subs
	params["direct_access"] = directAccess

	s.renderTemplate(writer, "mailing_list_subscribers", params)
}

// httpHandleMailingListMessage renders a single archived message.
func (s *Server) httpHandleMailingListMessage(writer http.ResponseWriter, request *http.Request, params map[string]any) {
	groupPath := params["group_path"].([]string)
	listName := params["list_name"].(string)
	idStr := params["email_id"].(string)
	id, err := strconv.Atoi(idStr)
	if err != nil || id <= 0 {
		web.ErrorPage400(s.templates, writer, params, "Invalid email id")
		return
	}

	groupID, err := s.resolveGroupPath(request.Context(), groupPath)
	if errors.Is(err, pgx.ErrNoRows) {
		web.ErrorPage404(s.templates, writer, params)
		return
	} else if err != nil {
		web.ErrorPage500(s.templates, writer, params, "Error resolving group: "+err.Error())
		return
	}

	var listID int
	if err := s.database.QueryRow(request.Context(),
		`SELECT id FROM mailing_lists WHERE group_id = $1 AND name = $2`,
		groupID, listName,
	).Scan(&listID); errors.Is(err, pgx.ErrNoRows) {
		web.ErrorPage404(s.templates, writer, params)
		return
	} else if err != nil {
		web.ErrorPage500(s.templates, writer, params, "Error loading mailing list: "+err.Error())
		return
	}

	var raw []byte
	if err := s.database.QueryRow(request.Context(),
		`SELECT content FROM mailing_list_emails WHERE id = $1 AND list_id = $2`, id, listID,
	).Scan(&raw); errors.Is(err, pgx.ErrNoRows) {
		web.ErrorPage404(s.templates, writer, params)
		return
	} else if err != nil {
		web.ErrorPage500(s.templates, writer, params, "Error loading email content: "+err.Error())
		return
	}

	entity, err := message.Read(bytes.NewReader(raw))
	if err != nil {
		web.ErrorPage500(s.templates, writer, params, "Error parsing email content: "+err.Error())
		return
	}

	subj := entity.Header.Get("Subject")
	from := entity.Header.Get("From")
	dateStr := entity.Header.Get("Date")
	var dateVal time.Time
	if t, err := mail.ParseDate(dateStr); err == nil {
		dateVal = t
	}

	isHTML, body := extractBody(entity)
	var bodyHTML any
	if isHTML {
		bodyHTML = bluemonday.UGCPolicy().SanitizeBytes([]byte(body))
	} else {
		bodyHTML = render.EscapeHTML(body)
	}

	params["email_subject"] = subj
	params["email_from"] = from
	params["email_date_raw"] = dateStr
	params["email_date"] = dateVal
	params["email_body_html"] = bodyHTML

	s.renderTemplate(writer, "mailing_list_message", params)
}

func extractBody(e *message.Entity) (bool, string) {
	ctype := e.Header.Get("Content-Type")
	mtype, params, _ := mime.ParseMediaType(ctype)
	var plain string
	var htmlBody string

	if strings.HasPrefix(mtype, "multipart/") {
		b := params["boundary"]
		if b == "" {
			data, _ := io.ReadAll(e.Body)
			return false, string(data)
		}
		mr := multipart.NewReader(e.Body, b)
		for {
			part, err := mr.NextPart()
			if errors.Is(err, io.EOF) {
				break
			}
			if err != nil {
				break
			}
			ptype, _, _ := mime.ParseMediaType(part.Header.Get("Content-Type"))
			pdata, _ := io.ReadAll(part)
			switch strings.ToLower(ptype) {
			case "text/plain":
				if plain == "" {
					plain = string(pdata)
				}
			case "text/html":
				if htmlBody == "" {
					htmlBody = string(pdata)
				}
			}
		}
		if plain != "" {
			return false, plain
		}
		if htmlBody != "" {
			return true, htmlBody
		}
		return false, ""
	}

	data, _ := io.ReadAll(e.Body)
	switch strings.ToLower(mtype) {
	case "", "text/plain":
		return false, string(data)
	case "text/html":
		return true, string(data)
	default:
		return false, string(data)
	}
}
// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>

package unsorted

import (
	"bytes"
	"compress/gzip"
	"compress/zlib"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"os/exec"
	"strings"

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

// httpHandleUploadPack handles incoming Git fetch/pull/clone's over the Smart
// HTTP protocol.
func (s *Server) httpHandleUploadPack(writer http.ResponseWriter, request *http.Request, params map[string]any) (err error) {
	if ct := request.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/x-git-upload-pack-request") {
		http.Error(writer, "bad content-type", http.StatusUnsupportedMediaType)
		return nil
	}

	decoded, err := decodeBody(request)
	if err != nil {
		http.Error(writer, "cannot decode request body", http.StatusBadRequest)
		return err
	}
	defer decoded.Close()

	var groupPath []string
	var repoName string
	var repoPath string
	var cmd *exec.Cmd

	groupPath, repoName = params["group_path"].([]string), params["repo_name"].(string)

	if err := s.database.QueryRow(request.Context(), `
	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: jion 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
	FROM group_path_cte c
	JOIN repos r ON r.group_id = c.id
	WHERE c.depth = cardinality($1::text[])
		AND r.name = $2
	`,
		pgtype.FlatArray[string](groupPath),
		repoName,
	).Scan(&repoPath); err != nil {
		return err
	}

	writer.Header().Set("Content-Type", "application/x-git-upload-pack-result")
	// writer.Header().Set("Connection", "Keep-Alive")
	// writer.Header().Set("Transfer-Encoding", "chunked")

	cmd = exec.CommandContext(request.Context(), "git", "upload-pack", "--stateless-rpc", repoPath)
	cmd.Env = append(os.Environ(), "LINDENII_FORGE_HOOKS_SOCKET_PATH="+s.config.Hooks.Socket)

	var stderrBuf bytes.Buffer
	cmd.Stderr = &stderrBuf

	cmd.Stdout = writer
	cmd.Stdin = decoded

	if gp := request.Header.Get("Git-Protocol"); gp != "" {
		cmd.Env = append(cmd.Env, "GIT_PROTOCOL="+gp)
	}

	if err = cmd.Run(); err != nil {
		log.Println(stderrBuf.String())
		return err
	}

	return nil
}

func decodeBody(r *http.Request) (io.ReadCloser, error) {
	switch ce := strings.ToLower(strings.TrimSpace(r.Header.Get("Content-Encoding"))); ce {
	case "", "identity":
		return r.Body, nil
	case "gzip":
		zr, err := gzip.NewReader(r.Body)
		if err != nil { return nil, err }
		if err != nil {
			return nil, err
		}
		return zr, nil
	case "deflate":
		zr, err := zlib.NewReader(r.Body)
		if err != nil { return nil, err }
		if err != nil {
			return nil, err
		}
		return zr, nil
	default:
		return nil, fmt.Errorf("unsupported Content-Encoding: %q", ce)
	}
}
// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>

package unsorted

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

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

// 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 {
		web.ErrorPage400(s.templates, 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"] = 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) {
		web.ErrorPage500(s.templates, 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, ":") {
			web.ErrorPage400Colon(s.templates, writer, params)
			return
		}
	}

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

	if segments[0] == "-" {
		if len(segments) < 2 {
			web.ErrorPage404(s.templates, 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":
			s.httpHandleUsers(writer, request, params)
			return
		default:
			web.ErrorPage404(s.templates, 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:
		web.ErrorPage404(s.templates, writer, params)
		return
	case len(segments) == sepIndex+2:
		web.ErrorPage404(s.templates, 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 {
						web.ErrorPage500(s.templates, writer, params, err.Error())
					}
					return
				case "git-upload-pack":
					if err = s.httpHandleUploadPack(writer, request, params); err != nil {
						web.ErrorPage500(s.templates, 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 {
					web.ErrorPage400(s.templates, 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 {
				web.ErrorPage500(s.templates, 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:], "/") {
					web.ErrorPage400(s.templates, 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:], "/") {
					web.ErrorPage400(s.templates, 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 {
					web.ErrorPage400(s.templates, writer, params, "Too many parameters")
					return
				}
				if misc.RedirectDir(writer, request) {
					return
				}
				s.httpHandleRepoLog(writer, request, params)
			case "commit":
				if len(segments) != sepIndex+5 {
					web.ErrorPage400(s.templates, writer, params, "Incorrect number of parameters")
					return
				}
				if misc.RedirectNoDir(writer, request) {
					return
				}
				params["commit_id"] = segments[sepIndex+4]
				s.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:
					web.ErrorPage400(s.templates, writer, params, "Too many parameters")
				}
			default:
				web.ErrorPage404(s.templates, writer, params)
				return
			}
		case "lists":
			params["list_name"] = moduleName

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

			feature := segments[sepIndex+3]
			switch feature {
			case "raw":
				if len(segments) != sepIndex+5 {
					web.ErrorPage400(s.templates, writer, params, "Incorrect number of parameters")
					return
				}
				if misc.RedirectNoDir(writer, request) {
					return
				}
				params["email_id"] = segments[sepIndex+4]
				s.httpHandleMailingListRaw(writer, request, params)
				return
			case "message":
				if len(segments) != sepIndex+5 {
					web.ErrorPage400(s.templates, writer, params, "Incorrect number of parameters")
					return
				}
				if misc.RedirectNoDir(writer, request) {
					return
				}
				params["email_id"] = segments[sepIndex+4]
				s.httpHandleMailingListMessage(writer, request, params)
				return
			case "subscribers":
				if len(segments) != sepIndex+4 {
					web.ErrorPage400(s.templates, writer, params, "Incorrect number of parameters")
					return
				}
				if misc.RedirectDir(writer, request) {
					return
				}
				s.httpHandleMailingListSubscribers(writer, request, params)
				return
			default:
				web.ErrorPage404(s.templates, writer, params)
				return
			}
		default:
			web.ErrorPage404(s.templates, writer, params)
			return
		}
	}
}
// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>

package unsorted

import (
	"context"
	"errors"
	"net/mail"
	"time"

	"github.com/emersion/go-message"
	"github.com/jackc/pgx/v5"
)

// lmtpHandleMailingList stores an incoming email into the mailing list archive
// for the specified group/list. It expects the list to be already existing.
func (s *Server) lmtpHandleMailingList(session *lmtpSession, groupPath []string, listName string, email *message.Entity, raw []byte, envelopeFrom string) error {
	ctx := session.ctx

	groupID, err := s.resolveGroupPath(ctx, groupPath)
	if err != nil {
		return err
	}

	var listID int
	if err := s.database.QueryRow(ctx,
		`SELECT id FROM mailing_lists WHERE group_id = $1 AND name = $2`,
		groupID, listName,
	).Scan(&listID); err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return errors.New("mailing list not found")
		}
		return err
	}

	title := email.Header.Get("Subject")
	sender := email.Header.Get("From")

	date := time.Now()
	if dh := email.Header.Get("Date"); dh != "" {
		if t, err := mail.ParseDate(dh); err == nil {
			date = t
		}
	}

	_, err = s.database.Exec(ctx, `INSERT INTO mailing_list_emails (list_id, title, sender, date, content) VALUES ($1, $2, $3, $4, $5)`, listID, title, sender, date, raw)
	if err != nil {
		return err
	}

	if derr := s.relayMailingListMessage(ctx, listID, envelopeFrom, raw); derr != nil {
		// for now, return the error to LMTP so the sender learns delivery failed...
		// should replace this with queueing or something nice
		return derr
	}

	return nil
}

// resolveGroupPath resolves a group path (segments) to a group ID.
func (s *Server) resolveGroupPath(ctx context.Context, groupPath []string) (int, error) {
	var groupID int
	err := s.database.QueryRow(ctx, `
	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
	FROM group_path_cte c
	WHERE c.depth = cardinality($1::text[])
	`, groupPath).Scan(&groupID)
	return groupID, err
}
// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
// SPDX-FileCopyrightText: Copyright (c) 2024 Robin Jarry <robin@jarry.cc>

package unsorted

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"net"
	"strings"
	"time"

	"github.com/emersion/go-message"
	"github.com/emersion/go-smtp"
	"go.lindenii.runxiyu.org/forge/forged/internal/misc"
)

type lmtpHandler struct{s *Server}
type lmtpHandler struct{ s *Server }

type lmtpSession struct {
	from   string
	to     []string
	ctx    context.Context
	cancel context.CancelFunc
	s      *Server
}

func (session *lmtpSession) Reset() {
	session.from = ""
	session.to = nil
}

func (session *lmtpSession) Logout() error {
	session.cancel()
	return nil
}

func (session *lmtpSession) AuthPlain(_, _ string) error {
	return nil
}

func (session *lmtpSession) Mail(from string, _ *smtp.MailOptions) error {
	session.from = from
	return nil
}

func (session *lmtpSession) Rcpt(to string, _ *smtp.RcptOptions) error {
	session.to = append(session.to, to)
	return nil
}

func (h *lmtpHandler) NewSession(_ *smtp.Conn) (smtp.Session, error) {
	ctx, cancel := context.WithCancel(context.Background())
	session := &lmtpSession{
		ctx:    ctx,
		cancel: cancel,
		s: h.s,
		s:      h.s,
	}
	return session, nil
}

func (s *Server) serveLMTP(listener net.Listener) error {
	smtpServer := smtp.NewServer(&lmtpHandler{s: s})
	smtpServer.LMTP = true
	smtpServer.Domain = s.config.LMTP.Domain
	smtpServer.Addr = s.config.LMTP.Socket
	smtpServer.WriteTimeout = time.Duration(s.config.LMTP.WriteTimeout) * time.Second
	smtpServer.ReadTimeout = time.Duration(s.config.LMTP.ReadTimeout) * time.Second
	smtpServer.EnableSMTPUTF8 = true
	return smtpServer.Serve(listener)
}

func (session *lmtpSession) Data(r io.Reader) error {
	var (
		email *message.Entity
		from  string
		to    []string
		err   error
		buf   bytes.Buffer
		data  []byte
		n     int64
	)

	fmt.Printf("%#v\n", session.s)
	n, err = io.CopyN(&buf, r, session.s.config.LMTP.MaxSize)
	switch {
	case n == session.s.config.LMTP.MaxSize:
		err = errors.New("Message too big.")
		// drain whatever is left in the pipe
		_, _ = io.Copy(io.Discard, r)
		goto end
	case errors.Is(err, io.EOF):
		// message was smaller than max size
		break
	case err != nil:
		goto end
	}

	data = buf.Bytes()

	email, err = message.Read(bytes.NewReader(data))
	if err != nil && message.IsUnknownCharset(err) {
		goto end
	}

	switch strings.ToLower(email.Header.Get("Auto-Submitted")) {
	case "auto-generated", "auto-replied":
		// Disregard automatic emails like OOO replies
		slog.Info("ignoring automatic message",
			"from", session.from,
			"to", strings.Join(session.to, ","),
			"message-id", email.Header.Get("Message-Id"),
			"subject", email.Header.Get("Subject"),
		)
		goto end
	}

	slog.Info("message received",
		"from", session.from,
		"to", strings.Join(session.to, ","),
		"message-id", email.Header.Get("Message-Id"),
		"subject", email.Header.Get("Subject"),
	)

	// Make local copies of the values before to ensure the references will
	// still be valid when the task is run.
	from = session.from
	to = session.to

	_ = from

	for _, to := range to {
		if !strings.HasSuffix(to, "@"+session.s.config.LMTP.Domain) {
			continue
		}
		localPart := to[:len(to)-len("@"+session.s.config.LMTP.Domain)]
		var segments []string
		segments, err = misc.PathToSegments(localPart)
		if err != nil {
			// TODO: Should the entire email fail or should we just
			// notify them out of band?
			err = fmt.Errorf("cannot parse path: %w", err)
			goto end
		}
		sepIndex := -1
		for i, part := range segments {
			if part == "-" {
				sepIndex = i
				break
			}
		}
		if segments[len(segments)-1] == "" {
			segments = segments[:len(segments)-1] // We don't care about dir or not.
		}
		if sepIndex == -1 || len(segments) <= sepIndex+2 {
			err = errors.New("illegal path")
			goto end
		}

		mbox := bytes.Buffer{}
		if _, err = fmt.Fprint(&mbox, "From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001\r\n"); err != nil {
			slog.Error("error handling patch... malloc???", "error", err)
			goto end
		}
		data = bytes.ReplaceAll(data, []byte("\r\n"), []byte("\n"))
		if _, err = mbox.Write(data); err != nil {
			slog.Error("error handling patch... malloc???", "error", err)
			goto end
		}
		// TODO: Is mbox's From escaping necessary here?

		groupPath := segments[:sepIndex]
		moduleType := segments[sepIndex+1]
		moduleName := segments[sepIndex+2]
		switch moduleType {
		case "repos":
			err = session.s.lmtpHandlePatch(session, groupPath, moduleName, &mbox)
			if err != nil {
				slog.Error("error handling patch", "error", err)
				goto end
			}
		case "lists":
			if err = session.s.lmtpHandleMailingList(session, groupPath, moduleName, email, data, from); err != nil {
				slog.Error("error handling mailing list message", "error", err)
				goto end
			}
		default:
			err = errors.New("Emailing any endpoint other than repositories, is not supported yet.") // TODO
			goto end
		}
	}

end:
	session.to = nil
	session.from = ""
	switch err {
	case nil:
		return nil
	default:
		return &smtp.SMTPError{
			Code:         550,
			Message:      "Permanent failure: " + err.Error(),
			EnhancedCode: [3]int{5, 7, 1},
		}
	}
}
// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>

package unsorted

import (
	"context"
	"crypto/tls"
	"errors"
	"fmt"
	"log/slog"
	"net"
	stdsmtp "net/smtp"
	"time"

	"github.com/emersion/go-sasl"
	"github.com/emersion/go-smtp"
)

// relayMailingListMessage connects to the configured SMTP relay and sends the
// raw message to all subscribers of the given list. The message is written verbatim
// from this point on and there is no modification of any headers or whatever,
func (s *Server) relayMailingListMessage(ctx context.Context, listID int, envelopeFrom string, raw []byte) error {
	rows, err := s.database.Query(ctx, `SELECT email FROM mailing_list_subscribers WHERE list_id = $1`, listID)
	if err != nil {
		return err
	}
	defer rows.Close()
	var recipients []string
	for rows.Next() {
		var email string
		if err = rows.Scan(&email); err != nil {
			return err
		}
		recipients = append(recipients, email)
	}
	if err = rows.Err(); err != nil {
		return err
	}
	if len(recipients) == 0 {
		slog.Info("mailing list has no subscribers", "list_id", listID)
		return nil
	}

	netw := s.config.SMTP.Net
	if netw == "" {
		netw = "tcp"
	}
	if s.config.SMTP.Addr == "" {
		return errors.New("smtp relay addr not configured")
	}
	helloName := s.config.SMTP.HelloName
	if helloName == "" {
		helloName = s.config.LMTP.Domain
	}
	transport := s.config.SMTP.Transport
	if transport == "" {
		transport = "plain"
	}

	switch transport {
	case "plain", "tls":
		d := net.Dialer{Timeout: 30 * time.Second}
		var conn net.Conn
		var err error
		if transport == "tls" {
			tlsCfg := &tls.Config{ServerName: hostFromAddr(s.config.SMTP.Addr), InsecureSkipVerify: s.config.SMTP.TLSInsecure}
			conn, err = tls.DialWithDialer(&d, netw, s.config.SMTP.Addr, tlsCfg)
		} else {
			conn, err = d.DialContext(ctx, netw, s.config.SMTP.Addr)
		}
		if err != nil {
			return fmt.Errorf("dial smtp: %w", err)
		}
		defer conn.Close()

		c := smtp.NewClient(conn)
		defer c.Close()

		if err := c.Hello(helloName); err != nil {
			return fmt.Errorf("smtp hello: %w", err)
		}

		if s.config.SMTP.Username != "" {
			mech := sasl.NewPlainClient("", s.config.SMTP.Username, s.config.SMTP.Password)
			if err := c.Auth(mech); err != nil {
				return fmt.Errorf("smtp auth: %w", err)
			}
		}

		if err := c.Mail(envelopeFrom, &smtp.MailOptions{}); err != nil {
			return fmt.Errorf("smtp mail from: %w", err)
		}
		for _, rcpt := range recipients {
			if err := c.Rcpt(rcpt, &smtp.RcptOptions{}); err != nil {
				return fmt.Errorf("smtp rcpt %s: %w", rcpt, err)
			}
		}
		wc, err := c.Data()
		if err != nil {
			return fmt.Errorf("smtp data: %w", err)
		}
		if _, err := wc.Write(raw); err != nil {
			_ = wc.Close()
			return fmt.Errorf("smtp write: %w", err)
		}
		if err := wc.Close(); err != nil {
			return fmt.Errorf("smtp data close: %w", err)
		}
		if err := c.Quit(); err != nil {
			return fmt.Errorf("smtp quit: %w", err)
		}
		return nil
	case "starttls":
		d := net.Dialer{Timeout: 30 * time.Second}
		conn, err := d.DialContext(ctx, netw, s.config.SMTP.Addr)
		if err != nil {
			return fmt.Errorf("dial smtp: %w", err)
		}
		defer conn.Close()

		host := hostFromAddr(s.config.SMTP.Addr)
		c, err := stdsmtp.NewClient(conn, host)
		if err != nil {
			return fmt.Errorf("smtp new client: %w", err)
		}
		defer c.Close()

		if err := c.Hello(helloName); err != nil {
			return fmt.Errorf("smtp hello: %w", err)
		}
		if ok, _ := c.Extension("STARTTLS"); !ok {
			return errors.New("smtp server does not support STARTTLS")
		}
		tlsCfg := &tls.Config{ServerName: host, InsecureSkipVerify: s.config.SMTP.TLSInsecure} // #nosec G402
		if err := c.StartTLS(tlsCfg); err != nil {
			return fmt.Errorf("starttls: %w", err)
		}

		// seems like ehlo is required after starttls
		if err := c.Hello(helloName); err != nil {
			return fmt.Errorf("smtp hello (post-starttls): %w", err)
		}

		if s.config.SMTP.Username != "" {
			auth := stdsmtp.PlainAuth("", s.config.SMTP.Username, s.config.SMTP.Password, host)
			if err := c.Auth(auth); err != nil {
				return fmt.Errorf("smtp auth: %w", err)
			}
		}
		if err := c.Mail(envelopeFrom); err != nil {
			return fmt.Errorf("smtp mail from: %w", err)
		}
		for _, rcpt := range recipients {
			if err := c.Rcpt(rcpt); err != nil {
				return fmt.Errorf("smtp rcpt %s: %w", rcpt, err)
			}
		}
		wc, err := c.Data()
		if err != nil {
			return fmt.Errorf("smtp data: %w", err)
		}
		if _, err := wc.Write(raw); err != nil {
			_ = wc.Close()
			return fmt.Errorf("smtp write: %w", err)
		}
		if err := wc.Close(); err != nil {
			return fmt.Errorf("smtp data close: %w", err)
		}
		if err := c.Quit(); err != nil {
			return fmt.Errorf("smtp quit: %w", err)
		}
		return nil
	default:
		return fmt.Errorf("unknown smtp transport: %q", transport)
	}
}

func hostFromAddr(addr string) string {
	host, _, err := net.SplitHostPort(addr)
	if err != nil || host == "" {
		return addr
	}
	return host
}
/*
 * SPDX-License-Identifier: AGPL-3.0-only
 * SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
 * SPDX-FileCopyrightText: Copyright (c) 2025 luk3yx <https://luk3yx.github.io>
 * SPDX-FileCopyrightText: Copyright (c) 2017-2025 Drew DeVault <https://drewdevault.com>
 *
 * Drew did not directly contribute here but we took significant portions of
 * SourceHut's CSS.
 */

* {
	box-sizing: border-box;
}

/* Base styles and variables */
html {
	font-family: sans-serif;
	background-color: var(--background-color);
	color: var(--text-color);
	font-size: 1rem;
	--background-color: hsl(0, 0%, 100%);
	--text-color: hsl(0, 0%, 0%);
	--link-color: hsl(320, 50%, 36%);
	--light-text-color: hsl(0, 0%, 45%);
	--darker-border-color: hsl(0, 0%, 72%);
	--lighter-border-color: hsl(0, 0%, 85%);
	--text-decoration-color: hsl(0, 0%, 72%);
	--darker-box-background-color: hsl(0, 0%, 92%);
	--lighter-box-background-color: hsl(0, 0%, 95%);
	--primary-color: hsl(320, 50%, 36%);
	--primary-color-contrast: hsl(320, 0%, 100%);
	--danger-color: #ff0000;
	--danger-color-contrast: #ffffff;
}

/* Dark mode overrides */
@media (prefers-color-scheme: dark) {
	html {
		--background-color: hsl(0, 0%, 0%);
		--text-color: hsl(0, 0%, 100%);
		--link-color: hsl(320, 50%, 76%);
		--light-text-color: hsl(0, 0%, 78%);
		--darker-border-color: hsl(0, 0%, 35%);
		--lighter-border-color: hsl(0, 0%, 25%);
		--text-decoration-color: hsl(0, 0%, 50%);
		--darker-box-background-color: hsl(0, 0%, 20%);
		--lighter-box-background-color: hsl(0, 0%, 15%);
	}
}

/* Global layout */
body {
	margin: 0;
}
html, code, pre {
	font-size: 0.96rem; /* TODO: Not always correct */
}

/* Toggle table controls */
.toggle-table-off, .toggle-table-on {
	opacity: 0;
	position: absolute;
}
.toggle-table-off:focus-visible + table > thead > tr > th > label,
.toggle-table-on:focus-visible + table > thead > tr > th > label {
	outline: 1.5px var(--primary-color) solid;
}
.toggle-table-off + table > thead > tr > th, .toggle-table-on + table > thead > tr > th {
	padding: 0;
}
.toggle-table-off + table > thead > tr > th > label, .toggle-table-on + table > thead > tr > th > label {
	width: 100%;
	display: inline-block;
	padding: 3px 0;
	cursor: pointer;
}
.toggle-table-off:checked + table > tbody {
	display: none;
}
.toggle-table-on + table > tbody {
	display: none;
}
.toggle-table-on:checked + table > tbody {
	display: table-row-group;
}

/* Footer styles */
footer {
	margin-left: auto;
	margin-right: auto;
	display: block;
	padding: 0 5px;
	width: fit-content;
	text-align: center;
	color: var(--light-text-color);
}
footer a:link, footer a:visited {
	color: inherit;
}

.padding {
	padding: 0 1rem;
}

/* Sticky footer */
body {
	position: relative;
	min-height: 100vh;
}
main {
	padding-bottom: 2.5rem;
}
footer {
	position: absolute;
	bottom: 0;
	width: 100%;
	height: 2rem;
}

/* Link styles */
a:link, a:visited {
	text-decoration-color: var(--text-decoration-color);
	color: var(--link-color);
}

/* Readme inline code styling */
#readme code:not(pre > code) {
	background-color: var(--lighter-box-background-color);
	border-radius: 2px;
	padding: 2px;
}

/* Readme word breaks to avoid overfull hboxes */
#readme {
	word-break: break-word;
}

/* Table styles */
table {
	border: var(--lighter-border-color) solid 1px;
	border-spacing: 0px;
	border-collapse: collapse;
}
table.wide {
	width: 100%;
}
td, th {
	padding: 3px 5px;
	border: var(--lighter-border-color) solid 1px;
}
.pad {
	padding: 3px 5px;
}
th, thead, tfoot {
	background-color: var(--lighter-box-background-color);
}
th[scope=row] {
	text-align: left;
}
th {
	font-weight: normal;
}
tr.title-row > th, th.title-row, .title-row {
	background-color: var(--lighter-box-background-color);
	font-weight: bold;
}
td > pre {
	margin: 0;
}
#readme > *:last-child {
	margin-bottom: 0;
}
#readme > *:first-child {
	margin-top: 0;
}

/* Table misc and scrolling */
.commit-id {
	font-family: monospace;
	word-break: break-word;
}
.scroll {
	overflow-x: auto;
}

/* Diff/chunk styles */
.chunk-unchanged {
	color: grey;
}
.chunk-addition {
	color: green;
}
@media (prefers-color-scheme: dark) {
	.chunk-addition {
		color: lime;
	}
}
.chunk-deletion {
	color: red;
}
.chunk-unknown {
	color: yellow;
}
pre.chunk {
	margin-top: 0;
	margin-bottom: 0;
}
.centering {
	text-align: center;
}

/* Toggle content sections */
.toggle-off-wrapper, .toggle-on-wrapper {
	border: var(--lighter-border-color) solid 1px;
}
.toggle-off-toggle, .toggle-on-toggle {
	opacity: 0;
	position: absolute;
}
.toggle-off-header, .toggle-on-header {
	font-weight: bold;
	cursor: pointer;
	display: block;
	width: 100%;
	background-color: var(--lighter-box-background-color);
}
.toggle-off-header > div, .toggle-on-header > div {
	padding: 3px 5px;
	display: block;
}
.toggle-on-content {
	display: none;
}
.toggle-on-toggle:focus-visible + .toggle-on-header, .toggle-off-toggle:focus-visible + .toggle-off-header {
	outline: 1.5px var(--primary-color) solid;
}
.toggle-on-toggle:checked + .toggle-on-header + .toggle-on-content {
	display: block;
}
.toggle-off-content {
	display: block;
}
.toggle-off-toggle:checked + .toggle-off-header + .toggle-off-content {
	display: none;
}

*:focus-visible {
	outline: 1.5px var(--primary-color) solid;
}

/* File display styles */
.file-patch + .file-patch {
	margin-top: 0.5rem;
}
.file-content {
	padding: 3px 5px;
}
.file-header {
	font-family: monospace;
	display: flex;
	flex-direction: row;
	align-items: center;
}
.file-header::after {
	content: "\25b6";
	font-family: sans-serif;
	margin-left: auto;
	line-height: 100%;
	margin-right: 0.25em;
}
.file-toggle:checked + .file-header::after {
	content: "\25bc";
}

/* Form elements */
textarea {
	box-sizing: border-box;
	background-color: var(--lighter-box-background-color);
	resize: vertical;
}
textarea,
input[type=text],
input[type=password] {
	font-family: sans-serif;
	background-color: var(--lighter-box-background-color);
	color: var(--text-color);
	border: none;
	padding: 0.3rem;
	width: 100%;
	box-sizing: border-box;
}
td.tdinput, th.tdinput {
	padding: 0;
	position: relative;
}
td.tdinput textarea,
td.tdinput input[type=text],
td.tdinput input[type=password],
th.tdinput textarea,
th.tdinput input[type=text],
th.tdinput input[type=password] {
	background-color: transparent;
}
td.tdinput select {
	position: absolute;
	background-color: var(--background-color);
	color: var(--text-color);
	border: none;
	/*
	width: 100%;
	height: 100%;
	*/
	box-sizing: border-box;
	top: 0;
	left: 0;
	right: 0;
	bottom: 0;
}
select:active {
	outline: 1.5px var(--primary-color) solid;
}


/* Button styles */
.btn-primary, a.btn-primary {
	background: var(--primary-color);
	color: var(--primary-color-contrast);
	border: var(--lighter-border-color) 1px solid;
	font-weight: bold;
}
.btn-danger, a.btn-danger {
	background: var(--danger-color);
	color: var(--danger-color-contrast);
	border: var(--lighter-border-color) 1px solid;
	font-weight: bold;
}
.btn-white, a.btn-white {
	background: var(--primary-color-contrast);
	color: var(--primary-color);
	border: var(--lighter-border-color) 1px solid;
}
.btn-normal, a.btn-normal,
input[type=file]::file-selector-button {
	background: var(--lighter-box-background-color);
	border: var(--lighter-border-color) 1px solid !important;
	color: var(--text-color);
}
.btn, .btn-white, .btn-danger, .btn-normal, .btn-primary,
input[type=submit],
input[type=file]::file-selector-button {
	display: inline-block;
	width: auto;
	min-width: fit-content;
	padding: .1rem .75rem;
	transition: background .1s linear;
	cursor: pointer;
}
a.btn, a.btn-white, a.btn-danger, a.btn-normal, a.btn-primary {
	text-decoration: none;
}

/* Header layout */
header#main-header {
	/* background-color: var(--lighter-box-background-color); */
	display: flex;
	flex-direction: row;
	align-items: center;
	justify-content: space-between;
	flex-wrap: wrap;
	padding-top: 1rem;
	padding-bottom: 1rem;
	gap: 0.5rem;
}
#main-header a, #main-header a:link, main-header a:visited {
	text-decoration: none;
	color: inherit;
}
#main-header-forge-title {
	white-space: nowrap;
}
#breadcrumb-nav {
	display: flex;
	align-items: center;
	flex: 1 1 auto;
	min-width: 0;
	overflow-x: auto;
	gap: 0.25rem;
	white-space: nowrap;
}
.breadcrumb-separator {
	margin: 0 0.25rem;
}
#main-header-user {
	display: flex;
	align-items: center;
	white-space: nowrap;
}
@media (max-width: 37.5rem) {
	header#main-header {
		flex-direction: column;
		align-items: flex-start;
	}

	#breadcrumb-nav {
		width: 100%;
		overflow-x: auto;
	}
}

/* Uncategorized */
table + table {
	margin-top: 1rem;
}

td > ul {
	padding-left: 1.5rem;
	margin-top: 0;
	margin-bottom: 0;
}



.complete-error-page hr {
	border: 0;
	border-bottom: 1px dashed;
}






.key-val-grid {
	display: grid;
	grid-template-columns: auto 1fr;
	gap: 0;
	border: var(--lighter-border-color) 1px solid;
	overflow: auto;
}

.key-val-grid > .title-row {
	grid-column: 1 / -1;
	background-color: var(--lighter-box-background-color);
	font-weight: bold;
	padding: 3px 5px;
	border-bottom: var(--lighter-border-color) 1px solid;
}

.key-val-grid > .row-label {
	background-color: var(--lighter-box-background-color);
	padding: 3px 5px;
	border-bottom: var(--lighter-border-color) 1px solid;
	border-right: var(--lighter-border-color) 1px solid;
	text-align: left;
	font-weight: normal;
}

.key-val-grid > .row-value {
	padding: 3px 5px;
	border-bottom: var(--lighter-border-color) 1px solid;
	word-break: break-word;
}

.key-val-grid code {
	font-family: monospace;
}

.key-val-grid ul {
	margin: 0;
	padding-left: 1.5rem;
}

.key-val-grid > .row-label:nth-last-of-type(2),
.key-val-grid > .row-value:last-of-type {
	border-bottom: none;
}

@media (max-width: 37.5rem) {
	.key-val-grid {
		grid-template-columns: 1fr;
	}

	.key-val-grid > .row-label {
		border-right: none;
	}
}
.key-val-grid > .title-row {
	grid-column: 1 / -1;
	background-color: var(--lighter-box-background-color);
	font-weight: bold;
	padding: 3px 5px;
	border-bottom: var(--lighter-border-color) 1px solid;
	margin: 0;
	text-align: center;
}

.key-val-grid-wrapper {
	max-width: 100%;
	width: fit-content;
}

/* Tab navigation */

.nav-tabs-standalone {
	border: none;
	list-style: none;
	margin: 0;
	flex-grow: 1;
	display: inline-flex;
	flex-wrap: nowrap;
	padding: 0;
	border-bottom: 0.25rem var(--darker-box-background-color) solid;
	width: 100%;
	max-width: 100%;
	min-width: 100%;
}

.nav-tabs-standalone > li {
	align-self: flex-end;
}
.nav-tabs-standalone > li > a {
	padding: 0 0.75rem;
}

.nav-item a.active {
	background-color: var(--darker-box-background-color);
}

.nav-item a, .nav-item a:link, .nav-item a:visited {
	text-decoration: none;
	color: inherit;
}

.repo-header-extension {
	margin-bottom: 1rem;
	background-color: var(--darker-box-background-color);
}

.repo-header > h2 {
	display: inline;
	margin: 0;
	padding-right: 1rem;
}

.repo-header > .nav-tabs-standalone {
	border: none;
  margin: 0;
  flex-grow: 1;
  display: inline-flex;
  flex-wrap: nowrap;
  padding: 0;
	margin: 0;
	flex-grow: 1;
	display: inline-flex;
	flex-wrap: nowrap;
	padding: 0;
}

.repo-header {
	display: flex;
	flex-wrap: nowrap;
}

.repo-header-extension-content {
	padding-top: 0.3rem;
	padding-bottom: 0.2rem;
}

.repo-header, .padding-wrapper, .repo-header-extension-content, #main-header, .readingwidth, .commit-list-small {
	padding-left: 1rem;
	padding-right: 1rem;
	max-width: 60rem;
	width: 100%;
	margin-left: auto;
	margin-right: auto;
}

.padding-wrapper {
	margin-bottom: 1rem;
}

/* TODO */

.commit-list-small .event {
	background-color: var(--lighter-box-background-color);
	padding: 0.5rem;
	margin-bottom: 1rem;
	max-width: 30rem;
}

.commit-list-small .event:last-child {
	margin-bottom: 1rem;
}

.commit-list-small a {
	color: var(--link-color);
	text-decoration: none;
	font-weight: 500;
}

.commit-list-small a:hover {
	text-decoration: underline;
	text-decoration-color: var(--text-decoration-color);
}

.commit-list-small .event > div {
	font-size: 0.95rem;
}

.commit-list-small .pull-right {
	float: right;
	font-size: 0.85em;
	margin-left: 1rem;
}

.commit-list-small pre.commit {
	margin: 0.25rem 0 0 0;
	padding: 0;
	font-family: inherit;
	font-size: 0.95rem;
	color: var(--text-color);
	white-space: pre-wrap;
}

.commit-list-small .commit-error {
	color: var(--danger-color);
	font-weight: bold;
	margin-top: 1rem;
}


.breakable {
	word-break: break-word;
	/* overflow-wrap: break-word;
	overflow: hidden; */
}
{{/*
	SPDX-License-Identifier: AGPL-3.0-only
	SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
*/}}
{{- define "group_view" -}}
{{- if .subgroups -}}
	<table class="wide">
		<thead>
			<tr>
				<th colspan="2" class="title-row">Subgroups</th>
			</tr>
			<tr>
				<th scope="col">Name</th>
				<th scope="col">Description</th>
			</tr>
		</thead>
		<tbody>
			{{- range .subgroups -}}
				<tr>
					<td>
						<a href="{{- .Name | path_escape -}}/">{{- .Name -}}</a>
					</td>
					<td>
						{{- .Description -}}
					</td>
				</tr>
			{{- end -}}
		</tbody>
	</table>
{{- end -}}
{{- if .repos -}}
<table class="wide">
	<thead>
		<tr>
			<th colspan="2" class="title-row">Repos</th>
			<tr>
				<th scope="col">Name</th>
				<th scope="col">Description</th>
			</tr>
		</tr>
	</thead>
	<tbody>
		{{- range .repos -}}
			<tr>
				<td>
					<a href="-/repos/{{- .Name | path_escape -}}/">{{- .Name -}}</a>
				</td>
				<td>
					{{- .Description -}}
				</td>
			</tr>
		{{- end -}}
	</tbody>
</table>
{{- end -}}
{{- if .mailing_lists -}}
	<table class="wide">
		<thead>
			<tr>
				<th colspan="2" class="title-row">Mailing lists</th>
			</tr>
			<tr>
				<th scope="col">Name</th>
				<th scope="col">Description</th>
			</tr>
		</thead>
		<tbody>
			{{- range .mailing_lists -}}
			<tr>
				<td>
					<a href="-/lists/{{- .Name | path_escape -}}/">{{- .Name -}}</a>
				</td>
				<td>
					{{- .Description -}}
				</td>
			</tr>
			{{- end -}}
		</tbody>
	</table>
{{- end -}}
{{- end -}}
{{/*
	SPDX-License-Identifier: AGPL-3.0-only
	SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
*/}}
{{- define "group" -}}
{{- $group_path := .group_path -}}
<!DOCTYPE html>
<html lang="en">
	<head>
		{{- template "head_common" . -}}
		<title>{{- range $i, $s := .group_path -}}{{- $s -}}{{- if ne $i (len $group_path) -}}/{{- end -}}{{- end }} &ndash; {{ .global.forge_title -}}</title>
	</head>
	<body class="group">
		{{- template "header" . -}}
		<main>
			<div class="padding-wrapper">
				{{- if .description -}}
				<p>{{- .description -}}</p>
				{{- end -}}
				{{- template "group_view" . -}}
			</div>
			{{- if .direct_access -}}
				<div class="padding-wrapper">
					<form method="POST" enctype="application/x-www-form-urlencoded">
						<table>
							<thead>
								<tr>
									<th class="title-row" colspan="2">
										Create repo
									</th>
								</tr>
							</thead>
							<tbody>
								<input type="hidden" name="op" value="create_repo" />
								<tr>
									<th scope="row">Name</th>
									<td class="tdinput">
										<input id="repo-name-input" name="repo_name" type="text" />
									</td>
								</tr>
								<tr>
									<th scope="row">Description</th>
									<td class="tdinput">
										<input id="repo-desc-input" name="repo_desc" type="text" />
									</td>
								</tr>
								<tr>
									<th scope="row">Contrib</th>
									<td class="tdinput">
										<select id="repo-contrib-input" name="repo_contrib">
											<option value="public">Public</option>
											<option value="ssh_pubkey">SSH public key</option>
											<option value="federated">Federated service</option>
											<option value="registered_user">Registered user</option>
											<option value="closed">Closed</option>
										</select>
									</td>
								</tr>
							</tbody>
							<tfoot>
								<tr>
									<td class="th-like" colspan="2">
										<div class="flex-justify">
											<div class="left">
											</div>
											<div class="right">
												<input class="btn-primary" type="submit" value="Create" />
											</div>
										</div>
									</td>
								</tr>
							</tfoot>
						</table>
					</form>
				</div>
				<div class="padding-wrapper">
					<form method="POST" enctype="application/x-www-form-urlencoded">
						<table>
							<thead>
								<tr>
									<th class="title-row" colspan="2">
										Create mailing list
									</th>
								</tr>
							</thead>
							<tbody>
								<input type="hidden" name="op" value="create_list" />
								<tr>
									<th scope="row">Name</th>
									<td class="tdinput">
										<input id="list-name-input" name="list_name" type="text" />
									</td>
								</tr>
								<tr>
									<th scope="row">Description</th>
									<td class="tdinput">
										<input id="list-desc-input" name="list_desc" type="text" />
									</td>
								</tr>
							</tbody>
							<tfoot>
								<tr>
									<td class="th-like" colspan="2">
										<div class="flex-justify">
											<div class="left">
											</div>
											<div class="right">
												<input class="btn-primary" type="submit" value="Create" />
											</div>
										</div>
									</td>
								</tr>
							</tfoot>
						</table>
					</form>
				</div>
			{{- end -}}
		</main>
		<footer>
			{{- template "footer" . -}}
		</footer>
	</body>
</html>
{{- end -}}
{{/*
	SPDX-License-Identifier: AGPL-3.0-only
	SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
*/}}
{{- define "mailing_list" -}}
<!DOCTYPE html>
<html lang="en">
 	<head>
		{{- template "head_common" . -}}
		<title>{{- index .group_path 0 -}}{{- range $i, $s := .group_path -}}{{- if gt $i 0 -}}/{{- $s -}}{{- end -}}{{- end }}/-/lists/{{ .list_name }} – {{ .global.forge_title -}}</title>
	</head>
	<body class="mailing-list">
		{{- template "header" . -}}
		<main>
			<div class="padding-wrapper">
				<h2>{{ .list_name }}</h2>
				{{- if .list_description -}}
				<p>{{ .list_description }}</p>
				{{- end -}}
				<p><strong>Address:</strong> <code>{{ .list_email_address }}</code></p>
				{{- if .direct_access -}}
				<p><a href="subscribers/">Manage subscribers</a></p>
				{{- end -}}
			</div>
			<div class="padding-wrapper">
				<table class="wide">
					<thead>
						<tr>
							<th colspan="4" class="title-row">Archive</th>
						</tr>
						<tr>
							<th scope="col">Title</th>
							<th scope="col">Sender</th>
							<th scope="col">Date</th>
							<th scope="col">Raw</th>
						</tr>
					</thead>
					<tbody>
						{{- range .list_emails -}}
						<tr>
							<td><a href="message/{{ .id }}">{{ .title }}</a></td>
							<td>{{ .sender }}</td>
							<td>{{ .date }}</td>
							<td><a href="raw/{{ .id }}">download</a></td>
						</tr>
						{{- end -}}
					</tbody>
				</table>
			</div>
		</main>
		<footer>
			{{- template "footer" . -}}
		</footer>
	</body>
</html>
{{- end -}}
{{/*
	SPDX-License-Identifier: AGPL-3.0-only
*/}}
{{- define "mailing_list_message" -}}
<!DOCTYPE html>
<html lang="en">
 	<head>
		{{- template "head_common" . -}}
		<title>{{ .email_subject }} – {{ .global.forge_title -}}</title>
	</head>
	<body class="mailing-list-message">
		{{- template "header" . -}}
		<main>
			<div class="padding-wrapper">
				<table class="wide">
					<thead>
						<tr>
							<th colspan="2" class="title-row">{{ .email_subject }}</th>
						</tr>
					</thead>
					<tbody>
						<tr>
							<th scope="row">From</th>
							<td>{{ .email_from }}</td>
						</tr>
						<tr>
							<th scope="row">Date</th>
							<td>{{ if .email_date.IsZero }}{{ .email_date_raw }}{{ else }}{{ .email_date }}{{ end }}</td>
						</tr>
					</tbody>
				</table>
			</div>
			<div class="padding-wrapper">
				<div class="readme">{{ .email_body_html }}</div>
			</div>
		</main>
		<footer>
			{{- template "footer" . -}}
		</footer>
	</body>
</html>
{{- end -}}
{{/*
	SPDX-License-Identifier: AGPL-3.0-only
	SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
*/}}
{{- define "mailing_list_subscribers" -}}
<!DOCTYPE html>
<html lang="en">
 	<head>
		{{- template "head_common" . -}}
		<title>{{ .list_name }} subscribers – {{ .global.forge_title -}}</title>
	</head>
	<body class="mailing-list-subscribers">
		{{- template "header" . -}}
		<main>
			<div class="padding-wrapper">
				<table class="wide">
					<thead>
						<tr>
							<th colspan="2" class="title-row">Subscribers for {{ .list_name }}</th>
						</tr>
						<tr>
							<th scope="col">Email</th>
							<th scope="col">Actions</th>
						</tr>
					</thead>
					<tbody>
						{{- range .subscribers -}}
						<tr>
							<td>{{ .email }}</td>
							<td>
								{{- if $.direct_access -}}
								<form method="POST" enctype="application/x-www-form-urlencoded">
									<input type="hidden" name="op" value="remove" />
									<input type="hidden" name="id" value="{{ .id }}" />
									<input class="btn-danger" type="submit" value="Remove" />
								</form>
								{{- end -}}
							</td>
						</tr>
						{{- end -}}
					</tbody>
				</table>
			</div>
			{{- if .direct_access -}}
			<div class="padding-wrapper">
				<form method="POST" enctype="application/x-www-form-urlencoded">
					<table>
						<thead>
							<tr>
								<th class="title-row" colspan="2">Add subscriber</th>
							</tr>
						</thead>
						<tbody>
							<input type="hidden" name="op" value="add" />
							<tr>
								<th scope="row">Email</th>
								<td class="tdinput"><input id="subscriber-email" name="email" type="email" /></td>
							</tr>
						</tbody>
						<tfoot>
							<tr>
								<td class="th-like" colspan="2">
									<div class="flex-justify">
										<div class="left"></div>
										<div class="right">
											<input class="btn-primary" type="submit" value="Add" />
										</div>
									</div>
								</td>
							</tr>
						</tfoot>
					</table>
				</form>
			</div>
			{{- end -}}
		</main>
		<footer>
			{{- template "footer" . -}}
		</footer>
	</body>
</html>
{{- end -}}
-- SPDX-License-Identifier: AGPL-3.0-only
-- SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>

CREATE TABLE groups (
	id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
	name TEXT NOT NULL,
	parent_group INTEGER REFERENCES groups(id) ON DELETE CASCADE,
	description TEXT,
	UNIQUE NULLS NOT DISTINCT (parent_group, name)
);

CREATE TABLE repos (
	id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
	group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE RESTRICT, -- I mean, should be CASCADE but deleting Git repos on disk also needs to be considered
	contrib_requirements TEXT NOT NULL CHECK (contrib_requirements IN ('closed', 'registered_user', 'federated', 'ssh_pubkey', 'public')),
	name TEXT NOT NULL,
	UNIQUE(group_id, name),
	description TEXT,
	filesystem_path TEXT
);

CREATE TABLE mailing_lists (
	id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
	group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE RESTRICT,
	name TEXT NOT NULL,
	UNIQUE(group_id, name),
	description TEXT
);

CREATE TABLE mailing_list_emails (
	id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
	list_id INTEGER NOT NULL REFERENCES mailing_lists(id) ON DELETE CASCADE,
	title TEXT NOT NULL,
	sender TEXT NOT NULL,
	date TIMESTAMP NOT NULL,
	content BYTEA NOT NULL
);

CREATE TABLE mailing_list_subscribers (
	id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
	list_id INTEGER NOT NULL REFERENCES mailing_lists(id) ON DELETE CASCADE,
	email TEXT NOT NULL,
	UNIQUE (list_id, email)
);

CREATE TABLE users (
	id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
	username TEXT UNIQUE,
	type TEXT NOT NULL CHECK (type IN ('pubkey_only', 'federated', 'registered', 'admin')),
	password TEXT
);

CREATE TABLE ssh_public_keys (
	id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
	key_string TEXT NOT NULL,
	user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
	CONSTRAINT unique_key_string EXCLUDE USING HASH (key_string WITH =)
);

CREATE TABLE sessions (
	user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
	session_id TEXT PRIMARY KEY NOT NULL,
	UNIQUE(user_id, session_id)
);

CREATE TABLE user_group_roles (
	group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
	user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
	PRIMARY KEY(user_id, group_id)
);

CREATE TABLE federated_identities (
	user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
	service TEXT NOT NULL,
	remote_username TEXT NOT NULL,
	PRIMARY KEY(user_id, service)
);

-- Ticket tracking

CREATE TABLE ticket_trackers (
	id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
	group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE RESTRICT,
	name TEXT NOT NULL,
	description TEXT,
	UNIQUE(group_id, name)
);

CREATE TABLE tickets (
	id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
	tracker_id INTEGER NOT NULL REFERENCES ticket_trackers(id) ON DELETE CASCADE,
	tracker_local_id INTEGER NOT NULL,
	title TEXT NOT NULL,
	description TEXT,
	UNIQUE(tracker_id, tracker_local_id)
);

CREATE OR REPLACE FUNCTION create_tracker_ticket_sequence()
RETURNS TRIGGER AS $$
DECLARE
	seq_name TEXT := 'tracker_ticket_seq_' || NEW.id;
BEGIN
	EXECUTE format('CREATE SEQUENCE %I', seq_name);
	RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER after_insert_ticket_tracker
AFTER INSERT ON ticket_trackers
FOR EACH ROW
EXECUTE FUNCTION create_tracker_ticket_sequence();

CREATE OR REPLACE FUNCTION drop_tracker_ticket_sequence()
RETURNS TRIGGER AS $$
DECLARE
	seq_name TEXT := 'tracker_ticket_seq_' || OLD.id;
BEGIN
	EXECUTE format('DROP SEQUENCE IF EXISTS %I', seq_name);
	RETURN OLD;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER before_delete_ticket_tracker
BEFORE DELETE ON ticket_trackers
FOR EACH ROW
EXECUTE FUNCTION drop_tracker_ticket_sequence();

CREATE OR REPLACE FUNCTION assign_tracker_local_id()
RETURNS TRIGGER AS $$
DECLARE
	seq_name TEXT := 'tracker_ticket_seq_' || NEW.tracker_id;
BEGIN
	IF NEW.tracker_local_id IS NULL THEN
		EXECUTE format('SELECT nextval(%L)', seq_name)
		INTO NEW.tracker_local_id;
	END IF;
	RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER before_insert_ticket
BEFORE INSERT ON tickets
FOR EACH ROW
EXECUTE FUNCTION assign_tracker_local_id();

-- Merge requests

CREATE TABLE merge_requests (
	id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
	repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
	repo_local_id INTEGER NOT NULL,
	title TEXT,
	creator INTEGER REFERENCES users(id) ON DELETE SET NULL,
	source_ref TEXT NOT NULL,
	destination_branch TEXT,
	status TEXT NOT NULL CHECK (status IN ('open', 'merged', 'closed')),
	UNIQUE (repo_id, repo_local_id),
	UNIQUE (repo_id, source_ref, destination_branch)
);

CREATE OR REPLACE FUNCTION create_repo_mr_sequence()
RETURNS TRIGGER AS $$
DECLARE
	seq_name TEXT := 'repo_mr_seq_' || NEW.id;
BEGIN
	EXECUTE format('CREATE SEQUENCE %I', seq_name);
	RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER after_insert_repo
AFTER INSERT ON repos
FOR EACH ROW
EXECUTE FUNCTION create_repo_mr_sequence();

CREATE OR REPLACE FUNCTION drop_repo_mr_sequence()
RETURNS TRIGGER AS $$
DECLARE
	seq_name TEXT := 'repo_mr_seq_' || OLD.id;
BEGIN
	EXECUTE format('DROP SEQUENCE IF EXISTS %I', seq_name);
	RETURN OLD;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER before_delete_repo
BEFORE DELETE ON repos
FOR EACH ROW
EXECUTE FUNCTION drop_repo_mr_sequence();


CREATE OR REPLACE FUNCTION assign_repo_local_id()
RETURNS TRIGGER AS $$
DECLARE
	seq_name TEXT := 'repo_mr_seq_' || NEW.repo_id;
BEGIN
	IF NEW.repo_local_id IS NULL THEN
		EXECUTE format('SELECT nextval(%L)', seq_name)
		INTO NEW.repo_local_id;
	END IF;
	RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER before_insert_merge_request
BEFORE INSERT ON merge_requests
FOR EACH ROW
EXECUTE FUNCTION assign_repo_local_id();