From c385564539130eb87f7a5a2f6bc2bdbf2c3eab3c 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 00aa182540..ac6a6c4a3c 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 e81f4307ee..9c7ef820ca 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.Equal(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 1da28e92074eebdfe89f8124c65cbc6389b402eb 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 7378dc21ad..a9a2ba75e2 100644 --- a/cmd/hook.go +++ b/cmd/hook.go @@ -237,7 +237,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 82ed392fb8..1e135222ec 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.Command{} + app.Commands = []*cli.Command{subcmdHookPreReceive()} + + finish := captureOutput(t, os.Stdout) + err = app.Run(t.Context(), []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.Command{} + app.Commands = []*cli.Command{subcmdHookPostReceive()} + + finish := captureOutput(t, os.Stdout) + err = app.Run(t.Context(), []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.Command{} app.Commands = []*cli.Command{subcmdHookUpdate()} From df370dd0beb3f380ee43c992412a1e60895382eb 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 19e0c04911..9c478eacbb 100644 --- a/services/context/api.go +++ b/services/context/api.go @@ -291,7 +291,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 68074964c8..7c46fb7735 100644 --- a/services/context/context.go +++ b/services/context/context.go @@ -192,7 +192,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 4944f1db2a9573f965e2c774922acf65c261632a 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 64866da076..cae717011e 100644 --- a/models/asymkey/gpg_key.go +++ b/models/asymkey/gpg_key.go @@ -111,7 +111,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 f42870cedab9bba3e068b25ee0361d89d12ebb35 Mon Sep 17 00:00:00 2001 From: Gusted Date: Wed, 31 Dec 2025 04:00:36 +0100 Subject: [PATCH 5/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 b042c2ce6b..cec5d45bd2 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 a5b4503e2ba9eaf413abf5773a64acc23ccb10d7 Mon Sep 17 00:00:00 2001 From: Mathieu Fenniak Date: Tue, 6 Jan 2026 10:57:14 -0700 Subject: [PATCH 6/8] test: backport SleepTillNextMinute --- modules/test/utils.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/modules/test/utils.go b/modules/test/utils.go index db131f19d0..af22872f44 100644 --- a/modules/test/utils.go +++ b/modules/test/utils.go @@ -52,3 +52,9 @@ func MockProtect[T any](p *T) (reset func()) { func SleepTillNextSecond() { time.Sleep(time.Second - time.Since(time.Now().Truncate(time.Second))) } + +// 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 4508a047f9f92d8665f5e388e597438a0861167d 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 96de080691..96ed154064 100644 --- a/services/feed/action.go +++ b/services/feed/action.go @@ -308,6 +308,10 @@ func (*actionNotifier) AutoMergePullRequest(ctx context.Context, doer *user_mode } func (*actionNotifier) PullReviewDismiss(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 a0f2289bb47766a6359c5b585ddfb5cadfffa26b 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/10721.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 release-notes/10721.md diff --git a/release-notes/10721.md b/release-notes/10721.md new file mode 100644 index 0000000000..807e660339 --- /dev/null +++ b/release-notes/10721.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