From 7c210a4855270ef0b91d8e5471479b04f24c65ae Mon Sep 17 00:00:00 2001 From: Luke Gao Date: Sat, 30 May 2026 15:33:54 +0800 Subject: [PATCH 01/49] feat: add reasoning content to AI conversation records and update related components (#1530) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix #1524 Root cause DeepSeek's reasoning models stream reasoning_content alongside content. Answer ignored it, so follow-up requests failed with 400: The reasoning_content in the thinking mode must be passed back to the API, and the thinking text was never shown or saved. Fix - Capture reasoning_content from the stream and pass it back to theAPI on subsequent rounds. - Persist it with the conversation (new DB column via migrationv2.0.2). - Render it in the chat UI as a collapsible "Thinking…/Thoughts"panel above the answer. Compatibility Nullable column, omitempty field, UI hides the panel when empty — old conversations and non-reasoning models behave exactly as before. Demo https://github.com/user-attachments/assets/49b1a2a1-9133-4ac2-bbeb-860215a50285 --- docs/docs.go | 3 + docs/swagger.json | 3 + docs/swagger.yaml | 2 + i18n/en_US.yaml | 2 + internal/controller/ai_controller.go | 74 +++++++++++++------ internal/entity/ai_conversation_record.go | 1 + internal/migrations/migrations.go | 1 + internal/migrations/v33.go | 39 ++++++++++ internal/schema/ai_conversation_schema.go | 1 + .../ai_conversation_service.go | 9 +++ ui/src/common/interface.ts | 1 + ui/src/components/BubbleAi/index.tsx | 38 ++++++++++ .../components/DetailModal/index.tsx | 1 + ui/src/pages/AiAssistant/index.tsx | 15 +++- .../pages/Search/components/AiCard/index.tsx | 15 +++- 15 files changed, 175 insertions(+), 30 deletions(-) create mode 100644 internal/migrations/v33.go diff --git a/docs/docs.go b/docs/docs.go index 57a23d432..03b06701b 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -8781,6 +8781,9 @@ const docTemplate = `{ "helpful": { "type": "integer" }, + "reasoning_content": { + "type": "string" + }, "role": { "type": "string" }, diff --git a/docs/swagger.json b/docs/swagger.json index dac2b38fd..3bfb2e3ce 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -8754,6 +8754,9 @@ "helpful": { "type": "integer" }, + "reasoning_content": { + "type": "string" + }, "role": { "type": "string" }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 7a7adb681..c08f1e8d4 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -228,6 +228,8 @@ definitions: type: integer helpful: type: integer + reasoning_content: + type: string role: type: string unhelpful: diff --git a/i18n/en_US.yaml b/i18n/en_US.yaml index 9a0d198b3..61f496500 100644 --- a/i18n/en_US.yaml +++ b/i18n/en_US.yaml @@ -867,6 +867,8 @@ ui: copy: Copy ask_a_follow_up: Ask a follow-up ask_placeholder: Ask a question + thinking: Thinking… + thoughts: Thoughts notifications: title: Notifications inbox: Inbox diff --git a/internal/controller/ai_controller.go b/internal/controller/ai_controller.go index 125cdab22..e7495253b 100644 --- a/internal/controller/ai_controller.go +++ b/internal/controller/ai_controller.go @@ -143,8 +143,9 @@ type StreamChoice struct { } type Delta struct { - Role string `json:"role,omitempty"` - Content string `json:"content,omitempty"` + Role string `json:"role,omitempty"` + Content string `json:"content,omitempty"` + ReasoningContent string `json:"reasoning_content,omitempty"` } type Usage struct { @@ -443,14 +444,15 @@ func (c *AIController) handleAIConversation(ctx *gin.Context, w http.ResponseWri Stream: true, } - toolCalls, newMessages, finished, aiResponse := c.processAIStream(ctx, w, id, conversationCtx.Model, client, aiReq, messages) + toolCalls, newMessages, finished, aiResponse, reasoningContent := c.processAIStream(ctx, w, id, conversationCtx.Model, client, aiReq, messages) messages = newMessages log.Debugf("Round %d: toolCalls=%v", round+1, toolCalls) - if aiResponse != "" { + if aiResponse != "" || reasoningContent != "" { conversationCtx.Messages = append(conversationCtx.Messages, &ai_conversation.ConversationMessage{ - Role: "assistant", - Content: aiResponse, + Role: "assistant", + Content: aiResponse, + ReasoningContent: reasoningContent, }) } @@ -459,7 +461,7 @@ func (c *AIController) handleAIConversation(ctx *gin.Context, w http.ResponseWri } if len(toolCalls) > 0 { - messages = c.executeToolCalls(ctx, w, id, conversationCtx.Model, toolCalls, messages) + messages = c.executeToolCalls(ctx, w, id, conversationCtx.Model, toolCalls, messages, aiResponse, reasoningContent) } else { return } @@ -471,12 +473,12 @@ func (c *AIController) handleAIConversation(ctx *gin.Context, w http.ResponseWri // processAIStream func (c *AIController) processAIStream( _ *gin.Context, w http.ResponseWriter, id, model string, client *openai.Client, aiReq openai.ChatCompletionRequest, messages []openai.ChatCompletionMessage) ( - []openai.ToolCall, []openai.ChatCompletionMessage, bool, string) { + []openai.ToolCall, []openai.ChatCompletionMessage, bool, string, string) { stream, err := client.CreateChatCompletionStream(context.Background(), aiReq) if err != nil { log.Errorf("Failed to create stream: %v", err) c.sendErrorResponse(w, id, model, "Failed to create AI stream") - return nil, messages, true, "" + return nil, messages, true, "", "" } defer func() { _ = stream.Close() @@ -484,6 +486,7 @@ func (c *AIController) processAIStream( var currentToolCalls []openai.ToolCall var accumulatedContent strings.Builder + var accumulatedReasoning strings.Builder var accumulatedMessage openai.ChatCompletionMessage toolCallsMap := make(map[int]*openai.ToolCall) @@ -528,6 +531,27 @@ func (c *AIController) processAIStream( } } + if choice.Delta.ReasoningContent != "" { + accumulatedReasoning.WriteString(choice.Delta.ReasoningContent) + + reasoningResponse := StreamResponse{ + ChatCompletionID: id, + Object: "chat.completion.chunk", + Created: time.Now().Unix(), + Model: model, + Choices: []StreamChoice{ + { + Index: 0, + Delta: Delta{ + ReasoningContent: choice.Delta.ReasoningContent, + }, + FinishReason: nil, + }, + }, + } + sendStreamData(w, reasoningResponse) + } + if choice.Delta.Content != "" { accumulatedContent.WriteString(choice.Delta.Content) @@ -554,26 +578,30 @@ func (c *AIController) processAIStream( for _, toolCall := range toolCallsMap { currentToolCalls = append(currentToolCalls, *toolCall) } - return currentToolCalls, messages, false, accumulatedContent.String() + return currentToolCalls, messages, false, accumulatedContent.String(), accumulatedReasoning.String() } else { aiResponseContent := accumulatedContent.String() - if aiResponseContent != "" { + aiReasoningContent := accumulatedReasoning.String() + if aiResponseContent != "" || aiReasoningContent != "" { accumulatedMessage = openai.ChatCompletionMessage{ - Role: openai.ChatMessageRoleAssistant, - Content: aiResponseContent, + Role: openai.ChatMessageRoleAssistant, + Content: aiResponseContent, + ReasoningContent: aiReasoningContent, } messages = append(messages, accumulatedMessage) } - return nil, messages, true, aiResponseContent + return nil, messages, true, aiResponseContent, aiReasoningContent } } } aiResponseContent := accumulatedContent.String() - if aiResponseContent != "" { + aiReasoningContent := accumulatedReasoning.String() + if aiResponseContent != "" || aiReasoningContent != "" { accumulatedMessage = openai.ChatCompletionMessage{ - Role: openai.ChatMessageRoleAssistant, - Content: aiResponseContent, + Role: openai.ChatMessageRoleAssistant, + Content: aiResponseContent, + ReasoningContent: aiReasoningContent, } messages = append(messages, accumulatedMessage) } @@ -582,14 +610,14 @@ func (c *AIController) processAIStream( for _, toolCall := range toolCallsMap { currentToolCalls = append(currentToolCalls, *toolCall) } - return currentToolCalls, messages, false, aiResponseContent + return currentToolCalls, messages, false, aiResponseContent, aiReasoningContent } - return currentToolCalls, messages, len(currentToolCalls) == 0, aiResponseContent + return currentToolCalls, messages, len(currentToolCalls) == 0, aiResponseContent, aiReasoningContent } // executeToolCalls -func (c *AIController) executeToolCalls(ctx *gin.Context, _ http.ResponseWriter, _, _ string, toolCalls []openai.ToolCall, messages []openai.ChatCompletionMessage) []openai.ChatCompletionMessage { +func (c *AIController) executeToolCalls(ctx *gin.Context, _ http.ResponseWriter, _, _ string, toolCalls []openai.ToolCall, messages []openai.ChatCompletionMessage, assistantContent, reasoningContent string) []openai.ChatCompletionMessage { validToolCalls := make([]openai.ToolCall, 0) for _, toolCall := range toolCalls { if toolCall.ID == "" || toolCall.Function.Name == "" { @@ -611,8 +639,10 @@ func (c *AIController) executeToolCalls(ctx *gin.Context, _ http.ResponseWriter, } assistantMsg := openai.ChatCompletionMessage{ - Role: openai.ChatMessageRoleAssistant, - ToolCalls: validToolCalls, + Role: openai.ChatMessageRoleAssistant, + Content: assistantContent, + ReasoningContent: reasoningContent, + ToolCalls: validToolCalls, } messages = append(messages, assistantMsg) diff --git a/internal/entity/ai_conversation_record.go b/internal/entity/ai_conversation_record.go index 14dea3470..da8f11b11 100644 --- a/internal/entity/ai_conversation_record.go +++ b/internal/entity/ai_conversation_record.go @@ -30,6 +30,7 @@ type AIConversationRecord struct { ChatCompletionID string `xorm:"not null VARCHAR(255) chat_completion_id"` Role string `xorm:"not null default '' VARCHAR(128) role"` Content string `xorm:"not null MEDIUMTEXT content"` + ReasoningContent string `xorm:"MEDIUMTEXT reasoning_content"` Helpful int `xorm:"not null default 0 INT(11) helpful"` Unhelpful int `xorm:"not null default 0 INT(11) unhelpful"` } diff --git a/internal/migrations/migrations.go b/internal/migrations/migrations.go index 682a7b207..7fca2d50d 100644 --- a/internal/migrations/migrations.go +++ b/internal/migrations/migrations.go @@ -108,6 +108,7 @@ var migrations = []Migration{ NewMigration("v1.8.0", "change admin menu", updateAdminMenuSettings, true), NewMigration("v1.8.1", "ai feat", aiFeat, true), NewMigration("v2.0.1", "change avatar type to text", updateAvatarType, false), + NewMigration("v2.0.2", "add reasoning content to ai conversation record", addAIConversationReasoningContent, false), } func GetMigrations() []Migration { diff --git a/internal/migrations/v33.go b/internal/migrations/v33.go new file mode 100644 index 000000000..810c21011 --- /dev/null +++ b/internal/migrations/v33.go @@ -0,0 +1,39 @@ +/* + * 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 migrations + +import ( + "context" + "fmt" + + "github.com/apache/answer/internal/entity" + "xorm.io/xorm" +) + +// addAIConversationReasoningContent adds a reasoning_content column to the +// ai_conversation_record table so that the chain-of-thought returned by +// reasoning/thinking-capable models (e.g. DeepSeek) is persisted along with +// the regular content and can be re-displayed when reloading a conversation. +func addAIConversationReasoningContent(ctx context.Context, x *xorm.Engine) error { + if err := x.Context(ctx).Sync(new(entity.AIConversationRecord)); err != nil { + return fmt.Errorf("sync ai_conversation_record table failed: %w", err) + } + return nil +} diff --git a/internal/schema/ai_conversation_schema.go b/internal/schema/ai_conversation_schema.go index fd34278a1..60ec5d747 100644 --- a/internal/schema/ai_conversation_schema.go +++ b/internal/schema/ai_conversation_schema.go @@ -48,6 +48,7 @@ type AIConversationRecord struct { ChatCompletionID string `json:"chat_completion_id"` Role string `json:"role"` Content string `json:"content"` + ReasoningContent string `json:"reasoning_content,omitempty"` Helpful int `json:"helpful"` Unhelpful int `json:"unhelpful"` CreatedAt int64 `json:"created_at"` diff --git a/internal/service/ai_conversation/ai_conversation_service.go b/internal/service/ai_conversation/ai_conversation_service.go index d095ac0e9..b7ddc6b10 100644 --- a/internal/service/ai_conversation/ai_conversation_service.go +++ b/internal/service/ai_conversation/ai_conversation_service.go @@ -51,6 +51,7 @@ type ConversationMessage struct { ChatCompletionID string `json:"chat_completion_id"` Role string `json:"role"` Content string `json:"content"` + ReasoningContent string `json:"reasoning_content,omitempty"` } // aiConversationService @@ -97,6 +98,7 @@ func (s *aiConversationService) SaveConversationRecords(ctx context.Context, con } content := strings.Builder{} + reasoning := strings.Builder{} for _, record := range records { if len(record.ChatCompletionID) > 0 { @@ -120,12 +122,17 @@ func (s *aiConversationService) SaveConversationRecords(ctx context.Context, con content.WriteString(record.Content) content.WriteString("\n") + if record.ReasoningContent != "" { + reasoning.WriteString(record.ReasoningContent) + reasoning.WriteString("\n") + } } aiRecord := &entity.AIConversationRecord{ ConversationID: conversationID, ChatCompletionID: chatcmplID, Role: "assistant", Content: content.String(), + ReasoningContent: reasoning.String(), Helpful: 0, Unhelpful: 0, } @@ -190,6 +197,7 @@ func (s *aiConversationService) GetConversationDetail(ctx context.Context, req * ChatCompletionID: record.ChatCompletionID, Role: record.Role, Content: record.Content, + ReasoningContent: record.ReasoningContent, Helpful: record.Helpful, Unhelpful: record.Unhelpful, CreatedAt: record.CreatedAt.Unix(), @@ -319,6 +327,7 @@ func (s *aiConversationService) GetConversationDetailForAdmin(ctx context.Contex ChatCompletionID: record.ChatCompletionID, Role: record.Role, Content: record.Content, + ReasoningContent: record.ReasoningContent, Helpful: record.Helpful, Unhelpful: record.Unhelpful, CreatedAt: record.CreatedAt.Unix(), diff --git a/ui/src/common/interface.ts b/ui/src/common/interface.ts index 308726e80..dbcec6c04 100644 --- a/ui/src/common/interface.ts +++ b/ui/src/common/interface.ts @@ -860,6 +860,7 @@ export interface AdminConversationListItem { export interface ConversationDetailItem { chat_completion_id: string; content: string; + reasoning_content?: string; role: string; helpful: number; unhelpful: number; diff --git a/ui/src/components/BubbleAi/index.tsx b/ui/src/components/BubbleAi/index.tsx index 1e79ca7c4..4cbd24179 100644 --- a/ui/src/components/BubbleAi/index.tsx +++ b/ui/src/components/BubbleAi/index.tsx @@ -32,6 +32,7 @@ interface IProps { isLast: boolean; isCompleted: boolean; content: string; + reasoningContent?: string; minHeight?: number; actionData: { helpful: number; @@ -55,6 +56,7 @@ const BubbleAi: FC = ({ isLast, isCompleted, content, + reasoningContent = '', chatId = '', actionData, minHeight = 0, @@ -65,6 +67,7 @@ const BubbleAi: FC = ({ const [isHelpful, setIsHelpful] = useState(false); const [isUnhelpful, setIsUnhelpful] = useState(false); const [canShowAction, setCanShowAction] = useState(false); + const [isThinkingOpen, setIsThinkingOpen] = useState(true); const [safeHtml, setSafeHtml] = useState(''); const typewriterRef = useRef<{ timer: NodeJS.Timeout | null; @@ -255,6 +258,14 @@ const BubbleAi: FC = ({ setIsUnhelpful(actionData.unhelpful > 0); }, [actionData]); + // Auto-collapse the "Thinking" panel once the actual answer starts streaming + // (only while the message is being generated; users can still toggle manually). + useEffect(() => { + if (content && !isCompleted) { + setIsThinkingOpen(false); + } + }, [content, isCompleted]); + useEffect(() => { if (fmtContainer.current && isCompleted && safeHtml) { htmlRender(fmtContainer.current, { @@ -275,6 +286,33 @@ const BubbleAi: FC = ({ ref={containerRef} style={{ minHeight: `${minHeight}px`, overflowAnchor: 'none' }}>
+ {reasoningContent ? ( +
+ + {isThinkingOpen && ( +
+ {reasoningContent} +
+ )} +
+ ) : null} +
= ({ visible, id, onClose }) => { isLast={false} isCompleted content={item.content} + reasoningContent={item.reasoning_content || ''} actionData={{ helpful: item.helpful, unhelpful: item.unhelpful, diff --git a/ui/src/pages/AiAssistant/index.tsx b/ui/src/pages/AiAssistant/index.tsx index 83ffe8f1e..e355e8041 100644 --- a/ui/src/pages/AiAssistant/index.tsx +++ b/ui/src/pages/AiAssistant/index.tsx @@ -154,7 +154,10 @@ const Index = () => { await requestAi('/answer/api/v1/chat/completions', { body: JSON.stringify(params), onMessage: (res) => { - if (!res.choices[0].delta?.content) { + const delta = res.choices[0]?.delta; + const deltaContent = delta?.content || ''; + const deltaReasoning = delta?.reasoning_content || ''; + if (!deltaContent && !deltaReasoning) { return; } setIsLoading(false); @@ -165,13 +168,16 @@ const Index = () => { if (lastConversion?.chat_completion_id === res?.chat_completion_id) { updatedRecords[updatedRecords.length - 1] = { ...lastConversion, - content: lastConversion.content + res.choices[0].delta.content, + content: (lastConversion.content || '') + deltaContent, + reasoning_content: + (lastConversion.reasoning_content || '') + deltaReasoning, }; } else { updatedRecords.push({ chat_completion_id: res.chat_completion_id, - role: res.choices[0].delta.role || 'assistant', - content: res.choices[0].delta.content, + role: delta?.role || 'assistant', + content: deltaContent, + reasoning_content: deltaReasoning, helpful: 0, unhelpful: 0, created_at: Date.now(), @@ -330,6 +336,7 @@ const Index = () => { isLast={isLastMessage} isCompleted={!isGenerate || !isLastMessage} content={item.content} + reasoningContent={item.reasoning_content || ''} actionData={{ helpful: item.helpful, unhelpful: item.unhelpful, diff --git a/ui/src/pages/Search/components/AiCard/index.tsx b/ui/src/pages/Search/components/AiCard/index.tsx index 99e21adc8..c6cc699ce 100644 --- a/ui/src/pages/Search/components/AiCard/index.tsx +++ b/ui/src/pages/Search/components/AiCard/index.tsx @@ -78,7 +78,10 @@ const Index = () => { await requestAi('/answer/api/v1/chat/completions', { body: JSON.stringify(params), onMessage: (res) => { - if (!res.choices[0].delta?.content) { + const delta = res.choices[0]?.delta; + const deltaContent = delta?.content || ''; + const deltaReasoning = delta?.reasoning_content || ''; + if (!deltaContent && !deltaReasoning) { return; } setIsLoading(false); @@ -90,13 +93,16 @@ const Index = () => { if (lastConversion?.chat_completion_id === res?.chat_completion_id) { updatedRecords[updatedRecords.length - 1] = { ...lastConversion, - content: lastConversion.content + res.choices[0].delta.content, + content: (lastConversion.content || '') + deltaContent, + reasoning_content: + (lastConversion.reasoning_content || '') + deltaReasoning, }; } else { updatedRecords.push({ chat_completion_id: res.chat_completion_id, - role: res.choices[0].delta.role || 'assistant', - content: res.choices[0].delta.content, + role: delta?.role || 'assistant', + content: deltaContent, + reasoning_content: deltaReasoning, helpful: 0, unhelpful: 0, created_at: Date.now(), @@ -154,6 +160,7 @@ const Index = () => { isLast={isLastMessage} isCompleted={!isGenerate || !isLastMessage} content={item.content} + reasoningContent={item.reasoning_content || ''} actionData={{ helpful: item.helpful, unhelpful: item.unhelpful, From 68085ab74277b399d3c6980a28d08eac1eef936f Mon Sep 17 00:00:00 2001 From: Luffy <52o@qq52o.cn> Date: Tue, 2 Jun 2026 11:50:54 +0800 Subject: [PATCH 02/49] fix: update license entries --- docs/release/LICENSE | 65 ++++++++++++++++++++++++-------------------- 1 file changed, 35 insertions(+), 30 deletions(-) diff --git a/docs/release/LICENSE b/docs/release/LICENSE index 926d64b79..58aea229d 100644 --- a/docs/release/LICENSE +++ b/docs/release/LICENSE @@ -214,11 +214,12 @@ Apache 2.0 licenses The following components are provided under the Apache 2.0 License. - (Apache License, Version 2.0) react-helmet-async (https://github.com/staylor/react-helmet-async) [link](./licenses/LICENSE-staylor-react-helmet-async.txt) - (Apache License, Version 2.0) golang-mock (https://github.com/golang/mock) [link](./licenses/LICENSE-golang-mock.txt) + (Apache License, Version 2.0) gomock (https://github.com/uber-go/mock) [link](./licenses/LICENSE-uber-go-mock.txt) (Apache License, Version 2.0) google-wire (https://github.com/google/wire) [link](./licenses/LICENSE-google-wire.txt) (Apache License, Version 2.0) mojocn-base64Captcha (https://github.com/mojocn/base64Captcha) [link](./licenses/LICENSE-mojocn-base64Captcha.txt) (Apache License, Version 2.0) ory-dockertest (https://github.com/ory/dockertest) [link](./licenses/LICENSE-ory-dockertest.txt) + (Apache License, Version 2.0) react-helmet-async (https://github.com/staylor/react-helmet-async) [link](./licenses/LICENSE-staylor-react-helmet-async.txt) + (Apache License, Version 2.0) sashabaranov-go-openai (https://github.com/sashabaranov/go-openai) [link](./licenses/LICENSE-sashabaranov-go-openai.txt) (Apache License, Version 2.0) spf13-cobra (https://github.com/spf13/cobra) [link](./licenses/LICENSE-spf13-cobra.txt) ======================================================================== @@ -227,57 +228,61 @@ MIT licenses The following components are provided under the MIT License. See project link for details. - (MIT License) axios (https://github.com/axios/axios) [link](./licenses/LICENSE-axios-axios.txt) - (MIT License) bootstrap (https://github.com/twbs/bootstrap) [link](./licenses/LICENSE-twbs-bootstrap.txt) - (MIT License) icons (https://github.com/twbs/icons) [link](./licenses/LICENSE-twbs-icons.txt) - (MIT License) classnames (https://github.com/JedWatson/classnames) [link](./LICENSE-JedWatson-classnames.txt) - (MIT License) codemirror (https://github.com/codemirror/basic-setup) [link](./licenses/LICENSE-codemirror-basic-setup.txt) (MIT License) @codemirror/lang-markdown (https://github.com/codemirror/lang-markdown) [link](./licenses/LICENSE-codemirror-lang-markdown.txt) (MIT License) @codemirror/language-data (https://github.com/codemirror/language-data) [link](./licenses/LICENSE-codemirror-language-data.txt) (MIT License) @codemirror/state (https://github.com/codemirror/state) [link](./licenses/LICENSE-codemirror-state.txt) (MIT License) @codemirror/view (https://github.com/codemirror/view) [link](./licenses/LICENSE-codemirror-view.txt) + (MIT License) anargu-gin-brotli (https://github.com/anargu/gin-brotli) [link](./licenses/LICENSE-anargu-gin-brotli.txt) + (MIT License) asaskevich-govalidator (https://github.com/asaskevich/govalidator) [link](./licenses/LICENSE-asaskevich-govalidator.txt) + (MIT License) axios (https://github.com/axios/axios) [link](./licenses/LICENSE-axios-axios.txt) + (MIT License) bootstrap (https://github.com/twbs/bootstrap) [link](./licenses/LICENSE-twbs-bootstrap.txt) + (MIT License) classnames (https://github.com/JedWatson/classnames) [link](./LICENSE-JedWatson-classnames.txt) + (MIT License) codemirror (https://github.com/codemirror/basic-setup) [link](./licenses/LICENSE-codemirror-basic-setup.txt) (MIT License) color (https://github.com/Qix-/color) [link](./licenses/LICENSE-Qix--color.txt) (MIT License) copy-to-clipboard (https://github.com/sudodoki/copy-to-clipboard) [link](./licenses/LICENSE-sudodoki-copy-to-clipboard.txt) (MIT License) dayjs (https://github.com/iamkun/dayjs) [link](./licenses/LICENSE-iamkun-dayjs.txt) - (MIT License) i18next (https://github.com/i18next/i18next) [link](./licenses/LICENSE-i18next-i18next.txt) - (MIT License) lodash (https://github.com/lodash/lodash) [link](./licenses/LICENSE-lodash-lodash.txt) - (MIT License) marked (https://github.com/markedjs/marked) [link](./licenses/LICENSE-markedjs-marked.txt) - (MIT License) next-share (https://github.com/Bunlong/next-share) [link](./licenses/LIcENSE-Bunlong-next-share.txt) - (MIT License) node-qrcode (https://github.com/soldair/node-qrcode) [link](./licenses/LICENSE-soldair-qrcode.txt) - (MIT License) react (https://github.com/facebook/react) [link](./licenses/LICENSE-facebook-react.txt) - (MIT License) react-bootstrap (https://github.com/react-bootstrap/react-bootstrap) [link](./licenses/LICENSE-react-bootstrap-react-bootstrap.txt) - (MIT License) react-i18next (https://github.com/i18next/react-i18next) [link](./licenses/LICENSE-i18next-react-i18next.txt) - (MIT License) react-router (https://github.com/remix-run/react-router) [link](./licenses/LICENSE-remix-run-react-router.txt) - (MIT License) swr (https://github.com/vercel/swr) [link](./licenses/LICENSE-vercel-swr.txt) - (MIT License) zustand (https://github.com/pmndrs/zustand) [link](./licenses/LICENSE-pmndrs-zustand.txt) - (MIT License) mozillazg-go-pinyin (https://github.com/mozillazg/go-pinyin) [link](./licenses/LICENSE-mozillazg-go-pinyin.txt) - (MIT License) Machiel-slugify (https://github.com/Machiel/slugify) [link](./licenses/LICENSE-Machiel-slugify.txt) - (MIT License) Masterminds-semver (https://github.com/Masterminds/semver) [link](./licenses/LICENSE-Masterminds-semver.txt) - (MIT License) anargu-gin-brotli (https://github.com/anargu/gin-brotli) [link](./licenses/LICENSE-anargu-gin-brotli.txt) - (MIT License) asaskevich-govalidator (https://github.com/asaskevich/govalidator) [link](./licenses/LICENSE-asaskevich-govalidator.txt) (MIT License) disintegration-imaging (https://github.com/disintegration/imaging) [link](./licenses/LICENSE-disintegration-imaging.txt) + (MIT License) front-matter (https://github.com/jxson/front-matter) [link](./licenses/LICENSE-jxson-front-matter.txt) (MIT License) gin-gonic-gin (https://github.com/gin-gonic/gin) [link](./licenses/LICENSE-gin-gonic-gin.txt) + (MIT License) go-gomail-gomail (https://gopkg.in/gomail.v2) [link](./licenses/LICENSE-go-gomail-gomail.txt) (MIT License) go-playground-locales (https://github.com/go-playground/locales) [link](./licenses/LICENSE-go-playground-locales.txt) (MIT License) go-playground-universal-translator (https://github.com/go-playground/universal-translator) [link](./licenses/LICENSE-go-playground-universal-translator.txt) (MIT License) go-playground-validator (https://github.com/go-playground/validator) [link](./licenses/LICENSE-go-playground-validator.txt) + (MIT License) go-resty-resty (https://github.com/go-resty/resty) [link](./licenses/LICENSE-go-resty-resty.txt) (MIT License) goccy-go-json (https://github.com/goccy/go-json) [link](./licenses/LICENSE-goccy-go-json.txt) + (MIT License) i18next (https://github.com/i18next/i18next) [link](./licenses/LICENSE-i18next-i18next.txt) + (MIT License) icons (https://github.com/twbs/icons) [link](./licenses/LICENSE-twbs-icons.txt) (MIT License) jinzhu-copier (https://github.com/jinzhu/copier) [link](./licenses/LICENSE-jinzhu-copier.txt) (MIT License) jinzhu-now (https://github.com/jinzhu/now) [link](./licenses/LICENSE-jinzhu-now.txt) + (MIT License) joho-godotenv (https://github.com/joho/godotenv) [link](./licenses/LICENSE-joho-godotenv.txt) (MIT License) jordan-wright-email (https://github.com/jordan-wright/email) [link](./licenses/LICENSE-jordan-wright-email.txt) + (MIT License) js-sha256 (https://github.com/emn178/js-sha256) [link](./licenses/LICENSE-emn178-js-sha256.txt) (MIT License) lib-pq (https://github.com/lib/pq) [link](./licenses/LICENSE-lib-pq.txt) + (MIT License) lodash (https://github.com/lodash/lodash) [link](./licenses/LICENSE-lodash-lodash.txt) + (MIT License) Machiel-slugify (https://github.com/Machiel/slugify) [link](./licenses/LICENSE-Machiel-slugify.txt) + (MIT License) mark3labs-mcp-go (https://github.com/mark3labs/mcp-go) [link](./licenses/LICENSE-mark3labs-mcp-go.txt) + (MIT License) marked (https://github.com/markedjs/marked) [link](./licenses/LICENSE-markedjs-marked.txt) + (MIT License) Masterminds-semver (https://github.com/Masterminds/semver) [link](./licenses/LICENSE-Masterminds-semver.txt) (MIT License) mattn-go-sqlite3 (https://github.com/mattn/go-sqlite3) [link](./licenses/LICENSE-mattn-go-sqlite3.txt) - (MIT License) segmentfault-pacman (https://github.com/segmentfault/pacman) [link](./licenses/LICENSE-segmentfault-pacman.txt) + (MIT License) mozillazg-go-pinyin (https://github.com/mozillazg/go-pinyin) [link](./licenses/LICENSE-mozillazg-go-pinyin.txt) + (MIT License) next-share (https://github.com/Bunlong/next-share) [link](./licenses/LIcENSE-Bunlong-next-share.txt) + (MIT License) node-qrcode (https://github.com/soldair/node-qrcode) [link](./licenses/LICENSE-soldair-qrcode.txt) + (MIT License) react (https://github.com/facebook/react) [link](./licenses/LICENSE-facebook-react.txt) + (MIT License) react-bootstrap (https://github.com/react-bootstrap/react-bootstrap) [link](./licenses/LICENSE-react-bootstrap-react-bootstrap.txt) + (MIT License) react-i18next (https://github.com/i18next/react-i18next) [link](./licenses/LICENSE-i18next-react-i18next.txt) + (MIT License) react-router (https://github.com/remix-run/react-router) [link](./licenses/LICENSE-remix-run-react-router.txt) (MIT License) robfig-cron (https://github.com/robfig/cron) [link](./licenses/LICENSE-robfig-cron.txt) (MIT License) scottleedavis-go-exif-remove (https://github.com/scottleedavis/go-exif-remove) [link](./licenses/LICENSE-scottleedavis-go-exif-remove.txt) + (MIT License) segmentfault-pacman (https://github.com/segmentfault/pacman) [link](./licenses/LICENSE-segmentfault-pacman.txt) (MIT License) stretchr-testify (https://github.com/stretchr/testify) [link](./licenses/LICENSE-stretchr-testify.txt) (MIT License) swaggo-files (https://github.com/swaggo/files) [link](./licenses/LICENSE-swaggo-files.txt) (MIT License) swaggo-gin-swagger (https://github.com/swaggo/gin-swagger) [link](./licenses/LICENSE-swaggo-gin-swagger.txt) (MIT License) swaggo-swag (https://github.com/swaggo/swag) [link](./licenses/LICENSE-swaggo-swag.txt) + (MIT License) swr (https://github.com/vercel/swr) [link](./licenses/LICENSE-vercel-swr.txt) (MIT License) tidwall-gjson (https://github.com/tidwall/gjson) [link](./licenses/LICENSE-tidwall-gjson.txt) + (MIT License) uuidjs-uuid (https://github.com/uuidjs/uuid) [link](./licenses/LICENSE-uuidjs-uuid.txt) (MIT License) yuin-goldmark (https://github.com/yuin/goldmark) [link](./licenses/LICENSE-yuin-goldmark.txt) - (MIT License) go-gomail-gomail (https://gopkg.in/gomail.v2) [link](./licenses/LICENSE-go-gomail-gomail.txt) - (MIT License) front-matter (https://github.com/jxson/front-matter) [link](./licenses/LICENSE-jxson-front-matter.txt) - (MIT License) js-sha256 (https://github.com/emn178/js-sha256) [link](./licenses/LICENSE-emn178-js-sha256.txt) + (MIT License) zustand (https://github.com/pmndrs/zustand) [link](./licenses/LICENSE-pmndrs-zustand.txt) ======================================================================== BSD licenses @@ -286,13 +291,13 @@ BSD licenses The following components are provided under a BSD license. See project link for details. (BSD 2-Clause) bwmarrin-snowflake (https://github.com/bwmarrin/snowflake) [link](./licenses/LICENSE-bwmarrin-snowflake.txt) - (BSD 2-Clause) xorm (https://xorm.io/xorm) [link](./licenses/LICENSE-xorm.txt) + (BSD 3-Clause) cznic-sqlite (https://modernc.org/sqlite) [link](./licenses/LICENSE-cznic-sqlite.txt) (BSD 3-Clause) google-uuid (https://github.com/google/uuid) [link](./licenses/LICENSE-google-uuid.txt) (BSD 3-Clause) grokify-html-strip-tags-go (https://github.com/grokify/html-strip-tags-go) [link](./licenses/LICENSE-grokify-html-strip-tags-go.txt) - (BSD 3-Clause) microcosm-cc-bluemonday (https://github.com/microcosm-cc/bluemonday) [link](./licenses/LICENSE-microcosm-cc-bluemonday.txt) - (BSD 3-Clause) cznic-sqlite (https://modernc.org/sqlite) [link](./licenses/LICENSE-cznic-sqlite.txt) (BSD 3-Clause) jsdiff (https://github.com/kpdecker/jsdiff) [link](./licenses/LICENSE-kpdecker-jsdiff.txt) + (BSD 3-Clause) microcosm-cc-bluemonday (https://github.com/microcosm-cc/bluemonday) [link](./licenses/LICENSE-microcosm-cc-bluemonday.txt) (BSD 3-Clause) qs (https://github.com/ljharb/qs) [link](./licenses/LICENSE-ljharb-qs.txt) + (BSD 2-Clause) xorm (https://xorm.io/xorm) [link](./licenses/LICENSE-xorm.txt) ======================================================================== ISC licenses From e884bb61cb1b4cd43469bdd4688a948009fedb3d Mon Sep 17 00:00:00 2001 From: Ahmed Qasid Date: Wed, 3 Jun 2026 17:06:26 +0300 Subject: [PATCH 03/49] fix: avoid topic fallback for non-Latin titles via pragmatic ASCII transliteration (#1526) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # fix: avoid `topic` fallback for non-Latin titles via pragmatic ASCII transliteration > **Scope update (in response to review):** this PR is intentionally broader than its original "Arabic-only" framing. The implementation changes URL slug generation for **every non-Latin, non-CJK script** that `slugify` previously stripped — see *Scope* below for the explicit list. The goal is *not* linguistically correct romanization; it is "avoid collapsing to `/topic` by producing a usable ASCII slug." ## What this PR is (and isn't) **Goal:** when a question title contains characters outside Basic Latin / Latin Extended / CJK Han, generate a URL slug that is a deterministic ASCII approximation instead of letting `slugify` strip everything and falling back to the literal `"topic"`. **Non-goal:** this is *not* a linguistically correct multi-language romanizer. The output is a machine-acceptable ASCII slug, not what a native speaker would choose. For example, `こんにちは` → `konnichiha` (not the more natural `kon'nichiwa`), `ไทย` → `aithy` (not `thai`). Treat the slug as an opaque, stable, indexable identifier — the path-after-`/questions//` is for SEO and shareability, the canonical reference is always the ID. ## The bug Pure non-Latin titles previously got stripped by `slugify.Slugify`, hit the empty-result fallback in `htmltext.UrlTitle`, and collapsed to the literal slug `"topic"`. On a live multilingual site, every Arabic / Thai / Japanese-hiragana / Korean / Hebrew / Cyrillic question ended up at `/questions//topic`. ## The fix `UrlTitle()` gets a `convertNonLatin` pre-step that mirrors the existing `convertChinese` pre-step pattern, using `github.com/mozillazg/go-unidecode` (same author as `go-pinyin` already in the repo, to minimise new-dep friction). ``` UrlTitle(title) → convertChinese(title) // pre-existing: Han-block → pinyin → convertNonLatin(title) // NEW: detect non-Latin letters → unidecode to ASCII → clearEmoji / slugify / url.QueryEscape / cutLongTitle (unchanged) ``` The non-Latin detector skips ASCII, Latin-1 Supplement, Latin Extended-A/B, and CJK Han. Inputs that hit none of those non-Latin letter categories short-circuit and return unchanged, so Latin-only and Chinese-only inputs remain byte-identical (pinned by tests). ## Scope — what scripts are affected This PR changes behavior for **any** title containing letters in scripts that `slugify` doesn't handle. Confirmed by tests in `pkg/htmltext/htmltext_test.go`: | Script | Example title | Before | After | | --- | --- | --- | --- | | Arabic | `كيف حالك` | `topic` | `kyf-hlk` | | Mixed Latin + Arabic | `مرحبا hello` | `hello` | `mrhb-hello` | | Thai | `ไทย ไทย` | `topic` | `aithy-aithy` | | Japanese hiragana | `こんにちは` | `topic` | `konnichiha` | | Korean | `안녕하세요` | `topic` | `annyeonghaseyo` | | Hebrew | `שלום עולם` | `topic` | `shlvm-vlm` | | Cyrillic | `Привет мир` | `topic` | `privet-mir` | **Unchanged:** | Case | Behavior | | --- | --- | | Pure Latin (`hello world`) | unchanged → `hello-world` | | Pure Chinese (`这是一个,标题,title`) | unchanged → `zhe-shi-yi-ge-biao-ti` (pinyin path) | | Japanese with Han-block kanji (`日本`) | unchanged → `ri-ben` (caught by pre-existing pinyin path; treated as Chinese reading, not Japanese — a pre-existing limitation, **not** introduced by this PR) | | Emoji only (`😂😂😂`) | unchanged → `topic` | | Empty / whitespace | unchanged → `topic` | ## Transliteration quality — explicit acknowledgement `go-unidecode` is a generic Unicode → ASCII approximation. It is **not** a per-language romanization library. Specifically: - It will pick *one* approximation per codepoint regardless of language context. `ใ` → `ai` (Thai romanization is `i` or `ai` depending on standard), `한` → `han`, `語` → `Yu` (Chinese pinyin reading even when used in Japanese), etc. - The result is *good enough* to be a stable, URL-safe, human-recognizable handle, but speakers of the source language will not consider it "correct." - It is deterministic, so the same title always produces the same slug — important since `url_title` is recomputed on every request. If maintainers prefer to scope this PR more narrowly (e.g. Arabic only, and reject Thai/Hebrew/Cyrillic/etc.), the detector in `containsNonLatin` can be tightened to specific Unicode blocks — but that means the other scripts continue to collapse to `topic`, which is the bug we're trying to fix. I'd argue the broader fix is preferable to a piecemeal one, but happy to narrow if you want. ## Live deployment / real-world verification This patch has been running in production on **[ask.namasoft.com](https://ask.namasoft.com)** (an Apache Answer instance we operate) since deployment, built directly from this branch via `docker compose build`. The site hosts Arabic-language questions, so the fix exercises the affected code path on every page load. Sample question URL on the deployed instance: > `https://ask.namasoft.com/questions/10010000000000115` The slug in the URL is the transliterated Arabic title rather than `topic`. No data migration was needed since `url_title` is computed on every request from `Title` and never persisted (see *Why this is safe to ship* below). ## Admin-configurable The transliteration is gated by a package-level `atomic.Bool` (default **on**, since the current behavior is objectively broken for affected users): - `htmltext.SetTransliterateNonLatin(enabled bool)` - `htmltext.IsTransliterateNonLatinEnabled() bool` This is deliberately the minimum surface needed to satisfy "the setting must be readable from `UrlTitle()`". A follow-up PR can add an admin UI section that calls `SetTransliterateNonLatin` on save and on startup, without having to re-plumb every `htmltext.UrlTitle` call site through `context.Context`. **Default choice — please confirm:** I picked **default-on** because the existing `topic` behavior is a bug for affected users. If you'd prefer default-off for strict backward compat on existing installs, flip the `init()` in `pkg/htmltext/htmltext.go` to `Store(false)` and surface the toggle as opt-in. ## Why this is safe to ship - `url_title` is **not** a persisted column. It's not on the `Question` entity in `internal/entity/question_entity.go`, no migration has ever added/dropped it, and every call site (`question_service.go`, `revision_service.go`, `vote_service.go`, search/report/review/rank/comment services, controllers, repos) recomputes it from `Title` at response-build time via `htmltext.UrlTitle(...)`. - That means the fix is read-only: existing rows light up with correct slugs on the next request, with no migration and no data rewrite. - Rollback is just redeploying the prior image; nothing on disk changes. ## Test coverage `pkg/htmltext/htmltext_test.go`: - **`TestUrlTitleTable`** — table-driven, one case per affected script (the full matrix above), plus: - `empty` → `topic` - `pure latin unchanged` → byte-identical to pre-fix - `pure chinese unchanged` → byte-identical to pre-fix (pins existing pinyin behavior) - `japanese kanji goes through pinyin path unchanged` → documents the pre-existing Han-block limitation - `emoji only falls back to topic` → unchanged - `long arabic truncates at cutLongTitle boundary` → exercises the 150-byte cap and UTF-8 boundary safety - **`TestUrlTitleTransliterationToggle`** — with the toggle off, non-Latin titles collapse to `topic` (pre-fix behavior); with it on, they transliterate. - Existing `TestUrlTitle` left untouched. Test plan for reviewers: - [ ] `go test ./pkg/htmltext/...` — all pass - [ ] Visit the live sample URL above and confirm slug is transliterated, not `topic` - [ ] Verify Chinese / Latin / emoji-only / empty behavior is byte-identical to `main` (covered by table tests) ## Out of scope (intentionally) - No admin UI / site setting plumbing in this PR — see *Admin-configurable* above. Happy to do the React `Non-Latin Languages Handling` admin page + `SiteType` + service / controller / migration in a follow-up if maintainers want it. - No change to the `"topic"` empty-result fallback. - No plugin interface for slug generation — mirrored the existing `convertChinese` pre-step pattern instead. - No per-language romanization library — this is an explicit non-goal; see *Transliteration quality* above. ## Issues / discussion I didn't find an existing upstream issue covering this — happy to be pointed at one if there is. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: LinkinStars --- .../LICENSE-mozillazg-go-unidecode.txt | 21 ++++ go.mod | 1 + go.sum | 2 + pkg/htmltext/htmltext.go | 49 ++++++++ pkg/htmltext/htmltext_test.go | 105 ++++++++++++++++++ 5 files changed, 178 insertions(+) create mode 100644 docs/release/licenses/LICENSE-mozillazg-go-unidecode.txt diff --git a/docs/release/licenses/LICENSE-mozillazg-go-unidecode.txt b/docs/release/licenses/LICENSE-mozillazg-go-unidecode.txt new file mode 100644 index 000000000..8a7780fcc --- /dev/null +++ b/docs/release/licenses/LICENSE-mozillazg-go-unidecode.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 mozillazg + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/go.mod b/go.mod index 89fd71460..11c3a8168 100644 --- a/go.mod +++ b/go.mod @@ -43,6 +43,7 @@ require ( github.com/mark3labs/mcp-go v0.43.2 github.com/microcosm-cc/bluemonday v1.0.27 github.com/mozillazg/go-pinyin v0.20.0 + github.com/mozillazg/go-unidecode v0.2.0 github.com/ory/dockertest/v3 v3.11.0 github.com/robfig/cron/v3 v3.0.1 github.com/sashabaranov/go-openai v1.41.2 diff --git a/go.sum b/go.sum index bf30d85b3..be61e11f6 100644 --- a/go.sum +++ b/go.sum @@ -462,6 +462,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/mozillazg/go-pinyin v0.20.0 h1:BtR3DsxpApHfKReaPO1fCqF4pThRwH9uwvXzm+GnMFQ= github.com/mozillazg/go-pinyin v0.20.0/go.mod h1:iR4EnMMRXkfpFVV5FMi4FNB6wGq9NV6uDWbUuPhP4Yc= +github.com/mozillazg/go-unidecode v0.2.0 h1:vFGEzAH9KSwyWmXCOblazEWDh7fOkpmy/Z4ArmamSUc= +github.com/mozillazg/go-unidecode v0.2.0/go.mod h1:zB48+/Z5toiRolOZy9ksLryJ976VIwmDmpQ2quyt1aA= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/nats-io/jwt v0.3.0/go.mod h1:fRYCDE99xlTsqUzISS1Bi75UBJ6ljOJQOAAu5VglpSg= github.com/nats-io/jwt v0.3.2/go.mod h1:/euKqTS1ZD+zzjYrY7pseZrTtWQSjujC7xjPc8wL6eU= diff --git a/pkg/htmltext/htmltext.go b/pkg/htmltext/htmltext.go index e2e017c8d..929080838 100644 --- a/pkg/htmltext/htmltext.go +++ b/pkg/htmltext/htmltext.go @@ -25,6 +25,8 @@ import ( "net/url" "regexp" "strings" + "sync/atomic" + "unicode" "unicode/utf8" "github.com/Machiel/slugify" @@ -32,6 +34,7 @@ import ( "github.com/apache/answer/pkg/converter" strip "github.com/grokify/html-strip-tags-go" "github.com/mozillazg/go-pinyin" + "github.com/mozillazg/go-unidecode" ) var ( @@ -47,8 +50,27 @@ var ( "\r", " ", "\t", " ", ) + + // Without this, pure non-Latin titles (Arabic, Cyrillic, Hebrew, ...) get + // stripped by slugify and collapse to the "topic" fallback. Chinese is + // handled separately by convertChinese. + transliterateNonLatin atomic.Bool ) +func init() { + transliterateNonLatin.Store(true) +} + +// SetTransliterateNonLatin toggles non-Latin script transliteration for URL slugs. +func SetTransliterateNonLatin(enabled bool) { + transliterateNonLatin.Store(enabled) +} + +// IsTransliterateNonLatinEnabled reports whether non-Latin transliteration is on. +func IsTransliterateNonLatinEnabled() bool { + return transliterateNonLatin.Load() +} + // ClearText clear HTML, get the clear text func ClearText(html string) string { if html == "" { @@ -66,6 +88,9 @@ func ClearText(html string) string { func UrlTitle(title string) (text string) { title = convertChinese(title) + if transliterateNonLatin.Load() { + title = convertNonLatin(title) + } title = clearEmoji(title) title = slugify.Slugify(title) title = url.QueryEscape(title) @@ -95,6 +120,30 @@ func convertChinese(content string) string { return strings.Join(pinyin.LazyConvert(content, nil), "-") } +// Short-circuits on Latin-only / Chinese-only input so existing slugs stay byte-identical. +func convertNonLatin(content string) string { + if !containsNonLatin(content) { + return content + } + return unidecode.Unidecode(content) +} + +func containsNonLatin(content string) bool { + for _, r := range content { + switch { + case r < 0x0080: // ASCII + continue + case r >= 0x0080 && r <= 0x024F: // Latin-1 Supplement, Latin Extended-A/B + continue + case unicode.Is(unicode.Han, r): // handled by convertChinese + continue + case unicode.IsLetter(r): + return true + } + } + return false +} + func cutLongTitle(title string) string { maxBytes := 150 if len(title) <= maxBytes { diff --git a/pkg/htmltext/htmltext_test.go b/pkg/htmltext/htmltext_test.go index 39de9e960..bcedcb3c8 100644 --- a/pkg/htmltext/htmltext_test.go +++ b/pkg/htmltext/htmltext_test.go @@ -87,6 +87,111 @@ func TestUrlTitle(t *testing.T) { } } +func TestUrlTitleTable(t *testing.T) { + // Long pure-Arabic title: 50 copies of the same Arabic word, joined by spaces. + // Unidecode of "كيف" is "kyf", so the slug becomes "kyf-" repeated and + // exceeds cutLongTitle's 150-byte cap. + longArabic := strings.Repeat("كيف ", 50) + wantLongArabic := strings.Repeat("kyf-", 37) + "ky" // 37*4 + 2 = 150 bytes + + cases := []struct { + name string + title string + want string + }{ + { + name: "empty", + title: "", + want: "topic", + }, + { + name: "pure latin unchanged", + title: "hello world", + want: "hello-world", + }, + { + // Pinyin conversion drops Latin runes by design — matches pre-fix behavior. + name: "pure chinese unchanged", + title: "这是一个,标题,title", + want: "zhe-shi-yi-ge-biao-ti", + }, + { + // The fix: previously collapsed to "topic" for all of these scripts. + // Outputs are an ASCII approximation, not linguistically correct + // romanization — see PR description. + name: "arabic transliterated", + title: "كيف حالك", + want: "kyf-hlk", + }, + { + name: "mixed latin and arabic", + title: "مرحبا hello", + want: "mrhb-hello", + }, + { + name: "thai transliterated", + title: "ไทย ไทย", + want: "aithy-aithy", + }, + { + name: "japanese hiragana transliterated", + title: "こんにちは", + want: "konnichiha", + }, + { + // Japanese with Han-block kanji is caught by the pre-existing pinyin + // pre-step (Chinese reading, not Japanese), so this path is unchanged + // by this PR. Pinning to document the existing behavior. + name: "japanese kanji goes through pinyin path unchanged", + title: "日本", + want: "ri-ben", + }, + { + name: "korean transliterated", + title: "안녕하세요", + want: "annyeonghaseyo", + }, + { + name: "hebrew transliterated", + title: "שלום עולם", + want: "shlvm-vlm", + }, + { + name: "cyrillic transliterated", + title: "Привет мир", + want: "privet-mir", + }, + { + name: "emoji only falls back to topic", + title: "😂😂😂", + want: "topic", + }, + { + name: "long arabic truncates at cutLongTitle boundary", + title: longArabic, + want: wantLongArabic, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := UrlTitle(tc.title) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestUrlTitleTransliterationToggle(t *testing.T) { + defer SetTransliterateNonLatin(true) + + SetTransliterateNonLatin(false) + // With transliteration off, pure-Arabic titles collapse to the existing + // "topic" fallback (the pre-fix behavior). + assert.Equal(t, "topic", UrlTitle("كيف حالك")) + + SetTransliterateNonLatin(true) + assert.Equal(t, "kyf-hlk", UrlTitle("كيف حالك")) +} + func TestFindFirstMatchedWord(t *testing.T) { var ( expectedWord, From cece87f9dc6dc61eedc4157c514b14d4788cbc51 Mon Sep 17 00:00:00 2001 From: hhc7 <169754973+hhc7@users.noreply.github.com> Date: Thu, 28 May 2026 18:04:27 +0800 Subject: [PATCH 04/49] feat: add recovery middleware to handle panic gracefully --- internal/base/middleware/recovery.go | 44 ++++++++++++++ internal/base/middleware/recovery_test.go | 71 +++++++++++++++++++++++ internal/base/server/http.go | 1 + 3 files changed, 116 insertions(+) create mode 100644 internal/base/middleware/recovery.go create mode 100644 internal/base/middleware/recovery_test.go diff --git a/internal/base/middleware/recovery.go b/internal/base/middleware/recovery.go new file mode 100644 index 000000000..7e844f78e --- /dev/null +++ b/internal/base/middleware/recovery.go @@ -0,0 +1,44 @@ +/* + * 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 ( + "net/http" + "runtime/debug" + + "github.com/apache/answer/internal/base/handler" + "github.com/apache/answer/internal/base/reason" + "github.com/gin-gonic/gin" + "github.com/segmentfault/pacman/log" +) + +func Recovery() gin.HandlerFunc { + return func(ctx *gin.Context) { + defer func() { + if err := recover(); err != nil { + log.Errorf("panic recovered: %v\n%s", err, debug.Stack()) + ctx.AbortWithStatusJSON(http.StatusInternalServerError, + handler.NewRespBody(http.StatusInternalServerError, reason.UnknownError).TrMsg(handler.GetLangByCtx(ctx)), + ) + } + }() + ctx.Next() + } +} diff --git a/internal/base/middleware/recovery_test.go b/internal/base/middleware/recovery_test.go new file mode 100644 index 000000000..c01719fce --- /dev/null +++ b/internal/base/middleware/recovery_test.go @@ -0,0 +1,71 @@ +/* + * 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 ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestRecovery_Panic(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(Recovery()) + r.GET("/panic", func(ctx *gin.Context) { + panic("test panic") + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/panic", nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500, got %d", w.Code) + } + + var body map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("response is not valid JSON: %v", err) + } + if body["reason"] != "base.unknown" { + t.Errorf("unexpected reason: %v", body["reason"]) + } +} + +func TestRecovery_NoPanic(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(Recovery()) + r.GET("/ok", func(ctx *gin.Context) { + ctx.String(http.StatusOK, "ok") + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/ok", nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } +} diff --git a/internal/base/server/http.go b/internal/base/server/http.go index 765cbf6be..1e8204d36 100644 --- a/internal/base/server/http.go +++ b/internal/base/server/http.go @@ -52,6 +52,7 @@ func NewHTTPServer(debug bool, gin.SetMode(gin.ReleaseMode) } r := gin.New() + r.Use(middleware.Recovery()) r.Use(func(ctx *gin.Context) { if strings.Contains(ctx.Request.URL.Path, "/chat/completions") { return From 682811f769f4db6d24a0d1749901d95c2d93a7eb Mon Sep 17 00:00:00 2001 From: hhc7 <169754973+hhc7@users.noreply.github.com> Date: Tue, 2 Jun 2026 13:55:35 +0800 Subject: [PATCH 05/49] fix: scope JSON 500 to API routes, skip rewriting already-flushed responses --- internal/base/middleware/recovery.go | 26 ++++++++-- internal/base/middleware/recovery_test.go | 63 ++++++++++++++++++++--- internal/base/server/http.go | 5 +- 3 files changed, 82 insertions(+), 12 deletions(-) diff --git a/internal/base/middleware/recovery.go b/internal/base/middleware/recovery.go index 7e844f78e..02b1cbde7 100644 --- a/internal/base/middleware/recovery.go +++ b/internal/base/middleware/recovery.go @@ -22,6 +22,7 @@ package middleware import ( "net/http" "runtime/debug" + "strings" "github.com/apache/answer/internal/base/handler" "github.com/apache/answer/internal/base/reason" @@ -29,14 +30,31 @@ import ( "github.com/segmentfault/pacman/log" ) -func Recovery() gin.HandlerFunc { +func Recovery(apiPrefixes ...string) gin.HandlerFunc { return func(ctx *gin.Context) { defer func() { if err := recover(); err != nil { log.Errorf("panic recovered: %v\n%s", err, debug.Stack()) - ctx.AbortWithStatusJSON(http.StatusInternalServerError, - handler.NewRespBody(http.StatusInternalServerError, reason.UnknownError).TrMsg(handler.GetLangByCtx(ctx)), - ) + + // Headers/body already flushed (SSE or any streamed response). + // We can no longer rewrite the response cleanly; just stop the chain. + if ctx.Writer.Written() { + ctx.Abort() + return + } + + path := ctx.Request.URL.Path + for _, p := range apiPrefixes { + if strings.HasPrefix(path, p) { + ctx.AbortWithStatusJSON(http.StatusInternalServerError, + handler.NewRespBody(http.StatusInternalServerError, reason.UnknownError). + TrMsg(handler.GetLangByCtx(ctx)), + ) + return + } + } + + ctx.AbortWithStatus(http.StatusInternalServerError) } }() ctx.Next() diff --git a/internal/base/middleware/recovery_test.go b/internal/base/middleware/recovery_test.go index c01719fce..58dbfdb0b 100644 --- a/internal/base/middleware/recovery_test.go +++ b/internal/base/middleware/recovery_test.go @@ -28,16 +28,17 @@ import ( "github.com/gin-gonic/gin" ) -func TestRecovery_Panic(t *testing.T) { +// Panic on an API path returns the project's unified JSON 500. +func TestRecovery_APIPathPanic(t *testing.T) { gin.SetMode(gin.TestMode) r := gin.New() - r.Use(Recovery()) - r.GET("/panic", func(ctx *gin.Context) { + r.Use(Recovery("/api")) + r.GET("/api/panic", func(ctx *gin.Context) { panic("test panic") }) w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, "/panic", nil) + req, _ := http.NewRequest(http.MethodGet, "/api/panic", nil) r.ServeHTTP(w, req) if w.Code != http.StatusInternalServerError { @@ -53,16 +54,64 @@ func TestRecovery_Panic(t *testing.T) { } } +// Panic on a non-API path returns a bare 500 with no body, so the browser can +// render its own error page instead of showing raw JSON. +func TestRecovery_NonAPIPathPanic(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(Recovery("/api")) + r.GET("/page", func(ctx *gin.Context) { + panic("test panic") + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/page", nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500, got %d", w.Code) + } + if w.Body.Len() != 0 { + t.Errorf("expected empty body for non-API path, got: %q", w.Body.String()) + } +} + +// Panic after the response has already started writing (SSE / streamed +// responses). The middleware must not touch the response — status and body +// already on the wire stay untouched, no JSON gets appended. +func TestRecovery_PanicAfterResponseStarted(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(Recovery("/api")) + r.GET("/api/stream", func(ctx *gin.Context) { + ctx.Writer.WriteHeader(http.StatusOK) + _, _ = ctx.Writer.Write([]byte("partial data")) + panic("test panic after write") + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/api/stream", nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status to remain 200 (already flushed), got %d", w.Code) + } + if w.Body.String() != "partial data" { + t.Errorf("expected body to remain 'partial data' (no error JSON appended), got: %q", w.Body.String()) + } +} + +// Normal requests pass through unaffected. func TestRecovery_NoPanic(t *testing.T) { gin.SetMode(gin.TestMode) r := gin.New() - r.Use(Recovery()) - r.GET("/ok", func(ctx *gin.Context) { + r.Use(Recovery("/api")) + r.GET("/api/ok", func(ctx *gin.Context) { ctx.String(http.StatusOK, "ok") }) w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, "/ok", nil) + req, _ := http.NewRequest(http.MethodGet, "/api/ok", nil) r.ServeHTTP(w, req) if w.Code != http.StatusOK { diff --git a/internal/base/server/http.go b/internal/base/server/http.go index 1e8204d36..8db557440 100644 --- a/internal/base/server/http.go +++ b/internal/base/server/http.go @@ -52,7 +52,10 @@ func NewHTTPServer(debug bool, gin.SetMode(gin.ReleaseMode) } r := gin.New() - r.Use(middleware.Recovery()) + r.Use(middleware.Recovery( + uiConf.APIBaseURL+"/answer/api/v1", + uiConf.APIBaseURL+"/answer/admin/api", + )) r.Use(func(ctx *gin.Context) { if strings.Contains(ctx.Request.URL.Path, "/chat/completions") { return From 43a91313d8b323b5f79a0e5e69b69a244a2468f3 Mon Sep 17 00:00:00 2001 From: hgaol Date: Tue, 9 Jun 2026 15:26:11 +0000 Subject: [PATCH 06/49] fix: accept answer fails when short links enabled (#1541) The ownership check added to AcceptAnswer compared the answer's QuestionID against the request's QuestionID directly. When short links are enabled, answerRepo.GetByID re-encodes QuestionID to its short form while the controller de-shorts req.QuestionID to its long form, so the two encodings of the same question never matched and every accept returned "Answer do not found". Normalize both ids via uid.DeShortID before comparing, preserving the privilege-escalation guard for answers that truly belong to another question. --- internal/service/content/answer_service.go | 8 +- .../service/content/answer_service_test.go | 81 +++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 internal/service/content/answer_service_test.go diff --git a/internal/service/content/answer_service.go b/internal/service/content/answer_service.go index d7506d6a8..25b0050ea 100644 --- a/internal/service/content/answer_service.go +++ b/internal/service/content/answer_service.go @@ -471,7 +471,7 @@ func (as *AnswerService) AcceptAnswer(ctx context.Context, req *schema.AcceptAns } // check answer belong to question - if acceptedAnswerInfo.QuestionID != req.QuestionID { + if !sameObjectID(acceptedAnswerInfo.QuestionID, req.QuestionID) { return errors.BadRequest(reason.AnswerNotFound) } acceptedAnswerInfo.ID = uid.DeShortID(acceptedAnswerInfo.ID) @@ -513,6 +513,12 @@ func (as *AnswerService) AcceptAnswer(ctx context.Context, req *schema.AcceptAns return nil } +// sameObjectID reports whether two object ids refer to the same row, +// regardless of whether each is in short-id or long-id form. +func sameObjectID(a, b string) bool { + return uid.DeShortID(a) == uid.DeShortID(b) +} + func (as *AnswerService) updateAnswerRank(ctx context.Context, userID string, questionInfo *entity.Question, newAnswerInfo *entity.Answer, oldAnswerInfo *entity.Answer, ) { diff --git a/internal/service/content/answer_service_test.go b/internal/service/content/answer_service_test.go new file mode 100644 index 000000000..3cfcb1056 --- /dev/null +++ b/internal/service/content/answer_service_test.go @@ -0,0 +1,81 @@ +/* + * 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/pkg/uid" +) + +// TestSameObjectID guards the AcceptAnswer ownership check (issue #1541). +// +// When short links are enabled, answerRepo.GetByID re-encodes the answer's +// QuestionID to its short form while the controller de-shorts req.QuestionID +// to its long form. The two encodings of the same question must be treated as +// equal, or accepting any answer fails with "Answer do not found". +func TestSameObjectID(t *testing.T) { + const longQID = "10010000000000001" + shortQID := uid.EnShortID(longQID) // e.g. "D1D1" + if shortQID == "" || shortQID == longQID { + t.Fatalf("precondition failed: EnShortID(%q)=%q, want a distinct short id", longQID, shortQID) + } + otherLongQID := "10010000000000002" + + tests := []struct { + name string + a string + b string + want bool + }{ + { + name: "short answer-side id vs long request-side id, same question (the bug)", + a: shortQID, + b: longQID, + want: true, + }, + { + name: "both long, same question (default permalink)", + a: longQID, + b: longQID, + want: true, + }, + { + name: "both short, same question", + a: shortQID, + b: shortQID, + want: true, + }, + { + name: "different questions must stay rejected (privilege-escalation guard)", + a: shortQID, + b: otherLongQID, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := sameObjectID(tt.a, tt.b); got != tt.want { + t.Errorf("sameObjectID(%q, %q) = %v, want %v", tt.a, tt.b, got, tt.want) + } + }) + } +} From d93e31e92afa500aec40090f2f15d0c0cf3c5639 Mon Sep 17 00:00:00 2001 From: Artur Iusupov Date: Fri, 5 Jun 2026 00:42:00 +0400 Subject: [PATCH 07/49] feat(site): allow disabling email verification --- docs/docs.go | 6 + docs/swagger.json | 6 + docs/swagger.yaml | 4 + i18n/en_US.yaml | 5 +- internal/controller/user_controller.go | 5 + internal/controller/user_controller_test.go | 37 ++++ internal/migrations/init.go | 7 +- internal/migrations/migrations.go | 1 + internal/migrations/v30.go | 9 +- internal/migrations/v34.go | 85 +++++++++ internal/migrations/v34_test.go | 172 ++++++++++++++++++ internal/schema/siteinfo_schema.go | 48 ++++- internal/schema/user_schema.go | 13 +- internal/service/content/user_service.go | 61 ++++++- internal/service/content/user_service_test.go | 155 ++++++++++++++++ internal/service/siteinfo/siteinfo_service.go | 22 ++- .../service/siteinfo/siteinfo_service_test.go | 104 +++++++++++ .../siteinfo_common/siteinfo_service.go | 2 +- .../siteinfo_common/siteinfo_service_test.go | 44 +++++ ui/src/common/interface.ts | 1 + ui/src/pages/Admin/Login/index.tsx | 15 ++ .../Register/components/SignUpForm/index.tsx | 13 +- ui/src/pages/Users/Register/index.tsx | 20 +- ui/src/services/common.ts | 5 +- ui/src/stores/loginSetting.ts | 1 + 25 files changed, 797 insertions(+), 44 deletions(-) create mode 100644 internal/controller/user_controller_test.go create mode 100644 internal/migrations/v34.go create mode 100644 internal/migrations/v34_test.go create mode 100644 internal/service/content/user_service_test.go create mode 100644 internal/service/siteinfo/siteinfo_service_test.go diff --git a/docs/docs.go b/docs/docs.go index 03b06701b..29e3a9622 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -12096,6 +12096,9 @@ const docTemplate = `{ }, "allow_password_login": { "type": "boolean" + }, + "require_email_verification": { + "type": "boolean" } } }, @@ -12116,6 +12119,9 @@ const docTemplate = `{ }, "allow_password_login": { "type": "boolean" + }, + "require_email_verification": { + "type": "boolean" } } }, diff --git a/docs/swagger.json b/docs/swagger.json index 3bfb2e3ce..2cc8f1305 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -12069,6 +12069,9 @@ }, "allow_password_login": { "type": "boolean" + }, + "require_email_verification": { + "type": "boolean" } } }, @@ -12089,6 +12092,9 @@ }, "allow_password_login": { "type": "boolean" + }, + "require_email_verification": { + "type": "boolean" } } }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index c08f1e8d4..2f7e3754e 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -2516,6 +2516,8 @@ definitions: type: boolean allow_password_login: type: boolean + require_email_verification: + type: boolean type: object schema.SiteLoginResp: properties: @@ -2529,6 +2531,8 @@ definitions: type: boolean allow_password_login: type: boolean + require_email_verification: + type: boolean type: object schema.SiteMCPReq: properties: diff --git a/i18n/en_US.yaml b/i18n/en_US.yaml index 61f496500..5d1faa3e0 100644 --- a/i18n/en_US.yaml +++ b/i18n/en_US.yaml @@ -2241,6 +2241,10 @@ ui: title: Email registration label: Allow email registration text: Turn off to prevent anyone creating new account through email. + email_verification: + title: Email verification + label: Require email verification + 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. @@ -2485,4 +2489,3 @@ ui: copied: Copied external_content_warning: External images/media are not displayed. - diff --git a/internal/controller/user_controller.go b/internal/controller/user_controller.go index 77c806e07..9552182ed 100644 --- a/internal/controller/user_controller.go +++ b/internal/controller/user_controller.go @@ -279,6 +279,7 @@ func (uc *UserController) UserRegisterByEmail(ctx *gin.Context) { handler.HandleResponse(ctx, errors.BadRequest(reason.EmailIllegalDomainError), nil) return } + applyEmailVerificationSetting(req, siteInfo) req.IP = ctx.ClientIP() isAdmin := middleware.GetUserIsAdminModerator(ctx) if !isAdmin { @@ -305,6 +306,10 @@ func (uc *UserController) UserRegisterByEmail(ctx *gin.Context) { } } +func applyEmailVerificationSetting(req *schema.UserRegisterReq, siteInfo *schema.SiteLoginResp) { + req.SkipEmailVerification = !siteInfo.RequireEmailVerification +} + // UserVerifyEmail godoc // @Summary UserVerifyEmail // @Description UserVerifyEmail diff --git a/internal/controller/user_controller_test.go b/internal/controller/user_controller_test.go new file mode 100644 index 000000000..e48dc8636 --- /dev/null +++ b/internal/controller/user_controller_test.go @@ -0,0 +1,37 @@ +/* + * 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" + "github.com/stretchr/testify/assert" +) + +func TestApplyEmailVerificationSetting(t *testing.T) { + req := &schema.UserRegisterReq{} + applyEmailVerificationSetting(req, &schema.SiteLoginResp{RequireEmailVerification: false}) + assert.True(t, req.SkipEmailVerification) + + req = &schema.UserRegisterReq{} + applyEmailVerificationSetting(req, &schema.SiteLoginResp{RequireEmailVerification: true}) + assert.False(t, req.SkipEmailVerification) +} diff --git a/internal/migrations/init.go b/internal/migrations/init.go index 9dbe6eb8e..d5098dd0c 100644 --- a/internal/migrations/init.go +++ b/internal/migrations/init.go @@ -226,9 +226,10 @@ func (m *Mentor) initSiteInfoGeneralData() { func (m *Mentor) initSiteInfoLoginConfig() { loginConfig := map[string]any{ - "allow_new_registrations": true, - "allow_email_registrations": true, - "allow_password_login": true, + "allow_new_registrations": true, + "allow_email_registrations": true, + "allow_password_login": true, + "require_email_verification": true, } loginConfigDataBytes, _ := json.Marshal(loginConfig) _, m.err = m.engine.Context(m.ctx).Insert(&entity.SiteInfo{ diff --git a/internal/migrations/migrations.go b/internal/migrations/migrations.go index 7fca2d50d..59fbb7bea 100644 --- a/internal/migrations/migrations.go +++ b/internal/migrations/migrations.go @@ -109,6 +109,7 @@ var migrations = []Migration{ NewMigration("v1.8.1", "ai feat", aiFeat, true), NewMigration("v2.0.1", "change avatar type to text", updateAvatarType, false), NewMigration("v2.0.2", "add reasoning content to ai conversation record", addAIConversationReasoningContent, false), + NewMigration("v2.0.3", "add require email verification login setting", addRequireEmailVerification, true), } func GetMigrations() []Migration { diff --git a/internal/migrations/v30.go b/internal/migrations/v30.go index 72765e372..bf785755a 100644 --- a/internal/migrations/v30.go +++ b/internal/migrations/v30.go @@ -302,10 +302,11 @@ func splitLegalMenu(ctx context.Context, x *xorm.Engine) error { PrivacyPolicyParsedText: oldSiteLegal.PrivacyPolicyParsedText, } siteLogin := &schema.SiteLoginResp{ - AllowNewRegistrations: oldSiteLogin.AllowNewRegistrations, - AllowEmailRegistrations: oldSiteLogin.AllowEmailRegistrations, - AllowPasswordLogin: oldSiteLogin.AllowPasswordLogin, - AllowEmailDomains: oldSiteLogin.AllowEmailDomains, + AllowNewRegistrations: oldSiteLogin.AllowNewRegistrations, + AllowEmailRegistrations: oldSiteLogin.AllowEmailRegistrations, + AllowPasswordLogin: oldSiteLogin.AllowPasswordLogin, + AllowEmailDomains: oldSiteLogin.AllowEmailDomains, + RequireEmailVerification: true, } siteGeneral := &schema.SiteGeneralReq{ Name: oldSiteGeneral.Name, diff --git a/internal/migrations/v34.go b/internal/migrations/v34.go new file mode 100644 index 000000000..1cc1895ba --- /dev/null +++ b/internal/migrations/v34.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 migrations + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/apache/answer/internal/base/constant" + "github.com/apache/answer/internal/entity" + "xorm.io/xorm" +) + +func addRequireEmailVerification(ctx context.Context, x *xorm.Engine) error { + loginSiteInfo := &entity.SiteInfo{} + exist, err := x.Context(ctx).Where("type = ?", constant.SiteTypeLogin).Get(loginSiteInfo) + if err != nil { + return fmt.Errorf("get login config failed: %w", err) + } + if !exist { + return nil + } + + content, err := backfillRequireEmailVerification(loginSiteInfo.Content) + if err != nil { + return fmt.Errorf("backfill login config failed: %w", err) + } + loginSiteInfo.Content = content + _, err = x.Context(ctx).ID(loginSiteInfo.ID).Cols("content").Update(loginSiteInfo) + if err != nil { + return fmt.Errorf("update login config failed: %w", err) + } + return nil +} + +func backfillRequireEmailVerification(content string) (string, error) { + if strings.TrimSpace(content) == "" { + content = "{}" + } + + loginConfig := map[string]json.RawMessage{} + if err := json.Unmarshal([]byte(content), &loginConfig); err != nil { + return "", err + } + if loginConfig == nil { + loginConfig = map[string]json.RawMessage{} + } + + requireEmailVerification, exists := loginConfig["require_email_verification"] + if !exists || bytes.Equal(bytes.TrimSpace(requireEmailVerification), []byte("null")) { + loginConfig["require_email_verification"] = json.RawMessage("true") + } else { + var value bool + if err := json.Unmarshal(requireEmailVerification, &value); err != nil { + return "", err + } + loginConfig["require_email_verification"] = requireEmailVerification + } + + data, err := json.Marshal(loginConfig) + if err != nil { + return "", err + } + return string(data), nil +} diff --git a/internal/migrations/v34_test.go b/internal/migrations/v34_test.go new file mode 100644 index 000000000..e082ed158 --- /dev/null +++ b/internal/migrations/v34_test.go @@ -0,0 +1,172 @@ +/* + * 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 migrations + +import ( + "context" + "encoding/json" + "testing" + + "github.com/apache/answer/internal/base/constant" + "github.com/apache/answer/internal/entity" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "xorm.io/xorm" +) + +func TestBackfillRequireEmailVerification(t *testing.T) { + tests := []struct { + name string + content string + expected bool + }{ + { + name: "adds true for missing key", + content: `{"allow_new_registrations":true,"allow_email_registrations":true,"allow_password_login":true}`, + expected: true, + }, + { + name: "converts null to true", + content: `{"allow_new_registrations":true,"allow_email_registrations":true,"allow_password_login":true,"require_email_verification":null}`, + expected: true, + }, + { + name: "preserves false", + content: `{"allow_new_registrations":true,"allow_email_registrations":true,"allow_password_login":true,"require_email_verification":false}`, + expected: false, + }, + { + name: "preserves true", + content: `{"allow_new_registrations":true,"allow_email_registrations":true,"allow_password_login":true,"require_email_verification":true}`, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + content, err := backfillRequireEmailVerification(tt.content) + require.NoError(t, err) + + var result map[string]bool + require.NoError(t, json.Unmarshal([]byte(content), &result)) + assert.Equal(t, tt.expected, result["require_email_verification"]) + }) + } +} + +func TestAddRequireEmailVerificationAbsentLoginRow(t *testing.T) { + x, err := xorm.NewEngine("sqlite", ":memory:") + require.NoError(t, err) + defer func() { + _ = x.Close() + }() + require.NoError(t, x.Sync(new(entity.SiteInfo))) + + require.NoError(t, addRequireEmailVerification(context.TODO(), x)) +} + +func TestAddRequireEmailVerificationUpdatesLoginRow(t *testing.T) { + x, err := xorm.NewEngine("sqlite", ":memory:") + require.NoError(t, err) + defer func() { + _ = x.Close() + }() + require.NoError(t, x.Sync(new(entity.SiteInfo))) + + _, err = x.Insert(&entity.SiteInfo{ + Type: constant.SiteTypeLogin, + Content: `{"allow_new_registrations":true}`, + Status: 1, + }) + require.NoError(t, err) + + require.NoError(t, addRequireEmailVerification(context.TODO(), x)) + + login := &entity.SiteInfo{} + exist, err := x.Where("type = ?", constant.SiteTypeLogin).Get(login) + require.NoError(t, err) + require.True(t, exist) + + var result struct { + RequireEmailVerification bool `json:"require_email_verification"` + } + require.NoError(t, json.Unmarshal([]byte(login.Content), &result)) + assert.True(t, result.RequireEmailVerification) +} + +func TestSplitLegalMenuKeepsRequireEmailVerificationDefaultTrue(t *testing.T) { + x, err := xorm.NewEngine("sqlite", ":memory:") + require.NoError(t, err) + defer func() { + _ = x.Close() + }() + require.NoError(t, x.Sync(new(entity.SiteInfo))) + + _, err = x.Insert(&entity.SiteInfo{ + Type: constant.SiteTypeLegal, + Content: `{ + "terms_of_service_original_text":"tos", + "terms_of_service_parsed_text":"tos", + "privacy_policy_original_text":"privacy", + "privacy_policy_parsed_text":"privacy", + "external_content_display":"always_display" + }`, + Status: 1, + }) + require.NoError(t, err) + _, err = x.Insert(&entity.SiteInfo{ + Type: constant.SiteTypeLogin, + Content: `{ + "allow_new_registrations":true, + "allow_email_registrations":true, + "allow_password_login":true, + "login_required":false, + "allow_email_domains":[] + }`, + Status: 1, + }) + require.NoError(t, err) + _, err = x.Insert(&entity.SiteInfo{ + Type: constant.SiteTypeGeneral, + Content: `{ + "name":"site", + "short_description":"short", + "description":"description", + "site_url":"https://example.com", + "contact_email":"admin@example.com", + "check_update":true + }`, + Status: 1, + }) + require.NoError(t, err) + + require.NoError(t, splitLegalMenu(context.TODO(), x)) + + login := &entity.SiteInfo{} + exist, err := x.Where("type = ?", constant.SiteTypeLogin).Get(login) + require.NoError(t, err) + require.True(t, exist) + + var result struct { + RequireEmailVerification bool `json:"require_email_verification"` + } + require.NoError(t, json.Unmarshal([]byte(login.Content), &result)) + assert.True(t, result.RequireEmailVerification) +} diff --git a/internal/schema/siteinfo_schema.go b/internal/schema/siteinfo_schema.go index bdf2308d3..3cd27873a 100644 --- a/internal/schema/siteinfo_schema.go +++ b/internal/schema/siteinfo_schema.go @@ -21,6 +21,7 @@ package schema import ( "context" + "encoding/json" "fmt" "net/mail" "net/url" @@ -216,12 +217,48 @@ type SiteUsersReq struct { AllowUpdateLocation bool `json:"allow_update_location"` } +// OptionalBool preserves whether a JSON boolean field was omitted, set to null, +// or set to a concrete boolean value. +type OptionalBool struct { + Set bool `json:"-"` + Null bool `json:"-"` + Value bool `json:"-"` +} + +func (b *OptionalBool) UnmarshalJSON(data []byte) error { + b.Set = true + if string(data) == "null" { + b.Null = true + b.Value = false + return nil + } + b.Null = false + return json.Unmarshal(data, &b.Value) +} + +func (b OptionalBool) MarshalJSON() ([]byte, error) { + if !b.Set || b.Null { + return []byte("null"), nil + } + return json.Marshal(b.Value) +} + // SiteLoginReq site login request type SiteLoginReq struct { - AllowNewRegistrations bool `json:"allow_new_registrations"` - AllowEmailRegistrations bool `json:"allow_email_registrations"` - AllowPasswordLogin bool `json:"allow_password_login"` - AllowEmailDomains []string `json:"allow_email_domains"` + AllowNewRegistrations bool `json:"allow_new_registrations"` + AllowEmailRegistrations bool `json:"allow_email_registrations"` + AllowPasswordLogin bool `json:"allow_password_login"` + AllowEmailDomains []string `json:"allow_email_domains"` + RequireEmailVerification OptionalBool `json:"require_email_verification" swaggertype:"boolean"` +} + +// SiteLoginResp site login response +type SiteLoginResp struct { + AllowNewRegistrations bool `json:"allow_new_registrations"` + AllowEmailRegistrations bool `json:"allow_email_registrations"` + AllowPasswordLogin bool `json:"allow_password_login"` + AllowEmailDomains []string `json:"allow_email_domains"` + RequireEmailVerification bool `json:"require_email_verification"` } // SiteCustomCssHTMLReq site custom css html @@ -310,9 +347,6 @@ type SiteInterfaceResp SiteInterfaceReq // SiteBrandingResp site branding response type SiteBrandingResp SiteBrandingReq -// SiteLoginResp site login response -type SiteLoginResp SiteLoginReq - // SiteCustomCssHTMLResp site custom css html response type SiteCustomCssHTMLResp SiteCustomCssHTMLReq diff --git a/internal/schema/user_schema.go b/internal/schema/user_schema.go index d209c8f58..7d0c55a58 100644 --- a/internal/schema/user_schema.go +++ b/internal/schema/user_schema.go @@ -219,12 +219,13 @@ type UserEmailLoginReq struct { // UserRegisterReq user register request type UserRegisterReq struct { - Name string `validate:"required,gte=2,lte=30" json:"name"` - Email string `validate:"required,email,gt=0,lte=500" json:"e_mail" ` - Pass string `validate:"required,gte=8,lte=32" json:"pass"` - CaptchaID string `json:"captcha_id"` - CaptchaCode string `json:"captcha_code"` - IP string `json:"-" ` + Name string `validate:"required,gte=2,lte=30" json:"name"` + Email string `validate:"required,email,gt=0,lte=500" json:"e_mail" ` + Pass string `validate:"required,gte=8,lte=32" json:"pass"` + CaptchaID string `json:"captcha_id"` + CaptchaCode string `json:"captcha_code"` + IP string `json:"-" ` + SkipEmailVerification bool `json:"-"` } func (u *UserRegisterReq) Check() (errFields []*validator.FormErrorField, err error) { diff --git a/internal/service/content/user_service.go b/internal/service/content/user_service.go index 42a2efda7..1d7866f35 100644 --- a/internal/service/content/user_service.go +++ b/internal/service/content/user_service.go @@ -518,18 +518,20 @@ func (us *UserService) UserRegisterByEmail(ctx context.Context, registerUserInfo log.Errorf("set default user notification config failed, err: %v", err) } - // send email - data := &schema.EmailCodeContent{ - Email: registerUserInfo.Email, - UserID: userInfo.ID, - } - code := token.GenerateToken() - verifyEmailURL := fmt.Sprintf("%s/users/account-activation?code=%s", us.getSiteUrl(ctx), code) - title, body, err := us.emailService.RegisterTemplate(ctx, verifyEmailURL) + err = applyRegistrationVerification(userInfo, registerUserInfo.SkipEmailVerification, registrationVerificationActions{ + sendActivationEmail: func() error { + return us.sendRegistrationActivationEmail(ctx, userInfo) + }, + activateUser: func() error { + return us.userActivity.UserActive(ctx, userInfo.ID) + }, + markEmailAvailable: func() error { + return us.userRepo.UpdateEmailStatus(ctx, userInfo.ID, entity.EmailStatusAvailable) + }, + }) if err != nil { return nil, nil, err } - go us.emailService.SendAndSaveCode(ctx, userInfo.ID, userInfo.EMail, title, body, code, data.ToJSONString()) roleID, err := us.userRoleService.GetUserRole(ctx, userInfo.ID) if err != nil { @@ -560,6 +562,47 @@ func (us *UserService) UserRegisterByEmail(ctx context.Context, registerUserInfo return resp, nil, nil } +type registrationVerificationActions struct { + sendActivationEmail func() error + activateUser func() error + markEmailAvailable func() error +} + +func applyRegistrationVerification( + userInfo *entity.User, skipEmailVerification bool, actions registrationVerificationActions, +) error { + userInfo.MailStatus = entity.EmailStatusToBeVerified + if !skipEmailVerification { + return actions.sendActivationEmail() + } + + if err := actions.activateUser(); err != nil { + log.Errorf("activate user during registration failed, fallback to email verification, err: %v", err) + return actions.sendActivationEmail() + } + if err := actions.markEmailAvailable(); err != nil { + log.Errorf("mark email available during registration failed, fallback to email verification, err: %v", err) + return actions.sendActivationEmail() + } + userInfo.MailStatus = entity.EmailStatusAvailable + return nil +} + +func (us *UserService) sendRegistrationActivationEmail(ctx context.Context, userInfo *entity.User) error { + data := &schema.EmailCodeContent{ + Email: userInfo.EMail, + UserID: userInfo.ID, + } + code := token.GenerateToken() + verifyEmailURL := fmt.Sprintf("%s/users/account-activation?code=%s", us.getSiteUrl(ctx), code) + title, body, err := us.emailService.RegisterTemplate(ctx, verifyEmailURL) + if err != nil { + return err + } + go us.emailService.SendAndSaveCode(ctx, userInfo.ID, userInfo.EMail, title, body, code, data.ToJSONString()) + return nil +} + func (us *UserService) UserVerifyEmailSend(ctx context.Context, userID string) error { userInfo, has, err := us.userRepo.GetByUserID(ctx, userID) if err != nil { diff --git a/internal/service/content/user_service_test.go b/internal/service/content/user_service_test.go new file mode 100644 index 000000000..0d77b383d --- /dev/null +++ b/internal/service/content/user_service_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 content + +import ( + "errors" + "testing" + + "github.com/apache/answer/internal/entity" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestApplyRegistrationVerification(t *testing.T) { + t.Run("enabled sends activation email and leaves user inactive", func(t *testing.T) { + userInfo := &entity.User{} + calls := map[string]int{} + + err := applyRegistrationVerification(userInfo, false, registrationVerificationActions{ + sendActivationEmail: func() error { + calls["sendActivationEmail"]++ + return nil + }, + activateUser: func() error { + calls["activateUser"]++ + return nil + }, + markEmailAvailable: func() error { + calls["markEmailAvailable"]++ + return nil + }, + }) + + require.NoError(t, err) + assert.Equal(t, entity.EmailStatusToBeVerified, userInfo.MailStatus) + assert.Equal(t, 1, calls["sendActivationEmail"]) + assert.Zero(t, calls["activateUser"]) + assert.Zero(t, calls["markEmailAvailable"]) + }) + + t.Run("disabled activates once and marks email available", func(t *testing.T) { + userInfo := &entity.User{} + calls := map[string]int{} + + err := applyRegistrationVerification(userInfo, true, registrationVerificationActions{ + sendActivationEmail: func() error { + calls["sendActivationEmail"]++ + return nil + }, + activateUser: func() error { + calls["activateUser"]++ + return nil + }, + markEmailAvailable: func() error { + calls["markEmailAvailable"]++ + return nil + }, + }) + + require.NoError(t, err) + assert.Equal(t, entity.EmailStatusAvailable, userInfo.MailStatus) + assert.Zero(t, calls["sendActivationEmail"]) + assert.Equal(t, 1, calls["activateUser"]) + assert.Equal(t, 1, calls["markEmailAvailable"]) + }) + + t.Run("disabled user activation failure falls back to email verification", func(t *testing.T) { + userInfo := &entity.User{} + calls := map[string]int{} + + err := applyRegistrationVerification(userInfo, true, registrationVerificationActions{ + sendActivationEmail: func() error { + calls["sendActivationEmail"]++ + return nil + }, + activateUser: func() error { + calls["activateUser"]++ + return errors.New("activate failed") + }, + markEmailAvailable: func() error { + calls["markEmailAvailable"]++ + return nil + }, + }) + + require.NoError(t, err) + assert.Equal(t, entity.EmailStatusToBeVerified, userInfo.MailStatus) + assert.Equal(t, 1, calls["sendActivationEmail"]) + assert.Equal(t, 1, calls["activateUser"]) + assert.Zero(t, calls["markEmailAvailable"]) + }) + + t.Run("disabled email status failure falls back to email verification", func(t *testing.T) { + userInfo := &entity.User{} + calls := map[string]int{} + + err := applyRegistrationVerification(userInfo, true, registrationVerificationActions{ + sendActivationEmail: func() error { + calls["sendActivationEmail"]++ + return nil + }, + activateUser: func() error { + calls["activateUser"]++ + return nil + }, + markEmailAvailable: func() error { + calls["markEmailAvailable"]++ + return errors.New("update failed") + }, + }) + + require.NoError(t, err) + assert.Equal(t, entity.EmailStatusToBeVerified, userInfo.MailStatus) + assert.Equal(t, 1, calls["sendActivationEmail"]) + assert.Equal(t, 1, calls["activateUser"]) + assert.Equal(t, 1, calls["markEmailAvailable"]) + }) + + t.Run("fallback email failure returns before active status", func(t *testing.T) { + userInfo := &entity.User{} + expectedErr := errors.New("email failed") + + err := applyRegistrationVerification(userInfo, true, registrationVerificationActions{ + sendActivationEmail: func() error { + return expectedErr + }, + activateUser: func() error { + return errors.New("activate failed") + }, + markEmailAvailable: func() error { + return nil + }, + }) + + require.ErrorIs(t, err, expectedErr) + assert.Equal(t, entity.EmailStatusToBeVerified, userInfo.MailStatus) + }) +} diff --git a/internal/service/siteinfo/siteinfo_service.go b/internal/service/siteinfo/siteinfo_service.go index 1e25cbaa4..7656a1407 100644 --- a/internal/service/siteinfo/siteinfo_service.go +++ b/internal/service/siteinfo/siteinfo_service.go @@ -291,7 +291,27 @@ func (s *SiteInfoService) SaveSiteSecurity(ctx context.Context, req *schema.Site // SaveSiteLogin save site legal configuration func (s *SiteInfoService) SaveSiteLogin(ctx context.Context, req *schema.SiteLoginReq) (err error) { - content, _ := json.Marshal(req) + requireEmailVerification := true + if req.RequireEmailVerification.Set { + if !req.RequireEmailVerification.Null { + requireEmailVerification = req.RequireEmailVerification.Value + } + } else { + currentLogin, err := s.GetSiteLogin(ctx) + if err != nil { + return err + } + requireEmailVerification = currentLogin.RequireEmailVerification + } + + loginConfig := &schema.SiteLoginResp{ + AllowNewRegistrations: req.AllowNewRegistrations, + AllowEmailRegistrations: req.AllowEmailRegistrations, + AllowPasswordLogin: req.AllowPasswordLogin, + AllowEmailDomains: req.AllowEmailDomains, + RequireEmailVerification: requireEmailVerification, + } + content, _ := json.Marshal(loginConfig) data := &entity.SiteInfo{ Type: constant.SiteTypeLogin, Content: string(content), diff --git a/internal/service/siteinfo/siteinfo_service_test.go b/internal/service/siteinfo/siteinfo_service_test.go new file mode 100644 index 000000000..fc4bbdf70 --- /dev/null +++ b/internal/service/siteinfo/siteinfo_service_test.go @@ -0,0 +1,104 @@ +/* + * 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 siteinfo + +import ( + "context" + "encoding/json" + "testing" + + "github.com/apache/answer/internal/base/constant" + "github.com/apache/answer/internal/entity" + "github.com/apache/answer/internal/schema" + "github.com/apache/answer/internal/service/mock" + "github.com/apache/answer/internal/service/siteinfo_common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func TestSiteInfoService_SaveSiteLoginRequireEmailVerification(t *testing.T) { + tests := []struct { + name string + currentContent string + requestPayload string + expectGet bool + expectedRequire bool + }{ + { + name: "omitted preserves normalized default", + currentContent: `{"allow_new_registrations":true,"allow_email_registrations":true,"allow_password_login":true}`, + requestPayload: `{"allow_new_registrations":true,"allow_email_registrations":true,"allow_password_login":true,"allow_email_domains":[]}`, + expectGet: true, + expectedRequire: true, + }, + { + name: "omitted preserves current false", + currentContent: `{"allow_new_registrations":true,"allow_email_registrations":true,"allow_password_login":true,"require_email_verification":false}`, + requestPayload: `{"allow_new_registrations":true,"allow_email_registrations":true,"allow_password_login":true,"allow_email_domains":[]}`, + expectGet: true, + expectedRequire: false, + }, + { + name: "null normalizes true", + requestPayload: `{"allow_new_registrations":true,"allow_email_registrations":true,"allow_password_login":true,"allow_email_domains":[],"require_email_verification":null}`, + expectedRequire: true, + }, + { + name: "explicit false persists false", + requestPayload: `{"allow_new_registrations":true,"allow_email_registrations":true,"allow_password_login":true,"allow_email_domains":[],"require_email_verification":false}`, + expectedRequire: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctl := gomock.NewController(t) + defer ctl.Finish() + + repo := mock.NewMockSiteInfoRepo(ctl) + if tt.expectGet { + repo.EXPECT().GetByType(gomock.Any(), constant.SiteTypeLogin). + Return(&entity.SiteInfo{Content: tt.currentContent}, true, nil) + } + + var savedContent string + repo.EXPECT().SaveByType(gomock.Any(), constant.SiteTypeLogin, gomock.Any()). + DoAndReturn(func(_ context.Context, _ string, data *entity.SiteInfo) error { + savedContent = data.Content + return nil + }) + + req := &schema.SiteLoginReq{} + require.NoError(t, json.Unmarshal([]byte(tt.requestPayload), req)) + + service := &SiteInfoService{ + siteInfoRepo: repo, + siteInfoCommonService: siteinfo_common.NewSiteInfoCommonService(repo), + } + require.NoError(t, service.SaveSiteLogin(context.TODO(), req)) + assert.NotContains(t, savedContent, `"require_email_verification":null`) + + saved := &schema.SiteLoginResp{} + require.NoError(t, json.Unmarshal([]byte(savedContent), saved)) + assert.Equal(t, tt.expectedRequire, saved.RequireEmailVerification) + }) + } +} diff --git a/internal/service/siteinfo_common/siteinfo_service.go b/internal/service/siteinfo_common/siteinfo_service.go index 5e3964c0c..752ae0510 100644 --- a/internal/service/siteinfo_common/siteinfo_service.go +++ b/internal/service/siteinfo_common/siteinfo_service.go @@ -235,7 +235,7 @@ func (s *siteInfoCommonService) GetSiteSecurity(ctx context.Context) (resp *sche // GetSiteLogin get site login config func (s *siteInfoCommonService) GetSiteLogin(ctx context.Context) (resp *schema.SiteLoginResp, err error) { - resp = &schema.SiteLoginResp{} + resp = &schema.SiteLoginResp{RequireEmailVerification: true} if err = s.GetSiteInfoByType(ctx, constant.SiteTypeLogin, resp); err != nil { return nil, err } diff --git a/internal/service/siteinfo_common/siteinfo_service_test.go b/internal/service/siteinfo_common/siteinfo_service_test.go index a87d427f2..430f45fff 100644 --- a/internal/service/siteinfo_common/siteinfo_service_test.go +++ b/internal/service/siteinfo_common/siteinfo_service_test.go @@ -50,3 +50,47 @@ func TestSiteInfoCommonService_GetSiteGeneral(t *testing.T) { require.NoError(t, err) assert.Equal(t, "name", resp.Name) } + +func TestSiteInfoCommonService_GetSiteLoginRequireEmailVerification(t *testing.T) { + tests := []struct { + name string + content string + expected bool + }{ + { + name: "missing key defaults true", + content: `{"allow_new_registrations":true,"allow_email_registrations":true,"allow_password_login":true}`, + expected: true, + }, + { + name: "null defaults true", + content: `{"allow_new_registrations":true,"allow_email_registrations":true,"allow_password_login":true,"require_email_verification":null}`, + expected: true, + }, + { + name: "explicit false is preserved", + content: `{"allow_new_registrations":true,"allow_email_registrations":true,"allow_password_login":true,"require_email_verification":false}`, + expected: false, + }, + { + name: "explicit true is preserved", + content: `{"allow_new_registrations":true,"allow_email_registrations":true,"allow_password_login":true,"require_email_verification":true}`, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctl := gomock.NewController(t) + defer ctl.Finish() + repo := mock.NewMockSiteInfoRepo(ctl) + repo.EXPECT().GetByType(gomock.Any(), constant.SiteTypeLogin). + Return(&entity.SiteInfo{Content: tt.content}, true, nil) + + siteInfoCommonService := NewSiteInfoCommonService(repo) + resp, err := siteInfoCommonService.GetSiteLogin(context.TODO()) + require.NoError(t, err) + assert.Equal(t, tt.expected, resp.RequireEmailVerification) + }) + } +} diff --git a/ui/src/common/interface.ts b/ui/src/common/interface.ts index dbcec6c04..8ab714230 100644 --- a/ui/src/common/interface.ts +++ b/ui/src/common/interface.ts @@ -488,6 +488,7 @@ export interface AdminSettingsLogin { allow_email_registrations: boolean; allow_email_domains: string[]; allow_password_login: boolean; + require_email_verification: boolean; } /** diff --git a/ui/src/pages/Admin/Login/index.tsx b/ui/src/pages/Admin/Login/index.tsx index a4a61152f..c596fc1c6 100644 --- a/ui/src/pages/Admin/Login/index.tsx +++ b/ui/src/pages/Admin/Login/index.tsx @@ -47,6 +47,12 @@ const Index: FC = () => { description: t('email_registration.text'), default: true, }, + require_email_verification: { + type: 'boolean', + title: t('email_verification.title'), + description: t('email_verification.text'), + default: true, + }, allow_password_login: { type: 'boolean', title: t('password_login.title'), @@ -73,6 +79,12 @@ const Index: FC = () => { label: t('email_registration.label'), }, }, + require_email_verification: { + 'ui:widget': 'switch', + 'ui:options': { + label: t('email_verification.label'), + }, + }, allow_password_login: { 'ui:widget': 'switch', 'ui:options': { @@ -105,6 +117,7 @@ const Index: FC = () => { allow_email_registrations: formData.allow_email_registrations.value, allow_email_domains: allowedEmailDomains, allow_password_login: formData.allow_password_login.value, + require_email_verification: formData.require_email_verification.value, }; putLoginSetting(reqParams) @@ -139,6 +152,8 @@ const Index: FC = () => { setting.allow_email_domains.join('\n'); } formMeta.allow_password_login.value = setting.allow_password_login; + formMeta.require_email_verification.value = + setting.require_email_verification; setFormData({ ...formMeta }); } }); diff --git a/ui/src/pages/Users/Register/components/SignUpForm/index.tsx b/ui/src/pages/Users/Register/components/SignUpForm/index.tsx index bffa44652..8dc30b965 100644 --- a/ui/src/pages/Users/Register/components/SignUpForm/index.tsx +++ b/ui/src/pages/Users/Register/components/SignUpForm/index.tsx @@ -23,14 +23,17 @@ import { Link } from 'react-router-dom'; import { Trans, useTranslation } from 'react-i18next'; import { useCaptchaPlugin } from '@/utils/pluginKit'; -import type { FormDataType, RegisterReqParams } from '@/common/interface'; +import type { + FormDataType, + RegisterReqParams, + UserInfoRes, +} from '@/common/interface'; import { register } from '@/services'; -import userStore from '@/stores/loggedUserInfo'; import { handleFormError, scrollToElementTop } from '@/utils'; import { useLegalClick } from '@/behaviour/useLegalClick'; interface Props { - callback: () => void; + callback: (user: UserInfoRes) => void; } const Index: React.FC = ({ callback }) => { @@ -53,7 +56,6 @@ const Index: React.FC = ({ callback }) => { }, }); - const updateUser = userStore((state) => state.update); const emailCaptcha = useCaptchaPlugin('email'); const nameRegex = /^[\w.-\s]{2,30}$/; @@ -139,8 +141,7 @@ const Index: React.FC = ({ callback }) => { register(reqParams) .then(async (res) => { await emailCaptcha?.close(); - updateUser(res); - callback(); + callback(res); }) .catch((err) => { if (err.isError) { diff --git a/ui/src/pages/Users/Register/index.tsx b/ui/src/pages/Users/Register/index.tsx index 4d894b6a8..0152983e7 100644 --- a/ui/src/pages/Users/Register/index.tsx +++ b/ui/src/pages/Users/Register/index.tsx @@ -20,12 +20,14 @@ import React, { useState } from 'react'; import { Container, Col } from 'react-bootstrap'; import { Trans, useTranslation } from 'react-i18next'; -import { Link } from 'react-router-dom'; +import { Link, useNavigate } from 'react-router-dom'; import { usePageTags } from '@/hooks'; +import type * as Type from '@/common/interface'; import { Unactivate, WelcomeTitle, PluginRender } from '@/components'; import { guard } from '@/utils'; -import { loginSettingStore } from '@/stores'; +import { loggedUserInfoStore, loginSettingStore } from '@/stores'; +import { setupAppTheme } from '@/utils/localize'; import { PluginType } from '@/utils/pluginKit/interface'; import SignUpForm from './components/SignUpForm'; @@ -33,9 +35,17 @@ import SignUpForm from './components/SignUpForm'; const Index: React.FC = () => { const [showForm, setShowForm] = useState(true); const { t } = useTranslation('translation', { keyPrefix: 'login' }); + const navigate = useNavigate(); const loginSetting = loginSettingStore((state) => state.login); - const onStep = () => { - setShowForm((bol) => !bol); + const updateUser = loggedUserInfoStore((state) => state.update); + const onRegister = (user: Type.UserInfoRes) => { + updateUser(user); + setupAppTheme(); + if (user.mail_status === 2) { + setShowForm(false); + return; + } + guard.handleLoginRedirect(navigate); }; usePageTags({ title: t('sign_up', { keyPrefix: 'page_title' }), @@ -60,7 +70,7 @@ const Index: React.FC = () => { slug_name="third_party_connector" className="mb-5" /> - {showSignupForm ? : null} + {showSignupForm ? : null}
Already have an account? Log in diff --git a/ui/src/services/common.ts b/ui/src/services/common.ts index cac34b4ff..f612bac93 100644 --- a/ui/src/services/common.ts +++ b/ui/src/services/common.ts @@ -129,7 +129,10 @@ export const login = (params: Type.LoginReqParams) => { }; export const register = (params: Type.RegisterReqParams) => { - return request.post('/answer/api/v1/user/register/email', params); + return request.post( + '/answer/api/v1/user/register/email', + params, + ); }; export const logout = () => { diff --git a/ui/src/stores/loginSetting.ts b/ui/src/stores/loginSetting.ts index 7acf765ee..f49c394b8 100644 --- a/ui/src/stores/loginSetting.ts +++ b/ui/src/stores/loginSetting.ts @@ -32,6 +32,7 @@ const loginSetting = create((set) => ({ allow_email_registrations: true, allow_email_domains: [], allow_password_login: true, + require_email_verification: true, }, update: (params) => set(() => { From 3b6f981b81ca4376138734e4039eab59b1fea63c Mon Sep 17 00:00:00 2001 From: Artur Iusupov Date: Tue, 9 Jun 2026 01:39:27 +0400 Subject: [PATCH 08/49] fix(site): require explicit email verification setting Replace OptionalBool with an explicit require_email_verification value for the login settings save request while keeping legacy read defaults intact. Use positive RequireEmailVerification naming through the registration flow and inline the site setting mapping. Add validation, save-path, and registration coverage for the explicit email verification setting. --- docs/docs.go | 3 + docs/swagger.json | 3 + docs/swagger.yaml | 2 + internal/controller/user_controller.go | 6 +- internal/controller/user_controller_test.go | 37 ---------- internal/migrations/v34.go | 2 + internal/schema/siteinfo_schema.go | 37 ++-------- internal/schema/siteinfo_schema_test.go | 72 +++++++++++++++++++ internal/schema/user_schema.go | 14 ++-- internal/service/content/user_service.go | 6 +- internal/service/content/user_service_test.go | 20 +++--- internal/service/siteinfo/siteinfo_service.go | 15 +--- .../service/siteinfo/siteinfo_service_test.go | 55 +++++++------- 13 files changed, 135 insertions(+), 137 deletions(-) delete mode 100644 internal/controller/user_controller_test.go create mode 100644 internal/schema/siteinfo_schema_test.go diff --git a/docs/docs.go b/docs/docs.go index 29e3a9622..4e48c88d0 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -12081,6 +12081,9 @@ const docTemplate = `{ }, "schema.SiteLoginReq": { "type": "object", + "required": [ + "require_email_verification" + ], "properties": { "allow_email_domains": { "type": "array", diff --git a/docs/swagger.json b/docs/swagger.json index 2cc8f1305..a075dfe45 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -12054,6 +12054,9 @@ }, "schema.SiteLoginReq": { "type": "object", + "required": [ + "require_email_verification" + ], "properties": { "allow_email_domains": { "type": "array", diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 2f7e3754e..b3416a10e 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -2518,6 +2518,8 @@ definitions: type: boolean require_email_verification: type: boolean + required: + - require_email_verification type: object schema.SiteLoginResp: properties: diff --git a/internal/controller/user_controller.go b/internal/controller/user_controller.go index 9552182ed..531d88b45 100644 --- a/internal/controller/user_controller.go +++ b/internal/controller/user_controller.go @@ -279,7 +279,7 @@ func (uc *UserController) UserRegisterByEmail(ctx *gin.Context) { handler.HandleResponse(ctx, errors.BadRequest(reason.EmailIllegalDomainError), nil) return } - applyEmailVerificationSetting(req, siteInfo) + req.RequireEmailVerification = siteInfo.RequireEmailVerification req.IP = ctx.ClientIP() isAdmin := middleware.GetUserIsAdminModerator(ctx) if !isAdmin { @@ -306,10 +306,6 @@ func (uc *UserController) UserRegisterByEmail(ctx *gin.Context) { } } -func applyEmailVerificationSetting(req *schema.UserRegisterReq, siteInfo *schema.SiteLoginResp) { - req.SkipEmailVerification = !siteInfo.RequireEmailVerification -} - // UserVerifyEmail godoc // @Summary UserVerifyEmail // @Description UserVerifyEmail diff --git a/internal/controller/user_controller_test.go b/internal/controller/user_controller_test.go deleted file mode 100644 index e48dc8636..000000000 --- a/internal/controller/user_controller_test.go +++ /dev/null @@ -1,37 +0,0 @@ -/* - * 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" - "github.com/stretchr/testify/assert" -) - -func TestApplyEmailVerificationSetting(t *testing.T) { - req := &schema.UserRegisterReq{} - applyEmailVerificationSetting(req, &schema.SiteLoginResp{RequireEmailVerification: false}) - assert.True(t, req.SkipEmailVerification) - - req = &schema.UserRegisterReq{} - applyEmailVerificationSetting(req, &schema.SiteLoginResp{RequireEmailVerification: true}) - assert.False(t, req.SkipEmailVerification) -} diff --git a/internal/migrations/v34.go b/internal/migrations/v34.go index 1cc1895ba..245bb976d 100644 --- a/internal/migrations/v34.go +++ b/internal/migrations/v34.go @@ -67,6 +67,8 @@ func backfillRequireEmailVerification(content string) (string, error) { } requireEmailVerification, exists := loginConfig["require_email_verification"] + // Legacy configs that predate this setting should keep the safer behavior. + // Treat a missing or null value as requiring email verification. if !exists || bytes.Equal(bytes.TrimSpace(requireEmailVerification), []byte("null")) { loginConfig["require_email_verification"] = json.RawMessage("true") } else { diff --git a/internal/schema/siteinfo_schema.go b/internal/schema/siteinfo_schema.go index 3cd27873a..1d0b27ff6 100644 --- a/internal/schema/siteinfo_schema.go +++ b/internal/schema/siteinfo_schema.go @@ -21,7 +21,6 @@ package schema import ( "context" - "encoding/json" "fmt" "net/mail" "net/url" @@ -217,39 +216,13 @@ type SiteUsersReq struct { AllowUpdateLocation bool `json:"allow_update_location"` } -// OptionalBool preserves whether a JSON boolean field was omitted, set to null, -// or set to a concrete boolean value. -type OptionalBool struct { - Set bool `json:"-"` - Null bool `json:"-"` - Value bool `json:"-"` -} - -func (b *OptionalBool) UnmarshalJSON(data []byte) error { - b.Set = true - if string(data) == "null" { - b.Null = true - b.Value = false - return nil - } - b.Null = false - return json.Unmarshal(data, &b.Value) -} - -func (b OptionalBool) MarshalJSON() ([]byte, error) { - if !b.Set || b.Null { - return []byte("null"), nil - } - return json.Marshal(b.Value) -} - // SiteLoginReq site login request type SiteLoginReq struct { - AllowNewRegistrations bool `json:"allow_new_registrations"` - AllowEmailRegistrations bool `json:"allow_email_registrations"` - AllowPasswordLogin bool `json:"allow_password_login"` - AllowEmailDomains []string `json:"allow_email_domains"` - RequireEmailVerification OptionalBool `json:"require_email_verification" swaggertype:"boolean"` + AllowNewRegistrations bool `json:"allow_new_registrations"` + AllowEmailRegistrations bool `json:"allow_email_registrations"` + AllowPasswordLogin bool `json:"allow_password_login"` + AllowEmailDomains []string `json:"allow_email_domains"` + RequireEmailVerification *bool `validate:"required" json:"require_email_verification" swaggertype:"boolean"` } // SiteLoginResp site login response diff --git a/internal/schema/siteinfo_schema_test.go b/internal/schema/siteinfo_schema_test.go new file mode 100644 index 000000000..e5413f4a9 --- /dev/null +++ b/internal/schema/siteinfo_schema_test.go @@ -0,0 +1,72 @@ +/* + * 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 ( + "encoding/json" + "testing" + + "github.com/apache/answer/internal/base/validator" + "github.com/segmentfault/pacman/i18n" + "github.com/stretchr/testify/require" +) + +func TestSiteLoginReqRequireEmailVerificationValidation(t *testing.T) { + tests := []struct { + name string + payload string + expectError bool + }{ + { + name: "omitted is invalid", + payload: `{"allow_new_registrations":true,"allow_email_registrations":true,"allow_password_login":true,"allow_email_domains":[]}`, + expectError: true, + }, + { + name: "null is invalid", + payload: `{"allow_new_registrations":true,"allow_email_registrations":true,"allow_password_login":true,"allow_email_domains":[],"require_email_verification":null}`, + expectError: true, + }, + { + name: "false is valid", + payload: `{"allow_new_registrations":true,"allow_email_registrations":true,"allow_password_login":true,"allow_email_domains":[],"require_email_verification":false}`, + expectError: false, + }, + { + name: "true is valid", + payload: `{"allow_new_registrations":true,"allow_email_registrations":true,"allow_password_login":true,"allow_email_domains":[],"require_email_verification":true}`, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := &SiteLoginReq{} + require.NoError(t, json.Unmarshal([]byte(tt.payload), req)) + + _, err := validator.GetValidatorByLang(i18n.DefaultLanguage).Check(req) + if tt.expectError { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/internal/schema/user_schema.go b/internal/schema/user_schema.go index 7d0c55a58..0683a5aff 100644 --- a/internal/schema/user_schema.go +++ b/internal/schema/user_schema.go @@ -219,13 +219,13 @@ type UserEmailLoginReq struct { // UserRegisterReq user register request type UserRegisterReq struct { - Name string `validate:"required,gte=2,lte=30" json:"name"` - Email string `validate:"required,email,gt=0,lte=500" json:"e_mail" ` - Pass string `validate:"required,gte=8,lte=32" json:"pass"` - CaptchaID string `json:"captcha_id"` - CaptchaCode string `json:"captcha_code"` - IP string `json:"-" ` - SkipEmailVerification bool `json:"-"` + Name string `validate:"required,gte=2,lte=30" json:"name"` + Email string `validate:"required,email,gt=0,lte=500" json:"e_mail" ` + Pass string `validate:"required,gte=8,lte=32" json:"pass"` + CaptchaID string `json:"captcha_id"` + CaptchaCode string `json:"captcha_code"` + IP string `json:"-" ` + RequireEmailVerification bool `json:"-"` } func (u *UserRegisterReq) Check() (errFields []*validator.FormErrorField, err error) { diff --git a/internal/service/content/user_service.go b/internal/service/content/user_service.go index 1d7866f35..d0ebe8754 100644 --- a/internal/service/content/user_service.go +++ b/internal/service/content/user_service.go @@ -518,7 +518,7 @@ func (us *UserService) UserRegisterByEmail(ctx context.Context, registerUserInfo log.Errorf("set default user notification config failed, err: %v", err) } - err = applyRegistrationVerification(userInfo, registerUserInfo.SkipEmailVerification, registrationVerificationActions{ + err = applyRegistrationVerification(userInfo, registerUserInfo.RequireEmailVerification, registrationVerificationActions{ sendActivationEmail: func() error { return us.sendRegistrationActivationEmail(ctx, userInfo) }, @@ -569,10 +569,10 @@ type registrationVerificationActions struct { } func applyRegistrationVerification( - userInfo *entity.User, skipEmailVerification bool, actions registrationVerificationActions, + userInfo *entity.User, requireEmailVerification bool, actions registrationVerificationActions, ) error { userInfo.MailStatus = entity.EmailStatusToBeVerified - if !skipEmailVerification { + if requireEmailVerification { return actions.sendActivationEmail() } diff --git a/internal/service/content/user_service_test.go b/internal/service/content/user_service_test.go index 0d77b383d..d77a1c448 100644 --- a/internal/service/content/user_service_test.go +++ b/internal/service/content/user_service_test.go @@ -29,11 +29,11 @@ import ( ) func TestApplyRegistrationVerification(t *testing.T) { - t.Run("enabled sends activation email and leaves user inactive", func(t *testing.T) { + t.Run("required sends activation email and leaves email pending", func(t *testing.T) { userInfo := &entity.User{} calls := map[string]int{} - err := applyRegistrationVerification(userInfo, false, registrationVerificationActions{ + err := applyRegistrationVerification(userInfo, true, registrationVerificationActions{ sendActivationEmail: func() error { calls["sendActivationEmail"]++ return nil @@ -55,11 +55,11 @@ func TestApplyRegistrationVerification(t *testing.T) { assert.Zero(t, calls["markEmailAvailable"]) }) - t.Run("disabled activates once and marks email available", func(t *testing.T) { + t.Run("not required activates once and marks email available", func(t *testing.T) { userInfo := &entity.User{} calls := map[string]int{} - err := applyRegistrationVerification(userInfo, true, registrationVerificationActions{ + err := applyRegistrationVerification(userInfo, false, registrationVerificationActions{ sendActivationEmail: func() error { calls["sendActivationEmail"]++ return nil @@ -81,11 +81,11 @@ func TestApplyRegistrationVerification(t *testing.T) { assert.Equal(t, 1, calls["markEmailAvailable"]) }) - t.Run("disabled user activation failure falls back to email verification", func(t *testing.T) { + t.Run("not required user activation failure falls back to email verification", func(t *testing.T) { userInfo := &entity.User{} calls := map[string]int{} - err := applyRegistrationVerification(userInfo, true, registrationVerificationActions{ + err := applyRegistrationVerification(userInfo, false, registrationVerificationActions{ sendActivationEmail: func() error { calls["sendActivationEmail"]++ return nil @@ -107,11 +107,11 @@ func TestApplyRegistrationVerification(t *testing.T) { assert.Zero(t, calls["markEmailAvailable"]) }) - t.Run("disabled email status failure falls back to email verification", func(t *testing.T) { + t.Run("not required email status failure falls back to email verification", func(t *testing.T) { userInfo := &entity.User{} calls := map[string]int{} - err := applyRegistrationVerification(userInfo, true, registrationVerificationActions{ + err := applyRegistrationVerification(userInfo, false, registrationVerificationActions{ sendActivationEmail: func() error { calls["sendActivationEmail"]++ return nil @@ -133,11 +133,11 @@ func TestApplyRegistrationVerification(t *testing.T) { assert.Equal(t, 1, calls["markEmailAvailable"]) }) - t.Run("fallback email failure returns before active status", func(t *testing.T) { + t.Run("fallback email failure returns before available email status", func(t *testing.T) { userInfo := &entity.User{} expectedErr := errors.New("email failed") - err := applyRegistrationVerification(userInfo, true, registrationVerificationActions{ + err := applyRegistrationVerification(userInfo, false, registrationVerificationActions{ sendActivationEmail: func() error { return expectedErr }, diff --git a/internal/service/siteinfo/siteinfo_service.go b/internal/service/siteinfo/siteinfo_service.go index 7656a1407..8b32b722e 100644 --- a/internal/service/siteinfo/siteinfo_service.go +++ b/internal/service/siteinfo/siteinfo_service.go @@ -291,17 +291,8 @@ func (s *SiteInfoService) SaveSiteSecurity(ctx context.Context, req *schema.Site // SaveSiteLogin save site legal configuration func (s *SiteInfoService) SaveSiteLogin(ctx context.Context, req *schema.SiteLoginReq) (err error) { - requireEmailVerification := true - if req.RequireEmailVerification.Set { - if !req.RequireEmailVerification.Null { - requireEmailVerification = req.RequireEmailVerification.Value - } - } else { - currentLogin, err := s.GetSiteLogin(ctx) - if err != nil { - return err - } - requireEmailVerification = currentLogin.RequireEmailVerification + if req.RequireEmailVerification == nil { + return errors.BadRequest(reason.RequestFormatError) } loginConfig := &schema.SiteLoginResp{ @@ -309,7 +300,7 @@ func (s *SiteInfoService) SaveSiteLogin(ctx context.Context, req *schema.SiteLog AllowEmailRegistrations: req.AllowEmailRegistrations, AllowPasswordLogin: req.AllowPasswordLogin, AllowEmailDomains: req.AllowEmailDomains, - RequireEmailVerification: requireEmailVerification, + RequireEmailVerification: *req.RequireEmailVerification, } content, _ := json.Marshal(loginConfig) data := &entity.SiteInfo{ diff --git a/internal/service/siteinfo/siteinfo_service_test.go b/internal/service/siteinfo/siteinfo_service_test.go index fc4bbdf70..a3f834a53 100644 --- a/internal/service/siteinfo/siteinfo_service_test.go +++ b/internal/service/siteinfo/siteinfo_service_test.go @@ -28,7 +28,6 @@ import ( "github.com/apache/answer/internal/entity" "github.com/apache/answer/internal/schema" "github.com/apache/answer/internal/service/mock" - "github.com/apache/answer/internal/service/siteinfo_common" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" @@ -37,33 +36,17 @@ import ( func TestSiteInfoService_SaveSiteLoginRequireEmailVerification(t *testing.T) { tests := []struct { name string - currentContent string - requestPayload string - expectGet bool + requireEmail bool expectedRequire bool }{ { - name: "omitted preserves normalized default", - currentContent: `{"allow_new_registrations":true,"allow_email_registrations":true,"allow_password_login":true}`, - requestPayload: `{"allow_new_registrations":true,"allow_email_registrations":true,"allow_password_login":true,"allow_email_domains":[]}`, - expectGet: true, - expectedRequire: true, - }, - { - name: "omitted preserves current false", - currentContent: `{"allow_new_registrations":true,"allow_email_registrations":true,"allow_password_login":true,"require_email_verification":false}`, - requestPayload: `{"allow_new_registrations":true,"allow_email_registrations":true,"allow_password_login":true,"allow_email_domains":[]}`, - expectGet: true, - expectedRequire: false, - }, - { - name: "null normalizes true", - requestPayload: `{"allow_new_registrations":true,"allow_email_registrations":true,"allow_password_login":true,"allow_email_domains":[],"require_email_verification":null}`, + name: "explicit true persists true", + requireEmail: true, expectedRequire: true, }, { name: "explicit false persists false", - requestPayload: `{"allow_new_registrations":true,"allow_email_registrations":true,"allow_password_login":true,"allow_email_domains":[],"require_email_verification":false}`, + requireEmail: false, expectedRequire: false, }, } @@ -74,11 +57,6 @@ func TestSiteInfoService_SaveSiteLoginRequireEmailVerification(t *testing.T) { defer ctl.Finish() repo := mock.NewMockSiteInfoRepo(ctl) - if tt.expectGet { - repo.EXPECT().GetByType(gomock.Any(), constant.SiteTypeLogin). - Return(&entity.SiteInfo{Content: tt.currentContent}, true, nil) - } - var savedContent string repo.EXPECT().SaveByType(gomock.Any(), constant.SiteTypeLogin, gomock.Any()). DoAndReturn(func(_ context.Context, _ string, data *entity.SiteInfo) error { @@ -86,12 +64,15 @@ func TestSiteInfoService_SaveSiteLoginRequireEmailVerification(t *testing.T) { return nil }) - req := &schema.SiteLoginReq{} - require.NoError(t, json.Unmarshal([]byte(tt.requestPayload), req)) - service := &SiteInfoService{ - siteInfoRepo: repo, - siteInfoCommonService: siteinfo_common.NewSiteInfoCommonService(repo), + siteInfoRepo: repo, + } + req := &schema.SiteLoginReq{ + AllowNewRegistrations: true, + AllowEmailRegistrations: true, + AllowPasswordLogin: true, + AllowEmailDomains: []string{}, + RequireEmailVerification: &tt.requireEmail, } require.NoError(t, service.SaveSiteLogin(context.TODO(), req)) assert.NotContains(t, savedContent, `"require_email_verification":null`) @@ -102,3 +83,15 @@ func TestSiteInfoService_SaveSiteLoginRequireEmailVerification(t *testing.T) { }) } } + +func TestSiteInfoService_SaveSiteLoginRequiresEmailVerificationValue(t *testing.T) { + service := &SiteInfoService{} + req := &schema.SiteLoginReq{ + AllowNewRegistrations: true, + AllowEmailRegistrations: true, + AllowPasswordLogin: true, + AllowEmailDomains: []string{}, + } + + require.Error(t, service.SaveSiteLogin(context.TODO(), req)) +} From e1d58ab6357766bbccb3da986bd6eabad8d5c924 Mon Sep 17 00:00:00 2001 From: Artur Iusupov Date: Wed, 17 Jun 2026 12:13:56 +0400 Subject: [PATCH 09/49] feat(notification): add interval for new question emails --- .../notification/new_question_notification.go | 38 +- .../new_question_notification_interval.go | 92 +++ .../new_question_notification_test.go | 652 ++++++++++++++++++ 3 files changed, 766 insertions(+), 16 deletions(-) create mode 100644 internal/service/notification/new_question_notification_interval.go create mode 100644 internal/service/notification/new_question_notification_test.go diff --git a/internal/service/notification/new_question_notification.go b/internal/service/notification/new_question_notification.go index 0a5471873..bd323c7f9 100644 --- a/internal/service/notification/new_question_notification.go +++ b/internal/service/notification/new_question_notification.go @@ -28,7 +28,6 @@ import ( "github.com/apache/answer/internal/base/translator" "github.com/apache/answer/internal/schema" "github.com/apache/answer/pkg/display" - "github.com/apache/answer/pkg/token" "github.com/apache/answer/plugin" "github.com/jinzhu/copier" "github.com/segmentfault/pacman/i18n" @@ -50,27 +49,34 @@ func (ns *ExternalNotificationService) handleNewQuestionNotification(ctx context } log.Debugf("get subscribers %d for question %s", len(subscribers), msg.NewQuestionTemplateRawData.QuestionID) - for _, subscriber := range subscribers { - for _, channel := range subscriber.Channels { - if !channel.Enable { - continue - } - if channel.Key == constant.EmailChannel { - ns.sendNewQuestionNotificationEmail(ctx, subscriber.UserID, &schema.NewQuestionTemplateRawData{ - QuestionTitle: msg.NewQuestionTemplateRawData.QuestionTitle, - QuestionID: msg.NewQuestionTemplateRawData.QuestionID, - UnsubscribeCode: token.GenerateToken(), - Tags: msg.NewQuestionTemplateRawData.Tags, - TagIDs: msg.NewQuestionTemplateRawData.TagIDs, - }) - } - } + interval := newQuestionNotificationEmailSendInterval() + if interval > 0 { + ns.syncNewQuestionNotificationToPlugin(ctx, msg) + ns.sendNewQuestionNotificationEmails(ctx, subscribers, msg.NewQuestionTemplateRawData, interval) + return nil } + ns.sendNewQuestionNotificationEmails(ctx, subscribers, msg.NewQuestionTemplateRawData, interval) ns.syncNewQuestionNotificationToPlugin(ctx, msg) return nil } +func (ns *ExternalNotificationService) sendNewQuestionNotificationEmails( + ctx context.Context, + subscribers []*NewQuestionSubscriber, + rawData *schema.NewQuestionTemplateRawData, + interval time.Duration, +) { + sendNewQuestionNotificationEmailsWithInterval( + ctx, + subscribers, + rawData, + interval, + nil, + ns.sendNewQuestionNotificationEmail, + ) +} + func (ns *ExternalNotificationService) getNewQuestionSubscribers(ctx context.Context, msg *schema.ExternalNotificationMsg) ( subscribers []*NewQuestionSubscriber, err error) { subscribersMapping := make(map[string]*NewQuestionSubscriber) diff --git a/internal/service/notification/new_question_notification_interval.go b/internal/service/notification/new_question_notification_interval.go new file mode 100644 index 000000000..22f17450f --- /dev/null +++ b/internal/service/notification/new_question_notification_interval.go @@ -0,0 +1,92 @@ +/* + * 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 notification + +import ( + "context" + "os" + "strconv" + "strings" + "time" + + "github.com/apache/answer/internal/base/constant" + "github.com/apache/answer/internal/schema" + "github.com/apache/answer/pkg/token" +) + +const newQuestionNotificationEmailSendIntervalEnv = "NEW_QUESTION_NOTIFICATION_EMAIL_SEND_INTERVAL_SECONDS" + +const maxNewQuestionNotificationEmailSendIntervalSeconds = int64(1<<63-1) / int64(time.Second) + +type newQuestionNotificationEmailSleeper func(time.Duration) + +type newQuestionNotificationEmailSender func(context.Context, string, *schema.NewQuestionTemplateRawData) + +func newQuestionNotificationEmailSendInterval() time.Duration { + return parseNewQuestionNotificationEmailSendInterval(os.Getenv(newQuestionNotificationEmailSendIntervalEnv)) +} + +func parseNewQuestionNotificationEmailSendInterval(value string) time.Duration { + value = strings.TrimSpace(value) + if len(value) == 0 { + return 0 + } + seconds, err := strconv.ParseInt(value, 10, 64) + if err != nil || seconds < 0 || seconds > maxNewQuestionNotificationEmailSendIntervalSeconds { + return 0 + } + return time.Duration(seconds) * time.Second +} + +func sendNewQuestionNotificationEmailsWithInterval( + ctx context.Context, + subscribers []*NewQuestionSubscriber, + rawData *schema.NewQuestionTemplateRawData, + interval time.Duration, + sleep newQuestionNotificationEmailSleeper, + send newQuestionNotificationEmailSender, +) { + if rawData == nil || send == nil { + return + } + if sleep == nil { + sleep = time.Sleep + } + + emailAttempts := 0 + for _, subscriber := range subscribers { + for _, channel := range subscriber.Channels { + if !channel.Enable || channel.Key != constant.EmailChannel { + continue + } + if interval > 0 && emailAttempts > 0 { + sleep(interval) + } + send(ctx, subscriber.UserID, &schema.NewQuestionTemplateRawData{ + QuestionTitle: rawData.QuestionTitle, + QuestionID: rawData.QuestionID, + UnsubscribeCode: token.GenerateToken(), + Tags: rawData.Tags, + TagIDs: rawData.TagIDs, + }) + emailAttempts++ + } + } +} diff --git a/internal/service/notification/new_question_notification_test.go b/internal/service/notification/new_question_notification_test.go new file mode 100644 index 000000000..4d1729a2f --- /dev/null +++ b/internal/service/notification/new_question_notification_test.go @@ -0,0 +1,652 @@ +/* + * 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 notification + +import ( + "context" + "encoding/json" + "os" + "reflect" + "testing" + "time" + + "github.com/apache/answer/internal/base/constant" + basedata "github.com/apache/answer/internal/base/data" + "github.com/apache/answer/internal/entity" + "github.com/apache/answer/internal/schema" + "github.com/apache/answer/internal/service/config" + "github.com/apache/answer/internal/service/export" + "github.com/apache/answer/internal/service/mock" + "go.uber.org/mock/gomock" +) + +func TestNewQuestionNotificationEmailSendInterval(t *testing.T) { + tests := []struct { + name string + value string + set bool + want time.Duration + }{ + { + name: "unset", + want: 0, + }, + { + name: "empty", + value: "", + set: true, + want: 0, + }, + { + name: "positive integer", + value: "5", + set: true, + want: 5 * time.Second, + }, + { + name: "positive integer with whitespace", + value: " 5 ", + set: true, + want: 5 * time.Second, + }, + { + name: "invalid", + value: "not-a-number", + set: true, + want: 0, + }, + { + name: "negative", + value: "-1", + set: true, + want: 0, + }, + { + name: "duration overflow", + value: "9223372037", + set: true, + want: 0, + }, + { + name: "parse int overflow", + value: "9223372036854775808", + set: true, + want: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setNewQuestionNotificationEmailSendIntervalEnv(t, tt.value, tt.set) + + got := newQuestionNotificationEmailSendInterval() + if got != tt.want { + t.Fatalf("newQuestionNotificationEmailSendInterval() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSendNewQuestionNotificationEmailsWithInterval(t *testing.T) { + rawData := &schema.NewQuestionTemplateRawData{ + QuestionTitle: "question", + QuestionID: "1", + Tags: []string{"go"}, + TagIDs: []string{"tag-1"}, + } + interval := 3 * time.Second + + tests := []struct { + name string + interval time.Duration + subscribers []*NewQuestionSubscriber + wantSends []string + wantSleeps []time.Duration + wantEvents []string + }{ + { + name: "interval 0", + interval: 0, + subscribers: []*NewQuestionSubscriber{ + newQuestionSubscriber("user-1", newQuestionEmailChannel(true)), + newQuestionSubscriber("user-2", newQuestionEmailChannel(true)), + }, + wantSends: []string{"user-1", "user-2"}, + wantEvents: []string{ + "send:user-1", + "send:user-2", + }, + }, + { + name: "0 enabled email attempts", + interval: interval, + subscribers: []*NewQuestionSubscriber{ + newQuestionSubscriber("user-1", newQuestionEmailChannel(false)), + newQuestionSubscriber("user-2", newQuestionNonEmailChannel(true)), + }, + }, + { + name: "1 enabled email attempt", + interval: interval, + subscribers: []*NewQuestionSubscriber{ + newQuestionSubscriber("user-1", newQuestionEmailChannel(true)), + }, + wantSends: []string{"user-1"}, + wantEvents: []string{ + "send:user-1", + }, + }, + { + name: "N enabled email attempts", + interval: interval, + subscribers: []*NewQuestionSubscriber{ + newQuestionSubscriber("user-1", newQuestionEmailChannel(true)), + newQuestionSubscriber("user-2", newQuestionEmailChannel(true)), + newQuestionSubscriber("user-3", newQuestionEmailChannel(true)), + }, + wantSends: []string{"user-1", "user-2", "user-3"}, + wantSleeps: []time.Duration{interval, interval}, + wantEvents: []string{ + "send:user-1", + "sleep:3s", + "send:user-2", + "sleep:3s", + "send:user-3", + }, + }, + { + name: "disabled email channel does not add delay", + interval: interval, + subscribers: []*NewQuestionSubscriber{ + newQuestionSubscriber("user-1", newQuestionEmailChannel(true)), + newQuestionSubscriber("user-2", newQuestionEmailChannel(false)), + newQuestionSubscriber("user-3", newQuestionEmailChannel(true)), + }, + wantSends: []string{"user-1", "user-3"}, + wantSleeps: []time.Duration{interval}, + wantEvents: []string{ + "send:user-1", + "sleep:3s", + "send:user-3", + }, + }, + { + name: "non-email channel does not add delay", + interval: interval, + subscribers: []*NewQuestionSubscriber{ + newQuestionSubscriber("user-1", newQuestionEmailChannel(true)), + newQuestionSubscriber("user-2", newQuestionNonEmailChannel(true)), + newQuestionSubscriber("user-3", newQuestionEmailChannel(true)), + }, + wantSends: []string{"user-1", "user-3"}, + wantSleeps: []time.Duration{interval}, + wantEvents: []string{ + "send:user-1", + "sleep:3s", + "send:user-3", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var gotEvents []string + var gotSleeps []time.Duration + sleep := func(duration time.Duration) { + gotSleeps = append(gotSleeps, duration) + gotEvents = append(gotEvents, "sleep:"+duration.String()) + } + + var gotSends []string + var gotCodes []string + send := func(_ context.Context, userID string, rawData *schema.NewQuestionTemplateRawData) { + gotSends = append(gotSends, userID) + gotEvents = append(gotEvents, "send:"+userID) + if rawData.UnsubscribeCode == "" { + t.Fatalf("expected unsubscribe code for %s", userID) + } + gotCodes = append(gotCodes, rawData.UnsubscribeCode) + } + + sendNewQuestionNotificationEmailsWithInterval( + context.Background(), tt.subscribers, rawData, tt.interval, sleep, send) + + if !reflect.DeepEqual(gotSends, tt.wantSends) { + t.Fatalf("send calls = %v, want %v", gotSends, tt.wantSends) + } + if !reflect.DeepEqual(gotSleeps, tt.wantSleeps) { + t.Fatalf("sleep calls = %v, want %v", gotSleeps, tt.wantSleeps) + } + if !reflect.DeepEqual(gotEvents, tt.wantEvents) { + t.Fatalf("events = %v, want %v", gotEvents, tt.wantEvents) + } + assertUniqueNewQuestionUnsubscribeCodes(t, gotCodes) + }) + } +} + +func TestHandleNewQuestionNotificationSendsEmailsThroughFanOut(t *testing.T) { + setNewQuestionNotificationEmailSendIntervalEnv(t, "0", true) + + cache, cleanup, err := basedata.NewCache(&basedata.CacheConf{}) + if err != nil { + t.Fatalf("new cache: %v", err) + } + t.Cleanup(cleanup) + + ctrl := gomock.NewController(t) + siteInfoService := mock.NewMockSiteInfoCommonService(ctrl) + siteInfoService.EXPECT().GetSiteGeneral(gomock.Any()).Return(&schema.SiteGeneralResp{ + Name: "Answer", + SiteUrl: "https://answer.test", + ContactEmail: "support@answer.test", + }, nil).AnyTimes() + siteInfoService.EXPECT().GetSiteSeo(gomock.Any()).Return(&schema.SiteSeoResp{ + Permalink: constant.PermalinkQuestionIDAndTitle, + }, nil).AnyTimes() + + emailRepo := &newQuestionNotificationTestEmailRepo{ + codesByUserID: make(map[string][]string), + } + notificationConfigRepo := &newQuestionNotificationTestUserNotificationConfigRepo{ + followedTagConfigs: map[string]*entity.UserNotificationConfig{ + "tag-user": newQuestionNotificationConfig( + "tag-user", constant.AllNewQuestionForFollowingTagsSource, true), + "dup-user": newQuestionNotificationConfig( + "dup-user", constant.AllNewQuestionForFollowingTagsSource, true), + "author": newQuestionNotificationConfig( + "author", constant.AllNewQuestionForFollowingTagsSource, true), + }, + allQuestionConfigs: []*entity.UserNotificationConfig{ + newQuestionNotificationConfig("all-user", constant.AllNewQuestionSource, true), + newQuestionNotificationConfig("dup-user", constant.AllNewQuestionSource, true), + newQuestionNotificationConfig("author", constant.AllNewQuestionSource, true), + }, + } + service := &ExternalNotificationService{ + data: &basedata.Data{ + Cache: cache, + }, + userNotificationConfigRepo: notificationConfigRepo, + followRepo: &newQuestionNotificationTestFollowRepo{ + followersByObjectID: map[string][]string{ + "tag-1": {"tag-user", "dup-user", "author"}, + }, + }, + emailService: export.NewEmailService( + config.NewConfigService(newQuestionNotificationTestConfigRepo{}), + emailRepo, + siteInfoService, + ), + userRepo: &newQuestionNotificationTestUserRepo{ + users: map[string]*entity.User{ + "tag-user": newQuestionNotificationTestUser("tag-user"), + "dup-user": newQuestionNotificationTestUser("dup-user"), + "all-user": newQuestionNotificationTestUser("all-user"), + "author": newQuestionNotificationTestUser("author"), + }, + }, + siteInfoService: siteInfoService, + } + + err = service.handleNewQuestionNotification(context.Background(), &schema.ExternalNotificationMsg{ + NewQuestionTemplateRawData: &schema.NewQuestionTemplateRawData{ + QuestionTitle: "New question", + QuestionID: "1", + QuestionAuthorUserID: "author", + Tags: []string{"go"}, + TagIDs: []string{"tag-1"}, + }, + }) + if err != nil { + t.Fatalf("handleNewQuestionNotification() error = %v", err) + } + + wantUsers := []string{"all-user", "dup-user", "tag-user"} + assertStringSet(t, emailRepo.userIDs(), wantUsers) + for _, userID := range wantUsers { + codes := emailRepo.codesByUserID[userID] + if len(codes) != 1 { + t.Fatalf("saved codes for %s = %v, want exactly one code", userID, codes) + } + if codes[0] == "" { + t.Fatalf("saved empty code for %s", userID) + } + } + if codes := emailRepo.codesByUserID["author"]; len(codes) > 0 { + t.Fatalf("question author received notification codes: %v", codes) + } +} + +func assertUniqueNewQuestionUnsubscribeCodes(t *testing.T, codes []string) { + t.Helper() + + seen := make(map[string]bool) + for _, code := range codes { + if seen[code] { + t.Fatalf("duplicate unsubscribe code %q", code) + } + seen[code] = true + } +} + +func setNewQuestionNotificationEmailSendIntervalEnv(t *testing.T, value string, set bool) { + t.Helper() + + oldValue, oldSet := os.LookupEnv(newQuestionNotificationEmailSendIntervalEnv) + if set { + if err := os.Setenv(newQuestionNotificationEmailSendIntervalEnv, value); err != nil { + t.Fatalf("set env: %v", err) + } + } else { + if err := os.Unsetenv(newQuestionNotificationEmailSendIntervalEnv); err != nil { + t.Fatalf("unset env: %v", err) + } + } + t.Cleanup(func() { + if oldSet { + _ = os.Setenv(newQuestionNotificationEmailSendIntervalEnv, oldValue) + } else { + _ = os.Unsetenv(newQuestionNotificationEmailSendIntervalEnv) + } + }) +} + +func newQuestionSubscriber(userID string, channels ...*schema.NotificationChannelConfig) *NewQuestionSubscriber { + return &NewQuestionSubscriber{ + UserID: userID, + Channels: channels, + } +} + +func newQuestionEmailChannel(enable bool) *schema.NotificationChannelConfig { + return &schema.NotificationChannelConfig{ + Key: constant.EmailChannel, + Enable: enable, + } +} + +func newQuestionNonEmailChannel(enable bool) *schema.NotificationChannelConfig { + return &schema.NotificationChannelConfig{ + Key: constant.NotificationChannelKey("inbox"), + Enable: enable, + } +} + +func newQuestionNotificationConfig( + userID string, source constant.NotificationSource, emailEnabled bool) *entity.UserNotificationConfig { + channels := schema.NotificationChannels{ + newQuestionEmailChannel(emailEnabled), + } + return &entity.UserNotificationConfig{ + UserID: userID, + Source: string(source), + Channels: channels.ToJsonString(), + Enabled: emailEnabled, + } +} + +func newQuestionNotificationTestUser(userID string) *entity.User { + return &entity.User{ + ID: userID, + Username: userID, + DisplayName: userID, + EMail: userID + "@example.com", + Status: entity.UserStatusAvailable, + MailStatus: entity.EmailStatusAvailable, + } +} + +func assertStringSet(t *testing.T, got, want []string) { + t.Helper() + + gotSet := make(map[string]bool) + for _, value := range got { + gotSet[value] = true + } + wantSet := make(map[string]bool) + for _, value := range want { + wantSet[value] = true + } + if !reflect.DeepEqual(gotSet, wantSet) { + t.Fatalf("values = %v, want %v", got, want) + } +} + +type newQuestionNotificationTestFollowRepo struct { + followersByObjectID map[string][]string +} + +func (r *newQuestionNotificationTestFollowRepo) GetFollowIDs( + context.Context, string, string) ([]string, error) { + return nil, nil +} + +func (r *newQuestionNotificationTestFollowRepo) GetFollowAmount(context.Context, string) (int, error) { + return 0, nil +} + +func (r *newQuestionNotificationTestFollowRepo) GetFollowUserIDs( + _ context.Context, objectID string) ([]string, error) { + return r.followersByObjectID[objectID], nil +} + +func (r *newQuestionNotificationTestFollowRepo) IsFollowed(context.Context, string, string) (bool, error) { + return false, nil +} + +func (r *newQuestionNotificationTestFollowRepo) MigrateFollowers( + context.Context, string, string, string) error { + return nil +} + +type newQuestionNotificationTestUserNotificationConfigRepo struct { + followedTagConfigs map[string]*entity.UserNotificationConfig + allQuestionConfigs []*entity.UserNotificationConfig +} + +func (r *newQuestionNotificationTestUserNotificationConfigRepo) Add( + context.Context, []string, string, string) error { + return nil +} + +func (r *newQuestionNotificationTestUserNotificationConfigRepo) Save( + context.Context, *entity.UserNotificationConfig) error { + return nil +} + +func (r *newQuestionNotificationTestUserNotificationConfigRepo) GetByUserID( + context.Context, string) ([]*entity.UserNotificationConfig, error) { + return nil, nil +} + +func (r *newQuestionNotificationTestUserNotificationConfigRepo) GetBySource( + _ context.Context, source constant.NotificationSource) ([]*entity.UserNotificationConfig, error) { + if source == constant.AllNewQuestionSource { + return r.allQuestionConfigs, nil + } + return nil, nil +} + +func (r *newQuestionNotificationTestUserNotificationConfigRepo) GetByUserIDAndSource( + context.Context, string, constant.NotificationSource) (*entity.UserNotificationConfig, bool, error) { + return nil, false, nil +} + +func (r *newQuestionNotificationTestUserNotificationConfigRepo) GetByUsersAndSource( + _ context.Context, userIDs []string, source constant.NotificationSource) ( + []*entity.UserNotificationConfig, error) { + if source != constant.AllNewQuestionForFollowingTagsSource { + return nil, nil + } + configs := make([]*entity.UserNotificationConfig, 0, len(userIDs)) + for _, userID := range userIDs { + if config, ok := r.followedTagConfigs[userID]; ok { + configs = append(configs, config) + } + } + return configs, nil +} + +type newQuestionNotificationTestUserRepo struct { + users map[string]*entity.User +} + +func (r *newQuestionNotificationTestUserRepo) AddUser(context.Context, *entity.User) error { + return nil +} + +func (r *newQuestionNotificationTestUserRepo) IncreaseAnswerCount(context.Context, string, int) error { + return nil +} + +func (r *newQuestionNotificationTestUserRepo) IncreaseQuestionCount(context.Context, string, int) error { + return nil +} + +func (r *newQuestionNotificationTestUserRepo) UpdateQuestionCount(context.Context, string, int64) error { + return nil +} + +func (r *newQuestionNotificationTestUserRepo) UpdateAnswerCount(context.Context, string, int) error { + return nil +} + +func (r *newQuestionNotificationTestUserRepo) UpdateLastLoginDate(context.Context, string) error { + return nil +} + +func (r *newQuestionNotificationTestUserRepo) UpdateEmailStatus(context.Context, string, int) error { + return nil +} + +func (r *newQuestionNotificationTestUserRepo) UpdateNoticeStatus(context.Context, string, int) error { + return nil +} + +func (r *newQuestionNotificationTestUserRepo) UpdateEmail(context.Context, string, string) error { + return nil +} + +func (r *newQuestionNotificationTestUserRepo) UpdateUserInterface( + context.Context, string, string, string) error { + return nil +} + +func (r *newQuestionNotificationTestUserRepo) UpdatePass(context.Context, string, string) error { + return nil +} + +func (r *newQuestionNotificationTestUserRepo) UpdateInfo(context.Context, *entity.User) error { + return nil +} + +func (r *newQuestionNotificationTestUserRepo) UpdateUserProfile(context.Context, *entity.User) error { + return nil +} + +func (r *newQuestionNotificationTestUserRepo) GetByUserID( + _ context.Context, userID string) (*entity.User, bool, error) { + user, ok := r.users[userID] + return user, ok, nil +} + +func (r *newQuestionNotificationTestUserRepo) BatchGetByID( + context.Context, []string) ([]*entity.User, error) { + return nil, nil +} + +func (r *newQuestionNotificationTestUserRepo) GetByUsername( + context.Context, string) (*entity.User, bool, error) { + return nil, false, nil +} + +func (r *newQuestionNotificationTestUserRepo) GetByUsernames( + context.Context, []string) ([]*entity.User, error) { + return nil, nil +} + +func (r *newQuestionNotificationTestUserRepo) GetByEmail( + context.Context, string) (*entity.User, bool, error) { + return nil, false, nil +} + +func (r *newQuestionNotificationTestUserRepo) GetUserCount(context.Context) (int64, error) { + return 0, nil +} + +func (r *newQuestionNotificationTestUserRepo) SearchUserListByName( + context.Context, string, int, bool) ([]*entity.User, error) { + return nil, nil +} + +func (r *newQuestionNotificationTestUserRepo) IsAvatarFileUsed(context.Context, string) (bool, error) { + return false, nil +} + +type newQuestionNotificationTestConfigRepo struct{} + +func (newQuestionNotificationTestConfigRepo) GetConfigByID( + context.Context, int) (*entity.Config, error) { + return nil, nil +} + +func (newQuestionNotificationTestConfigRepo) GetConfigByKey( + context.Context, string) (*entity.Config, error) { + config := export.EmailConfig{ + FromEmail: "noreply@answer.test", + FromName: "Answer", + } + value, _ := json.Marshal(config) + return &entity.Config{ + Value: string(value), + }, nil +} + +func (newQuestionNotificationTestConfigRepo) GetConfigByKeyFromDB( + context.Context, string) (*entity.Config, error) { + return nil, nil +} + +func (newQuestionNotificationTestConfigRepo) UpdateConfig(context.Context, string, string) error { + return nil +} + +type newQuestionNotificationTestEmailRepo struct { + codesByUserID map[string][]string +} + +func (r *newQuestionNotificationTestEmailRepo) SetCode( + _ context.Context, userID, code, _ string, _ time.Duration) error { + r.codesByUserID[userID] = append(r.codesByUserID[userID], code) + return nil +} + +func (r *newQuestionNotificationTestEmailRepo) VerifyCode(context.Context, string) (string, error) { + return "", nil +} + +func (r *newQuestionNotificationTestEmailRepo) userIDs() []string { + userIDs := make([]string, 0, len(r.codesByUserID)) + for userID := range r.codesByUserID { + userIDs = append(userIDs, userID) + } + return userIDs +} From d10e6aad701d857141da9fcebe35c99be5dc38ac Mon Sep 17 00:00:00 2001 From: Artur Iusupov Date: Sat, 27 Jun 2026 23:35:28 +0400 Subject: [PATCH 10/49] fix(notification): move new question email throttling to worker --- .../notification/external_notification.go | 5 + .../notification/new_question_email_worker.go | 308 ++++++++++ .../new_question_email_worker_test.go | 558 ++++++++++++++++++ .../notification/new_question_notification.go | 48 +- .../new_question_notification_interval.go | 47 +- .../new_question_notification_test.go | 472 ++++++++++----- 6 files changed, 1227 insertions(+), 211 deletions(-) create mode 100644 internal/service/notification/new_question_email_worker.go create mode 100644 internal/service/notification/new_question_email_worker_test.go diff --git a/internal/service/notification/external_notification.go b/internal/service/notification/external_notification.go index 425a8c2bb..5282cab0f 100644 --- a/internal/service/notification/external_notification.go +++ b/internal/service/notification/external_notification.go @@ -45,6 +45,7 @@ type ExternalNotificationService struct { notificationQueueService noticequeue.ExternalService userExternalLoginRepo user_external_login.UserExternalLoginRepo siteInfoService siteinfo_common.SiteInfoCommonService + newQuestionEmailWorker *newQuestionEmailWorker } func NewExternalNotificationService( @@ -67,6 +68,10 @@ func NewExternalNotificationService( userExternalLoginRepo: userExternalLoginRepo, siteInfoService: siteInfoService, } + n.newQuestionEmailWorker = newQuestionEmailWorkerWithDefaults( + newQuestionNotificationEmailSendInterval, + n.sendNewQuestionNotificationEmail, + ) notificationQueueService.RegisterHandler(n.Handler) return n } diff --git a/internal/service/notification/new_question_email_worker.go b/internal/service/notification/new_question_email_worker.go new file mode 100644 index 000000000..be3e6504d --- /dev/null +++ b/internal/service/notification/new_question_email_worker.go @@ -0,0 +1,308 @@ +/* + * 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 notification + +import ( + "context" + "sync" + "time" + + "github.com/apache/answer/internal/schema" + "github.com/apache/answer/pkg/token" + "github.com/segmentfault/pacman/log" +) + +const newQuestionEmailWorkerQueueSize = 128 + +type newQuestionEmailTask struct { + UserIDs []string + QuestionTitle string + QuestionID string + Tags []string + TagIDs []string +} + +type newQuestionEmailIntervalProvider func() time.Duration + +type newQuestionEmailTimer interface { + C() <-chan time.Time + Stop() +} + +type newQuestionEmailTimerFactory func(time.Duration) newQuestionEmailTimer + +type newQuestionEmailWorker struct { + tasks chan newQuestionEmailTask + send newQuestionNotificationEmailSender + interval newQuestionEmailIntervalProvider + timerFactory newQuestionEmailTimerFactory + ctx context.Context + cancel context.CancelFunc + mu sync.RWMutex + closed bool + wg sync.WaitGroup +} + +func newQuestionEmailWorkerWithDefaults( + interval newQuestionEmailIntervalProvider, + send newQuestionNotificationEmailSender, +) *newQuestionEmailWorker { + return newQuestionEmailWorkerWithBuffer( + interval, + send, + newRealNewQuestionEmailTimer, + newQuestionEmailWorkerQueueSize, + ) +} + +func newQuestionEmailWorkerWithBuffer( + interval newQuestionEmailIntervalProvider, + send newQuestionNotificationEmailSender, + timerFactory newQuestionEmailTimerFactory, + bufferSize int, +) *newQuestionEmailWorker { + if interval == nil { + interval = newQuestionNotificationEmailSendInterval + } + if timerFactory == nil { + timerFactory = newRealNewQuestionEmailTimer + } + ctx, cancel := context.WithCancel(context.Background()) + w := &newQuestionEmailWorker{ + tasks: make(chan newQuestionEmailTask, bufferSize), + send: send, + interval: interval, + timerFactory: timerFactory, + ctx: ctx, + cancel: cancel, + } + w.wg.Add(1) + go w.run() + return w +} + +func (w *newQuestionEmailWorker) TryEnqueue(task newQuestionEmailTask) bool { + if w == nil { + log.Warnf("[new_question_email] worker is nil, dropping new question email task") + return false + } + + task = copyNewQuestionEmailTask(task) + + w.mu.RLock() + defer w.mu.RUnlock() + + if w.closed { + log.Warnf("[new_question_email] worker is closed, dropping new question email task for question %s", task.QuestionID) + return false + } + + if w.ctx == nil { + log.Warnf("[new_question_email] worker context is nil, dropping new question email task for question %s", task.QuestionID) + return false + } + + select { + case <-w.ctx.Done(): + log.Warnf("[new_question_email] worker is canceled, dropping new question email task for question %s", task.QuestionID) + return false + default: + } + + select { + case w.tasks <- task: + log.Debugf("[new_question_email] enqueued task for question %s to %d users", task.QuestionID, len(task.UserIDs)) + return true + case <-w.ctx.Done(): + log.Warnf("[new_question_email] worker canceled while enqueueing task for question %s", task.QuestionID) + return false + default: + log.Warnf("[new_question_email] queue is full, dropping new question email task for question %s", task.QuestionID) + return false + } +} + +func (w *newQuestionEmailWorker) Close() { + if w == nil { + return + } + + w.mu.Lock() + if w.closed { + w.mu.Unlock() + return + } + w.closed = true + if w.cancel != nil { + w.cancel() + } + w.mu.Unlock() + + w.wg.Wait() + if dropped := w.dropPendingTasks(); dropped > 0 { + log.Warnf("[new_question_email] dropped %d pending tasks during shutdown", dropped) + } + log.Infof("[new_question_email] worker closed") +} + +func (w *newQuestionEmailWorker) run() { + defer w.wg.Done() + + emailAttemptSent := false + for { + if w.ctx.Err() != nil { + return + } + + select { + case <-w.ctx.Done(): + return + case task := <-w.tasks: + if w.ctx.Err() != nil { + return + } + if !w.processTask(task, &emailAttemptSent) { + return + } + } + } +} + +func (w *newQuestionEmailWorker) processTask(task newQuestionEmailTask, emailAttemptSent *bool) bool { + for _, userID := range task.UserIDs { + if w.ctx.Err() != nil { + return false + } + if *emailAttemptSent { + interval := w.interval() + if interval > 0 && !waitNewQuestionEmailInterval(w.ctx, interval, w.timerFactory) { + return false + } + } + if w.ctx.Err() != nil { + return false + } + if w.send == nil { + log.Errorf("[new_question_email] sender is nil, dropping email attempt for user %s question %s", userID, task.QuestionID) + *emailAttemptSent = true + continue + } + w.send(w.ctx, userID, task.newRawData()) + *emailAttemptSent = true + } + return true +} + +func (w *newQuestionEmailWorker) dropPendingTasks() int { + dropped := 0 + for { + select { + case <-w.tasks: + dropped++ + default: + return dropped + } + } +} + +func waitNewQuestionEmailInterval( + ctx context.Context, + interval time.Duration, + timerFactory newQuestionEmailTimerFactory, +) bool { + if interval <= 0 { + return true + } + if timerFactory == nil { + timerFactory = newRealNewQuestionEmailTimer + } + timer := timerFactory(interval) + defer timer.Stop() + + select { + case <-timer.C(): + return true + case <-ctx.Done(): + return false + } +} + +func (task newQuestionEmailTask) newRawData() *schema.NewQuestionTemplateRawData { + return &schema.NewQuestionTemplateRawData{ + QuestionTitle: task.QuestionTitle, + QuestionID: task.QuestionID, + UnsubscribeCode: token.GenerateToken(), + Tags: copyStringSlice(task.Tags), + TagIDs: copyStringSlice(task.TagIDs), + } +} + +func newQuestionEmailTaskFromRawData( + userIDs []string, + rawData *schema.NewQuestionTemplateRawData, +) newQuestionEmailTask { + if rawData == nil { + return newQuestionEmailTask{UserIDs: copyStringSlice(userIDs)} + } + return newQuestionEmailTask{ + UserIDs: copyStringSlice(userIDs), + QuestionTitle: rawData.QuestionTitle, + QuestionID: rawData.QuestionID, + Tags: copyStringSlice(rawData.Tags), + TagIDs: copyStringSlice(rawData.TagIDs), + } +} + +func copyNewQuestionEmailTask(task newQuestionEmailTask) newQuestionEmailTask { + task.UserIDs = copyStringSlice(task.UserIDs) + task.Tags = copyStringSlice(task.Tags) + task.TagIDs = copyStringSlice(task.TagIDs) + return task +} + +func copyStringSlice(values []string) []string { + if values == nil { + return nil + } + copied := make([]string, len(values)) + copy(copied, values) + return copied +} + +type realNewQuestionEmailTimer struct { + timer *time.Timer +} + +func newRealNewQuestionEmailTimer(interval time.Duration) newQuestionEmailTimer { + return &realNewQuestionEmailTimer{timer: time.NewTimer(interval)} +} + +func (t *realNewQuestionEmailTimer) C() <-chan time.Time { + return t.timer.C +} + +func (t *realNewQuestionEmailTimer) Stop() { + if !t.timer.Stop() { + select { + case <-t.timer.C: + default: + } + } +} diff --git a/internal/service/notification/new_question_email_worker_test.go b/internal/service/notification/new_question_email_worker_test.go new file mode 100644 index 000000000..302de4eb9 --- /dev/null +++ b/internal/service/notification/new_question_email_worker_test.go @@ -0,0 +1,558 @@ +/* + * 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 notification + +import ( + "context" + "reflect" + "runtime" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/apache/answer/internal/schema" +) + +func TestNewQuestionEmailWorkerDelaysBetweenAttempts(t *testing.T) { + timerFactory := newFakeNewQuestionEmailTimerFactory() + sendCh := make(chan newQuestionEmailSendEvent, 2) + worker := newQuestionEmailWorkerWithBuffer( + func() time.Duration { return 3 * time.Second }, + newQuestionEmailSendRecorder(sendCh), + timerFactory.New, + 2, + ) + defer worker.Close() + + if !worker.TryEnqueue(newQuestionEmailWorkerTask("question-1", "user-1", "user-2")) { + t.Fatalf("TryEnqueue() = false, want true") + } + + first := receiveNewQuestionEmailSend(t, sendCh) + if first.userID != "user-1" { + t.Fatalf("first send user = %s, want user-1", first.userID) + } + timer := timerFactory.WaitForTimer(t) + assertNoNewQuestionEmailSend(t, sendCh) + timer.Fire() + + second := receiveNewQuestionEmailSend(t, sendCh) + if second.userID != "user-2" { + t.Fatalf("second send user = %s, want user-2", second.userID) + } + assertUniqueNewQuestionUnsubscribeCodes(t, []string{ + first.rawData.UnsubscribeCode, + second.rawData.UnsubscribeCode, + }) + if got := timerFactory.Durations(); !reflect.DeepEqual(got, []time.Duration{3 * time.Second}) { + t.Fatalf("timer durations = %v, want [3s]", got) + } +} + +func TestNewQuestionEmailWorkerDelayContinuesAcrossTaskBoundaries(t *testing.T) { + timerFactory := newFakeNewQuestionEmailTimerFactory() + sendCh := make(chan newQuestionEmailSendEvent, 2) + worker := newQuestionEmailWorkerWithBuffer( + func() time.Duration { return 5 * time.Second }, + newQuestionEmailSendRecorder(sendCh), + timerFactory.New, + 2, + ) + defer worker.Close() + + if !worker.TryEnqueue(newQuestionEmailWorkerTask("question-1", "user-1")) { + t.Fatalf("TryEnqueue() task 1 = false, want true") + } + first := receiveNewQuestionEmailSend(t, sendCh) + if first.userID != "user-1" { + t.Fatalf("first send user = %s, want user-1", first.userID) + } + + if !worker.TryEnqueue(newQuestionEmailWorkerTask("question-2", "user-2")) { + t.Fatalf("TryEnqueue() task 2 = false, want true") + } + timer := timerFactory.WaitForTimer(t) + assertNoNewQuestionEmailSend(t, sendCh) + timer.Fire() + + second := receiveNewQuestionEmailSend(t, sendCh) + if second.userID != "user-2" { + t.Fatalf("second send user = %s, want user-2", second.userID) + } +} + +func TestNewQuestionEmailWorkerZeroIntervalSendsWithoutTimers(t *testing.T) { + var timerCount int + sendCh := make(chan newQuestionEmailSendEvent, 3) + worker := newQuestionEmailWorkerWithBuffer( + func() time.Duration { return 0 }, + newQuestionEmailSendRecorder(sendCh), + func(time.Duration) newQuestionEmailTimer { + timerCount++ + return newFakeNewQuestionEmailTimer() + }, + 2, + ) + defer worker.Close() + + if !worker.TryEnqueue(newQuestionEmailWorkerTask("question-1", "user-1", "user-2", "user-3")) { + t.Fatalf("TryEnqueue() = false, want true") + } + + gotUsers := []string{ + receiveNewQuestionEmailSend(t, sendCh).userID, + receiveNewQuestionEmailSend(t, sendCh).userID, + receiveNewQuestionEmailSend(t, sendCh).userID, + } + if !reflect.DeepEqual(gotUsers, []string{"user-1", "user-2", "user-3"}) { + t.Fatalf("send users = %v, want [user-1 user-2 user-3]", gotUsers) + } + if timerCount != 0 { + t.Fatalf("timer count = %d, want 0", timerCount) + } +} + +func TestNewQuestionEmailWorkerCloseCancelsPendingWaitAndDropsQueuedTasks(t *testing.T) { + timerFactory := newFakeNewQuestionEmailTimerFactory() + sendCh := make(chan newQuestionEmailSendEvent, 3) + worker := newQuestionEmailWorkerWithBuffer( + func() time.Duration { return time.Hour }, + newQuestionEmailSendRecorder(sendCh), + timerFactory.New, + 2, + ) + + if !worker.TryEnqueue(newQuestionEmailWorkerTask("question-1", "user-1", "user-2")) { + t.Fatalf("TryEnqueue() task 1 = false, want true") + } + if !worker.TryEnqueue(newQuestionEmailWorkerTask("question-2", "user-3")) { + t.Fatalf("TryEnqueue() task 2 = false, want true") + } + + first := receiveNewQuestionEmailSend(t, sendCh) + if first.userID != "user-1" { + t.Fatalf("first send user = %s, want user-1", first.userID) + } + timer := timerFactory.WaitForTimer(t) + + worker.Close() + timer.AssertStopped(t) + assertNoNewQuestionEmailSend(t, sendCh) + if got := len(worker.tasks); got != 0 { + t.Fatalf("pending tasks after Close() = %d, want 0", got) + } + if worker.TryEnqueue(newQuestionEmailWorkerTask("question-3", "user-4")) { + t.Fatalf("TryEnqueue() after Close() = true, want false") + } +} + +func TestNewQuestionEmailWorkerProcessesSerially(t *testing.T) { + entered := make(chan string, 2) + releaseFirst := make(chan struct{}) + worker := newQuestionEmailWorkerWithBuffer( + func() time.Duration { return 0 }, + func(_ context.Context, userID string, _ *schema.NewQuestionTemplateRawData) { + entered <- userID + if userID == "user-1" { + <-releaseFirst + } + }, + nil, + 2, + ) + defer worker.Close() + + if !worker.TryEnqueue(newQuestionEmailWorkerTask("question-1", "user-1", "user-2")) { + t.Fatalf("TryEnqueue() = false, want true") + } + if got := receiveString(t, entered); got != "user-1" { + t.Fatalf("first send user = %s, want user-1", got) + } + assertNoString(t, entered) + + close(releaseFirst) + if got := receiveString(t, entered); got != "user-2" { + t.Fatalf("second send user = %s, want user-2", got) + } +} + +func TestNewQuestionEmailWorkerBuildsFreshRawDataPerAttempt(t *testing.T) { + sendCh := make(chan newQuestionEmailSendEvent, 2) + worker := newQuestionEmailWorkerWithBuffer( + func() time.Duration { return 0 }, + func(_ context.Context, userID string, rawData *schema.NewQuestionTemplateRawData) { + if rawData.QuestionAuthorUserID != "" { + t.Errorf("QuestionAuthorUserID = %q, want empty", rawData.QuestionAuthorUserID) + } + if userID == "user-1" { + rawData.Tags[0] = "mutated" + rawData.TagIDs[0] = "mutated" + } + sendCh <- newQuestionEmailSendEvent{userID: userID, rawData: rawData} + }, + nil, + 2, + ) + defer worker.Close() + + if !worker.TryEnqueue(newQuestionEmailTask{ + UserIDs: []string{"user-1", "user-2"}, + QuestionTitle: "Question", + QuestionID: "question-1", + Tags: []string{"go"}, + TagIDs: []string{"tag-1"}, + }) { + t.Fatalf("TryEnqueue() = false, want true") + } + + first := receiveNewQuestionEmailSend(t, sendCh) + second := receiveNewQuestionEmailSend(t, sendCh) + if first.rawData.UnsubscribeCode == "" || second.rawData.UnsubscribeCode == "" { + t.Fatalf("unsubscribe codes must be non-empty: %q %q", + first.rawData.UnsubscribeCode, second.rawData.UnsubscribeCode) + } + if first.rawData.UnsubscribeCode == second.rawData.UnsubscribeCode { + t.Fatalf("unsubscribe codes must be unique, both were %q", first.rawData.UnsubscribeCode) + } + if !reflect.DeepEqual(second.rawData.Tags, []string{"go"}) || + !reflect.DeepEqual(second.rawData.TagIDs, []string{"tag-1"}) { + t.Fatalf("second raw data tags = %v/%v, want original values", + second.rawData.Tags, second.rawData.TagIDs) + } +} + +func TestNewQuestionEmailWorkerTryEnqueueCopiesTaskAndFailsFast(t *testing.T) { + worker := newUnstartedNewQuestionEmailWorkerForTest(1) + task := newQuestionEmailTask{ + UserIDs: []string{"user-1"}, + QuestionTitle: "Question", + QuestionID: "question-1", + Tags: []string{"go"}, + TagIDs: []string{"tag-1"}, + } + if !worker.TryEnqueue(task) { + t.Fatalf("TryEnqueue() = false, want true") + } + task.UserIDs[0] = "mutated-user" + task.Tags[0] = "mutated-tag" + task.TagIDs[0] = "mutated-tag-id" + + queuedTask := <-worker.tasks + if !reflect.DeepEqual(queuedTask.UserIDs, []string{"user-1"}) || + !reflect.DeepEqual(queuedTask.Tags, []string{"go"}) || + !reflect.DeepEqual(queuedTask.TagIDs, []string{"tag-1"}) { + t.Fatalf("queued task was mutated: %+v", queuedTask) + } + + if !worker.TryEnqueue(newQuestionEmailWorkerTask("question-2", "user-2")) { + t.Fatalf("TryEnqueue() refill = false, want true") + } + if worker.TryEnqueue(newQuestionEmailWorkerTask("question-3", "user-3")) { + t.Fatalf("TryEnqueue() with full queue = true, want false") + } + + worker.Close() + if worker.TryEnqueue(newQuestionEmailWorkerTask("question-4", "user-4")) { + t.Fatalf("TryEnqueue() after Close() = true, want false") + } + + canceledWorker := newUnstartedNewQuestionEmailWorkerForTest(1) + canceledWorker.cancel() + if canceledWorker.TryEnqueue(newQuestionEmailWorkerTask("question-5", "user-5")) { + t.Fatalf("TryEnqueue() after cancel = true, want false") + } +} + +func TestNewQuestionEmailWorkerTryEnqueueConcurrentClose(t *testing.T) { + const ( + iterations = 100 + senders = 32 + ) + + for iteration := 0; iteration < iterations; iteration++ { + worker := newUnstartedNewQuestionEmailWorkerForTest(1) + if !worker.TryEnqueue(newQuestionEmailWorkerTask("already-queued", "queued-user")) { + t.Fatalf("iteration %d: pre-fill TryEnqueue() = false, want true", iteration) + } + + start := make(chan struct{}) + ready := make(chan struct{}, senders) + panicCh := make(chan any, senders) + var closeObserved atomic.Bool + var acceptedAfterCloseObserved atomic.Int64 + var wg sync.WaitGroup + + for sender := 0; sender < senders; sender++ { + wg.Add(1) + go func(sender int) { + defer wg.Done() + defer func() { + if recovered := recover(); recovered != nil { + panicCh <- recovered + } + }() + + ready <- struct{}{} + <-start + for { + accepted := worker.TryEnqueue(newQuestionEmailWorkerTask("question", "user")) + if accepted && closeObserved.Load() { + acceptedAfterCloseObserved.Add(1) + } + if closeObserved.Load() { + return + } + runtime.Gosched() + } + }(sender) + } + for sender := 0; sender < senders; sender++ { + <-ready + } + + closeDoneObserved := make(chan struct{}) + go func() { + <-worker.ctx.Done() + closeObserved.Store(true) + close(closeDoneObserved) + }() + + close(start) + runtime.Gosched() + + closeDone := make(chan struct{}) + go func() { + worker.Close() + close(closeDone) + }() + + select { + case <-closeDone: + case <-time.After(time.Second): + t.Fatalf("iteration %d: Close() did not return", iteration) + } + select { + case <-closeDoneObserved: + case <-time.After(time.Second): + t.Fatalf("iteration %d: close was not observed", iteration) + } + + wgDone := make(chan struct{}) + go func() { + wg.Wait() + close(wgDone) + }() + select { + case <-wgDone: + case <-time.After(time.Second): + t.Fatalf("iteration %d: TryEnqueue goroutines did not return", iteration) + } + + select { + case recovered := <-panicCh: + t.Fatalf("iteration %d: TryEnqueue panicked during Close(): %v", iteration, recovered) + default: + } + if got := acceptedAfterCloseObserved.Load(); got != 0 { + t.Fatalf("iteration %d: accepted %d enqueue attempts after close was observed, want 0", + iteration, got) + } + if worker.TryEnqueue(newQuestionEmailWorkerTask("after-close", "user")) { + t.Fatalf("iteration %d: TryEnqueue() after Close() = true, want false", iteration) + } + if got := len(worker.tasks); got != 0 { + t.Fatalf("iteration %d: pending tasks after Close() = %d, want 0", iteration, got) + } + } +} + +func TestWaitNewQuestionEmailIntervalCancel(t *testing.T) { + timerFactory := newFakeNewQuestionEmailTimerFactory() + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan bool, 1) + go func() { + done <- waitNewQuestionEmailInterval(ctx, time.Minute, timerFactory.New) + }() + + timer := timerFactory.WaitForTimer(t) + cancel() + + select { + case got := <-done: + if got { + t.Fatalf("waitNewQuestionEmailInterval() = true, want false") + } + case <-time.After(time.Second): + t.Fatalf("waitNewQuestionEmailInterval() did not return after cancellation") + } + timer.AssertStopped(t) +} + +type newQuestionEmailSendEvent struct { + userID string + rawData *schema.NewQuestionTemplateRawData +} + +func newQuestionEmailSendRecorder(sendCh chan<- newQuestionEmailSendEvent) newQuestionNotificationEmailSender { + return func(_ context.Context, userID string, rawData *schema.NewQuestionTemplateRawData) { + sendCh <- newQuestionEmailSendEvent{userID: userID, rawData: rawData} + } +} + +func newQuestionEmailWorkerTask(questionID string, userIDs ...string) newQuestionEmailTask { + return newQuestionEmailTask{ + UserIDs: userIDs, + QuestionTitle: "Question", + QuestionID: questionID, + Tags: []string{"go"}, + TagIDs: []string{"tag-1"}, + } +} + +func newUnstartedNewQuestionEmailWorkerForTest(bufferSize int) *newQuestionEmailWorker { + ctx, cancel := context.WithCancel(context.Background()) + return &newQuestionEmailWorker{ + tasks: make(chan newQuestionEmailTask, bufferSize), + interval: func() time.Duration { return 0 }, + timerFactory: newRealNewQuestionEmailTimer, + ctx: ctx, + cancel: cancel, + } +} + +func receiveNewQuestionEmailSend(t *testing.T, sendCh <-chan newQuestionEmailSendEvent) newQuestionEmailSendEvent { + t.Helper() + select { + case event := <-sendCh: + return event + case <-time.After(time.Second): + t.Fatalf("timed out waiting for new question email send") + return newQuestionEmailSendEvent{} + } +} + +func assertNoNewQuestionEmailSend(t *testing.T, sendCh <-chan newQuestionEmailSendEvent) { + t.Helper() + select { + case event := <-sendCh: + t.Fatalf("unexpected new question email send: %+v", event) + default: + } +} + +func receiveString(t *testing.T, ch <-chan string) string { + t.Helper() + select { + case value := <-ch: + return value + case <-time.After(time.Second): + t.Fatalf("timed out waiting for string") + return "" + } +} + +func assertNoString(t *testing.T, ch <-chan string) { + t.Helper() + select { + case value := <-ch: + t.Fatalf("unexpected string: %s", value) + default: + } +} + +type fakeNewQuestionEmailTimerFactory struct { + timers chan *fakeNewQuestionEmailTimer + mu sync.Mutex + durations []time.Duration +} + +func newFakeNewQuestionEmailTimerFactory() *fakeNewQuestionEmailTimerFactory { + return &fakeNewQuestionEmailTimerFactory{ + timers: make(chan *fakeNewQuestionEmailTimer, 16), + } +} + +func (f *fakeNewQuestionEmailTimerFactory) New(duration time.Duration) newQuestionEmailTimer { + timer := newFakeNewQuestionEmailTimer() + + f.mu.Lock() + f.durations = append(f.durations, duration) + f.mu.Unlock() + + f.timers <- timer + return timer +} + +func (f *fakeNewQuestionEmailTimerFactory) WaitForTimer(t *testing.T) *fakeNewQuestionEmailTimer { + t.Helper() + select { + case timer := <-f.timers: + return timer + case <-time.After(time.Second): + t.Fatalf("timed out waiting for timer") + return nil + } +} + +func (f *fakeNewQuestionEmailTimerFactory) Durations() []time.Duration { + f.mu.Lock() + defer f.mu.Unlock() + + durations := make([]time.Duration, len(f.durations)) + copy(durations, f.durations) + return durations +} + +type fakeNewQuestionEmailTimer struct { + ch chan time.Time + stopped chan struct{} + once sync.Once +} + +func newFakeNewQuestionEmailTimer() *fakeNewQuestionEmailTimer { + return &fakeNewQuestionEmailTimer{ + ch: make(chan time.Time, 1), + stopped: make(chan struct{}), + } +} + +func (t *fakeNewQuestionEmailTimer) C() <-chan time.Time { + return t.ch +} + +func (t *fakeNewQuestionEmailTimer) Stop() { + t.once.Do(func() { + close(t.stopped) + }) +} + +func (t *fakeNewQuestionEmailTimer) Fire() { + t.ch <- time.Now() +} + +func (t *fakeNewQuestionEmailTimer) AssertStopped(tb testing.TB) { + tb.Helper() + select { + case <-t.stopped: + case <-time.After(time.Second): + tb.Fatalf("timer was not stopped") + } +} diff --git a/internal/service/notification/new_question_notification.go b/internal/service/notification/new_question_notification.go index bd323c7f9..43c5ff859 100644 --- a/internal/service/notification/new_question_notification.go +++ b/internal/service/notification/new_question_notification.go @@ -49,32 +49,42 @@ func (ns *ExternalNotificationService) handleNewQuestionNotification(ctx context } log.Debugf("get subscribers %d for question %s", len(subscribers), msg.NewQuestionTemplateRawData.QuestionID) - interval := newQuestionNotificationEmailSendInterval() - if interval > 0 { - ns.syncNewQuestionNotificationToPlugin(ctx, msg) - ns.sendNewQuestionNotificationEmails(ctx, subscribers, msg.NewQuestionTemplateRawData, interval) - return nil - } - - ns.sendNewQuestionNotificationEmails(ctx, subscribers, msg.NewQuestionTemplateRawData, interval) ns.syncNewQuestionNotificationToPlugin(ctx, msg) + ns.enqueueNewQuestionNotificationEmails(subscribers, msg.NewQuestionTemplateRawData) return nil } -func (ns *ExternalNotificationService) sendNewQuestionNotificationEmails( - ctx context.Context, +func (ns *ExternalNotificationService) enqueueNewQuestionNotificationEmails( subscribers []*NewQuestionSubscriber, rawData *schema.NewQuestionTemplateRawData, - interval time.Duration, ) { - sendNewQuestionNotificationEmailsWithInterval( - ctx, - subscribers, - rawData, - interval, - nil, - ns.sendNewQuestionNotificationEmail, - ) + task := newQuestionEmailTaskFromRawData(collectNewQuestionNotificationEmailUserIDs(subscribers), rawData) + if len(task.UserIDs) == 0 { + return + } + if ns.newQuestionEmailWorker == nil { + log.Warnf("[new_question_email] worker is nil, dropping task for question %s", task.QuestionID) + return + } + if !ns.newQuestionEmailWorker.TryEnqueue(task) { + log.Warnf("[new_question_email] failed to enqueue task for question %s", task.QuestionID) + } +} + +func collectNewQuestionNotificationEmailUserIDs(subscribers []*NewQuestionSubscriber) []string { + userIDs := make([]string, 0, len(subscribers)) + for _, subscriber := range subscribers { + if subscriber == nil { + continue + } + for _, channel := range subscriber.Channels { + if channel == nil || !channel.Enable || channel.Key != constant.EmailChannel { + continue + } + userIDs = append(userIDs, subscriber.UserID) + } + } + return userIDs } func (ns *ExternalNotificationService) getNewQuestionSubscribers(ctx context.Context, msg *schema.ExternalNotificationMsg) ( diff --git a/internal/service/notification/new_question_notification_interval.go b/internal/service/notification/new_question_notification_interval.go index 22f17450f..19bb1cea8 100644 --- a/internal/service/notification/new_question_notification_interval.go +++ b/internal/service/notification/new_question_notification_interval.go @@ -26,16 +26,14 @@ import ( "strings" "time" - "github.com/apache/answer/internal/base/constant" "github.com/apache/answer/internal/schema" - "github.com/apache/answer/pkg/token" ) const newQuestionNotificationEmailSendIntervalEnv = "NEW_QUESTION_NOTIFICATION_EMAIL_SEND_INTERVAL_SECONDS" -const maxNewQuestionNotificationEmailSendIntervalSeconds = int64(1<<63-1) / int64(time.Second) +const maxNewQuestionNotificationEmailSendInterval = 5 * time.Minute -type newQuestionNotificationEmailSleeper func(time.Duration) +const maxNewQuestionNotificationEmailSendIntervalSeconds = int64(maxNewQuestionNotificationEmailSendInterval / time.Second) type newQuestionNotificationEmailSender func(context.Context, string, *schema.NewQuestionTemplateRawData) @@ -49,44 +47,11 @@ func parseNewQuestionNotificationEmailSendInterval(value string) time.Duration { return 0 } seconds, err := strconv.ParseInt(value, 10, 64) - if err != nil || seconds < 0 || seconds > maxNewQuestionNotificationEmailSendIntervalSeconds { + if err != nil || seconds < 0 { return 0 } - return time.Duration(seconds) * time.Second -} - -func sendNewQuestionNotificationEmailsWithInterval( - ctx context.Context, - subscribers []*NewQuestionSubscriber, - rawData *schema.NewQuestionTemplateRawData, - interval time.Duration, - sleep newQuestionNotificationEmailSleeper, - send newQuestionNotificationEmailSender, -) { - if rawData == nil || send == nil { - return - } - if sleep == nil { - sleep = time.Sleep - } - - emailAttempts := 0 - for _, subscriber := range subscribers { - for _, channel := range subscriber.Channels { - if !channel.Enable || channel.Key != constant.EmailChannel { - continue - } - if interval > 0 && emailAttempts > 0 { - sleep(interval) - } - send(ctx, subscriber.UserID, &schema.NewQuestionTemplateRawData{ - QuestionTitle: rawData.QuestionTitle, - QuestionID: rawData.QuestionID, - UnsubscribeCode: token.GenerateToken(), - Tags: rawData.Tags, - TagIDs: rawData.TagIDs, - }) - emailAttempts++ - } + if seconds > maxNewQuestionNotificationEmailSendIntervalSeconds { + return maxNewQuestionNotificationEmailSendInterval } + return time.Duration(seconds) * time.Second } diff --git a/internal/service/notification/new_question_notification_test.go b/internal/service/notification/new_question_notification_test.go index 4d1729a2f..e7db3814d 100644 --- a/internal/service/notification/new_question_notification_test.go +++ b/internal/service/notification/new_question_notification_test.go @@ -24,6 +24,7 @@ import ( "encoding/json" "os" "reflect" + "sync" "testing" "time" @@ -34,6 +35,7 @@ import ( "github.com/apache/answer/internal/service/config" "github.com/apache/answer/internal/service/export" "github.com/apache/answer/internal/service/mock" + "github.com/apache/answer/plugin" "go.uber.org/mock/gomock" ) @@ -79,11 +81,23 @@ func TestNewQuestionNotificationEmailSendInterval(t *testing.T) { want: 0, }, { - name: "duration overflow", - value: "9223372037", + name: "whitespace", + value: " ", set: true, want: 0, }, + { + name: "above max clamps to max", + value: "301", + set: true, + want: maxNewQuestionNotificationEmailSendInterval, + }, + { + name: "duration overflow clamps to max", + value: "9223372037", + set: true, + want: maxNewQuestionNotificationEmailSendInterval, + }, { name: "parse int overflow", value: "9223372036854775808", @@ -104,145 +118,7 @@ func TestNewQuestionNotificationEmailSendInterval(t *testing.T) { } } -func TestSendNewQuestionNotificationEmailsWithInterval(t *testing.T) { - rawData := &schema.NewQuestionTemplateRawData{ - QuestionTitle: "question", - QuestionID: "1", - Tags: []string{"go"}, - TagIDs: []string{"tag-1"}, - } - interval := 3 * time.Second - - tests := []struct { - name string - interval time.Duration - subscribers []*NewQuestionSubscriber - wantSends []string - wantSleeps []time.Duration - wantEvents []string - }{ - { - name: "interval 0", - interval: 0, - subscribers: []*NewQuestionSubscriber{ - newQuestionSubscriber("user-1", newQuestionEmailChannel(true)), - newQuestionSubscriber("user-2", newQuestionEmailChannel(true)), - }, - wantSends: []string{"user-1", "user-2"}, - wantEvents: []string{ - "send:user-1", - "send:user-2", - }, - }, - { - name: "0 enabled email attempts", - interval: interval, - subscribers: []*NewQuestionSubscriber{ - newQuestionSubscriber("user-1", newQuestionEmailChannel(false)), - newQuestionSubscriber("user-2", newQuestionNonEmailChannel(true)), - }, - }, - { - name: "1 enabled email attempt", - interval: interval, - subscribers: []*NewQuestionSubscriber{ - newQuestionSubscriber("user-1", newQuestionEmailChannel(true)), - }, - wantSends: []string{"user-1"}, - wantEvents: []string{ - "send:user-1", - }, - }, - { - name: "N enabled email attempts", - interval: interval, - subscribers: []*NewQuestionSubscriber{ - newQuestionSubscriber("user-1", newQuestionEmailChannel(true)), - newQuestionSubscriber("user-2", newQuestionEmailChannel(true)), - newQuestionSubscriber("user-3", newQuestionEmailChannel(true)), - }, - wantSends: []string{"user-1", "user-2", "user-3"}, - wantSleeps: []time.Duration{interval, interval}, - wantEvents: []string{ - "send:user-1", - "sleep:3s", - "send:user-2", - "sleep:3s", - "send:user-3", - }, - }, - { - name: "disabled email channel does not add delay", - interval: interval, - subscribers: []*NewQuestionSubscriber{ - newQuestionSubscriber("user-1", newQuestionEmailChannel(true)), - newQuestionSubscriber("user-2", newQuestionEmailChannel(false)), - newQuestionSubscriber("user-3", newQuestionEmailChannel(true)), - }, - wantSends: []string{"user-1", "user-3"}, - wantSleeps: []time.Duration{interval}, - wantEvents: []string{ - "send:user-1", - "sleep:3s", - "send:user-3", - }, - }, - { - name: "non-email channel does not add delay", - interval: interval, - subscribers: []*NewQuestionSubscriber{ - newQuestionSubscriber("user-1", newQuestionEmailChannel(true)), - newQuestionSubscriber("user-2", newQuestionNonEmailChannel(true)), - newQuestionSubscriber("user-3", newQuestionEmailChannel(true)), - }, - wantSends: []string{"user-1", "user-3"}, - wantSleeps: []time.Duration{interval}, - wantEvents: []string{ - "send:user-1", - "sleep:3s", - "send:user-3", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var gotEvents []string - var gotSleeps []time.Duration - sleep := func(duration time.Duration) { - gotSleeps = append(gotSleeps, duration) - gotEvents = append(gotEvents, "sleep:"+duration.String()) - } - - var gotSends []string - var gotCodes []string - send := func(_ context.Context, userID string, rawData *schema.NewQuestionTemplateRawData) { - gotSends = append(gotSends, userID) - gotEvents = append(gotEvents, "send:"+userID) - if rawData.UnsubscribeCode == "" { - t.Fatalf("expected unsubscribe code for %s", userID) - } - gotCodes = append(gotCodes, rawData.UnsubscribeCode) - } - - sendNewQuestionNotificationEmailsWithInterval( - context.Background(), tt.subscribers, rawData, tt.interval, sleep, send) - - if !reflect.DeepEqual(gotSends, tt.wantSends) { - t.Fatalf("send calls = %v, want %v", gotSends, tt.wantSends) - } - if !reflect.DeepEqual(gotSleeps, tt.wantSleeps) { - t.Fatalf("sleep calls = %v, want %v", gotSleeps, tt.wantSleeps) - } - if !reflect.DeepEqual(gotEvents, tt.wantEvents) { - t.Fatalf("events = %v, want %v", gotEvents, tt.wantEvents) - } - assertUniqueNewQuestionUnsubscribeCodes(t, gotCodes) - }) - } -} - -func TestHandleNewQuestionNotificationSendsEmailsThroughFanOut(t *testing.T) { +func TestHandleNewQuestionNotificationEnqueuesEmailTask(t *testing.T) { setNewQuestionNotificationEmailSendIntervalEnv(t, "0", true) cache, cleanup, err := basedata.NewCache(&basedata.CacheConf{}) @@ -305,6 +181,7 @@ func TestHandleNewQuestionNotificationSendsEmailsThroughFanOut(t *testing.T) { }, siteInfoService: siteInfoService, } + service.newQuestionEmailWorker = newUnstartedNewQuestionEmailWorkerForTest(1) err = service.handleNewQuestionNotification(context.Background(), &schema.ExternalNotificationMsg{ NewQuestionTemplateRawData: &schema.NewQuestionTemplateRawData{ @@ -319,19 +196,201 @@ func TestHandleNewQuestionNotificationSendsEmailsThroughFanOut(t *testing.T) { t.Fatalf("handleNewQuestionNotification() error = %v", err) } + var task newQuestionEmailTask + select { + case task = <-service.newQuestionEmailWorker.tasks: + default: + t.Fatalf("expected enqueued new question email task") + } + wantUsers := []string{"all-user", "dup-user", "tag-user"} - assertStringSet(t, emailRepo.userIDs(), wantUsers) - for _, userID := range wantUsers { - codes := emailRepo.codesByUserID[userID] - if len(codes) != 1 { - t.Fatalf("saved codes for %s = %v, want exactly one code", userID, codes) - } - if codes[0] == "" { - t.Fatalf("saved empty code for %s", userID) + assertStringSet(t, task.UserIDs, wantUsers) + if task.QuestionTitle != "New question" || task.QuestionID != "1" { + t.Fatalf("task question data = %+v", task) + } + if !reflect.DeepEqual(task.Tags, []string{"go"}) || !reflect.DeepEqual(task.TagIDs, []string{"tag-1"}) { + t.Fatalf("task tags = %v/%v", task.Tags, task.TagIDs) + } + if len(emailRepo.codesByUserID) > 0 { + t.Fatalf("handler sent emails synchronously: %v", emailRepo.codesByUserID) + } +} + +func TestHandleNewQuestionNotificationSkipsEnqueueWithoutEnabledEmailAttempts(t *testing.T) { + cache, cleanup, err := basedata.NewCache(&basedata.CacheConf{}) + if err != nil { + t.Fatalf("new cache: %v", err) + } + t.Cleanup(cleanup) + + service := &ExternalNotificationService{ + data: &basedata.Data{Cache: cache}, + userNotificationConfigRepo: &newQuestionNotificationTestUserNotificationConfigRepo{ + followedTagConfigs: map[string]*entity.UserNotificationConfig{ + "tag-user": newQuestionNotificationConfig( + "tag-user", constant.AllNewQuestionForFollowingTagsSource, false), + }, + allQuestionConfigs: []*entity.UserNotificationConfig{ + newQuestionNotificationConfig("all-user", constant.AllNewQuestionSource, false), + }, + }, + followRepo: &newQuestionNotificationTestFollowRepo{ + followersByObjectID: map[string][]string{"tag-1": {"tag-user"}}, + }, + userRepo: &newQuestionNotificationTestUserRepo{ + users: map[string]*entity.User{ + "tag-user": newQuestionNotificationTestUser("tag-user"), + "all-user": newQuestionNotificationTestUser("all-user"), + }, + }, + newQuestionEmailWorker: newUnstartedNewQuestionEmailWorkerForTest(1), + } + + err = service.handleNewQuestionNotification(context.Background(), &schema.ExternalNotificationMsg{ + NewQuestionTemplateRawData: &schema.NewQuestionTemplateRawData{ + QuestionTitle: "New question", + QuestionID: "1", + Tags: []string{"go"}, + TagIDs: []string{"tag-1"}, + }, + }) + if err != nil { + t.Fatalf("handleNewQuestionNotification() error = %v", err) + } + select { + case task := <-service.newQuestionEmailWorker.tasks: + t.Fatalf("unexpected enqueued task: %+v", task) + default: + } +} + +func TestHandleNewQuestionNotificationReturnsWhenEmailWorkerQueueFull(t *testing.T) { + cache, cleanup, err := basedata.NewCache(&basedata.CacheConf{}) + if err != nil { + t.Fatalf("new cache: %v", err) + } + t.Cleanup(cleanup) + + worker := newUnstartedNewQuestionEmailWorkerForTest(1) + if !worker.TryEnqueue(newQuestionEmailWorkerTask("already-queued", "queued-user")) { + t.Fatalf("pre-fill TryEnqueue() = false, want true") + } + service := &ExternalNotificationService{ + data: &basedata.Data{Cache: cache}, + userNotificationConfigRepo: &newQuestionNotificationTestUserNotificationConfigRepo{ + allQuestionConfigs: []*entity.UserNotificationConfig{ + newQuestionNotificationConfig("all-user", constant.AllNewQuestionSource, true), + }, + }, + followRepo: &newQuestionNotificationTestFollowRepo{ + followersByObjectID: map[string][]string{}, + }, + userRepo: &newQuestionNotificationTestUserRepo{ + users: map[string]*entity.User{ + "all-user": newQuestionNotificationTestUser("all-user"), + }, + }, + newQuestionEmailWorker: worker, + } + + err = service.handleNewQuestionNotification(context.Background(), &schema.ExternalNotificationMsg{ + NewQuestionTemplateRawData: &schema.NewQuestionTemplateRawData{ + QuestionTitle: "New question", + QuestionID: "1", + }, + }) + if err != nil { + t.Fatalf("handleNewQuestionNotification() error = %v", err) + } + if got := len(worker.tasks); got != 1 { + t.Fatalf("worker queue length = %d, want 1", got) + } +} + +func TestHandleNewQuestionNotificationSyncsPluginBeforeEmailEnqueue(t *testing.T) { + cache, cleanup, err := basedata.NewCache(&basedata.CacheConf{}) + if err != nil { + t.Fatalf("new cache: %v", err) + } + t.Cleanup(cleanup) + + ctrl := gomock.NewController(t) + siteInfoService := mock.NewMockSiteInfoCommonService(ctrl) + siteInfoService.EXPECT().GetSiteGeneral(gomock.Any()).Return(&schema.SiteGeneralResp{ + Name: "Answer", + SiteUrl: "https://answer.test", + ContactEmail: "support@answer.test", + }, nil).AnyTimes() + siteInfoService.EXPECT().GetSiteSeo(gomock.Any()).Return(&schema.SiteSeoResp{ + Permalink: constant.PermalinkQuestionIDAndTitle, + }, nil).AnyTimes() + siteInfoService.EXPECT().GetSiteInterface(gomock.Any()).Return(&schema.SiteInterfaceSettingsResp{ + Language: "en", + }, nil).AnyTimes() + + notifyStarted := make(chan plugin.NotificationMessage, 1) + releaseNotify := make(chan struct{}) + enableNewQuestionNotificationTestPlugin(t, notifyStarted, releaseNotify) + + worker := newUnstartedNewQuestionEmailWorkerForTest(1) + service := &ExternalNotificationService{ + data: &basedata.Data{Cache: cache}, + userNotificationConfigRepo: &newQuestionNotificationTestUserNotificationConfigRepo{ + followedTagConfigs: map[string]*entity.UserNotificationConfig{ + "tag-user": newQuestionNotificationConfig( + "tag-user", constant.AllNewQuestionForFollowingTagsSource, true), + }, + }, + followRepo: &newQuestionNotificationTestFollowRepo{ + followersByObjectID: map[string][]string{"tag-1": {"tag-user"}}, + }, + userRepo: &newQuestionNotificationTestUserRepo{ + users: map[string]*entity.User{ + "tag-user": newQuestionNotificationTestUser("tag-user"), + }, + }, + userExternalLoginRepo: newQuestionNotificationTestUserExternalLoginRepo{}, + siteInfoService: siteInfoService, + newQuestionEmailWorker: worker, + } + + errCh := make(chan error, 1) + go func() { + errCh <- service.handleNewQuestionNotification(context.Background(), &schema.ExternalNotificationMsg{ + NewQuestionTemplateRawData: &schema.NewQuestionTemplateRawData{ + QuestionTitle: "New question", + QuestionID: "1", + Tags: []string{"go"}, + TagIDs: []string{"tag-1"}, + }, + }) + }() + + select { + case <-notifyStarted: + case <-time.After(time.Second): + t.Fatalf("plugin notification was not sent") + } + select { + case task := <-worker.tasks: + t.Fatalf("email task enqueued before plugin sync completed: %+v", task) + default: + } + close(releaseNotify) + + select { + case err := <-errCh: + if err != nil { + t.Fatalf("handleNewQuestionNotification() error = %v", err) } + case <-time.After(time.Second): + t.Fatalf("handleNewQuestionNotification() did not return") } - if codes := emailRepo.codesByUserID["author"]; len(codes) > 0 { - t.Fatalf("question author received notification codes: %v", codes) + select { + case task := <-worker.tasks: + assertStringSet(t, task.UserIDs, []string{"tag-user"}) + default: + t.Fatalf("expected email task after plugin sync completed") } } @@ -650,3 +709,114 @@ func (r *newQuestionNotificationTestEmailRepo) userIDs() []string { } return userIDs } + +var ( + newQuestionNotificationTestPluginOnce sync.Once + newQuestionNotificationTestPluginInst = &newQuestionNotificationTestPlugin{} +) + +func enableNewQuestionNotificationTestPlugin( + t *testing.T, + notifyStarted chan plugin.NotificationMessage, + releaseNotify <-chan struct{}, +) { + t.Helper() + + newQuestionNotificationTestPluginInst.setChannels(notifyStarted, releaseNotify) + newQuestionNotificationTestPluginOnce.Do(func() { + plugin.Register(newQuestionNotificationTestPluginInst) + }) + plugin.StatusManager.Enable(newQuestionNotificationTestPluginInst.Info().SlugName, true) + t.Cleanup(func() { + plugin.StatusManager.Enable(newQuestionNotificationTestPluginInst.Info().SlugName, false) + newQuestionNotificationTestPluginInst.setChannels(nil, nil) + }) +} + +type newQuestionNotificationTestPlugin struct { + mu sync.Mutex + notifyStarted chan plugin.NotificationMessage + releaseNotify <-chan struct{} +} + +func (p *newQuestionNotificationTestPlugin) Info() plugin.Info { + return plugin.Info{SlugName: "new-question-notification-test-plugin"} +} + +func (p *newQuestionNotificationTestPlugin) GetNewQuestionSubscribers() []string { + return nil +} + +func (p *newQuestionNotificationTestPlugin) Notify(msg plugin.NotificationMessage) { + p.mu.Lock() + notifyStarted := p.notifyStarted + releaseNotify := p.releaseNotify + p.mu.Unlock() + + if notifyStarted != nil { + select { + case notifyStarted <- msg: + default: + } + } + if releaseNotify != nil { + <-releaseNotify + } +} + +func (p *newQuestionNotificationTestPlugin) setChannels( + notifyStarted chan plugin.NotificationMessage, + releaseNotify <-chan struct{}, +) { + p.mu.Lock() + defer p.mu.Unlock() + p.notifyStarted = notifyStarted + p.releaseNotify = releaseNotify +} + +type newQuestionNotificationTestUserExternalLoginRepo struct{} + +func (newQuestionNotificationTestUserExternalLoginRepo) AddUserExternalLogin( + context.Context, *entity.UserExternalLogin) error { + return nil +} + +func (newQuestionNotificationTestUserExternalLoginRepo) UpdateInfo( + context.Context, *entity.UserExternalLogin) error { + return nil +} + +func (newQuestionNotificationTestUserExternalLoginRepo) GetByExternalID( + context.Context, string, string) (*entity.UserExternalLogin, bool, error) { + return nil, false, nil +} + +func (newQuestionNotificationTestUserExternalLoginRepo) GetByUserID( + context.Context, string, string) (*entity.UserExternalLogin, bool, error) { + return nil, false, nil +} + +func (newQuestionNotificationTestUserExternalLoginRepo) GetUserExternalLoginList( + context.Context, string) ([]*entity.UserExternalLogin, error) { + return nil, nil +} + +func (newQuestionNotificationTestUserExternalLoginRepo) DeleteUserExternalLogin( + context.Context, string, string) error { + return nil +} + +func (newQuestionNotificationTestUserExternalLoginRepo) DeleteUserExternalLoginByUserID( + context.Context, string) error { + return nil +} + +func (newQuestionNotificationTestUserExternalLoginRepo) SetCacheUserExternalLoginInfo( + context.Context, string, *schema.ExternalLoginUserInfoCache) error { + return nil +} + +func (newQuestionNotificationTestUserExternalLoginRepo) GetCacheUserExternalLoginInfo( + context.Context, string) (*schema.ExternalLoginUserInfoCache, error) { + return nil, nil +} From b70dda997a4e5d6c44559875c68c3ee49f5ef3e4 Mon Sep 17 00:00:00 2001 From: Artur Iusupov Date: Wed, 1 Jul 2026 13:30:44 +0400 Subject: [PATCH 11/49] fix(notification): make new question email queue configurable --- .../notification/new_question_email_worker.go | 30 +++- .../new_question_email_worker_test.go | 128 ++++++++++++++++++ 2 files changed, 156 insertions(+), 2 deletions(-) diff --git a/internal/service/notification/new_question_email_worker.go b/internal/service/notification/new_question_email_worker.go index be3e6504d..c3df1beea 100644 --- a/internal/service/notification/new_question_email_worker.go +++ b/internal/service/notification/new_question_email_worker.go @@ -21,6 +21,9 @@ package notification import ( "context" + "os" + "strconv" + "strings" "sync" "time" @@ -29,7 +32,11 @@ import ( "github.com/segmentfault/pacman/log" ) -const newQuestionEmailWorkerQueueSize = 128 +const defaultNewQuestionEmailWorkerQueueSize = 1024 + +const maxNewQuestionEmailWorkerQueueSize = 65536 + +const newQuestionEmailWorkerQueueSizeEnv = "NEW_QUESTION_NOTIFICATION_EMAIL_QUEUE_SIZE" type newQuestionEmailTask struct { UserIDs []string @@ -68,10 +75,29 @@ func newQuestionEmailWorkerWithDefaults( interval, send, newRealNewQuestionEmailTimer, - newQuestionEmailWorkerQueueSize, + newQuestionEmailWorkerQueueSize(), ) } +func newQuestionEmailWorkerQueueSize() int { + return parseNewQuestionEmailWorkerQueueSize(os.Getenv(newQuestionEmailWorkerQueueSizeEnv)) +} + +func parseNewQuestionEmailWorkerQueueSize(value string) int { + value = strings.TrimSpace(value) + if len(value) == 0 { + return defaultNewQuestionEmailWorkerQueueSize + } + queueSize, err := strconv.ParseInt(value, 10, 64) + if err != nil || queueSize <= 0 { + return defaultNewQuestionEmailWorkerQueueSize + } + if queueSize > int64(maxNewQuestionEmailWorkerQueueSize) { + return maxNewQuestionEmailWorkerQueueSize + } + return int(queueSize) +} + func newQuestionEmailWorkerWithBuffer( interval newQuestionEmailIntervalProvider, send newQuestionNotificationEmailSender, diff --git a/internal/service/notification/new_question_email_worker_test.go b/internal/service/notification/new_question_email_worker_test.go index 302de4eb9..e7ea4880d 100644 --- a/internal/service/notification/new_question_email_worker_test.go +++ b/internal/service/notification/new_question_email_worker_test.go @@ -21,6 +21,7 @@ package notification import ( "context" + "os" "reflect" "runtime" "sync" @@ -31,6 +32,111 @@ import ( "github.com/apache/answer/internal/schema" ) +func TestParseNewQuestionEmailWorkerQueueSize(t *testing.T) { + tests := []struct { + name string + value string + want int + }{ + { + name: "empty", + value: "", + want: defaultNewQuestionEmailWorkerQueueSize, + }, + { + name: "whitespace", + value: " ", + want: defaultNewQuestionEmailWorkerQueueSize, + }, + { + name: "invalid", + value: "invalid", + want: defaultNewQuestionEmailWorkerQueueSize, + }, + { + name: "zero", + value: "0", + want: defaultNewQuestionEmailWorkerQueueSize, + }, + { + name: "negative", + value: "-1", + want: defaultNewQuestionEmailWorkerQueueSize, + }, + { + name: "parse int overflow", + value: "9223372036854775808", + want: defaultNewQuestionEmailWorkerQueueSize, + }, + { + name: "positive", + value: "2048", + want: 2048, + }, + { + name: "max", + value: "65536", + want: maxNewQuestionEmailWorkerQueueSize, + }, + { + name: "above max", + value: "65537", + want: maxNewQuestionEmailWorkerQueueSize, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseNewQuestionEmailWorkerQueueSize(tt.value) + if got != tt.want { + t.Fatalf("parseNewQuestionEmailWorkerQueueSize(%q) = %d, want %d", tt.value, got, tt.want) + } + }) + } +} + +func TestNewQuestionEmailWorkerQueueSizeUnsetEnv(t *testing.T) { + setNewQuestionEmailWorkerQueueSizeEnv(t, "", false) + + got := newQuestionEmailWorkerQueueSize() + if got != defaultNewQuestionEmailWorkerQueueSize { + t.Fatalf("newQuestionEmailWorkerQueueSize() = %d, want %d", + got, defaultNewQuestionEmailWorkerQueueSize) + } +} + +func TestNewQuestionEmailWorkerWithDefaultsUsesQueueSizeEnv(t *testing.T) { + tests := []struct { + name string + value string + want int + }{ + { + name: "configured", + value: "2048", + want: 2048, + }, + { + name: "invalid uses default", + value: "invalid", + want: defaultNewQuestionEmailWorkerQueueSize, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setNewQuestionEmailWorkerQueueSizeEnv(t, tt.value, true) + + worker := newQuestionEmailWorkerWithDefaults(func() time.Duration { return 0 }, nil) + defer worker.Close() + + if got := cap(worker.tasks); got != tt.want { + t.Fatalf("cap(worker.tasks) = %d, want %d", got, tt.want) + } + }) + } +} + func TestNewQuestionEmailWorkerDelaysBetweenAttempts(t *testing.T) { timerFactory := newFakeNewQuestionEmailTimerFactory() sendCh := make(chan newQuestionEmailSendEvent, 2) @@ -438,6 +544,28 @@ func newUnstartedNewQuestionEmailWorkerForTest(bufferSize int) *newQuestionEmail } } +func setNewQuestionEmailWorkerQueueSizeEnv(t *testing.T, value string, set bool) { + t.Helper() + + oldValue, oldSet := os.LookupEnv(newQuestionEmailWorkerQueueSizeEnv) + if set { + if err := os.Setenv(newQuestionEmailWorkerQueueSizeEnv, value); err != nil { + t.Fatalf("set env: %v", err) + } + } else { + if err := os.Unsetenv(newQuestionEmailWorkerQueueSizeEnv); err != nil { + t.Fatalf("unset env: %v", err) + } + } + t.Cleanup(func() { + if oldSet { + _ = os.Setenv(newQuestionEmailWorkerQueueSizeEnv, oldValue) + } else { + _ = os.Unsetenv(newQuestionEmailWorkerQueueSizeEnv) + } + }) +} + func receiveNewQuestionEmailSend(t *testing.T, sendCh <-chan newQuestionEmailSendEvent) newQuestionEmailSendEvent { t.Helper() select { From c17e0c94c85716b94aa831ed47c06e69250cd96b Mon Sep 17 00:00:00 2001 From: LinkinStars Date: Tue, 7 Jul 2026 19:03:27 +0800 Subject: [PATCH 12/49] fix(notification): remove buffer size parameter from new question email worker for test --- .../new_question_email_worker_test.go | 22 +++++---- .../new_question_notification_test.go | 45 ++++++++----------- 2 files changed, 29 insertions(+), 38 deletions(-) diff --git a/internal/service/notification/new_question_email_worker_test.go b/internal/service/notification/new_question_email_worker_test.go index e7ea4880d..739dfe505 100644 --- a/internal/service/notification/new_question_email_worker_test.go +++ b/internal/service/notification/new_question_email_worker_test.go @@ -346,7 +346,7 @@ func TestNewQuestionEmailWorkerBuildsFreshRawDataPerAttempt(t *testing.T) { } func TestNewQuestionEmailWorkerTryEnqueueCopiesTaskAndFailsFast(t *testing.T) { - worker := newUnstartedNewQuestionEmailWorkerForTest(1) + worker := newUnstartedNewQuestionEmailWorkerForTest() task := newQuestionEmailTask{ UserIDs: []string{"user-1"}, QuestionTitle: "Question", @@ -380,7 +380,7 @@ func TestNewQuestionEmailWorkerTryEnqueueCopiesTaskAndFailsFast(t *testing.T) { t.Fatalf("TryEnqueue() after Close() = true, want false") } - canceledWorker := newUnstartedNewQuestionEmailWorkerForTest(1) + canceledWorker := newUnstartedNewQuestionEmailWorkerForTest() canceledWorker.cancel() if canceledWorker.TryEnqueue(newQuestionEmailWorkerTask("question-5", "user-5")) { t.Fatalf("TryEnqueue() after cancel = true, want false") @@ -393,8 +393,8 @@ func TestNewQuestionEmailWorkerTryEnqueueConcurrentClose(t *testing.T) { senders = 32 ) - for iteration := 0; iteration < iterations; iteration++ { - worker := newUnstartedNewQuestionEmailWorkerForTest(1) + for iteration := range iterations { + worker := newUnstartedNewQuestionEmailWorkerForTest() if !worker.TryEnqueue(newQuestionEmailWorkerTask("already-queued", "queued-user")) { t.Fatalf("iteration %d: pre-fill TryEnqueue() = false, want true", iteration) } @@ -406,10 +406,8 @@ func TestNewQuestionEmailWorkerTryEnqueueConcurrentClose(t *testing.T) { var acceptedAfterCloseObserved atomic.Int64 var wg sync.WaitGroup - for sender := 0; sender < senders; sender++ { - wg.Add(1) - go func(sender int) { - defer wg.Done() + for range senders { + wg.Go(func() { defer func() { if recovered := recover(); recovered != nil { panicCh <- recovered @@ -428,9 +426,9 @@ func TestNewQuestionEmailWorkerTryEnqueueConcurrentClose(t *testing.T) { } runtime.Gosched() } - }(sender) + }) } - for sender := 0; sender < senders; sender++ { + for range senders { <-ready } @@ -533,10 +531,10 @@ func newQuestionEmailWorkerTask(questionID string, userIDs ...string) newQuestio } } -func newUnstartedNewQuestionEmailWorkerForTest(bufferSize int) *newQuestionEmailWorker { +func newUnstartedNewQuestionEmailWorkerForTest() *newQuestionEmailWorker { ctx, cancel := context.WithCancel(context.Background()) return &newQuestionEmailWorker{ - tasks: make(chan newQuestionEmailTask, bufferSize), + tasks: make(chan newQuestionEmailTask, 1), interval: func() time.Duration { return 0 }, timerFactory: newRealNewQuestionEmailTimer, ctx: ctx, diff --git a/internal/service/notification/new_question_notification_test.go b/internal/service/notification/new_question_notification_test.go index e7db3814d..3bb6a3dd8 100644 --- a/internal/service/notification/new_question_notification_test.go +++ b/internal/service/notification/new_question_notification_test.go @@ -181,7 +181,7 @@ func TestHandleNewQuestionNotificationEnqueuesEmailTask(t *testing.T) { }, siteInfoService: siteInfoService, } - service.newQuestionEmailWorker = newUnstartedNewQuestionEmailWorkerForTest(1) + service.newQuestionEmailWorker = newUnstartedNewQuestionEmailWorkerForTest() err = service.handleNewQuestionNotification(context.Background(), &schema.ExternalNotificationMsg{ NewQuestionTemplateRawData: &schema.NewQuestionTemplateRawData{ @@ -243,7 +243,7 @@ func TestHandleNewQuestionNotificationSkipsEnqueueWithoutEnabledEmailAttempts(t "all-user": newQuestionNotificationTestUser("all-user"), }, }, - newQuestionEmailWorker: newUnstartedNewQuestionEmailWorkerForTest(1), + newQuestionEmailWorker: newUnstartedNewQuestionEmailWorkerForTest(), } err = service.handleNewQuestionNotification(context.Background(), &schema.ExternalNotificationMsg{ @@ -271,7 +271,7 @@ func TestHandleNewQuestionNotificationReturnsWhenEmailWorkerQueueFull(t *testing } t.Cleanup(cleanup) - worker := newUnstartedNewQuestionEmailWorkerForTest(1) + worker := newUnstartedNewQuestionEmailWorkerForTest() if !worker.TryEnqueue(newQuestionEmailWorkerTask("already-queued", "queued-user")) { t.Fatalf("pre-fill TryEnqueue() = false, want true") } @@ -332,7 +332,7 @@ func TestHandleNewQuestionNotificationSyncsPluginBeforeEmailEnqueue(t *testing.T releaseNotify := make(chan struct{}) enableNewQuestionNotificationTestPlugin(t, notifyStarted, releaseNotify) - worker := newUnstartedNewQuestionEmailWorkerForTest(1) + worker := newUnstartedNewQuestionEmailWorkerForTest() service := &ExternalNotificationService{ data: &basedata.Data{Cache: cache}, userNotificationConfigRepo: &newQuestionNotificationTestUserNotificationConfigRepo{ @@ -428,13 +428,6 @@ func setNewQuestionNotificationEmailSendIntervalEnv(t *testing.T, value string, }) } -func newQuestionSubscriber(userID string, channels ...*schema.NotificationChannelConfig) *NewQuestionSubscriber { - return &NewQuestionSubscriber{ - UserID: userID, - Channels: channels, - } -} - func newQuestionEmailChannel(enable bool) *schema.NotificationChannelConfig { return &schema.NotificationChannelConfig{ Key: constant.EmailChannel, @@ -442,13 +435,6 @@ func newQuestionEmailChannel(enable bool) *schema.NotificationChannelConfig { } } -func newQuestionNonEmailChannel(enable bool) *schema.NotificationChannelConfig { - return &schema.NotificationChannelConfig{ - Key: constant.NotificationChannelKey("inbox"), - Enable: enable, - } -} - func newQuestionNotificationConfig( userID string, source constant.NotificationSource, emailEnabled bool) *entity.UserNotificationConfig { channels := schema.NotificationChannels{ @@ -702,14 +688,6 @@ func (r *newQuestionNotificationTestEmailRepo) VerifyCode(context.Context, strin return "", nil } -func (r *newQuestionNotificationTestEmailRepo) userIDs() []string { - userIDs := make([]string, 0, len(r.codesByUserID)) - for userID := range r.codesByUserID { - userIDs = append(userIDs, userID) - } - return userIDs -} - var ( newQuestionNotificationTestPluginOnce sync.Once newQuestionNotificationTestPluginInst = &newQuestionNotificationTestPlugin{} @@ -820,3 +798,18 @@ func (newQuestionNotificationTestUserExternalLoginRepo) GetCacheUserExternalLogi context.Context, string) (*schema.ExternalLoginUserInfoCache, error) { return nil, nil } + +func (newQuestionNotificationTestUserExternalLoginRepo) SetCacheOAuthState( + context.Context, string, *schema.ExternalLoginOAuthState, time.Duration) error { + return nil +} + +func (newQuestionNotificationTestUserExternalLoginRepo) GetCacheOAuthState( + context.Context, string) (*schema.ExternalLoginOAuthState, error) { + return nil, nil +} + +func (newQuestionNotificationTestUserExternalLoginRepo) DeleteCacheOAuthState( + context.Context, string) error { + return nil +} From 9df5853942f17fccc81d0c3a449cbb7fc51d4ff4 Mon Sep 17 00:00:00 2001 From: LinkinStars Date: Tue, 7 Jul 2026 19:32:24 +0800 Subject: [PATCH 13/49] fix(tests): update goroutine handling in new question email worker tests --- .../new_question_email_worker_test.go | 7 +++++-- .../new_question_notification_test.go | 15 --------------- 2 files changed, 5 insertions(+), 17 deletions(-) diff --git a/internal/service/notification/new_question_email_worker_test.go b/internal/service/notification/new_question_email_worker_test.go index 739dfe505..c708ffd20 100644 --- a/internal/service/notification/new_question_email_worker_test.go +++ b/internal/service/notification/new_question_email_worker_test.go @@ -407,7 +407,10 @@ func TestNewQuestionEmailWorkerTryEnqueueConcurrentClose(t *testing.T) { var wg sync.WaitGroup for range senders { - wg.Go(func() { + //nolint:modernize // CI uses Go 1.23, which does not support WaitGroup.Go. + wg.Add(1) + go func() { + defer wg.Done() defer func() { if recovered := recover(); recovered != nil { panicCh <- recovered @@ -426,7 +429,7 @@ func TestNewQuestionEmailWorkerTryEnqueueConcurrentClose(t *testing.T) { } runtime.Gosched() } - }) + }() } for range senders { <-ready diff --git a/internal/service/notification/new_question_notification_test.go b/internal/service/notification/new_question_notification_test.go index 3bb6a3dd8..754481f6a 100644 --- a/internal/service/notification/new_question_notification_test.go +++ b/internal/service/notification/new_question_notification_test.go @@ -798,18 +798,3 @@ func (newQuestionNotificationTestUserExternalLoginRepo) GetCacheUserExternalLogi context.Context, string) (*schema.ExternalLoginUserInfoCache, error) { return nil, nil } - -func (newQuestionNotificationTestUserExternalLoginRepo) SetCacheOAuthState( - context.Context, string, *schema.ExternalLoginOAuthState, time.Duration) error { - return nil -} - -func (newQuestionNotificationTestUserExternalLoginRepo) GetCacheOAuthState( - context.Context, string) (*schema.ExternalLoginOAuthState, error) { - return nil, nil -} - -func (newQuestionNotificationTestUserExternalLoginRepo) DeleteCacheOAuthState( - context.Context, string) error { - return nil -} From 98329f3b05949246bddaa83dd5651f590e7afae7 Mon Sep 17 00:00:00 2001 From: ferhat elmas Date: Sun, 26 Jul 2026 04:04:08 +0200 Subject: [PATCH 14/49] fix: advanced site settings setup from migration 30 Signed-off-by: ferhat elmas --- internal/migrations/migrations.go | 1 + internal/migrations/v35.go | 72 +++++++++++++++++++++ internal/migrations/v35_test.go | 101 ++++++++++++++++++++++++++++++ 3 files changed, 174 insertions(+) create mode 100644 internal/migrations/v35.go create mode 100644 internal/migrations/v35_test.go diff --git a/internal/migrations/migrations.go b/internal/migrations/migrations.go index 59fbb7bea..360f688af 100644 --- a/internal/migrations/migrations.go +++ b/internal/migrations/migrations.go @@ -110,6 +110,7 @@ var migrations = []Migration{ NewMigration("v2.0.1", "change avatar type to text", updateAvatarType, false), NewMigration("v2.0.2", "add reasoning content to ai conversation record", addAIConversationReasoningContent, false), NewMigration("v2.0.3", "add require email verification login setting", addRequireEmailVerification, true), + NewMigration("v2.0.4", "repair missing advanced site settings", repairAdvancedSiteInfo, true), } func GetMigrations() []Migration { diff --git a/internal/migrations/v35.go b/internal/migrations/v35.go new file mode 100644 index 000000000..f63037cc9 --- /dev/null +++ b/internal/migrations/v35.go @@ -0,0 +1,72 @@ +/* + * 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 migrations + +import ( + "context" + "encoding/json" + + "github.com/apache/answer/internal/base/constant" + "github.com/apache/answer/internal/entity" + "github.com/apache/answer/internal/schema" + "xorm.io/builder" + "xorm.io/xorm" +) + +func repairAdvancedSiteInfo(ctx context.Context, x *xorm.Engine) error { + advanced := &entity.SiteInfo{} + exists, err := x.Context(ctx).Where(builder.Eq{"type": constant.SiteTypeAdvanced}).Get(advanced) + if err != nil { + return err + } + if exists { + return nil + } + + write := &entity.SiteInfo{} + exists, err = x.Context(ctx).Where(builder.Eq{"type": constant.SiteTypeWrite}).Get(write) + if err != nil { + return err + } + if !exists { + return nil + } + + siteWrite := &schema.SiteWriteResp{} + if err := json.Unmarshal([]byte(write.Content), siteWrite); err != nil { + return err + } + content, err := json.Marshal(&schema.SiteAdvancedResp{ + MaxImageSize: siteWrite.MaxImageSize, + MaxAttachmentSize: siteWrite.MaxAttachmentSize, + MaxImageMegapixel: siteWrite.MaxImageMegapixel, + AuthorizedImageExtensions: siteWrite.AuthorizedImageExtensions, + AuthorizedAttachmentExtensions: siteWrite.AuthorizedAttachmentExtensions, + }) + if err != nil { + return err + } + _, err = x.Context(ctx).Insert(&entity.SiteInfo{ + Type: constant.SiteTypeAdvanced, + Content: string(content), + Status: 1, + }) + return err +} diff --git a/internal/migrations/v35_test.go b/internal/migrations/v35_test.go new file mode 100644 index 000000000..7c065e933 --- /dev/null +++ b/internal/migrations/v35_test.go @@ -0,0 +1,101 @@ +/* + * 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 migrations + +import ( + "context" + "testing" + + "github.com/apache/answer/internal/base/constant" + "github.com/apache/answer/internal/entity" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "xorm.io/xorm" +) + +func TestRepairAdvancedSiteInfoAddsMissingSettings(t *testing.T) { + x, err := xorm.NewEngine("sqlite", ":memory:") + require.NoError(t, err) + defer func() { + _ = x.Close() + }() + require.NoError(t, x.Sync(new(entity.SiteInfo))) + + _, err = x.Insert(&entity.SiteInfo{ + Type: constant.SiteTypeWrite, + Content: `{"max_image_size":5}`, + Status: 1, + }) + require.NoError(t, err) + + var repairMigration Migration + for _, m := range GetMigrations() { + if m.Version() == "v2.0.4" { + repairMigration = m + break + } + } + require.NotNil(t, repairMigration) + require.NoError(t, repairMigration.Migrate(context.Background(), x)) + + advanced := &entity.SiteInfo{} + exists, err := x.Where("type = ?", constant.SiteTypeAdvanced).Get(advanced) + require.NoError(t, err) + require.True(t, exists) + assert.JSONEq(t, `{ + "max_image_size": 5, + "max_attachment_size": 0, + "max_image_megapixel": 0, + "authorized_image_extensions": null, + "authorized_attachment_extensions": null + }`, advanced.Content) +} + +func TestRepairAdvancedSiteInfoPreservesExistingSettings(t *testing.T) { + x, err := xorm.NewEngine("sqlite", ":memory:") + require.NoError(t, err) + defer func() { + _ = x.Close() + }() + require.NoError(t, x.Sync(new(entity.SiteInfo))) + + const existingContent = `{"max_image_size":99}` + _, err = x.Insert( + &entity.SiteInfo{ + Type: constant.SiteTypeWrite, + Content: `{invalid`, + Status: 1, + }, + &entity.SiteInfo{ + Type: constant.SiteTypeAdvanced, + Content: existingContent, + Status: 1, + }, + ) + require.NoError(t, err) + + require.NoError(t, repairAdvancedSiteInfo(context.Background(), x)) + + advanced := &entity.SiteInfo{} + exists, err := x.Where("type = ?", constant.SiteTypeAdvanced).Get(advanced) + require.NoError(t, err) + require.True(t, exists) + assert.JSONEq(t, existingContent, advanced.Content) +} From 4488ccc689559517d3c272ecd7a90ebdf6f7ecec Mon Sep 17 00:00:00 2001 From: Max Engine Date: Sun, 26 Jul 2026 03:38:37 -0600 Subject: [PATCH 15/49] fix: tag search never matches on slug name The search term was formatted into LOWER(%s) and passed as the *value* of the LIKE, so the function name ended up inside the pattern: slug_name LIKE '%LOWER(coco)%' That can never match. Only the display_name clause did any work, and LIKE is case-sensitive on Postgres, so searching a tag by the name it is written in returns nothing: slug_name=Coco -> matches slug_name=coco -> no match Tags are lower case by convention, so lower case is what users type, and the filter appears to report that no such tag exists. Lower both sides instead. The term normalisation is extracted so it can be covered by a test without a database. --- internal/repo/tag_common/tag_common_repo.go | 22 ++++++++++++++++++--- internal/repo/tag_common/tagsearch_test.go | 17 ++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) create mode 100644 internal/repo/tag_common/tagsearch_test.go diff --git a/internal/repo/tag_common/tag_common_repo.go b/internal/repo/tag_common/tag_common_repo.go index c4762b0ca..73c338cc0 100644 --- a/internal/repo/tag_common/tag_common_repo.go +++ b/internal/repo/tag_common/tag_common_repo.go @@ -21,7 +21,6 @@ package tag_common import ( "context" - "fmt" "strconv" "strings" @@ -171,10 +170,20 @@ func (tr *tagCommonRepo) GetTagPage(ctx context.Context, page, pageSize int, tag session := tr.data.DB.Context(ctx) if len(tag.SlugName) > 0 { + // Both sides lowered, so the search is case-insensitive. + // + // This previously read LOWER(%s) formatted against the *search term*, + // which put the function name into the value: the query became + // slug_name LIKE '%LOWER(coco)%' and could never match. Only the + // display_name clause did anything, and that is case-sensitive on + // Postgres, so typing a tag in lower case -- which is how tags are + // written and therefore how anyone types them -- returned nothing at all + // and read as "no such tag". + search := searchTermForTag(tag.SlugName) mainTagCond := builder.And( builder.Or( - builder.Like{"slug_name", fmt.Sprintf("LOWER(%s)", tag.SlugName)}, - builder.Like{"display_name", tag.SlugName}, + builder.Like{"LOWER(slug_name)", search}, + builder.Like{"LOWER(display_name)", search}, ), builder.Eq{"main_tag_id": 0}, ) @@ -293,3 +302,10 @@ func (tr *tagCommonRepo) UpdateTagsAttribute(ctx context.Context, tags []string, } return } + +// searchTermForTag normalises a tag search term. Lowering it here, and lowering +// the columns in the query, is what makes the search case-insensitive: tags are +// written in lower case, so that is how people type them. +func searchTermForTag(term string) string { + return strings.ToLower(strings.TrimSpace(term)) +} diff --git a/internal/repo/tag_common/tagsearch_test.go b/internal/repo/tag_common/tagsearch_test.go new file mode 100644 index 000000000..29cd50cc5 --- /dev/null +++ b/internal/repo/tag_common/tagsearch_test.go @@ -0,0 +1,17 @@ +package tag_common + +import "testing" + +// The bug: the search term was formatted into LOWER(%s), which put the function +// name into the value rather than applying it to the column, so the query became +// slug_name LIKE '%LOWER(coco)%' and matched nothing. Only display_name did any +// work, and that is case-sensitive on Postgres -- so typing a tag the way tags +// are actually written returned "no such tag". +func TestSearchTermIsLoweredNotWrapped(t *testing.T) { + for _, in := range []string{"Coco", "COCO", "coco"} { + got := searchTermForTag(in) + if got != "coco" { + t.Errorf("searchTermForTag(%q) = %q, want %q", in, got, "coco") + } + } +} From da622a4927c172196301d86dbd49b35ef7a8f776 Mon Sep 17 00:00:00 2001 From: ferhat elmas Date: Sun, 2 Aug 2026 15:29:35 +0200 Subject: [PATCH 16/49] test(converter): pin renderLinkIsUrl behavior Add a table-driven test covering the markdown link destination check before replacing the govalidator dependency with stdlib logic. Signed-off-by: ferhat elmas --- pkg/converter/markdown_test.go | 78 ++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 pkg/converter/markdown_test.go diff --git a/pkg/converter/markdown_test.go b/pkg/converter/markdown_test.go new file mode 100644 index 000000000..06173d233 --- /dev/null +++ b/pkg/converter/markdown_test.go @@ -0,0 +1,78 @@ +/* + * 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 converter + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRenderLinkIsUrl(t *testing.T) { + cases := []struct { + name string + in string + want bool + }{ + {"absolute http URL", "http://example.com/path?q=1#f", true}, + {"absolute https URL", "https://example.com", true}, + {"ftp URL", "ftp://example.com/file", true}, + {"uppercase scheme and host", "HTTP://EXAMPLE.COM", false}, + {"bare domain", "example.com", true}, + {"bare domain with path", "example.com/questions/123", true}, + {"www subdomain", "www.example.com", true}, + {"bare IP", "10.0.0.1", true}, + {"IP with port and path", "10.0.0.1:8080/a", true}, + {"host with port", "localhost:8080", true}, + {"domain with port and path", "example.com:8080/x", true}, + {"IPv6 with port", "[::1]:8080", true}, + {"userinfo", "user:pass@example.com", true}, + {"mailto", "mailto:a@b.com", true}, + {"email-like destination", "a@b.co", true}, + {"userinfo without scheme", "user@h.co", true}, + {"trailing dot FQDN", "example.com.", true}, + {"empty", "", false}, + {"single word", "foo", false}, + {"path segment no dot", "questions/123", false}, + {"absolute path", "/questions/123", true}, + {"scheme-less authority path", "//cdn.example.com/x", true}, + {"anchor", "#section", false}, + {"leading dot", ".hidden", false}, + {"javascript scheme", "javascript:alert(1)", false}, + {"tel scheme", "tel:+1234", false}, + {"host with leading dot", "http://.example.com", false}, + {"trailing colon", "example.com:", false}, + {"single label with scheme", "http://localhost", true}, + {"single label no scheme no port", "localhost", false}, + {"not a url", "not a url", false}, + {"whitespace in path", "h.co/p q", false}, + {"label with leading hyphen", "-ex.com", false}, + {"label with trailing hyphen", "ex-.com", false}, + {"invalid IPv4 quad", "999.1.1.1", false}, + {"IPv4 with leading zeros", "01.2.3.4", false}, + {"three letter domain", "a.b", false}, + } + r := &DangerousHTMLRenderer{} + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, r.renderLinkIsUrl(tc.in)) + }) + } +} From 1ccb4b7adcc3c23d218add997a3126e80a495574 Mon Sep 17 00:00:00 2001 From: LinkinStars Date: Tue, 11 Aug 2026 19:48:24 +0800 Subject: [PATCH 17/49] fix: add ASF header to tag search test Signed-off-by: LinkinStars --- internal/repo/tag_common/tagsearch_test.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/internal/repo/tag_common/tagsearch_test.go b/internal/repo/tag_common/tagsearch_test.go index 29cd50cc5..ea2182b91 100644 --- a/internal/repo/tag_common/tagsearch_test.go +++ b/internal/repo/tag_common/tagsearch_test.go @@ -1,3 +1,22 @@ +/* + * 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 tag_common import "testing" From b80ad0a892f6a597108dd3ca37dd299bd82682ad Mon Sep 17 00:00:00 2001 From: LinkinStars Date: Fri, 14 Aug 2026 16:34:33 +0800 Subject: [PATCH 18/49] 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(-) create mode 100644 internal/service/auth/auth_test.go diff --git a/internal/service/auth/auth.go b/internal/service/auth/auth.go index 7d2751059..a7827c78e 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 000000000..17cbd0010 --- /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") } From 32b451ce8758e04c1e45b0ed9712eb8fbc7b66cc Mon Sep 17 00:00:00 2001 From: LinkinStars Date: Fri, 14 Aug 2026 16:48:53 +0800 Subject: [PATCH 19/49] fix: normalize imported content --- internal/service/importer/importer_service.go | 29 ++++++++----- .../service/importer/importer_service_test.go | 41 +++++++++++++++++++ 2 files changed, 59 insertions(+), 11 deletions(-) create mode 100644 internal/service/importer/importer_service_test.go diff --git a/internal/service/importer/importer_service.go b/internal/service/importer/importer_service.go index 9d12bf07b..1842479b3 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 = "

" + questionInfo.Content + "

" - 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 000000000..ff32740b4 --- /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: ``, + Tags: []string{"security"}, + }) + + for _, unsafeContent := range []string{"onerror", " Date: Tue, 9 Jun 2026 18:22:58 +0300 Subject: [PATCH 20/49] i18n: complete Russian (ru_RU) translation --- i18n/i18n.yaml | 2 +- i18n/ru_RU.yaml | 797 ++++++++++++++++++++++++------------------------ 2 files changed, 401 insertions(+), 398 deletions(-) diff --git a/i18n/i18n.yaml b/i18n/i18n.yaml index 7abb748db..e2c89c69b 100644 --- a/i18n/i18n.yaml +++ b/i18n/i18n.yaml @@ -43,7 +43,7 @@ language_options: progress: 96 - label: "Русский" value: "ru_RU" - progress: 80 + progress: 100 - label: "简体中文" value: "zh_CN" progress: 100 diff --git a/i18n/ru_RU.yaml b/i18n/ru_RU.yaml index 525083210..e716faa95 100644 --- a/i18n/ru_RU.yaml +++ b/i18n/ru_RU.yaml @@ -140,7 +140,7 @@ backend: pass: other: Пароль old_pass: - other: Current password + other: Текущий пароль original_text: other: Это сообщение email_or_password_wrong_error: @@ -182,7 +182,7 @@ backend: cannot_edit_after_deadline: other: Невозможно редактировать комментарий из-за того, что он был создан слишком давно. content_cannot_empty: - other: Comment content cannot be empty. + other: Содержимое комментария не может быть пустым. email: duplicate: other: Адрес электронной почты уже существует. @@ -233,9 +233,9 @@ backend: cannot_update: other: Нет разрешения на обновление. content_cannot_empty: - other: . + other: Содержимое не может быть пустым content_less_than_minimum: - other: Not enough content entered. + other: Введено недостаточно содержимого. rank: fail_to_meet_the_condition: other: Ранг репутации не соответствует условию. @@ -266,7 +266,7 @@ backend: cannot_set_synonym_as_itself: other: Вы не можете установить синоним текущего тега. minimum_count: - other: Not enough tags were entered. + other: Введено недостаточно тегов. smtp: config_from_name_cannot_be_email: other: Поле отправителя не может содержать email адрес. @@ -311,13 +311,13 @@ backend: add_bulk_users_amount_error: other: "Количество пользователей, которое Вы добавляете, должно быть в промежутке от 1 до {{.MaxAmount}}." status_suspended_forever: - other: "This user was suspended forever. This user doesn't meet a community guideline." + other: "Этот пользователь заблокирован навсегда. Этот пользователь не соответствует правилам сообщества." status_suspended_until: - other: "This user was suspended until {{.SuspendedUntil}}. This user doesn't meet a community guideline." + other: "Этот пользователь заблокирован до {{.SuspendedUntil}}. Этот пользователь не соответствует правилам сообщества." status_deleted: - other: "This user was deleted." + other: "Этот пользователь удален." status_inactive: - other: "This user is inactive." + other: "Этот пользователь неактивен." config: read_config_failed: other: Не удалось прочитать конфигурацию @@ -506,7 +506,7 @@ backend: title: other: "[{{.SiteName}}] Новый вопрос: {{.QuestionTitle}}" body: - other: "{{.QuestionTitle}}
\n{{.Tags}}

\n\n--
\nNote: This is an automatic system email, please do not reply to this message as your response will not be seen.

\n\nUnsubscribe" + other: "{{.QuestionTitle}}
\n{{.Tags}}

\n\n--
\nПримечание: Данное сообщение является автоматическим, отвечать на него не нужно.

\n\nОтписаться" pass_reset: title: other: "[{{.SiteName }}] Пароль сброшен" @@ -576,37 +576,37 @@ backend: other: Впервые добавить голос в сообщении. first_link: name: - other: First Link + other: Первая ссылка desc: - other: First added a link to another post. + other: Впервые добавил ссылку на другой пост. first_reaction: name: - other: First Reaction + other: Первая реакция desc: - other: First reacted to the post. + other: Впервые отреагировал на пост. first_share: name: - other: First Share + other: Первый репост desc: - other: First shared a post. + other: Впервые поделился постом. scholar: name: - other: Scholar + other: Ученик desc: - other: Asked a question and accepted an answer. + other: Задал вопрос и принял ответ. commentator: name: - other: Commentator + other: Комментатор desc: other: Оставить 5 комментариев. new_user_of_the_month: name: - other: New User of the Month + other: Новичок месяца desc: - other: Outstanding contributions in their first month. + other: Выдающийся вклад в первый месяц. read_guidelines: name: - other: Read Guidelines + other: Прочитал правила desc: other: Прочтите [правила сообщества]. reader: @@ -623,193 +623,193 @@ backend: name: other: Неплохо поделился desc: - other: Shared a post with 25 unique visitors. + other: Поделился постом с 25 уникальными посетителями. good_share: name: - other: Good Share + other: Хороший репост desc: - other: Shared a post with 300 unique visitors. + other: Поделился постом с 300 уникальными посетителями. great_share: name: - other: Great Share + other: Отличный репост desc: - other: Shared a post with 1000 unique visitors. + other: Поделился постом с 1000 уникальными посетителями. out_of_love: name: - other: Out of Love + other: От любви desc: - other: Used 50 up votes in a day. + other: Поставил 50 голосов «за» за день. higher_love: name: - other: Higher Love + other: Большая любовь desc: - other: Used 50 up votes in a day 5 times. + other: Поставил 50 голосов «за» за день 5 раз. crazy_in_love: name: - other: Crazy in Love + other: Без ума от любви desc: - other: Used 50 up votes in a day 20 times. + other: Поставил 50 голосов «за» за день 20 раз. promoter: name: - other: Promoter + other: Промоутер desc: - other: Invited a user. + other: Пригласил пользователя. campaigner: name: - other: Campaigner + other: Агитатор desc: - other: Invited 3 basic users. + other: Пригласил 3 базовых пользователей. champion: name: - other: Champion + other: Чемпион desc: - other: Invited 5 members. + other: Пригласил 5 участников. thank_you: name: other: Спасибо desc: - other: Has 20 up voted posts and gave 10 up votes. + other: Имеет 20 постов с голосами «за» и отдал 10 голосов «за». gives_back: name: - other: Gives Back + other: Отдает взамен desc: - other: Has 100 up voted posts and gave 100 up votes. + other: Имеет 100 постов с голосами «за» и отдал 100 голосов «за». empathetic: name: - other: Empathetic + other: Чуткий desc: - other: Has 500 up voted posts and gave 1000 up votes. + other: Имеет 500 постов с голосами «за» и отдал 1000 голосов «за». enthusiast: name: - other: Enthusiast + other: Энтузиаст desc: - other: Visited 10 consecutive days. + other: Заходил 10 дней подряд. aficionado: name: - other: Aficionado + other: Поклонник desc: - other: Visited 100 consecutive days. + other: Заходил 100 дней подряд. devotee: name: - other: Devotee + other: Преданный desc: - other: Visited 365 consecutive days. + other: Заходил 365 дней подряд. anniversary: name: - other: Anniversary + other: Годовщина desc: - other: Активный участник на год, опубликовал по крайней мере один раз. + other: Активный участник в течение года с минимум одной публикацией. appreciated: name: - other: Appreciated + other: Оцененный desc: - other: Received 1 up vote on 20 posts. + other: Получил 1 голос «за» на 20 постах. respected: name: - other: Respected + other: Уважаемый desc: - other: Received 2 up votes on 100 posts. + other: Получил 2 голоса «за» на 100 постах. admired: name: - other: Admired + other: Признанный desc: - other: Received 5 up votes on 300 posts. + other: Получил 5 голосов «за» на 300 постах. solved: name: - other: Solved + other: Решено desc: - other: Have an answer be accepted. + other: Один из ваших ответов был принят. guidance_counsellor: name: - other: Guidance Counsellor + other: Наставник desc: - other: Have 10 answers be accepted. + other: Принято 10 ваших ответов. know_it_all: name: - other: Know-it-All + other: Всезнайка desc: - other: Have 50 answers be accepted. + other: Принято 50 ваших ответов. solution_institution: name: - other: Solution Institution + other: Кладезь решений desc: - other: Have 150 answers be accepted. + other: Принято 150 ваших ответов. nice_answer: name: - other: Nice Answer + other: Хороший ответ desc: - other: Answer score of 10 or more. + other: Рейтинг ответа 10 или более. good_answer: name: - other: Good Answer + other: Отличный ответ desc: - other: Answer score of 25 or more. + other: Рейтинг ответа 25 или более. great_answer: name: - other: Great Answer + other: Превосходный ответ desc: - other: Answer score of 50 or more. + other: Рейтинг ответа 50 или более. nice_question: name: - other: Nice Question + other: Хороший вопрос desc: - other: Question score of 10 or more. + other: Рейтинг вопроса 10 или более. good_question: name: - other: Good Question + other: Отличный вопрос desc: - other: Question score of 25 or more. + other: Рейтинг вопроса 25 или более. great_question: name: - other: Great Question + other: Превосходный вопрос desc: - other: Question score of 50 or more. + other: Рейтинг вопроса 50 или более. popular_question: name: - other: Popular Question + other: Популярный вопрос desc: - other: Question with 500 views. + other: Вопрос с 500 просмотрами. notable_question: name: - other: Notable Question + other: Заметный вопрос desc: - other: Question with 1,000 views. + other: Вопрос с 1000 просмотрами. famous_question: name: - other: Famous Question + other: Знаменитый вопрос desc: - other: Question with 5,000 views. + other: Вопрос с 5000 просмотрами. popular_link: name: - other: Popular Link + other: Популярная ссылка desc: - other: Posted an external link with 50 clicks. + other: Опубликовал внешнюю ссылку с 50 переходами. hot_link: name: - other: Hot Link + other: Горячая ссылка desc: - other: Posted an external link with 300 clicks. + other: Опубликовал внешнюю ссылку с 300 переходами. famous_link: name: - other: Famous Link + other: Знаменитая ссылка desc: - other: Posted an external link with 100 clicks. + other: Опубликовал внешнюю ссылку с 100 переходами. default_badge_groups: getting_started: name: - other: Getting Started + other: Начало работы community: name: - other: Community + other: Сообщество posting: name: - other: Posting + other: Публикации # The following fields are used for interface presentation(Front-end) ui: how_to_format: title: 'Форматирование:' desc: >- -
  • mention a post: #post_id

  • to make links

    <https://url.com>

    [Title](https://url.com)
  • put returns between paragraphs

  • _italic_ or **bold**

  • indent code by 4 spaces

  • quote by placing > at start of line

  • backtick escapes `like _this_`

  • create code fences with backticks `

    ```
    code here
    ```
+
  • упомянуть пост: #post_id

  • чтобы создать ссылки

    <https://url.com>

    [Заголовок](https://url.com)
  • разделяйте абзацы пустыми строками

  • _курсив_ или **жирный**

  • отступ кода в 4 пробела

  • цитата с помощью > в начале строки

  • обратные кавычки экранируют `вот _так_`

  • создавайте блоки кода обратными кавычками `

    ```
    код здесь
    ```
pagination: prev: Назад next: Следующий @@ -821,7 +821,7 @@ ui: tag_wiki: wiki тэг create_tag: Создать тег edit_tag: Изменить тег - ask_a_question: Create Question + ask_a_question: Создать вопрос edit_question: Редактировать вопрос edit_answer: Редактировать ответ search: Поиск @@ -845,17 +845,17 @@ ui: http_50X: Ошибка HTTP 500 http_403: Ошибка HTTP 403 logout: Выйти - posts: Posts - ai_assistant: AI Assistant + posts: Посты + ai_assistant: AI-ассистент ai_assistant: - description: Got a question? Ask it and get answers, perspectives, and recommendations. - recent_conversations: Recent Conversations - show_more: Show more - new: New chat - ai_generate: AI-generated from posts and may not be accurate. - copy: Copy - ask_a_follow_up: Ask a follow-up - ask_placeholder: Ask a question + description: Есть вопрос? Задайте его и получите ответы, мнения и рекомендации. + recent_conversations: Недавние обсуждения + show_more: Показать еще + new: Новый чат + ai_generate: Сгенерировано ИИ на основе постов и может быть неточным. + copy: Копировать + ask_a_follow_up: Задать уточняющий вопрос + ask_placeholder: Задайте вопрос notifications: title: Уведомления inbox: Входящие @@ -981,8 +981,8 @@ ui: cell: Ячейка file: text: Прикрепить файлы - not_supported: "Don’t support that file type. Try again with {{file_type}}." - max_size: "Attach files size cannot exceed {{size}} MB." + not_supported: "Этот тип файла не поддерживается. Попробуйте снова с {{file_type}}." + max_size: "Размер прикрепляемых файлов не может превышать {{size}} МБ." close_modal: title: Я закрываю этот пост как... btn_cancel: Отменить @@ -1047,20 +1047,20 @@ ui: delete: title: Удалить этот тег tip_with_posts: >- -

We do not allow deleting tag with posts.

Please remove this tag from the posts first.

+

Мы не разрешаем удалять тег с постами.

Сначала удалите этот тег из постов.

tip_with_synonyms: >- -

We do not allow deleting tag with synonyms.

Please remove the synonyms from this tag first.

+

Мы не разрешаем удалять тег с синонимами.

Сначала удалите синонимы у этого тега.

tip: Вы уверены, что хотите удалить? close: Закрыть merge: - title: Merge tag - source_tag_title: Source tag - source_tag_description: The source tag and its associated data will be remapped to the target tag. - target_tag_title: Target tag - target_tag_description: A synonym between these two tags will be created after merging. - no_results: No tags matched - btn_submit: Submit - btn_close: Close + title: Объединить тег + source_tag_title: Исходный тег + source_tag_description: Исходный тег и связанные с ним данные будут переназначены на целевой тег. + target_tag_title: Целевой тег + target_tag_description: После объединения между этими двумя тегами будет создан синоним. + no_results: Нет подходящих тегов + btn_submit: Отправить + btn_close: Закрыть edit_tag: title: Изменить тег default_reason: Правка тега @@ -1079,17 +1079,17 @@ ui: day: дней hours: часов days: дней - month: month - months: months - year: year + month: месяц + months: месяцев + year: год reaction: heart: сердечко - smile: smile - frown: frown + smile: улыбка + frown: недовольство btn_label: добавить или удалить реакции undo_emoji: отменить реакцию {{ emoji }} - react_emoji: react with {{ emoji }} - unreact_emoji: unreact with {{ emoji }} + react_emoji: поставить реакцию {{ emoji }} + unreact_emoji: убрать реакцию {{ emoji }} comment: btn_add_comment: Добавить комментарий reply_to: Ответить на @@ -1135,12 +1135,12 @@ ui: search_placeholder: Фильтр по названию тега no_desc: Тег не имеет описания. more: Подробнее - wiki: Wiki + wiki: Вики ask: - title: Create Question + title: Создать вопрос edit_title: Редактировать вопрос default_reason: Редактировать вопрос - default_first_reason: Create question + default_first_reason: Создать вопрос similar_questions: Похожие вопросы form: fields: @@ -1148,7 +1148,7 @@ ui: label: Версия title: label: Заголовок - placeholder: What's your topic? Be specific. + placeholder: О чем ваш вопрос? Сформулируйте конкретно. msg: empty: Заголовок не может быть пустым. range: Заголовок должен быть меньше 150 символов @@ -1157,8 +1157,8 @@ ui: msg: empty: Вопрос не может быть пустым. hint: - optional_body: Describe what the question is about. - minimum_characters: "Describe what the question is about, at least {{min_content_length}} characters are required." + optional_body: Опишите, о чем ваш вопрос. + minimum_characters: "Опишите, о чем ваш вопрос, требуется минимум {{min_content_length}} символов." tags: label: Теги msg: @@ -1179,9 +1179,9 @@ ui: add_btn: Тег create_btn: новый тег search_tag: Поиск тега - hint: Describe what your content is about, at least one tag is required. - hint_zero_tags: Describe what your content is about. - hint_more_than_one_tag: "Describe what your content is about, at least {{min_tags_number}} tags are required." + hint: Опишите, о чем ваш контент, требуется минимум один тег. + hint_zero_tags: Опишите, о чем ваш контент. + hint_more_than_one_tag: "Опишите, о чем ваш контент, требуется минимум {{min_tags_number}} тегов." no_result: Нет соответствующих тэгов tag_required_text: Обязательный тег (хотя бы один) header: @@ -1200,7 +1200,7 @@ ui: search: placeholder: Поиск footer: - build_on: Powered by <1> Apache Answer + build_on: Сделано с помощью <1> Apache Answer upload_img: name: Изменить loading: загрузка... @@ -1232,8 +1232,8 @@ ui: label: Имя пользователя msg: empty: Имя пользователя не должно быть пустым. - range: Name must be between 2 to 30 characters in length. - character: 'Must use the character set "a-z", "0-9", " - . _"' + range: Имя должно содержать от 2 до 30 символов. + character: 'Используйте набор символов "a-z", "0-9", " - . _"' email: label: Email адрес msg: @@ -1305,13 +1305,13 @@ ui: display_name: label: Отображаемое имя msg: Отображаемое имя не может быть пустым. - msg_range: Display name must be 2-30 characters in length. + msg_range: Отображаемое имя должно содержать от 2 до 30 символов. username: label: Имя пользователя caption: Люди могут упоминать вас как "@username". msg: Имя пользователя не может быть пустым. - msg_range: Username must be 2-30 characters in length. - character: 'Must use the character set "a-z", "0-9", "- . _"' + msg_range: Имя пользователя должно содержать от 2 до 30 символов. + character: 'Используйте набор символов "a-z", "0-9", "- . _"' avatar: label: Изображение профиля gravatar: Gravatar @@ -1348,7 +1348,7 @@ ui: change_email_info: >- Мы отправили электронное письмо на этот адрес. Пожалуйста, следуйте инструкциям из письма. email: - label: Email + label: Эл. почта new_email: label: Новый email msg: Новый email не может быть пустым. @@ -1386,27 +1386,27 @@ ui: review: Ваша версия будет отображаться после проверки. sent_success: Отправлено успешно related_question: - title: Related + title: Похожие answers: ответы linked_question: - title: Linked - description: Posts linked to - no_linked_question: No contents linked from this content. + title: Связанные + description: Посты, связанные с + no_linked_question: Из этого контента нет ссылок на другой контент. invite_to_answer: - title: Позвать на помощь + title: Помощь desc: Выберите людей, которые, по вашему мнению, могут знать ответ. invite: Пригласил вас ответить add: Добавить пользователей search: Поиск людей question_detail: action: Действия - created: Created + created: Создано Asked: Спросил(а) asked: спросил(а) update: Изменён - Edited: Edited + Edited: Изменено edit: отредактировал - commented: commented + commented: прокомментировал Views: Просмотрен Follow: Подписаться Following: Подписки @@ -1424,7 +1424,7 @@ ui: title: Ответы score: Оценка newest: Последние - oldest: Oldest + oldest: Сначала старые btn_accept: Принять btn_accepted: Принято write_answer: @@ -1450,12 +1450,12 @@ ui: content: Вы уверены, что хотите открыть заново? list: confirm_btn: Список - title: List this post - content: Are you sure you want to list? + title: Добавить эту запись в список + content: Вы уверены, что хотите добавить в список? unlist: confirm_btn: Убрать из списка - title: Unlist this post - content: Are you sure you want to unlist? + title: Убрать эту запись из списка + content: Вы уверены, что хотите убрать из списка? pin: title: Закрепить сообщение content: Вы уверены, что хотите закрепить глобально? Это сообщение появится вверху всех списков сообщений. @@ -1477,14 +1477,14 @@ ui: save: Сохранить delete: Удалить undelete: Отменить удаление - list: List - unlist: Unlist - unlisted: Unlisted + list: В список + unlist: Убрать из списка + unlisted: Убрано из списка login: Авторизоваться signup: Регистрация logout: Выйти verify: Подтвердить - create: Create + create: Создать approve: Одобрить reject: Отклонить skip: Пропустить @@ -1508,24 +1508,24 @@ ui: system_setting: Настройки системы default: По умолчанию reset: Сбросить - tag: Tag - post_lowercase: post - filter: Filter - ignore: Ignore - submit: Submit - normal: Normal - closed: Closed - deleted: Deleted - deleted_permanently: Deleted permanently - pending: Pending - more: More - view: View - card: Card - compact: Compact - display_below: Display below - always_display: Always display - or: or - back_sites: Back to sites + tag: Тег + post_lowercase: запись + filter: Фильтр + ignore: Игнорировать + submit: Отправить + normal: Обычная + closed: Закрыта + deleted: Удалена + deleted_permanently: Удалена навсегда + pending: В ожидании + more: Еще + view: Вид + card: Карточки + compact: Компактный + display_below: Показывать ниже + always_display: Всегда показывать + or: или + back_sites: Вернуться к сайтам search: title: Результаты поиска keywords: Ключевые слова @@ -1533,7 +1533,7 @@ ui: follow: Подписаться following: Подписка counts: "Результатов: {{count}}" - counts_loading: "... Results" + counts_loading: "... Результаты" more: Ещё sort_btns: relevance: По релевантности @@ -1543,7 +1543,7 @@ ui: more: Больше tips: title: Советы по расширенному поиску - tag: "<1>[tag] search with a tag" + tag: "<1>[tag] поиск по тегу" user: "<1>user:username поиск по автору" answer: "<1>ответов:0 вопросы без ответов" score: "<1>score:3 записи с рейтингом 3+" @@ -1556,18 +1556,18 @@ ui: via: Поделитесь постом через... copied: Скопировано facebook: Поделиться на Facebook - twitter: Share to X + twitter: Поделиться в X cannot_vote_for_self: Вы не можете проголосовать за свой собственный пост. modal_confirm: title: Ошибка... delete_permanently: - title: Delete permanently - content: Are you sure you want to delete permanently? + title: Удалить навсегда + content: Вы уверены, что хотите удалить навсегда? account_result: success: Ваша новая учетная запись подтверждена; вы будете перенаправлены на главную страницу. link: Перейти на главную - oops: Oops! - invalid: The link you used no longer works. + oops: Упс! + invalid: Ссылка, которую вы использовали, больше не работает. confirm_new_email: Ваш адрес электронной почты был обновлен. confirm_new_email_invalid: >- Извините, эта ссылка для подтверждения больше недействительна. Возможно, ваш адрес электронной почты уже был изменен? @@ -1585,14 +1585,14 @@ ui: all_questions: Все вопросы x_questions: "{{ count }} вопросов" x_answers: "{{ count }} ответов" - x_posts: "{{ count }} Posts" + x_posts: "{{ count }} записей" questions: Вопросы answers: Ответы newest: Последние active: Активные - hot: Hot - frequent: Frequent - recommend: Recommend + hot: Популярные + frequent: Частые + recommend: Рекомендованные score: Оценка unanswered: Без ответа modified: изменён @@ -1611,7 +1611,7 @@ ui: reputation: Репутация comments: Комментарии votes: Голоса - badges: Badges + badges: Значки newest: Последние score: Оценки edit_profile: Редактировать профиль @@ -1626,20 +1626,20 @@ ui: top_questions: Топ вопросов stats: Статистика list_empty: Сообщений не найдено.
Возможно, вы хотели бы выбрать другую вкладку? - content_empty: No posts found. + content_empty: Записи не найдены. accepted: Принято answered: отвеченные asked: спросил downvoted: проголосовано против - mod_short: MOD + mod_short: МОД mod_long: Модераторы x_reputation: репутация x_votes: полученные голоса x_answers: ответы x_questions: вопросы - recent_badges: Recent Badges + recent_badges: Недавние значки install: - title: Installation + title: Установка next: Следующий done: Готово config_yaml_error: Не удается создать файл config.yaml. @@ -1668,22 +1668,22 @@ ui: placeholder: /data/answer.db msg: Файл базы данных не может быть пустым. ssl_enabled: - label: Enable SSL + label: Включить SSL ssl_enabled_on: - label: On + label: Да ssl_enabled_off: - label: Off + label: Нет ssl_mode: - label: SSL Mode + label: Режим SSL ssl_root_cert: - placeholder: sslrootcert file path - msg: Path to sslrootcert file cannot be empty + placeholder: путь к файлу sslrootcert + msg: Путь к файлу sslrootcert не может быть пустым ssl_cert: - placeholder: sslcert file path - msg: Path to sslcert file cannot be empty + placeholder: путь к файлу sslcert + msg: Путь к файлу sslcert не может быть пустым ssl_key: - placeholder: sslkey file path - msg: Path to sslkey file cannot be empty + placeholder: путь к файлу sslkey + msg: Путь к файлу sslkey не может быть пустым config_yaml: title: Создайте файл config.yaml label: Файл config.yaml создан. @@ -1716,8 +1716,8 @@ ui: admin_name: label: Имя msg: Имя не может быть пустым. - character: 'Must use the character set "a-z", "0-9", " - . _"' - msg_max_length: Name must be between 2 to 30 characters in length. + character: 'Можно использовать символы из набора "a-z", "0-9", " - . _"' + msg_max_length: Длина имени должна составлять от 2 до 30 символов. admin_password: label: Пароль text: >- @@ -1726,16 +1726,16 @@ ui: msg_min_length: Длина пароля должна составлять не менее 8 символов. msg_max_length: Длина пароля должна составлять не более 32 символов. admin_confirm_password: - label: "Confirm Password" - text: "Please re-enter your password to confirm." - msg: "Confirm password does not match." + label: "Подтверждение пароля" + text: "Пожалуйста, введите пароль повторно для подтверждения." + msg: "Подтверждение пароля не совпадает." admin_email: - label: Email + label: Эл. почта text: Вам понадобится этот адрес электронной почты для входа в систему. msg: empty: Адрес электронной почты не может быть пустым. incorrect: Недопустимый формат e-mail адреса. - ready_title: Your site is ready + ready_title: Ваш сайт готов ready_desc: >- Если вам когда-нибудь захочется изменить дополнительные настройки, посетите <1>раздел администратора; найдите его в меню сайта. good_luck: "Получайте удовольствие и удачи!" @@ -1768,7 +1768,7 @@ ui: questions: Вопросы answers: Ответы users: Пользователи - badges: Badges + badges: Значки flags: Отметить settings: Настройки general: Основные @@ -1777,7 +1777,7 @@ ui: branding: Фирменное оформление legal: Правовая информация write: Написать - terms: Terms + terms: Условия tos: Пользовательское Соглашение privacy: Конфиденциальность seo: SEO @@ -1787,18 +1787,18 @@ ui: privileges: Привилегии plugins: Плагины installed_plugins: Установленные плагины - apperance: Appearance - community: Community - advanced: Advanced - tags: Tags - rules: Rules - policies: Policies - security: Security - files: Files - apikeys: API Keys - intelligence: Intelligence - ai_assistant: AI Assistant - ai_settings: AI Settings + apperance: Внешний вид + community: Сообщество + advanced: Дополнительно + tags: Теги + rules: Правила + policies: Политики + security: Безопасность + files: Файлы + apikeys: API-ключи + intelligence: Интеллект + ai_assistant: ИИ-ассистент + ai_settings: Настройки ИИ mcp: MCP website_welcome: Добро пожаловать на {{site_name}} user_center: @@ -1807,32 +1807,32 @@ ui: login_failed_email_tip: Не удалось войти в систему, пожалуйста, разрешите этому приложению получить доступ к вашей электронной почте, прежде чем повторять попытку. badges: modal: - title: Congratulations - content: You've earned a new badge. - close: Close - confirm: View badges - title: Badges - awarded: Awarded - earned_×: Earned ×{{ number }} - ×_awarded: "{{ number }} awarded" - can_earn_multiple: You can earn this multiple times. - earned: Earned + title: Поздравляем + content: Вы получили новый значок. + close: Закрыть + confirm: Посмотреть значки + title: Значки + awarded: Присвоено + earned_×: Получено ×{{ number }} + ×_awarded: "присвоено: {{ number }}" + can_earn_multiple: Этот значок можно получить несколько раз. + earned: Получено admin: admin_header: title: Администратор dashboard: title: Панель управления - welcome: Welcome to Admin! + welcome: Добро пожаловать в панель администратора! site_statistics: Статистика сайта questions: "Вопросы:" - resolved: "Resolved:" - unanswered: "Unanswered:" + resolved: "Решено:" + unanswered: "Без ответа:" answers: "Ответы:" comments: "Комментарии:" votes: "Голоса:" users: "Пользователи:" flags: "Жалобы:" - reviews: "Reviews:" + reviews: "На проверке:" site_health: Здоровье сайта version: "Версия:" https: "HTTPS:" @@ -1894,21 +1894,21 @@ ui: btn_cancel: Отменить btn_submit: Отправить edit_profile_modal: - title: Edit profile + title: Редактировать профиль form: fields: display_name: - label: Display name - msg_range: Display name must be 2-30 characters in length. + label: Отображаемое имя + msg_range: Отображаемое имя должно содержать от 2 до 30 символов. username: - label: Username - msg_range: Username must be 2-30 characters in length. + label: Имя пользователя + msg_range: Имя пользователя должно содержать от 2 до 30 символов. email: - label: Email - msg_invalid: Invalid Email Address. - edit_success: Edited successfully - btn_cancel: Cancel - btn_submit: Submit + label: Электронная почта + msg_invalid: Неверный адрес электронной почты. + edit_success: Изменено успешно + btn_cancel: Отмена + btn_submit: Отправить user_modal: title: Создание новых пользователей form: @@ -1920,7 +1920,7 @@ ui: msg: "Пожалуйста, введите адрес электронной почты пользователя, по одному на строку." display_name: label: Отображаемое имя - msg: Display name must be 2-30 characters in length. + msg: Отображаемое имя должно содержать от 2 до 30 символов. email: label: Email msg: Некорректный email. @@ -1934,10 +1934,10 @@ ui: name: Имя email: Email reputation: Репутация - created_at: Created time - delete_at: Deleted time - suspend_at: Suspended time - suspend_until: Suspend until + created_at: Время создания + delete_at: Время удаления + suspend_at: Время блокировки + suspend_until: Заблокировать до status: Статус role: Роль action: Действия @@ -1955,7 +1955,7 @@ ui: filter: placeholder: "Фильтровать по имени, user:id" set_new_password: Задать новый пароль - edit_profile: Edit profile + edit_profile: Редактировать профиль change_status: Изменить статус change_role: Изменить роль show_logs: Показать логи @@ -1972,11 +1972,11 @@ ui: suspend_user: title: Заблокировать этого пользователя content: Заблокированный пользователь не сможет войти. - label: How long will the user be suspended for? - forever: Forever + label: На какой срок заблокировать пользователя? + forever: Навсегда questions: page_title: Вопросы - unlisted: Unlisted + unlisted: Скрытый из списка post: Публикация votes: Голоса answers: Ответы @@ -2035,11 +2035,11 @@ ui: msg: Часовой пояс не может быть пустым. text: Выберите город в том же часовом поясе, что и вы. avatar: - label: Default avatar - text: For users without a custom avatar of their own. + label: Аватар по умолчанию + text: Для пользователей без собственного аватара. gravatar_base_url: - label: Gravatar base URL - text: URL of the Gravatar provider's API base. Ignored when empty. + label: Базовый URL Gravatar + text: URL базы API провайдера Gravatar. Игнорируется, если пусто. smtp: page_title: SMTP from_email: @@ -2106,49 +2106,49 @@ ui: label: Условия конфиденциальности text: "Вы можете добавить содержание политики конфиденциальности здесь. Если у вас уже есть документ, размещенный в другом месте, укажите полный URL-адрес здесь." external_content_display: - label: External content - text: "Content includes images, videos, and media embedded from external websites." - always_display: Always display external content - ask_before_display: Ask before displaying external content + label: Внешний контент + text: "Контент включает изображения, видео и медиа, встроенные с внешних сайтов." + always_display: Всегда отображать внешний контент + ask_before_display: Спрашивать перед отображением внешнего контента write: - page_title: Files + page_title: Файлы min_content: - label: Minimum question body length - text: Minimum allowed question body length in characters. + label: Минимальная длина текста вопроса + text: Минимально допустимая длина текста вопроса в символах. restrict_answer: - title: Answer write + title: Написание ответов label: Каждый пользователь может написать только один ответ на каждый вопрос - text: "Turn off to allow users to write multiple answers to the same question, which may cause answers to be unfocused." + text: "Отключите, чтобы разрешить пользователям писать несколько ответов на один вопрос, что может привести к потере фокуса ответов." min_tags: - label: "Minimum tags per question" - text: "Minimum number of tags required in a question." + label: "Минимум тегов на вопрос" + text: "Минимальное число тегов, требуемых в вопросе." recommend_tags: label: Рекомендованные теги - text: "Recommend tags will show in the dropdown list by default." + text: "Рекомендуемые теги по умолчанию будут отображаться в выпадающем списке." msg: - contain_reserved: "recommended tags cannot contain reserved tags" + contain_reserved: "рекомендуемые теги не могут содержать зарезервированные теги" required_tag: - title: Set required tags - label: Set “Recommend tags” as required tags + title: Задать обязательные теги + label: Сделать «рекомендуемые теги» обязательными text: "Каждый новый вопрос должен иметь хотя бы один рекомендуемый тег." reserved_tags: label: Зарезервированные теги - text: "Reserved tags can only be used by moderator." + text: "Зарезервированные теги могут использоваться только модератором." image_size: - label: Max image size (MB) - text: "The maximum image upload size." + label: Макс. размер изображения (МБ) + text: "Максимальный размер загружаемого изображения." attachment_size: - label: Max attachment size (MB) - text: "The maximum attachment files upload size." + label: Макс. размер вложения (МБ) + text: "Максимальный размер загружаемых файлов вложений." image_megapixels: - label: Max image megapixels - text: "Maximum number of megapixels allowed for an image." + label: Макс. число мегапикселей изображения + text: "Максимальное число мегапикселей, допустимое для изображения." image_extensions: - label: Authorized image extensions - text: "A list of file extensions allowed for image display, separate with commas." + label: Разрешенные расширения изображений + text: "Список расширений файлов, разрешенных для отображения изображений, через запятую." attachment_extensions: - label: Authorized attachment extensions - text: "A list of file extensions allowed for upload, separate with commas. WARNING: Allowing uploads may cause security issues." + label: Разрешенные расширения вложений + text: "Список расширений файлов, разрешенных для загрузки, через запятую. ВНИМАНИЕ: разрешение загрузки может привести к проблемам с безопасностью." seo: page_title: SEO permalink: @@ -2165,27 +2165,30 @@ ui: color_scheme: label: Цветовая схема navbar_style: - label: Navbar background style + label: Стиль фона панели навигации primary_color: label: Основной цвет text: Измените цвета, используемые в ваших темах layout: - label: Layout - full_width: Full-width - fixed_width: Fixed-width + label: Макет + full_width: На всю ширину + fixed_width: Фиксированная ширина css_and_html: page_title: CSS и HTML custom_css: label: Пользовательский CSS - text: > + text: >- + Это будет вставлено как <link> head: label: Head - text: > + text: >- + Это будет вставлено перед </head> header: label: Header - text: > + text: >- + Это будет вставлено после <body> footer: label: Нижняя панель @@ -2216,7 +2219,7 @@ ui: text: "Предупреждение: При отключении, вы не сможете войти, если ранее не настроили другой способ входа." installed_plugins: title: Установленные плагины - plugin_link: Plugins extend and expand the functionality. You may find plugins in the <1>Plugin Repository. + plugin_link: Плагины расширяют и дополняют функциональность. Вы можете найти плагины в <1>репозитории плагинов. filter: all: Все active: Активные @@ -2260,94 +2263,94 @@ ui: label: Необходимый уровень репутации text: Выберите количество репутации, необходимое для получения привилегий msg: - should_be_number: the input should be number - number_larger_1: number should be equal or larger than 1 + should_be_number: значение должно быть числом + number_larger_1: число должно быть равно или больше 1 badges: - action: Action - active: Active - activate: Activate - all: All - awards: Awards - deactivate: Deactivate + action: Действие + active: Активные + activate: Активировать + all: Все + awards: Награды + deactivate: Деактивировать filter: - placeholder: Filter by name, badge:id - group: Group - inactive: Inactive - name: Name - show_logs: Show logs - status: Status - title: Badges + placeholder: Фильтр по имени, badge:id + group: Группа + inactive: Неактивные + name: Название + show_logs: Показать логи + status: Статус + title: Значки apikeys: - title: API Keys - add_api_key: Add API Key - desc: Description - scope: Scope - key: Key - created: Created - last_used: Last used + title: API-ключи + add_api_key: Добавить API-ключ + desc: Описание + scope: Область действия + key: Ключ + created: Создан + last_used: Последнее использование add_or_edit_modal: - add_title: Add API Key - edit_title: Edit API Key - description: Description - description_required: Description is required. - scope: Scope - global: Global - read-only: Read-only + add_title: Добавить API-ключ + edit_title: Изменить API-ключ + description: Описание + description_required: Описание обязательно. + scope: Область действия + global: Глобальный + read-only: Только для чтения created_modal: - title: API key created - api_key: API key - description: This key will not be displayed again. Make sure you take a copy before continuing. + title: API-ключ создан + api_key: API-ключ + description: Этот ключ больше не будет показан. Обязательно скопируйте его, прежде чем продолжить. delete_modal: - title: Delete API Key - content: Any applications or scripts using this key will no longer be able to access the API. This is permanent! + title: Удалить API-ключ + content: Любые приложения или скрипты, использующие этот ключ, больше не смогут обращаться к API. Это действие необратимо! ai_settings: enabled: - label: AI enabled - check: Enable AI features - text: The AI model must be configured correctly before it can be used. + label: ИИ включен + check: Включить функции ИИ + text: Перед использованием модель ИИ должна быть корректно настроена. provider: - label: Provider + label: Провайдер api_host: - label: API host - msg: API host is required + label: API-хост + msg: API-хост обязателен api_key: - label: API key - check: Check - check_success: "Connection successful." - msg: API key is required + label: API-ключ + check: Проверить + check_success: "Подключение выполнено успешно." + msg: API-ключ обязателен model: - label: Model - msg: Model is required - add_success: AI settings updated successfully. + label: Модель + msg: Модель обязательна + add_success: Настройки ИИ успешно обновлены. conversations: - topic: Topic - helpful: Helpful - unhelpful: Unhelpful - created: Created - action: Action - empty: No conversations found. + topic: Тема + helpful: Полезный + unhelpful: Бесполезный + created: Создан + action: Действие + empty: Диалоги не найдены. delete_modal: - title: Delete conversation - content: Are you sure you want to delete this conversation? This is permanent! - delete_success: Conversation deleted successfully. + title: Удалить диалог + content: Вы уверены, что хотите удалить этот диалог? Это действие необратимо! + delete_success: Диалог успешно удален. mcp: mcp_server: - label: MCP server - switch: Enabled + label: MCP-сервер + switch: Включен type: - label: Type + label: Тип url: label: URL http_header: - label: HTTP header - text: Please replace {key} with the API Key. + label: HTTP-заголовок + text: Замените {key} на API-ключ. form: optional: (опционально) empty: не может быть пустым invalid: недействителен btn_submit: Сохранить not_found_props: "Требуемое свойство {{ key }} не найдено." - select: Select + select: Выбрать page_review: review: На проверку proposed: предложенный @@ -2359,22 +2362,22 @@ ui: edit_answer: Редактирование ответа edit_tag: Редактирование тега empty: Нет задач для проверки. - approve_revision_tip: Do you approve this revision? - approve_flag_tip: Do you approve this flag? - approve_post_tip: Do you approve this post? - approve_user_tip: Do you approve this user? + approve_revision_tip: Вы одобряете эту правку? + approve_flag_tip: Вы одобряете эту жалобу? + approve_post_tip: Вы одобряете этот пост? + approve_user_tip: Вы одобряете этого пользователя? suggest_edits: Предложенные исправления - flag_post: Flag post - flag_user: Flag user - queued_post: Queued post - queued_user: Queued user - filter_label: Type + flag_post: Пожаловаться на пост + flag_user: Пожаловаться на пользователя + queued_post: Пост в очереди + queued_user: Пользователь в очереди + filter_label: Тип reputation: репутация - flag_post_type: Flagged this post as {{ type }}. - flag_user_type: Flagged this user as {{ type }}. - edit_post: Edit post - list_post: List post - unlist_post: Unlist post + flag_post_type: На этот пост подана жалоба как на {{ type }}. + flag_user_type: На этого пользователя подана жалоба как на {{ type }}. + edit_post: Редактировать пост + list_post: Показать пост в списке + unlist_post: Скрыть пост из списка timeline: undeleted: Восстановлен deleted: Удаленные @@ -2386,21 +2389,21 @@ ui: rollback: откатить edited: отредактированный answered: отвеченные - asked: asked + asked: задал вопрос closed: закрытый reopened: Открыт повторно created: созданный pin: закрепленный unpin: незакреплённые - show: listed - hide: unlisted - title: "History for" + show: показан в списке + hide: скрыт из списка + title: "История" tag_title: "Хронология" - show_votes: "Show votes" + show_votes: "Показать голоса" n_or_a: Недоступно title_for_question: "Хронология" - title_for_answer: "Timeline for answer to {{ title }} by {{ author }}" - title_for_tag: "Timeline for tag" + title_for_answer: "Хронология ответа на {{ title }} от {{ author }}" + title_for_tag: "Хронология тега" datetime: Дата и время type: Тип by: Автор @@ -2420,31 +2423,31 @@ ui: discard_confirm: Вы уверены, что хотите отказаться от своего черновика? messages: post_deleted: Этот пост был удалён. - post_cancel_deleted: This post has been undeleted. + post_cancel_deleted: Этот пост был восстановлен. post_pin: Этот пост был закреплен. post_unpin: Этот пост был откреплен. post_hide_list: Это сообщение было скрыто из списка. post_show_list: Этот пост был показан в списке. post_reopen: Этот пост был вновь открыт. - post_list: This post has been listed. - post_unlist: This post has been unlisted. - post_pending: Your post is awaiting review. This is a preview, it will be visible after it has been approved. - post_closed: This post has been closed. - answer_deleted: This answer has been deleted. - answer_cancel_deleted: This answer has been undeleted. - change_user_role: This user's role has been changed. - user_inactive: This user is already inactive. - user_normal: This user is already normal. - user_suspended: This user has been suspended. - user_deleted: This user has been deleted. - user_added: User has been added successfully. - badge_activated: This badge has been activated. - badge_inactivated: This badge has been inactivated. - users_deleted: These users have been deleted. - posts_deleted: These questions have been deleted. - answers_deleted: These answers have been deleted. - copy: Copy to clipboard - copied: Copied - external_content_warning: External images/media are not displayed. + post_list: Этот пост был показан в списке. + post_unlist: Этот пост был скрыт из списка. + post_pending: Ваш пост ожидает проверки. Это предварительный просмотр, он станет видимым после одобрения. + post_closed: Этот пост был закрыт. + answer_deleted: Этот ответ был удален. + answer_cancel_deleted: Этот ответ был восстановлен. + change_user_role: Роль этого пользователя была изменена. + user_inactive: Этот пользователь уже неактивен. + user_normal: Этот пользователь уже имеет обычный статус. + user_suspended: Этот пользователь был заблокирован. + user_deleted: Этот пользователь был удален. + user_added: Пользователь успешно добавлен. + badge_activated: Этот значок был активирован. + badge_inactivated: Этот значок был деактивирован. + users_deleted: Эти пользователи были удалены. + posts_deleted: Эти вопросы были удалены. + answers_deleted: Эти ответы были удалены. + copy: Копировать в буфер обмена + copied: Скопировано + external_content_warning: Внешние изображения/медиа не отображаются. From 21e0714a0876c6b2e3e8eee44178d97846c6bcb3 Mon Sep 17 00:00:00 2001 From: LinkinStars Date: Fri, 14 Aug 2026 17:03:23 +0800 Subject: [PATCH 21/49] fix: align question operation permissions --- internal/controller/question_controller.go | 23 ++++++--- .../controller/question_controller_test.go | 49 +++++++++++++++++++ 2 files changed, 65 insertions(+), 7 deletions(-) create mode 100644 internal/controller/question_controller_test.go diff --git a/internal/controller/question_controller.go b/internal/controller/question_controller.go index 05ad319ab..644e30976 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 000000000..35d75f41d --- /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) + } + }) + } +} From e8ded23a4acb9868b4da004aa47427a90576495f Mon Sep 17 00:00:00 2001 From: LinkinStars Date: Fri, 14 Aug 2026 19:17:10 +0800 Subject: [PATCH 22/49] fix: strengthen request safeguards --- internal/base/middleware/header.go | 4 + internal/base/middleware/header_test.go | 45 ++++++++++ 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_test/question_link_security_test.go | 85 +++++++++++++++++++ internal/router/static_router.go | 20 ++++- internal/router/static_router_test.go | 46 ++++++++++ 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 + pkg/checker/file_type_test.go | 36 ++++++++ 20 files changed, 384 insertions(+), 38 deletions(-) create mode 100644 internal/base/middleware/header_test.go create mode 100644 internal/repo/repo_test/question_link_security_test.go create mode 100644 internal/router/static_router_test.go create mode 100644 internal/schema/email_template_test.go create mode 100644 pkg/checker/file_type_test.go diff --git a/internal/base/middleware/header.go b/internal/base/middleware/header.go index 717d2ac08..15de54dfc 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_test.go b/internal/base/middleware/header_test.go new file mode 100644 index 000000000..9f838aebc --- /dev/null +++ b/internal/base/middleware/header_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 middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +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 bfd157a92..b2aea6b80 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 8db557440..22e7afdb7 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 644e30976..c83fd5782 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 3449efb8b..3f38fc6cb 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 000000000..3c38448b5 --- /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 a6c80fc04..e16002cfc 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/router/static_router_test.go b/internal/router/static_router_test.go new file mode 100644 index 000000000..b123c95db --- /dev/null +++ b/internal/router/static_router_test.go @@ -0,0 +1,46 @@ +/* + * 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 router + +import ( + "path/filepath" + "testing" + + "github.com/apache/answer/internal/base/constant" +) + +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 d7e4b929a..57f1ac3c3 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 000000000..8a11738da --- /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 4c2313852..4c6d2ead7 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 73f66a4c1..b172018bf 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 c1f800ff9..f556fa173 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 d77a1c448..9c654aaa4 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 3a7306342..8a45f1edd 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 58f808468..72901b556 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 29e338046..072feec9b 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 19999ef10..c324fd34f 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/pkg/checker/file_type_test.go b/pkg/checker/file_type_test.go new file mode 100644 index 000000000..3b47de557 --- /dev/null +++ b/pkg/checker/file_type_test.go @@ -0,0 +1,36 @@ +/* + * 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 ( + "os" + "path/filepath" + "testing" +) + +func TestDecodeAndCheckImageFileRejectsUnsupportedExtension(t *testing.T) { + filePath := filepath.Join(t.TempDir(), "not-an-image.svg") + if err := os.WriteFile(filePath, []byte(""), 0o600); err != nil { + t.Fatal(err) + } + if DecodeAndCheckImageFile(filePath, 1_000_000) { + t.Fatal("unsupported image extensions must be rejected") + } +} From 2e2c3019a257eddc88a083711307e0109b99a2fc Mon Sep 17 00:00:00 2001 From: LinkinStars Date: Mon, 17 Aug 2026 10:28:49 +0800 Subject: [PATCH 23/49] fix: enforce question access controls --- internal/controller/question_controller.go | 42 ++++++++---- internal/service/content/question_service.go | 28 +++++--- .../question_service_visibility_test.go | 65 +++++++++++++++++++ 3 files changed, 113 insertions(+), 22 deletions(-) create mode 100644 internal/service/content/question_service_visibility_test.go diff --git a/internal/controller/question_controller.go b/internal/controller/question_controller.go index c83fd5782..66d7b221f 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 b172018bf..f7899566b 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 000000000..9e10b47b6 --- /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") + } + }) + } +} From 63a67542f4c23afcf3303b4a46e52bda60e31730 Mon Sep 17 00:00:00 2001 From: LinkinStars Date: Mon, 17 Aug 2026 10:49:54 +0800 Subject: [PATCH 24/49] 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(-) create mode 100644 internal/controller/mcp_controller_test.go diff --git a/internal/controller/mcp_controller.go b/internal/controller/mcp_controller.go index e24c1a546..a5709664a 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 000000000..09f75a6a3 --- /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) + } + }) + } +} From a203e7f608f52205f567f27633cf7e9d6428a86e Mon Sep 17 00:00:00 2001 From: LinkinStars Date: Mon, 17 Aug 2026 11:32:39 +0800 Subject: [PATCH 25/49] 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 3f38fc6cb..379b43805 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 57f1ac3c3..e28cf90d8 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 { From 2c0ced322d17a822e82c9de624dd6c252e0ba40b Mon Sep 17 00:00:00 2001 From: Duansg Date: Sat, 15 Aug 2026 22:50:33 -0700 Subject: [PATCH 26/49] fix: use TagStatusDeleted instead of QuestionStatusDeleted in tag permission --- internal/service/permission/tag_permission.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/service/permission/tag_permission.go b/internal/service/permission/tag_permission.go index 67ac2fa08..dfb50027d 100644 --- a/internal/service/permission/tag_permission.go +++ b/internal/service/permission/tag_permission.go @@ -58,7 +58,7 @@ func GetTagPermission(ctx context.Context, status int, canEdit, canDelete, canMe }) } - if canRecover && status == entity.QuestionStatusDeleted { + if canRecover && status == entity.TagStatusDeleted { actions = append(actions, &schema.PermissionMemberAction{ Action: "undelete", Name: translator.Tr(lang, undeleteActionName), From 61ff3ea17e3307585304148977feb7dadd248ee7 Mon Sep 17 00:00:00 2001 From: LinkinStars Date: Thu, 20 Aug 2026 16:26:57 +0800 Subject: [PATCH 27/49] fix: hide hidden questions from public answer profiles --- internal/entity/answer_entity.go | 1 + internal/repo/answer/answer_repo.go | 16 ++-- .../personal_answer_visibility_test.go | 89 +++++++++++++++++++ internal/service/content/question_service.go | 1 + 4 files changed, 101 insertions(+), 6 deletions(-) create mode 100644 internal/repo/repo_test/personal_answer_visibility_test.go diff --git a/internal/entity/answer_entity.go b/internal/entity/answer_entity.go index 4c9436ecb..0e93aa97f 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 42e3494a8..615d8dfb1 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 000000000..f50105aad --- /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 f7899566b..82d6e1e08 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 { From a52cc0aeb2b071ad73fc918c75a9b799b13352a2 Mon Sep 17 00:00:00 2001 From: LinkinStars Date: Tue, 25 Aug 2026 16:15:06 +0800 Subject: [PATCH 28/49] 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_service_visibility_test.go | 95 +++++++++++++++++++ 4 files changed, 129 insertions(+), 20 deletions(-) create mode 100644 internal/service/comment/comment_service_visibility_test.go diff --git a/internal/controller/comment_controller.go b/internal/controller/comment_controller.go index b9beead94..e0f18a8a0 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 a9c8a21a3..8297c8807 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 0decd5847..455ab864b 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 000000000..69be2a96b --- /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) + } + }) + } +} From 41a9d887d9361c8b4b70c50abfbb516ec09fe918 Mon Sep 17 00:00:00 2001 From: LinkinStars Date: Tue, 25 Aug 2026 16:36:04 +0800 Subject: [PATCH 29/49] fix(security): restrict similar question visibility --- internal/controller/question_controller.go | 12 ++- internal/service/content/question_service.go | 16 +++- .../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 66d7b221f..8294cdcbd 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 82d6e1e08..ced0e0109 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 9e10b47b6..5cfeb5b77 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 From 372f0776ae88603823ed57e8c808240f58ab948e Mon Sep 17 00:00:00 2001 From: LinkinStars Date: Tue, 25 Aug 2026 17:29:24 +0800 Subject: [PATCH 30/49] fix(security): revoke stale visit tokens --- .../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(-) create mode 100644 internal/base/middleware/visit_img_auth_test.go 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 000000000..7d1858ce2 --- /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 597352b23..7c957d784 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 a7827c78e..ac86b7ab6 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) { From 76739fcfd790dbc79f7df19d7294f26015de4ea1 Mon Sep 17 00:00:00 2001 From: LinkinStars Date: Wed, 26 Aug 2026 11:04:20 +0800 Subject: [PATCH 31/49] 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 6e16c6a85..58e55c118 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 e0f18a8a0..4bdd8ac21 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 13b4c0953..53b298edc 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 302796677..10ce42a77 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 bf80c56ec..e81b8630c 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 8297c8807..0fe5ee00d 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 1f702df47..3c1e33256 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 e82adcc2f..15a547941 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 455ab864b..0cbf20282 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 bda7b582b..e43467a5d 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 1f74769f5..045654385 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 3edbc1b45..c57d83e9a 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 072feec9b..acbaf9965 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. From 7a2e2475f72cc8bc678fc38684eb93e206238b92 Mon Sep 17 00:00:00 2001 From: Andreas Polzer Date: Thu, 27 Aug 2026 04:34:31 +0200 Subject: [PATCH 32/49] fix(ui): allow uppercase letters in username on profile settings (#1572) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users whose username contains an uppercase letter cannot save their profile at all — not even when they leave the username untouched, because the whole form is validated on submit. The check in profile settings is the only one of four that rejects uppercase: | where | pattern | uppercase | |---|---|---| | `ui/src/pages/Users/Register/components/SignUpForm/index.tsx` | `/^[\w.-\s]{2,30}$/` | allowed | | `ui/src/pages/Install/components/FourthStep/index.tsx` | `/^[\w.-\s]{2,30}$/` | allowed | | `pkg/checker/username.go` | `^[\w.\- ]{2,30}$` | allowed | | `ui/src/pages/Users/Settings/Profile/index.tsx` | `/[^a-z0-9\-._]/` | **rejected** | So one can sign up as `MaxMustermann`, and from then on the profile page is locked. The error message points at the username field without saying why a name the server itself issued is suddenly invalid. I ran into this migrating a 26-year-old forum to Answer: 3368 of 5479 accounts carry uppercase letters in names that have been in use for two decades. ## Proposed Changes - add the ignore-case flag to the username check in profile settings, bringing it in line with registration, installation and the server - permits nothing the server would reject; no data or API behaviour changes An alternative would be to reuse the exact pattern from `SignUpForm`, but that also allows spaces, which felt like a larger change than this fix needs. Happy to switch if you prefer the patterns to be literally identical. Co-authored-by: Besser Sehen Landshut Co-authored-by: Claude Opus 5 (1M context) --- ui/src/pages/Users/Settings/Profile/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/pages/Users/Settings/Profile/index.tsx b/ui/src/pages/Users/Settings/Profile/index.tsx index 62bd6fb8e..d695c5530 100644 --- a/ui/src/pages/Users/Settings/Profile/index.tsx +++ b/ui/src/pages/Users/Settings/Profile/index.tsx @@ -177,7 +177,7 @@ const Index: React.FC = () => { isInvalid: true, errorMsg: t('username.msg_range'), }; - } else if (/[^a-z0-9\-._]/.test(username.value)) { + } else if (/[^a-z0-9\-._]/i.test(username.value)) { bol = false; formData.username = { value: username.value, From 548889e2e96310c4ca1d1ea4ffe41df0e5a00a0f Mon Sep 17 00:00:00 2001 From: m8522s <43844394+m8522s@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:05:55 +0200 Subject: [PATCH 33/49] New and fixed translations --- i18n/de_DE.yaml | 316 +++++++++++++++++++++++++----------------------- 1 file changed, 162 insertions(+), 154 deletions(-) diff --git a/i18n/de_DE.yaml b/i18n/de_DE.yaml index 151341c60..90a88ff0f 100644 --- a/i18n/de_DE.yaml +++ b/i18n/de_DE.yaml @@ -71,7 +71,7 @@ backend: user: other: Standard ohne speziellen Zugriff. admin: - other: Habe die volle Berechtigung, auf die Seite zuzugreifen. + other: Hat die volle Berechtigung, auf die Seite zuzugreifen. moderator: other: Hat Zugriff auf alle Beiträge außer Admin-Einstellungen. privilege: @@ -266,7 +266,7 @@ backend: cannot_set_synonym_as_itself: other: Du kannst das Synonym des aktuellen Tags nicht als sich selbst festlegen. minimum_count: - other: Not enough tags were entered. + other: Nicht genügend Tags angegeben. smtp: config_from_name_cannot_be_email: other: Der Absendername kann keine E-Mail-Adresse sein. @@ -311,13 +311,13 @@ backend: add_bulk_users_amount_error: other: "Die Anzahl der Benutzer, die du auf einmal hinzufügst, sollte im Bereich von 1-{{.MaxAmount}} liegen." status_suspended_forever: - other: "This user was suspended forever. This user doesn't meet a community guideline." + other: "Dieser Benutzer wurde dauerhaft gesperrt. Dieser Benutzer verstößt gegen eine Community-Richtlinie." status_suspended_until: - other: "This user was suspended until {{.SuspendedUntil}}. This user doesn't meet a community guideline." + other: "Dieser Benutzer wird bis {{.SuspendedUntil}} gesperrt. Dieser Benutzer verstößt gegen eine Community-Richtlinie." status_deleted: - other: "This user was deleted." + other: "Dieser Benutzer wurde gelösch." status_inactive: - other: "This user is inactive." + other: "Dieser Benutzer ist inaktiv." config: read_config_failed: other: Lesekonfiguration fehlgeschlagen @@ -456,7 +456,7 @@ backend: comment_answer: other: kommentierte Antwort reply_to_you: - other: hat Ihnen geantwortet + other: hat dir geantwortet mention_you: other: hat dich erwähnt your_question_is_closed: @@ -598,7 +598,7 @@ backend: name: other: Kommentator desc: - other: Hinterlassen Sie 5 Kommentare. + other: Hinterlasse 5 Kommentare. new_user_of_the_month: name: other: Neuer Benutzer des Monats @@ -606,14 +606,14 @@ backend: other: Ausstehende Beiträge in ihrem ersten Monat. read_guidelines: name: - other: Lesen Sie die Richtlinien + other: Lese die Richtlinien desc: - other: Lesen Sie die [Community-Richtlinien]. + other: Lese die [Community-Richtlinien]. reader: name: other: Leser desc: - other: Lesen Sie alle Antworten in einem Thema mit mehr als 10 Antworten. + other: Lese alle Antworten in einem Thema mit mehr als 10 Antworten. welcome: name: other: Willkommen @@ -761,7 +761,7 @@ backend: other: Fragen mit 25 oder mehr Punkten. great_question: name: - other: Große Frage + other: Großartige Frage desc: other: Frage mit 50 oder mehr Punkten. popular_question: @@ -809,7 +809,16 @@ ui: how_to_format: title: Wie man formatiert desc: >- -
  • einen Beitrag erwähnen: #post_id

  • um Links

    <https://url.com>

    [Titel](https://url.com)
  • Zwischen den Absätzen Zeilenumbrüche einfügen

  • _italic_ oder **fett**

  • Code um 4 Leerzeichen einrücken

  • Zitat durch Setzen von > am Anfang der Zeile

  • Backtick-Escapes `wie _this_`

  • Codeumrandungen mit Backticks `

    `
    Code hier
    ``
+
    +
  • Einen Beitrag erwähnen: #post_id

  • +
  • Links einfügen:

    <https://url.com>

    [Titel](https://url.com)
  • +
  • Zwischen den Absätzen Zeilenumbrüche einfügen

  • +
  • _kursiv_ oder **fett**

  • +
  • Code um 4 Leerzeichen einrücken

  • +
  • Zitat durch Setzen von > am Anfang der Zeile

  • +
  • Backtick-Escapes `wie _dies_`

  • +
  • Codeumrandungen mit Backticks `

    ```
    Code hier
    ```
  • +
pagination: prev: Zurück next: Weiter @@ -821,7 +830,7 @@ ui: tag_wiki: tag Wiki create_tag: Tag erstellen edit_tag: Tag bearbeiten - ask_a_question: Create Question + ask_a_question: Frage erstellen edit_question: Frage bearbeiten edit_answer: Antwort bearbeiten search: Suchen @@ -846,16 +855,16 @@ ui: http_403: HTTP Fehler 403 logout: Ausloggen posts: Posts - ai_assistant: AI Assistant + ai_assistant: KI-Assistent ai_assistant: - description: Got a question? Ask it and get answers, perspectives, and recommendations. - recent_conversations: Recent Conversations - show_more: Show more - new: New chat - ai_generate: AI-generated from posts and may not be accurate. - copy: Copy - ask_a_follow_up: Ask a follow-up - ask_placeholder: Ask a question + description: Was möchtest du wissen? Stell eine Frage und erhalte Antworten, Sichtweisen und Empfehlungen. + recent_conversations: Letzte Unterhaltungen + show_more: Mehr anzeigen + new: Neuer Chat + ai_generate: KI-generierte Inhalte aus Beiträgen sind möglicherweise nicht korrekt + copy: Kopieren + ask_a_follow_up: Eine Folgefrage stellen + ask_placeholder: Stell eine Frage notifications: title: Benachrichtigungen inbox: Posteingang @@ -882,7 +891,7 @@ ui: blockquote: text: Blockzitat bold: - text: Stark + text: Fett chart: text: Bestenliste flow_chart: Flussdiagramm @@ -924,7 +933,7 @@ ui: help: text: Hilfe hr: - text: Horizontale Richtlinie + text: Horizontale Linie image: text: Bild add_image: Bild hinzufügen @@ -957,7 +966,7 @@ ui: outdent: text: Ausrücken italic: - text: Hervorhebung + text: Kursiv link: text: Hyperlink add_link: Hyperlink hinzufügen @@ -981,7 +990,7 @@ ui: cell: Zelle file: text: Datei anhängen - not_supported: "Diesen Dateityp nicht unterstützen. Versuchen Sie es erneut mit {{file_type}}." + not_supported: "Diesen Dateityp nicht unterstützen. Versuche es erneut mit {{file_type}}." max_size: "Dateigröße anhängen darf {{size}} MB nicht überschreiten." close_modal: title: Ich schließe diesen Beitrag als... @@ -1047,9 +1056,9 @@ ui: delete: title: Diesen Tag löschen tip_with_posts: >- -

Wir erlauben es nicht, Tags mit Beiträgenzu löschen.

Bitte entfernen Sie dieses Tag zuerst aus den Beiträgen.

+

Wir erlauben es nicht, Tags mit Beiträgenzu löschen.

Bitte entferne dieses Tag zuerst aus den Beiträgen.

tip_with_synonyms: >- -

Wir erlauben nicht Tags mit Synonymenzu löschen.

Bitte entfernen Sie zuerst die Synonyme von diesem Schlagwort.

+

Wir erlauben nicht Tags mit Synonymenzu löschen.

Bitte entferne zuerst die Synonyme von diesem Schlagwort.

tip: Bist du sicher, dass du löschen möchtest? close: Schließen merge: @@ -1076,12 +1085,12 @@ ui: x_minutes_ago: "Vor {{count}}m" x_hours_ago: "Vor {{count}}h" hour: Stunde - day: tag + day: Tag hours: Stunden days: Tage - month: month - months: months - year: year + month: Monat + months: Monate + year: Jahr reaction: heart: Herz smile: Lächeln @@ -1137,10 +1146,10 @@ ui: more: Mehr wiki: Wiki ask: - title: Create Question + title: Frage erstellen edit_title: Frage bearbeiten default_reason: Frage bearbeiten - default_first_reason: Create question + default_first_reason: Frage erstellen similar_questions: Ähnliche Fragen form: fields: @@ -1148,17 +1157,17 @@ ui: label: Version title: label: Titel - placeholder: What's your topic? Be specific. + placeholder: Was ist das Thema? msg: empty: Der Titel darf nicht leer sein. range: Titel bis zu 150 Zeichen body: - label: Körper + label: Inhalt msg: - empty: Körper darf nicht leer sein. + empty: Inhalt darf nicht leer sein. hint: - optional_body: Describe what the question is about. - minimum_characters: "Describe what the question is about, at least {{min_content_length}} characters are required." + optional_body: Beschreibe worum es in der Frage geht. + minimum_characters: "Erläutere mit wenigstens {{min_content_length}} Zeichen, worum es in der Frage geht." tags: label: Stichworte msg: @@ -1179,9 +1188,9 @@ ui: add_btn: Schlagwort hinzufügen create_btn: Neuen Tag erstellen search_tag: Tag suchen - hint: Describe what your content is about, at least one tag is required. - hint_zero_tags: Describe what your content is about. - hint_more_than_one_tag: "Describe what your content is about, at least {{min_tags_number}} tags are required." + hint: Beschreibe den Inhalt. Mindestens ein Tag ist erforderlich. + hint_zero_tags: Beschreibe den Inhalt. + hint_more_than_one_tag: "Beschreibe mit mindestens {{min_tags_number}} Tags, worum es bei diesem Thema geht." no_result: Keine Tags gefunden tag_required_text: Benötigter Tag (mindestens eins) header: @@ -1233,7 +1242,7 @@ ui: msg: empty: Der Name darf nicht leer sein. range: Der Name muss zwischen 2 und 30 Zeichen lang sein. - character: 'Must use the character set "a-z", "0-9", " - . _"' + character: 'Erlaubte Zeichen sind "a-z", "0-9", " - . _"' email: label: E-Mail msg: @@ -1253,7 +1262,7 @@ ui: msg: empty: E-Mail darf nicht leer sein. change_email: - btn_cancel: Stornieren + btn_cancel: Abbrechen btn_update: E-Mail Adresse aktualisieren send_success: >- Wenn ein Konto mit {{mail}} übereinstimmt, solltest du in Kürze eine E-Mail mit Anweisungen erhalten, wie du dein Passwort zurücksetzen kannst. @@ -1311,7 +1320,7 @@ ui: caption: Leute können dich als "@Benutzername" erwähnen. msg: Benutzername darf nicht leer sein. msg_range: Der Benutzername muss zwischen 2 und 30 Zeichen lang sein. - character: 'Must use the character set "a-z", "0-9", "- . _"' + character: 'Erlaubte Zeichen sind "a-z", "0-9", "- . _"' avatar: label: Profilbild gravatar: Gravatar @@ -1389,9 +1398,9 @@ ui: title: Related answers: antworten linked_question: - title: Linked - description: Posts linked to - no_linked_question: No contents linked from this content. + title: Verlinkt + description: Beitrag verlinkt nach + no_linked_question: Zu diesem Inhalt sind keine Links vorhanden. invite_to_answer: title: Frage jemanden desc: Lade Leute ein, von denen du glaubst, dass sie die Antwort wissen könnten. @@ -1400,7 +1409,7 @@ ui: search: Personen suchen question_detail: action: Aktion - created: Created + created: Erstellt Asked: Gefragt asked: gefragt update: Geändert @@ -1451,11 +1460,11 @@ ui: list: confirm_btn: Liste title: Diesen Beitrag auflisten - content: Möchten Sie diesen Beitrag wirklich in der Liste anzeigen? + content: Möchtest du diesen Beitrag wirklich in der Liste anzeigen? unlist: confirm_btn: Von Liste nehmen title: Diesen Beitrag von der Liste nehmen - content: Möchten Sie diesen Beitrag wirklich aus der Liste ausblenden? + content: Möchtest du diesen Beitrag wirklich aus der Liste ausblenden? pin: title: Diesen Beitrag anpinnen content: Bist du sicher, dass du den Beitrag global anheften möchtest? Dieser Beitrag wird in allen Beitragslisten ganz oben erscheinen. @@ -1533,7 +1542,7 @@ ui: follow: Folgen following: Folgend counts: "{{count}} Ergebnisse" - counts_loading: "... Results" + counts_loading: "... Ergebnisse" more: Mehr sort_btns: relevance: Relevanz @@ -1562,12 +1571,12 @@ ui: title: Fehler... delete_permanently: title: Endgültig löschen - content: Sind Sie sicher, dass Sie den Inhalt endgültig löschen möchten? + content: Bist du sicher, dass du den Inhalt endgültig löschen möchtest? account_result: success: Dein neues Konto ist bestätigt; du wirst zur Startseite weitergeleitet. link: Weiter zur Startseite oops: Hoppla! - invalid: Der Link, den Sie verwendet haben, funktioniert nicht mehr. + invalid: Der verwendete Link funktioniert nicht mehr. confirm_new_email: Deine E-Mail wurde aktualisiert. confirm_new_email_invalid: >- Dieser Bestätigungslink ist leider nicht mehr gültig. Vielleicht wurde deine E-Mail-Adresse bereits geändert? @@ -1710,13 +1719,13 @@ ui: empty: Kontakt-E-Mail kann nicht leer sein. incorrect: Falsches Format der Kontakt-E-Mail. login_required: - label: Privat + label: Privater Bereich switch: Anmeldung erforderlich text: Nur eingeloggte Benutzer können auf diese Community zugreifen. admin_name: label: Name msg: Der Name darf nicht leer sein. - character: 'Must use the character set "a-z", "0-9", " - . _"' + character: 'Erlaubte Zeichen sind "a-z", "0-9", " - . _"' msg_max_length: Der Name muss zwischen 2 und 30 Zeichen lang sein. admin_password: label: Passwort @@ -1727,7 +1736,7 @@ ui: msg_max_length: Das Passwort darf maximal 32 Zeichen lang sein. admin_confirm_password: label: "Passwort bestätigen" - text: "Bitte geben Sie Ihr Passwort erneut ein, um es zu bestätigen." + text: "Bitte gib dein Passwort erneut ein, um es zu bestätigen." msg: "Passwortbestätigung stimmt nicht überein!" admin_email: label: E-Mail @@ -1777,7 +1786,7 @@ ui: branding: Branding legal: Rechtliches write: Schreiben - terms: Terms + terms: Nutzungsbedingungen tos: Nutzungsbedingungen privacy: Privatsphäre seo: SEO @@ -1789,16 +1798,16 @@ ui: installed_plugins: Installierte Plugins apperance: Erscheinungsbild community: Community - advanced: Advanced + advanced: Erweitert tags: Tags - rules: Rules - policies: Policies - security: Security - files: Files - apikeys: API Keys - intelligence: Intelligence - ai_assistant: AI Assistant - ai_settings: AI Settings + rules: Regeln + policies: Richtlinien + security: Sicherheit + files: Dateien + apikeys: API-Schlüssel + intelligence: Intelligenz + ai_assistant: KI-Assistent + ai_settings: KI-Einstellungen mcp: MCP website_welcome: Willkommen auf {{site_name}} user_center: @@ -1808,7 +1817,7 @@ ui: badges: modal: title: Glückwunsch - content: Sie haben sich ein neues Abzeichen verdient. + content: Du hast dir ein neues Abzeichen verdient. close: Schließen confirm: Abzeichen ansehen title: Abzeichen @@ -1833,7 +1842,7 @@ ui: users: "Nutzer:" flags: "Meldungen:" reviews: "Rezension:" - site_health: Gesundheit der Website + site_health: Status der Website version: "Version:" https: "HTTPS:" upload_folder: "Hochladeverzeichnis:" @@ -1914,7 +1923,7 @@ ui: form: fields: users: - label: Masse Benutzer hinzufügen + label: Mehrere Benutzer hinzufügen placeholder: "John Smith, john@example.com, BUSYopr2\nAlice, alice@example.com, fpDntV8q" text: Trenne "Name, E-Mail, Passwort" mit Kommas. Ein Benutzer pro Zeile. msg: "Bitte gib die E-Mail des Nutzers ein, eine pro Zeile." @@ -1937,7 +1946,7 @@ ui: created_at: Angelegt am delete_at: Löschzeit suspend_at: Sperrzeit - suspend_until: Suspend until + suspend_until: Gesperrt bis status: Status role: Rolle action: Aktion @@ -1972,8 +1981,8 @@ ui: suspend_user: title: Diesen Benutzer sperren content: Ein gesperrter Benutzer kann sich nicht einloggen. - label: How long will the user be suspended for? - forever: Forever + label: Wie lang soll dieser Benutzer gesperrt sein? + forever: Für immer questions: page_title: Fragen unlisted: Nicht gelistet @@ -2035,11 +2044,11 @@ ui: msg: Die Zeitzone darf nicht leer sein. text: Wähle eine Stadt in der gleichen Zeitzone wie du. avatar: - label: Default avatar - text: For users without a custom avatar of their own. + label: Standard-Avatar + text: Für Nutzer, die keinen eigenen Avatar haben. gravatar_base_url: - label: Gravatar base URL - text: URL of the Gravatar provider's API base. Ignored when empty. + label: Gravatar Basis-URL + text: URL zur API des Gravatar-Anbieters. Wird ignoriert, wenn leer. smtp: page_title: SMTP from_email: @@ -2111,17 +2120,17 @@ ui: always_display: Externen Inhalt immer anzeigen ask_before_display: Vor der Anzeige externer Inhalte fragen write: - page_title: Files + page_title: Dateien min_content: - label: Minimum question body length - text: Minimum allowed question body length in characters. + label: Minimale Länge für den Inhalt einer Frage + text: Minimale Anzahl an Zeichen für den Inhalt einer Frage. restrict_answer: title: Antwort bearbeiten label: Jeder Benutzer kann für jede Frage nur eine Antwort schreiben - text: "Schalten Sie aus, um es Benutzern zu ermöglichen, mehrere Antworten auf dieselbe Frage zu schreiben, was dazu führen kann, dass Antworten nicht im Fokus stehen." + text: "Schalte es aus, um es Benutzern zu ermöglichen, mehrere Antworten auf dieselbe Frage zu schreiben, was dazu führen kann, dass Antworten nicht im Fokus stehen." min_tags: - label: "Minimum tags per question" - text: "Minimum number of tags required in a question." + label: "Minimum an Tags pro Frage" + text: "Benötigte minimale Anzahl an Tags pro Frage." recommend_tags: label: Empfohlene Tags text: "Empfohlene Tags werden standardmäßig in der Dropdown-Liste angezeigt." @@ -2141,14 +2150,14 @@ ui: label: Maximale Anhanggröße (MB) text: "Die maximale Dateigröße für Dateianhänge." image_megapixels: - label: Max. BildmePixel - text: "Maximale Anzahl an Megapixeln für ein Bild." + label: Max. Megapixel pro Bild + text: "Maximale Anzahl an Megapixel für ein Bild." image_extensions: - label: Autorisierte Bilderweiterungen - text: "Eine Liste von Dateierweiterungen, die für die Anzeige von Bildern erlaubt sind, getrennt durch Kommata." + label: Erlaubte Bilderweiterungen + text: "Eine Liste von Dateierweiterungen, die für die Anzeige von Bildern erlaubt sind, getrennt durch Kommas." attachment_extensions: - label: Autorisierte Anhänge Erweiterungen - text: "Eine Liste von Dateierweiterungen, die für das Hochladen erlaubt sind, getrennt mit Kommas. WARNUNG: Erlaubt Uploads kann Sicherheitsprobleme verursachen." + label: Erlaubte Anhänge Erweiterungen + text: "Eine Liste von Dateierweiterungen, die für das Hochladen erlaubt sind, getrennt mit Kommas. WARNUNG: Der Erlaubten von Uploads kann Sicherheitsprobleme verursachen." seo: page_title: SEO permalink: @@ -2168,28 +2177,25 @@ ui: label: Hintergrundstil der Navigationsleiste primary_color: label: Primäre Farbe - text: Ändere die Farben, die von deinen Themes verwendet werden + text: Ändere die Farben, die von deinen Themen verwendet werden layout: label: Layout - full_width: Full-width - fixed_width: Fixed-width + full_width: Volle Breite + fixed_width: Feste Breite css_and_html: page_title: CSS und HTML custom_css: - label: Benutzerdefinierte CSS - text: > - + label: Benutzerdefiniertes CSS + text: Dies wird als <link> eingefügt. head: - label: Kopf - text: > - + label: Head + text: Dies wird vor </head> eingefügt. header: label: Header - text: > - + text: Dies wird nach <body> eingefügt. footer: label: Fusszeile - text: Dies wird vor eingefügt. + text: Dies wird vor </body> eingefügt. sidebar: label: Seitenleiste text: Dies wird in die Seitenleiste eingefügt. @@ -2203,16 +2209,20 @@ ui: title: E-Mail Registrierung label: E-Mail-Registrierung zulassen text: Abschalten, um zu verhindern, dass jemand ein neues Konto per E-Mail erstellt. + email_verification: + title: E-Mail Überprüfung + label: E-Mail-Adresse überprüfen + text: Wenn aktiv müssen Anwender ihre E-Mail-Adresse verifizieren, bevor sie sie hier verwenden. allowed_email_domains: title: Zugelassene E-Mail-Domänen text: E-Mail-Domänen, bei denen die Nutzer Konten registrieren müssen. Eine Domäne pro Zeile. Wird ignoriert, wenn leer. private: - title: Privatgelände + title: Privater Bereich label: Anmeldung erforderlich text: Nur angemeldete Benutzer können auf diese Community zugreifen. password_login: title: Passwort-Login - label: E-Mail-und Passwort-Login erlauben + label: E-Mail- und Passwort-Login erlauben text: "WARNUNG: Wenn du diese Option abschaltest, kannst du dich möglicherweise nicht mehr anmelden, wenn du zuvor keine andere Anmeldemethode konfiguriert hast." installed_plugins: title: Installierte Plugins @@ -2278,69 +2288,69 @@ ui: status: Status title: Abzeichen apikeys: - title: API Keys - add_api_key: Add API Key - desc: Description + title: API-Schlüssel + add_api_key: API-Schlüssel hinzufügen + desc: Beschreibung scope: Scope - key: Key - created: Created - last_used: Last used + key: Schlüssel + created: Erstellt + last_used: Zuletzt verwendet add_or_edit_modal: - add_title: Add API Key - edit_title: Edit API Key - description: Description - description_required: Description is required. + add_title: API-Schlüssel hinzufügen + edit_title: API-Schüssel ändern + description: Beschreibung + description_required: Beschreibung benötigt. scope: Scope global: Global - read-only: Read-only + read-only: Nur lesen created_modal: - title: API key created - api_key: API key - description: This key will not be displayed again. Make sure you take a copy before continuing. + title: API-Schlüssel erstellt + api_key: API-Schlüssel + description: Dieser Schlüssel wird nicht erneut angezeigt. Erstellt dir jetzt eine Kopie. delete_modal: - title: Delete API Key - content: Any applications or scripts using this key will no longer be able to access the API. This is permanent! + title: API-Schlüssel löschen + content: Alle Anwendungen und Skripte, die diesen Schlüssel verwenden, können zukünftig nicht mehr auf die API zugreifen. ai_settings: enabled: - label: AI enabled - check: Enable AI features - text: The AI model must be configured correctly before it can be used. + label: KI benutzen + check: KI-Funktionen aktivieren + text: Das KI-Modell muss korrekt eingerichtet sein, bevor es verwendet werden kann. provider: - label: Provider + label: Anbieter api_host: - label: API host - msg: API host is required + label: API-Server + msg: API-Server wird benötigt api_key: - label: API key - check: Check - check_success: "Connection successful." - msg: API key is required + label: API-Schlüssel + check: Prüfen + check_success: "Verbindung erfolgreich." + msg: API-Schlüssel wird benötigt model: - label: Model - msg: Model is required + label: Modell + msg: Modell wird benötigt add_success: AI settings updated successfully. conversations: - topic: Topic - helpful: Helpful - unhelpful: Unhelpful - created: Created - action: Action - empty: No conversations found. + topic: Thema + helpful: Hilfreich + unhelpful: Nicht hilfreich + created: Erstellt + action: Aktion + empty: Keine Unterhaltungen vorhanden. delete_modal: - title: Delete conversation - content: Are you sure you want to delete this conversation? This is permanent! - delete_success: Conversation deleted successfully. + title: Unterhaltung löschen + content: Diese Unterhaltung wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden! + delete_success: Unterhaltung gelöscht. mcp: mcp_server: - label: MCP server - switch: Enabled + label: MCP-Server + switch: Aktiv type: - label: Type + label: Typ url: label: URL http_header: - label: HTTP header - text: Please replace {key} with the API Key. + label: HTTP-Kopfzeilen + text: Ersetze {key} durch den API-Schlüssel. form: optional: (optional) empty: kann nicht leer sein @@ -2359,10 +2369,10 @@ ui: edit_answer: Antwort bearbeiten edit_tag: Tag bearbeiten empty: Keine Überprüfungsaufgaben mehr übrig. - approve_revision_tip: Akzeptieren Sie diese Revision? - approve_flag_tip: Sind Sie mit diesem Bericht einverstanden? - approve_post_tip: Bestätigen Sie diesen Beitrag? - approve_user_tip: Bestätigen Sie diesen Benutzer? + approve_revision_tip: Akzeptierst du diese Revision? + approve_flag_tip: Bist du mit diesem Bericht einverstanden? + approve_post_tip: Diesen Beitrag bestätigen? + approve_user_tip: Diesen Benutzer bestätigen? suggest_edits: Änderungsvorschläge flag_post: Beitrag melden flag_user: Nutzer melden @@ -2401,7 +2411,7 @@ ui: title_for_question: "Zeitleiste für" title_for_answer: "Zeitachse für die Antwort auf {{ title }} von {{ author }}" title_for_tag: "Zeitachse für Tag" - datetime: Terminzeit + datetime: Zeitangabe type: Typ by: Von comment: Kommentar @@ -2423,7 +2433,7 @@ ui: post_cancel_deleted: Dieser Beitrag wurde wiederhergestellt. post_pin: Dieser Beitrag wurde angepinnt. post_unpin: Dieser Beitrag wurde losgelöst. - post_hide_list: Dieser Beitrag wurde aus der Liste verborgen. + post_hide_list: Dieser Beitrag wurde von der Liste verborgen. post_show_list: Dieser Beitrag wird in der Liste angezeigt. post_reopen: Dieser Beitrag wurde wieder geöffnet. post_list: Dieser Beitrag wurde angezeigt. @@ -2446,5 +2456,3 @@ ui: copy: In die Zwischenablage kopieren copied: Kopiert external_content_warning: Externe Bilder/Medien werden nicht angezeigt. - - From dd4c6f3b53d1941b9a3a012fc01fc314536f83ca Mon Sep 17 00:00:00 2001 From: hgaol Date: Fri, 22 May 2026 18:34:42 +0800 Subject: [PATCH 34/49] feat(plugin): add IsVectorSearchEnabled function to check plugin status --- internal/controller/ai_controller.go | 5 +++++ plugin/vector_search.go | 10 ++++++++++ 2 files changed, 15 insertions(+) diff --git a/internal/controller/ai_controller.go b/internal/controller/ai_controller.go index e7495253b..4c5c4cdde 100644 --- a/internal/controller/ai_controller.go +++ b/internal/controller/ai_controller.go @@ -43,6 +43,7 @@ import ( tagcommonser "github.com/apache/answer/internal/service/tag_common" usercommon "github.com/apache/answer/internal/service/user_common" "github.com/apache/answer/pkg/token" + "github.com/apache/answer/plugin" "github.com/gin-gonic/gin" "github.com/mark3labs/mcp-go/mcp" "github.com/sashabaranov/go-openai" @@ -702,7 +703,11 @@ func (c *AIController) sendErrorResponse(w http.ResponseWriter, id, model, error // getMCPTools func (c *AIController) getMCPTools() []openai.Tool { openaiTools := make([]openai.Tool, 0) + vectorSearchEnabled := plugin.IsVectorSearchEnabled() for _, mcpTool := range mcp_tools.MCPToolsList { + if mcpTool.Name == "semantic_search" && !vectorSearchEnabled { + continue + } openaiTool := c.convertMCPToolToOpenAI(mcpTool) openaiTools = append(openaiTools, openaiTool) } diff --git a/plugin/vector_search.go b/plugin/vector_search.go index 134247d6c..4c46027aa 100644 --- a/plugin/vector_search.go +++ b/plugin/vector_search.go @@ -124,6 +124,16 @@ var ( registerVectorSearch = MakePlugin[VectorSearch](false) ) +// IsVectorSearchEnabled reports whether at least one VectorSearch plugin is currently enabled. +func IsVectorSearchEnabled() bool { + enabled := false + _ = CallVectorSearch(func(vs VectorSearch) error { + enabled = true + return nil + }) + return enabled +} + // GenerateEmbedding is a base utility function that generates an embedding vector // using an OpenAI-compatible API. Plugins that don't have a built-in vectorizer // (most vector databases) can call this function with their own credentials. From 947a48b6437783c6bb0ec013ed32ec2366779e0f Mon Sep 17 00:00:00 2001 From: LiuDag <1038678031@qq.com> Date: Fri, 28 Aug 2026 14:58:34 +0800 Subject: [PATCH 35/49] fix(ai): stop advertising semantic_search when no vector search plugin is enabled (#1597) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #1532. ### Problem The `semantic_search` MCP tool was always advertised to the model, even when no VectorSearch plugin was enabled. The model could select it, the call reached `EmbeddingService.SearchSimilar`, and every such conversation logged `semantic search failed: semantic search is not available: no vector search plugin is enabled` — polluting logs and wasting a tool-call round trip on a capability that does not exist in the deployment. ### Fix - `EmbeddingService.Available()` reports whether a VectorSearch plugin is currently enabled. - `MCPController.SemanticSearchAvailable()` exposes that to the AI chat. - `getMCPTools()` omits the `semantic_search` tool when unavailable; all other MCP tools keep working exactly as before. - The default AI prompts (zh/en, and admin-custom prompts) drop the `semantic_search` instruction lines in the same situation, so the model is no longer nudged toward a missing capability. ### Testing - New unit tests: tool advertisement with/without the capability, prompt-line stripping, and prompt adaptation (`internal/controller/ai_tools_test.go`). - `go test ./internal/controller/` — all pass. Co-authored-by: LinkinStars --- internal/controller/ai_controller.go | 56 +++++++++--- internal/controller/ai_tools_test.go | 90 +++++++++++++++++++ internal/controller/mcp_controller.go | 7 ++ .../service/embedding/embedding_service.go | 6 ++ 4 files changed, 149 insertions(+), 10 deletions(-) create mode 100644 internal/controller/ai_tools_test.go diff --git a/internal/controller/ai_controller.go b/internal/controller/ai_controller.go index 4c5c4cdde..c2fcc8733 100644 --- a/internal/controller/ai_controller.go +++ b/internal/controller/ai_controller.go @@ -43,7 +43,6 @@ import ( tagcommonser "github.com/apache/answer/internal/service/tag_common" usercommon "github.com/apache/answer/internal/service/user_common" "github.com/apache/answer/pkg/token" - "github.com/apache/answer/plugin" "github.com/gin-gonic/gin" "github.com/mark3labs/mcp-go/mcp" "github.com/sashabaranov/go-openai" @@ -325,19 +324,45 @@ func (c *AIController) getPromptByLanguage(language i18n.Language, question stri return c.getDefaultPrompt(language, question) } - return fmt.Sprintf(promptTemplate, question) + return c.adaptPromptToCapabilities(fmt.Sprintf(promptTemplate, question)) } // getDefaultPrompt prompt func (c *AIController) getDefaultPrompt(language i18n.Language, question string) string { + var prompt string switch language { case i18n.LanguageChinese: - return fmt.Sprintf(constant.DefaultAIPromptConfigZhCN, question) + prompt = fmt.Sprintf(constant.DefaultAIPromptConfigZhCN, question) case i18n.LanguageEnglish: - return fmt.Sprintf(constant.DefaultAIPromptConfigEnUS, question) + prompt = fmt.Sprintf(constant.DefaultAIPromptConfigEnUS, question) default: - return fmt.Sprintf(constant.DefaultAIPromptConfigEnUS, question) + prompt = fmt.Sprintf(constant.DefaultAIPromptConfigEnUS, question) } + return c.adaptPromptToCapabilities(prompt) +} + +// adaptPromptToCapabilities removes instructions for tools the current +// deployment cannot serve, so the model is never prompted to call a missing +// capability. +func (c *AIController) adaptPromptToCapabilities(prompt string) string { + if c.mcpController.SemanticSearchAvailable() { + return prompt + } + return stripSemanticSearchLine(prompt) +} + +// stripSemanticSearchLine drops every prompt line that references the +// semantic_search tool. +func stripSemanticSearchLine(prompt string) string { + lines := strings.Split(prompt, "\n") + kept := make([]string, 0, len(lines)) + for _, line := range lines { + if strings.Contains(line, semanticSearchToolName) { + continue + } + kept = append(kept, line) + } + return strings.Join(kept, "\n") } // initializeConversationContext @@ -700,12 +725,23 @@ func (c *AIController) sendErrorResponse(w http.ResponseWriter, id, model, error sendStreamData(w, errorResponse) } -// getMCPTools +// semanticSearchToolName is the MCP tool backed by the optional VectorSearch +// plugin. It must not be advertised when no such plugin is enabled. +const semanticSearchToolName = "semantic_search" + +// getMCPTools builds the tool list advertised to the model. The +// semantic_search tool is omitted when no VectorSearch plugin is enabled, +// otherwise the model can select a capability that always fails. func (c *AIController) getMCPTools() []openai.Tool { - openaiTools := make([]openai.Tool, 0) - vectorSearchEnabled := plugin.IsVectorSearchEnabled() - for _, mcpTool := range mcp_tools.MCPToolsList { - if mcpTool.Name == "semantic_search" && !vectorSearchEnabled { + return c.buildOpenAITools(mcp_tools.MCPToolsList, c.mcpController.SemanticSearchAvailable()) +} + +// buildOpenAITools converts MCP tools into OpenAI tool definitions, optionally +// excluding the semantic_search tool. +func (c *AIController) buildOpenAITools(tools []mcp.Tool, includeSemanticSearch bool) []openai.Tool { + openaiTools := make([]openai.Tool, 0, len(tools)) + for _, mcpTool := range tools { + if !includeSemanticSearch && mcpTool.Name == semanticSearchToolName { continue } openaiTool := c.convertMCPToolToOpenAI(mcpTool) diff --git a/internal/controller/ai_tools_test.go b/internal/controller/ai_tools_test.go new file mode 100644 index 000000000..363ca04ba --- /dev/null +++ b/internal/controller/ai_tools_test.go @@ -0,0 +1,90 @@ +/* + * 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 ( + "strings" + "testing" + + "github.com/apache/answer/internal/schema/mcp_tools" + "github.com/apache/answer/internal/service/embedding" +) + +func toolNames(c *AIController, includeSemanticSearch bool) map[string]bool { + tools := c.buildOpenAITools(mcp_tools.MCPToolsList, includeSemanticSearch) + names := make(map[string]bool, len(tools)) + for _, t := range tools { + names[t.Function.Name] = true + } + return names +} + +func TestBuildOpenAIToolsIncludesSemanticSearch(t *testing.T) { + c := &AIController{} + names := toolNames(c, true) + if !names[semanticSearchToolName] { + t.Fatalf("semantic_search should be advertised when a vector search plugin is available: %v", names) + } + if len(names) != len(mcp_tools.MCPToolsList) { + t.Fatalf("expect %d tools, got %d", len(mcp_tools.MCPToolsList), len(names)) + } +} + +func TestBuildOpenAIToolsExcludesSemanticSearch(t *testing.T) { + c := &AIController{} + names := toolNames(c, false) + if names[semanticSearchToolName] { + t.Fatalf("semantic_search must not be advertised without a vector search plugin") + } + if len(names) != len(mcp_tools.MCPToolsList)-1 { + t.Fatalf("expect %d tools, got %d", len(mcp_tools.MCPToolsList)-1, len(names)) + } + if !names["get_questions"] || !names["get_user"] { + t.Fatalf("other MCP tools must remain advertised: %v", names) + } +} + +func TestStripSemanticSearchLine(t *testing.T) { + prompt := "You are an assistant.\n- get_questions: search questions\n- semantic_search: search by meaning\n- get_user: search users\n" + got := stripSemanticSearchLine(prompt) + if strings.Contains(got, "semantic_search") { + t.Fatalf("semantic_search line not stripped: %q", got) + } + if !strings.Contains(got, "get_questions") || !strings.Contains(got, "get_user") { + t.Fatalf("unrelated lines were dropped: %q", got) + } + if !strings.HasPrefix(got, "You are an assistant.\n") { + t.Fatalf("leading lines must be kept: %q", got) + } +} + +func TestAdaptPromptToCapabilitiesStripsWhenUnavailable(t *testing.T) { + // In tests no VectorSearch plugin is registered, so semantic search is + // unavailable and the prompt must be adapted. + c := &AIController{mcpController: &MCPController{embeddingService: &embedding.EmbeddingService{}}} + prompt := "intro\n- semantic_search: search by meaning\noutro\n" + got := c.adaptPromptToCapabilities(prompt) + if strings.Contains(got, "semantic_search") { + t.Fatalf("expected semantic_search line removed: %q", got) + } + if !strings.Contains(got, "intro") || !strings.Contains(got, "outro") { + t.Fatalf("other content must be preserved: %q", got) + } +} diff --git a/internal/controller/mcp_controller.go b/internal/controller/mcp_controller.go index e24c1a546..942117f43 100644 --- a/internal/controller/mcp_controller.go +++ b/internal/controller/mcp_controller.go @@ -488,3 +488,10 @@ func (c *MCPController) MCPSemanticSearchHandler() func(ctx context.Context, req return mcp.NewToolResultText(string(data)), nil } } + +// SemanticSearchAvailable reports whether a VectorSearch plugin is currently +// enabled, so the AI chat can omit the semantic_search tool entirely instead +// of letting the model call into a missing capability. +func (c *MCPController) SemanticSearchAvailable() bool { + return c.embeddingService.Available() +} diff --git a/internal/service/embedding/embedding_service.go b/internal/service/embedding/embedding_service.go index c69d60d8e..62fccb427 100644 --- a/internal/service/embedding/embedding_service.go +++ b/internal/service/embedding/embedding_service.go @@ -35,6 +35,12 @@ func NewEmbeddingService() *EmbeddingService { return &EmbeddingService{} } +// Available reports whether a VectorSearch plugin is currently enabled, so +// callers can hide semantic search capabilities instead of failing at call time. +func (s *EmbeddingService) Available() bool { + return plugin.IsVectorSearchEnabled() +} + // SearchSimilar delegates to the VectorSearch plugin. // Returns an error if no plugin is enabled. func (s *EmbeddingService) SearchSimilar(ctx context.Context, query string, topK int) ([]plugin.VectorSearchResult, error) { From 9c15df876e8d823d18b72782662e9c74c1018448 Mon Sep 17 00:00:00 2001 From: Max Freedom Pollard <272618364+MaxFreedomPollard@users.noreply.github.com> Date: Mon, 7 Sep 2026 06:28:01 -0400 Subject: [PATCH 36/49] fix: comment url swaps title and answer id CommentURL called AnswerURL(permalink, siteUrl, questionID, answerID, title), but AnswerURL takes (permalink, siteUrl, questionID, title, answerID), so the last two arguments were reversed for every comment left on an answer. The answer route is /questions/:id/:title/:answerid, so the answer id landed in the title slot and the title landed in the answer id slot. Under the numeric permalink settings the title was run through uid.DeShortID and came out as an unrelated number. Under the short id settings uid.EnShortID returned it unchanged, so the raw title, spaces and all, was written into the path. The broken link reaches the new comment notification email built in internal/service/export/email_service.go and the CommentUrl handed to notification plugins in internal/service/notification_common/notification.go. A comment on a question takes the other branch and was already correct. Pass title and answerID in the order AnswerURL declares them, and add pkg/display/url_test.go covering both branches across all four permalink settings. --- pkg/display/url.go | 2 +- pkg/display/url_test.go | 115 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 pkg/display/url_test.go diff --git a/pkg/display/url.go b/pkg/display/url.go index 11574f0a5..fc7fbdbec 100644 --- a/pkg/display/url.go +++ b/pkg/display/url.go @@ -54,7 +54,7 @@ func AnswerURL(permalink int, siteUrl, questionID, title, answerID string) strin // CommentURL get comment url func CommentURL(permalink int, siteUrl, questionID, title, answerID, commentID string) string { if len(answerID) > 0 { - return AnswerURL(permalink, siteUrl, questionID, answerID, title) + "?commentId=" + commentID + return AnswerURL(permalink, siteUrl, questionID, title, answerID) + "?commentId=" + commentID } return QuestionURL(permalink, siteUrl, questionID, title) + "?commentId=" + commentID } diff --git a/pkg/display/url_test.go b/pkg/display/url_test.go new file mode 100644 index 000000000..dffbb3957 --- /dev/null +++ b/pkg/display/url_test.go @@ -0,0 +1,115 @@ +/* + * 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 display + +import ( + "testing" + + "github.com/apache/answer/internal/base/constant" + "github.com/stretchr/testify/assert" +) + +const ( + testSiteURL = "https://example.com" + testQuestionID = "10010000000000001" + testTitle = "How to install Answer" + testAnswerID = "10020000000000002" + testCommentID = "10030000000000003" +) + +// CommentURL passed title and answerID to AnswerURL in the wrong order, so a +// comment on an answer linked to a URL built from the title where the answer id +// belongs. Under the short id permalinks the raw title, spaces and all, ended up +// in the path. +func TestCommentURLOnAnswer(t *testing.T) { + cases := []struct { + name string + permalink int + want string + }{ + { + name: "question id and title", + permalink: constant.PermalinkQuestionIDAndTitle, + want: "https://example.com/questions/10010000000000001/how-to-install-answer/10020000000000002?commentId=10030000000000003", + }, + { + name: "question id", + permalink: constant.PermalinkQuestionID, + want: "https://example.com/questions/10010000000000001/10020000000000002?commentId=10030000000000003", + }, + { + name: "question id and title by short id", + permalink: constant.PermalinkQuestionIDAndTitleByShortID, + want: "https://example.com/questions/D1D1/how-to-install-answer/E1E1?commentId=10030000000000003", + }, + { + name: "question id by short id", + permalink: constant.PermalinkQuestionIDByShortID, + want: "https://example.com/questions/D1D1/E1E1?commentId=10030000000000003", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := CommentURL(tc.permalink, testSiteURL, testQuestionID, testTitle, testAnswerID, testCommentID) + assert.Equal(t, tc.want, got) + assert.Equal(t, + AnswerURL(tc.permalink, testSiteURL, testQuestionID, testTitle, testAnswerID)+"?commentId="+testCommentID, + got, + "a comment on an answer should be the answer URL plus the comment query") + }) + } +} + +// A comment on the question itself takes the other branch, which was already +// correct. Pin it so the fix above stays scoped to the answer branch. +func TestCommentURLOnQuestion(t *testing.T) { + cases := []struct { + name string + permalink int + want string + }{ + { + name: "question id and title", + permalink: constant.PermalinkQuestionIDAndTitle, + want: "https://example.com/questions/10010000000000001/how-to-install-answer?commentId=10030000000000003", + }, + { + name: "question id", + permalink: constant.PermalinkQuestionID, + want: "https://example.com/questions/10010000000000001?commentId=10030000000000003", + }, + { + name: "question id and title by short id", + permalink: constant.PermalinkQuestionIDAndTitleByShortID, + want: "https://example.com/questions/D1D1/how-to-install-answer?commentId=10030000000000003", + }, + { + name: "question id by short id", + permalink: constant.PermalinkQuestionIDByShortID, + want: "https://example.com/questions/D1D1?commentId=10030000000000003", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := CommentURL(tc.permalink, testSiteURL, testQuestionID, testTitle, "", testCommentID) + assert.Equal(t, tc.want, got) + }) + } +} From f4db9ec5275b3b2b3d925e35c7c6ce4966722483 Mon Sep 17 00:00:00 2001 From: Steven Koo Date: Tue, 1 Sep 2026 16:07:16 -0500 Subject: [PATCH 37/49] charts: support using an existing PVC via persistence.existingClaim Add a new `persistence.existingClaim` value that, when set, causes the deployment to mount the specified PVC instead of the chart-managed one and skips creation of the PVC resource entirely. Signed-off-by: Steven Koo --- charts/templates/deployment.yaml | 2 +- charts/templates/pvc.yaml | 2 +- charts/values.yaml | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/charts/templates/deployment.yaml b/charts/templates/deployment.yaml index f1b9d1862..57d57ac29 100644 --- a/charts/templates/deployment.yaml +++ b/charts/templates/deployment.yaml @@ -101,7 +101,7 @@ spec: - name: data {{- if .Values.persistence.enabled }} persistentVolumeClaim: - claimName: {{ include "answer.fullname" . }}-claim + claimName: {{ .Values.persistence.existingClaim | default (printf "%s-claim" (include "answer.fullname" .)) }} {{- else }} emptyDir: {} {{- end -}} diff --git a/charts/templates/pvc.yaml b/charts/templates/pvc.yaml index 640fb9fe0..e01ff820f 100644 --- a/charts/templates/pvc.yaml +++ b/charts/templates/pvc.yaml @@ -15,7 +15,7 @@ # specific language governing permissions and limitations # under the License. -{{ if .Values.persistence.enabled -}} +{{ if and .Values.persistence.enabled (not .Values.persistence.existingClaim) -}} kind: PersistentVolumeClaim apiVersion: v1 metadata: diff --git a/charts/values.yaml b/charts/values.yaml index d932db848..7a5c16f3b 100644 --- a/charts/values.yaml +++ b/charts/values.yaml @@ -91,6 +91,8 @@ persistence: accessMode: ReadWriteOnce size: 5Gi annotations: {} + # Use an existing PVC instead of creating one; when set, no PVC is created by this chart + existingClaim: "" # To restore a PVC from a VolumeSnapshot, set the dataSource; # the kind and apiGroup are optional and default to the shown values dataSource: {} From 7a7caac953cb1d980c2d496346421a059ca89e59 Mon Sep 17 00:00:00 2001 From: LinkinStars Date: Tue, 8 Sep 2026 14:30:12 +0800 Subject: [PATCH 38/49] 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(-) create mode 100644 pkg/checker/email_test.go diff --git a/i18n/en_US.yaml b/i18n/en_US.yaml index 5d1faa3e0..ac4191be0 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 f16ed9fad..2ee0c1a49 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 a9732fdf0..0eaebe9cf 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 000000000..b84c8e45b --- /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) + } + }) + } +} From 3985d20c7e5b85811eb5f009a4de7e14822f0e0e Mon Sep 17 00:00:00 2001 From: LinkinStars Date: Tue, 8 Sep 2026 15:43:49 +0800 Subject: [PATCH 39/49] fix(security): preserve User Center session identity --- .../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(-) create mode 100644 internal/service/auth/user_center_status_test.go create mode 100644 internal/service/user_external_login/user_center_login_service_test.go 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 000000000..df8b379e3 --- /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 90895ac4a..4dda13b7a 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 000000000..95d756607 --- /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) {} From 7f226dcd096b3456760afee08b2337bc7d858e22 Mon Sep 17 00:00:00 2001 From: Andy-Sverdlov-LucaNet <141342766+Andy-Sverdlov-LucaNet@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:13:35 +0200 Subject: [PATCH 40/49] fix: lowercase the email before hashing the Gravatar URL The Gravatar specification hashes the trimmed, lowercased address. GetAvatarURL only trimmed it, so an account whose stored address contains an uppercase letter hashed to an address Gravatar does not know: the user's avatar was never found and the identicon fallback was rendered instead. selectedAvatar recomputes this URL from the stored address on every response, so this affected both default_avatar: gravatar and an explicit per-user avatar.type: gravatar, and a user could not work around it by re-selecting Gravatar in their profile. Nothing in the backend normalises a stored address, and the external login path copies the provider's address verbatim, so accounts created through an OIDC/OAuth2 connector inherit whatever casing the identity provider sends. The web UI already lowercases before hashing, so the Settings -> Profile preview showed the user's real avatar while every other surface showed an identicon. This removes that disagreement. The hash is computed on read, so existing accounts resolve correctly as soon as this ships. Stored addresses are left untouched. Co-Authored-By: Claude Opus 5 --- pkg/gravatar/gravatar.go | 2 +- pkg/gravatar/gravatar_test.go | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/pkg/gravatar/gravatar.go b/pkg/gravatar/gravatar.go index 9c79b3872..30874f159 100644 --- a/pkg/gravatar/gravatar.go +++ b/pkg/gravatar/gravatar.go @@ -29,7 +29,7 @@ import ( // GetAvatarURL get avatar url from gravatar by email func GetAvatarURL(baseURL, email string) string { - hasher := sha256.Sum256([]byte(strings.TrimSpace(email))) + hasher := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(email)))) hash := hex.EncodeToString(hasher[:]) return baseURL + hash } diff --git a/pkg/gravatar/gravatar_test.go b/pkg/gravatar/gravatar_test.go index b88a69649..9a51773a0 100644 --- a/pkg/gravatar/gravatar_test.go +++ b/pkg/gravatar/gravatar_test.go @@ -41,6 +41,21 @@ func TestGetAvatarURL(t *testing.T) { args: args{email: "answer@answer.com"}, want: "https://www.gravatar.com/avatar/7296942c1f63d97f6c124705142009867638f7b3dbcdadd0cb1bcb40e427eb8e", }, + { + name: "mixed case address", + args: args{email: "Answer@Answer.com"}, + want: "https://www.gravatar.com/avatar/7296942c1f63d97f6c124705142009867638f7b3dbcdadd0cb1bcb40e427eb8e", + }, + { + name: "upper case address", + args: args{email: "ANSWER@ANSWER.COM"}, + want: "https://www.gravatar.com/avatar/7296942c1f63d97f6c124705142009867638f7b3dbcdadd0cb1bcb40e427eb8e", + }, + { + name: "padded mixed case address", + args: args{email: " Answer@Answer.com "}, + want: "https://www.gravatar.com/avatar/7296942c1f63d97f6c124705142009867638f7b3dbcdadd0cb1bcb40e427eb8e", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { From 22867b411c1a802f0a8f9dae79a07ab0289c37fd Mon Sep 17 00:00:00 2001 From: LinkinStars Date: Tue, 15 Sep 2026 10:11:37 +0800 Subject: [PATCH 41/49] 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 ++++ .../meta/meta_service_visibility_test.go | 103 ++++++++++++++++++ 5 files changed, 136 insertions(+), 7 deletions(-) create mode 100644 internal/service/meta/meta_service_visibility_test.go diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 446f6cc0b..66e162784 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 624daf093..2bce5eb15 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 e5a072529..64362879a 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 e48e8f468..070c04a82 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 000000000..a3cbbdbb6 --- /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 +} From d787464a637c840b88828bfdc9550040da094a18 Mon Sep 17 00:00:00 2001 From: Xan Torres Date: Wed, 19 Aug 2026 11:58:07 +0800 Subject: [PATCH 42/49] test: guard frontend behaviour a successful build does not demonstrate Two things the server depends on are invisible to the build. Both fail silently, so a green build and a working dev server actively disguise them. The server parses the script and stylesheet paths out of the built index.html and reuses them on every server-rendered page. That parse is coupled to the exact attribute set and attribute order the frontend build writes into those tags. A build that emits a different tag shape still succeeds, the dev server still works, the binary still compiles, and the pages simply render with no scripts and no stylesheet. TestGetStyleResolvesBuiltAssets asserts the parse still finds them. Languages other than the default one are loaded with a template-literal dynamic import through an alias that points outside the frontend root. A bundler that cannot enumerate that pattern still builds and still serves a working app; the resources never arrive, and only for non-default languages, so a smoke test in the default language misses it. check-locale-resolution.js bundles that same import with the project's own configuration, runs it, and requires two languages to resolve to distinct translated content. Both run through make check-ui. check-built-assets.sh --self-check confirms the asset check still fails on tag shapes the parser cannot read, so a check that quietly stopped asserting anything is distinguishable from a passing one. (cherry picked from commit 71cd9246fedbf6430c6a7373759fdf0be4029e4a, internal/controller/template_controller_test.go only) --- .../controller/template_controller_test.go | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 internal/controller/template_controller_test.go diff --git a/internal/controller/template_controller_test.go b/internal/controller/template_controller_test.go new file mode 100644 index 000000000..273b7df84 --- /dev/null +++ b/internal/controller/template_controller_test.go @@ -0,0 +1,56 @@ +/* + * 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/ui" + "github.com/stretchr/testify/require" +) + +// GetStyle scrapes the script and stylesheet paths out of the built +// index.html and every server-rendered page reuses them. The scrape is +// coupled to the exact attribute order and attribute set that the frontend +// build tool writes into those tags, and nothing in the system reports a +// mismatch: the frontend build still succeeds, the dev server still works, +// the binary still compiles, and the server-rendered pages simply come back +// with no script tags and no stylesheet. +// +// Assert the coupling directly so a change to the emitted tag shape fails +// here instead of shipping. +func TestGetStyleResolvesBuiltAssets(t *testing.T) { + const builtIndexPath = "build/index.html" + + if _, err := ui.Build.ReadFile(builtIndexPath); err != nil { + t.Skipf("no frontend build embedded at %s; build the frontend and re-run: %v", builtIndexPath, err) + } + + scripts, css := GetStyle() + + require.NotEmpty(t, scripts, + "no script sources parsed out of %s; server-rendered pages would load without any JavaScript", builtIndexPath) + for i, src := range scripts { + require.NotEmpty(t, src, "script source %d parsed out of %s is empty", i, builtIndexPath) + } + + require.NotEmpty(t, css, + "no stylesheet href parsed out of %s; server-rendered pages would load unstyled", builtIndexPath) +} From 791b5f743f08a78cb1608c580c3266f7ba8fe059 Mon Sep 17 00:00:00 2001 From: Xan Torres Date: Wed, 19 Aug 2026 11:58:34 +0800 Subject: [PATCH 43/49] fix: parse built asset tags without depending on the emitted format GetStyle scraped index.html with regexes matching one exact tag shape: classic scripts with defer first, and stylesheet links with href before rel. Any bundler emitting a different shape returned nothing, and server-rendered pages would load with no JavaScript and no stylesheet while every build step still reported success. The tags are now read from the parsed document, so attribute order, attribute set and quoting no longer matter. header.html emits the scraped paths as script tags itself, and those were classic scripts. A module bundle loaded that way fails on its first import, so fixing only the parsing would have left server-rendered pages broken; the tag is now declared as a module. The self-check fixtures are replaced. The previous two asserted failure on module scripts and on rel-before-href, both of which the parser now accepts, so they would have inverted into false alarms. The replacements cover a stylesheet with no script, a script with no stylesheet, and an inline script with no src. golang.org/x/net moves to a direct requirement, matching its use here. (cherry picked from commit eab6f9d80c084d8345a4c82821a64aaef512cce5, go.mod and internal/controller/template_controller.go only) --- go.mod | 2 +- internal/controller/template_controller.go | 60 ++++++++++++++++++---- 2 files changed, 52 insertions(+), 10 deletions(-) diff --git a/go.mod b/go.mod index 5787c8b18..7b68c180c 100644 --- a/go.mod +++ b/go.mod @@ -64,6 +64,7 @@ require ( go.uber.org/mock v0.6.0 golang.org/x/crypto v0.53.0 golang.org/x/image v0.20.0 + golang.org/x/net v0.56.0 golang.org/x/term v0.44.0 golang.org/x/text v0.39.0 gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df @@ -170,7 +171,6 @@ require ( go.uber.org/zap v1.27.0 // indirect golang.org/x/arch v0.10.0 // indirect golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect - golang.org/x/net v0.56.0 // indirect golang.org/x/sys v0.46.0 // indirect golang.org/x/tools v0.47.0 // indirect google.golang.org/protobuf v1.34.2 // indirect diff --git a/internal/controller/template_controller.go b/internal/controller/template_controller.go index 31cc5152a..8290d8b68 100644 --- a/internal/controller/template_controller.go +++ b/internal/controller/template_controller.go @@ -20,6 +20,7 @@ package controller import ( + "bytes" "encoding/json" "fmt" "html/template" @@ -50,6 +51,7 @@ import ( "github.com/apache/answer/ui" "github.com/gin-gonic/gin" "github.com/segmentfault/pacman/log" + "golang.org/x/net/html" ) var SiteUrl = "" @@ -88,19 +90,59 @@ func GetStyle() (script []string, css string) { if err != nil { return } - scriptRegexp := regexp.MustCompile(``) - scriptData := scriptRegexp.FindAllStringSubmatch(string(file), -1) - for _, s := range scriptData { - if len(s) == 2 { - script = append(script, s[1]) + + // The frontend build tool controls attribute order, attribute set (e.g. + // module vs classic scripts), and quoting for the emitted tags, and that + // shape has already changed once. Walk the parsed DOM instead of matching + // a literal tag shape so the next bundler change fails a test instead of + // silently shipping pages with no JS or CSS. + doc, err := html.Parse(bytes.NewReader(file)) + if err != nil { + return + } + + attr := func(n *html.Node, key string) (string, bool) { + for _, a := range n.Attr { + if a.Key == key { + return a.Val, true + } + } + return "", false + } + isStylesheet := func(n *html.Node) bool { + rel, ok := attr(n, "rel") + if !ok { + return false + } + for _, tok := range strings.Fields(rel) { + if strings.EqualFold(tok, "stylesheet") { + return true + } } + return false } - cssRegexp := regexp.MustCompile(``) - cssListData := cssRegexp.FindStringSubmatch(string(file)) - if len(cssListData) == 2 { - css = cssListData[1] + var walk func(*html.Node) + walk = func(n *html.Node) { + if n.Type == html.ElementNode { + switch n.Data { + case "script": + if src, ok := attr(n, "src"); ok && src != "" { + script = append(script, src) + } + case "link": + if css == "" && isStylesheet(n) { + if href, ok := attr(n, "href"); ok && href != "" { + css = href + } + } + } + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + walk(c) + } } + walk(doc) return } func (tc *TemplateController) SiteInfo(ctx *gin.Context) *schema.TemplateSiteInfoResp { From 88c5ef666df0749b2c1a24d464047d48430c6a58 Mon Sep 17 00:00:00 2001 From: Xan Torres Date: Sat, 1 Aug 2026 19:44:13 +0800 Subject: [PATCH 44/49] fix: render every entry stylesheet, not only the first GetStyle returned a single stylesheet path because the previous build emitted exactly one. The current build emits two, so server-rendered pages loaded partially unstyled while every build step still reported success. The stylesheet is now a list, mirroring how script paths are already collected and prefixed, and the template renders one link per entry. This is the same assumption as the tag-shape one fixed earlier: the server encoded a property of one bundler's output, here that there is exactly one entry stylesheet. (cherry picked from commit d66e21b252752ef9d231b958e7452e66202ba0f3) --- internal/controller/template_controller.go | 19 ++++++++++++------- .../controller/template_controller_test.go | 4 ++++ ui/template/header.html | 4 +++- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/internal/controller/template_controller.go b/internal/controller/template_controller.go index 8290d8b68..3c1021662 100644 --- a/internal/controller/template_controller.go +++ b/internal/controller/template_controller.go @@ -57,8 +57,11 @@ import ( var SiteUrl = "" type TemplateController struct { - scriptPath []string - cssPath string + scriptPath []string + // cssPath lists every stylesheet the frontend build emits, in document + // order; a build that emits more than one entry stylesheet needs all of + // them, not just the first, or server-rendered pages come back unstyled. + cssPath []string templateRenderController *templaterender.TemplateRenderController siteInfoService siteinfo_common.SiteInfoCommonService eventQueueService eventqueue.Service @@ -85,7 +88,7 @@ func NewTemplateController( questionService: questionService, } } -func GetStyle() (script []string, css string) { +func GetStyle() (script []string, css []string) { file, err := ui.Build.ReadFile("build/index.html") if err != nil { return @@ -131,9 +134,9 @@ func GetStyle() (script []string, css string) { script = append(script, src) } case "link": - if css == "" && isStylesheet(n) { + if isStylesheet(n) { if href, ok := attr(n, "href"); ok && href != "" { - css = href + css = append(css, href) } } } @@ -602,7 +605,7 @@ func (tc *TemplateController) Page404(ctx *gin.Context) { func (tc *TemplateController) html(ctx *gin.Context, code int, tpl string, siteInfo *schema.TemplateSiteInfoResp, data gin.H) { prefix := "" - cssPath := "" + cssPath := make([]string, len(tc.cssPath)) scriptPath := make([]string, len(tc.scriptPath)) _ = plugin.CallCDN(func(fn plugin.CDN) error { @@ -614,7 +617,9 @@ func (tc *TemplateController) html(ctx *gin.Context, code int, tpl string, siteI if prefix[len(prefix)-1:] == "/" { prefix = strings.TrimSuffix(prefix, "/") } - cssPath = prefix + tc.cssPath + for i, path := range tc.cssPath { + cssPath[i] = prefix + path + } for i, path := range tc.scriptPath { scriptPath[i] = prefix + path } diff --git a/internal/controller/template_controller_test.go b/internal/controller/template_controller_test.go index 273b7df84..6b4b4b79b 100644 --- a/internal/controller/template_controller_test.go +++ b/internal/controller/template_controller_test.go @@ -53,4 +53,8 @@ func TestGetStyleResolvesBuiltAssets(t *testing.T) { require.NotEmpty(t, css, "no stylesheet href parsed out of %s; server-rendered pages would load unstyled", builtIndexPath) + for i, href := range css { + require.NotEmpty(t, href, + "stylesheet href %d parsed out of %s is empty; server-rendered pages would load unstyled", i, builtIndexPath) + } } diff --git a/ui/template/header.html b/ui/template/header.html index d5d9a18ac..f9f0468b6 100644 --- a/ui/template/header.html +++ b/ui/template/header.html @@ -34,7 +34,9 @@ - + {{range $path := .cssPath}} + + {{end}} Date: Sat, 1 Aug 2026 20:40:39 +0800 Subject: [PATCH 45/49] style: iterate the rel attribute tokens without building a slice The linter configured for this repository flags the slice-building form, so make lint fails on it. (cherry picked from commit 1cfd5daf81bfe0823f497c27fea43297b15b18c9) --- internal/controller/template_controller.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/controller/template_controller.go b/internal/controller/template_controller.go index 3c1021662..4343738a6 100644 --- a/internal/controller/template_controller.go +++ b/internal/controller/template_controller.go @@ -117,7 +117,7 @@ func GetStyle() (script []string, css []string) { if !ok { return false } - for _, tok := range strings.Fields(rel) { + for tok := range strings.FieldsSeq(rel) { if strings.EqualFold(tok, "stylesheet") { return true } From 69fc3c96c3800f673f9ad1d2b0c7d32105c86b9f Mon Sep 17 00:00:00 2001 From: Xan Torres Date: Sat, 1 Aug 2026 21:09:52 +0800 Subject: [PATCH 46/49] test: assert every declared stylesheet is parsed, not just one Asserting that the parsed stylesheet list is non-empty leaves the exact regression this repository already hit uncovered: a parser that stops at the first stylesheet returns a one element list, satisfies every existing assertion, and silently drops the rest of the page's CSS. Count the declarations again by a cruder method than the parser uses and require the two to agree, so the parser has to be checked against something other than itself. The count is a lower bound: a build that quotes attributes differently drives it to zero and it stops constraining, which is why it supplements the shape-independent assertions rather than replacing them. Verified by reintroducing the truncation and watching this fail. (cherry picked from commit 9232cbd1fa7ef0a19e053474acf7c5468af43e74) --- .../controller/template_controller_test.go | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/internal/controller/template_controller_test.go b/internal/controller/template_controller_test.go index 6b4b4b79b..74c0db0a6 100644 --- a/internal/controller/template_controller_test.go +++ b/internal/controller/template_controller_test.go @@ -20,6 +20,7 @@ package controller import ( + "strings" "testing" "github.com/apache/answer/ui" @@ -39,7 +40,8 @@ import ( func TestGetStyleResolvesBuiltAssets(t *testing.T) { const builtIndexPath = "build/index.html" - if _, err := ui.Build.ReadFile(builtIndexPath); err != nil { + raw, err := ui.Build.ReadFile(builtIndexPath) + if err != nil { t.Skipf("no frontend build embedded at %s; build the frontend and re-run: %v", builtIndexPath, err) } @@ -57,4 +59,20 @@ func TestGetStyleResolvesBuiltAssets(t *testing.T) { require.NotEmpty(t, href, "stylesheet href %d parsed out of %s is empty; server-rendered pages would load unstyled", i, builtIndexPath) } + + // Finding every stylesheet matters as much as finding one. The build emits + // more than a single entry stylesheet, and a parser that stopped at the + // first one would still satisfy every assertion above while half the page's + // CSS silently stopped loading. That regression has happened once already. + // + // Count them again by a deliberately different and cruder method than the + // parser uses, so the two have to agree. It is a lower bound: a build that + // quotes attributes differently drives this to zero and the comparison + // simply stops constraining, which is why it supplements the assertions + // above rather than replacing them. + declared := strings.Count(string(raw), `rel="stylesheet"`) + require.GreaterOrEqual(t, len(css), declared, + "%s declares at least %d stylesheets but only %d were parsed out of it; "+ + "server-rendered pages would load missing part of their CSS", + builtIndexPath, declared, len(css)) } From ace02c499a0ccf64f05e99185d7e6dcfd4aead06 Mon Sep 17 00:00:00 2001 From: Xan Torres Date: Wed, 19 Aug 2026 11:59:30 +0800 Subject: [PATCH 47/49] chore: align asset-check comments with the DOM-tolerant parser The GetStyle comment framed the DOM walk around attribute order, attribute set, and quoting, the same properties the old regex parser depended on, without stating that the new parser ignores all of them. check-built-assets.sh described its self-check as failing when asset tags change shape, but the fixtures test a missing script or stylesheet tag, and the parser accepts any shape as long as the tag is present. Comments now state the actual constraint: tag shape does not affect parsing, and the guarding check fails when a build is present but a required script or stylesheet tag is missing from it. (cherry picked from commit 3bc12e80a659a08dbe72c840a60b25c8c0f90963, internal/controller/template_controller.go only) --- internal/controller/template_controller.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/controller/template_controller.go b/internal/controller/template_controller.go index 4343738a6..0f2f5b68b 100644 --- a/internal/controller/template_controller.go +++ b/internal/controller/template_controller.go @@ -94,11 +94,11 @@ func GetStyle() (script []string, css []string) { return } - // The frontend build tool controls attribute order, attribute set (e.g. - // module vs classic scripts), and quoting for the emitted tags, and that - // shape has already changed once. Walk the parsed DOM instead of matching - // a literal tag shape so the next bundler change fails a test instead of - // silently shipping pages with no JS or CSS. + // Script and stylesheet tags are read from the parsed document, so + // attribute order, attribute set (module vs classic scripts), and + // quoting do not matter. That shape has already changed once; a + // bundler change that breaks it now fails the guarding test instead + // of silently shipping pages with no JS or CSS. doc, err := html.Parse(bytes.NewReader(file)) if err != nil { return From 736dde0eed9b4067f0958fa7816bece99f7aa9cd Mon Sep 17 00:00:00 2001 From: Xan Torres Date: Fri, 18 Sep 2026 17:48:25 +0800 Subject: [PATCH 48/49] build: replace Create React App with Vite (#1616) Fixes #1580. Part of #1578. Step 2 of 3, atomic. Depends on https://github.com/apache/answer/pull/1582 (the Go asset contract, now merged into `dev`), which is why the server-side parsing does not appear in this diff. Replaces `react-scripts` and `react-app-rewired` with Vite. The configuration preserves the output contracts the server depends on: the build directory stays at `ui/build`, which `ui/static.go` embeds, and emitted assets stay under `static/`, which `internal/router/ui.go` serves as a route. Files keep the `static/js`, `static/css` and `static/media` grouping, which the `analyze` script matches on; the ignore rules for `ui/build`, previously pinned to that layout's directory depth, now ignore everything under `build` except the tracked favicon. Production sourcemaps stay on, and the `REACT_APP_` prefix is retained so `ui/scripts/env.js` remains the single source of truth for configuration shared with the server. That same script's `public_url` value now also drives Vite's `base` configuration, and the manifest link in `ui/index.html` uses Vite's `%BASE_URL%` macro instead of a hardcoded path, so a non-root deployment keeps every asset and manifest reference prefixed the way it did under the previous toolchain's `PUBLIC_URL` wiring. Agentic tooling did the mechanical work in this series; every change was reviewed by a human before being committed. ### The one server-side line `header.html` re-emitted the paths `GetStyle()` finds as classic scripts. An ES module loaded through a classic script tag fails on its first import, so the tag is now declared as a module and carries `crossorigin`, matching the tag Vite's own build emits for the client-rendered entry point. Rewriting the built `index.html` instead was not an option, because `internal/router/ui.go` serves that same file to boot the SPA and it cannot misdeclare its own script type. A `type=module` script fetches in CORS mode, so any CDN origin serving these files needs to send the matching CORS headers, with or without `crossorigin` present; default same-origin deployments are unaffected. The note for CDN users is in the CDN plugin READMEs, apache/answer-plugins#326, per the placement decided on #1567, and the release notes for the version that ships this should carry a short pointer to it. ### Route code splitting Routes loaded pages with `` lazy(() => import(`@/pages/${pagePath}`)) ``. That shape cannot be statically analyzed, so no page received its own chunk and the specifier reached the browser untransformed, leaving every lazily routed page unable to load. Pages are now enumerated with a bounded glob covering the three directory depths routes actually use, excluding component subtrees so their own index files do not become route chunks. A page path with no matching module now rejects with the requested path and the list of known keys, surfacing through the existing route error boundary. ### Behaviour changes, called out deliberately **The custom stylesheet link now derives its href from the build's own base, not a separately computed value.** `PageTags` built the `/custom.css` link from `process.env.PUBLIC_URL`, which the previous toolchain exposed with its trailing slash already stripped; at the default configuration that value was an empty string, resolving to `/custom.css`. `import.meta.env.BASE_URL`, the direct equivalent under the new toolchain, keeps the trailing slash, so substituting it in the same place would resolve the same default configuration to `//custom.css` instead. The trailing slash is stripped explicitly before the substitution, reproducing the previous output at the default configuration and staying correct away from it. The output here is unchanged; only the mechanism it depends on is. **Two routes were dead ends and now fail loudly instead of silently.** `pages/403` and `pages/Admin/UserOverview` both referenced modules that did not exist in the tree, and both failed the same way under the previous toolchain, silently rendering blank. With the glob above, both now report the missing path through the route error boundary. Step 3 repoints `pages/403` at `pages/404/403`, an existing component, so that route renders; `pages/Admin/UserOverview` stays a visible gap because creating the missing page is a content decision. Neither is a regression. **The markdown editor now renders its themed background.** `src/components/Editor/index.scss` read `var(-bs-body-bg)` with a single leading dash. That is not a valid custom property reference, so the declaration was discarded and the editor never received the background it asks for. The previous CSS minifier accepted the invalid value; the current one rejects it outright and fails the build (`[lightningcss minify] Unexpected token Ident("-bs-body-bg")`), which is how it surfaced and why the one-line fix is part of this PR rather than a follow-up. Fixing it changes rendering. ### Dependency removals `react-scripts`, `react-app-rewired`, `customize-cra` and `config-overrides.js` are gone, and `yaml-loader` is replaced by the equivalent Vite plugin, pinned to the schema the previous loader used so bare dates and merge keys keep parsing the same way they did before. The scaffold test the old toolchain generated (`App.test.tsx`) and its jest packages go with it; this project has no test runner and no unit tests, before or after. Three removed packages were already inert before this migration. Both purgecss packages were declared but wired nowhere: no postcss config exists, the overrides file never referenced them, and no script invoked them. `buffer` was aliased and provided as a global, but no application source uses it, and the one dependency requiring it declares `buffer: false` in its own browser field. `sass` and `@types/node` are raised to the versions the toolchain requires; the previous `sass` predates the async compiler API it now calls. `sass` is pinned below the release that begins deprecating `@import`, which this project uses across 30 files. Migrating those to `@use` is a separate concern. Bootstrap's own Sass internals print dozens of dependency deprecation warnings on every build, unrelated to anything in this project's own styles; `vite.config.mts` sets `css.preprocessorOptions.scss.quietDeps: true` to silence those specifically while still surfacing warnings from this project's own stylesheets. One such app-own warning remains in this PR's build output, a mixed-declarations notice from `Comment/index.scss`; the one-line reorder that clears it is a step 3 nit. The eslint config no longer extends `react-app/jest`, which shipped inside `react-scripts` and configured rules for a test suite this project does not have. ### A failure found only by running the built application With the build green and the dev server working, the built application did not boot. React never mounted, the page showed its loading spinner indefinitely, and the browser console was empty. `i18next` attaches its resource-store methods to the instance inside `init()`. The builtin plugins register their translations while their modules are being evaluated. Whether that happens before or after `init` depends on how the bundler groups and orders chunks, so the previously working order was incidental rather than guaranteed. When it inverts, the registration throws while the entry module is still evaluating, which takes the application down before it mounts and produces no console output. Registration now happens immediately only when there is an initialised instance to register into, and otherwise falls to the `initialized` handler the code already installed, which is correct in either order. The check that guards both orders is part of step 3. ### Parity fixes found in review **Bootstrap icon fonts.** The bootstrap-icons stylesheet points at font files under a path the bundler could not resolve on the first migration pass, so the build silently emitted zero font files and every icon rendered as a missing-glyph box. The stylesheet now overrides the package's font-directory variable to a path Vite can resolve; the fonts are emitted, and the boot test below confirms they are served. **Type checking.** `pnpm build` now also runs `tsc --noEmit` before the bundler runs, and is clean today. The previous toolchain ran its checker in the dev server (blocking) and during builds (downgraded to warnings by this project's `TSC_COMPILE_ON_ERROR` setting); none of that carried over when the bundler changed, so until this fix a type error shipped with no signal at all. **Yaml parsing.** The Vite yaml plugin defaults to js-yaml's more permissive schema, which resolves bare dates to JS `Date` objects and enables merge keys; the previous loader's schema kept both as plain strings. The plugin is now pinned to that same schema. **Declared Node range.** The bundler's declared support range is `^20.19.0 || >=22.12.0`. `ui/package.json`'s own `engines.node` allowed `>=20`, which admits versions below that floor, and now matches it exactly. The release workflow's pinned Node, which also sat below the floor, is raised to `20.19.0` to match. That version bump is the only change this PR makes anywhere under `.github/`; it does not add a job. **Typed environment variables.** `import.meta.env.REACT_APP_*` previously typed as `any`, since no `ImportMetaEnv` augmentation existed; a typo'd key would compile clean and only surface as a missing value at runtime. The two keys the app reads this way, `REACT_APP_API_URL` and `REACT_APP_BASE_URL`, are now declared, with `strictImportMetaEnv` enabled so an undeclared key is a type error instead of a silent `any`. ### Verification On `dev` at `ace02c49`, which includes step 1, plus these commits: `pnpm install --frozen-lockfile` under the pinned `pnpm@9.7.0` and `pnpm build` (which now includes `tsc --noEmit`) complete; the build prints three warnings, each once: `front-matter`, a dependency unrelated to this project's own pinned `js-yaml@^4.1.0`, pulls in a legacy `js-yaml@3.x` copy whose `buffer` import gets externalized for browser compatibility; the Sass mixed-declarations notice from `Comment/index.scss` mentioned above; and one chunk over the default size threshold, see the note on chunking below. `go build ./...` and `go vet ./...` pass; `TestGetStyleResolvesBuiltAssets` passes against the Vite output; a search for `react-scripts`, `react-app-rewired`, `customize-cra` and `config-overrides` finds nothing outside the lockfile. The built binary was booted against sqlite3 and loaded in a browser: `/` renders with the client mounted (React's fiber container present on the root element), the module entry script and both entry stylesheets are fetched, and the console shows no errors. The same holds for `/tags`. The server-rendered `/` response itself carries the module entry script and both entry stylesheets, which is `GetStyle()` from step 1 finding them in the Vite output and `header.html` re-emitting them. The three `make check-ui` guards from #1567 (asset paths, non-default locale, plugin i18n order) are not in this PR; they arrive as step 3 and were run green against the equivalent tree there. ### Measurements Captured for #1567 at its head `d06b623`, against `main` at `3b9f137`, same machine, same Node and package manager versions, clean tree and clean install on both sides, five runs per timing metric. They are carried over rather than recaptured: the frontend build inputs here are the same as at that head, minus the step 3 nits and plus the newer locale files on `dev`. | Metric | Before | After | |---|---|---| | Cold production build | 19.87s | 4.77s | | Warm build | 8.75s | 4.87s | | Dev server time to ready | 7104ms | 359ms | | HMR latency | 421ms | 139ms | | Bundle JS, raw / gzip | 3477.60KB / 1198.75KB | 3010.49KB / 1035.57KB | | Bundle total, raw / gzip | 4290.39KB / 1652.24KB | 3763.97KB / 1474.34KB | | Direct dependencies | 75 | 65 | | Packages installed | 1578 | 689 | | Audit findings, critical/high/moderate/low | 4/78/63/13 | 1/50/38/6 | Five things worth stating rather than leaving to be inferred: - Cold and warm builds are within noise of each other, because the new build has no meaningful persistent cache to warm and now also runs a type check on every build, cold or warm alike. The old toolchain had a real warm cache, which is why its two figures differ so much. The comparison to draw is cold against cold. - Dev server time-to-ready is wall clock on both sides, including process spawn. The new tool self-reports a much smaller number that excludes that, and using it would compare two different quantities. - Chunk boundaries differ structurally between the two bundlers, so the bundle rows compare total shipped bytes rather than like-for-like chunks. - HMR latency was measured once, at commit 7200ca3 on the #1567 branch, on both toolchains, and carried forward from there: none of the commits since touch the hot-update path. - The bundle totals include the bootstrap-icons font files the first migration pass had silently dropped, and the cold build includes the restored type check. Both are named costs of parity fixes, already folded into the deltas. ### Not included This PR adds no CI job for the frontend. The project runs no frontend job today, and a build on every push is a cost a maintainer should choose to take on, not one this migration should impose. One constraint carries forward for whoever wires that job later: a bare `go test ./...` reports ok while asserting nothing about the built asset paths, because `TestGetStyleResolvesBuiltAssets` skips when no frontend build is embedded. Any future CI job needs to build the frontend first. The dev server now binds to loopback only by default; reaching it from another device on the network needs an explicit `--host` flag. Create React App's SVG-as-component imports (`import { ReactComponent as X } from './x.svg'`) are not carried over to this configuration. Nothing in this codebase used them. The commits carry `(cherry picked from commit ...)` lines pointing at the branch behind #1567, where these changes were first reviewed and where the regression matrix (subdirectory deploy, OAuth callbacks, absolute CDN `public_url`) was run. --- .../workflows/build-binary-for-release.yml | 2 +- .gitignore | 6 +- ui/.env.development | 1 - ui/.env.production | 4 +- ui/.eslintignore | 1 - ui/.eslintrc.js | 1 - ui/.gitignore | 6 +- ui/config-overrides.js | 154 - ui/{public => }/index.html | 3 +- ui/package.json | 27 +- ui/pnpm-lock.yaml | 13897 +++------------- ui/scripts/env.js | 4 +- ui/src/App.test.tsx | 30 - ui/src/App.tsx | 2 +- ui/src/components/Editor/index.scss | 2 +- .../InitialLoadingPlaceholder/index.scss | 2 +- .../InitialLoadingPlaceholder/index.tsx | 2 +- ui/src/components/PageTags/index.tsx | 2 +- ui/src/index.scss | 8 +- .../components/HealthStatus/index.tsx | 4 +- .../AuthFailed/components/WeCom.tsx | 15 +- ui/src/react-app-env.d.ts | 16 +- ui/src/router/alias.ts | 2 +- ui/src/router/index.tsx | 51 +- ui/src/utils/common.ts | 3 +- ui/src/utils/localize.ts | 4 +- ui/src/utils/pluginKit/utils.ts | 12 +- ui/src/utils/request.ts | 3 +- ui/template/header.html | 2 +- ui/tsconfig.json | 1 - ui/vite.config.mts | 130 + 31 files changed, 2865 insertions(+), 11532 deletions(-) delete mode 100644 ui/config-overrides.js rename ui/{public => }/index.html (97%) delete mode 100644 ui/src/App.test.tsx create mode 100644 ui/vite.config.mts diff --git a/.github/workflows/build-binary-for-release.yml b/.github/workflows/build-binary-for-release.yml index 2bce67142..76f1c9530 100644 --- a/.github/workflows/build-binary-for-release.yml +++ b/.github/workflows/build-binary-for-release.yml @@ -36,7 +36,7 @@ jobs: - name: Set up Node uses: actions/setup-node@v4 with: - node-version: 20.18.1 + node-version: 20.19.0 - name: Node Build run: make install-ui-packages ui diff --git a/.gitignore b/.gitignore index ba66f51a0..d5aed0da5 100644 --- a/.gitignore +++ b/.gitignore @@ -17,10 +17,8 @@ /go.work* /logs /ui/node_modules -/ui/build/*/*/* -/ui/build/*.json -/ui/build/*.html -/ui/build/*.txt +/ui/build/* +!/ui/build/favicon.ico /vendor Thumbs*.db tmp diff --git a/ui/.env.development b/ui/.env.development index a634cee2e..3e28eec99 100644 --- a/ui/.env.development +++ b/ui/.env.development @@ -1,2 +1 @@ -PUBLIC_URL REACT_APP_API_URL = http://10.0.20.84:8080/ diff --git a/ui/.env.production b/ui/.env.production index f86b9ccdd..192714cf7 100644 --- a/ui/.env.production +++ b/ui/.env.production @@ -1,6 +1,4 @@ -TSC_COMPILE_ON_ERROR=true -ESLINT_NO_DEV_ERRORS=true -PUBLIC_URL=/ +REACT_APP_PUBLIC_URL=/ REACT_APP_API_URL=/ REACT_APP_BASE_URL= REACT_APP_API_BASE_URL= diff --git a/ui/.eslintignore b/ui/.eslintignore index 1e6d1c5cd..0e9877734 100644 --- a/ui/.eslintignore +++ b/ui/.eslintignore @@ -1,5 +1,4 @@ public -config-overrides.js commitlint.config.js build .eslintrc.js diff --git a/ui/.eslintrc.js b/ui/.eslintrc.js index 1d9052600..bd81c2d6b 100644 --- a/ui/.eslintrc.js +++ b/ui/.eslintrc.js @@ -24,7 +24,6 @@ module.exports = { es2021: true, }, extends: [ - 'react-app/jest', 'plugin:react/recommended', 'airbnb', 'airbnb-typescript', diff --git a/ui/.gitignore b/ui/.gitignore index 3b1e96bd4..653e95a4e 100644 --- a/ui/.gitignore +++ b/ui/.gitignore @@ -10,10 +10,8 @@ node_modules # production -/build/*/*/* -/build/*.json -/build/*.html -/build/*.txt +/build/* +!/build/favicon.ico # misc .DS_Store diff --git a/ui/config-overrides.js b/ui/config-overrides.js deleted file mode 100644 index 7d62b1d8e..000000000 --- a/ui/config-overrides.js +++ /dev/null @@ -1,154 +0,0 @@ -/* - * 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. - */ - -const { - addWebpackModuleRule, - addWebpackAlias, - setWebpackOptimizationSplitChunks, - addWebpackPlugin, -} = require("customize-cra"); -const webpack = require('webpack'); - -const path = require("path"); -const i18nPath = path.resolve(__dirname, "../i18n"); - -module.exports = { - webpack: function(config, env) { - addWebpackAlias({ - "@": path.resolve(__dirname, "src"), - "@i18n": i18nPath, - buffer: 'buffer', - })(config); - - addWebpackModuleRule({ - test: /\.ya?ml$/, - use: "yaml-loader" - })(config); - - addWebpackPlugin( - new webpack.ProvidePlugin({ - Buffer: ['buffer', 'Buffer'], - }) - )(config); - - setWebpackOptimizationSplitChunks({ - maxInitialRequests: 20, - minSize: 20 * 1024, - minChunks: 2, - cacheGroups: { - automaticNamePrefix: 'chunk', - mix1: { - test: (module, chunks) => { - return ( - module.resource && - (module.resource.includes('components') || - /\/node_modules\/react-bootstrap\//.test(module.resource)) - ); - }, - name: 'chunk-mix1', - filename: 'static/js/[name].[contenthash:8].chunk.js', - priority: 14, - reuseExistingChunk: true, - minChunks: process.env.NODE_ENV === 'production' ? 1 : 2, - chunks: 'initial', - }, - mix2: { - name: 'chunk-mix2', - test: /[\/]node_modules[\/](i18next|lodash|marked|next-share)[\/]/, - filename: 'static/js/[name].[contenthash:8].chunk.js', - priority: 13, - reuseExistingChunk: true, - minChunks: 1, - chunks: 'initial', - }, - mix3: { - name: 'chunk-mix3', - test: /[\/]node_modules[\/](@remix-run|@restart|axios|diff)[\/]/, - filename: 'static/js/[name].[contenthash:8].chunk.js', - priority: 12, - reuseExistingChunk: true, - minChunks: 1, - chunks: 'initial', - }, - codemirror: { - name: 'codemirror', - test: /[\/]node_modules[\/](\@codemirror)[\/]/, - priority: 10, - reuseExistingChunk: true, - minChunks: process.env.NODE_ENV === 'production' ? 1 : 2, - chunks: 'initial', - enforce: true, - }, - lezer: { - name: 'lezer', - test: /[\/]node_modules[\/](\@lezer)[\/]/, - priority: 9, - reuseExistingChunk: true, - minChunks: process.env.NODE_ENV === 'production' ? 1 : 2, - chunks: 'initial', - enforce: true, - }, - reactDom: { - name: 'react-dom', - test: /[\/]node_modules[\/](react-dom)[\/]/, - filename: 'static/js/[name].[contenthash:8].chunk.js', - priority: 8, - reuseExistingChunk: true, - chunks: 'all', - enforce: true, - }, - nodesInitial: { - name: 'chunk-nodesInitial', - filename: 'static/js/[name].[contenthash:8].chunk.js', - test: /[\/]node_modules[\/]/, - priority: 1, - minChunks: 1, - chunks: 'initial', - reuseExistingChunk: true, - }, - }, - })(config); - - // add i18n dir to ModuleScopePlugin allowedPaths - const moduleScopePlugin = config.resolve.plugins.find(_ => _.constructor.name === "ModuleScopePlugin"); - if (moduleScopePlugin) { - moduleScopePlugin.allowedPaths.push(i18nPath); - } - - return config; - }, - devServer: function(configFunction) { - return function(proxy, allowedHost) { - const config = configFunction(proxy, allowedHost); - config.proxy = [ - { - context: ['/answer', '/installation'], - target: process.env.REACT_APP_API_URL, - changeOrigin: true, - secure: false, - }, - { - context: ['/custom.css'], - target: process.env.REACT_APP_API_URL, - } - ]; - return config; - }; - } -}; diff --git a/ui/public/index.html b/ui/index.html similarity index 97% rename from ui/public/index.html rename to ui/index.html index 5bca47e40..af48ece0b 100644 --- a/ui/public/index.html +++ b/ui/index.html @@ -25,7 +25,7 @@ - + @@ -86,6 +86,7 @@
+ + {{end}} {{if $.siteinfo.JsonLD }}{{ .siteinfo.JsonLD | templateHTML}}{{end}} diff --git a/ui/tsconfig.json b/ui/tsconfig.json index 648dd0253..02d23f751 100644 --- a/ui/tsconfig.json +++ b/ui/tsconfig.json @@ -33,7 +33,6 @@ }, "include": [ "src", - "node_modules/@testing-library/jest-dom", "scripts" ], "exclude": [ diff --git a/ui/vite.config.mts b/ui/vite.config.mts new file mode 100644 index 000000000..90f47e78d --- /dev/null +++ b/ui/vite.config.mts @@ -0,0 +1,130 @@ +/* + * 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. + */ + +import path from 'path'; +import { fileURLToPath } from 'url'; + +import react from '@vitejs/plugin-react'; +import yaml from '@modyfi/vite-plugin-yaml'; +import { CORE_SCHEMA } from 'js-yaml'; +import { defineConfig, loadEnv } from 'vite'; + +// This file is loaded as a real ES module, where __dirname does not exist. +const rootDir = path.dirname(fileURLToPath(import.meta.url)); +const i18nDir = path.resolve(rootDir, '../i18n'); + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, rootDir, 'REACT_APP_'); + + // configs/config.yaml ui.public_url, as written by scripts/env.js, may or + // may not already carry a trailing slash (the root value is exactly "/"). + // Vite requires base to end with one, so add it only when missing rather + // than concatenating blindly and risking "//". + // + // Vite keeps an absolute external base (e.g. a CDN URL) exactly as given + // only for `vite build`. `vite dev` and `vite preview` reduce the same + // base to its bare pathname, dropping the scheme and host. That split is + // intentional here, not a bug to unify: those two commands only serve + // this app locally, and the Go server only ever embeds `vite build`'s + // output, so the reduction never reaches anything a real deployment + // serves. + const publicUrl = env.REACT_APP_PUBLIC_URL || '/'; + const base = publicUrl.endsWith('/') ? publicUrl : `${publicUrl}/`; + + return { + // The previous yaml-loader (yaml@2.6.1 core schema) kept bare dates as + // strings and left merge keys unresolved. @modyfi/vite-plugin-yaml + // defaults to js-yaml's DEFAULT_SCHEMA, which resolves bare YYYY-MM-DD + // scalars to JS Date objects and enables merge keys. Pin CORE_SCHEMA so + // yaml imports keep parsing the way they did before the migration. + plugins: [react(), yaml({ schema: CORE_SCHEMA })], + + css: { + preprocessorOptions: { + // bootstrap 5.3.3's own scss internals emit dozens of deprecation + // warnings (color functions, mixed-decls) on every build. They are + // unactionable here and bury warnings that point at our own code. + scss: { quietDeps: true }, + }, + }, + + // scripts/env.js generates .env.production from the server's own + // configs/config.yaml using REACT_APP_ names. Reading that prefix keeps the + // generator as the single source of truth for both sides. + envPrefix: 'REACT_APP_', + + base, + + resolve: { + alias: { + '@': path.resolve(rootDir, 'src'), + '@i18n': i18nDir, + }, + }, + + build: { + // ui/static.go embeds this directory, and internal/router/ui.go serves + // /static from it. Neither path is configurable from here. + outDir: 'build', + assetsDir: 'static', + // Matches the previous build so before/after size comparisons measure the + // bundler rather than a change of sourcemap setting. + sourcemap: true, + rollupOptions: { + output: { + // Keep emitted files grouped under static/js, static/css and + // static/media. The analyze script globs that layout, and a flat + // static/ directory silently matches nothing. + entryFileNames: 'static/js/[name].[hash].js', + chunkFileNames: 'static/js/[name].[hash].chunk.js', + assetFileNames: (assetInfo) => { + const name = assetInfo.names?.[0] ?? ''; + if (name.endsWith('.css')) { + return 'static/css/[name].[hash][extname]'; + } + return 'static/media/[name].[hash][extname]'; + }, + }, + }, + }, + + server: { + port: 3000, + proxy: { + '/answer': { + target: env.REACT_APP_API_URL, + changeOrigin: true, + secure: false, + }, + '/installation': { + target: env.REACT_APP_API_URL, + changeOrigin: true, + secure: false, + }, + '/custom.css': { + target: env.REACT_APP_API_URL, + }, + }, + fs: { + // Languages live outside this root and are loaded through @i18n. + allow: [rootDir, i18nDir], + }, + }, + }; +}); From 6744295fa888e0621fc9f9a8ace04f1a4daf8554 Mon Sep 17 00:00:00 2001 From: LinkinStars Date: Tue, 22 Sep 2026 15:53:14 +0800 Subject: [PATCH 49/49] 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 0623e1efd..74e51886b 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 cf257aba2..0d958e007 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