You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@answer.apache.org by li...@apache.org on 2026/10/12 06:58:39 UTC

(answer) 02/02: fix(security): enforce profile content visibility

This is an automated email from the ASF dual-hosted git repository.

LinkinStars pushed a commit to branch fix/2.0.4/user
in repository https://gitbox.apache.org/repos/asf/answer.git

commit b45786f0a2c28c99d5851f62ef76ce75b2733bca
Author: LinkinStars <li...@foxmail.com>
AuthorDate: Sat Oct 10 13:58:01 2026 +0800

    fix(security): enforce profile content visibility
    
    Apply consistent public visibility rules when building profile content.
---
 internal/entity/answer_entity.go                   | 11 ++--
 internal/repo/answer/answer_repo.go                | 21 +++++---
 .../repo_test/personal_answer_visibility_test.go   | 58 ++++++++++++++++++++++
 internal/service/content/question_service.go       | 10 +++-
 4 files changed, 86 insertions(+), 14 deletions(-)

diff --git a/internal/entity/answer_entity.go b/internal/entity/answer_entity.go
index 0e93aa97..2c825391 100644
--- a/internal/entity/answer_entity.go
+++ b/internal/entity/answer_entity.go
@@ -57,11 +57,12 @@ type Answer struct {
 
 type AnswerSearch struct {
 	Answer
-	IncludeDeleted bool   `json:"include_deleted"`
-	LoginUserID    string `json:"login_user_id"`
-	Order          string `json:"order_by"`                   // default or updated
-	Page           int    `json:"page" form:"page"`           // Query number of pages
-	PageSize       int    `json:"page_size" form:"page_size"` // Search page size
+	IncludeDeleted     bool   `json:"include_deleted"`
+	OnlyPublicQuestion bool   `json:"-"`
+	LoginUserID        string `json:"login_user_id"`
+	Order              string `json:"order_by"`                   // default or updated
+	Page               int    `json:"page" form:"page"`           // Query number of pages
+	PageSize           int    `json:"page_size" form:"page_size"` // Search page size
 }
 
 type PersonalAnswerPageQueryCond struct {
diff --git a/internal/repo/answer/answer_repo.go b/internal/repo/answer/answer_repo.go
index 615d8dfb..40f78b3a 100644
--- a/internal/repo/answer/answer_repo.go
+++ b/internal/repo/answer/answer_repo.go
@@ -348,28 +348,33 @@ func (ar *answerRepo) SearchList(ctx context.Context, search *entity.AnswerSearc
 	}
 	offset := search.Page * search.PageSize
 	session := ar.data.DB.Context(ctx)
+	if search.OnlyPublicQuestion {
+		session = session.Join("INNER", "question", "answer.question_id = question.id").
+			And("question.show = ? AND question.status IN (?, ?)",
+				entity.QuestionShow, entity.QuestionStatusAvailable, entity.QuestionStatusClosed)
+	}
 
 	if search.QuestionID != "" {
-		session = session.And("question_id = ?", search.QuestionID)
+		session = session.And("answer.question_id = ?", search.QuestionID)
 	}
 	if len(search.UserID) > 0 {
-		session = session.And("user_id = ?", search.UserID)
+		session = session.And("answer.user_id = ?", search.UserID)
 	}
 	switch search.Order {
 	case entity.AnswerSearchOrderByTime:
-		session = session.OrderBy("created_at desc")
+		session = session.OrderBy("answer.created_at desc")
 	case entity.AnswerSearchOrderByTimeAsc:
-		session = session.OrderBy("created_at asc")
+		session = session.OrderBy("answer.created_at asc")
 	case entity.AnswerSearchOrderByVote:
-		session = session.OrderBy("vote_count desc")
+		session = session.OrderBy("answer.vote_count desc")
 	default:
-		session = session.OrderBy("adopted desc,vote_count desc,created_at asc")
+		session = session.OrderBy("answer.adopted desc,answer.vote_count desc,answer.created_at asc")
 	}
 	if !search.IncludeDeleted {
 		if search.LoginUserID == "" {
-			session = session.And("status = ? ", entity.AnswerStatusAvailable)
+			session = session.And("answer.status = ? ", entity.AnswerStatusAvailable)
 		} else {
-			session = session.And("status = ? OR user_id = ?", entity.AnswerStatusAvailable, search.LoginUserID)
+			session = session.And("answer.status = ? OR answer.user_id = ?", entity.AnswerStatusAvailable, search.LoginUserID)
 		}
 	}
 
diff --git a/internal/repo/repo_test/personal_answer_visibility_test.go b/internal/repo/repo_test/personal_answer_visibility_test.go
index f50105aa..254ddfd2 100644
--- a/internal/repo/repo_test/personal_answer_visibility_test.go
+++ b/internal/repo/repo_test/personal_answer_visibility_test.go
@@ -87,3 +87,61 @@ func TestPersonalAnswerPageRespectsHiddenQuestionVisibility(t *testing.T) {
 	require.Equal(t, int64(2), ownerTotal)
 	require.Len(t, ownerAnswers, 2)
 }
+
+func TestAnswerSearchRespectsPublicParentQuestionVisibility(t *testing.T) {
+	ctx := context.Background()
+	questionRepo := question.NewQuestionRepo(testDataSource, unique.NewUniqueIDRepo(testDataSource))
+	answerRepo := answer.NewAnswerRepo(testDataSource, nil, nil, nil)
+
+	newQuestion := func(title string, status, show int) *entity.Question {
+		q := &entity.Question{
+			UserID:       "top-answer-question-owner",
+			Title:        title,
+			OriginalText: title,
+			ParsedText:   title,
+			Status:       status,
+			Show:         show,
+		}
+		require.NoError(t, questionRepo.AddQuestion(ctx, q))
+		return q
+	}
+	visibleQuestion := newQuestion("visible question", entity.QuestionStatusAvailable, entity.QuestionShow)
+	closedQuestion := newQuestion("closed question", entity.QuestionStatusClosed, entity.QuestionShow)
+	hiddenQuestion := newQuestion("hidden question", entity.QuestionStatusAvailable, entity.QuestionHide)
+	pendingQuestion := newQuestion("pending question", entity.QuestionStatusPending, entity.QuestionShow)
+	deletedQuestion := newQuestion("deleted question", entity.QuestionStatusDeleted, entity.QuestionShow)
+	questions := []*entity.Question{visibleQuestion, closedQuestion, hiddenQuestion, pendingQuestion, deletedQuestion}
+	answers := make([]*entity.Answer, 0, len(questions))
+	for _, q := range questions {
+		item := &entity.Answer{QuestionID: q.ID, UserID: "top-answer-author", OriginalText: "answer", ParsedText: "answer", Status: entity.AnswerStatusAvailable}
+		_, err := testDataSource.DB.Context(ctx).Insert(item)
+		require.NoError(t, err)
+		answers = append(answers, item)
+	}
+	t.Cleanup(func() {
+		for _, item := range answers {
+			_, _ = testDataSource.DB.Context(ctx).ID(item.ID).Delete(&entity.Answer{})
+		}
+		for _, q := range questions {
+			_, _ = testDataSource.DB.Context(ctx).ID(q.ID).Delete(&entity.Question{})
+		}
+	})
+
+	publicAnswers, total, err := answerRepo.SearchList(ctx, &entity.AnswerSearch{
+		Answer:             entity.Answer{UserID: "top-answer-author"},
+		OnlyPublicQuestion: true,
+		PageSize:           10,
+	})
+	require.NoError(t, err)
+	require.Equal(t, int64(2), total)
+	require.Len(t, publicAnswers, 2)
+	questionIDs := map[string]bool{}
+	for _, item := range publicAnswers {
+		questionIDs[item.QuestionID] = true
+	}
+	require.True(t, questionIDs[visibleQuestion.ID])
+	require.True(t, questionIDs[closedQuestion.ID])
+	require.False(t, questionIDs[hiddenQuestion.ID])
+	require.False(t, questionIDs[pendingQuestion.ID])
+	require.False(t, questionIDs[deletedQuestion.ID])
+}
diff --git a/internal/service/content/question_service.go b/internal/service/content/question_service.go
index ed33f9ea..3350ef9b 100644
--- a/internal/service/content/question_service.go
+++ b/internal/service/content/question_service.go
@@ -1319,6 +1319,10 @@ func (qs *QuestionService) PersonalCollectionPage(ctx context.Context, req *sche
 }
 
 func canViewPersonalCollectionQuestion(question *schema.QuestionInfoResp, userID string, isAdminModerator bool) bool {
+	return canViewQuestion(question, userID, isAdminModerator)
+}
+
+func canViewQuestion(question *schema.QuestionInfoResp, userID string, isAdminModerator bool) bool {
 	return (&schema.SimpleObjectInfo{
 		ObjectType:            constant.QuestionObjectType,
 		QuestionCreatorUserID: question.UserID,
@@ -1354,6 +1358,7 @@ func (qs *QuestionService) SearchUserTopList(ctx context.Context, userName strin
 	answersearch.UserID = userinfo.ID
 	answersearch.PageSize = 5
 	answersearch.Order = entity.AnswerSearchOrderByVote
+	answersearch.OnlyPublicQuestion = true
 	questionIDs := make([]string, 0)
 	answerList, _, err := qs.questioncommon.AnswerCommon.Search(ctx, answersearch)
 	if err != nil {
@@ -1370,7 +1375,7 @@ func (qs *QuestionService) SearchUserTopList(ctx context.Context, userName strin
 	}
 	for _, item := range answerlist {
 		_, ok := questionMaps[item.QuestionID]
-		if ok {
+		if ok && canViewQuestion(questionMaps[item.QuestionID], "", false) {
 			item.QuestionInfo = questionMaps[item.QuestionID]
 		}
 	}
@@ -1383,6 +1388,9 @@ func (qs *QuestionService) SearchUserTopList(ctx context.Context, userName strin
 	}
 
 	for _, item := range answerlist {
+		if item.QuestionInfo == nil {
+			continue
+		}
 		info := &schema.UserAnswerInfo{}
 		_ = copier.Copy(info, item)
 		info.AnswerID = item.ID