Lindenii Project Forge
LMTP: Patch handling stub
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org> package main import ( "log/slog" "github.com/emersion/go-message" ) func lmtpHandlePatch(groupPath []string, repoName string, email *message.Entity) (err error) { slog.Info("Pretend like I'm handling a patch!") return nil }
// 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 main import ( "bytes" "errors"
"fmt"
"io" "log/slog" "net" "strings" "time" "github.com/emersion/go-message" "github.com/emersion/go-smtp" ) type lmtpHandler struct{} type lmtpSession struct { from string to []string } func (session *lmtpSession) Reset() { session.from = "" session.to = nil } func (session *lmtpSession) Logout() error { 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 (*lmtpHandler) NewSession(_ *smtp.Conn) (smtp.Session, error) {
// TODO
session := &lmtpSession{} return session, nil } func serveLMTP(listener net.Listener) error {
// TODO: Manually construct smtp.Server
smtpServer := smtp.NewServer(&lmtpHandler{}) smtpServer.LMTP = true smtpServer.Domain = config.LMTP.Domain smtpServer.Addr = config.LMTP.Socket smtpServer.WriteTimeout = time.Duration(config.LMTP.WriteTimeout) * time.Second smtpServer.ReadTimeout = time.Duration(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 ) n, err = io.CopyN(&buf, r, config.LMTP.MaxSize) switch { case n == 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
// 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 queued task function is evaluated.
// still be valid when the task is run.
from = session.from to = session.to
// TODO: Process the actual message contents _, _ = from, to
_ = from for _, to := range to { if !strings.HasSuffix(to, "@"+config.LMTP.Domain) { continue } localPart := to[:len(to)-len("@"+config.LMTP.Domain)] segments, err := 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 } groupPath := segments[:sepIndex] moduleType := segments[sepIndex+1] moduleName := segments[sepIndex+2] switch moduleType { case "repos": err = lmtpHandlePatch(groupPath, moduleName, email) if err != nil { goto end } default: err = fmt.Errorf("Emailing any endpoint other than repositories, is not supported yet.") // TODO goto end } }
end: session.to = nil session.from = "" return err }
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org> package main import ( "context" "errors" "fmt" "io" "net/url"
"strings"
"go.lindenii.runxiyu.org/lindenii-common/ansiec" ) var errIllegalSSHRepoPath = errors.New("illegal SSH repo path") // getRepoInfo2 also fetches repo information... it should be deprecated and // implemented in individual handlers. func getRepoInfo2(ctx context.Context, sshPath, sshPubkey string) (groupPath []string, repoName string, repoID int, repoPath string, directAccess bool, contribReq, userType string, userID int, err error) { var segments []string var sepIndex int var moduleType, moduleName string
segments = strings.Split(strings.TrimPrefix(sshPath, "/"), "/")
segments, err = pathToSegments(sshPath) if err != nil { return }
for i, segment := range segments { var err error segments[i], err = url.PathUnescape(segment) if err != nil { return []string{}, "", 0, "", false, "", "", 0, err } } if segments[0] == ":" { return []string{}, "", 0, "", false, "", "", 0, errIllegalSSHRepoPath } sepIndex = -1 for i, part := range segments { if part == ":" { sepIndex = i break } } if segments[len(segments)-1] == "" { segments = segments[:len(segments)-1] } switch { case sepIndex == -1: return []string{}, "", 0, "", false, "", "", 0, errIllegalSSHRepoPath case len(segments) <= sepIndex+2: return []string{}, "", 0, "", false, "", "", 0, errIllegalSSHRepoPath } groupPath = segments[:sepIndex] moduleType = segments[sepIndex+1] moduleName = segments[sepIndex+2] repoName = moduleName switch moduleType { case "repos": _1, _2, _3, _4, _5, _6, _7 := getRepoInfo(ctx, groupPath, moduleName, sshPubkey) return groupPath, repoName, _1, _2, _3, _4, _5, _6, _7 default: return []string{}, "", 0, "", false, "", "", 0, errIllegalSSHRepoPath } } // writeRedError is a helper function that basically does a Fprintf but makes // the entire thing red, in terms of ANSI escape sequences. It's useful when // producing error messages on SSH connections. func writeRedError(w io.Writer, format string, args ...any) { fmt.Fprintln(w, ansiec.Red+fmt.Sprintf(format, args...)+ansiec.Reset) }
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org> package main import ( "errors" "net/http" "net/url" "strings" ) var ( errDupRefSpec = errors.New("duplicate ref spec") errNoRefSpec = errors.New("no ref spec") ) // getParamRefTypeName looks at the query parameters in an HTTP request and // returns its ref name and type, if any. func getParamRefTypeName(request *http.Request) (retRefType, retRefName string, err error) { rawQuery := request.URL.RawQuery queryValues, err := url.ParseQuery(rawQuery) if err != nil { return } done := false for _, refType := range []string{"commit", "branch", "tag"} { refName, ok := queryValues[refType] if ok { if done { err = errDupRefSpec return } done = true if len(refName) != 1 { err = errDupRefSpec return } retRefName = refName[0] retRefType = refType } } if !done { err = errNoRefSpec } return } // parseReqURI parses an HTTP request URL, and returns a slice of path segments // and the query parameters. It handles %2F correctly. func parseReqURI(requestURI string) (segments []string, params url.Values, err error) { path, paramsStr, _ := strings.Cut(requestURI, "?")
segments, err = pathToSegments(path) if err != nil { return } params, err = url.ParseQuery(paramsStr) return } func pathToSegments(path string) (segments []string, err error) {
segments = strings.Split(strings.TrimPrefix(path, "/"), "/") for i, segment := range segments { segments[i], err = url.PathUnescape(segment) if err != nil { return } }
params, err = url.ParseQuery(paramsStr)
return } // redirectDir returns true and redirects the user to a version of the URL with // a trailing slash, if and only if the request URL does not already have a // trailing slash. func redirectDir(writer http.ResponseWriter, request *http.Request) bool { requestURI := request.RequestURI pathEnd := strings.IndexAny(requestURI, "?#") var path, rest string if pathEnd == -1 { path = requestURI } else { path = requestURI[:pathEnd] rest = requestURI[pathEnd:] } if !strings.HasSuffix(path, "/") { http.Redirect(writer, request, path+"/"+rest, http.StatusSeeOther) return true } return false } // redirectNoDir returns true and redirects the user to a version of the URL // without a trailing slash, if and only if the request URL has a trailing // slash. func redirectNoDir(writer http.ResponseWriter, request *http.Request) bool { requestURI := request.RequestURI pathEnd := strings.IndexAny(requestURI, "?#") var path, rest string if pathEnd == -1 { path = requestURI } else { path = requestURI[:pathEnd] rest = requestURI[pathEnd:] } if strings.HasSuffix(path, "/") { http.Redirect(writer, request, strings.TrimSuffix(path, "/")+rest, http.StatusSeeOther) return true } return false } // redirectUnconditionally unconditionally redirects the user back to the // current page while preserving query parameters. func redirectUnconditionally(writer http.ResponseWriter, request *http.Request) { requestURI := request.RequestURI pathEnd := strings.IndexAny(requestURI, "?#") var path, rest string if pathEnd == -1 { path = requestURI } else { path = requestURI[:pathEnd] rest = requestURI[pathEnd:] } http.Redirect(writer, request, path+rest, http.StatusSeeOther) } // segmentsToURL joins URL segments to the path component of a URL. // Each segment is escaped properly first. func segmentsToURL(segments []string) string { for i, segment := range segments { segments[i] = url.PathEscape(segment) } return strings.Join(segments, "/") } // anyContain returns true if and only if ss contains a string that contains c. func anyContain(ss []string, c string) bool { for _, s := range ss { if strings.Contains(s, c) { return true } } return false }