Hi… I am well aware that this diff view is very suboptimal. It will be fixed when the refactored server comes along!
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 }} – {{ .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();