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/09/22 08:04:10 UTC

(answer) branch dev updated (736dde0e -> 6c00c788)

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

LinkinStars pushed a change to branch dev
in repository https://gitbox.apache.org/repos/asf/answer.git


    from 736dde0e build: replace Create React App with Vite (#1616)
     new 66bbc744 fix: avoid sensitive auth logging
     new 1a8627e2 fix: normalize imported content
     new 4e4d4387 fix: align question operation permissions
     new 9ace9007 fix: strengthen request safeguards
     new 625ead8f fix: enforce question access controls
     new 6c2c2569 fix: constrain MCP content access
     new 1fdf7af3 chore: satisfy static checks
     new a0d90268 fix: hide hidden questions from public answer profiles
     new e0daae42 fix(security): enforce personal comment visibility
     new 9eb83297 fix(security): restrict similar question visibility
     new c58049e1 fix(security): revoke stale visit tokens
     new 82c82330 fix(security): enforce mutation visibility checks
     new 3e15d992 fix: normalize allowed email domains
     new e9265e3f fix(security): preserve User Center session identity
     new f6075659 fix(security): enforce reaction content visibility
     new 6c00c788 chore: update version to 2.0.3 and adjust prerequisites in README

The 16 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 Makefile                                           |   2 +-
 README.md                                          |   4 +-
 cmd/wire_gen.go                                    |   2 +-
 i18n/en_US.yaml                                    |   3 +-
 i18n/zh_CN.yaml                                    |   3 +-
 internal/base/middleware/header.go                 |   4 +
 .../middleware/{short_id.go => header_test.go}     |  35 +++--
 internal/base/middleware/visit_img_auth.go         |   1 +
 internal/base/middleware/visit_img_auth_test.go    | 155 +++++++++++++++++++++
 internal/base/server/http.go                       |   2 +-
 internal/controller/answer_controller.go           |   3 +-
 internal/controller/comment_controller.go          |   9 +-
 internal/controller/mcp_controller.go              |  30 +++-
 internal/controller/mcp_controller_test.go         |  50 +++++++
 internal/controller/meta_controller.go             |   2 +
 internal/controller/question_controller.go         |  78 ++++++++---
 internal/controller/question_controller_test.go    |  49 +++++++
 internal/controller/report_controller.go           |   3 +-
 internal/controller/vote_controller.go             |   6 +-
 internal/entity/answer_entity.go                   |   1 +
 internal/repo/answer/answer_repo.go                |  16 ++-
 internal/repo/auth/auth.go                         |  16 +++
 internal/repo/question/question_repo.go            |  14 +-
 .../repo_test/personal_answer_visibility_test.go   |  89 ++++++++++++
 .../repo/repo_test/question_link_security_test.go  |  85 +++++++++++
 internal/router/static_router.go                   |  20 ++-
 .../lang.go => router/static_router_test.go}       |  35 +++--
 internal/schema/answer_schema.go                   |  23 +--
 internal/schema/comment_schema.go                  |   7 +-
 internal/schema/email_template.go                  |   7 +-
 ...r_activity_schema.go => email_template_test.go} |  42 ++----
 internal/schema/meta_schema.go                     |  14 +-
 internal/schema/question_schema.go                 |  14 +-
 internal/schema/report_schema.go                   |   7 +-
 internal/schema/vote_schema.go                     |  11 +-
 internal/service/auth/auth.go                      |  10 +-
 internal/service/auth/auth_test.go                 |  99 +++++++++++++
 internal/service/auth/user_center_status_test.go   | 111 +++++++++++++++
 internal/service/comment/comment_service.go        |  47 ++++---
 .../comment/comment_service_visibility_test.go     |  95 +++++++++++++
 internal/service/content/answer_service.go         |   8 ++
 internal/service/content/question_service.go       |  47 +++++--
 .../content/question_service_visibility_test.go    | 140 +++++++++++++++++++
 internal/service/content/user_service.go           |  31 +++--
 internal/service/content/user_service_test.go      |  43 ++++++
 internal/service/content/vote_service.go           |   6 +
 internal/service/importer/importer_service.go      |  29 ++--
 .../importer/importer_service_test.go}             |  31 ++---
 internal/service/meta/meta_service.go              |  22 +++
 .../service/meta/meta_service_visibility_test.go   | 103 ++++++++++++++
 internal/service/question_common/question.go       |   2 +-
 internal/service/report/report_service.go          |   3 +
 internal/service/uploader/upload.go                |  13 +-
 internal/service/user_admin/user_backyard.go       |  11 +-
 .../user_center_login_service.go                   |  28 +++-
 .../user_center_login_service_test.go              | 149 ++++++++++++++++++++
 pkg/checker/email.go                               |   3 +-
 pkg/checker/email_test.go                          |  45 ++++++
 pkg/checker/file_type.go                           |   2 +
 pkg/checker/{url.go => file_type_test.go}          |  25 ++--
 60 files changed, 1691 insertions(+), 254 deletions(-)
 copy internal/base/middleware/{short_id.go => header_test.go} (56%)
 create mode 100644 internal/base/middleware/visit_img_auth_test.go
 create mode 100644 internal/controller/mcp_controller_test.go
 create mode 100644 internal/controller/question_controller_test.go
 create mode 100644 internal/repo/repo_test/personal_answer_visibility_test.go
 create mode 100644 internal/repo/repo_test/question_link_security_test.go
 copy internal/{base/handler/lang.go => router/static_router_test.go} (56%)
 copy internal/schema/{answer_activity_schema.go => email_template_test.go} (50%)
 create mode 100644 internal/service/auth/auth_test.go
 create mode 100644 internal/service/auth/user_center_status_test.go
 create mode 100644 internal/service/comment/comment_service_visibility_test.go
 create mode 100644 internal/service/content/question_service_visibility_test.go
 copy internal/{base/middleware/user_center_plugin_auth.go => service/importer/importer_service_test.go} (61%)
 create mode 100644 internal/service/meta/meta_service_visibility_test.go
 create mode 100644 internal/service/user_external_login/user_center_login_service_test.go
 create mode 100644 pkg/checker/email_test.go
 copy pkg/checker/{url.go => file_type_test.go} (69%)


(answer) 07/16: chore: satisfy static checks

Posted by li...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

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

commit 1fdf7af38e9458cac895e251e94771e508224111
Author: LinkinStars <li...@foxmail.com>
AuthorDate: Mon Aug 17 11:32:39 2026 +0800

    chore: satisfy static checks
---
 internal/repo/question/question_repo.go | 7 ++++---
 internal/schema/email_template.go       | 8 ++------
 2 files changed, 6 insertions(+), 9 deletions(-)

diff --git a/internal/repo/question/question_repo.go b/internal/repo/question/question_repo.go
index 3f38fc6c..379b4380 100644
--- a/internal/repo/question/question_repo.go
+++ b/internal/repo/question/question_repo.go
@@ -833,11 +833,12 @@ func (qr *questionRepo) GetQuestionLink(ctx context.Context, page, pageSize int,
 		Distinct("question.id").
 		Where("question_link.status = ?", entity.QuestionLinkStatusAvailable).
 		Select("question.*")
-	if isAdminModerator {
+	switch {
+	case isAdminModerator:
 		session.Where("question.status IN (?, ?, ?)", entity.QuestionStatusAvailable, entity.QuestionStatusClosed, entity.QuestionStatusPending)
-	} else if loginUserID != "" {
+	case loginUserID != "":
 		session.Where("(question.status IN (?, ?) OR (question.status = ? AND question.user_id = ?))", entity.QuestionStatusAvailable, entity.QuestionStatusClosed, entity.QuestionStatusPending, loginUserID)
-	} else {
+	default:
 		session.In("question.status", []int{entity.QuestionStatusAvailable, entity.QuestionStatusClosed})
 	}
 
diff --git a/internal/schema/email_template.go b/internal/schema/email_template.go
index 57f1ac3c..e28cf90d 100644
--- a/internal/schema/email_template.go
+++ b/internal/schema/email_template.go
@@ -21,6 +21,7 @@ package schema
 
 import (
 	"encoding/json"
+	"slices"
 
 	"github.com/apache/answer/internal/base/constant"
 )
@@ -57,12 +58,7 @@ func (r *EmailCodeContent) FromJSONString(data string) error {
 }
 
 func (r *EmailCodeContent) IsSourceType(sourceTypes ...EmailSourceType) bool {
-	for _, sourceType := range sourceTypes {
-		if r.SourceType == sourceType {
-			return true
-		}
-	}
-	return false
+	return slices.Contains(sourceTypes, r.SourceType)
 }
 
 type RegisterTemplateData struct {


(answer) 11/16: fix(security): revoke stale visit tokens

Posted by li...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

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

commit c58049e169b3fa3a439195a78257681acd85364b
Author: LinkinStars <li...@foxmail.com>
AuthorDate: Tue Aug 25 17:29:24 2026 +0800

    fix(security): revoke stale visit tokens
---
 internal/base/middleware/visit_img_auth_test.go | 155 ++++++++++++++++++++++++
 internal/repo/auth/auth.go                      |  16 +++
 internal/service/auth/auth.go                   |   7 +-
 3 files changed, 175 insertions(+), 3 deletions(-)

diff --git a/internal/base/middleware/visit_img_auth_test.go b/internal/base/middleware/visit_img_auth_test.go
new file mode 100644
index 00000000..7d1858ce
--- /dev/null
+++ b/internal/base/middleware/visit_img_auth_test.go
@@ -0,0 +1,155 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package middleware
+
+import (
+	"context"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/apache/answer/internal/base/constant"
+	"github.com/apache/answer/internal/base/data"
+	"github.com/apache/answer/internal/entity"
+	authrepo "github.com/apache/answer/internal/repo/auth"
+	authservice "github.com/apache/answer/internal/service/auth"
+	"github.com/apache/answer/internal/service/siteinfo_common"
+	"github.com/gin-gonic/gin"
+)
+
+type visitAuthTestSiteInfoRepo struct{}
+
+func (visitAuthTestSiteInfoRepo) SaveByType(context.Context, string, *entity.SiteInfo) error {
+	return nil
+}
+
+func (visitAuthTestSiteInfoRepo) GetByType(context.Context, string, ...bool) (*entity.SiteInfo, bool, error) {
+	return &entity.SiteInfo{Content: `{"login_required":true}`}, true, nil
+}
+
+func (visitAuthTestSiteInfoRepo) IsBrandingFileUsed(context.Context, string) (bool, error) {
+	return false, nil
+}
+
+func TestVisitAuthRejectsRevokedAndSuspendedSessions(t *testing.T) {
+	ctx := context.Background()
+	cache, cleanup, err := data.NewCache(&data.CacheConf{})
+	if err != nil {
+		t.Fatalf("create cache: %v", err)
+	}
+	t.Cleanup(cleanup)
+
+	repo := authrepo.NewAuthRepo(&data.Data{Cache: cache})
+	service := authservice.NewAuthService(repo, nil)
+	accessToken, visitToken, err := service.SetUserCacheInfo(ctx, &entity.UserCacheInfo{
+		UserID:      "visit-auth-user",
+		UserStatus:  entity.UserStatusAvailable,
+		EmailStatus: entity.EmailStatusAvailable,
+	})
+	if err != nil {
+		t.Fatalf("create session: %v", err)
+	}
+
+	filePath := filepath.Join(t.TempDir(), "private.txt")
+	if err := os.WriteFile(filePath, []byte("private upload"), 0o600); err != nil {
+		t.Fatalf("create private upload: %v", err)
+	}
+	serve := func(token string) *httptest.ResponseRecorder {
+		gin.SetMode(gin.TestMode)
+		engine := gin.New()
+		siteInfo := siteinfo_common.NewSiteInfoCommonService(visitAuthTestSiteInfoRepo{})
+		authMiddleware := NewAuthUserMiddleware(service, siteInfo)
+		engine.Use(authMiddleware.VisitAuth())
+		engine.StaticFile("/uploads/post/private.txt", filePath)
+		req := httptest.NewRequest(http.MethodGet, "/uploads/post/private.txt", nil)
+		req.AddCookie(&http.Cookie{Name: constant.UserVisitCookiesCacheKey, Value: token})
+		recorder := httptest.NewRecorder()
+		engine.ServeHTTP(recorder, req)
+		return recorder
+	}
+
+	if response := serve(visitToken); response.Code != http.StatusOK {
+		t.Fatalf("fresh visit token returned %d, want %d", response.Code, http.StatusOK)
+	}
+
+	service.RemoveUserAllTokens(ctx, "visit-auth-user")
+	if userInfo, err := service.GetUserCacheInfo(ctx, accessToken); err != nil || userInfo != nil {
+		t.Fatalf("revoked access token remained valid: userInfo=%v err=%v", userInfo, err)
+	}
+	if response := serve(visitToken); response.Code != http.StatusFound || response.Header().Get("Location") != "/403" {
+		t.Fatalf("revoked visit token returned status=%d location=%q, want 302 /403", response.Code, response.Header().Get("Location"))
+	}
+
+	_, suspendedVisitToken, err := service.SetUserCacheInfo(ctx, &entity.UserCacheInfo{
+		UserID:      "suspended-visit-auth-user",
+		UserStatus:  entity.UserStatusAvailable,
+		EmailStatus: entity.EmailStatusAvailable,
+	})
+	if err != nil {
+		t.Fatalf("create suspended-user session: %v", err)
+	}
+	if err := service.SetUserStatus(ctx, &entity.UserCacheInfo{
+		UserID:      "suspended-visit-auth-user",
+		UserStatus:  entity.UserStatusSuspended,
+		EmailStatus: entity.EmailStatusAvailable,
+	}); err != nil {
+		t.Fatalf("suspend user: %v", err)
+	}
+	if response := serve(suspendedVisitToken); response.Code != http.StatusFound || response.Header().Get("Location") != "/403" {
+		t.Fatalf("suspended visit token returned status=%d location=%q, want 302 /403", response.Code, response.Header().Get("Location"))
+	}
+}
+
+func TestRemoveTokensExceptCurrentUserRevokesOnlyOtherVisitTokens(t *testing.T) {
+	ctx := context.Background()
+	cache, cleanup, err := data.NewCache(&data.CacheConf{})
+	if err != nil {
+		t.Fatalf("create cache: %v", err)
+	}
+	t.Cleanup(cleanup)
+
+	service := authservice.NewAuthService(authrepo.NewAuthRepo(&data.Data{Cache: cache}), nil)
+	currentAccessToken, currentVisitToken, err := service.SetUserCacheInfo(ctx, &entity.UserCacheInfo{
+		UserID:      "multi-session-user",
+		UserStatus:  entity.UserStatusAvailable,
+		EmailStatus: entity.EmailStatusAvailable,
+	})
+	if err != nil {
+		t.Fatalf("create current session: %v", err)
+	}
+	_, otherVisitToken, err := service.SetUserCacheInfo(ctx, &entity.UserCacheInfo{
+		UserID:      "multi-session-user",
+		UserStatus:  entity.UserStatusAvailable,
+		EmailStatus: entity.EmailStatusAvailable,
+	})
+	if err != nil {
+		t.Fatalf("create other session: %v", err)
+	}
+
+	service.RemoveTokensExceptCurrentUser(ctx, "multi-session-user", currentAccessToken)
+	if !service.CheckUserVisitToken(ctx, currentVisitToken) {
+		t.Fatal("current visit token was revoked")
+	}
+	if service.CheckUserVisitToken(ctx, otherVisitToken) {
+		t.Fatal("other visit token remained valid")
+	}
+}
diff --git a/internal/repo/auth/auth.go b/internal/repo/auth/auth.go
index 597352b2..7c957d78 100644
--- a/internal/repo/auth/auth.go
+++ b/internal/repo/auth/auth.go
@@ -224,6 +224,14 @@ func (ar *authRepo) RemoveUserTokens(ctx context.Context, userID string, remainT
 		if token == remainToken {
 			continue
 		}
+		userInfo, err := ar.GetUserCacheInfo(ctx, token)
+		if err != nil {
+			log.Error(err)
+		} else if userInfo != nil && len(userInfo.VisitToken) > 0 {
+			if err := ar.RemoveUserVisitCacheInfo(ctx, userInfo.VisitToken); err != nil {
+				log.Error(err)
+			}
+		}
 		if err := ar.RemoveUserCacheInfo(ctx, token); err != nil {
 			log.Error(err)
 		} else {
@@ -233,6 +241,14 @@ func (ar *authRepo) RemoveUserTokens(ctx context.Context, userID string, remainT
 	if err := ar.RemoveUserStatus(ctx, userID); err != nil {
 		log.Error(err)
 	}
+	if remainToken != "" {
+		mapping = map[string]bool{remainToken: true}
+		content, _ := json.Marshal(mapping)
+		if err := ar.data.Cache.SetString(ctx, key, string(content), constant.UserTokenCacheTime); err != nil {
+			log.Error(err)
+		}
+		return
+	}
 	if err := ar.data.Cache.Del(ctx, key); err != nil {
 		log.Error(err)
 	}
diff --git a/internal/service/auth/auth.go b/internal/service/auth/auth.go
index a7827c78..ac86b7ab 100644
--- a/internal/service/auth/auth.go
+++ b/internal/service/auth/auth.go
@@ -102,13 +102,14 @@ func (as *AuthService) SetUserCacheInfo(ctx context.Context, userInfo *entity.Us
 
 func (as *AuthService) CheckUserVisitToken(ctx context.Context, visitToken string) bool {
 	accessToken, err := as.authRepo.GetUserVisitCacheInfo(ctx, visitToken)
-	if err != nil {
+	if err != nil || len(accessToken) == 0 {
 		return false
 	}
-	if len(accessToken) == 0 {
+	userInfo, err := as.GetUserCacheInfo(ctx, accessToken)
+	if err != nil || userInfo == nil {
 		return false
 	}
-	return true
+	return userInfo.EmailStatus == entity.EmailStatusAvailable && userInfo.UserStatus == entity.UserStatusAvailable
 }
 
 func (as *AuthService) SetUserStatus(ctx context.Context, userInfo *entity.UserCacheInfo) (err error) {


(answer) 13/16: fix: normalize allowed email domains

Posted by li...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

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

commit 3e15d99251a3204c6ad08d0b5f4c072985582c83
Author: LinkinStars <li...@foxmail.com>
AuthorDate: Tue Sep 8 14:30:12 2026 +0800

    fix: normalize allowed email domains
---
 i18n/en_US.yaml           |  3 +--
 i18n/zh_CN.yaml           |  3 +--
 pkg/checker/email.go      |  3 ++-
 pkg/checker/email_test.go | 45 +++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 49 insertions(+), 5 deletions(-)

diff --git a/i18n/en_US.yaml b/i18n/en_US.yaml
index 5d1faa3e..ac4191be 100644
--- a/i18n/en_US.yaml
+++ b/i18n/en_US.yaml
@@ -2247,7 +2247,7 @@ ui:
         text: When enabled, users must verify their email address before using the site.
       allowed_email_domains:
         title: Allowed email domains
-        text: Email domains that users must register accounts with. One domain per line. Ignored when empty.
+        text: Email domains users must use to register accounts. Enter one domain per line, including the @ symbol (for example, @example.com). Ignored when empty.
       private:
         title: Private
         label: Login required
@@ -2488,4 +2488,3 @@ ui:
     copy: Copy to clipboard
     copied: Copied
     external_content_warning: External images/media are not displayed.
-
diff --git a/i18n/zh_CN.yaml b/i18n/zh_CN.yaml
index f16ed9fa..2ee0c1a4 100644
--- a/i18n/zh_CN.yaml
+++ b/i18n/zh_CN.yaml
@@ -2205,7 +2205,7 @@ ui:
         text: 关闭以阻止任何人通过邮箱创建新账户。
       allowed_email_domains:
         title: 允许的邮箱域
-        text: 允许注册账户的邮箱域。每行一个域名。留空时忽略。
+        text: 允许用于注册账户的邮箱域。每行一个域名,须包含 @ 符号(例如 @example.com)。留空时忽略。
       private:
         title: 非公开的
         label: 需要登录
@@ -2447,4 +2447,3 @@ ui:
     copied: 已复制
     external_content_warning: 外部图像/媒体未显示。
 
-
diff --git a/pkg/checker/email.go b/pkg/checker/email.go
index a9732fdf..0eaebe9c 100644
--- a/pkg/checker/email.go
+++ b/pkg/checker/email.go
@@ -27,7 +27,8 @@ func EmailInAllowEmailDomain(email string, allowEmailDomains []string) bool {
 	}
 
 	for _, domain := range allowEmailDomains {
-		if strings.HasSuffix(email, domain) {
+		domain = strings.TrimPrefix(domain, "@")
+		if strings.HasSuffix(email, "@"+domain) {
 			return true
 		}
 	}
diff --git a/pkg/checker/email_test.go b/pkg/checker/email_test.go
new file mode 100644
index 00000000..b84c8e45
--- /dev/null
+++ b/pkg/checker/email_test.go
@@ -0,0 +1,45 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package checker
+
+import "testing"
+
+func TestEmailInAllowEmailDomain(t *testing.T) {
+	tests := []struct {
+		name              string
+		email             string
+		allowEmailDomains []string
+		want              bool
+	}{
+		{name: "allow when no domains are configured", email: "user@example.com", want: true},
+		{name: "allow legacy domain configuration", email: "user@example.com", allowEmailDomains: []string{"example.com"}, want: true},
+		{name: "allow domain configuration with at sign", email: "user@example.com", allowEmailDomains: []string{"@example.com"}, want: true},
+		{name: "reject a domain with an allowed suffix", email: "attacker@notexample.com", allowEmailDomains: []string{"example.com"}, want: false},
+		{name: "reject a domain with an allowed suffix and at sign", email: "attacker@notexample.com", allowEmailDomains: []string{"@example.com"}, want: false},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := EmailInAllowEmailDomain(tt.email, tt.allowEmailDomains); got != tt.want {
+				t.Errorf("EmailInAllowEmailDomain() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}


(answer) 02/16: fix: normalize imported content

Posted by li...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

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

commit 1a8627e26e9b70713b140fedae7f8606a50d2203
Author: LinkinStars <li...@foxmail.com>
AuthorDate: Fri Aug 14 16:48:53 2026 +0800

    fix: normalize imported content
---
 internal/service/importer/importer_service.go      | 29 +++++++++------
 internal/service/importer/importer_service_test.go | 41 ++++++++++++++++++++++
 2 files changed, 59 insertions(+), 11 deletions(-)

diff --git a/internal/service/importer/importer_service.go b/internal/service/importer/importer_service.go
index 9d12bf07..1842479b 100644
--- a/internal/service/importer/importer_service.go
+++ b/internal/service/importer/importer_service.go
@@ -32,6 +32,7 @@ import (
 	"github.com/apache/answer/internal/service/permission"
 	"github.com/apache/answer/internal/service/rank"
 	usercommon "github.com/apache/answer/internal/service/user_common"
+	"github.com/apache/answer/pkg/converter"
 	"github.com/apache/answer/plugin"
 	"github.com/gin-gonic/gin"
 	"github.com/segmentfault/pacman/errors"
@@ -70,7 +71,7 @@ func (ip *ImporterService) NewImporterFunc() plugin.ImporterFunc {
 }
 
 func (ip *ImporterService) ImportQuestion(ctx context.Context, questionInfo plugin.QuestionImporterInfo) (err error) {
-	req := &schema.QuestionAdd{}
+	req := newImportedQuestionRequest(questionInfo)
 	errFields := make([]*validator.FormErrorField, 0)
 	// To limit rate, remove the following code from comment: Part 1/2
 	// reject, rejectKey := ipc.rateLimitMiddleware.DuplicateRequestRejection(ctx, req)
@@ -94,16 +95,6 @@ func (ip *ImporterService) ImportQuestion(ctx context.Context, questionInfo plug
 	// 	}
 	// }()
 	req.UserID = userInfo.ID
-	req.Title = questionInfo.Title
-	req.Content = questionInfo.Content
-	req.HTML = "<p>" + questionInfo.Content + "</p>"
-	req.Tags = make([]*schema.TagItem, len(questionInfo.Tags))
-	for i, tag := range questionInfo.Tags {
-		req.Tags[i] = &schema.TagItem{
-			SlugName:    tag,
-			DisplayName: tag,
-		}
-	}
 	canList, requireRanks, err := ip.rankService.CheckOperationPermissionsForRanks(ctx, req.UserID, []string{
 		permission.QuestionAdd,
 		permission.QuestionEdit,
@@ -169,3 +160,19 @@ func (ip *ImporterService) ImportQuestion(ctx context.Context, questionInfo plug
 	log.Info("Add Question Successfully")
 	return nil
 }
+
+func newImportedQuestionRequest(questionInfo plugin.QuestionImporterInfo) *schema.QuestionAdd {
+	req := &schema.QuestionAdd{
+		Title:   questionInfo.Title,
+		Content: questionInfo.Content,
+		HTML:    converter.Markdown2HTML(questionInfo.Content),
+		Tags:    make([]*schema.TagItem, len(questionInfo.Tags)),
+	}
+	for i, tag := range questionInfo.Tags {
+		req.Tags[i] = &schema.TagItem{
+			SlugName:    tag,
+			DisplayName: tag,
+		}
+	}
+	return req
+}
diff --git a/internal/service/importer/importer_service_test.go b/internal/service/importer/importer_service_test.go
new file mode 100644
index 00000000..ff32740b
--- /dev/null
+++ b/internal/service/importer/importer_service_test.go
@@ -0,0 +1,41 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package importer
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/apache/answer/plugin"
+)
+
+func TestNewImportedQuestionRequestSanitizesContent(t *testing.T) {
+	request := newImportedQuestionRequest(plugin.QuestionImporterInfo{
+		Title:   "Imported question",
+		Content: `<img src=x onerror=alert(1)><script>alert(1)</script>`,
+		Tags:    []string{"security"},
+	})
+
+	for _, unsafeContent := range []string{"onerror", "<script"} {
+		if strings.Contains(strings.ToLower(request.HTML), unsafeContent) {
+			t.Fatalf("imported question HTML contains unsafe content %q: %s", unsafeContent, request.HTML)
+		}
+	}
+}


(answer) 09/16: fix(security): enforce personal comment visibility

Posted by li...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

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

commit e0daae42336a6625191fbe07af6ada588fe245ec
Author: LinkinStars <li...@foxmail.com>
AuthorDate: Tue Aug 25 16:15:06 2026 +0800

    fix(security): enforce personal comment visibility
---
 internal/controller/comment_controller.go          |  6 +-
 internal/schema/comment_schema.go                  |  4 +-
 internal/service/comment/comment_service.go        | 44 ++++++----
 .../comment/comment_service_visibility_test.go     | 95 ++++++++++++++++++++++
 4 files changed, 129 insertions(+), 20 deletions(-)

diff --git a/internal/controller/comment_controller.go b/internal/controller/comment_controller.go
index b9beead9..e0f18a8a 100644
--- a/internal/controller/comment_controller.go
+++ b/internal/controller/comment_controller.go
@@ -280,7 +280,11 @@ func (cc *CommentController) GetCommentPersonalWithPage(ctx *gin.Context) {
 		return
 	}
 
-	req.UserID = middleware.GetLoginUserIDFromContext(ctx)
+	req.LoginUserID = middleware.GetLoginUserIDFromContext(ctx)
+	if len(req.Username) == 0 {
+		req.UserID = req.LoginUserID
+	}
+	req.IsAdminModerator = middleware.GetUserIsAdminModerator(ctx)
 
 	resp, err := cc.commentService.GetCommentPersonalWithPage(ctx, req)
 	handler.HandleResponse(ctx, err, resp)
diff --git a/internal/schema/comment_schema.go b/internal/schema/comment_schema.go
index a9c8a21a..8297c880 100644
--- a/internal/schema/comment_schema.go
+++ b/internal/schema/comment_schema.go
@@ -232,7 +232,9 @@ type GetCommentPersonalWithPageReq struct {
 	// username
 	Username string `validate:"omitempty,gt=0,lte=100" form:"username"`
 	// user id
-	UserID string `json:"-"`
+	UserID           string `json:"-"`
+	LoginUserID      string `json:"-"`
+	IsAdminModerator bool   `json:"-"`
 }
 
 // GetCommentPersonalWithPageResp comment response
diff --git a/internal/service/comment/comment_service.go b/internal/service/comment/comment_service.go
index 0decd584..455ab864 100644
--- a/internal/service/comment/comment_service.go
+++ b/internal/service/comment/comment_service.go
@@ -539,32 +539,40 @@ func (cs *CommentService) GetCommentPersonalWithPage(ctx context.Context, req *s
 	}
 	resp := make([]*schema.GetCommentPersonalWithPageResp, 0)
 	for _, comment := range commentList {
+		if len(comment.ObjectID) == 0 {
+			continue
+		}
+		objInfo, err := cs.objectInfoService.GetInfo(ctx, comment.ObjectID)
+		if err != nil {
+			log.Error(err)
+			continue
+		}
+		if !canViewPersonalComment(objInfo, req.LoginUserID, req.IsAdminModerator) {
+			continue
+		}
 		commentResp := &schema.GetCommentPersonalWithPageResp{
-			CommentID: comment.ID,
-			CreatedAt: comment.CreatedAt.Unix(),
-			ObjectID:  comment.ObjectID,
-			Content:   comment.ParsedText, // todo trim
+			CommentID:  comment.ID,
+			CreatedAt:  comment.CreatedAt.Unix(),
+			ObjectID:   comment.ObjectID,
+			Content:    comment.ParsedText, // todo trim
+			ObjectType: objInfo.ObjectType,
+			Title:      objInfo.Title,
+			UrlTitle:   htmltext.UrlTitle(objInfo.Title),
+			QuestionID: objInfo.QuestionID,
+			AnswerID:   objInfo.AnswerID,
 		}
-		if len(comment.ObjectID) > 0 {
-			objInfo, err := cs.objectInfoService.GetInfo(ctx, comment.ObjectID)
-			if err != nil {
-				log.Error(err)
-			} else {
-				commentResp.ObjectType = objInfo.ObjectType
-				commentResp.Title = objInfo.Title
-				commentResp.UrlTitle = htmltext.UrlTitle(objInfo.Title)
-				commentResp.QuestionID = objInfo.QuestionID
-				commentResp.AnswerID = objInfo.AnswerID
-				if objInfo.QuestionStatus == entity.QuestionStatusDeleted {
-					commentResp.Title = "Deleted question"
-				}
-			}
+		if objInfo.QuestionStatus == entity.QuestionStatusDeleted {
+			commentResp.Title = "Deleted question"
 		}
 		resp = append(resp, commentResp)
 	}
 	return pager.NewPageModel(total, resp), nil
 }
 
+func canViewPersonalComment(objInfo *schema.SimpleObjectInfo, userID string, isAdminModerator bool) bool {
+	return objInfo.CheckVisibility(userID, isAdminModerator) == nil
+}
+
 func (cs *CommentService) notificationQuestionComment(ctx context.Context, questionUserID,
 	questionID, questionTitle, commentID, commentUserID, commentSummary string) {
 	if questionUserID == commentUserID {
diff --git a/internal/service/comment/comment_service_visibility_test.go b/internal/service/comment/comment_service_visibility_test.go
new file mode 100644
index 00000000..69be2a96
--- /dev/null
+++ b/internal/service/comment/comment_service_visibility_test.go
@@ -0,0 +1,95 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package comment
+
+import (
+	"testing"
+
+	"github.com/apache/answer/internal/base/constant"
+	"github.com/apache/answer/internal/entity"
+	"github.com/apache/answer/internal/schema"
+)
+
+func TestCanViewPersonalComment(t *testing.T) {
+	testCases := []struct {
+		name     string
+		status   int
+		show     int
+		viewer   string
+		isAdmin  bool
+		expected bool
+	}{
+		{"anonymous can view public question", entity.QuestionStatusAvailable, entity.QuestionShow, "", false, true},
+		{"anonymous cannot view hidden question", entity.QuestionStatusAvailable, entity.QuestionHide, "", false, false},
+		{"anonymous cannot view pending question", entity.QuestionStatusPending, entity.QuestionShow, "", false, false},
+		{"anonymous cannot view deleted question", entity.QuestionStatusDeleted, entity.QuestionShow, "", false, false},
+		{"question owner can view hidden question", entity.QuestionStatusAvailable, entity.QuestionHide, "question-owner", false, true},
+		{"moderator can view deleted question", entity.QuestionStatusDeleted, entity.QuestionShow, "", true, true},
+	}
+
+	for _, testCase := range testCases {
+		t.Run(testCase.name, func(t *testing.T) {
+			objInfo := &schema.SimpleObjectInfo{
+				ObjectType:            constant.QuestionObjectType,
+				QuestionCreatorUserID: "question-owner",
+				QuestionStatus:        testCase.status,
+				QuestionShow:          testCase.show,
+			}
+			if got := canViewPersonalComment(objInfo, testCase.viewer, testCase.isAdmin); got != testCase.expected {
+				t.Fatalf("canViewPersonalComment() = %v, want %v", got, testCase.expected)
+			}
+		})
+	}
+}
+
+func TestCanViewPersonalCommentOnAnswer(t *testing.T) {
+	testCases := []struct {
+		name     string
+		viewer   string
+		isAdmin  bool
+		info     *schema.SimpleObjectInfo
+		expected bool
+	}{
+		{
+			name:     "anonymous cannot view answer on hidden question",
+			info:     &schema.SimpleObjectInfo{ObjectType: constant.AnswerObjectType, ObjectCreatorUserID: "answer-owner", QuestionID: "question-id", QuestionCreatorUserID: "question-owner", AnswerStatus: entity.AnswerStatusAvailable, QuestionStatus: entity.QuestionStatusAvailable, QuestionShow: entity.QuestionHide},
+			expected: false,
+		},
+		{
+			name:     "question owner can view answer on hidden question",
+			viewer:   "question-owner",
+			info:     &schema.SimpleObjectInfo{ObjectType: constant.AnswerObjectType, ObjectCreatorUserID: "answer-owner", QuestionID: "question-id", QuestionCreatorUserID: "question-owner", AnswerStatus: entity.AnswerStatusAvailable, QuestionStatus: entity.QuestionStatusAvailable, QuestionShow: entity.QuestionHide},
+			expected: true,
+		},
+		{
+			name:     "anonymous cannot view pending answer",
+			info:     &schema.SimpleObjectInfo{ObjectType: constant.AnswerObjectType, ObjectCreatorUserID: "answer-owner", QuestionID: "question-id", QuestionCreatorUserID: "question-owner", AnswerStatus: entity.AnswerStatusPending, QuestionStatus: entity.QuestionStatusAvailable, QuestionShow: entity.QuestionShow},
+			expected: false,
+		},
+	}
+
+	for _, testCase := range testCases {
+		t.Run(testCase.name, func(t *testing.T) {
+			if got := canViewPersonalComment(testCase.info, testCase.viewer, testCase.isAdmin); got != testCase.expected {
+				t.Fatalf("canViewPersonalComment() = %v, want %v", got, testCase.expected)
+			}
+		})
+	}
+}


(answer) 06/16: fix: constrain MCP content access

Posted by li...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

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

commit 6c2c2569457e5923b7cea0adec86c5c59cd92faa
Author: LinkinStars <li...@foxmail.com>
AuthorDate: Mon Aug 17 10:49:54 2026 +0800

    fix: constrain MCP content access
---
 internal/controller/mcp_controller.go      | 30 +++++++++++++++++-
 internal/controller/mcp_controller_test.go | 50 ++++++++++++++++++++++++++++++
 2 files changed, 79 insertions(+), 1 deletion(-)

diff --git a/internal/controller/mcp_controller.go b/internal/controller/mcp_controller.go
index 942117f4..eebb8baf 100644
--- a/internal/controller/mcp_controller.go
+++ b/internal/controller/mcp_controller.go
@@ -139,7 +139,7 @@ func (c *MCPController) MCPQuestionDetailHandler() func(ctx context.Context, req
 		}
 
 		question, err := c.questioncommon.Info(ctx, cond.QuestionID, "")
-		if err != nil {
+		if err != nil || !mcpQuestionIsPublic(question) {
 			log.Errorf("get question failed: %v", err)
 			return mcp.NewToolResultText("No question found."), nil
 		}
@@ -161,6 +161,9 @@ func (c *MCPController) MCPAnswersHandler() func(ctx context.Context, request mc
 			return nil, err
 		}
 		cond := schema.NewMCPSearchAnswerCond(request)
+		if len(cond.QuestionID) == 0 {
+			return mcp.NewToolResultText("[]"), nil
+		}
 
 		siteGeneral, err := c.siteInfoService.GetSiteGeneral(ctx)
 		if err != nil {
@@ -169,6 +172,10 @@ func (c *MCPController) MCPAnswersHandler() func(ctx context.Context, request mc
 		}
 
 		if len(cond.QuestionID) > 0 {
+			question, err := c.questioncommon.Info(ctx, cond.QuestionID, "")
+			if err != nil || !mcpQuestionIsPublic(question) {
+				return mcp.NewToolResultText("[]"), nil
+			}
 			answerList, err := c.answerRepo.GetAnswerList(ctx, &entity.Answer{QuestionID: cond.QuestionID})
 			if err != nil {
 				log.Errorf("get answers failed: %v", err)
@@ -214,12 +221,33 @@ func (c *MCPController) MCPAnswersHandler() func(ctx context.Context, request mc
 	}
 }
 
+func mcpQuestionIsPublic(question *schema.QuestionInfoResp) bool {
+	return question != nil && question.Show == entity.QuestionShow &&
+		(question.Status == entity.QuestionStatusAvailable || question.Status == entity.QuestionStatusClosed)
+}
+
+func (c *MCPController) mcpObjectQuestionIsPublic(ctx context.Context, objectID string) bool {
+	question, err := c.questioncommon.Info(ctx, objectID, "")
+	if err == nil {
+		return mcpQuestionIsPublic(question)
+	}
+	answer, exist, err := c.answerRepo.GetAnswer(ctx, objectID)
+	if err != nil || !exist || answer.Status != entity.AnswerStatusAvailable {
+		return false
+	}
+	question, err = c.questioncommon.Info(ctx, answer.QuestionID, "")
+	return err == nil && mcpQuestionIsPublic(question)
+}
+
 func (c *MCPController) MCPCommentsHandler() func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
 	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
 		if err := c.ensureMCPEnabled(ctx); err != nil {
 			return nil, err
 		}
 		cond := schema.NewMCPSearchCommentCond(request)
+		if len(cond.ObjectID) == 0 || !c.mcpObjectQuestionIsPublic(ctx, cond.ObjectID) {
+			return mcp.NewToolResultText("No comments found."), nil
+		}
 
 		siteGeneral, err := c.siteInfoService.GetSiteGeneral(ctx)
 		if err != nil {
diff --git a/internal/controller/mcp_controller_test.go b/internal/controller/mcp_controller_test.go
new file mode 100644
index 00000000..09f75a6a
--- /dev/null
+++ b/internal/controller/mcp_controller_test.go
@@ -0,0 +1,50 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package controller
+
+import (
+	"testing"
+
+	"github.com/apache/answer/internal/entity"
+	"github.com/apache/answer/internal/schema"
+)
+
+func TestMCPQuestionIsPublic(t *testing.T) {
+	testCases := []struct {
+		name     string
+		question *schema.QuestionInfoResp
+		want     bool
+	}{
+		{"nil", nil, false},
+		{"available", &schema.QuestionInfoResp{Status: entity.QuestionStatusAvailable, Show: entity.QuestionShow}, true},
+		{"closed", &schema.QuestionInfoResp{Status: entity.QuestionStatusClosed, Show: entity.QuestionShow}, true},
+		{"hidden", &schema.QuestionInfoResp{Status: entity.QuestionStatusAvailable, Show: entity.QuestionHide}, false},
+		{"deleted", &schema.QuestionInfoResp{Status: entity.QuestionStatusDeleted, Show: entity.QuestionShow}, false},
+		{"pending", &schema.QuestionInfoResp{Status: entity.QuestionStatusPending, Show: entity.QuestionShow}, false},
+	}
+
+	for _, testCase := range testCases {
+		t.Run(testCase.name, func(t *testing.T) {
+			if got := mcpQuestionIsPublic(testCase.question); got != testCase.want {
+				t.Fatalf("mcpQuestionIsPublic() = %v, want %v", got, testCase.want)
+			}
+		})
+	}
+}


(answer) 03/16: fix: align question operation permissions

Posted by li...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

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

commit 4e4d4387b9d12b7a87eb4c789e2b2666fec88454
Author: LinkinStars <li...@foxmail.com>
AuthorDate: Fri Aug 14 17:03:23 2026 +0800

    fix: align question operation permissions
---
 internal/controller/question_controller.go      | 23 ++++++++----
 internal/controller/question_controller_test.go | 49 +++++++++++++++++++++++++
 2 files changed, 65 insertions(+), 7 deletions(-)

diff --git a/internal/controller/question_controller.go b/internal/controller/question_controller.go
index 05ad319a..644e3097 100644
--- a/internal/controller/question_controller.go
+++ b/internal/controller/question_controller.go
@@ -144,13 +144,7 @@ func (qc *QuestionController) OperationQuestion(ctx *gin.Context) {
 		handler.HandleResponse(ctx, err, nil)
 		return
 	}
-	req.CanPin = canList[0]
-	req.CanList = canList[1]
-	if (req.Operation == schema.QuestionOperationPin || req.Operation == schema.QuestionOperationUnPin) && !req.CanPin {
-		handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil)
-		return
-	}
-	if (req.Operation == schema.QuestionOperationHide || req.Operation == schema.QuestionOperationShow) && !req.CanList {
+	if !canOperateQuestion(req.Operation, canList) {
 		handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil)
 		return
 	}
@@ -158,6 +152,21 @@ func (qc *QuestionController) OperationQuestion(ctx *gin.Context) {
 	handler.HandleResponse(ctx, err, nil)
 }
 
+func canOperateQuestion(operation string, canList []bool) bool {
+	switch operation {
+	case schema.QuestionOperationPin:
+		return canList[0]
+	case schema.QuestionOperationUnPin:
+		return canList[1]
+	case schema.QuestionOperationHide:
+		return canList[2]
+	case schema.QuestionOperationShow:
+		return canList[3]
+	default:
+		return true
+	}
+}
+
 // CloseQuestion Close question
 // @Summary Close question
 // @Description Close question
diff --git a/internal/controller/question_controller_test.go b/internal/controller/question_controller_test.go
new file mode 100644
index 00000000..35d75f41
--- /dev/null
+++ b/internal/controller/question_controller_test.go
@@ -0,0 +1,49 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package controller
+
+import (
+	"testing"
+
+	"github.com/apache/answer/internal/schema"
+)
+
+func TestCanOperateQuestionUsesMatchingPermission(t *testing.T) {
+	testCases := []struct {
+		name      string
+		operation string
+		canList   []bool
+		want      bool
+	}{
+		{"pin", schema.QuestionOperationPin, []bool{true, false, false, false}, true},
+		{"unpin", schema.QuestionOperationUnPin, []bool{false, true, false, false}, true},
+		{"hide", schema.QuestionOperationHide, []bool{false, false, true, false}, true},
+		{"show", schema.QuestionOperationShow, []bool{false, false, false, true}, true},
+		{"unpin does not authorize hide", schema.QuestionOperationHide, []bool{false, true, false, false}, false},
+	}
+
+	for _, testCase := range testCases {
+		t.Run(testCase.name, func(t *testing.T) {
+			if got := canOperateQuestion(testCase.operation, testCase.canList); got != testCase.want {
+				t.Fatalf("canOperateQuestion(%q, %v) = %v, want %v", testCase.operation, testCase.canList, got, testCase.want)
+			}
+		})
+	}
+}


(answer) 14/16: fix(security): preserve User Center session identity

Posted by li...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

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

commit e9265e3f66b14ea5d902d12b452d6047d973aa88
Author: LinkinStars <li...@foxmail.com>
AuthorDate: Tue Sep 8 15:43:49 2026 +0800

    fix(security): preserve User Center session identity
---
 internal/service/auth/user_center_status_test.go   | 111 +++++++++++++++
 .../user_center_login_service.go                   |  28 +++-
 .../user_center_login_service_test.go              | 149 +++++++++++++++++++++
 3 files changed, 284 insertions(+), 4 deletions(-)

diff --git a/internal/service/auth/user_center_status_test.go b/internal/service/auth/user_center_status_test.go
new file mode 100644
index 00000000..df8b379e
--- /dev/null
+++ b/internal/service/auth/user_center_status_test.go
@@ -0,0 +1,111 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package auth_test
+
+import (
+	"context"
+	"fmt"
+	"testing"
+
+	"github.com/apache/answer/internal/base/data"
+	"github.com/apache/answer/internal/entity"
+	authrepo "github.com/apache/answer/internal/repo/auth"
+	authservice "github.com/apache/answer/internal/service/auth"
+	"github.com/apache/answer/plugin"
+)
+
+func TestGetUserCacheInfoChecksUserCenterStatus(t *testing.T) {
+	cache, cleanup, err := data.NewCache(&data.CacheConf{})
+	if err != nil {
+		t.Fatalf("create cache: %v", err)
+	}
+	t.Cleanup(cleanup)
+
+	previousCallUserCenter := plugin.CallUserCenter
+	testUserCenter := &authTestUserCenter{}
+	plugin.CallUserCenter = plugin.CallFn[plugin.UserCenter](func(fn plugin.Caller[plugin.UserCenter]) error {
+		return fn(testUserCenter)
+	})
+	t.Cleanup(func() { plugin.CallUserCenter = previousCallUserCenter })
+
+	service := authservice.NewAuthService(authrepo.NewAuthRepo(&data.Data{Cache: cache}), nil)
+	for _, expectedStatus := range []plugin.UserStatus{plugin.UserStatusSuspended, plugin.UserStatusDeleted} {
+		t.Run(fmt.Sprintf("status_%d", expectedStatus), func(t *testing.T) {
+			testUserCenter.status = expectedStatus
+			testUserCenter.externalID = ""
+			accessToken, _, err := service.SetUserCacheInfo(context.Background(), &entity.UserCacheInfo{
+				UserID:      "user-center-user",
+				UserStatus:  entity.UserStatusAvailable,
+				EmailStatus: entity.EmailStatusAvailable,
+				ExternalID:  "central-user-1001",
+			})
+			if err != nil {
+				t.Fatalf("cache user session: %v", err)
+			}
+
+			userInfo, err := service.GetUserCacheInfo(context.Background(), accessToken)
+			if err != nil {
+				t.Fatalf("get user cache info: %v", err)
+			}
+			if userInfo.UserStatus != int(expectedStatus) {
+				t.Fatalf("user status = %d, want %d", userInfo.UserStatus, expectedStatus)
+			}
+			if testUserCenter.externalID != "central-user-1001" {
+				t.Fatalf("user center checked external ID = %q, want %q", testUserCenter.externalID, "central-user-1001")
+			}
+		})
+	}
+}
+
+type authTestUserCenter struct {
+	externalID string
+	status     plugin.UserStatus
+}
+
+func (*authTestUserCenter) Info() plugin.Info { return plugin.Info{SlugName: "auth-test-user-center"} }
+
+func (*authTestUserCenter) Description() plugin.UserCenterDesc { return plugin.UserCenterDesc{} }
+
+func (*authTestUserCenter) ControlCenterItems() []plugin.ControlCenter { return nil }
+
+func (*authTestUserCenter) LoginCallback(*plugin.GinContext) (*plugin.UserCenterBasicUserInfo, error) {
+	return nil, nil
+}
+
+func (*authTestUserCenter) SignUpCallback(*plugin.GinContext) (*plugin.UserCenterBasicUserInfo, error) {
+	return nil, nil
+}
+
+func (*authTestUserCenter) UserInfo(string) (*plugin.UserCenterBasicUserInfo, error) { return nil, nil }
+
+func (u *authTestUserCenter) UserStatus(externalID string) plugin.UserStatus {
+	u.externalID = externalID
+	return u.status
+}
+
+func (*authTestUserCenter) UserList([]string) ([]*plugin.UserCenterBasicUserInfo, error) {
+	return nil, nil
+}
+
+func (*authTestUserCenter) UserSettings(string) (*plugin.SettingInfo, error) { return nil, nil }
+
+func (*authTestUserCenter) PersonalBranding(string) []*plugin.PersonalBranding { return nil }
+
+func (*authTestUserCenter) AfterLogin(string, string) {}
diff --git a/internal/service/user_external_login/user_center_login_service.go b/internal/service/user_external_login/user_center_login_service.go
index 90895ac4..4dda13b7 100644
--- a/internal/service/user_external_login/user_center_login_service.go
+++ b/internal/service/user_external_login/user_center_login_service.go
@@ -40,11 +40,31 @@ import (
 	"github.com/segmentfault/pacman/log"
 )
 
+type userCenterLoginUserRepo interface {
+	AddUser(ctx context.Context, user *entity.User) (err error)
+	GetByUserID(ctx context.Context, userID string) (userInfo *entity.User, exist bool, err error)
+	GetByUsername(ctx context.Context, username string) (userInfo *entity.User, exist bool, err error)
+	UpdateLastLoginDate(ctx context.Context, userID string) (err error)
+}
+
+type userCenterLoginExternalLoginRepo interface {
+	AddUserExternalLogin(ctx context.Context, user *entity.UserExternalLogin) (err error)
+	GetByExternalID(ctx context.Context, provider, externalID string) (
+		userInfo *entity.UserExternalLogin, exist bool, err error)
+	GetUserExternalLoginList(ctx context.Context, userID string) (resp []*entity.UserExternalLogin, err error)
+}
+
+type userCenterLoginUserCommonService interface {
+	MakeUsername(ctx context.Context, username string) (string, error)
+	CacheLoginUserInfo(ctx context.Context, userID string, userStatus, emailStatus int, externalID string) (
+		accessToken string, userCacheInfo *entity.UserCacheInfo, err error)
+}
+
 // UserCenterLoginService user external login service
 type UserCenterLoginService struct {
-	userRepo              usercommon.UserRepo
-	userExternalLoginRepo UserExternalLoginRepo
-	userCommonService     *usercommon.UserCommon
+	userRepo              userCenterLoginUserRepo
+	userExternalLoginRepo userCenterLoginExternalLoginRepo
+	userCommonService     userCenterLoginUserCommonService
 	userActivity          activity.UserActiveActivityRepo
 	siteInfoCommonService siteinfo_common.SiteInfoCommonService
 }
@@ -134,7 +154,7 @@ func (us *UserCenterLoginService) ExternalLogin(
 	}
 
 	accessToken, _, err := us.userCommonService.CacheLoginUserInfo(
-		ctx, oldUserInfo.ID, oldUserInfo.MailStatus, oldUserInfo.Status, oldExternalLoginUserInfo.ExternalID)
+		ctx, oldUserInfo.ID, oldUserInfo.MailStatus, oldUserInfo.Status, basicUserInfo.ExternalID)
 	return &schema.UserExternalLoginResp{AccessToken: accessToken}, err
 }
 
diff --git a/internal/service/user_external_login/user_center_login_service_test.go b/internal/service/user_external_login/user_center_login_service_test.go
new file mode 100644
index 00000000..95d75660
--- /dev/null
+++ b/internal/service/user_external_login/user_center_login_service_test.go
@@ -0,0 +1,149 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package user_external_login
+
+import (
+	"context"
+	"testing"
+
+	"github.com/apache/answer/internal/entity"
+	"github.com/apache/answer/plugin"
+)
+
+func TestUserCenterFirstLoginCachesExternalID(t *testing.T) {
+	userRepo := &userCenterLoginTestUserRepo{}
+	externalLoginRepo := &userCenterLoginTestExternalLoginRepo{}
+	userCommonService := &userCenterLoginTestUserCommonService{}
+	service := &UserCenterLoginService{
+		userRepo:              userRepo,
+		userExternalLoginRepo: externalLoginRepo,
+		userCommonService:     userCommonService,
+		userActivity:          userCenterLoginTestActivity{},
+	}
+
+	const externalID = "central-user-1001"
+	resp, err := service.ExternalLogin(context.Background(), userCenterLoginTestUserCenter{},
+		&plugin.UserCenterBasicUserInfo{ExternalID: externalID, Username: "central-user"})
+	if err != nil {
+		t.Fatalf("first user center login failed: %v", err)
+	}
+	if resp.AccessToken == "" {
+		t.Fatal("first user center login did not issue an access token")
+	}
+	if userCommonService.externalID != externalID {
+		t.Fatalf("cached external ID = %q, want %q", userCommonService.externalID, externalID)
+	}
+	if externalLoginRepo.added == nil || externalLoginRepo.added.ExternalID != externalID {
+		t.Fatalf("persisted external ID = %#v, want %q", externalLoginRepo.added, externalID)
+	}
+}
+
+type userCenterLoginTestUserRepo struct{}
+
+func (*userCenterLoginTestUserRepo) AddUser(_ context.Context, user *entity.User) error {
+	user.ID = "answer-user-1"
+	return nil
+}
+
+func (*userCenterLoginTestUserRepo) GetByUserID(context.Context, string) (*entity.User, bool, error) {
+	return nil, false, nil
+}
+
+func (*userCenterLoginTestUserRepo) GetByUsername(context.Context, string) (*entity.User, bool, error) {
+	return nil, false, nil
+}
+
+func (*userCenterLoginTestUserRepo) UpdateLastLoginDate(context.Context, string) error { return nil }
+
+type userCenterLoginTestExternalLoginRepo struct {
+	added *entity.UserExternalLogin
+}
+
+func (*userCenterLoginTestExternalLoginRepo) GetByExternalID(context.Context, string, string) (*entity.UserExternalLogin, bool, error) {
+	return &entity.UserExternalLogin{}, false, nil
+}
+
+func (r *userCenterLoginTestExternalLoginRepo) AddUserExternalLogin(_ context.Context, user *entity.UserExternalLogin) error {
+	r.added = user
+	return nil
+}
+
+func (*userCenterLoginTestExternalLoginRepo) GetUserExternalLoginList(context.Context, string) ([]*entity.UserExternalLogin, error) {
+	return nil, nil
+}
+
+type userCenterLoginTestUserCommonService struct {
+	externalID string
+}
+
+func (*userCenterLoginTestUserCommonService) MakeUsername(_ context.Context, username string) (string, error) {
+	return username, nil
+}
+
+func (s *userCenterLoginTestUserCommonService) CacheLoginUserInfo(
+	_ context.Context, _ string, _, _ int, externalID string,
+) (string, *entity.UserCacheInfo, error) {
+	s.externalID = externalID
+	return "access-token", &entity.UserCacheInfo{ExternalID: externalID}, nil
+}
+
+type userCenterLoginTestActivity struct{}
+
+func (userCenterLoginTestActivity) UserActive(context.Context, string) error { return nil }
+
+type userCenterLoginTestUserCenter struct{}
+
+func (userCenterLoginTestUserCenter) Info() plugin.Info {
+	return plugin.Info{SlugName: "test-user-center"}
+}
+
+func (userCenterLoginTestUserCenter) Description() plugin.UserCenterDesc {
+	return plugin.UserCenterDesc{}
+}
+
+func (userCenterLoginTestUserCenter) ControlCenterItems() []plugin.ControlCenter { return nil }
+
+func (userCenterLoginTestUserCenter) LoginCallback(*plugin.GinContext) (*plugin.UserCenterBasicUserInfo, error) {
+	return nil, nil
+}
+
+func (userCenterLoginTestUserCenter) SignUpCallback(*plugin.GinContext) (*plugin.UserCenterBasicUserInfo, error) {
+	return nil, nil
+}
+
+func (userCenterLoginTestUserCenter) UserInfo(string) (*plugin.UserCenterBasicUserInfo, error) {
+	return nil, nil
+}
+
+func (userCenterLoginTestUserCenter) UserStatus(string) plugin.UserStatus {
+	return plugin.UserStatusAvailable
+}
+
+func (userCenterLoginTestUserCenter) UserList([]string) ([]*plugin.UserCenterBasicUserInfo, error) {
+	return nil, nil
+}
+
+func (userCenterLoginTestUserCenter) UserSettings(string) (*plugin.SettingInfo, error) {
+	return nil, nil
+}
+
+func (userCenterLoginTestUserCenter) PersonalBranding(string) []*plugin.PersonalBranding { return nil }
+
+func (userCenterLoginTestUserCenter) AfterLogin(string, string) {}


(answer) 05/16: fix: enforce question access controls

Posted by li...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

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

commit 625ead8f6bed5c8bee94189cec6ddb0d6f51065b
Author: LinkinStars <li...@foxmail.com>
AuthorDate: Mon Aug 17 10:28:49 2026 +0800

    fix: enforce question access controls
---
 internal/controller/question_controller.go         | 42 +++++++++-----
 internal/service/content/question_service.go       | 28 +++++++---
 .../content/question_service_visibility_test.go    | 65 ++++++++++++++++++++++
 3 files changed, 113 insertions(+), 22 deletions(-)

diff --git a/internal/controller/question_controller.go b/internal/controller/question_controller.go
index c83fd578..66d7b221 100644
--- a/internal/controller/question_controller.go
+++ b/internal/controller/question_controller.go
@@ -242,6 +242,24 @@ func (qc *QuestionController) GetQuestion(ctx *gin.Context) {
 	id := ctx.Query("id")
 	id = uid.DeShortID(id)
 	userID := middleware.GetLoginUserIDFromContext(ctx)
+	req, err := qc.questionPermission(ctx, userID, id)
+	if err != nil {
+		handler.HandleResponse(ctx, err, nil)
+		return
+	}
+
+	info, err := qc.questionService.GetQuestionAndAddPV(ctx, id, userID, req)
+	if err != nil {
+		handler.HandleResponse(ctx, err, nil)
+		return
+	}
+	if handler.GetEnableShortID(ctx) {
+		info.ID = uid.EnShortID(info.ID)
+	}
+	handler.HandleResponse(ctx, nil, info)
+}
+
+func (qc *QuestionController) questionPermission(ctx *gin.Context, userID, questionID string) (schema.QuestionPermission, error) {
 	req := schema.QuestionPermission{}
 	req.IsAdminModerator = middleware.GetUserIsAdminModerator(ctx)
 	canList, err := qc.rankService.CheckOperationPermissions(ctx, userID, []string{
@@ -257,10 +275,9 @@ func (qc *QuestionController) GetQuestion(ctx *gin.Context) {
 		permission.QuestionUnDelete,
 	})
 	if err != nil {
-		handler.HandleResponse(ctx, err, nil)
-		return
+		return req, err
 	}
-	objectOwner := qc.rankService.CheckOperationObjectOwner(ctx, userID, id)
+	objectOwner := qc.rankService.CheckOperationObjectOwner(ctx, userID, questionID)
 
 	req.CanEdit = canList[0] || objectOwner
 	req.CanDelete = canList[1]
@@ -272,16 +289,7 @@ func (qc *QuestionController) GetQuestion(ctx *gin.Context) {
 	req.CanShow = canList[7]
 	req.CanInviteOtherToAnswer = canList[8]
 	req.CanRecover = canList[9]
-
-	info, err := qc.questionService.GetQuestionAndAddPV(ctx, id, userID, req)
-	if err != nil {
-		handler.HandleResponse(ctx, err, nil)
-		return
-	}
-	if handler.GetEnableShortID(ctx) {
-		info.ID = uid.EnShortID(info.ID)
-	}
-	handler.HandleResponse(ctx, nil, info)
+	return req, nil
 }
 
 // GetQuestionInviteUserInfo get question invite user info
@@ -295,7 +303,13 @@ func (qc *QuestionController) GetQuestion(ctx *gin.Context) {
 // @Router /answer/api/v1/question/invite [get]
 func (qc *QuestionController) GetQuestionInviteUserInfo(ctx *gin.Context) {
 	questionID := uid.DeShortID(ctx.Query("id"))
-	resp, err := qc.questionService.InviteUserInfo(ctx, questionID)
+	userID := middleware.GetLoginUserIDFromContext(ctx)
+	per, err := qc.questionPermission(ctx, userID, questionID)
+	if err != nil {
+		handler.HandleResponse(ctx, err, nil)
+		return
+	}
+	resp, err := qc.questionService.InviteUserInfo(ctx, questionID, userID, per)
 	handler.HandleResponse(ctx, err, resp)
 }
 
diff --git a/internal/service/content/question_service.go b/internal/service/content/question_service.go
index b172018b..f7899566 100644
--- a/internal/service/content/question_service.go
+++ b/internal/service/content/question_service.go
@@ -1091,13 +1091,8 @@ func (qs *QuestionService) GetQuestion(ctx context.Context, questionID, userID s
 	if err != nil {
 		return
 	}
-	// If the question is deleted or pending, only the administrator and the author can view it
-	if (question.Status == entity.QuestionStatusDeleted ||
-		question.Status == entity.QuestionStatusPending) && !per.CanReopen && question.UserID != userID {
-		return nil, errors.NotFound(reason.QuestionNotFound)
-	}
-	if question.Show == entity.QuestionHide && !per.IsAdminModerator && question.UserID != userID {
-		return nil, errors.NotFound(reason.QuestionNotFound)
+	if err = checkQuestionVisibility(question, userID, per); err != nil {
+		return nil, err
 	}
 	if question.Status != entity.QuestionStatusClosed {
 		per.CanReopen = false
@@ -1142,6 +1137,19 @@ func (qs *QuestionService) GetQuestion(ctx context.Context, questionID, userID s
 	return question, nil
 }
 
+func checkQuestionVisibility(question *schema.QuestionInfoResp, userID string, per schema.QuestionPermission) error {
+	// Deleted and pending questions are visible only to their author or users who can reopen them.
+	if (question.Status == entity.QuestionStatusDeleted ||
+		question.Status == entity.QuestionStatusPending) && !per.CanReopen && question.UserID != userID {
+		return errors.NotFound(reason.QuestionNotFound)
+	}
+	// Hidden questions are visible only to their author or an administrator/moderator.
+	if question.Show == entity.QuestionHide && !per.IsAdminModerator && question.UserID != userID {
+		return errors.NotFound(reason.QuestionNotFound)
+	}
+	return nil
+}
+
 // GetQuestionAndAddPV get question one
 func (qs *QuestionService) GetQuestionAndAddPV(ctx context.Context, questionID, loginUserID string,
 	per schema.QuestionPermission) (
@@ -1153,7 +1161,11 @@ func (qs *QuestionService) GetQuestionAndAddPV(ctx context.Context, questionID,
 	return qs.GetQuestion(ctx, questionID, loginUserID, per)
 }
 
-func (qs *QuestionService) InviteUserInfo(ctx context.Context, questionID string) (inviteList []*schema.UserBasicInfo, err error) {
+func (qs *QuestionService) InviteUserInfo(ctx context.Context, questionID, userID string,
+	per schema.QuestionPermission) (inviteList []*schema.UserBasicInfo, err error) {
+	if _, err = qs.GetQuestion(ctx, questionID, userID, per); err != nil {
+		return nil, err
+	}
 	return qs.questioncommon.InviteUserInfo(ctx, questionID)
 }
 
diff --git a/internal/service/content/question_service_visibility_test.go b/internal/service/content/question_service_visibility_test.go
new file mode 100644
index 00000000..9e10b47b
--- /dev/null
+++ b/internal/service/content/question_service_visibility_test.go
@@ -0,0 +1,65 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package content
+
+import (
+	"testing"
+
+	"github.com/apache/answer/internal/entity"
+	"github.com/apache/answer/internal/schema"
+)
+
+func TestCheckQuestionVisibility(t *testing.T) {
+	testCases := []struct {
+		name   string
+		status int
+		show   int
+		userID string
+		viewer string
+		per    schema.QuestionPermission
+		allow  bool
+	}{
+		{"public question", entity.QuestionStatusAvailable, entity.QuestionShow, "author", "", schema.QuestionPermission{}, true},
+		{"pending question anonymous", entity.QuestionStatusPending, entity.QuestionShow, "author", "", schema.QuestionPermission{}, false},
+		{"pending question author", entity.QuestionStatusPending, entity.QuestionShow, "author", "author", schema.QuestionPermission{}, true},
+		{"pending question reviewer", entity.QuestionStatusPending, entity.QuestionShow, "author", "reviewer", schema.QuestionPermission{CanReopen: true}, true},
+		{"deleted question anonymous", entity.QuestionStatusDeleted, entity.QuestionShow, "author", "", schema.QuestionPermission{}, false},
+		{"hidden question anonymous", entity.QuestionStatusAvailable, entity.QuestionHide, "author", "", schema.QuestionPermission{}, false},
+		{"hidden question author", entity.QuestionStatusAvailable, entity.QuestionHide, "author", "author", schema.QuestionPermission{}, true},
+		{"hidden question moderator", entity.QuestionStatusAvailable, entity.QuestionHide, "author", "moderator", schema.QuestionPermission{IsAdminModerator: true}, true},
+	}
+
+	for _, testCase := range testCases {
+		t.Run(testCase.name, func(t *testing.T) {
+			question := &schema.QuestionInfoResp{
+				Status: testCase.status,
+				Show:   testCase.show,
+				UserID: testCase.userID,
+			}
+			err := checkQuestionVisibility(question, testCase.viewer, testCase.per)
+			if testCase.allow && err != nil {
+				t.Fatalf("visibility unexpectedly denied: %v", err)
+			}
+			if !testCase.allow && err == nil {
+				t.Fatal("visibility unexpectedly allowed")
+			}
+		})
+	}
+}


(answer) 12/16: fix(security): enforce mutation visibility checks

Posted by li...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

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

commit 82c8233021cc09a44db40bc51c9fec29f29aafb5
Author: LinkinStars <li...@foxmail.com>
AuthorDate: Wed Aug 26 11:04:20 2026 +0800

    fix(security): enforce mutation visibility checks
---
 internal/controller/answer_controller.go     |  3 ++-
 internal/controller/comment_controller.go    |  3 ++-
 internal/controller/report_controller.go     |  3 ++-
 internal/controller/vote_controller.go       |  6 ++++--
 internal/schema/answer_schema.go             | 23 ++++++++++++-----------
 internal/schema/comment_schema.go            |  3 ++-
 internal/schema/report_schema.go             |  7 ++++---
 internal/schema/vote_schema.go               | 11 ++++++-----
 internal/service/comment/comment_service.go  |  3 +++
 internal/service/content/answer_service.go   |  8 ++++++++
 internal/service/content/vote_service.go     |  6 ++++++
 internal/service/report/report_service.go    |  3 +++
 internal/service/user_admin/user_backyard.go |  1 +
 13 files changed, 55 insertions(+), 25 deletions(-)

diff --git a/internal/controller/answer_controller.go b/internal/controller/answer_controller.go
index 6e16c6a8..58e55c11 100644
--- a/internal/controller/answer_controller.go
+++ b/internal/controller/answer_controller.go
@@ -208,6 +208,7 @@ func (ac *AnswerController) AddAnswer(ctx *gin.Context) {
 	}()
 	req.QuestionID = uid.DeShortID(req.QuestionID)
 	req.UserID = middleware.GetLoginUserIDFromContext(ctx)
+	req.IsAdminModerator = middleware.GetUserIsAdminModerator(ctx)
 
 	canList, err := ac.rankService.CheckOperationPermissions(ctx, req.UserID, []string{
 		permission.AnswerEdit,
@@ -220,7 +221,7 @@ func (ac *AnswerController) AddAnswer(ctx *gin.Context) {
 	}
 
 	linkUrlLimitUser := canList[2]
-	isAdmin := middleware.GetUserIsAdminModerator(ctx)
+	isAdmin := req.IsAdminModerator
 	if !isAdmin || !linkUrlLimitUser {
 		captchaPass := ac.actionService.ActionRecordVerifyCaptcha(ctx, entity.CaptchaActionAnswer, req.UserID, req.CaptchaID, req.CaptchaCode)
 		if !captchaPass {
diff --git a/internal/controller/comment_controller.go b/internal/controller/comment_controller.go
index e0f18a8a..4bdd8ac2 100644
--- a/internal/controller/comment_controller.go
+++ b/internal/controller/comment_controller.go
@@ -88,6 +88,7 @@ func (cc *CommentController) AddComment(ctx *gin.Context) {
 	}()
 	req.ObjectID = uid.DeShortID(req.ObjectID)
 	req.UserID = middleware.GetLoginUserIDFromContext(ctx)
+	req.IsAdminModerator = middleware.GetUserIsAdminModerator(ctx)
 
 	canList, err := cc.rankService.CheckOperationPermissions(ctx, req.UserID, []string{
 		permission.CommentAdd,
@@ -100,7 +101,7 @@ func (cc *CommentController) AddComment(ctx *gin.Context) {
 		return
 	}
 	linkUrlLimitUser := canList[3]
-	isAdmin := middleware.GetUserIsAdminModerator(ctx)
+	isAdmin := req.IsAdminModerator
 	if !isAdmin || !linkUrlLimitUser {
 		captchaPass := cc.actionService.ActionRecordVerifyCaptcha(ctx, entity.CaptchaActionComment, req.UserID, req.CaptchaID, req.CaptchaCode)
 		if !captchaPass {
diff --git a/internal/controller/report_controller.go b/internal/controller/report_controller.go
index 13b4c095..53b298ed 100644
--- a/internal/controller/report_controller.go
+++ b/internal/controller/report_controller.go
@@ -73,7 +73,8 @@ func (rc *ReportController) AddReport(ctx *gin.Context) {
 	}
 	req.ObjectID = uid.DeShortID(req.ObjectID)
 	req.UserID = middleware.GetLoginUserIDFromContext(ctx)
-	isAdmin := middleware.GetUserIsAdminModerator(ctx)
+	req.IsAdminModerator = middleware.GetUserIsAdminModerator(ctx)
+	isAdmin := req.IsAdminModerator
 	if !isAdmin {
 		captchaPass := rc.actionService.ActionRecordVerifyCaptcha(ctx, entity.CaptchaActionReport, req.UserID, req.CaptchaID, req.CaptchaCode)
 		if !captchaPass {
diff --git a/internal/controller/vote_controller.go b/internal/controller/vote_controller.go
index 30279667..10ce42a7 100644
--- a/internal/controller/vote_controller.go
+++ b/internal/controller/vote_controller.go
@@ -72,6 +72,7 @@ func (vc *VoteController) VoteUp(ctx *gin.Context) {
 	}
 	req.ObjectID = uid.DeShortID(req.ObjectID)
 	req.UserID = middleware.GetLoginUserIDFromContext(ctx)
+	req.IsAdminModerator = middleware.GetUserIsAdminModerator(ctx)
 
 	can, needRank, err := vc.rankService.CheckVotePermission(ctx, req.UserID, req.ObjectID, true)
 	if err != nil {
@@ -85,7 +86,7 @@ func (vc *VoteController) VoteUp(ctx *gin.Context) {
 		return
 	}
 
-	isAdmin := middleware.GetUserIsAdminModerator(ctx)
+	isAdmin := req.IsAdminModerator
 	if !isAdmin {
 		captchaPass := vc.actionService.ActionRecordVerifyCaptcha(ctx, entity.CaptchaActionVote, req.UserID, req.CaptchaID, req.CaptchaCode)
 		if !captchaPass {
@@ -126,7 +127,8 @@ func (vc *VoteController) VoteDown(ctx *gin.Context) {
 	}
 	req.ObjectID = uid.DeShortID(req.ObjectID)
 	req.UserID = middleware.GetLoginUserIDFromContext(ctx)
-	isAdmin := middleware.GetUserIsAdminModerator(ctx)
+	req.IsAdminModerator = middleware.GetUserIsAdminModerator(ctx)
+	isAdmin := req.IsAdminModerator
 
 	can, needRank, err := vc.rankService.CheckVotePermission(ctx, req.UserID, req.ObjectID, false)
 	if err != nil {
diff --git a/internal/schema/answer_schema.go b/internal/schema/answer_schema.go
index bf80c56e..e81b8630 100644
--- a/internal/schema/answer_schema.go
+++ b/internal/schema/answer_schema.go
@@ -47,17 +47,18 @@ const (
 )
 
 type AnswerAddReq struct {
-	QuestionID  string `json:"question_id"`
-	Content     string `validate:"required,notblank,gte=6,lte=65535" json:"content"`
-	HTML        string `json:"-"`
-	UserID      string `json:"-"`
-	CanEdit     bool   `json:"-"`
-	CanDelete   bool   `json:"-"`
-	CanRecover  bool   `json:"-"`
-	CaptchaID   string `json:"captcha_id"`
-	CaptchaCode string `json:"captcha_code"`
-	IP          string `json:"-"`
-	UserAgent   string `json:"-"`
+	QuestionID       string `json:"question_id"`
+	Content          string `validate:"required,notblank,gte=6,lte=65535" json:"content"`
+	HTML             string `json:"-"`
+	UserID           string `json:"-"`
+	IsAdminModerator bool   `json:"-"`
+	CanEdit          bool   `json:"-"`
+	CanDelete        bool   `json:"-"`
+	CanRecover       bool   `json:"-"`
+	CaptchaID        string `json:"captcha_id"`
+	CaptchaCode      string `json:"captcha_code"`
+	IP               string `json:"-"`
+	UserAgent        string `json:"-"`
 }
 
 func (req *AnswerAddReq) Check() (errFields []*validator.FormErrorField, err error) {
diff --git a/internal/schema/comment_schema.go b/internal/schema/comment_schema.go
index 8297c880..0fe5ee00 100644
--- a/internal/schema/comment_schema.go
+++ b/internal/schema/comment_schema.go
@@ -44,7 +44,8 @@ type AddCommentReq struct {
 	CaptchaCode         string   `json:"captcha_code"`
 
 	// user id
-	UserID string `json:"-"`
+	UserID           string `json:"-"`
+	IsAdminModerator bool   `json:"-"`
 	// whether user can add it
 	CanAdd bool `json:"-"`
 	// whether user can edit it
diff --git a/internal/schema/report_schema.go b/internal/schema/report_schema.go
index 1f702df4..3c1e3325 100644
--- a/internal/schema/report_schema.go
+++ b/internal/schema/report_schema.go
@@ -28,9 +28,10 @@ type AddReportReq struct {
 	// report content
 	Content string `validate:"omitempty,gt=0,lte=500" json:"content"`
 	// user id
-	UserID      string `json:"-"`
-	CaptchaID   string `json:"captcha_id"` // captcha_id
-	CaptchaCode string `json:"captcha_code"`
+	UserID           string `json:"-"`
+	IsAdminModerator bool   `json:"-"`
+	CaptchaID        string `json:"captcha_id"` // captcha_id
+	CaptchaCode      string `json:"captcha_code"`
 }
 
 // GetReportListReq get report list all request
diff --git a/internal/schema/vote_schema.go b/internal/schema/vote_schema.go
index e82adcc2..15a54794 100644
--- a/internal/schema/vote_schema.go
+++ b/internal/schema/vote_schema.go
@@ -20,11 +20,12 @@
 package schema
 
 type VoteReq struct {
-	ObjectID    string `validate:"required" json:"object_id"`
-	IsCancel    bool   `validate:"omitempty" json:"is_cancel"`
-	CaptchaID   string `json:"captcha_id"`
-	CaptchaCode string `json:"captcha_code"`
-	UserID      string `json:"-"`
+	ObjectID         string `validate:"required" json:"object_id"`
+	IsCancel         bool   `validate:"omitempty" json:"is_cancel"`
+	CaptchaID        string `json:"captcha_id"`
+	CaptchaCode      string `json:"captcha_code"`
+	UserID           string `json:"-"`
+	IsAdminModerator bool   `json:"-"`
 }
 
 type VoteResp struct {
diff --git a/internal/service/comment/comment_service.go b/internal/service/comment/comment_service.go
index 455ab864..0cbf2028 100644
--- a/internal/service/comment/comment_service.go
+++ b/internal/service/comment/comment_service.go
@@ -144,6 +144,9 @@ func (cs *CommentService) AddComment(ctx context.Context, req *schema.AddComment
 	if objInfo.IsDeleted() {
 		return nil, errors.BadRequest(reason.NewObjectAlreadyDeleted)
 	}
+	if err := objInfo.CheckVisibility(req.UserID, req.IsAdminModerator); err != nil {
+		return nil, err
+	}
 	objInfo.ObjectID = uid.DeShortID(objInfo.ObjectID)
 	objInfo.QuestionID = uid.DeShortID(objInfo.QuestionID)
 	objInfo.AnswerID = uid.DeShortID(objInfo.AnswerID)
diff --git a/internal/service/content/answer_service.go b/internal/service/content/answer_service.go
index bda7b582..e43467a5 100644
--- a/internal/service/content/answer_service.go
+++ b/internal/service/content/answer_service.go
@@ -258,6 +258,14 @@ func (as *AnswerService) Insert(ctx context.Context, req *schema.AnswerAddReq) (
 	if !exist {
 		return "", errors.BadRequest(reason.QuestionNotFound)
 	}
+	if err := (&schema.SimpleObjectInfo{
+		ObjectType:            constant.QuestionObjectType,
+		QuestionCreatorUserID: questionInfo.UserID,
+		QuestionStatus:        questionInfo.Status,
+		QuestionShow:          questionInfo.Show,
+	}).CheckVisibility(req.UserID, req.IsAdminModerator); err != nil {
+		return "", err
+	}
 	if questionInfo.Status == entity.QuestionStatusClosed || questionInfo.Status == entity.QuestionStatusDeleted {
 		err = errors.BadRequest(reason.AnswerCannotAddByClosedQuestion)
 		return "", err
diff --git a/internal/service/content/vote_service.go b/internal/service/content/vote_service.go
index 1f74769f..04565438 100644
--- a/internal/service/content/vote_service.go
+++ b/internal/service/content/vote_service.go
@@ -94,6 +94,9 @@ func (vs *VoteService) VoteUp(ctx context.Context, req *schema.VoteReq) (resp *s
 	if objectInfo.IsDeleted() {
 		return nil, errors.BadRequest(reason.NewObjectAlreadyDeleted)
 	}
+	if err := objectInfo.CheckVisibility(req.UserID, req.IsAdminModerator); err != nil {
+		return nil, err
+	}
 	// make object id must be decoded
 	objectInfo.ObjectID = req.ObjectID
 
@@ -145,6 +148,9 @@ func (vs *VoteService) VoteDown(ctx context.Context, req *schema.VoteReq) (resp
 	if objectInfo.IsDeleted() {
 		return nil, errors.BadRequest(reason.NewObjectAlreadyDeleted)
 	}
+	if err := objectInfo.CheckVisibility(req.UserID, req.IsAdminModerator); err != nil {
+		return nil, err
+	}
 	// make object id must be decoded
 	objectInfo.ObjectID = req.ObjectID
 
diff --git a/internal/service/report/report_service.go b/internal/service/report/report_service.go
index 3edbc1b4..c57d83e9 100644
--- a/internal/service/report/report_service.go
+++ b/internal/service/report/report_service.go
@@ -99,6 +99,9 @@ func (rs *ReportService) AddReport(ctx context.Context, req *schema.AddReportReq
 	if objInfo.IsDeleted() {
 		return errors.BadRequest(reason.NewObjectAlreadyDeleted)
 	}
+	if err := objInfo.CheckVisibility(req.UserID, req.IsAdminModerator); err != nil {
+		return err
+	}
 
 	cf, err := rs.configService.GetConfigByID(ctx, req.ReportType)
 	if err != nil || cf == nil {
diff --git a/internal/service/user_admin/user_backyard.go b/internal/service/user_admin/user_backyard.go
index 072feec9..acbaf996 100644
--- a/internal/service/user_admin/user_backyard.go
+++ b/internal/service/user_admin/user_backyard.go
@@ -170,6 +170,7 @@ func (us *UserAdminService) UpdateUserStatus(ctx context.Context, req *schema.Up
 		if err := us.revokeUserAPIKeys(ctx, userInfo.ID); err != nil {
 			return err
 		}
+		us.authService.RemoveUserAllTokens(ctx, userInfo.ID)
 	}
 
 	// remove all content that user created, such as question, answer, comment, etc.


(answer) 01/16: fix: avoid sensitive auth logging

Posted by li...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

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

commit 66bbc744f1100f7a3e7885569c48d3061b2cf727
Author: LinkinStars <li...@foxmail.com>
AuthorDate: Fri Aug 14 16:34:33 2026 +0800

    fix: avoid sensitive auth logging
---
 internal/service/auth/auth.go      |  3 --
 internal/service/auth/auth_test.go | 99 ++++++++++++++++++++++++++++++++++++++
 2 files changed, 99 insertions(+), 3 deletions(-)

diff --git a/internal/service/auth/auth.go b/internal/service/auth/auth.go
index 7d275105..a7827c78 100644
--- a/internal/service/auth/auth.go
+++ b/internal/service/auth/auth.go
@@ -26,7 +26,6 @@ import (
 	"github.com/apache/answer/internal/service/apikey"
 	"github.com/apache/answer/pkg/token"
 	"github.com/apache/answer/plugin"
-	"github.com/segmentfault/pacman/log"
 )
 
 // AuthRepo auth repository
@@ -198,9 +197,7 @@ func (as *AuthService) AuthAPIKey(ctx context.Context, read bool, apiKey string)
 	}
 	// If the request is not read-only, check if the API key has write permissions
 	if !read && apiKeyInfo.Scope == "read-only" {
-		log.Warnf("API key %s does not have write permissions", apiKeyInfo.AccessKey)
 		return false, nil
 	}
-	log.Infof("API key %s is valid, scope: %s", apiKeyInfo.AccessKey, apiKeyInfo.Scope)
 	return true, nil
 }
diff --git a/internal/service/auth/auth_test.go b/internal/service/auth/auth_test.go
new file mode 100644
index 00000000..17cbd001
--- /dev/null
+++ b/internal/service/auth/auth_test.go
@@ -0,0 +1,99 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package auth
+
+import (
+	"context"
+	"fmt"
+	"strings"
+	"testing"
+
+	"github.com/apache/answer/internal/entity"
+	"github.com/segmentfault/pacman/log"
+)
+
+func TestAuthAPIKeyDoesNotLogAccessKey(t *testing.T) {
+	const accessKey = "sk_sensitive-api-key-must-not-be-logged"
+
+	logger := &authTestLogger{}
+	previousLogger := log.GetLogger()
+	log.SetLogger(logger)
+	t.Cleanup(func() { log.SetLogger(previousLogger) })
+
+	service := NewAuthService(nil, &authTestAPIKeyRepo{
+		key: &entity.APIKey{AccessKey: accessKey, Scope: "read-only"},
+	})
+
+	pass, err := service.AuthAPIKey(context.Background(), true, accessKey)
+	if err != nil || !pass {
+		t.Fatalf("read-only API key should authenticate read request: pass=%v err=%v", pass, err)
+	}
+
+	pass, err = service.AuthAPIKey(context.Background(), false, accessKey)
+	if err != nil || pass {
+		t.Fatalf("read-only API key should not authenticate write request: pass=%v err=%v", pass, err)
+	}
+
+	if logs := logger.String(); strings.Contains(logs, accessKey) {
+		t.Fatalf("authentication logs contain API key: %s", logs)
+	}
+}
+
+type authTestAPIKeyRepo struct {
+	key *entity.APIKey
+}
+
+func (r *authTestAPIKeyRepo) GetAPIKeyList(context.Context) ([]*entity.APIKey, error) {
+	return nil, nil
+}
+
+func (r *authTestAPIKeyRepo) GetAPIKey(context.Context, string) (*entity.APIKey, bool, error) {
+	return r.key, true, nil
+}
+
+func (r *authTestAPIKeyRepo) UpdateAPIKey(context.Context, entity.APIKey) error { return nil }
+
+func (r *authTestAPIKeyRepo) AddAPIKey(context.Context, entity.APIKey) error { return nil }
+
+func (r *authTestAPIKeyRepo) DeleteAPIKey(context.Context, int) error { return nil }
+
+func (r *authTestAPIKeyRepo) DeleteAPIKeysByUserID(context.Context, string) error { return nil }
+
+type authTestLogger struct {
+	entries []string
+}
+
+func (l *authTestLogger) Debug(v ...any) { l.entries = append(l.entries, fmt.Sprint(v...)) }
+func (l *authTestLogger) Debugf(format string, v ...any) {
+	l.entries = append(l.entries, fmt.Sprintf(format, v...))
+}
+func (l *authTestLogger) Info(v ...any) { l.entries = append(l.entries, fmt.Sprint(v...)) }
+func (l *authTestLogger) Infof(format string, v ...any) {
+	l.entries = append(l.entries, fmt.Sprintf(format, v...))
+}
+func (l *authTestLogger) Warn(v ...any) { l.entries = append(l.entries, fmt.Sprint(v...)) }
+func (l *authTestLogger) Warnf(format string, v ...any) {
+	l.entries = append(l.entries, fmt.Sprintf(format, v...))
+}
+func (l *authTestLogger) Error(v ...any) { l.entries = append(l.entries, fmt.Sprint(v...)) }
+func (l *authTestLogger) Errorf(format string, v ...any) {
+	l.entries = append(l.entries, fmt.Sprintf(format, v...))
+}
+func (l *authTestLogger) String() string { return strings.Join(l.entries, "\n") }


(answer) 08/16: fix: hide hidden questions from public answer profiles

Posted by li...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

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

commit a0d902687540637d31febc7c923aa1c8c0beb140
Author: LinkinStars <li...@foxmail.com>
AuthorDate: Thu Aug 20 16:26:57 2026 +0800

    fix: hide hidden questions from public answer profiles
---
 internal/entity/answer_entity.go                   |  1 +
 internal/repo/answer/answer_repo.go                | 16 ++--
 .../repo_test/personal_answer_visibility_test.go   | 89 ++++++++++++++++++++++
 internal/service/content/question_service.go       |  1 +
 4 files changed, 101 insertions(+), 6 deletions(-)

diff --git a/internal/entity/answer_entity.go b/internal/entity/answer_entity.go
index 4c9436ec..0e93aa97 100644
--- a/internal/entity/answer_entity.go
+++ b/internal/entity/answer_entity.go
@@ -70,6 +70,7 @@ type PersonalAnswerPageQueryCond struct {
 	UserID      string
 	Order       string
 	ShowPending bool
+	ShowHidden  bool
 }
 
 // TableName answer table name
diff --git a/internal/repo/answer/answer_repo.go b/internal/repo/answer/answer_repo.go
index 42e3494a..615d8dfb 100644
--- a/internal/repo/answer/answer_repo.go
+++ b/internal/repo/answer/answer_repo.go
@@ -394,20 +394,24 @@ func (ar *answerRepo) GetPersonalAnswerPage(ctx context.Context, req *entity.Per
 		UserID: req.UserID,
 	}
 	session := ar.data.DB.Context(ctx)
+	if !req.ShowHidden {
+		session = session.Join("INNER", "question", "answer.question_id = question.id").
+			And("question.show = ?", entity.QuestionShow)
+	}
 	switch req.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 req.ShowPending {
-		session = session.And("status != ?", entity.AnswerStatusDeleted)
+		session = session.And("answer.status != ?", entity.AnswerStatusDeleted)
 	} else {
-		session = session.And("status = ?", entity.AnswerStatusAvailable)
+		session = session.And("answer.status = ?", entity.AnswerStatusAvailable)
 	}
 	resp = make([]*entity.Answer, 0)
 	total, err = pager.Help(req.Page, req.PageSize, &resp, cond, session)
diff --git a/internal/repo/repo_test/personal_answer_visibility_test.go b/internal/repo/repo_test/personal_answer_visibility_test.go
new file mode 100644
index 00000000..f50105aa
--- /dev/null
+++ b/internal/repo/repo_test/personal_answer_visibility_test.go
@@ -0,0 +1,89 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package repo_test
+
+import (
+	"context"
+	"testing"
+
+	"github.com/apache/answer/internal/entity"
+	"github.com/apache/answer/internal/repo/answer"
+	"github.com/apache/answer/internal/repo/question"
+	"github.com/apache/answer/internal/repo/unique"
+	"github.com/stretchr/testify/require"
+)
+
+func TestPersonalAnswerPageRespectsHiddenQuestionVisibility(t *testing.T) {
+	ctx := context.Background()
+	questionRepo := question.NewQuestionRepo(testDataSource, unique.NewUniqueIDRepo(testDataSource))
+	answerRepo := answer.NewAnswerRepo(testDataSource, nil, nil, nil)
+
+	newQuestion := func(title string, show int) *entity.Question {
+		q := &entity.Question{
+			UserID:       "personal-answer-question-owner",
+			Title:        title,
+			OriginalText: title,
+			ParsedText:   title,
+			Status:       entity.QuestionStatusAvailable,
+			Show:         show,
+		}
+		require.NoError(t, questionRepo.AddQuestion(ctx, q))
+		return q
+	}
+
+	visibleQuestion := newQuestion("visible question", entity.QuestionShow)
+	hiddenQuestion := newQuestion("hidden question", entity.QuestionHide)
+	answers := []*entity.Answer{
+		{QuestionID: visibleQuestion.ID, UserID: "personal-answer-author", OriginalText: "visible answer", ParsedText: "visible answer", Status: entity.AnswerStatusAvailable},
+		{QuestionID: hiddenQuestion.ID, UserID: "personal-answer-author", OriginalText: "hidden answer", ParsedText: "hidden answer", Status: entity.AnswerStatusAvailable},
+	}
+	for _, item := range answers {
+		_, err := testDataSource.DB.Context(ctx).Insert(item)
+		require.NoError(t, err)
+	}
+	t.Cleanup(func() {
+		for _, item := range answers {
+			_, _ = testDataSource.DB.Context(ctx).ID(item.ID).Delete(&entity.Answer{})
+		}
+		for _, item := range []*entity.Question{visibleQuestion, hiddenQuestion} {
+			_, _ = testDataSource.DB.Context(ctx).ID(item.ID).Delete(&entity.Question{})
+		}
+	})
+
+	publicAnswers, publicTotal, err := answerRepo.GetPersonalAnswerPage(ctx, &entity.PersonalAnswerPageQueryCond{
+		Page:     1,
+		PageSize: 20,
+		UserID:   "personal-answer-author",
+	})
+	require.NoError(t, err)
+	require.Equal(t, int64(1), publicTotal)
+	require.Len(t, publicAnswers, 1)
+	require.Equal(t, visibleQuestion.ID, publicAnswers[0].QuestionID)
+
+	ownerAnswers, ownerTotal, err := answerRepo.GetPersonalAnswerPage(ctx, &entity.PersonalAnswerPageQueryCond{
+		Page:       1,
+		PageSize:   20,
+		UserID:     "personal-answer-author",
+		ShowHidden: true,
+	})
+	require.NoError(t, err)
+	require.Equal(t, int64(2), ownerTotal)
+	require.Len(t, ownerAnswers, 2)
+}
diff --git a/internal/service/content/question_service.go b/internal/service/content/question_service.go
index f7899566..82d6e1e0 100644
--- a/internal/service/content/question_service.go
+++ b/internal/service/content/question_service.go
@@ -1232,6 +1232,7 @@ func (qs *QuestionService) PersonalAnswerPage(ctx context.Context, req *schema.P
 	cond.Page = req.Page
 	cond.PageSize = req.PageSize
 	cond.ShowPending = req.IsAdmin || req.LoginUserID == cond.UserID
+	cond.ShowHidden = req.IsAdmin || req.LoginUserID == cond.UserID
 	if req.OrderCond == "newest" {
 		cond.Order = entity.AnswerSearchOrderByTime
 	} else {


(answer) 16/16: chore: update version to 2.0.3 and adjust prerequisites in README

Posted by li...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

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

commit 6c00c78896409f27407351e9e4110ab79646f0a9
Author: LinkinStars <li...@foxmail.com>
AuthorDate: Tue Sep 22 15:53:14 2026 +0800

    chore: update version to 2.0.3 and adjust prerequisites in README
---
 Makefile  | 2 +-
 README.md | 4 ++--
 2 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/Makefile b/Makefile
index 0623e1ef..74e51886 100644
--- a/Makefile
+++ b/Makefile
@@ -1,6 +1,6 @@
 .PHONY: build clean ui
 
-VERSION=2.0.2
+VERSION=2.0.3
 BIN=answer
 DIR_SRC=./cmd/answer
 DOCKER_CMD=docker
diff --git a/README.md b/README.md
index cf257aba..0d958e00 100644
--- a/README.md
+++ b/README.md
@@ -23,7 +23,7 @@ To learn more about the project, visit [answer.apache.org](https://answer.apache
 ### Running with docker
 
 ```bash
-docker run -d -p 9080:80 -v answer-data:/data --name answer apache/answer:2.0.2
+docker run -d -p 9080:80 -v answer-data:/data --name answer apache/answer:2.0.3
 ```
 
 For more information, see [Installation](https://answer.apache.org/docs/installation).
@@ -40,7 +40,7 @@ You can also check out the [plugins here](https://answer.apache.org/plugins).
 
 ### Prerequisites
 
-- Golang >= 1.23
+- Golang >= 1.25
 - Node.js >= 20
 - pnpm >= 9
 - [mockgen](https://github.com/uber-go/mock?tab=readme-ov-file#installation) >= 0.6.0


(answer) 15/16: fix(security): enforce reaction content visibility

Posted by li...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

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

commit f60756596d39b01e0ac8fdfc3ac8e6d7d30e00b3
Author: LinkinStars <li...@foxmail.com>
AuthorDate: Tue Sep 15 10:11:37 2026 +0800

    fix(security): enforce reaction content visibility
---
 cmd/wire_gen.go                                    |   2 +-
 internal/controller/meta_controller.go             |   2 +
 internal/schema/meta_schema.go                     |  14 +--
 internal/service/meta/meta_service.go              |  22 +++++
 .../service/meta/meta_service_visibility_test.go   | 103 +++++++++++++++++++++
 5 files changed, 136 insertions(+), 7 deletions(-)

diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go
index 446f6cc0..66e16278 100644
--- a/cmd/wire_gen.go
+++ b/cmd/wire_gen.go
@@ -274,7 +274,7 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database,
 	permissionController := controller.NewPermissionController(rankService)
 	userPluginController := controller.NewUserPluginController(pluginCommonService)
 	reviewController := controller.NewReviewController(reviewService, rankService, captchaService)
-	metaService := meta2.NewMetaService(metaCommonService, userCommon, answerRepo, questionRepo, eventqueueService)
+	metaService := meta2.NewMetaService(metaCommonService, userCommon, answerRepo, questionRepo, objService, eventqueueService)
 	metaController := controller.NewMetaController(metaService)
 	badgeGroupRepo := badge_group.NewBadgeGroupRepo(dataData, uniqueIDRepo)
 	eventRuleRepo := badge.NewEventRuleRepo(dataData)
diff --git a/internal/controller/meta_controller.go b/internal/controller/meta_controller.go
index 624daf09..2bce5eb1 100644
--- a/internal/controller/meta_controller.go
+++ b/internal/controller/meta_controller.go
@@ -57,6 +57,7 @@ func (mc *MetaController) AddOrUpdateReaction(ctx *gin.Context) {
 	}
 	req.ObjectID = uid.DeShortID(req.ObjectID)
 	req.UserID = middleware.GetLoginUserIDFromContext(ctx)
+	req.IsAdminModerator = middleware.GetUserIsAdminModerator(ctx)
 
 	resp, err := mc.metaService.AddOrUpdateReaction(ctx, req)
 	handler.HandleResponse(ctx, err, resp)
@@ -78,6 +79,7 @@ func (mc *MetaController) GetReaction(ctx *gin.Context) {
 	}
 	req.ObjectID = uid.DeShortID(req.ObjectID)
 	req.UserID = middleware.GetLoginUserIDFromContext(ctx)
+	req.IsAdminModerator = middleware.GetUserIsAdminModerator(ctx)
 
 	resp, err := mc.metaService.GetReactionByObjectId(ctx, req)
 	handler.HandleResponse(ctx, err, resp)
diff --git a/internal/schema/meta_schema.go b/internal/schema/meta_schema.go
index e5a07252..64362879 100644
--- a/internal/schema/meta_schema.go
+++ b/internal/schema/meta_schema.go
@@ -22,15 +22,17 @@ package schema
 import "slices"
 
 type UpdateReactionReq struct {
-	ObjectID string `validate:"required" json:"object_id"`
-	Emoji    string `validate:"required,oneof=heart smile frown" json:"emoji"`
-	Reaction string `validate:"required,oneof=activate deactivate" json:"reaction"`
-	UserID   string `json:"-"`
+	ObjectID         string `validate:"required" json:"object_id"`
+	Emoji            string `validate:"required,oneof=heart smile frown" json:"emoji"`
+	Reaction         string `validate:"required,oneof=activate deactivate" json:"reaction"`
+	UserID           string `json:"-"`
+	IsAdminModerator bool   `json:"-"`
 }
 
 type GetReactionReq struct {
-	ObjectID string `validate:"required" form:"object_id"`
-	UserID   string `json:"-"`
+	ObjectID         string `validate:"required" form:"object_id"`
+	UserID           string `json:"-"`
+	IsAdminModerator bool   `json:"-"`
 }
 
 // ReactionsSummaryMeta reactions summary meta
diff --git a/internal/service/meta/meta_service.go b/internal/service/meta/meta_service.go
index e48e8f46..070c04a8 100644
--- a/internal/service/meta/meta_service.go
+++ b/internal/service/meta/meta_service.go
@@ -36,18 +36,24 @@ import (
 	"github.com/apache/answer/internal/schema"
 	answercommon "github.com/apache/answer/internal/service/answer_common"
 	metacommon "github.com/apache/answer/internal/service/meta_common"
+	"github.com/apache/answer/internal/service/object_info"
 	questioncommon "github.com/apache/answer/internal/service/question_common"
 	usercommon "github.com/apache/answer/internal/service/user_common"
 	"github.com/apache/answer/pkg/obj"
 	myErrors "github.com/segmentfault/pacman/errors"
 )
 
+type objectInfoService interface {
+	GetInfo(ctx context.Context, objectID string) (objInfo *schema.SimpleObjectInfo, err error)
+}
+
 // MetaService user service
 type MetaService struct {
 	metaCommonService *metacommon.MetaCommonService
 	userCommon        *usercommon.UserCommon
 	questionRepo      questioncommon.QuestionRepo
 	answerRepo        answercommon.AnswerRepo
+	objectInfoService objectInfoService
 	eventQueueService eventqueue.Service
 }
 
@@ -56,6 +62,7 @@ func NewMetaService(
 	userCommon *usercommon.UserCommon,
 	answerRepo answercommon.AnswerRepo,
 	questionRepo questioncommon.QuestionRepo,
+	objectInfoService *object_info.ObjService,
 	eventQueueService eventqueue.Service,
 ) *MetaService {
 	return &MetaService{
@@ -63,12 +70,16 @@ func NewMetaService(
 		questionRepo:      questionRepo,
 		userCommon:        userCommon,
 		answerRepo:        answerRepo,
+		objectInfoService: objectInfoService,
 		eventQueueService: eventQueueService,
 	}
 }
 
 // GetReactionByObjectId get reaction
 func (ms *MetaService) GetReactionByObjectId(ctx context.Context, req *schema.GetReactionReq) (resp *schema.GetReactionByObjectIdResp, err error) {
+	if err := ms.checkReactionVisibility(ctx, req.ObjectID, req.UserID, req.IsAdminModerator); err != nil {
+		return nil, err
+	}
 	reactionMeta, err := ms.metaCommonService.GetMetaByObjectIdAndKey(ctx, req.ObjectID, entity.ObjectReactSummaryKey)
 
 	// if not exist, return nil
@@ -91,6 +102,9 @@ func (ms *MetaService) GetReactionByObjectId(ctx context.Context, req *schema.Ge
 
 // AddOrUpdateReaction add or update reaction
 func (ms *MetaService) AddOrUpdateReaction(ctx context.Context, req *schema.UpdateReactionReq) (resp *schema.GetReactionByObjectIdResp, err error) {
+	if err := ms.checkReactionVisibility(ctx, req.ObjectID, req.UserID, req.IsAdminModerator); err != nil {
+		return nil, err
+	}
 	// check if object exist and it's answer or question
 	objectType, err := obj.GetObjectTypeStrByObjectID(req.ObjectID)
 	if err != nil {
@@ -158,6 +172,14 @@ func (ms *MetaService) AddOrUpdateReaction(ctx context.Context, req *schema.Upda
 	return resp, nil
 }
 
+func (ms *MetaService) checkReactionVisibility(ctx context.Context, objectID, userID string, isAdminModerator bool) error {
+	objectInfo, err := ms.objectInfoService.GetInfo(ctx, objectID)
+	if err != nil {
+		return err
+	}
+	return objectInfo.CheckVisibility(userID, isAdminModerator)
+}
+
 // updateReaction update reaction
 func (ms *MetaService) updateReaction(req *schema.UpdateReactionReq, reactions *schema.ReactionsSummaryMeta) {
 	switch req.Reaction {
diff --git a/internal/service/meta/meta_service_visibility_test.go b/internal/service/meta/meta_service_visibility_test.go
new file mode 100644
index 00000000..a3cbbdbb
--- /dev/null
+++ b/internal/service/meta/meta_service_visibility_test.go
@@ -0,0 +1,103 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package meta
+
+import (
+	"context"
+	"testing"
+
+	"github.com/apache/answer/internal/base/constant"
+	"github.com/apache/answer/internal/entity"
+	"github.com/apache/answer/internal/schema"
+)
+
+func TestReactionVisibility(t *testing.T) {
+	tests := []struct {
+		name             string
+		objectInfo       *schema.SimpleObjectInfo
+		userID           string
+		isAdminModerator bool
+		allowed          bool
+	}{
+		{
+			name:       "anonymous cannot access hidden question",
+			objectInfo: &schema.SimpleObjectInfo{ObjectType: constant.QuestionObjectType, QuestionCreatorUserID: "question-owner", QuestionShow: entity.QuestionHide},
+		},
+		{
+			name:       "non-owner cannot access pending question",
+			objectInfo: &schema.SimpleObjectInfo{ObjectType: constant.QuestionObjectType, QuestionCreatorUserID: "question-owner", QuestionStatus: entity.QuestionStatusPending, QuestionShow: entity.QuestionShow},
+			userID:     "other-user",
+		},
+		{
+			name:       "non-owner cannot access deleted answer",
+			objectInfo: &schema.SimpleObjectInfo{ObjectType: constant.AnswerObjectType, ObjectCreatorUserID: "answer-owner", QuestionCreatorUserID: "question-owner", AnswerStatus: entity.AnswerStatusDeleted, QuestionShow: entity.QuestionShow},
+			userID:     "other-user",
+		},
+		{
+			name:       "non-owner cannot access answer on hidden question",
+			objectInfo: &schema.SimpleObjectInfo{ObjectType: constant.AnswerObjectType, ObjectCreatorUserID: "answer-owner", QuestionID: "question-id", QuestionCreatorUserID: "question-owner", AnswerStatus: entity.AnswerStatusAvailable, QuestionShow: entity.QuestionHide},
+			userID:     "other-user",
+		},
+		{
+			name:       "question owner can access hidden question",
+			objectInfo: &schema.SimpleObjectInfo{ObjectType: constant.QuestionObjectType, QuestionCreatorUserID: "question-owner", QuestionShow: entity.QuestionHide},
+			userID:     "question-owner",
+			allowed:    true,
+		},
+		{
+			name:             "moderator can access deleted answer",
+			objectInfo:       &schema.SimpleObjectInfo{ObjectType: constant.AnswerObjectType, ObjectCreatorUserID: "answer-owner", QuestionCreatorUserID: "question-owner", AnswerStatus: entity.AnswerStatusDeleted, QuestionShow: entity.QuestionShow},
+			isAdminModerator: true,
+			allowed:          true,
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			service := &MetaService{objectInfoService: metaTestObjectInfoService{objectInfo: test.objectInfo}}
+			err := service.checkReactionVisibility(context.Background(), "object-id", test.userID, test.isAdminModerator)
+			if (err == nil) != test.allowed {
+				t.Fatalf("visibility error = %v, allowed = %v", err, test.allowed)
+			}
+		})
+	}
+}
+
+func TestReactionOperationsRejectRestrictedObject(t *testing.T) {
+	service := &MetaService{objectInfoService: metaTestObjectInfoService{
+		objectInfo: &schema.SimpleObjectInfo{ObjectType: constant.QuestionObjectType, QuestionCreatorUserID: "question-owner", QuestionShow: entity.QuestionHide},
+	}}
+
+	if _, err := service.GetReactionByObjectId(context.Background(), &schema.GetReactionReq{ObjectID: "question-id"}); err == nil {
+		t.Fatal("anonymous read of a hidden question reaction was allowed")
+	}
+	if _, err := service.AddOrUpdateReaction(context.Background(), &schema.UpdateReactionReq{ObjectID: "question-id", UserID: "other-user"}); err == nil {
+		t.Fatal("non-owner write to a hidden question reaction was allowed")
+	}
+}
+
+type metaTestObjectInfoService struct {
+	objectInfo *schema.SimpleObjectInfo
+	err        error
+}
+
+func (s metaTestObjectInfoService) GetInfo(context.Context, string) (*schema.SimpleObjectInfo, error) {
+	return s.objectInfo, s.err
+}


(answer) 04/16: fix: strengthen request safeguards

Posted by li...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

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

commit 9ace900784f2ba9bca8c855b93d6f5828644dc47
Author: LinkinStars <li...@foxmail.com>
AuthorDate: Fri Aug 14 19:17:10 2026 +0800

    fix: strengthen request safeguards
---
 internal/base/middleware/header.go                 |  4 +
 .../base/middleware/{header.go => header_test.go}  | 23 ++++--
 internal/base/middleware/visit_img_auth.go         |  1 +
 internal/base/server/http.go                       |  2 +-
 internal/controller/question_controller.go         |  1 +
 internal/repo/question/question_repo.go            | 13 +++-
 .../repo/repo_test/question_link_security_test.go  | 85 ++++++++++++++++++++++
 internal/router/static_router.go                   | 20 ++++-
 .../header.go => router/static_router_test.go}     | 26 +++++--
 internal/schema/email_template.go                  | 11 ++-
 internal/schema/email_template_test.go             | 41 +++++++++++
 internal/schema/question_schema.go                 | 14 ++--
 internal/service/content/question_service.go       |  2 +-
 internal/service/content/user_service.go           | 31 +++++---
 internal/service/content/user_service_test.go      | 43 +++++++++++
 internal/service/question_common/question.go       |  2 +-
 internal/service/uploader/upload.go                | 13 ++--
 internal/service/user_admin/user_backyard.go       | 10 ++-
 pkg/checker/file_type.go                           |  2 +
 .../header.go => pkg/checker/file_type_test.go     | 20 ++---
 20 files changed, 304 insertions(+), 60 deletions(-)

diff --git a/internal/base/middleware/header.go b/internal/base/middleware/header.go
index 717d2ac0..15de54df 100644
--- a/internal/base/middleware/header.go
+++ b/internal/base/middleware/header.go
@@ -25,8 +25,12 @@ import (
 	"github.com/gin-gonic/gin"
 )
 
+const contentSecurityPolicy = "default-src 'self'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'; object-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: http: https:; font-src 'self' data:; connect-src 'self'"
+
 func HeadersByRequestURI() gin.HandlerFunc {
 	return func(c *gin.Context) {
+		c.Header("Content-Security-Policy", contentSecurityPolicy)
+		c.Header("X-Content-Type-Options", "nosniff")
 		if strings.HasPrefix(c.Request.RequestURI, "/static/") {
 			c.Header("cache-control", "public, max-age=31536000")
 		}
diff --git a/internal/base/middleware/header.go b/internal/base/middleware/header_test.go
similarity index 56%
copy from internal/base/middleware/header.go
copy to internal/base/middleware/header_test.go
index 717d2ac0..9f838aeb 100644
--- a/internal/base/middleware/header.go
+++ b/internal/base/middleware/header_test.go
@@ -20,15 +20,26 @@
 package middleware
 
 import (
-	"strings"
+	"net/http"
+	"net/http/httptest"
+	"testing"
 
 	"github.com/gin-gonic/gin"
 )
 
-func HeadersByRequestURI() gin.HandlerFunc {
-	return func(c *gin.Context) {
-		if strings.HasPrefix(c.Request.RequestURI, "/static/") {
-			c.Header("cache-control", "public, max-age=31536000")
-		}
+func TestHeadersByRequestURI(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	router := gin.New()
+	router.Use(HeadersByRequestURI())
+	router.GET("/", func(ctx *gin.Context) { ctx.Status(http.StatusNoContent) })
+
+	response := httptest.NewRecorder()
+	router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/", nil))
+
+	if got := response.Header().Get("X-Content-Type-Options"); got != "nosniff" {
+		t.Fatalf("X-Content-Type-Options = %q, want nosniff", got)
+	}
+	if got := response.Header().Get("Content-Security-Policy"); got != contentSecurityPolicy {
+		t.Fatalf("Content-Security-Policy = %q, want %q", got, contentSecurityPolicy)
 	}
 }
diff --git a/internal/base/middleware/visit_img_auth.go b/internal/base/middleware/visit_img_auth.go
index bfd157a9..b2aea6b8 100644
--- a/internal/base/middleware/visit_img_auth.go
+++ b/internal/base/middleware/visit_img_auth.go
@@ -43,6 +43,7 @@ func (am *AuthUserMiddleware) VisitAuth() gin.HandlerFunc {
 
 		siteSecurity, err := am.siteInfoCommonService.GetSiteSecurity(ctx)
 		if err != nil {
+			ctx.AbortWithStatus(http.StatusInternalServerError)
 			return
 		}
 		if !siteSecurity.LoginRequired {
diff --git a/internal/base/server/http.go b/internal/base/server/http.go
index 8db55744..22e7afdb 100644
--- a/internal/base/server/http.go
+++ b/internal/base/server/http.go
@@ -78,7 +78,7 @@ func NewHTTPServer(debug bool,
 	rootGroup := r.Group("")
 	swaggerRouter.Register(rootGroup)
 	static := r.Group(uiConf.APIBaseURL)
-	static.Use(avatarMiddleware.AvatarThumb(), authUserMiddleware.VisitAuth())
+	static.Use(authUserMiddleware.VisitAuth(), avatarMiddleware.AvatarThumb())
 	staticRouter.RegisterStaticRouter(static)
 
 	// The route must be available without logging in
diff --git a/internal/controller/question_controller.go b/internal/controller/question_controller.go
index 644e3097..c83fd578 100644
--- a/internal/controller/question_controller.go
+++ b/internal/controller/question_controller.go
@@ -998,6 +998,7 @@ func (qc *QuestionController) GetQuestionLink(ctx *gin.Context) {
 		return
 	}
 	req.LoginUserID = middleware.GetLoginUserIDFromContext(ctx)
+	req.IsAdminModerator = middleware.GetUserIsAdminModerator(ctx)
 	req.QuestionID = uid.DeShortID(req.QuestionID)
 	questions, total, err := qc.questionService.GetQuestionLink(ctx, req)
 	if err != nil {
diff --git a/internal/repo/question/question_repo.go b/internal/repo/question/question_repo.go
index 3449efb8..3f38fc6c 100644
--- a/internal/repo/question/question_repo.go
+++ b/internal/repo/question/question_repo.go
@@ -817,10 +817,9 @@ func (qr *questionRepo) UpdateQuestionLinkStatus(ctx context.Context, status int
 }
 
 // GetQuestionLink get linked question to questionID
-func (qr *questionRepo) GetQuestionLink(ctx context.Context, page, pageSize int, questionID string, orderCond string, inDays int) (questionList []*entity.Question, total int64, err error) {
+func (qr *questionRepo) GetQuestionLink(ctx context.Context, page, pageSize int, questionID, loginUserID string, isAdminModerator bool, orderCond string, inDays int) (questionList []*entity.Question, total int64, err error) {
 	questionList = make([]*entity.Question, 0)
 	questionID = uid.DeShortID(questionID)
-	questionStatus := []int{entity.QuestionStatusAvailable, entity.QuestionStatusClosed, entity.QuestionStatusPending}
 	if questionID == "0" {
 		return nil, 0, errors.InternalServer(reason.DatabaseError).WithError(
 			fmt.Errorf("questionID is empty"),
@@ -833,8 +832,14 @@ func (qr *questionRepo) GetQuestionLink(ctx context.Context, page, pageSize int,
 		Where("question_link.to_question_id = ? AND question.show = ?", questionID, entity.QuestionShow).
 		Distinct("question.id").
 		Where("question_link.status = ?", entity.QuestionLinkStatusAvailable).
-		Select("question.*").
-		In("question.status", questionStatus)
+		Select("question.*")
+	if isAdminModerator {
+		session.Where("question.status IN (?, ?, ?)", entity.QuestionStatusAvailable, entity.QuestionStatusClosed, entity.QuestionStatusPending)
+	} else if loginUserID != "" {
+		session.Where("(question.status IN (?, ?) OR (question.status = ? AND question.user_id = ?))", entity.QuestionStatusAvailable, entity.QuestionStatusClosed, entity.QuestionStatusPending, loginUserID)
+	} else {
+		session.In("question.status", []int{entity.QuestionStatusAvailable, entity.QuestionStatusClosed})
+	}
 
 	switch orderCond {
 	case "newest":
diff --git a/internal/repo/repo_test/question_link_security_test.go b/internal/repo/repo_test/question_link_security_test.go
new file mode 100644
index 00000000..3c38448b
--- /dev/null
+++ b/internal/repo/repo_test/question_link_security_test.go
@@ -0,0 +1,85 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package repo_test
+
+import (
+	"context"
+	"testing"
+
+	"github.com/apache/answer/internal/entity"
+	"github.com/apache/answer/internal/repo/question"
+	"github.com/apache/answer/internal/repo/unique"
+	"github.com/stretchr/testify/require"
+)
+
+func TestQuestionRepoGetQuestionLinkRespectsPendingVisibility(t *testing.T) {
+	ctx := context.Background()
+	questionRepo := question.NewQuestionRepo(testDataSource, unique.NewUniqueIDRepo(testDataSource))
+
+	newQuestion := func(userID, title string, status, show int) *entity.Question {
+		q := &entity.Question{
+			UserID:       userID,
+			Title:        title,
+			OriginalText: title,
+			ParsedText:   title,
+			Status:       status,
+			Show:         show,
+		}
+		require.NoError(t, questionRepo.AddQuestion(ctx, q))
+		return q
+	}
+
+	target := newQuestion("link-target-owner", "link target", entity.QuestionStatusAvailable, entity.QuestionShow)
+	available := newQuestion("link-author", "available", entity.QuestionStatusAvailable, entity.QuestionShow)
+	pendingOwner := newQuestion("link-author", "pending owner", entity.QuestionStatusPending, entity.QuestionShow)
+	pendingOther := newQuestion("link-other", "pending other", entity.QuestionStatusPending, entity.QuestionShow)
+	hidden := newQuestion("link-author", "hidden", entity.QuestionStatusAvailable, entity.QuestionHide)
+	questions := []*entity.Question{target, available, pendingOwner, pendingOther, hidden}
+
+	for _, from := range questions[1:] {
+		_, err := testDataSource.DB.Context(ctx).Insert(&entity.QuestionLink{
+			FromQuestionID: from.ID,
+			ToQuestionID:   target.ID,
+			Status:         entity.QuestionLinkStatusAvailable,
+		})
+		require.NoError(t, err)
+	}
+	t.Cleanup(func() {
+		_, _ = testDataSource.DB.Context(ctx).Where("to_question_id = ?", target.ID).Delete(&entity.QuestionLink{})
+		for _, q := range questions {
+			_, _ = testDataSource.DB.Context(ctx).ID(q.ID).Delete(&entity.Question{})
+		}
+	})
+
+	assertLinkedIDs := func(loginUserID string, isAdminModerator bool, want ...string) {
+		got, _, err := questionRepo.GetQuestionLink(ctx, 1, 20, target.ID, loginUserID, isAdminModerator, "newest", 0)
+		require.NoError(t, err)
+		gotIDs := make([]string, 0, len(got))
+		for _, q := range got {
+			gotIDs = append(gotIDs, q.ID)
+		}
+		require.ElementsMatch(t, want, gotIDs)
+	}
+
+	assertLinkedIDs("", false, available.ID)
+	assertLinkedIDs("link-author", false, available.ID, pendingOwner.ID)
+	assertLinkedIDs("link-other", false, available.ID, pendingOther.ID)
+	assertLinkedIDs("link-moderator", true, available.ID, pendingOwner.ID, pendingOther.ID)
+}
diff --git a/internal/router/static_router.go b/internal/router/static_router.go
index a6c80fc0..e16002cf 100644
--- a/internal/router/static_router.go
+++ b/internal/router/static_router.go
@@ -53,10 +53,11 @@ func (a *StaticRouter) RegisterStaticRouter(r *gin.RouterGroup) {
 		filePath := c.Param("filepath")
 		// The original filename is 123.pdf
 		originalFilename := filepath.Base(filePath)
-		// The real filename is hash.pdf
-		realFilename := strings.TrimSuffix(filePath, "/"+originalFilename) + filepath.Ext(originalFilename)
-		// The file local path is /uploads/files/post/hash.pdf
-		fileLocalPath := filepath.Join(a.serviceConfig.UploadPath, constant.FilesPostSubPath, realFilename)
+		fileLocalPath, ok := attachmentFileLocalPath(a.serviceConfig.UploadPath, filePath, originalFilename)
+		if !ok {
+			c.Redirect(http.StatusFound, "/404")
+			return
+		}
 		// If the file is not exist, return 404
 		if !dir.CheckFileExist(fileLocalPath) {
 			c.Redirect(http.StatusFound, "/404")
@@ -65,3 +66,14 @@ func (a *StaticRouter) RegisterStaticRouter(r *gin.RouterGroup) {
 		c.FileAttachment(fileLocalPath, originalFilename)
 	})
 }
+
+func attachmentFileLocalPath(uploadPath, requestPath, originalFilename string) (string, bool) {
+	realFilename := strings.TrimSuffix(requestPath, "/"+originalFilename) + filepath.Ext(originalFilename)
+	attachmentRoot := filepath.Join(uploadPath, constant.FilesPostSubPath)
+	fileLocalPath := filepath.Join(attachmentRoot, realFilename)
+	relPath, err := filepath.Rel(attachmentRoot, fileLocalPath)
+	if err != nil || filepath.IsAbs(relPath) || relPath == ".." || strings.HasPrefix(relPath, ".."+string(filepath.Separator)) {
+		return "", false
+	}
+	return fileLocalPath, true
+}
diff --git a/internal/base/middleware/header.go b/internal/router/static_router_test.go
similarity index 52%
copy from internal/base/middleware/header.go
copy to internal/router/static_router_test.go
index 717d2ac0..b123c95d 100644
--- a/internal/base/middleware/header.go
+++ b/internal/router/static_router_test.go
@@ -17,18 +17,30 @@
  * under the License.
  */
 
-package middleware
+package router
 
 import (
-	"strings"
+	"path/filepath"
+	"testing"
 
-	"github.com/gin-gonic/gin"
+	"github.com/apache/answer/internal/base/constant"
 )
 
-func HeadersByRequestURI() gin.HandlerFunc {
-	return func(c *gin.Context) {
-		if strings.HasPrefix(c.Request.RequestURI, "/static/") {
-			c.Header("cache-control", "public, max-age=31536000")
+func TestAttachmentFileLocalPathRejectsTraversal(t *testing.T) {
+	uploadPath := t.TempDir()
+
+	filePath, ok := attachmentFileLocalPath(uploadPath, "/hash/report.pdf", "report.pdf")
+	if !ok {
+		t.Fatal("valid attachment path was rejected")
+	}
+	want := filepath.Join(uploadPath, constant.FilesPostSubPath, "hash.pdf")
+	if filePath != want {
+		t.Fatalf("attachment path = %q, want %q", filePath, want)
+	}
+
+	for _, requestPath := range []string{"/../../outside/secret.txt", "/hash/../../../secret.txt"} {
+		if _, ok := attachmentFileLocalPath(uploadPath, requestPath, filepath.Base(requestPath)); ok {
+			t.Fatalf("traversal path %q was accepted", requestPath)
 		}
 	}
 }
diff --git a/internal/schema/email_template.go b/internal/schema/email_template.go
index d7e4b929..57f1ac3c 100644
--- a/internal/schema/email_template.go
+++ b/internal/schema/email_template.go
@@ -28,7 +28,7 @@ import (
 const (
 	AccountActivationSourceType EmailSourceType = "account-activation"
 	PasswordResetSourceType     EmailSourceType = "password-reset"
-	ConfirmNewEmailSourceType   EmailSourceType = "password-reset"
+	ConfirmNewEmailSourceType   EmailSourceType = "confirm-new-email"
 	UnsubscribeSourceType       EmailSourceType = "unsubscribe"
 	BindingSourceType           EmailSourceType = "binding"
 )
@@ -56,6 +56,15 @@ func (r *EmailCodeContent) FromJSONString(data string) error {
 	return json.Unmarshal([]byte(data), &r)
 }
 
+func (r *EmailCodeContent) IsSourceType(sourceTypes ...EmailSourceType) bool {
+	for _, sourceType := range sourceTypes {
+		if r.SourceType == sourceType {
+			return true
+		}
+	}
+	return false
+}
+
 type RegisterTemplateData struct {
 	SiteName    string
 	RegisterUrl string
diff --git a/internal/schema/email_template_test.go b/internal/schema/email_template_test.go
new file mode 100644
index 00000000..8a11738d
--- /dev/null
+++ b/internal/schema/email_template_test.go
@@ -0,0 +1,41 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package schema
+
+import "testing"
+
+func TestEmailCodeContentIsSourceType(t *testing.T) {
+	if PasswordResetSourceType == ConfirmNewEmailSourceType {
+		t.Fatal("password reset and confirm new email source types must be distinct")
+	}
+
+	passwordResetCode := &EmailCodeContent{SourceType: PasswordResetSourceType}
+	if !passwordResetCode.IsSourceType(PasswordResetSourceType) {
+		t.Fatal("password reset code should match the password reset source type")
+	}
+	if passwordResetCode.IsSourceType(UnsubscribeSourceType, ConfirmNewEmailSourceType) {
+		t.Fatal("password reset code must not match another source type")
+	}
+
+	unsubscribeCode := &EmailCodeContent{SourceType: UnsubscribeSourceType}
+	if unsubscribeCode.IsSourceType(PasswordResetSourceType, AccountActivationSourceType) {
+		t.Fatal("unsubscribe code must not match an account credential source type")
+	}
+}
diff --git a/internal/schema/question_schema.go b/internal/schema/question_schema.go
index 4c231385..4c6d2ead 100644
--- a/internal/schema/question_schema.go
+++ b/internal/schema/question_schema.go
@@ -514,13 +514,13 @@ type PersonalCollectionPageReq struct {
 }
 
 type GetQuestionLinkReq struct {
-	Page       int    `validate:"omitempty,min=1" form:"page"`
-	PageSize   int    `validate:"omitempty,min=1,max=100" form:"page_size"`
-	QuestionID string `validate:"required" form:"question_id"`
-	OrderCond  string `validate:"omitempty,oneof=newest active hot score unanswered recommend frequent" form:"order"`
-	InDays     int    `validate:"omitempty,min=1" form:"in_days"`
-
-	LoginUserID string `json:"-"`
+	Page             int    `validate:"omitempty,min=1" form:"page"`
+	PageSize         int    `validate:"omitempty,min=1,max=100" form:"page_size"`
+	QuestionID       string `validate:"required" form:"question_id"`
+	OrderCond        string `validate:"omitempty,oneof=newest active hot score unanswered recommend frequent" form:"order"`
+	InDays           int    `validate:"omitempty,min=1" form:"in_days"`
+	LoginUserID      string `json:"-"`
+	IsAdminModerator bool   `json:"-"`
 }
 
 type GetQuestionLinkResp struct {
diff --git a/internal/service/content/question_service.go b/internal/service/content/question_service.go
index 73f66a4c..b172018b 100644
--- a/internal/service/content/question_service.go
+++ b/internal/service/content/question_service.go
@@ -1748,7 +1748,7 @@ func (qs *QuestionService) GetQuestionLink(ctx context.Context, req *schema.GetQ
 		req.InDays = schema.HotInDays
 	}
 
-	questionList, total, err := qs.questionRepo.GetQuestionLink(ctx, req.Page, req.PageSize, req.QuestionID, req.OrderCond, req.InDays)
+	questionList, total, err := qs.questionRepo.GetQuestionLink(ctx, req.Page, req.PageSize, req.QuestionID, req.LoginUserID, req.IsAdminModerator, req.OrderCond, req.InDays)
 	if err != nil {
 		return nil, 0, err
 	}
diff --git a/internal/service/content/user_service.go b/internal/service/content/user_service.go
index c1f800ff..f556fa17 100644
--- a/internal/service/content/user_service.go
+++ b/internal/service/content/user_service.go
@@ -227,8 +227,9 @@ func (us *UserService) RetrievePassWord(ctx context.Context, req *schema.UserRet
 
 	// send email
 	data := &schema.EmailCodeContent{
-		Email:  req.Email,
-		UserID: userInfo.ID,
+		SourceType: schema.PasswordResetSourceType,
+		Email:      req.Email,
+		UserID:     userInfo.ID,
 	}
 	code := token.GenerateToken()
 	verifyEmailURL := fmt.Sprintf("%s/users/password-reset?code=%s", us.getSiteUrl(ctx), code)
@@ -247,6 +248,9 @@ func (us *UserService) UpdatePasswordWhenForgot(ctx context.Context, req *schema
 	if err != nil {
 		return errors.BadRequest(reason.EmailVerifyURLExpired)
 	}
+	if !data.IsSourceType(schema.PasswordResetSourceType) {
+		return errors.BadRequest(reason.EmailVerifyURLExpired)
+	}
 
 	userInfo, exist, err := us.userRepo.GetByEmail(ctx, data.Email)
 	if err != nil {
@@ -598,8 +602,9 @@ func applyRegistrationVerification(
 
 func (us *UserService) sendRegistrationActivationEmail(ctx context.Context, userInfo *entity.User) error {
 	data := &schema.EmailCodeContent{
-		Email:  userInfo.EMail,
-		UserID: userInfo.ID,
+		SourceType: schema.AccountActivationSourceType,
+		Email:      userInfo.EMail,
+		UserID:     userInfo.ID,
 	}
 	code := token.GenerateToken()
 	verifyEmailURL := fmt.Sprintf("%s/users/account-activation?code=%s", us.getSiteUrl(ctx), code)
@@ -621,8 +626,9 @@ func (us *UserService) UserVerifyEmailSend(ctx context.Context, userID string) e
 	}
 
 	data := &schema.EmailCodeContent{
-		Email:  userInfo.EMail,
-		UserID: userInfo.ID,
+		SourceType: schema.AccountActivationSourceType,
+		Email:      userInfo.EMail,
+		UserID:     userInfo.ID,
 	}
 	code := token.GenerateToken()
 	verifyEmailURL := fmt.Sprintf("%s/users/account-activation?code=%s", us.getSiteUrl(ctx), code)
@@ -640,6 +646,9 @@ func (us *UserService) UserVerifyEmail(ctx context.Context, req *schema.UserVeri
 	if err != nil {
 		return nil, errors.BadRequest(reason.EmailVerifyURLExpired)
 	}
+	if !data.IsSourceType(schema.AccountActivationSourceType, schema.BindingSourceType) {
+		return nil, errors.BadRequest(reason.EmailVerifyURLExpired)
+	}
 
 	userInfo, has, err := us.userRepo.GetByEmail(ctx, data.Email)
 	if err != nil {
@@ -736,8 +745,9 @@ func (us *UserService) UserChangeEmailSendCode(ctx context.Context, req *schema.
 	}
 
 	data := &schema.EmailCodeContent{
-		Email:  req.Email,
-		UserID: req.UserID,
+		SourceType: schema.ConfirmNewEmailSourceType,
+		Email:      req.Email,
+		UserID:     req.UserID,
 	}
 	code := token.GenerateToken()
 	var title, body string
@@ -763,6 +773,9 @@ func (us *UserService) UserChangeEmailVerify(ctx context.Context, content string
 	if err != nil {
 		return nil, errors.BadRequest(reason.EmailVerifyURLExpired)
 	}
+	if !data.IsSourceType(schema.ConfirmNewEmailSourceType) {
+		return nil, errors.BadRequest(reason.EmailVerifyURLExpired)
+	}
 
 	_, exist, err := us.userRepo.GetByEmail(ctx, data.Email)
 	if err != nil {
@@ -896,7 +909,7 @@ func (us *UserService) UserUnsubscribeNotification(
 	ctx context.Context, req *schema.UserUnsubscribeNotificationReq) (err error) {
 	data := &schema.EmailCodeContent{}
 	err = data.FromJSONString(req.Content)
-	if err != nil || len(data.UserID) == 0 {
+	if err != nil || len(data.UserID) == 0 || !data.IsSourceType(schema.UnsubscribeSourceType) {
 		return errors.BadRequest(reason.EmailVerifyURLExpired)
 	}
 
diff --git a/internal/service/content/user_service_test.go b/internal/service/content/user_service_test.go
index d77a1c44..9c654aaa 100644
--- a/internal/service/content/user_service_test.go
+++ b/internal/service/content/user_service_test.go
@@ -20,14 +20,57 @@
 package content
 
 import (
+	"context"
 	"errors"
 	"testing"
 
 	"github.com/apache/answer/internal/entity"
+	"github.com/apache/answer/internal/schema"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 )
 
+func TestEmailCodePurposeIsEnforcedBeforeUserMutation(t *testing.T) {
+	service := &UserService{}
+	ctx := context.Background()
+
+	t.Run("password reset rejects a code issued for another purpose", func(t *testing.T) {
+		content := (&schema.EmailCodeContent{
+			SourceType: schema.UnsubscribeSourceType,
+			Email:      "user@example.test",
+		}).ToJSONString()
+
+		err := service.UpdatePasswordWhenForgot(ctx, &schema.UserRePassWordRequest{Content: content})
+		if err == nil {
+			t.Fatal("password reset accepted an unsubscribe code")
+		}
+	})
+
+	t.Run("email activation rejects a password reset code", func(t *testing.T) {
+		content := (&schema.EmailCodeContent{
+			SourceType: schema.PasswordResetSourceType,
+			Email:      "user@example.test",
+		}).ToJSONString()
+
+		_, err := service.UserVerifyEmail(ctx, &schema.UserVerifyEmailReq{Content: content})
+		if err == nil {
+			t.Fatal("email activation accepted a password reset code")
+		}
+	})
+
+	t.Run("change email rejects a password reset code", func(t *testing.T) {
+		content := (&schema.EmailCodeContent{
+			SourceType: schema.PasswordResetSourceType,
+			Email:      "user@example.test",
+		}).ToJSONString()
+
+		_, err := service.UserChangeEmailVerify(ctx, content)
+		if err == nil {
+			t.Fatal("change email accepted a password reset code")
+		}
+	})
+}
+
 func TestApplyRegistrationVerification(t *testing.T) {
 	t.Run("required sends activation email and leaves email pending", func(t *testing.T) {
 		userInfo := &entity.User{}
diff --git a/internal/service/question_common/question.go b/internal/service/question_common/question.go
index 3a730634..8a45f1ed 100644
--- a/internal/service/question_common/question.go
+++ b/internal/service/question_common/question.go
@@ -88,7 +88,7 @@ type QuestionRepo interface {
 	RemoveQuestionLink(ctx context.Context, link ...*entity.QuestionLink) (err error)
 	RecoverQuestionLink(ctx context.Context, link ...*entity.QuestionLink) (err error)
 	UpdateQuestionLinkStatus(ctx context.Context, status int, links ...*entity.QuestionLink) (err error)
-	GetQuestionLink(ctx context.Context, page, pageSize int, questionID string, orderCond string, inDays int) (questions []*entity.Question, total int64, err error)
+	GetQuestionLink(ctx context.Context, page, pageSize int, questionID, loginUserID string, isAdminModerator bool, orderCond string, inDays int) (questions []*entity.Question, total int64, err error)
 }
 
 // QuestionCommon user service
diff --git a/internal/service/uploader/upload.go b/internal/service/uploader/upload.go
index 58f80846..72901b55 100644
--- a/internal/service/uploader/upload.go
+++ b/internal/service/uploader/upload.go
@@ -319,13 +319,13 @@ func (us *uploaderService) uploadImageFile(ctx *gin.Context, file *multipart.Fil
 	if err := ctx.SaveUploadedFile(file, filePath); err != nil {
 		return "", errors.InternalServer(reason.UnknownError).WithError(err).WithStack()
 	}
-
-	src, err := file.Open()
-	if err != nil {
-		return "", errors.InternalServer(reason.UnknownError).WithError(err).WithStack()
-	}
+	saved := false
 	defer func() {
-		_ = src.Close()
+		if !saved {
+			if removeErr := os.Remove(filePath); removeErr != nil && !os.IsNotExist(removeErr) {
+				log.Errorf("remove failed uploaded file failed: %v", removeErr)
+			}
+		}
 	}()
 
 	if !checker.DecodeAndCheckImageFile(filePath, siteAdvanced.GetMaxImageMegapixel()) {
@@ -337,6 +337,7 @@ func (us *uploaderService) uploadImageFile(ctx *gin.Context, file *multipart.Fil
 	}
 
 	url = fmt.Sprintf("%s/uploads/%s", siteGeneral.SiteUrl, fileSubPath)
+	saved = true
 	return url, nil
 }
 
diff --git a/internal/service/user_admin/user_backyard.go b/internal/service/user_admin/user_backyard.go
index 29e33804..072feec9 100644
--- a/internal/service/user_admin/user_backyard.go
+++ b/internal/service/user_admin/user_backyard.go
@@ -597,8 +597,9 @@ func (us *UserAdminService) GetUserActivation(ctx context.Context, req *schema.G
 	}
 
 	data := &schema.EmailCodeContent{
-		Email:  userInfo.EMail,
-		UserID: userInfo.ID,
+		SourceType: schema.AccountActivationSourceType,
+		Email:      userInfo.EMail,
+		UserID:     userInfo.ID,
 	}
 	code := token.GenerateToken()
 	us.emailService.SaveCode(ctx, userInfo.ID, code, data.ToJSONString())
@@ -624,8 +625,9 @@ func (us *UserAdminService) SendUserActivation(ctx context.Context, req *schema.
 	}
 
 	data := &schema.EmailCodeContent{
-		Email:  userInfo.EMail,
-		UserID: userInfo.ID,
+		SourceType: schema.AccountActivationSourceType,
+		Email:      userInfo.EMail,
+		UserID:     userInfo.ID,
 	}
 	code := token.GenerateToken()
 	verifyEmailURL := fmt.Sprintf("%s/users/account-activation?code=%s", general.SiteUrl, code)
diff --git a/pkg/checker/file_type.go b/pkg/checker/file_type.go
index 19999ef1..c324fd34 100644
--- a/pkg/checker/file_type.go
+++ b/pkg/checker/file_type.go
@@ -61,6 +61,8 @@ func DecodeAndCheckImageFile(localFilePath string, maxImageMegapixel int) bool {
 		if !decodeAndCheckImageFile(localFilePath, maxImageMegapixel, ext, webpImageCheck) {
 			return false
 		}
+	default:
+		return false
 	}
 	return true
 }
diff --git a/internal/base/middleware/header.go b/pkg/checker/file_type_test.go
similarity index 67%
copy from internal/base/middleware/header.go
copy to pkg/checker/file_type_test.go
index 717d2ac0..3b47de55 100644
--- a/internal/base/middleware/header.go
+++ b/pkg/checker/file_type_test.go
@@ -17,18 +17,20 @@
  * under the License.
  */
 
-package middleware
+package checker
 
 import (
-	"strings"
-
-	"github.com/gin-gonic/gin"
+	"os"
+	"path/filepath"
+	"testing"
 )
 
-func HeadersByRequestURI() gin.HandlerFunc {
-	return func(c *gin.Context) {
-		if strings.HasPrefix(c.Request.RequestURI, "/static/") {
-			c.Header("cache-control", "public, max-age=31536000")
-		}
+func TestDecodeAndCheckImageFileRejectsUnsupportedExtension(t *testing.T) {
+	filePath := filepath.Join(t.TempDir(), "not-an-image.svg")
+	if err := os.WriteFile(filePath, []byte("<svg></svg>"), 0o600); err != nil {
+		t.Fatal(err)
+	}
+	if DecodeAndCheckImageFile(filePath, 1_000_000) {
+		t.Fatal("unsupported image extensions must be rejected")
 	}
 }


(answer) 10/16: fix(security): restrict similar question visibility

Posted by li...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

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

commit 9eb832970d7df03af94da7721c2899bfe5277bd8
Author: LinkinStars <li...@foxmail.com>
AuthorDate: Tue Aug 25 16:36:04 2026 +0800

    fix(security): restrict similar question visibility
---
 internal/controller/question_controller.go         | 12 +++-
 internal/service/content/question_service.go       | 16 ++++-
 .../content/question_service_visibility_test.go    | 75 ++++++++++++++++++++++
 3 files changed, 101 insertions(+), 2 deletions(-)

diff --git a/internal/controller/question_controller.go b/internal/controller/question_controller.go
index 66d7b221..8294cdcb 100644
--- a/internal/controller/question_controller.go
+++ b/internal/controller/question_controller.go
@@ -832,7 +832,17 @@ func (qc *QuestionController) UpdateQuestionInviteUser(ctx *gin.Context) {
 // @Router /answer/api/v1/question/similar [get]
 func (qc *QuestionController) GetSimilarQuestions(ctx *gin.Context) {
 	title := ctx.Query("title")
-	resp, err := qc.questionService.GetQuestionsByTitle(ctx, title)
+	userID := middleware.GetLoginUserIDFromContext(ctx)
+	canReopen, err := qc.rankService.CheckOperationPermission(ctx, userID, permission.QuestionReopen, "")
+	if err != nil {
+		handler.HandleResponse(ctx, err, nil)
+		return
+	}
+	per := schema.QuestionPermission{
+		IsAdminModerator: middleware.GetUserIsAdminModerator(ctx),
+		CanReopen:        canReopen,
+	}
+	resp, err := qc.questionService.GetQuestionsByTitle(ctx, title, userID, per)
 	handler.HandleResponse(ctx, err, resp)
 }
 
diff --git a/internal/service/content/question_service.go b/internal/service/content/question_service.go
index 82d6e1e0..ced0e010 100644
--- a/internal/service/content/question_service.go
+++ b/internal/service/content/question_service.go
@@ -1385,7 +1385,7 @@ func (qs *QuestionService) SearchUserTopList(ctx context.Context, userName strin
 }
 
 // GetQuestionsByTitle get questions by title
-func (qs *QuestionService) GetQuestionsByTitle(ctx context.Context, title string) (
+func (qs *QuestionService) GetQuestionsByTitle(ctx context.Context, title, userID string, per schema.QuestionPermission) (
 	resp []*schema.QuestionBaseInfo, err error) {
 	resp = make([]*schema.QuestionBaseInfo, 0)
 	if len(title) == 0 {
@@ -1428,6 +1428,9 @@ func (qs *QuestionService) GetQuestionsByTitle(ctx context.Context, title string
 		}
 	}
 	for _, question := range questions {
+		if !canViewSimilarQuestion(question, userID, per) {
+			continue
+		}
 		item := &schema.QuestionBaseInfo{}
 		item.ID = question.ID
 		item.Title = question.Title
@@ -1448,6 +1451,17 @@ func (qs *QuestionService) GetQuestionsByTitle(ctx context.Context, title string
 	return resp, nil
 }
 
+func canViewSimilarQuestion(question *entity.Question, userID string, per schema.QuestionPermission) bool {
+	if question == nil || question.Status == entity.QuestionStatusDeleted {
+		return false
+	}
+	return checkQuestionVisibility(&schema.QuestionInfoResp{
+		UserID: question.UserID,
+		Status: question.Status,
+		Show:   question.Show,
+	}, userID, per) == nil
+}
+
 // SimilarQuestion
 func (qs *QuestionService) SimilarQuestion(ctx context.Context, questionID string, loginUserID string) ([]*schema.QuestionPageResp, int64, error) {
 	question, err := qs.questioncommon.Info(ctx, questionID, loginUserID)
diff --git a/internal/service/content/question_service_visibility_test.go b/internal/service/content/question_service_visibility_test.go
index 9e10b47b..5cfeb5b7 100644
--- a/internal/service/content/question_service_visibility_test.go
+++ b/internal/service/content/question_service_visibility_test.go
@@ -20,12 +20,87 @@
 package content
 
 import (
+	"context"
 	"testing"
 
+	"github.com/apache/answer/internal/base/data"
 	"github.com/apache/answer/internal/entity"
 	"github.com/apache/answer/internal/schema"
 )
 
+func TestCanViewSimilarQuestionWithSQLite(t *testing.T) {
+	ctx := context.Background()
+	db, err := data.NewDB(false, &data.Database{Driver: "sqlite", Connection: ":memory:"})
+	if err != nil {
+		t.Fatalf("create SQLite database: %v", err)
+	}
+	t.Cleanup(func() { _ = db.Close() })
+	if err := db.Sync2(new(entity.Question)); err != nil {
+		t.Fatalf("create question table: %v", err)
+	}
+
+	questions := []*entity.Question{
+		{ID: "1", UserID: "author", Title: "public match", OriginalText: "public", ParsedText: "public", Status: entity.QuestionStatusAvailable, Show: entity.QuestionShow},
+		{ID: "2", UserID: "author", Title: "pending match", OriginalText: "pending", ParsedText: "pending", Status: entity.QuestionStatusPending, Show: entity.QuestionShow},
+		{ID: "3", UserID: "author", Title: "hidden match", OriginalText: "hidden", ParsedText: "hidden", Status: entity.QuestionStatusAvailable, Show: entity.QuestionHide},
+		{ID: "4", UserID: "author", Title: "deleted match", OriginalText: "deleted", ParsedText: "deleted", Status: entity.QuestionStatusDeleted, Show: entity.QuestionShow},
+		{ID: "5", UserID: "viewer", Title: "own pending match", OriginalText: "own pending", ParsedText: "own pending", Status: entity.QuestionStatusPending, Show: entity.QuestionShow},
+	}
+	for _, question := range questions {
+		if _, err := db.Context(ctx).Insert(question); err != nil {
+			t.Fatalf("insert question %s: %v", question.ID, err)
+		}
+	}
+
+	var candidates []*entity.Question
+	if err := db.Context(ctx).Where("title like ?", "%match%").Find(&candidates); err != nil {
+		t.Fatalf("load similar-question candidates: %v", err)
+	}
+	if len(candidates) != len(questions) {
+		t.Fatalf("loaded %d candidates, want %d", len(candidates), len(questions))
+	}
+
+	visibleIDs := make(map[string]bool)
+	for _, question := range candidates {
+		if canViewSimilarQuestion(question, "viewer", schema.QuestionPermission{}) {
+			visibleIDs[question.ID] = true
+		}
+	}
+	if len(visibleIDs) != 2 || !visibleIDs["1"] || !visibleIDs["5"] {
+		t.Fatalf("visible similar questions = %v, want [1 5]", visibleIDs)
+	}
+}
+
+func TestCanViewSimilarQuestion(t *testing.T) {
+	testCases := []struct {
+		name    string
+		status  int
+		show    int
+		author  string
+		viewer  string
+		per     schema.QuestionPermission
+		allowed bool
+	}{
+		{"public question", entity.QuestionStatusAvailable, entity.QuestionShow, "author", "viewer", schema.QuestionPermission{}, true},
+		{"other user's pending question", entity.QuestionStatusPending, entity.QuestionShow, "author", "viewer", schema.QuestionPermission{}, false},
+		{"author's pending question", entity.QuestionStatusPending, entity.QuestionShow, "author", "author", schema.QuestionPermission{}, true},
+		{"reviewer's pending question", entity.QuestionStatusPending, entity.QuestionShow, "author", "reviewer", schema.QuestionPermission{CanReopen: true}, true},
+		{"other user's hidden question", entity.QuestionStatusAvailable, entity.QuestionHide, "author", "viewer", schema.QuestionPermission{}, false},
+		{"author's hidden question", entity.QuestionStatusAvailable, entity.QuestionHide, "author", "author", schema.QuestionPermission{}, true},
+		{"moderator's hidden question", entity.QuestionStatusAvailable, entity.QuestionHide, "author", "moderator", schema.QuestionPermission{IsAdminModerator: true}, true},
+		{"deleted question", entity.QuestionStatusDeleted, entity.QuestionShow, "author", "author", schema.QuestionPermission{}, false},
+	}
+
+	for _, testCase := range testCases {
+		t.Run(testCase.name, func(t *testing.T) {
+			question := &entity.Question{UserID: testCase.author, Status: testCase.status, Show: testCase.show}
+			if got := canViewSimilarQuestion(question, testCase.viewer, testCase.per); got != testCase.allowed {
+				t.Fatalf("canViewSimilarQuestion() = %t, want %t", got, testCase.allowed)
+			}
+		})
+	}
+}
+
 func TestCheckQuestionVisibility(t *testing.T) {
 	testCases := []struct {
 		name   string