Make Golang correctly delete temp files during uploading (#36128)

Fix #36127
This commit is contained in:
wxiaoguang 2025-12-12 02:59:42 +08:00 committed by GitHub
parent 01351cc6c7
commit f25409fab8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 57 additions and 11 deletions

View File

@ -370,6 +370,10 @@ func loadServerFrom(rootCfg ConfigProvider) {
} }
} }
// TODO: GOLANG-HTTP-TMPDIR: Some Golang packages (like "http") use os.TempDir() to create temporary files when uploading files.
// So ideally we should set the TMPDIR environment variable to make them use our managed temp directory.
// But there is no clear place to set it currently, for example: when running "install" page, the AppDataPath is not ready yet, then AppDataTempDir won't work
EnableGzip = sec.Key("ENABLE_GZIP").MustBool() EnableGzip = sec.Key("ENABLE_GZIP").MustBool()
EnablePprof = sec.Key("ENABLE_PPROF").MustBool(false) EnablePprof = sec.Key("ENABLE_PPROF").MustBool(false)
PprofDataPath = sec.Key("PPROF_DATA_PATH").MustString(filepath.Join(AppWorkPath, "data/tmp/pprof")) PprofDataPath = sec.Key("PPROF_DATA_PATH").MustString(filepath.Join(AppWorkPath, "data/tmp/pprof"))

View File

@ -46,11 +46,15 @@ func RouterMockPoint(pointName string) func(next http.Handler) http.Handler {
// //
// Then the mock function will be executed as a middleware at the mock point. // Then the mock function will be executed as a middleware at the mock point.
// It only takes effect in testing mode (setting.IsInTesting == true). // It only takes effect in testing mode (setting.IsInTesting == true).
func RouteMock(pointName string, h any) { func RouteMock(pointName string, h any) func() {
if _, ok := routeMockPoints[pointName]; !ok { if _, ok := routeMockPoints[pointName]; !ok {
panic("route mock point not found: " + pointName) panic("route mock point not found: " + pointName)
} }
old := routeMockPoints[pointName]
routeMockPoints[pointName] = toHandlerProvider(h) routeMockPoints[pointName] = toHandlerProvider(h)
return func() {
routeMockPoints[pointName] = old
}
} }
// RouteMockReset resets all mock points (no mock anymore) // RouteMockReset resets all mock points (no mock anymore)

View File

@ -55,7 +55,7 @@ func NewRouter() *Router {
// Use supports two middlewares // Use supports two middlewares
func (r *Router) Use(middlewares ...any) { func (r *Router) Use(middlewares ...any) {
for _, m := range middlewares { for _, m := range middlewares {
if m != nil { if !isNilOrFuncNil(m) {
r.chiRouter.Use(toHandlerProvider(m)) r.chiRouter.Use(toHandlerProvider(m))
} }
} }

View File

@ -72,8 +72,13 @@ func RequestContextHandler() func(h http.Handler) http.Handler {
req = req.WithContext(cache.WithCacheContext(ctx)) req = req.WithContext(cache.WithCacheContext(ctx))
ds.SetContextValue(httplib.RequestContextKey, req) ds.SetContextValue(httplib.RequestContextKey, req)
ds.AddCleanUp(func() { ds.AddCleanUp(func() {
if req.MultipartForm != nil { // TODO: GOLANG-HTTP-TMPDIR: Golang saves the uploaded files to temp directory (TMPDIR) when parsing multipart-form.
_ = req.MultipartForm.RemoveAll() // remove the temp files buffered to tmp directory // The "req" might have changed due to the new "req.WithContext" calls
// For example: in NewBaseContext, a new "req" with context is created, and the multipart-form is parsed there.
// So we always use the latest "req" from the data store.
ctxReq := ds.GetContextValue(httplib.RequestContextKey).(*http.Request)
if ctxReq.MultipartForm != nil {
_ = ctxReq.MultipartForm.RemoveAll() // remove the temp files buffered to tmp directory
} }
}) })
next.ServeHTTP(respWriter, req) next.ServeHTTP(respWriter, req)

View File

@ -227,6 +227,8 @@ func ctxDataSet(args ...any) func(ctx *context.Context) {
} }
} }
const RouterMockPointBeforeWebRoutes = "before-web-routes"
// Routes returns all web routes // Routes returns all web routes
func Routes() *web.Router { func Routes() *web.Router {
routes := web.NewRouter() routes := web.NewRouter()
@ -285,7 +287,7 @@ func Routes() *web.Router {
webRoutes := web.NewRouter() webRoutes := web.NewRouter()
webRoutes.Use(mid...) webRoutes.Use(mid...)
webRoutes.Group("", func() { registerWebRoutes(webRoutes) }, common.BlockExpensive(), common.QoS()) webRoutes.Group("", func() { registerWebRoutes(webRoutes) }, common.BlockExpensive(), common.QoS(), web.RouterMockPoint(RouterMockPointBeforeWebRoutes))
routes.Mount("", webRoutes) routes.Mount("", webRoutes)
return routes return routes
} }

View File

@ -43,8 +43,10 @@ type Base struct {
Locale translation.Locale Locale translation.Locale
} }
var ParseMultipartFormMaxMemory = int64(32 << 20)
func (b *Base) ParseMultipartForm() bool { func (b *Base) ParseMultipartForm() bool {
err := b.Req.ParseMultipartForm(32 << 20) err := b.Req.ParseMultipartForm(ParseMultipartFormMaxMemory)
if err != nil { if err != nil {
// TODO: all errors caused by client side should be ignored (connection closed). // TODO: all errors caused by client side should be ignored (connection closed).
if !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) { if !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {

View File

@ -7,17 +7,23 @@ import (
"bytes" "bytes"
"image" "image"
"image/png" "image/png"
"io/fs"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"os"
"strings" "strings"
"testing" "testing"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/storage" "code.gitea.io/gitea/modules/storage"
"code.gitea.io/gitea/modules/test" "code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/modules/web"
route_web "code.gitea.io/gitea/routers/web"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/tests" "code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func testGeneratePngBytes() []byte { func testGeneratePngBytes() []byte {
@ -52,14 +58,38 @@ func testCreateIssueAttachment(t *testing.T, session *TestSession, csrf, repoURL
return obj["uuid"] return obj["uuid"]
} }
func TestCreateAnonymousAttachment(t *testing.T) { func TestAttachments(t *testing.T) {
defer tests.PrepareTestEnv(t)() defer tests.PrepareTestEnv(t)()
t.Run("CreateAnonymousAttachment", testCreateAnonymousAttachment)
t.Run("CreateUser2IssueAttachment", testCreateUser2IssueAttachment)
t.Run("UploadAttachmentDeleteTemp", testUploadAttachmentDeleteTemp)
t.Run("GetAttachment", testGetAttachment)
}
func testUploadAttachmentDeleteTemp(t *testing.T) {
session := loginUser(t, "user2")
countTmpFile := func() int {
// TODO: GOLANG-HTTP-TMPDIR: Golang saves the uploaded file to os.TempDir() when it exceeds the max memory limit.
files, err := fs.Glob(os.DirFS(os.TempDir()), "multipart-*") //nolint:usetesting // Golang's "http" package's behavior
require.NoError(t, err)
return len(files)
}
var tmpFileCountDuringUpload int
defer test.MockVariableValue(&context.ParseMultipartFormMaxMemory, 1)()
defer web.RouteMock(route_web.RouterMockPointBeforeWebRoutes, func(resp http.ResponseWriter, req *http.Request) {
tmpFileCountDuringUpload = countTmpFile()
})()
_ = testCreateIssueAttachment(t, session, GetUserCSRFToken(t, session), "user2/repo1", "image.png", testGeneratePngBytes(), http.StatusOK)
assert.Equal(t, 1, tmpFileCountDuringUpload, "the temp file should exist when uploaded size exceeds the parse form's max memory")
assert.Equal(t, 0, countTmpFile(), "the temp file should be deleted after upload")
}
func testCreateAnonymousAttachment(t *testing.T) {
session := emptyTestSession(t) session := emptyTestSession(t)
testCreateIssueAttachment(t, session, GetAnonymousCSRFToken(t, session), "user2/repo1", "image.png", testGeneratePngBytes(), http.StatusSeeOther) testCreateIssueAttachment(t, session, GetAnonymousCSRFToken(t, session), "user2/repo1", "image.png", testGeneratePngBytes(), http.StatusSeeOther)
} }
func TestCreateIssueAttachment(t *testing.T) { func testCreateUser2IssueAttachment(t *testing.T) {
defer tests.PrepareTestEnv(t)()
const repoURL = "user2/repo1" const repoURL = "user2/repo1"
session := loginUser(t, "user2") session := loginUser(t, "user2")
uuid := testCreateIssueAttachment(t, session, GetUserCSRFToken(t, session), repoURL, "image.png", testGeneratePngBytes(), http.StatusOK) uuid := testCreateIssueAttachment(t, session, GetUserCSRFToken(t, session), repoURL, "image.png", testGeneratePngBytes(), http.StatusOK)
@ -90,8 +120,7 @@ func TestCreateIssueAttachment(t *testing.T) {
MakeRequest(t, req, http.StatusOK) MakeRequest(t, req, http.StatusOK)
} }
func TestGetAttachment(t *testing.T) { func testGetAttachment(t *testing.T) {
defer tests.PrepareTestEnv(t)()
adminSession := loginUser(t, "user1") adminSession := loginUser(t, "user1")
user2Session := loginUser(t, "user2") user2Session := loginUser(t, "user2")
user8Session := loginUser(t, "user8") user8Session := loginUser(t, "user8")