Hi… I am well aware that this diff view is very suboptimal. It will be fixed when the refactored server comes along!
misc: Move url.go into the misc package
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org> package main import ( "errors" "net/http" "path/filepath" "strconv" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype"
"go.lindenii.runxiyu.org/forge/misc"
)
// 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 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 = 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) {
errorPage404(writer, params)
return
} else if err != nil {
errorPage500(writer, params, "Error getting group: "+err.Error())
return
}
// ACL
var count int
err = 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 {
errorPage500(writer, params, "Error checking access: "+err.Error())
return
}
directAccess := (count > 0)
if request.Method == http.MethodPost {
if !directAccess {
errorPage403(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 == "" {
errorPage400(writer, params, "Repo name is required")
return
}
var newRepoID int
err := 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 {
errorPage500(writer, params, "Error creating repo: "+err.Error())
return
}
filePath := filepath.Join(config.Git.RepoDir, strconv.Itoa(newRepoID)+".git")
_, err = database.Exec(
request.Context(),
`UPDATE repos
SET filesystem_path = $1
WHERE id = $2`,
filePath,
newRepoID,
)
if err != nil {
errorPage500(writer, params, "Error updating repo path: "+err.Error())
return
}
if err = gitInit(filePath); err != nil {
errorPage500(writer, params, "Error initializing repo: "+err.Error())
return
}
redirectUnconditionally(writer, request)
misc.RedirectUnconditionally(writer, request)
return
}
// Repos
var rows pgx.Rows
rows, err = database.Query(request.Context(), `
SELECT name, COALESCE(description, '')
FROM repos
WHERE group_id = $1
`, groupID)
if err != nil {
errorPage500(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 {
errorPage500(writer, params, "Error getting repos: "+err.Error())
return
}
repos = append(repos, nameDesc{name, description})
}
if err = rows.Err(); err != nil {
errorPage500(writer, params, "Error getting repos: "+err.Error())
return
}
// Subgroups
rows, err = database.Query(request.Context(), `
SELECT name, COALESCE(description, '')
FROM groups
WHERE parent_group = $1
`, groupID)
if err != nil {
errorPage500(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 {
errorPage500(writer, params, "Error getting subgroups: "+err.Error())
return
}
subgroups = append(subgroups, nameDesc{name, description})
}
if err = rows.Err(); err != nil {
errorPage500(writer, params, "Error getting subgroups: "+err.Error())
return
}
params["repos"] = repos
params["subgroups"] = subgroups
params["description"] = groupDesc
params["direct_access"] = directAccess
renderTemplate(writer, "group", params)
}
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org> package main import ( "fmt" "html/template" "net/http" "strings" "go.lindenii.runxiyu.org/forge/git2c"
"go.lindenii.runxiyu.org/forge/misc"
)
// httpHandleRepoRaw serves raw files, or directory listings that point to raw
// files.
func httpHandleRepoRaw(writer http.ResponseWriter, request *http.Request, params map[string]any) {
repoName := params["repo_name"].(string)
groupPath := params["group_path"].([]string)
rawPathSpec := params["rest"].(string)
pathSpec := strings.TrimSuffix(rawPathSpec, "/")
params["path_spec"] = pathSpec
_, repoPath, _, _, _, _, _ := getRepoInfo(request.Context(), groupPath, repoName, "")
client, err := git2c.NewClient(config.Git.Socket)
if err != nil {
errorPage500(writer, params, err.Error())
return
}
defer client.Close()
files, content, err := client.Cmd2(repoPath, pathSpec)
if err != nil {
errorPage500(writer, params, err.Error())
return
}
switch {
case files != nil:
params["files"] = files
params["readme_filename"] = "README.md"
params["readme"] = template.HTML("<p>README rendering here is WIP again</p>") // TODO
renderTemplate(writer, "repo_raw_dir", params)
case content != "":
if redirectNoDir(writer, request) {
if misc.RedirectNoDir(writer, request) {
return
}
writer.Header().Set("Content-Type", "application/octet-stream")
fmt.Fprint(writer, content)
default:
errorPage500(writer, params, "Unknown error fetching repo raw data")
}
}
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org> package main import ( "errors" "log/slog" "net/http" "net/url" "strconv" "strings" "github.com/jackc/pgx/v5"
"go.lindenii.runxiyu.org/forge/misc"
)
type forgeHTTPRouter struct{}
// ServeHTTP handles all incoming HTTP requests and routes them to the correct
// location.
//
// TODO: This function is way too large.
func (router *forgeHTTPRouter) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
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
}
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 = parseReqURI(request.RequestURI); err != nil {
if segments, _, err = misc.ParseReqURI(request.RequestURI); err != nil {
errorPage400(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"] = 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)
}
for _, v := range segments {
if strings.Contains(v, ":") {
errorPage400Colon(writer, params)
return
}
}
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) {
} else if len(segments) == 2 && misc.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
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) {
if misc.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) {
if params["ref_type"], params["ref_name"], err = misc.GetParamRefTypeName(request); err != nil {
if errors.Is(err, misc.ErrNoRefSpec) {
params["ref_type"] = ""
} else {
errorPage400(writer, params, "Error querying ref type: "+err.Error())
return
}
}
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
}
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] + "@" + config.LMTP.Domain
params["http_clone_url"] = genHTTPRemoteURL(groupPath, moduleName)
params["ssh_clone_url"] = genSSHRemoteURL(groupPath, moduleName)
if len(segments) == sepIndex+3 {
if redirectDir(writer, request) {
if misc.RedirectDir(writer, request) {
return
}
httpHandleRepoIndex(writer, request, params)
return
}
repoFeature := segments[sepIndex+3]
switch repoFeature {
case "tree":
if anyContain(segments[sepIndex+4:], "/") {
if misc.AnyContain(segments[sepIndex+4:], "/") {
errorPage400(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 && redirectDir(writer, request) {
if len(segments) < sepIndex+5 && misc.RedirectDir(writer, request) {
return } httpHandleRepoTree(writer, request, params) case "branches":
if redirectDir(writer, request) {
if misc.RedirectDir(writer, request) {
return } httpHandleRepoBranches(writer, request, params) return case "raw":
if anyContain(segments[sepIndex+4:], "/") {
if misc.AnyContain(segments[sepIndex+4:], "/") {
errorPage400(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 && redirectDir(writer, request) {
if len(segments) < sepIndex+5 && misc.RedirectDir(writer, request) {
return
}
httpHandleRepoRaw(writer, request, params)
case "log":
if len(segments) > sepIndex+4 {
errorPage400(writer, params, "Too many parameters")
return
}
if redirectDir(writer, request) {
if misc.RedirectDir(writer, request) {
return
}
httpHandleRepoLog(writer, request, params)
case "commit":
if len(segments) != sepIndex+5 {
errorPage400(writer, params, "Incorrect number of parameters")
return
}
if redirectNoDir(writer, request) {
if misc.RedirectNoDir(writer, request) {
return } params["commit_id"] = segments[sepIndex+4] httpHandleRepoCommit(writer, request, params) case "contrib":
if redirectDir(writer, request) {
if misc.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:
errorPage400(writer, params, "Too many parameters")
}
default:
errorPage404(writer, params)
return
}
default:
errorPage404(writer, params)
return
}
}
}
// 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" "context" "errors" "fmt" "io" "log/slog" "net" "strings" "time" "github.com/emersion/go-message" "github.com/emersion/go-smtp"
"go.lindenii.runxiyu.org/forge/misc"
)
type lmtpHandler struct{}
type lmtpSession struct {
from string
to []string
ctx context.Context
cancel context.CancelFunc
}
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 (*lmtpHandler) NewSession(_ *smtp.Conn) (smtp.Session, error) {
ctx, cancel := context.WithCancel(context.Background())
session := &lmtpSession{
ctx: ctx,
cancel: cancel,
}
return session, nil
}
func serveLMTP(listener net.Listener) error {
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.
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, "@"+config.LMTP.Domain) {
continue
}
localPart := to[:len(to)-len("@"+config.LMTP.Domain)]
var segments []string
segments, err = pathToSegments(localPart)
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 = lmtpHandlePatch(session, groupPath, moduleName, &mbox)
if err != nil {
slog.Error("error handling patch", "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 main import ( "net/url" "strings"
"go.lindenii.runxiyu.org/forge/misc"
)
// We don't use path.Join because it collapses multiple slashes into one.
// genSSHRemoteURL generates SSH remote URLs from a given group path and repo
// name.
func genSSHRemoteURL(groupPath []string, repoName string) string {
return strings.TrimSuffix(config.SSH.Root, "/") + "/" + segmentsToURL(groupPath) + "/-/repos/" + url.PathEscape(repoName)
return strings.TrimSuffix(config.SSH.Root, "/") + "/" + misc.SegmentsToURL(groupPath) + "/-/repos/" + url.PathEscape(repoName)
}
// genHTTPRemoteURL generates HTTP remote URLs from a given group path and repo
// name.
func genHTTPRemoteURL(groupPath []string, repoName string) string {
return strings.TrimSuffix(config.HTTP.Root, "/") + "/" + segmentsToURL(groupPath) + "/-/repos/" + url.PathEscape(repoName)
return strings.TrimSuffix(config.HTTP.Root, "/") + "/" + misc.SegmentsToURL(groupPath) + "/-/repos/" + url.PathEscape(repoName)
}
// 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" "go.lindenii.runxiyu.org/forge/ansiec"
"go.lindenii.runxiyu.org/forge/misc"
)
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, err = pathToSegments(sshPath)
segments, err = misc.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
package misc
import ( "errors" "net/http" "net/url" "strings" ) var (
errDupRefSpec = errors.New("duplicate ref spec")
errNoRefSpec = errors.New("no ref spec")
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) {
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
err = ErrDupRefSpec
return
}
done = true
if len(refName) != 1 {
err = errDupRefSpec
err = ErrDupRefSpec
return
}
retRefName = refName[0]
retRefType = refType
}
}
if !done {
err = errNoRefSpec
err = ErrNoRefSpec
} return }
// parseReqURI parses an HTTP request URL, and returns a slice of path segments
// 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) {
func ParseReqURI(requestURI string) (segments []string, params url.Values, err error) {
path, paramsStr, _ := strings.Cut(requestURI, "?")
segments, err = pathToSegments(path)
segments, err = PathToSegments(path)
if err != nil {
return
}
params, err = url.ParseQuery(paramsStr)
return
}
func pathToSegments(path string) (segments []string, err error) {
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
}
}
return
}
// redirectDir returns true and redirects the user to a version of the URL with
// 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 {
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
// 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 {
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
// RedirectUnconditionally unconditionally redirects the user back to the
// current page while preserving query parameters.
func redirectUnconditionally(writer http.ResponseWriter, request *http.Request) {
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.
// SegmentsToURL joins URL segments to the path component of a URL.
// Each segment is escaped properly first.
func segmentsToURL(segments []string) string {
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 {
// 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
}