Lindenii Project Forge
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();