Lindenii Project Forge
Support X-Forwarded-For for reverse proxies
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-FileContributor: Runxi Yu <https://runxiyu.org> package main import ( "bufio" "context" "errors" "os" "github.com/jackc/pgx/v5/pgxpool" "go.lindenii.runxiyu.org/lindenii-common/scfg" ) var database *pgxpool.Pool var config struct { HTTP struct { Net string `scfg:"net"` Addr string `scfg:"addr"` CookieExpiry int `scfg:"cookie_expiry"` Root string `scfg:"root"` ReadTimeout uint `scfg:"read_timeout"` WriteTimeout uint `scfg:"write_timeout"` IdleTimeout uint `scfg:"idle_timeout"`
ReverseProxy bool `scfg:"reverse_proxy"`
} `scfg:"http"` Hooks struct { Socket string `scfg:"socket"` Execs string `scfg:"execs"` } `scfg:"hooks"` Git struct { RepoDir string `scfg:"repo_dir"` } `scfg:"git"` SSH struct { Net string `scfg:"net"` Addr string `scfg:"addr"` Key string `scfg:"key"` Root string `scfg:"root"` } `scfg:"ssh"` IRC struct { Net string `scfg:"net"` Addr string `scfg:"addr"` TLS bool `scfg:"tls"` SendQ uint `scfg:"sendq"` Nick string `scfg:"nick"` User string `scfg:"user"` Gecos string `scfg:"gecos"` } `scfg:"irc"` General struct { Title string `scfg:"title"` } `scfg:"general"` DB struct { Type string `scfg:"type"` Conn string `scfg:"conn"` } `scfg:"db"` } func loadConfig(path string) (err error) { var configFile *os.File var decoder *scfg.Decoder if configFile, err = os.Open(path); err != nil { return err } defer configFile.Close() decoder = scfg.NewDecoder(bufio.NewReader(configFile)) if err = decoder.Decode(&config); err != nil { return err } if config.DB.Type != "postgres" { return errors.New("unsupported database type") } if database, err = pgxpool.New(context.Background(), config.DB.Conn); err != nil { return err } globalData["forge_title"] = config.General.Title return nil }
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 /var/run/lindenii/forge/http.sock # 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 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 { repo_dir /var/lib/lindenii/forge/repos } 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 }
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-FileContributor: Runxi Yu <https://runxiyu.org> package main import ( "errors" "net/http" "strconv" "strings" "github.com/jackc/pgx/v5" "go.lindenii.runxiyu.org/lindenii-common/clog" ) type forgeHTTPRouter struct{} func (router *forgeHTTPRouter) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
clog.Info("Incoming HTTP: " + request.RemoteAddr + " " + request.Method + " " + request.RequestURI)
var remoteAddr string if 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 } clog.Info("Incoming HTTP: " + remoteAddr + " " + request.Method + " " + request.RequestURI)
var segments []string var err error var sepIndex int params := make(map[string]any) if segments, _, err = parseReqURI(request.RequestURI); err != nil { http.Error(writer, err.Error(), http.StatusBadRequest) return } if segments[len(segments)-1] == "" { segments = segments[:len(segments)-1] } params["url_segments"] = segments params["global"] = globalData var userID int // 0 for none userID, params["username"], err = getUserFromRequest(request) params["user_id"] = userID if err != nil && !errors.Is(err, http.ErrNoCookie) && !errors.Is(err, pgx.ErrNoRows) { errorPage500(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) } if len(segments) == 0 { httpHandleIndex(writer, request, params) return } if segments[0] == ":" { if len(segments) < 2 { errorPage404(writer, params) return } else if len(segments) == 2 && redirectDir(writer, request) { return } switch segments[1] { case "static": staticHandler.ServeHTTP(writer, request) return case "source": sourceHandler.ServeHTTP(writer, request) return } } if segments[0] == ":" { switch segments[1] { case "login": httpHandleLogin(writer, request, params) return case "users": httpHandleUsers(writer, request, params) return case "gc": httpHandleGC(writer, request, params) return default: errorPage404(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 redirectDir(writer, request) { return } httpHandleGroupIndex(writer, request, params) case len(segments) == sepIndex+1: errorPage404(writer, params) return case len(segments) == sepIndex+2: errorPage404(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 = httpHandleRepoInfo(writer, request, params); err != nil { errorPage500(writer, params, err.Error()) } return case "git-upload-pack": if err = httpHandleUploadPack(writer, request, params); err != nil { errorPage500(writer, params, err.Error()) } return } } if params["ref_type"], params["ref_name"], err = getParamRefTypeName(request); err != nil { if errors.Is(err, errNoRefSpec) { params["ref_type"] = "" } else { errorPage500(writer, params, "Error querying ref type: "+err.Error()) return } } // TODO: subgroups if params["repo"], params["repo_description"], params["repo_id"], err = openRepo(request.Context(), groupPath, moduleName); err != nil { errorPage500(writer, params, "Error opening repo: "+err.Error()) return } if len(segments) == sepIndex+3 { if redirectDir(writer, request) { return } httpHandleRepoIndex(writer, request, params) return } repoFeature := segments[sepIndex+3] switch repoFeature { case "tree": if anyContain(segments[sepIndex+4:], "/") { errorPage400(writer, params, "Repo tree paths may not contain slashes in any segments") return } params["rest"] = strings.Join(segments[sepIndex+4:], "/") if len(segments) < sepIndex+5 && redirectDir(writer, request) { return } httpHandleRepoTree(writer, request, params) case "raw": if anyContain(segments[sepIndex+4:], "/") { errorPage400(writer, params, "Repo tree paths may not contain slashes in any segments") return } params["rest"] = strings.Join(segments[sepIndex+4:], "/") if len(segments) < sepIndex+5 && redirectDir(writer, request) { return } httpHandleRepoRaw(writer, request, params) case "log": if len(segments) > sepIndex+4 { http.Error(writer, "Too many parameters", http.StatusBadRequest) return } if redirectDir(writer, request) { return } httpHandleRepoLog(writer, request, params) case "commit": if redirectNoDir(writer, request) { return } params["commit_id"] = segments[sepIndex+4] httpHandleRepoCommit(writer, request, params) case "contrib": if redirectDir(writer, request) { return } switch len(segments) { case sepIndex + 4: httpHandleRepoContribIndex(writer, request, params) case sepIndex + 5: params["mr_id"] = segments[sepIndex+4] httpHandleRepoContribOne(writer, request, params) default: http.Error(writer, "Too many parameters", http.StatusBadRequest) } default: errorPage404(writer, params) return } default: errorPage404(writer, params) return } } }