From d99b6d786484f04db13e1943c8bd6e0d8760dc09 Mon Sep 17 00:00:00 2001 From: Mathieu Fenniak Date: Thu, 18 Dec 2025 09:47:40 -0700 Subject: [PATCH 1/8] fix: hide user profile anonymous options on public repo APIs --- models/fixtures/user.yml | 1 + services/convert/repository.go | 21 ++++++++++++++----- tests/integration/api_repo_test.go | 33 ++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/models/fixtures/user.yml b/models/fixtures/user.yml index 630505b8b4..64a1c85da6 100644 --- a/models/fixtures/user.yml +++ b/models/fixtures/user.yml @@ -46,6 +46,7 @@ email: user2@example.com keep_email_private: true keep_pronouns_private: true + pronouns: he/him email_notifications_preference: enabled passwd: ZogKvWdyEx:password passwd_hash_algo: dummy diff --git a/services/convert/repository.go b/services/convert/repository.go index 1b0f46b3da..a075d09455 100644 --- a/services/convert/repository.go +++ b/services/convert/repository.go @@ -4,7 +4,7 @@ package convert import ( - "context" + stdCtx "context" "time" "forgejo.org/models" @@ -15,14 +15,15 @@ import ( unit_model "forgejo.org/models/unit" "forgejo.org/modules/log" api "forgejo.org/modules/structs" + "forgejo.org/services/context" ) // ToRepo converts a Repository to api.Repository -func ToRepo(ctx context.Context, repo *repo_model.Repository, permissionInRepo access_model.Permission) *api.Repository { +func ToRepo(ctx stdCtx.Context, repo *repo_model.Repository, permissionInRepo access_model.Permission) *api.Repository { return innerToRepo(ctx, repo, permissionInRepo, false) } -func innerToRepo(ctx context.Context, repo *repo_model.Repository, permissionInRepo access_model.Permission, isParent bool) *api.Repository { +func innerToRepo(ctx stdCtx.Context, repo *repo_model.Repository, permissionInRepo access_model.Permission, isParent bool) *api.Repository { var parent *api.Repository if permissionInRepo.Units == nil && permissionInRepo.UnitsMode == nil { @@ -179,9 +180,19 @@ func innerToRepo(ctx context.Context, repo *repo_model.Repository, permissionInR repoAPIURL := repo.APIURL() + // Calculate the effective permission for `ToUserWithAccessMode` for the repo owner. When accessing a public repo, + // permissionInRepo.AccessMode will be AccessModeRead even for an anonymous user -- in that case, downgrade + // `ownerViewPerms` to `AccessModeNone`. `innerToRepo` doesn't have great access to recognize an anonymous user, so + // the best-effort made here is to check if `ctx` is an `APIContext`. + ownerViewPerms := permissionInRepo.AccessMode + apiCtx, ok := ctx.(*context.APIContext) + if ok && apiCtx.Doer == nil { + ownerViewPerms = perm.AccessModeNone + } + return &api.Repository{ ID: repo.ID, - Owner: ToUserWithAccessMode(ctx, repo.Owner, permissionInRepo.AccessMode), + Owner: ToUserWithAccessMode(ctx, repo.Owner, ownerViewPerms), Name: repo.Name, FullName: repo.FullName(), Description: repo.Description, @@ -246,7 +257,7 @@ func innerToRepo(ctx context.Context, repo *repo_model.Repository, permissionInR } // ToRepoTransfer convert a models.RepoTransfer to a structs.RepeTransfer -func ToRepoTransfer(ctx context.Context, t *models.RepoTransfer) *api.RepoTransfer { +func ToRepoTransfer(ctx stdCtx.Context, t *models.RepoTransfer) *api.RepoTransfer { teams, _ := ToTeams(ctx, t.Teams, false) return &api.RepoTransfer{ diff --git a/tests/integration/api_repo_test.go b/tests/integration/api_repo_test.go index 1d24e51dfb..0521ce7584 100644 --- a/tests/integration/api_repo_test.go +++ b/tests/integration/api_repo_test.go @@ -289,6 +289,39 @@ func TestAPIViewRepo(t *testing.T) { assert.EqualValues(t, 1, repo.Stars) } +// Validate that private information on the user profile isn't exposed by way of being an owner of a public repository. +func TestAPIViewRepoOwnerSettings(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + var repo api.Repository + + req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1") + resp := MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &repo) + assert.EqualValues(t, 1, repo.ID) + assert.Equal(t, "user2@noreply.example.org", repo.Owner.Email) // unauthed, always private + assert.Empty(t, repo.Owner.Pronouns) // user2.keep_pronouns_private = true + + session := loginUser(t, "user2") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository) + req = NewRequest(t, "GET", "/api/v1/repos/user2/repo1").AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &repo) + assert.Equal(t, "user2@noreply.example.org", repo.Owner.Email) // user2.keep_email_private = true + assert.Equal(t, "he/him", repo.Owner.Pronouns) // user2.keep_pronouns_private = true + + req = NewRequest(t, "GET", "/api/v1/repos/user12/repo10") + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &repo) + assert.EqualValues(t, 10, repo.ID) + assert.Equal(t, "user12@noreply.example.org", repo.Owner.Email) // unauthed, always private + + req = NewRequest(t, "GET", "/api/v1/repos/user12/repo10").AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &repo) + assert.Equal(t, "user12@example.com", repo.Owner.Email) // user2.keep_email_private = false +} + func TestAPIOrgRepos(t *testing.T) { defer tests.PrepareTestEnv(t)() user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) From 7f374db798756eafb16788ac66e367dad5f9e72a Mon Sep 17 00:00:00 2001 From: Mathieu Fenniak Date: Thu, 18 Dec 2025 21:41:15 -0700 Subject: [PATCH 2/8] fix: incorrect whitespace handling on pre&post receive hooks --- cmd/hook.go | 4 +- cmd/hook_test.go | 133 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 2 deletions(-) diff --git a/cmd/hook.go b/cmd/hook.go index 1630c41edd..971c988b5c 100644 --- a/cmd/hook.go +++ b/cmd/hook.go @@ -231,7 +231,7 @@ Forgejo or set your environment appropriately.`, "") continue } - fields := bytes.Fields(scanner.Bytes()) + fields := bytes.Split(scanner.Bytes(), []byte(" ")) if len(fields) != 3 { continue } @@ -397,7 +397,7 @@ Forgejo or set your environment appropriately.`, "") continue } - fields := bytes.Fields(scanner.Bytes()) + fields := bytes.Split(scanner.Bytes(), []byte(" ")) if len(fields) != 3 { continue } diff --git a/cmd/hook_test.go b/cmd/hook_test.go index 89bd3cf737..067c978bdb 100644 --- a/cmd/hook_test.go +++ b/cmd/hook_test.go @@ -14,6 +14,9 @@ import ( "testing" "time" + "forgejo.org/modules/git" + "forgejo.org/modules/json" + "forgejo.org/modules/private" "forgejo.org/modules/setting" "forgejo.org/modules/test" @@ -162,6 +165,136 @@ func TestDelayWriter(t *testing.T) { }) } +func TestRunHookPrePostReceive(t *testing.T) { + // Setup the environment. + defer test.MockVariableValue(&setting.InternalToken, "Random")() + defer test.MockVariableValue(&setting.InstallLock, true)() + defer test.MockVariableValue(&setting.Git.VerbosePush, true)() + t.Setenv("SSH_ORIGINAL_COMMAND", "true") + + tests := []struct { + name string + inputLine string + oldCommitID string + newCommitID string + refFullName string + }{ + { + name: "base case", + inputLine: "00000000000000000000 00000000000000000001 refs/head/main\n", + oldCommitID: "00000000000000000000", + newCommitID: "00000000000000000001", + refFullName: "refs/head/main", + }, + { + name: "nbsp case", + inputLine: "00000000000000000000 00000000000000000001 refs/head/ma\u00A0in\n", + oldCommitID: "00000000000000000000", + newCommitID: "00000000000000000001", + refFullName: "refs/head/ma\u00A0in", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Setup the Stdin. + f, err := os.OpenFile(t.TempDir()+"/stdin", os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o666) + require.NoError(t, err) + _, err = f.Write([]byte(tt.inputLine)) + require.NoError(t, err) + _, err = f.Seek(0, 0) + require.NoError(t, err) + defer test.MockVariableValue(os.Stdin, *f)() + + // Setup the server that processes the hooks. + var serverError error + var hookOpts *private.HookOptions + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + serverError = err + w.WriteHeader(500) + return + } + + err = json.Unmarshal(body, &hookOpts) + if err != nil { + serverError = err + w.WriteHeader(500) + return + } + + w.WriteHeader(200) + + resp := &private.HookPostReceiveResult{} + bytes, err := json.Marshal(resp) + if err != nil { + serverError = err + return + } + + _, err = w.Write(bytes) + if err != nil { + serverError = err + return + } + })) + defer ts.Close() + defer test.MockVariableValue(&setting.LocalURL, ts.URL+"/")() + + t.Run("pre-receive", func(t *testing.T) { + app := cli.NewApp() + app.Commands = []*cli.Command{subcmdHookPreReceive} + + finish := captureOutput(t, os.Stdout) + err = app.Run([]string{"./forgejo", "pre-receive"}) + require.NoError(t, err) + out := finish() + require.Empty(t, out) + + require.NoError(t, serverError) + require.NotNil(t, hookOpts) + + require.Len(t, hookOpts.OldCommitIDs, 1) + assert.Equal(t, tt.oldCommitID, hookOpts.OldCommitIDs[0]) + require.Len(t, hookOpts.NewCommitIDs, 1) + assert.Equal(t, tt.newCommitID, hookOpts.NewCommitIDs[0]) + require.Len(t, hookOpts.RefFullNames, 1) + assert.Equal(t, git.RefName(tt.refFullName), hookOpts.RefFullNames[0]) + }) + + // seek stdin back to beginning + _, err = f.Seek(0, 0) + require.NoError(t, err) + // reset state from prev test + serverError = nil + hookOpts = nil + + t.Run("post-receive", func(t *testing.T) { + app := cli.NewApp() + app.Commands = []*cli.Command{subcmdHookPostReceive} + + finish := captureOutput(t, os.Stdout) + err = app.Run([]string{"./forgejo", "post-receive"}) + require.NoError(t, err) + out := finish() + require.Empty(t, out) + + require.NoError(t, serverError) + require.NotNil(t, hookOpts) + + require.Len(t, hookOpts.OldCommitIDs, 1) + assert.Equal(t, tt.oldCommitID, hookOpts.OldCommitIDs[0]) + require.Len(t, hookOpts.NewCommitIDs, 1) + assert.Equal(t, tt.newCommitID, hookOpts.NewCommitIDs[0]) + require.Len(t, hookOpts.RefFullNames, 1) + assert.Equal(t, git.RefName(tt.refFullName), hookOpts.RefFullNames[0]) + }) + }) + } +} + func TestRunHookUpdate(t *testing.T) { app := cli.NewApp() app.Commands = []*cli.Command{subcmdHookUpdate} From dd2f8a1352d53c9d3bb2577144ff09a8a21d3261 Mon Sep 17 00:00:00 2001 From: Mathieu Fenniak Date: Fri, 26 Dec 2025 20:26:37 -0700 Subject: [PATCH 3/8] fix: reduce memory usage while processing large attachment uploads --- services/context/api.go | 2 +- services/context/context.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/services/context/api.go b/services/context/api.go index 37f0e0f559..ceec69a3df 100644 --- a/services/context/api.go +++ b/services/context/api.go @@ -290,7 +290,7 @@ func APIContexter() func(http.Handler) http.Handler { // If request sends files, parse them here otherwise the Query() can't be parsed and the CsrfToken will be invalid. if ctx.Req.Method == "POST" && strings.Contains(ctx.Req.Header.Get("Content-Type"), "multipart/form-data") { - if err := ctx.Req.ParseMultipartForm(setting.Attachment.MaxSize << 20); err != nil && !strings.Contains(err.Error(), "EOF") { // 32MB max size + if err := ctx.Req.ParseMultipartForm(32 << 20); err != nil && !strings.Contains(err.Error(), "EOF") { // 32MB max size ctx.InternalServerError(err) return } diff --git a/services/context/context.go b/services/context/context.go index 91484c5ba3..8ecfcd04c3 100644 --- a/services/context/context.go +++ b/services/context/context.go @@ -181,7 +181,7 @@ func Contexter() func(next http.Handler) http.Handler { // If request sends files, parse them here otherwise the Query() can't be parsed and the CsrfToken will be invalid. if ctx.Req.Method == "POST" && strings.Contains(ctx.Req.Header.Get("Content-Type"), "multipart/form-data") { - if err := ctx.Req.ParseMultipartForm(setting.Attachment.MaxSize << 20); err != nil && !strings.Contains(err.Error(), "EOF") { // 32MB max size + if err := ctx.Req.ParseMultipartForm(32 << 20); err != nil && !strings.Contains(err.Error(), "EOF") { // 32MB max size ctx.ServerError("ParseMultipartForm", err) return } From bc402472e8f6ef6e8b19f989e0e2a6782e3777f3 Mon Sep 17 00:00:00 2001 From: Gusted Date: Tue, 30 Dec 2025 05:05:38 +0100 Subject: [PATCH 4/8] fix: use correct GPG key for export `GPGKeyToEntity` incorrectly assumed that within a keyring with multiple keys that the first key is verified and should be exported. Look at all keys and find the one that matches the verified key ID. --- models/asymkey/gpg_key.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/models/asymkey/gpg_key.go b/models/asymkey/gpg_key.go index b7e10ce85c..ba3aae395c 100644 --- a/models/asymkey/gpg_key.go +++ b/models/asymkey/gpg_key.go @@ -110,7 +110,13 @@ func GPGKeyToEntity(ctx context.Context, k *GPGKey) (*openpgp.Entity, error) { if err != nil { return nil, err } - return keys[0], err + + for _, key := range keys { + if key.PrimaryKey.KeyIdString() == k.KeyID { + return key, nil + } + } + return nil, fmt.Errorf("key with %s id not found", k.KeyID) } // parseSubGPGKey parse a sub Key From 09c95df7a4829a75357b9b55b5ed8247bf7af8ea Mon Sep 17 00:00:00 2001 From: Mathieu Fenniak Date: Tue, 6 Jan 2026 10:57:14 -0700 Subject: [PATCH 5/8] test: backport SleepTillNextMinute --- modules/test/utils.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/modules/test/utils.go b/modules/test/utils.go index f60bad022e..2595cf0894 100644 --- a/modules/test/utils.go +++ b/modules/test/utils.go @@ -7,6 +7,7 @@ import ( "net/http" "net/http/httptest" "strings" + "time" "forgejo.org/modules/json" ) @@ -46,3 +47,9 @@ func MockProtect[T any](p *T) (reset func()) { old := *p return func() { *p = old } } + +// When this is called, sleep until the truncated unix time to a minute was +// increased by one. +func SleepTillNextMinute() { + time.Sleep(time.Minute - time.Since(time.Now().Truncate(time.Minute))) +} From 69a85a673f5c92c3939c27a4dbf29a72be56241a Mon Sep 17 00:00:00 2001 From: Gusted Date: Wed, 31 Dec 2025 04:00:36 +0100 Subject: [PATCH 6/8] chore: add integration test Add a integration test that verifies that only the verified key is shown in `{user}.gpg`. --- tests/integration/api_gpg_keys_test.go | 228 +++++++++++++++++++++++++ 1 file changed, 228 insertions(+) diff --git a/tests/integration/api_gpg_keys_test.go b/tests/integration/api_gpg_keys_test.go index 1acbf91de0..5f2b16b048 100644 --- a/tests/integration/api_gpg_keys_test.go +++ b/tests/integration/api_gpg_keys_test.go @@ -1,19 +1,28 @@ // Copyright 2017 The Gogs Authors. All rights reserved. +// Copyright 2025 The Forgejo Authors. All rights reserved. // SPDX-License-Identifier: MIT package integration import ( + "bytes" "net/http" "net/http/httptest" "strconv" + "strings" "testing" + "time" auth_model "forgejo.org/models/auth" api "forgejo.org/modules/structs" + "forgejo.org/modules/test" + "forgejo.org/services/context" "forgejo.org/tests" + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/ProtonMail/go-crypto/openpgp/armor" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) type makeRequestFunc func(testing.TB, *RequestWrapper, int) *httptest.ResponseRecorder @@ -277,3 +286,222 @@ mIMMn8taHIaQO7v9ln2EVQYTzbNCmwTw9ovTM0j/Pbkg2EftfP1TCoxQHvBnsCED =MgDv -----END PGP PUBLIC KEY BLOCK-----`) } + +func TestAPIGPGMultipleKeys(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // Token used for verification is valid for each minute, to prevent + // this test being flaky wait till the next minute if we have less than + // five seconds until the next minute. + if _, _, sec := time.Now().Clock(); sec >= 55 { + test.SleepTillNextMinute() + } + + session := loginUser(t, "user2") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser) + + publicKeyring := `-----BEGIN PGP PUBLIC KEY BLOCK----- + +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 +=3agD +-----END PGP PUBLIC KEY BLOCK-----` + + privateKeyring := `-----BEGIN PGP PRIVATE KEY BLOCK----- + +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 +=9to/ +-----END PGP PRIVATE KEY BLOCK----- +` + block, err := armor.Decode(strings.NewReader(privateKeyring)) + require.NoError(t, err) + keyring, err := openpgp.ReadKeyRing(block.Body) + require.NoError(t, err) + assert.Len(t, keyring, 1) + + var verificationToken string + t.Run("No signature provided", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "POST", "/api/v1/user/gpg_keys", api.CreateGPGKeyOption{ + ArmoredKey: publicKeyring, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusNotFound) + + var apiError context.APIError + DecodeJSON(t, resp, &apiError) + + var ok bool + _, verificationToken, ok = strings.Cut(apiError.Message, ": ") + assert.True(t, ok) + }) + + t.Run("Signature provided", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + signatureOutput := &bytes.Buffer{} + require.NoError(t, openpgp.ArmoredDetachSign(signatureOutput, keyring[0], strings.NewReader(verificationToken), nil)) + + req := NewRequestWithJSON(t, "POST", "/api/v1/user/gpg_keys", api.CreateGPGKeyOption{ + ArmoredKey: publicKeyring, + Signature: signatureOutput.String(), + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + }) + + t.Run("{user}.gpg", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user2.gpg") + resp := session.MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, `-----BEGIN PGP PUBLIC KEY BLOCK----- + +xsFNBGlUhjYBEADHuyzOjbPk3U0qC85lGTecJBKb1CgUZ0xphv5wNg1TBzLjQLRB +DzbnhGAROTjS08dESwrG5sf8lNKRRr9rRIKQ7p+OqhK2AkOqGC25vB6vJSBhUYNy +UWDqV02XSmNy33FlwAgalIiff12hpZ+PctmzFcBY6TJ7Zh1dIKvS17CNZvkfjb/r +m1r8VQk8I4qdgVUg8Ky898T8B6/sAHhKcDZNXXoOLpCCUsm6STCVlp+YE0fN3H6u +UiyJ++zjrS85fzV9Ug+rDZr+V/ZiOc5e+mqIo51L9m9VfFsmZfJDZvPBcFJgcISv +S7LySW/lJvZ+ntKoFANzxzlLOUNYWJv+X/tYzJIoiu4J9NAuUwieFU85CYNbz9aO +7BUlVvNDVNn2sSTX/Aum9nqLvdi17pQRNmp7NFrmSzD9GbvD1G4C51tDesnCJI/j +m5QQpor8PeiOAcaHey/GFIS0+e1nytT3RU72P8M22u44QVp+WSAeahnsEBHVLbA7 +BUoShykXul65d9Tko2H6lf4s5ggi66mXPvHH1O0Ry57JEcJVAFHpp+teckmXf8zD +a+/ha4U+RtJkkJ4RI6mPrFnamx57mYPT2ji0igpU2paAtIGhbDxSi5UD3ZycdmDj +Tw/uUTxl/lon+N7DVEFC+2F8HD2Aiw6TJ92AWs3CCZRsURMphH1W7cskvQARAQAB +zRZIYWhhIHVzZXIyQGV4YW1wbGUuY29twsGOBBMBCgA4FiEEr+MV6CFVMbWYwDdi +we3dkWyHwqYFAmlUhjYCGwMFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AACgkQwe3d +kWyHwqapNhAAlPbcf96Ntyk0vttBOSDS/owq1fk2I/h66NgDK0G3601JT0JhFv1P +nY9YV5w5rzuTvCLbQWALr8UD52B8Ua6fEn5XjiLf6dU6OuZnYqIgTqi2qm4xCuNh +p5E1UqNLBb6Nv225R1CB6XGEda+wFQkTRif2e+bQOi4/+hl6tp6tQXkqAMdJAhD5 +3v0nvvpExHkPtRyfnoP2pFLC3Oic1fVlNUD9Gz2U/rL+BkKVFTRXHfsdf1eP5osO +HbgXe8S8xWAW+K8+FeU/5uyzx6HVIMHvvu8ZxefEvkuFyBtzeikOulp7gY0OBSpr +ysRbPGUHXz2W+l3t21L3QKx++gq+aPW3ILkzyK0ovtXoJdU0QeJmbM7UaqfkYHcU +ut9HaohCheLIcWlpTaqJlqMeSxx+I0NHIbRGILM2Db0DWRms0zZxYIywtlJlolSP +hJeq3jd/Yc334CXVtY/bU8pxInJewzUtAqMhJ8AUvUfsUpmaMUg0Vkqv7NrZBhL7 +TFZXsVpw1M3Vc5nF8cfX8GfDh16cmFx9Tpz9SrVPSVAwiK1V+Lp9p6CGPZhE3Y33 +b94YUmt+CvWxKx8OeiUGEqLG3W/KpXbnYUUetyCtCMikdmaGPpvq0ifDv5AEt3eZ +DWcqZOZ1dSTG9wmKghkVrgnB6Zow4c62WFtrDtFoN0XAjC42KsufgNQ= +=766/ +-----END PGP PUBLIC KEY BLOCK-----`, resp.Body.String()) + }) +} From 97844fa8a8dc583dcb6ea3a32af972ab1e1b13eb Mon Sep 17 00:00:00 2001 From: Gusted Date: Tue, 30 Dec 2025 02:23:15 +0100 Subject: [PATCH 7/8] fix: load reviewer for pull review dismiss action notifier This was implicitly loaded during the mail notifications notifier. If you disable mail notifications on Forgejo then this will result in the reviewer not being loaded and NPE. --- services/feed/action.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/services/feed/action.go b/services/feed/action.go index a2cd0551a3..483ecbbde5 100644 --- a/services/feed/action.go +++ b/services/feed/action.go @@ -300,6 +300,10 @@ func (*actionNotifier) AutoMergePullRequest(ctx context.Context, doer *user_mode } func (*actionNotifier) NotifyPullRevieweDismiss(ctx context.Context, doer *user_model.User, review *issues_model.Review, comment *issues_model.Comment) { + if err := review.LoadReviewer(ctx); err != nil { + log.Error("LoadReviewer '%d/%d': %v", review.ID, review.ReviewerID, err) + return + } reviewerName := review.Reviewer.Name if len(review.OriginalAuthor) > 0 { reviewerName = review.OriginalAuthor From 7b274ac6d2462fc63756ec9c158556b346fb51a5 Mon Sep 17 00:00:00 2001 From: Mathieu Fenniak Date: Tue, 6 Jan 2026 09:54:52 -0700 Subject: [PATCH 8/8] doc: add release notes for Jan 8 security release --- release-notes/10722.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 release-notes/10722.md diff --git a/release-notes/10722.md b/release-notes/10722.md new file mode 100644 index 0000000000..807e660339 --- /dev/null +++ b/release-notes/10722.md @@ -0,0 +1,5 @@ +fix: hide user profile anonymous options on public repo APIs +fix: incorrect whitespace handling on pre&post receive hooks +fix: reduce memory usage while processing large attachment uploads +fix: load reviewer for pull review dismiss action notifier +fix: use correct GPG key for export