Files
zeekayandHanzo Dev aa87442fdd Record upstream authorship centrally in NOTICE instead of in every file header
Source files keep their SPDX-License-Identifier and Hanzo copyright line; the
Gitea, Gogs and LFS Test Server copyrights they used to repeat now live once in
NOTICE, alongside LICENSE. The header lint accepts any copyright line above the
SPDX line, so it no longer demands an upstream one.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-10 17:50:35 -07:00

216 lines
7.1 KiB
Go

// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// SPDX-License-Identifier: MIT
package context
import (
"errors"
"fmt"
"html/template"
"io"
"net/http"
"strconv"
"strings"
"github.com/hanzoai/git/modules/httplib"
"github.com/hanzoai/git/modules/json"
"github.com/hanzoai/git/modules/log"
"github.com/hanzoai/git/modules/reqctx"
"github.com/hanzoai/git/modules/setting"
"github.com/hanzoai/git/modules/translation"
"github.com/hanzoai/git/modules/util"
"github.com/hanzoai/git/modules/web/middleware"
)
type BaseContextKeyType struct{}
var BaseContextKey BaseContextKeyType
// Base is the base context for all web handlers
// ATTENTION: This struct should never be manually constructed in routes/services,
// it has many internal details which should be carefully prepared by the framework.
// If it is abused, it would cause strange bugs like panic/resource-leak.
type Base struct {
reqctx.RequestContext
Resp ResponseWriter
Req *http.Request
// Data is prepared by ContextDataStore middleware, this field only refers to the pre-created/prepared ContextData.
// Although it's mainly used for MVC templates, sometimes it's also used to pass data between middlewares/handler
Data reqctx.ContextData
// Locale is mainly for Web context, although the API context also uses it in some cases: message response, form validation
Locale translation.Locale
}
var ParseMultipartFormMaxMemory = int64(32 << 20)
func (b *Base) ParseMultipartForm() bool {
err := b.Req.ParseMultipartForm(ParseMultipartFormMaxMemory)
if err != nil {
// TODO: all errors caused by client side should be ignored (connection closed).
if !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
// Errors caused by server side (disk full) should be logged.
log.Error("Failed to parse request multipart form for %s: %v", b.Req.RequestURI, err)
}
b.HTTPError(http.StatusInternalServerError, "failed to parse request multipart form")
return false
}
return true
}
// AppendAccessControlExposeHeaders append headers by name to "Access-Control-Expose-Headers" header
func (b *Base) AppendAccessControlExposeHeaders(names ...string) {
val := b.RespHeader().Get("Access-Control-Expose-Headers")
if len(val) != 0 {
b.RespHeader().Set("Access-Control-Expose-Headers", fmt.Sprintf("%s, %s", val, strings.Join(names, ", ")))
} else {
b.RespHeader().Set("Access-Control-Expose-Headers", strings.Join(names, ", "))
}
}
// SetTotalCountHeader set "X-Total-Count" header
func (b *Base) SetTotalCountHeader(total int64) {
b.RespHeader().Set("X-Total-Count", strconv.FormatInt(total, 10))
b.AppendAccessControlExposeHeaders("X-Total-Count")
}
// Written returns true if there are something sent to web browser
func (b *Base) Written() bool {
return b.Resp.WrittenStatus() != 0
}
func (b *Base) WrittenStatus() int {
return b.Resp.WrittenStatus()
}
// Status writes status code
func (b *Base) Status(status int) {
b.Resp.WriteHeader(status)
}
// Write writes data to web browser
func (b *Base) Write(bs []byte) (int, error) {
return b.Resp.Write(bs)
}
// RespHeader returns the response header
func (b *Base) RespHeader() http.Header {
return b.Resp.Header()
}
// HTTPError returned an error to web browser
// FIXME: many calls to this HTTPError are not right: it shouldn't expose err.Error() directly, it doesn't accept more than one content
func (b *Base) HTTPError(status int, contents ...string) {
v := http.StatusText(status)
if len(contents) > 0 {
v = contents[0]
}
http.Error(b.Resp, v, status)
}
// JSON render content as JSON
func (b *Base) JSON(status int, content any) {
b.Resp.Header().Set("Content-Type", "application/json;charset=utf-8")
b.Resp.WriteHeader(status)
if err := json.NewEncoder(b.Resp).Encode(content); err != nil {
log.Error("Render JSON failed: %v", err)
}
}
// RemoteAddr returns the client machine ip address
func (b *Base) RemoteAddr() string {
return b.Req.RemoteAddr
}
// PlainTextBytes renders bytes as plain text
func (b *Base) plainTextInternal(skip, status int, bs []byte) {
statusPrefix := status / 100
if statusPrefix == 4 || statusPrefix == 5 {
log.Log(skip, log.TRACE, "plainTextInternal (status=%d): %s", status, string(bs))
}
b.Resp.Header().Set("Content-Type", "text/plain;charset=utf-8")
b.Resp.Header().Set("X-Content-Type-Options", "nosniff")
b.Resp.WriteHeader(status)
_, _ = b.Resp.Write(bs)
}
// PlainTextBytes renders bytes as plain text
func (b *Base) PlainTextBytes(status int, bs []byte) {
b.plainTextInternal(2, status, bs)
}
// PlainText renders content as plain text
func (b *Base) PlainText(status int, text string) {
b.plainTextInternal(2, status, []byte(text))
}
// Redirect redirects the request
func (b *Base) Redirect(location string, status ...int) {
code := util.OptionalArg(status, http.StatusSeeOther)
if !httplib.IsRelativeURL(location) {
// Some browsers (Safari) have buggy behavior for Cookie + Cache + External Redirection, eg: /my-path => https://other/path
// 1. the first request to "/my-path" contains cookie
// 2. some time later, the request to "/my-path" doesn't contain cookie (caused by Prevent web tracking)
// 3. The forge's Sessioner doesn't see the session cookie, so it generates a new session id, and returns it to browser
// 4. then the browser accepts the empty session, then the user is logged out
// So in this case, we should remove the session cookie from the response header
removeSessionCookieHeader(b.Resp)
}
// In case the request is made by "fetch-action" module, make JS redirect to the new location
// Otherwise, the JS fetch will follow the redirection and read a "login" page, embed it to the current page, which is not expected.
if b.Req.Header.Get("X-Fetch-Action") != "" {
b.JSON(http.StatusOK, map[string]any{"redirect": location})
return
}
http.Redirect(b.Resp, b.Req, location, code)
}
type ServeHeaderOptions = httplib.ServeHeaderOptions
func (b *Base) SetServeHeaders(opts ServeHeaderOptions) {
httplib.ServeSetHeaders(b.Resp, opts)
}
// ServeContent serves content to http request
func (b *Base) ServeContent(r io.ReadSeeker, opts ServeHeaderOptions) {
httplib.ServeSetHeaders(b.Resp, opts)
http.ServeContent(b.Resp, b.Req, opts.Filename, opts.LastModified, r)
}
func (b *Base) Tr(msg string, args ...any) template.HTML {
return b.Locale.Tr(msg, args...)
}
func (b *Base) TrN(cnt any, key1, keyN string, args ...any) template.HTML {
return b.Locale.TrN(cnt, key1, keyN, args...)
}
func NewBaseContext(resp http.ResponseWriter, req *http.Request) *Base {
reqCtx := reqctx.FromContext(req.Context())
b := &Base{
RequestContext: reqCtx,
Req: req,
Resp: WrapResponseWriter(resp),
Locale: middleware.Locale(resp, req),
Data: reqCtx.GetData(),
}
b.Req = b.Req.WithContext(b)
reqCtx.SetContextValue(BaseContextKey, b)
reqCtx.SetContextValue(translation.ContextKey, b.Locale)
reqCtx.SetContextValue(httplib.RequestContextKey, b.Req)
return b
}
func NewBaseContextForTest(resp http.ResponseWriter, req *http.Request) *Base {
if !setting.IsInTesting {
panic("This function is only for testing")
}
ctx := reqctx.NewRequestContextForTest(req.Context())
*req = *req.WithContext(ctx)
return NewBaseContext(resp, req)
}