chore: import upstream snapshot with attribution
CI / Check and lint (push) Failing after 1s
Lint / Lint (ubuntu-latest) (push) Failing after 1s

This commit is contained in:
wehub-resource-sync
2026-07-13 12:22:32 +08:00
commit 39dbe3a57d
1131 changed files with 272709 additions and 0 deletions
@@ -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 controller
import (
"github.com/apache/answer/internal/base/handler"
"github.com/apache/answer/internal/base/middleware"
"github.com/apache/answer/internal/schema"
"github.com/apache/answer/internal/service/activity"
"github.com/apache/answer/internal/service/role"
"github.com/apache/answer/pkg/uid"
"github.com/gin-gonic/gin"
)
type ActivityController struct {
activityService *activity.ActivityService
}
// NewActivityController new activity controller.
func NewActivityController(
activityService *activity.ActivityService) *ActivityController {
return &ActivityController{activityService: activityService}
}
// GetObjectTimeline get object timeline
// @Summary get object timeline
// @Description get object timeline
// @Tags Comment
// @Produce json
// @Param object_id query string false "object id"
// @Param tag_slug_name query string false "tag slug name"
// @Param object_type query string false "object type" Enums(question, answer, tag)
// @Param show_vote query boolean false "is show vote"
// @Success 200 {object} handler.RespBody{data=schema.GetObjectTimelineResp}
// @Router /answer/api/v1/activity/timeline [get]
func (ac *ActivityController) GetObjectTimeline(ctx *gin.Context) {
req := &schema.GetObjectTimelineReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.ObjectID = uid.DeShortID(req.ObjectID)
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
req.IsAdminModerator = middleware.GetUserIsAdminModerator(ctx)
if userInfo := middleware.GetUserInfoFromContext(ctx); userInfo != nil {
req.IsAdmin = userInfo.RoleID == role.RoleAdminID
}
resp, err := ac.activityService.GetObjectTimeline(ctx, req)
handler.HandleResponse(ctx, err, resp)
}
// GetObjectTimelineDetail get object timeline detail
// @Summary get object timeline detail
// @Description get object timeline detail
// @Tags Comment
// @Produce json
// @Param revision_id query string true "revision id"
// @Success 200 {object} handler.RespBody{data=schema.GetObjectTimelineResp}
// @Router /answer/api/v1/activity/timeline/detail [get]
func (ac *ActivityController) GetObjectTimelineDetail(ctx *gin.Context) {
req := &schema.GetObjectTimelineDetailReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
req.IsAdminModerator = middleware.GetUserIsAdminModerator(ctx)
resp, err := ac.activityService.GetObjectTimelineDetail(ctx, req)
handler.HandleResponse(ctx, err, resp)
}
+793
View File
@@ -0,0 +1,793 @@
/*
* 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 (
"context"
"encoding/json"
"fmt"
"maps"
"net/http"
"strings"
"time"
"github.com/apache/answer/internal/base/constant"
"github.com/apache/answer/internal/base/handler"
"github.com/apache/answer/internal/base/middleware"
"github.com/apache/answer/internal/schema"
"github.com/apache/answer/internal/schema/mcp_tools"
"github.com/apache/answer/internal/service/ai_conversation"
answercommon "github.com/apache/answer/internal/service/answer_common"
"github.com/apache/answer/internal/service/comment"
"github.com/apache/answer/internal/service/content"
"github.com/apache/answer/internal/service/feature_toggle"
questioncommon "github.com/apache/answer/internal/service/question_common"
"github.com/apache/answer/internal/service/siteinfo_common"
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/gin-gonic/gin"
"github.com/mark3labs/mcp-go/mcp"
"github.com/sashabaranov/go-openai"
"github.com/segmentfault/pacman/errors"
"github.com/segmentfault/pacman/i18n"
"github.com/segmentfault/pacman/log"
)
type AIController struct {
searchService *content.SearchService
siteInfoService siteinfo_common.SiteInfoCommonService
tagCommonService *tagcommonser.TagCommonService
questioncommon *questioncommon.QuestionCommon
commentRepo comment.CommentRepo
userCommon *usercommon.UserCommon
answerRepo answercommon.AnswerRepo
mcpController *MCPController
aiConversationService ai_conversation.AIConversationService
featureToggleSvc *feature_toggle.FeatureToggleService
}
// NewAIController new site info controller.
func NewAIController(
searchService *content.SearchService,
siteInfoService siteinfo_common.SiteInfoCommonService,
tagCommonService *tagcommonser.TagCommonService,
questioncommon *questioncommon.QuestionCommon,
commentRepo comment.CommentRepo,
userCommon *usercommon.UserCommon,
answerRepo answercommon.AnswerRepo,
mcpController *MCPController,
aiConversationService ai_conversation.AIConversationService,
featureToggleSvc *feature_toggle.FeatureToggleService,
) *AIController {
return &AIController{
searchService: searchService,
siteInfoService: siteInfoService,
tagCommonService: tagCommonService,
questioncommon: questioncommon,
commentRepo: commentRepo,
userCommon: userCommon,
answerRepo: answerRepo,
mcpController: mcpController,
aiConversationService: aiConversationService,
featureToggleSvc: featureToggleSvc,
}
}
func (c *AIController) ensureAIChatEnabled(ctx *gin.Context) bool {
if c.featureToggleSvc == nil {
return true
}
if err := c.featureToggleSvc.EnsureEnabled(ctx, feature_toggle.FeatureAIChatbot); err != nil {
handler.HandleResponse(ctx, err, nil)
return false
}
return true
}
type ChatCompletionsRequest struct {
Messages []Message `validate:"required,gte=1" json:"messages"`
ConversationID string `json:"conversation_id"`
UserID string `json:"-"`
}
type Message struct {
Role string `json:"role" binding:"required"`
Content string `json:"content" binding:"required"`
}
type ChatCompletionsResponse struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []Choice `json:"choices"`
Usage Usage `json:"usage"`
}
type StreamResponse struct {
ChatCompletionID string `json:"chat_completion_id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []StreamChoice `json:"choices"`
}
type Choice struct {
Index int `json:"index"`
Message Message `json:"message"`
FinishReason string `json:"finish_reason"`
}
type StreamChoice struct {
Index int `json:"index"`
Delta Delta `json:"delta"`
FinishReason *string `json:"finish_reason"`
}
type Delta struct {
Role string `json:"role,omitempty"`
Content string `json:"content,omitempty"`
ReasoningContent string `json:"reasoning_content,omitempty"`
}
type Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
}
type ConversationContext struct {
ConversationID string
UserID string
UserQuestion string
Messages []*ai_conversation.ConversationMessage
IsNewConversation bool
Model string
}
func (c *ConversationContext) GetOpenAIMessages() []openai.ChatCompletionMessage {
messages := make([]openai.ChatCompletionMessage, len(c.Messages))
for i, msg := range c.Messages {
messages[i] = openai.ChatCompletionMessage{
Role: msg.Role,
Content: msg.Content,
}
}
return messages
}
// sendStreamData
func sendStreamData(w http.ResponseWriter, data StreamResponse) {
jsonData, err := json.Marshal(data)
if err != nil {
return
}
_, _ = fmt.Fprintf(w, "data: %s\n\n", string(jsonData))
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
}
func (c *AIController) ChatCompletions(ctx *gin.Context) {
if !c.ensureAIChatEnabled(ctx) {
return
}
aiConfig, err := c.siteInfoService.GetSiteAI(context.Background())
if err != nil {
log.Errorf("Failed to get AI config: %v", err)
handler.HandleResponse(ctx, errors.BadRequest("AI service configuration error"), nil)
return
}
if !aiConfig.Enabled {
handler.HandleResponse(ctx, errors.ServiceUnavailable("AI service is not enabled"), nil)
return
}
aiProvider := aiConfig.GetProvider()
req := &ChatCompletionsRequest{}
if handler.BindAndCheck(ctx, req) {
return
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
data, _ := json.Marshal(req)
log.Infof("ai chat request data: %s", string(data))
ctx.Header("Content-Type", "text/event-stream")
ctx.Header("Cache-Control", "no-cache")
ctx.Header("Connection", "keep-alive")
ctx.Header("Access-Control-Allow-Origin", "*")
ctx.Header("Access-Control-Allow-Headers", "Cache-Control")
ctx.Status(http.StatusOK)
w := ctx.Writer
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
chatcmplID := "chatcmpl-" + token.GenerateToken()
created := time.Now().Unix()
firstResponse := StreamResponse{
ChatCompletionID: chatcmplID,
Object: "chat.completion.chunk",
Created: time.Now().Unix(),
Model: aiProvider.Model,
Choices: []StreamChoice{{Index: 0, Delta: Delta{Role: "assistant"}, FinishReason: nil}},
}
sendStreamData(w, firstResponse)
conversationCtx := c.initializeConversationContext(ctx, aiProvider.Model, req)
if conversationCtx == nil {
log.Error("Failed to initialize conversation context")
c.sendErrorResponse(w, chatcmplID, aiProvider.Model, "Failed to initialize conversation context")
return
}
c.redirectRequestToAI(ctx, w, chatcmplID, conversationCtx)
finishReason := "stop"
endResponse := StreamResponse{
ChatCompletionID: chatcmplID,
Object: "chat.completion.chunk",
Created: created,
Model: aiProvider.Model,
Choices: []StreamChoice{{Index: 0, Delta: Delta{}, FinishReason: &finishReason}},
}
sendStreamData(w, endResponse)
_, _ = fmt.Fprintf(w, "data: [DONE]\n\n")
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
c.saveConversationRecord(ctx, chatcmplID, conversationCtx)
}
func (c *AIController) redirectRequestToAI(ctx *gin.Context, w http.ResponseWriter, id string, conversationCtx *ConversationContext) {
client := c.createOpenAIClient()
c.handleAIConversation(ctx, w, id, client, conversationCtx)
}
// createOpenAIClient
func (c *AIController) createOpenAIClient() *openai.Client {
config := openai.DefaultConfig("")
config.BaseURL = ""
aiConfig, err := c.siteInfoService.GetSiteAI(context.Background())
if err != nil {
log.Errorf("Failed to get AI config: %v", err)
return openai.NewClientWithConfig(config)
}
if !aiConfig.Enabled {
log.Warn("AI feature is disabled")
return openai.NewClientWithConfig(config)
}
aiProvider := aiConfig.GetProvider()
config = openai.DefaultConfig(aiProvider.APIKey)
config.BaseURL = aiProvider.APIHost
if !strings.HasSuffix(config.BaseURL, "/v1") {
config.BaseURL += "/v1"
}
return openai.NewClientWithConfig(config)
}
// getPromptByLanguage
func (c *AIController) getPromptByLanguage(language i18n.Language, question string) string {
aiConfig, err := c.siteInfoService.GetSiteAI(context.Background())
if err != nil {
log.Errorf("Failed to get AI config: %v", err)
return c.getDefaultPrompt(language, question)
}
var promptTemplate string
switch language {
case i18n.LanguageChinese:
promptTemplate = aiConfig.PromptConfig.ZhCN
case i18n.LanguageEnglish:
promptTemplate = aiConfig.PromptConfig.EnUS
default:
promptTemplate = aiConfig.PromptConfig.EnUS
}
if promptTemplate == "" {
return c.getDefaultPrompt(language, question)
}
return fmt.Sprintf(promptTemplate, question)
}
// getDefaultPrompt prompt
func (c *AIController) getDefaultPrompt(language i18n.Language, question string) string {
switch language {
case i18n.LanguageChinese:
return fmt.Sprintf(constant.DefaultAIPromptConfigZhCN, question)
case i18n.LanguageEnglish:
return fmt.Sprintf(constant.DefaultAIPromptConfigEnUS, question)
default:
return fmt.Sprintf(constant.DefaultAIPromptConfigEnUS, question)
}
}
// initializeConversationContext
func (c *AIController) initializeConversationContext(ctx *gin.Context, model string, req *ChatCompletionsRequest) *ConversationContext {
if len(req.ConversationID) == 0 {
req.ConversationID = token.GenerateToken()
}
conversationCtx := &ConversationContext{
UserID: req.UserID,
Messages: make([]*ai_conversation.ConversationMessage, 0),
ConversationID: req.ConversationID,
Model: model,
}
conversationDetail, exist, err := c.aiConversationService.GetConversationDetail(ctx, &schema.AIConversationDetailReq{
ConversationID: req.ConversationID,
UserID: req.UserID,
})
if err != nil {
log.Errorf("Failed to get conversation detail: %v", err)
return nil
}
if !exist {
conversationCtx.UserQuestion = req.Messages[0].Content
conversationCtx.Messages = c.buildInitialMessages(ctx, req)
conversationCtx.IsNewConversation = true
return conversationCtx
}
conversationCtx.IsNewConversation = false
for _, record := range conversationDetail.Records {
conversationCtx.Messages = append(conversationCtx.Messages, &ai_conversation.ConversationMessage{
ChatCompletionID: record.ChatCompletionID,
Role: record.Role,
Content: record.Content,
})
}
conversationCtx.Messages = append(conversationCtx.Messages, &ai_conversation.ConversationMessage{
Role: req.Messages[0].Role,
Content: req.Messages[0].Content,
})
return conversationCtx
}
// buildInitialMessages
func (c *AIController) buildInitialMessages(ctx *gin.Context, req *ChatCompletionsRequest) []*ai_conversation.ConversationMessage {
question := ""
if len(req.Messages) == 1 {
question = req.Messages[0].Content
} else {
messages := make([]*ai_conversation.ConversationMessage, len(req.Messages))
for i, msg := range req.Messages {
messages[i] = &ai_conversation.ConversationMessage{
Role: msg.Role,
Content: msg.Content,
}
}
return messages
}
currentLang := handler.GetLangByCtx(ctx)
prompt := c.getPromptByLanguage(currentLang, question)
return []*ai_conversation.ConversationMessage{{Role: openai.ChatMessageRoleUser, Content: prompt}}
}
// saveConversationRecord
func (c *AIController) saveConversationRecord(ctx context.Context, chatcmplID string, conversationCtx *ConversationContext) {
if conversationCtx == nil || len(conversationCtx.Messages) == 0 {
return
}
if conversationCtx.IsNewConversation {
topic := conversationCtx.UserQuestion
if topic == "" {
log.Warn("No user message found for new conversation")
return
}
err := c.aiConversationService.CreateConversation(ctx, conversationCtx.UserID, conversationCtx.ConversationID, topic)
if err != nil {
log.Errorf("Failed to create conversation: %v", err)
return
}
}
err := c.aiConversationService.SaveConversationRecords(ctx, conversationCtx.ConversationID, chatcmplID, conversationCtx.Messages)
if err != nil {
log.Errorf("Failed to save conversation records: %v", err)
}
}
func (c *AIController) handleAIConversation(ctx *gin.Context, w http.ResponseWriter, id string, client *openai.Client, conversationCtx *ConversationContext) {
maxRounds := 10
messages := conversationCtx.GetOpenAIMessages()
for round := range maxRounds {
log.Debugf("AI conversation round: %d", round+1)
aiReq := openai.ChatCompletionRequest{
Model: conversationCtx.Model,
Messages: messages,
Tools: c.getMCPTools(),
Stream: true,
}
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 != "" || reasoningContent != "" {
conversationCtx.Messages = append(conversationCtx.Messages, &ai_conversation.ConversationMessage{
Role: "assistant",
Content: aiResponse,
ReasoningContent: reasoningContent,
})
}
if finished {
return
}
if len(toolCalls) > 0 {
messages = c.executeToolCalls(ctx, w, id, conversationCtx.Model, toolCalls, messages, aiResponse, reasoningContent)
} else {
return
}
}
log.Warnf("AI conversation reached maximum rounds limit: %d", maxRounds)
}
// 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, 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, "", ""
}
defer func() {
_ = stream.Close()
}()
var currentToolCalls []openai.ToolCall
var accumulatedContent strings.Builder
var accumulatedReasoning strings.Builder
var accumulatedMessage openai.ChatCompletionMessage
toolCallsMap := make(map[int]*openai.ToolCall)
for {
response, err := stream.Recv()
if err != nil {
if err.Error() == "EOF" {
log.Info("Stream finished")
break
}
log.Errorf("Stream error: %v", err)
break
}
if len(response.Choices) == 0 {
continue
}
choice := response.Choices[0]
if len(choice.Delta.ToolCalls) > 0 {
for _, deltaToolCall := range choice.Delta.ToolCalls {
index := *deltaToolCall.Index
if _, exists := toolCallsMap[index]; !exists {
toolCallsMap[index] = &openai.ToolCall{
ID: deltaToolCall.ID,
Type: deltaToolCall.Type,
Function: openai.FunctionCall{
Name: deltaToolCall.Function.Name,
Arguments: deltaToolCall.Function.Arguments,
},
}
} else {
if deltaToolCall.Function.Arguments != "" {
toolCallsMap[index].Function.Arguments += deltaToolCall.Function.Arguments
}
if deltaToolCall.Function.Name != "" {
toolCallsMap[index].Function.Name = deltaToolCall.Function.Name
}
}
}
}
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)
contentResponse := StreamResponse{
ChatCompletionID: id,
Object: "chat.completion.chunk",
Created: time.Now().Unix(),
Model: model,
Choices: []StreamChoice{
{
Index: 0,
Delta: Delta{
Content: choice.Delta.Content,
},
FinishReason: nil,
},
},
}
sendStreamData(w, contentResponse)
}
if len(choice.FinishReason) > 0 {
if choice.FinishReason == "tool_calls" {
for _, toolCall := range toolCallsMap {
currentToolCalls = append(currentToolCalls, *toolCall)
}
return currentToolCalls, messages, false, accumulatedContent.String(), accumulatedReasoning.String()
} else {
aiResponseContent := accumulatedContent.String()
aiReasoningContent := accumulatedReasoning.String()
if aiResponseContent != "" || aiReasoningContent != "" {
accumulatedMessage = openai.ChatCompletionMessage{
Role: openai.ChatMessageRoleAssistant,
Content: aiResponseContent,
ReasoningContent: aiReasoningContent,
}
messages = append(messages, accumulatedMessage)
}
return nil, messages, true, aiResponseContent, aiReasoningContent
}
}
}
aiResponseContent := accumulatedContent.String()
aiReasoningContent := accumulatedReasoning.String()
if aiResponseContent != "" || aiReasoningContent != "" {
accumulatedMessage = openai.ChatCompletionMessage{
Role: openai.ChatMessageRoleAssistant,
Content: aiResponseContent,
ReasoningContent: aiReasoningContent,
}
messages = append(messages, accumulatedMessage)
}
if len(toolCallsMap) > 0 {
for _, toolCall := range toolCallsMap {
currentToolCalls = append(currentToolCalls, *toolCall)
}
return currentToolCalls, messages, false, aiResponseContent, aiReasoningContent
}
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, assistantContent, reasoningContent string) []openai.ChatCompletionMessage {
validToolCalls := make([]openai.ToolCall, 0)
for _, toolCall := range toolCalls {
if toolCall.ID == "" || toolCall.Function.Name == "" {
log.Errorf("Invalid tool call: missing required fields. ID: %s, Function: %v", toolCall.ID, toolCall.Function)
continue
}
if toolCall.Function.Arguments == "" {
toolCall.Function.Arguments = "{}"
}
validToolCalls = append(validToolCalls, toolCall)
log.Debugf("Valid tool call: ID=%s, Name=%s, Arguments=%s", toolCall.ID, toolCall.Function.Name, toolCall.Function.Arguments)
}
if len(validToolCalls) == 0 {
log.Warn("No valid tool calls found")
return messages
}
assistantMsg := openai.ChatCompletionMessage{
Role: openai.ChatMessageRoleAssistant,
Content: assistantContent,
ReasoningContent: reasoningContent,
ToolCalls: validToolCalls,
}
messages = append(messages, assistantMsg)
for _, toolCall := range validToolCalls {
if toolCall.Function.Name != "" {
var args map[string]any
if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &args); err != nil {
log.Errorf("Failed to parse tool arguments for %s: %v, arguments: %s", toolCall.Function.Name, err, toolCall.Function.Arguments)
errorResult := fmt.Sprintf("Error parsing tool arguments: %v", err)
toolMessage := openai.ChatCompletionMessage{
Role: openai.ChatMessageRoleTool,
Content: errorResult,
ToolCallID: toolCall.ID,
}
messages = append(messages, toolMessage)
continue
}
result, err := c.callMCPTool(ctx, toolCall.Function.Name, args)
if err != nil {
log.Errorf("Failed to call MCP tool %s: %v", toolCall.Function.Name, err)
result = fmt.Sprintf("Error calling tool %s: %v", toolCall.Function.Name, err)
}
toolMessage := openai.ChatCompletionMessage{
Role: openai.ChatMessageRoleTool,
Content: result,
ToolCallID: toolCall.ID,
}
messages = append(messages, toolMessage)
}
}
return messages
}
// sendErrorResponse send error response in stream
func (c *AIController) sendErrorResponse(w http.ResponseWriter, id, model, errorMsg string) {
errorResponse := StreamResponse{
ChatCompletionID: id,
Object: "chat.completion.chunk",
Created: time.Now().Unix(),
Model: model,
Choices: []StreamChoice{
{
Index: 0,
Delta: Delta{
Content: fmt.Sprintf("Error: %s", errorMsg),
},
FinishReason: nil,
},
},
}
sendStreamData(w, errorResponse)
}
// getMCPTools
func (c *AIController) getMCPTools() []openai.Tool {
openaiTools := make([]openai.Tool, 0)
for _, mcpTool := range mcp_tools.MCPToolsList {
openaiTool := c.convertMCPToolToOpenAI(mcpTool)
openaiTools = append(openaiTools, openaiTool)
}
return openaiTools
}
// convertMCPToolToOpenAI
func (c *AIController) convertMCPToolToOpenAI(mcpTool mcp.Tool) openai.Tool {
properties := make(map[string]any)
required := make([]string, 0)
maps.Copy(properties, mcpTool.InputSchema.Properties)
required = append(required, mcpTool.InputSchema.Required...)
parameters := map[string]any{
"type": "object",
"properties": properties,
}
if len(required) > 0 {
parameters["required"] = required
}
return openai.Tool{
Type: openai.ToolTypeFunction,
Function: &openai.FunctionDefinition{
Name: mcpTool.Name,
Description: mcpTool.Description,
Parameters: parameters,
},
}
}
// callMCPTool
func (c *AIController) callMCPTool(ctx context.Context, toolName string, arguments map[string]any) (string, error) {
request := mcp.CallToolRequest{
Request: mcp.Request{},
Params: struct {
Name string `json:"name"`
Arguments any `json:"arguments,omitempty"`
Meta *mcp.Meta `json:"_meta,omitempty"`
}{
Name: toolName,
Arguments: arguments,
},
}
var result *mcp.CallToolResult
var err error
log.Debugf("Calling MCP tool: %s with arguments: %v", toolName, arguments)
switch toolName {
case "get_questions":
result, err = c.mcpController.MCPQuestionsHandler()(ctx, request)
case "get_answers_by_question_id":
result, err = c.mcpController.MCPAnswersHandler()(ctx, request)
case "get_comments":
result, err = c.mcpController.MCPCommentsHandler()(ctx, request)
case "get_tags":
result, err = c.mcpController.MCPTagsHandler()(ctx, request)
case "get_tag_detail":
result, err = c.mcpController.MCPTagDetailsHandler()(ctx, request)
case "get_user":
result, err = c.mcpController.MCPUserDetailsHandler()(ctx, request)
case "semantic_search":
result, err = c.mcpController.MCPSemanticSearchHandler()(ctx, request)
default:
return "", fmt.Errorf("unknown tool: %s", toolName)
}
if err != nil {
return "", err
}
data, _ := json.Marshal(result)
log.Debugf("MCP tool %s called successfully, result: %v", toolName, string(data))
if result != nil && len(result.Content) > 0 {
if textContent, ok := result.Content[0].(mcp.TextContent); ok {
return textContent.Text, nil
}
}
return "No result found", nil
}
@@ -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.
*/
package controller
import (
"github.com/apache/answer/internal/base/handler"
"github.com/apache/answer/internal/base/middleware"
"github.com/apache/answer/internal/schema"
"github.com/apache/answer/internal/service/ai_conversation"
"github.com/apache/answer/internal/service/feature_toggle"
"github.com/gin-gonic/gin"
)
// AIConversationController ai conversation controller
type AIConversationController struct {
aiConversationService ai_conversation.AIConversationService
featureToggleSvc *feature_toggle.FeatureToggleService
}
// NewAIConversationController creates a new AI conversation controller
func NewAIConversationController(
aiConversationService ai_conversation.AIConversationService,
featureToggleSvc *feature_toggle.FeatureToggleService,
) *AIConversationController {
return &AIConversationController{
aiConversationService: aiConversationService,
featureToggleSvc: featureToggleSvc,
}
}
func (ctrl *AIConversationController) ensureEnabled(ctx *gin.Context) bool {
if ctrl.featureToggleSvc == nil {
return true
}
if err := ctrl.featureToggleSvc.EnsureEnabled(ctx, feature_toggle.FeatureAIChatbot); err != nil {
handler.HandleResponse(ctx, err, nil)
return false
}
return true
}
// GetConversationList gets conversation list
// @Summary get conversation list
// @Description get conversation list
// @Tags ai-conversation
// @Accept json
// @Produce json
// @Param page query int false "page"
// @Param page_size query int false "page size"
// @Success 200 {object} handler.RespBody{data=pager.PageModel{list=[]schema.AIConversationListItem}}
// @Router /answer/api/v1/ai/conversation/page [get]
func (ctrl *AIConversationController) GetConversationList(ctx *gin.Context) {
if !ctrl.ensureEnabled(ctx) {
return
}
req := &schema.AIConversationListReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
resp, err := ctrl.aiConversationService.GetConversationList(ctx, req)
handler.HandleResponse(ctx, err, resp)
}
// GetConversationDetail gets conversation detail
// @Summary get conversation detail
// @Description get conversation detail
// @Tags ai-conversation
// @Accept json
// @Produce json
// @Param conversation_id query string true "conversation id"
// @Success 200 {object} handler.RespBody{data=schema.AIConversationDetailResp}
// @Router /answer/api/v1/ai/conversation [get]
func (ctrl *AIConversationController) GetConversationDetail(ctx *gin.Context) {
if !ctrl.ensureEnabled(ctx) {
return
}
req := &schema.AIConversationDetailReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
resp, _, err := ctrl.aiConversationService.GetConversationDetail(ctx, req)
handler.HandleResponse(ctx, err, resp)
}
// VoteRecord vote record
// @Summary vote record
// @Description vote record
// @Tags ai-conversation
// @Accept json
// @Produce json
// @Param data body schema.AIConversationVoteReq true "vote request"
// @Success 200 {object} handler.RespBody
// @Router /answer/api/v1/ai/conversation/vote [post]
func (ctrl *AIConversationController) VoteRecord(ctx *gin.Context) {
if !ctrl.ensureEnabled(ctx) {
return
}
req := &schema.AIConversationVoteReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
err := ctrl.aiConversationService.VoteRecord(ctx, req)
handler.HandleResponse(ctx, err, nil)
}
+459
View File
@@ -0,0 +1,459 @@
/*
* 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 (
"fmt"
"net/http"
"github.com/apache/answer/internal/base/handler"
"github.com/apache/answer/internal/base/middleware"
"github.com/apache/answer/internal/base/reason"
"github.com/apache/answer/internal/base/translator"
"github.com/apache/answer/internal/base/validator"
"github.com/apache/answer/internal/entity"
"github.com/apache/answer/internal/schema"
"github.com/apache/answer/internal/service/action"
"github.com/apache/answer/internal/service/content"
"github.com/apache/answer/internal/service/permission"
"github.com/apache/answer/internal/service/rank"
"github.com/apache/answer/internal/service/siteinfo_common"
"github.com/apache/answer/pkg/uid"
"github.com/gin-gonic/gin"
"github.com/segmentfault/pacman/errors"
)
// AnswerController answer controller
type AnswerController struct {
answerService *content.AnswerService
rankService *rank.RankService
actionService *action.CaptchaService
siteInfoCommonService siteinfo_common.SiteInfoCommonService
rateLimitMiddleware *middleware.RateLimitMiddleware
}
// NewAnswerController new controller
func NewAnswerController(
answerService *content.AnswerService,
rankService *rank.RankService,
actionService *action.CaptchaService,
siteInfoCommonService siteinfo_common.SiteInfoCommonService,
rateLimitMiddleware *middleware.RateLimitMiddleware,
) *AnswerController {
return &AnswerController{
answerService: answerService,
rankService: rankService,
actionService: actionService,
siteInfoCommonService: siteInfoCommonService,
rateLimitMiddleware: rateLimitMiddleware,
}
}
// RemoveAnswer delete answer
// @Summary delete answer
// @Description delete answer
// @Tags Answer
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param data body schema.RemoveAnswerReq true "answer"
// @Success 200 {object} handler.RespBody
// @Router /answer/api/v1/answer [delete]
func (ac *AnswerController) RemoveAnswer(ctx *gin.Context) {
req := &schema.RemoveAnswerReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.ID = uid.DeShortID(req.ID)
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
isAdmin := middleware.GetUserIsAdminModerator(ctx)
if !isAdmin {
captchaPass := ac.actionService.ActionRecordVerifyCaptcha(ctx, entity.CaptchaActionDelete, req.UserID, req.CaptchaID, req.CaptchaCode)
if !captchaPass {
errFields := append([]*validator.FormErrorField{}, &validator.FormErrorField{
ErrorField: "captcha_code",
ErrorMsg: translator.Tr(handler.GetLangByCtx(ctx), reason.CaptchaVerificationFailed),
})
handler.HandleResponse(ctx, errors.BadRequest(reason.CaptchaVerificationFailed), errFields)
return
}
}
objectOwner := ac.rankService.CheckOperationObjectOwner(ctx, req.UserID, req.ID)
canList, err := ac.rankService.CheckOperationPermissions(ctx, req.UserID, []string{
permission.AnswerDelete,
})
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
req.CanDelete = canList[0] || objectOwner
if !req.CanDelete {
handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil)
return
}
err = ac.answerService.RemoveAnswer(ctx, req)
if !isAdmin {
ac.actionService.ActionRecordAdd(ctx, entity.CaptchaActionDelete, req.UserID)
}
handler.HandleResponse(ctx, err, nil)
}
// RecoverAnswer recover answer
// @Summary recover answer
// @Description recover the deleted answer
// @Tags Answer
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param data body schema.RecoverAnswerReq true "answer"
// @Success 200 {object} handler.RespBody
// @Router /answer/api/v1/answer/recover [post]
func (ac *AnswerController) RecoverAnswer(ctx *gin.Context) {
req := &schema.RecoverAnswerReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.AnswerID = uid.DeShortID(req.AnswerID)
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
canList, err := ac.rankService.CheckOperationPermissions(ctx, req.UserID, []string{
permission.AnswerUnDelete,
})
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
if !canList[0] {
handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil)
return
}
err = ac.answerService.RecoverAnswer(ctx, req)
handler.HandleResponse(ctx, err, nil)
}
// GetAnswerInfo get answer info
// @Summary Get Answer Detail
// @Description Get Answer Detail
// @Tags Answer
// @Accept json
// @Produce json
// @Param id query string true "id"
// @Success 200 {object} handler.RespBody{data=schema.GetAnswerInfoResp}
// @Router /answer/api/v1/answer/info [get]
func (ac *AnswerController) GetAnswerInfo(ctx *gin.Context) {
id := ctx.Query("id")
id = uid.DeShortID(id)
userID := middleware.GetLoginUserIDFromContext(ctx)
isAdminModerator := middleware.GetUserIsAdminModerator(ctx)
info, questionInfo, has, err := ac.answerService.Get(ctx, id, userID, isAdminModerator)
if err != nil {
handler.HandleResponse(ctx, err, gin.H{})
return
}
if !has {
handler.HandleResponse(ctx, fmt.Errorf(""), gin.H{})
return
}
handler.HandleResponse(ctx, err, &schema.GetAnswerInfoResp{
Info: info,
Question: questionInfo,
})
}
// AddAnswer add answer
// @Summary Add Answer
// @Description add answer
// @Tags Answer
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param data body schema.AnswerAddReq true "add answer request"
// @Success 200 {object} handler.RespBody{}
// @Router /answer/api/v1/answer [post]
func (ac *AnswerController) AddAnswer(ctx *gin.Context) {
req := &schema.AnswerAddReq{}
if handler.BindAndCheck(ctx, req) {
return
}
reject, rejectKey := ac.rateLimitMiddleware.DuplicateRequestRejection(ctx, req)
if reject {
return
}
defer func() {
// If status is not 200 means that the bad request has been returned, so the record should be cleared
if ctx.Writer.Status() != http.StatusOK {
ac.rateLimitMiddleware.DuplicateRequestClear(ctx, rejectKey)
}
}()
req.QuestionID = uid.DeShortID(req.QuestionID)
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
canList, err := ac.rankService.CheckOperationPermissions(ctx, req.UserID, []string{
permission.AnswerEdit,
permission.AnswerDelete,
permission.LinkUrlLimit,
})
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
linkUrlLimitUser := canList[2]
isAdmin := middleware.GetUserIsAdminModerator(ctx)
if !isAdmin || !linkUrlLimitUser {
captchaPass := ac.actionService.ActionRecordVerifyCaptcha(ctx, entity.CaptchaActionAnswer, req.UserID, req.CaptchaID, req.CaptchaCode)
if !captchaPass {
errFields := append([]*validator.FormErrorField{}, &validator.FormErrorField{
ErrorField: "captcha_code",
ErrorMsg: translator.Tr(handler.GetLangByCtx(ctx), reason.CaptchaVerificationFailed),
})
handler.HandleResponse(ctx, errors.BadRequest(reason.CaptchaVerificationFailed), errFields)
return
}
}
can, err := ac.rankService.CheckOperationPermission(ctx, req.UserID, permission.AnswerAdd, "")
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
if !can {
handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil)
return
}
write, err := ac.siteInfoCommonService.GetSiteQuestion(ctx)
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
if write.RestrictAnswer {
// check if there's already an answer by this user
ids, err := ac.answerService.GetCountByUserIDQuestionID(ctx, req.UserID, req.QuestionID)
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
if len(ids) >= 1 {
handler.HandleResponse(ctx, errors.Forbidden(reason.AnswerRestrictAnswer), nil)
return
}
}
req.UserAgent = ctx.GetHeader("User-Agent")
req.IP = ctx.ClientIP()
answerID, err := ac.answerService.Insert(ctx, req)
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
if !isAdmin || !linkUrlLimitUser {
ac.actionService.ActionRecordAdd(ctx, entity.CaptchaActionAnswer, req.UserID)
}
info, questionInfo, has, err := ac.answerService.Get(ctx, answerID, req.UserID, isAdmin)
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
if !has {
handler.HandleResponse(ctx, nil, nil)
return
}
objectOwner := ac.rankService.CheckOperationObjectOwner(ctx, req.UserID, info.ID)
req.CanEdit = canList[0] || objectOwner
req.CanDelete = canList[1] || objectOwner
info.MemberActions = permission.GetAnswerPermission(ctx, req.UserID, info.UserID,
0, req.CanEdit, req.CanDelete, false)
handler.HandleResponse(ctx, nil, gin.H{
"info": info,
"question": questionInfo,
})
}
// UpdateAnswer update answer
// @Summary Update Answer
// @Description Update Answer
// @Tags Answer
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param data body schema.AnswerUpdateReq true "AnswerUpdateReq"
// @Success 200 {object} handler.RespBody{}
// @Router /answer/api/v1/answer [put]
func (ac *AnswerController) UpdateAnswer(ctx *gin.Context) {
req := &schema.AnswerUpdateReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
canList, err := ac.rankService.CheckOperationPermissions(ctx, req.UserID, []string{
permission.AnswerEdit,
permission.AnswerEditWithoutReview,
permission.LinkUrlLimit,
})
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
linkUrlLimitUser := canList[2]
isAdmin := middleware.GetUserIsAdminModerator(ctx)
if !isAdmin || !linkUrlLimitUser {
captchaPass := ac.actionService.ActionRecordVerifyCaptcha(ctx, entity.CaptchaActionEdit, req.UserID, req.CaptchaID, req.CaptchaCode)
if !captchaPass {
errFields := append([]*validator.FormErrorField{}, &validator.FormErrorField{
ErrorField: "captcha_code",
ErrorMsg: translator.Tr(handler.GetLangByCtx(ctx), reason.CaptchaVerificationFailed),
})
handler.HandleResponse(ctx, errors.BadRequest(reason.CaptchaVerificationFailed), errFields)
return
}
}
objectOwner := ac.rankService.CheckOperationObjectOwner(ctx, req.UserID, req.ID)
req.CanEdit = canList[0] || objectOwner
req.NoNeedReview = canList[1] || objectOwner
if !req.CanEdit {
handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil)
return
}
_, err = ac.answerService.Update(ctx, req)
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
if !isAdmin || !linkUrlLimitUser {
ac.actionService.ActionRecordAdd(ctx, entity.CaptchaActionEdit, req.UserID)
}
_, _, _, err = ac.answerService.Get(ctx, req.ID, req.UserID, isAdmin)
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
handler.HandleResponse(ctx, nil, &schema.AnswerUpdateResp{WaitForReview: !req.NoNeedReview})
}
// AnswerList godoc
// @Summary AnswerList
// @Description AnswerList <br> <b>order</b> (default or updated)
// @Tags Answer
// @Accept json
// @Produce json
// @Param question_id query string true "question_id"
// @Param order query string true "order"
// @Param page query string true "page"
// @Param page_size query string true "page_size"
// @Success 200 {string} string ""
// @Router /answer/api/v1/answer/page [get]
func (ac *AnswerController) AnswerList(ctx *gin.Context) {
req := &schema.AnswerListReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
req.QuestionID = uid.DeShortID(req.QuestionID)
req.IsAdminModerator = middleware.GetUserIsAdminModerator(ctx)
canList, err := ac.rankService.CheckOperationPermissions(ctx, req.UserID, []string{
permission.AnswerEdit,
permission.AnswerDelete,
permission.AnswerUnDelete,
})
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
req.CanEdit = canList[0]
req.CanDelete = canList[1]
req.CanRecover = canList[2]
list, count, err := ac.answerService.SearchList(ctx, req)
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
handler.HandleResponse(ctx, nil, gin.H{
"list": list,
"count": count,
})
}
// AcceptAnswer accept answer
// @Summary Accept Answer
// @Description Accept Answer
// @Tags Answer
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param data body schema.AcceptAnswerReq true "AcceptAnswerReq"
// @Success 200 {object} handler.RespBody{}
// @Router /answer/api/v1/answer/acceptance [post]
func (ac *AnswerController) AcceptAnswer(ctx *gin.Context) {
req := &schema.AcceptAnswerReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
req.AnswerID = uid.DeShortID(req.AnswerID)
req.QuestionID = uid.DeShortID(req.QuestionID)
can, err := ac.rankService.CheckOperationPermission(ctx, req.UserID, permission.AnswerAccept, req.QuestionID)
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
if !can {
handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil)
return
}
err = ac.answerService.AcceptAnswer(ctx, req)
handler.HandleResponse(ctx, err, nil)
}
// AdminUpdateAnswerStatus update answer status
// @Summary update answer status
// @Description update answer status
// @Tags admin
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param data body schema.AdminUpdateAnswerStatusReq true "AdminUpdateAnswerStatusReq"
// @Success 200 {object} handler.RespBody
// @Router /answer/admin/api/answer/status [put]
func (ac *AnswerController) AdminUpdateAnswerStatus(ctx *gin.Context) {
req := &schema.AdminUpdateAnswerStatusReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.AnswerID = uid.DeShortID(req.AnswerID)
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
err := ac.answerService.AdminSetAnswerStatus(ctx, req)
handler.HandleResponse(ctx, err, nil)
}
+153
View File
@@ -0,0 +1,153 @@
/*
* 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 (
"github.com/apache/answer/internal/base/handler"
"github.com/apache/answer/internal/base/middleware"
"github.com/apache/answer/internal/base/pager"
"github.com/apache/answer/internal/schema"
"github.com/apache/answer/internal/service/badge"
"github.com/apache/answer/pkg/uid"
"github.com/gin-gonic/gin"
)
type BadgeController struct {
badgeService *badge.BadgeService
badgeAwardService *badge.BadgeAwardService
}
func NewBadgeController(
badgeService *badge.BadgeService,
badgeAwardService *badge.BadgeAwardService) *BadgeController {
return &BadgeController{
badgeService: badgeService,
badgeAwardService: badgeAwardService,
}
}
// GetBadgeList list all badges
// @Summary list all badges group by group
// @Description list all badges group by group
// @Tags api-badge
// @Accept json
// @Produce json
// @Success 200 {object} handler.RespBody{data=[]schema.GetBadgeListResp}
// @Router /answer/api/v1/badges [get]
func (b *BadgeController) GetBadgeList(ctx *gin.Context) {
userID := middleware.GetLoginUserIDFromContext(ctx)
resp, err := b.badgeService.ListByGroup(ctx, userID)
handler.HandleResponse(ctx, err, resp)
}
// GetBadgeInfo get badge info
// @Summary get badge info
// @Description get badge info
// @Tags api-badge
// @Accept json
// @Produce json
// @Param id query string true "id" default(string)
// @Success 200 {object} handler.RespBody{data=schema.GetBadgeInfoResp}
// @Router /answer/api/v1/badge [get]
func (b *BadgeController) GetBadgeInfo(ctx *gin.Context) {
id := ctx.Query("id")
id = uid.DeShortID(id)
userID := middleware.GetLoginUserIDFromContext(ctx)
resp, err := b.badgeService.GetBadgeInfo(ctx, id, userID)
handler.HandleResponse(ctx, err, resp)
}
// GetBadgeAwardList get badge award list
// @Summary get badge award list
// @Description get badge award list
// @Tags api-badge
// @Accept json
// @Produce json
// @Param page query int false "page"
// @Param page_size query int false "page size"
// @Param badge_id query string true "badge id"
// @Param username query string false "only list the award by username"
// @Success 200 {object} handler.RespBody{data=schema.GetBadgeInfoResp}
// @Router /answer/api/v1/badge/awards/page [get]
func (b *BadgeController) GetBadgeAwardList(ctx *gin.Context) {
req := &schema.GetBadgeAwardWithPageReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.BadgeID = uid.DeShortID(req.BadgeID)
resp, total, err := b.badgeAwardService.GetBadgeAwardList(ctx, req)
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
handler.HandleResponse(ctx, nil, pager.NewPageModel(total, resp))
}
// GetAllBadgeAwardListByUsername get user badge award list
// @Summary get user badge award list
// @Description get user badge award list
// @Tags api-badge
// @Accept json
// @Produce json
// @Param username query string true "user name"
// @Success 200 {object} handler.RespBody{data=[]schema.GetUserBadgeAwardListResp}
// @Router /answer/api/v1/badge/user/awards [get]
func (b *BadgeController) GetAllBadgeAwardListByUsername(ctx *gin.Context) {
req := &schema.GetUserBadgeAwardListReq{}
if handler.BindAndCheck(ctx, req) {
return
}
resp, total, err := b.badgeAwardService.GetUserBadgeAwardList(ctx, req)
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
handler.HandleResponse(ctx, nil, pager.NewPageModel(total, resp))
}
// GetRecentBadgeAwardListByUsername get user badge award list
// @Summary get user badge award list
// @Description get user badge award list
// @Tags api-badge
// @Accept json
// @Produce json
// @Param username query string true "user name"
// @Success 200 {object} handler.RespBody{data=[]schema.GetUserBadgeAwardListResp}
// @Router /answer/api/v1/badge/user/awards/recent [get]
func (b *BadgeController) GetRecentBadgeAwardListByUsername(ctx *gin.Context) {
req := &schema.GetUserBadgeAwardListReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.Limit = 10
resp, total, err := b.badgeAwardService.GetUserRecentBadgeAwardList(ctx, req)
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
handler.HandleResponse(ctx, nil, pager.NewPageModel(total, resp))
}
@@ -0,0 +1,62 @@
/*
* 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 (
"github.com/apache/answer/internal/base/handler"
"github.com/apache/answer/internal/base/middleware"
"github.com/apache/answer/internal/schema"
"github.com/apache/answer/internal/service/collection"
"github.com/apache/answer/pkg/uid"
"github.com/gin-gonic/gin"
)
// CollectionController collection controller
type CollectionController struct {
collectionService *collection.CollectionService
}
// NewCollectionController new controller
func NewCollectionController(collectionService *collection.CollectionService) *CollectionController {
return &CollectionController{collectionService: collectionService}
}
// CollectionSwitch add collection
// @Summary add collection
// @Description add collection
// @Tags Collection
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param data body schema.CollectionSwitchReq true "collection"
// @Success 200 {object} handler.RespBody{data=schema.CollectionSwitchResp}
// @Router /answer/api/v1/collection/switch [post]
func (cc *CollectionController) CollectionSwitch(ctx *gin.Context) {
req := &schema.CollectionSwitchReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.ObjectID = uid.DeShortID(req.ObjectID)
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
resp, err := cc.collectionService.CollectionSwitch(ctx, req)
handler.HandleResponse(ctx, err, resp)
}
+318
View File
@@ -0,0 +1,318 @@
/*
* 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 (
"net/http"
"github.com/apache/answer/internal/base/handler"
"github.com/apache/answer/internal/base/middleware"
"github.com/apache/answer/internal/base/reason"
"github.com/apache/answer/internal/base/translator"
"github.com/apache/answer/internal/base/validator"
"github.com/apache/answer/internal/entity"
"github.com/apache/answer/internal/schema"
"github.com/apache/answer/internal/service/action"
"github.com/apache/answer/internal/service/comment"
"github.com/apache/answer/internal/service/permission"
"github.com/apache/answer/internal/service/rank"
"github.com/apache/answer/pkg/uid"
"github.com/gin-gonic/gin"
"github.com/segmentfault/pacman/errors"
)
// CommentController comment controller
type CommentController struct {
commentService *comment.CommentService
rankService *rank.RankService
actionService *action.CaptchaService
rateLimitMiddleware *middleware.RateLimitMiddleware
}
// NewCommentController new controller
func NewCommentController(
commentService *comment.CommentService,
rankService *rank.RankService,
actionService *action.CaptchaService,
rateLimitMiddleware *middleware.RateLimitMiddleware,
) *CommentController {
return &CommentController{
commentService: commentService,
rankService: rankService,
actionService: actionService,
rateLimitMiddleware: rateLimitMiddleware,
}
}
// AddComment add comment
// @Summary add comment
// @Description add comment
// @Tags Comment
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param data body schema.AddCommentReq true "comment"
// @Success 200 {object} handler.RespBody{data=schema.GetCommentResp}
// @Router /answer/api/v1/comment [post]
func (cc *CommentController) AddComment(ctx *gin.Context) {
req := &schema.AddCommentReq{}
if handler.BindAndCheck(ctx, req) {
return
}
reject, rejectKey := cc.rateLimitMiddleware.DuplicateRequestRejection(ctx, req)
if reject {
return
}
defer func() {
// If status is not 200 means that the bad request has been returned, so the record should be cleared
if ctx.Writer.Status() != http.StatusOK {
cc.rateLimitMiddleware.DuplicateRequestClear(ctx, rejectKey)
}
}()
req.ObjectID = uid.DeShortID(req.ObjectID)
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
canList, err := cc.rankService.CheckOperationPermissions(ctx, req.UserID, []string{
permission.CommentAdd,
permission.CommentEdit,
permission.CommentDelete,
permission.LinkUrlLimit,
})
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
linkUrlLimitUser := canList[3]
isAdmin := middleware.GetUserIsAdminModerator(ctx)
if !isAdmin || !linkUrlLimitUser {
captchaPass := cc.actionService.ActionRecordVerifyCaptcha(ctx, entity.CaptchaActionComment, req.UserID, req.CaptchaID, req.CaptchaCode)
if !captchaPass {
errFields := append([]*validator.FormErrorField{}, &validator.FormErrorField{
ErrorField: "captcha_code",
ErrorMsg: translator.Tr(handler.GetLangByCtx(ctx), reason.CaptchaVerificationFailed),
})
handler.HandleResponse(ctx, errors.BadRequest(reason.CaptchaVerificationFailed), errFields)
return
}
}
req.CanAdd = canList[0]
req.CanEdit = canList[1]
req.CanDelete = canList[2]
if !req.CanAdd {
handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil)
return
}
req.UserAgent = ctx.GetHeader("User-Agent")
req.IP = ctx.ClientIP()
resp, err := cc.commentService.AddComment(ctx, req)
if !isAdmin || !linkUrlLimitUser {
cc.actionService.ActionRecordAdd(ctx, entity.CaptchaActionComment, req.UserID)
}
handler.HandleResponse(ctx, err, resp)
}
// RemoveComment remove comment
// @Summary remove comment
// @Description remove comment
// @Tags Comment
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param data body schema.RemoveCommentReq true "comment"
// @Success 200 {object} handler.RespBody
// @Router /answer/api/v1/comment [delete]
func (cc *CommentController) RemoveComment(ctx *gin.Context) {
req := &schema.RemoveCommentReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
isAdmin := middleware.GetUserIsAdminModerator(ctx)
if !isAdmin {
captchaPass := cc.actionService.ActionRecordVerifyCaptcha(ctx, entity.CaptchaActionDelete, req.UserID, req.CaptchaID, req.CaptchaCode)
if !captchaPass {
errFields := append([]*validator.FormErrorField{}, &validator.FormErrorField{
ErrorField: "captcha_code",
ErrorMsg: translator.Tr(handler.GetLangByCtx(ctx), reason.CaptchaVerificationFailed),
})
handler.HandleResponse(ctx, errors.BadRequest(reason.CaptchaVerificationFailed), errFields)
return
}
}
can, err := cc.rankService.CheckOperationPermission(ctx, req.UserID, permission.CommentDelete, req.CommentID)
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
if !can {
handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil)
return
}
err = cc.commentService.RemoveComment(ctx, req)
if !isAdmin {
cc.actionService.ActionRecordAdd(ctx, entity.CaptchaActionDelete, req.UserID)
}
handler.HandleResponse(ctx, err, nil)
}
// UpdateComment update comment
// @Summary update comment
// @Description update comment
// @Tags Comment
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param data body schema.UpdateCommentReq true "comment"
// @Success 200 {object} handler.RespBody
// @Router /answer/api/v1/comment [put]
func (cc *CommentController) UpdateComment(ctx *gin.Context) {
req := &schema.UpdateCommentReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
req.IsAdmin = middleware.GetIsAdminFromContext(ctx)
canList, err := cc.rankService.CheckOperationPermissions(ctx, req.UserID, []string{
permission.CommentEdit,
permission.LinkUrlLimit,
})
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
req.CanEdit = canList[0] || cc.rankService.CheckOperationObjectOwner(ctx, req.UserID, req.CommentID)
linkUrlLimitUser := canList[1]
if !req.CanEdit {
handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil)
return
}
if !req.IsAdmin || !linkUrlLimitUser {
captchaPass := cc.actionService.ActionRecordVerifyCaptcha(ctx, entity.CaptchaActionEdit, req.UserID, req.CaptchaID, req.CaptchaCode)
if !captchaPass {
errFields := append([]*validator.FormErrorField{}, &validator.FormErrorField{
ErrorField: "captcha_code",
ErrorMsg: translator.Tr(handler.GetLangByCtx(ctx), reason.CaptchaVerificationFailed),
})
handler.HandleResponse(ctx, errors.BadRequest(reason.CaptchaVerificationFailed), errFields)
return
}
}
resp, err := cc.commentService.UpdateComment(ctx, req)
if !req.IsAdmin || !linkUrlLimitUser {
cc.actionService.ActionRecordAdd(ctx, entity.CaptchaActionEdit, req.UserID)
}
handler.HandleResponse(ctx, err, resp)
}
// GetCommentWithPage get comment page
// @Summary get comment page
// @Description get comment page
// @Tags Comment
// @Produce json
// @Param page query int false "page"
// @Param page_size query int false "page size"
// @Param object_id query string true "object id"
// @Param query_cond query string false "query condition" Enums(vote)
// @Success 200 {object} handler.RespBody{data=pager.PageModel{list=[]schema.GetCommentResp}}
// @Router /answer/api/v1/comment/page [get]
func (cc *CommentController) GetCommentWithPage(ctx *gin.Context) {
req := &schema.GetCommentWithPageReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.ObjectID = uid.DeShortID(req.ObjectID)
req.CommentID = uid.DeShortID(req.CommentID)
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
req.IsAdminModerator = middleware.GetUserIsAdminModerator(ctx)
canList, err := cc.rankService.CheckOperationPermissions(ctx, req.UserID, []string{
permission.CommentEdit,
permission.CommentDelete,
})
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
req.CanEdit = canList[0]
req.CanDelete = canList[1]
resp, err := cc.commentService.GetCommentWithPage(ctx, req)
handler.HandleResponse(ctx, err, resp)
}
// GetCommentPersonalWithPage user personal comment list
// @Summary user personal comment list
// @Description user personal comment list
// @Tags Comment
// @Produce json
// @Param page query int false "page"
// @Param page_size query int false "page size"
// @Param username query string false "username"
// @Success 200 {object} handler.RespBody{data=pager.PageModel{list=[]schema.GetCommentPersonalWithPageResp}}
// @Router /answer/api/v1/personal/comment/page [get]
func (cc *CommentController) GetCommentPersonalWithPage(ctx *gin.Context) {
req := &schema.GetCommentPersonalWithPageReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
resp, err := cc.commentService.GetCommentPersonalWithPage(ctx, req)
handler.HandleResponse(ctx, err, resp)
}
// GetComment godoc
// @Summary get comment by id
// @Description get comment by id
// @Tags Comment
// @Produce json
// @Param id query string true "id"
// @Success 200 {object} handler.RespBody{data=pager.PageModel{list=[]schema.GetCommentResp}}
// @Router /answer/api/v1/comment [get]
func (cc *CommentController) GetComment(ctx *gin.Context) {
req := &schema.GetCommentReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
req.IsAdminModerator = middleware.GetUserIsAdminModerator(ctx)
canList, err := cc.rankService.CheckOperationPermissions(ctx, req.UserID, []string{
permission.CommentEdit,
permission.CommentDelete,
})
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
req.CanEdit = canList[0]
req.CanDelete = canList[1]
resp, err := cc.commentService.GetComment(ctx, req)
handler.HandleResponse(ctx, err, resp)
}
+332
View File
@@ -0,0 +1,332 @@
/*
* 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 (
"fmt"
"net/http"
"net/url"
"github.com/apache/answer/internal/base/handler"
"github.com/apache/answer/internal/base/middleware"
"github.com/apache/answer/internal/schema"
"github.com/apache/answer/internal/service/export"
"github.com/apache/answer/internal/service/siteinfo_common"
"github.com/apache/answer/internal/service/user_external_login"
"github.com/apache/answer/plugin"
"github.com/gin-gonic/gin"
"github.com/segmentfault/pacman/log"
)
const (
commonRouterPrefix = "/answer/api/v1"
ConnectorLoginRouterPrefix = "/connector/login/"
ConnectorRedirectRouterPrefix = "/connector/redirect/"
)
// ConnectorController comment controller
type ConnectorController struct {
siteInfoService siteinfo_common.SiteInfoCommonService
userExternalService *user_external_login.UserExternalLoginService
emailService *export.EmailService
}
// NewConnectorController new controller
func NewConnectorController(
siteInfoService siteinfo_common.SiteInfoCommonService,
emailService *export.EmailService,
userExternalService *user_external_login.UserExternalLoginService,
) *ConnectorController {
return &ConnectorController{
siteInfoService: siteInfoService,
userExternalService: userExternalService,
emailService: emailService,
}
}
// ConnectorLoginDispatcher dispatch connector login request to specific connector by slug name
// We can't register specific router for each connector when application start, because the plugin status will be changed by admin.
// If the plugin is disabled, the router should be unavailable.
func (cc *ConnectorController) ConnectorLoginDispatcher(ctx *gin.Context) {
slugName := ctx.Param("name")
var c plugin.Connector
_ = plugin.CallConnector(func(connector plugin.Connector) error {
if connector.ConnectorSlugName() == slugName {
c = connector
}
return nil
})
if c == nil {
log.Errorf("connector %s not found", slugName)
ctx.Redirect(http.StatusFound, "/50x")
return
}
cc.ConnectorLogin(c)(ctx)
}
func (cc *ConnectorController) ConnectorRedirectDispatcher(ctx *gin.Context) {
slugName := ctx.Param("name")
var c plugin.Connector
_ = plugin.CallConnector(func(connector plugin.Connector) error {
if connector.ConnectorSlugName() == slugName {
c = connector
}
return nil
})
if c == nil {
log.Errorf("connector %s not found", slugName)
ctx.Redirect(http.StatusFound, "/50x")
return
}
cc.ConnectorRedirect(c)(ctx)
}
func (cc *ConnectorController) ConnectorLogin(connector plugin.Connector) (fn func(ctx *gin.Context)) {
return func(ctx *gin.Context) {
general, err := cc.siteInfoService.GetSiteGeneral(ctx)
if err != nil {
log.Error(err)
ctx.Redirect(http.StatusFound, "/50x")
return
}
state := ctx.Query("state")
if len(state) > 0 {
stateInfo, err := cc.userExternalService.GetOAuthState(ctx, state)
if err != nil || stateInfo == nil || stateInfo.Provider != connector.ConnectorSlugName() {
log.Errorf("invalid connector oauth state for provider %s", connector.ConnectorSlugName())
ctx.Redirect(http.StatusFound, "/50x")
return
}
} else {
state, err = cc.userExternalService.GenerateOAuthState(ctx, connector.ConnectorSlugName(),
schema.ExternalLoginOAuthStateLoginIntent, "")
if err != nil {
log.Errorf("generate connector oauth state failed: %v", err)
ctx.Redirect(http.StatusFound, "/50x")
return
}
q := ctx.Request.URL.Query()
q.Set("state", state)
ctx.Request.URL.RawQuery = q.Encode()
}
receiverURL := fmt.Sprintf("%s%s%s%s", general.SiteUrl,
commonRouterPrefix, ConnectorRedirectRouterPrefix, connector.ConnectorSlugName())
redirectURL := connector.ConnectorSender(ctx, receiverURL)
if len(redirectURL) > 0 {
ctx.Redirect(http.StatusFound, redirectURL)
}
}
}
func (cc *ConnectorController) ConnectorRedirect(connector plugin.Connector) (fn func(ctx *gin.Context)) {
return func(ctx *gin.Context) {
siteGeneral, err := cc.siteInfoService.GetSiteGeneral(ctx)
if err != nil {
log.Errorf("get site info failed: %v", err)
ctx.Redirect(http.StatusFound, "/50x")
return
}
receiverURL := fmt.Sprintf("%s%s%s%s", siteGeneral.SiteUrl,
commonRouterPrefix, ConnectorRedirectRouterPrefix, connector.ConnectorSlugName())
userInfo, err := connector.ConnectorReceiver(ctx, receiverURL)
if err != nil {
log.Errorf("connector received failed, error info: %v, response data is: %s", err, userInfo.MetaInfo)
ctx.Redirect(http.StatusFound, "/50x")
return
}
log.Debugf("connector received: %+v", userInfo)
u := &schema.ExternalLoginUserInfoCache{
Provider: connector.ConnectorSlugName(),
ExternalID: userInfo.ExternalID,
DisplayName: userInfo.DisplayName,
Username: userInfo.Username,
Email: userInfo.Email,
Avatar: userInfo.Avatar,
MetaInfo: userInfo.MetaInfo,
}
stateInfo, err := cc.userExternalService.ConsumeOAuthState(ctx, ctx.Query("state"))
if err != nil {
log.Errorf("get connector oauth state failed: %v", err)
ctx.Redirect(http.StatusFound, "/50x")
return
}
if stateInfo != nil && stateInfo.Provider != connector.ConnectorSlugName() {
log.Errorf("connector oauth state provider mismatch: %s != %s",
stateInfo.Provider, connector.ConnectorSlugName())
ctx.Redirect(http.StatusFound, "/50x")
return
}
if stateInfo != nil && stateInfo.Intent == schema.ExternalLoginOAuthStateBindIntent {
if err = cc.userExternalService.BindExternalLoginToUser(ctx, stateInfo.UserID, u); err != nil {
log.Errorf("bind external login failed: %v", err)
ctx.Redirect(http.StatusFound, "/50x")
return
}
ctx.Redirect(http.StatusFound, fmt.Sprintf("%s/users/settings/account", siteGeneral.SiteUrl))
return
}
resp, err := cc.userExternalService.ExternalLogin(ctx, u)
if err != nil {
log.Errorf("external login failed: %v", err)
ctx.Redirect(http.StatusFound, "/50x")
return
}
if len(resp.ErrMsg) > 0 {
ctx.Redirect(http.StatusFound, fmt.Sprintf("/50x?title=%s&msg=%s", resp.ErrTitle, resp.ErrMsg))
return
}
if len(resp.AccessToken) > 0 {
ctx.Redirect(http.StatusFound, fmt.Sprintf("%s/users/auth-landing?access_token=%s",
siteGeneral.SiteUrl, resp.AccessToken))
} else {
ctx.Redirect(http.StatusFound, fmt.Sprintf("%s/users/confirm-email?binding_key=%s",
siteGeneral.SiteUrl, resp.BindingKey))
}
}
}
// ConnectorsInfo get all enabled connectors
// @Summary get all enabled connectors
// @Description get all enabled connectors
// @Tags PluginConnector
// @Security ApiKeyAuth
// @Produce json
// @Success 200 {object} handler.RespBody{data=[]schema.ConnectorInfoResp}
// @Router /answer/api/v1/connector/info [get]
func (cc *ConnectorController) ConnectorsInfo(ctx *gin.Context) {
general, err := cc.siteInfoService.GetSiteGeneral(ctx)
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
resp := make([]*schema.ConnectorInfoResp, 0)
_ = plugin.CallConnector(func(fn plugin.Connector) error {
connectorName := fn.ConnectorName()
resp = append(resp, &schema.ConnectorInfoResp{
Name: connectorName.Translate(ctx),
Icon: fn.ConnectorLogoSVG(),
Link: fmt.Sprintf("%s%s%s%s", general.SiteUrl,
commonRouterPrefix, ConnectorLoginRouterPrefix, fn.ConnectorSlugName()),
})
return nil
})
handler.HandleResponse(ctx, nil, resp)
}
// ExternalLoginBindingUserSendEmail external login binding user send email
// @Summary external login binding user send email
// @Description external login binding user send email
// @Tags PluginConnector
// @Accept json
// @Produce json
// @Param data body schema.ExternalLoginBindingUserSendEmailReq true "external login binding user send email"
// @Success 200 {object} handler.RespBody{data=schema.ExternalLoginBindingUserSendEmailResp}
// @Router /answer/api/v1/connector/binding/email [post]
func (cc *ConnectorController) ExternalLoginBindingUserSendEmail(ctx *gin.Context) {
req := &schema.ExternalLoginBindingUserSendEmailReq{}
if handler.BindAndCheck(ctx, req) {
return
}
resp, err := cc.userExternalService.ExternalLoginBindingUserSendEmail(ctx, req)
handler.HandleResponse(ctx, err, resp)
}
// ConnectorsUserInfo get all connectors info about user
// @Summary get all connectors info about user
// @Description get all connectors info about user
// @Tags PluginConnector
// @Security ApiKeyAuth
// @Produce json
// @Success 200 {object} handler.RespBody{data=[]schema.ConnectorUserInfoResp}
// @Router /answer/api/v1/connector/user/info [get]
func (cc *ConnectorController) ConnectorsUserInfo(ctx *gin.Context) {
general, err := cc.siteInfoService.GetSiteGeneral(ctx)
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
userID := middleware.GetLoginUserIDFromContext(ctx)
userInfoList, err := cc.userExternalService.GetExternalLoginUserInfoList(ctx, userID)
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
userExternalLoginMapping := make(map[string]string)
for _, userInfo := range userInfoList {
userExternalLoginMapping[userInfo.Provider] = userInfo.ExternalID
}
resp := make([]*schema.ConnectorUserInfoResp, 0)
err = plugin.CallConnector(func(fn plugin.Connector) error {
externalID := userExternalLoginMapping[fn.ConnectorSlugName()]
connectorName := fn.ConnectorName()
link := fmt.Sprintf("%s%s%s%s", general.SiteUrl,
commonRouterPrefix, ConnectorLoginRouterPrefix, fn.ConnectorSlugName())
if len(externalID) == 0 {
state, err := cc.userExternalService.GenerateOAuthState(ctx, fn.ConnectorSlugName(),
schema.ExternalLoginOAuthStateBindIntent, userID)
if err != nil {
return err
}
link = fmt.Sprintf("%s?state=%s", link, url.QueryEscape(state))
}
resp = append(resp, &schema.ConnectorUserInfoResp{
Name: connectorName.Translate(ctx),
Icon: fn.ConnectorLogoSVG(),
Link: link,
Binding: len(externalID) > 0,
ExternalID: externalID,
})
return nil
})
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
handler.HandleResponse(ctx, nil, resp)
}
// ExternalLoginUnbinding unbind external user login
// @Summary unbind external user login
// @Description unbind external user login
// @Tags PluginConnector
// @Security ApiKeyAuth
// @Accept json
// @Produce json
// @Param data body schema.ExternalLoginUnbindingReq true "ExternalLoginUnbindingReq"
// @Success 200 {object} handler.RespBody{}
// @Router /answer/api/v1/connector/user/unbinding [delete]
func (cc *ConnectorController) ExternalLoginUnbinding(ctx *gin.Context) {
req := &schema.ExternalLoginUnbindingReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
resp, err := cc.userExternalService.ExternalLoginUnbinding(ctx, req)
handler.HandleResponse(ctx, err, resp)
}
+60
View File
@@ -0,0 +1,60 @@
/*
* 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 "github.com/google/wire"
// ProviderSetController is controller providers.
var ProviderSetController = wire.NewSet(
NewLangController,
NewCommentController,
NewReportController,
NewVoteController,
NewTagController,
NewFollowController,
NewCollectionController,
NewUserController,
NewQuestionController,
NewAnswerController,
NewSearchController,
NewRevisionController,
NewRankController,
NewReasonController,
NewNotificationController,
NewSiteInfoController,
NewDashboardController,
NewUploadController,
NewActivityController,
NewTemplateController,
NewConnectorController,
NewUserCenterController,
NewPermissionController,
NewUserPluginController,
NewReviewController,
NewCaptchaController,
NewMetaController,
NewEmbedController,
NewBadgeController,
NewRenderController,
NewSidebarController,
NewMCPController,
NewAIController,
NewAIConversationController,
)
@@ -0,0 +1,55 @@
/*
* 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 (
"github.com/apache/answer/internal/base/handler"
"github.com/apache/answer/internal/service/dashboard"
"github.com/gin-gonic/gin"
)
type DashboardController struct {
dashboardService dashboard.DashboardService
}
// NewDashboardController new controller
func NewDashboardController(
dashboardService dashboard.DashboardService,
) *DashboardController {
return &DashboardController{
dashboardService: dashboardService,
}
}
// DashboardInfo godoc
// @Summary DashboardInfo
// @Description DashboardInfo
// @Tags admin
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Router /answer/admin/api/dashboard [get]
// @Success 200 {object} handler.RespBody
func (ac *DashboardController) DashboardInfo(ctx *gin.Context) {
info, err := ac.dashboardService.Statistical(ctx)
handler.HandleResponse(ctx, err, gin.H{
"info": info,
})
}
+52
View File
@@ -0,0 +1,52 @@
/*
* 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 (
"github.com/apache/answer/internal/base/handler"
"github.com/apache/answer/plugin"
"github.com/gin-gonic/gin"
)
type EmbedController struct {
}
func NewEmbedController() *EmbedController {
return &EmbedController{}
}
// GetEmbedConfig get embed plugin config
// @Summary get embed plugin config
// @Description get embed plugin config
// @Tags Plugin
// @Accept json
// @Produce json
// @Success 200 {object} handler.RespBody{data=[]plugin.EmbedConfig}
// @Router /answer/api/v1/embed/config [get]
func (c *EmbedController) GetEmbedConfig(ctx *gin.Context) {
resp := make([]*plugin.EmbedConfig, 0)
err := plugin.CallEmbed(func(embed plugin.Embed) (err error) {
resp, err = embed.GetEmbedConfigs(ctx)
return err
})
handler.HandleResponse(ctx, err, resp)
}
+90
View File
@@ -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 (
"github.com/apache/answer/internal/base/handler"
"github.com/apache/answer/internal/base/middleware"
"github.com/apache/answer/internal/schema"
"github.com/apache/answer/internal/service/follow"
"github.com/apache/answer/pkg/uid"
"github.com/gin-gonic/gin"
"github.com/jinzhu/copier"
)
// FollowController activity controller
type FollowController struct {
followService *follow.FollowService
}
// NewFollowController new controller
func NewFollowController(followService *follow.FollowService) *FollowController {
return &FollowController{followService: followService}
}
// Follow godoc
// @Summary follow object or cancel follow operation
// @Description follow object or cancel follow operation
// @Tags Activity
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param data body schema.FollowReq true "follow"
// @Success 200 {object} handler.RespBody{data=schema.FollowResp}
// @Router /answer/api/v1/follow [post]
func (fc *FollowController) Follow(ctx *gin.Context) {
req := &schema.FollowReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.ObjectID = uid.DeShortID(req.ObjectID)
dto := &schema.FollowDTO{}
_ = copier.Copy(dto, req)
dto.UserID = middleware.GetLoginUserIDFromContext(ctx)
resp, err := fc.followService.Follow(ctx, dto)
if err != nil {
handler.HandleResponse(ctx, err, schema.ErrTypeToast)
} else {
handler.HandleResponse(ctx, err, resp)
}
}
// UpdateFollowTags update user follow tags
// @Summary update user follow tags
// @Description update user follow tags
// @Tags Activity
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param data body schema.UpdateFollowTagsReq true "follow"
// @Success 200 {object} handler.RespBody{}
// @Router /answer/api/v1/follow/tags [put]
func (fc *FollowController) UpdateFollowTags(ctx *gin.Context) {
req := &schema.UpdateFollowTagsReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
err := fc.followService.UpdateFollowTags(ctx, req)
handler.HandleResponse(ctx, err, nil)
}
+91
View File
@@ -0,0 +1,91 @@
/*
* 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 (
"encoding/json"
"github.com/apache/answer/internal/base/handler"
"github.com/apache/answer/internal/base/translator"
"github.com/apache/answer/internal/service/siteinfo_common"
"github.com/gin-gonic/gin"
"github.com/segmentfault/pacman/i18n"
)
type LangController struct {
translator i18n.Translator
siteInfoService siteinfo_common.SiteInfoCommonService
}
// NewLangController new language controller.
func NewLangController(tr i18n.Translator, siteInfoService siteinfo_common.SiteInfoCommonService) *LangController {
return &LangController{translator: tr, siteInfoService: siteInfoService}
}
// GetLangMapping get language config mapping
// @Summary get language config mapping
// @Description get language config mapping
// @Tags Lang
// @Param Accept-Language header string true "Accept-Language"
// @Produce json
// @Success 200 {object} handler.RespBody{}
// @Router /answer/api/v1/language/config [get]
func (u *LangController) GetLangMapping(ctx *gin.Context) {
data, _ := u.translator.Dump(handler.GetLangByCtx(ctx))
var resp map[string]any
_ = json.Unmarshal(data, &resp)
handler.HandleResponse(ctx, nil, resp)
}
// GetAdminLangOptions Get language options
// @Summary Get language options
// @Description Get language options
// @Security ApiKeyAuth
// @Tags Lang
// @Produce json
// @Success 200 {object} handler.RespBody{}
// @Router /answer/admin/api/language/options [get]
func (u *LangController) GetAdminLangOptions(ctx *gin.Context) {
handler.HandleResponse(ctx, nil, translator.LanguageOptions)
}
// GetUserLangOptions Get language options
// @Summary Get language options
// @Description Get language options
// @Tags Lang
// @Produce json
// @Success 200 {object} handler.RespBody{}
// @Router /answer/api/v1/language/options [get]
func (u *LangController) GetUserLangOptions(ctx *gin.Context) {
siteInterfaceResp, err := u.siteInfoService.GetSiteInterface(ctx)
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
options := translator.LanguageOptions
if len(siteInterfaceResp.Language) > 0 {
defaultOption := []*translator.LangOption{
{Label: translator.DefaultLangOption, Value: translator.DefaultLangOption},
}
options = append(defaultOption, options...)
}
handler.HandleResponse(ctx, nil, options)
}
+490
View File
@@ -0,0 +1,490 @@
/*
* 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 (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/apache/answer/internal/base/pager"
"github.com/apache/answer/internal/entity"
"github.com/apache/answer/internal/schema"
answercommon "github.com/apache/answer/internal/service/answer_common"
"github.com/apache/answer/internal/service/comment"
"github.com/apache/answer/internal/service/content"
"github.com/apache/answer/internal/service/embedding"
"github.com/apache/answer/internal/service/feature_toggle"
questioncommon "github.com/apache/answer/internal/service/question_common"
"github.com/apache/answer/internal/service/siteinfo_common"
tagcommonser "github.com/apache/answer/internal/service/tag_common"
usercommon "github.com/apache/answer/internal/service/user_common"
"github.com/apache/answer/plugin"
"github.com/mark3labs/mcp-go/mcp"
"github.com/segmentfault/pacman/log"
)
type MCPController struct {
searchService *content.SearchService
siteInfoService siteinfo_common.SiteInfoCommonService
tagCommonService *tagcommonser.TagCommonService
questioncommon *questioncommon.QuestionCommon
commentRepo comment.CommentRepo
userCommon *usercommon.UserCommon
answerRepo answercommon.AnswerRepo
featureToggleSvc *feature_toggle.FeatureToggleService
embeddingService *embedding.EmbeddingService
}
// NewMCPController new site info controller.
func NewMCPController(
searchService *content.SearchService,
siteInfoService siteinfo_common.SiteInfoCommonService,
tagCommonService *tagcommonser.TagCommonService,
questioncommon *questioncommon.QuestionCommon,
commentRepo comment.CommentRepo,
userCommon *usercommon.UserCommon,
answerRepo answercommon.AnswerRepo,
featureToggleSvc *feature_toggle.FeatureToggleService,
embeddingService *embedding.EmbeddingService,
) *MCPController {
return &MCPController{
searchService: searchService,
siteInfoService: siteInfoService,
tagCommonService: tagCommonService,
questioncommon: questioncommon,
commentRepo: commentRepo,
userCommon: userCommon,
answerRepo: answerRepo,
featureToggleSvc: featureToggleSvc,
embeddingService: embeddingService,
}
}
func (c *MCPController) ensureMCPEnabled(ctx context.Context) error {
if c.featureToggleSvc == nil {
return nil
}
return c.featureToggleSvc.EnsureEnabled(ctx, feature_toggle.FeatureMCP)
}
func (c *MCPController) MCPQuestionsHandler() 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.NewMCPSearchCond(request)
siteGeneral, err := c.siteInfoService.GetSiteGeneral(ctx)
if err != nil {
log.Errorf("get site general info failed: %v", err)
return nil, err
}
searchResp, err := c.searchService.Search(ctx, &schema.SearchDTO{
Query: cond.ToQueryString() + " is:question",
Page: 1,
Size: 5,
Order: "newest",
})
if err != nil {
return nil, err
}
resp := make([]*schema.MCPSearchQuestionInfoResp, 0)
for _, question := range searchResp.SearchResults {
t := &schema.MCPSearchQuestionInfoResp{
QuestionID: question.Object.QuestionID,
Title: question.Object.Title,
Content: question.Object.Excerpt,
Link: fmt.Sprintf("%s/questions/%s", siteGeneral.SiteUrl, question.Object.QuestionID),
}
resp = append(resp, t)
}
data, _ := json.Marshal(resp)
return mcp.NewToolResultText(string(data)), nil
}
}
func (c *MCPController) MCPQuestionDetailHandler() 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.NewMCPSearchQuestionDetail(request)
siteGeneral, err := c.siteInfoService.GetSiteGeneral(ctx)
if err != nil {
log.Errorf("get site general info failed: %v", err)
return nil, err
}
question, err := c.questioncommon.Info(ctx, cond.QuestionID, "")
if err != nil {
log.Errorf("get question failed: %v", err)
return mcp.NewToolResultText("No question found."), nil
}
resp := &schema.MCPSearchQuestionInfoResp{
QuestionID: question.ID,
Title: question.Title,
Content: question.Content,
Link: fmt.Sprintf("%s/questions/%s", siteGeneral.SiteUrl, question.ID),
}
res, _ := json.Marshal(resp)
return mcp.NewToolResultText(string(res)), nil
}
}
func (c *MCPController) MCPAnswersHandler() 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.NewMCPSearchAnswerCond(request)
siteGeneral, err := c.siteInfoService.GetSiteGeneral(ctx)
if err != nil {
log.Errorf("get site general info failed: %v", err)
return nil, err
}
if len(cond.QuestionID) > 0 {
answerList, err := c.answerRepo.GetAnswerList(ctx, &entity.Answer{QuestionID: cond.QuestionID})
if err != nil {
log.Errorf("get answers failed: %v", err)
return nil, err
}
resp := make([]*schema.MCPSearchAnswerInfoResp, 0)
for _, answer := range answerList {
if answer.Status != entity.AnswerStatusAvailable {
continue
}
t := &schema.MCPSearchAnswerInfoResp{
QuestionID: answer.QuestionID,
AnswerID: answer.ID,
AnswerContent: answer.OriginalText,
Link: fmt.Sprintf("%s/questions/%s/answers/%s", siteGeneral.SiteUrl, answer.QuestionID, answer.ID),
}
resp = append(resp, t)
}
data, _ := json.Marshal(resp)
return mcp.NewToolResultText(string(data)), nil
}
answerList, err := c.answerRepo.GetAnswerList(ctx, &entity.Answer{QuestionID: cond.QuestionID})
if err != nil {
log.Errorf("get answers failed: %v", err)
return nil, err
}
resp := make([]*schema.MCPSearchAnswerInfoResp, 0)
for _, answer := range answerList {
if answer.Status != entity.AnswerStatusAvailable {
continue
}
t := &schema.MCPSearchAnswerInfoResp{
QuestionID: answer.QuestionID,
AnswerID: answer.ID,
AnswerContent: answer.OriginalText,
Link: fmt.Sprintf("%s/questions/%s/answers/%s", siteGeneral.SiteUrl, answer.QuestionID, answer.ID),
}
resp = append(resp, t)
}
data, _ := json.Marshal(resp)
return mcp.NewToolResultText(string(data)), nil
}
}
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)
siteGeneral, err := c.siteInfoService.GetSiteGeneral(ctx)
if err != nil {
log.Errorf("get site general info failed: %v", err)
return nil, err
}
dto := &comment.CommentQuery{
PageCond: pager.PageCond{Page: 1, PageSize: 5},
QueryCond: "newest",
ObjectID: cond.ObjectID,
}
commentList, total, err := c.commentRepo.GetCommentPage(ctx, dto)
if err != nil {
return nil, err
}
if total == 0 {
return mcp.NewToolResultText("No comments found."), nil
}
resp := make([]*schema.MCPSearchCommentInfoResp, 0)
for _, comment := range commentList {
t := &schema.MCPSearchCommentInfoResp{
CommentID: comment.ID,
Content: comment.OriginalText,
ObjectID: comment.ObjectID,
Link: fmt.Sprintf("%s/comments/%s", siteGeneral.SiteUrl, comment.ID),
}
resp = append(resp, t)
}
data, _ := json.Marshal(resp)
return mcp.NewToolResultText(string(data)), nil
}
}
func (c *MCPController) MCPTagsHandler() 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.NewMCPSearchTagCond(request)
siteGeneral, err := c.siteInfoService.GetSiteGeneral(ctx)
if err != nil {
log.Errorf("get site general info failed: %v", err)
return nil, err
}
tags, total, err := c.tagCommonService.GetTagPage(ctx, 1, 10, &entity.Tag{DisplayName: cond.TagName}, "newest")
if err != nil {
log.Errorf("get tags failed: %v", err)
return nil, err
}
if total == 0 {
res := strings.Builder{}
res.WriteString("No tags found.\n")
return mcp.NewToolResultText(res.String()), nil
}
resp := make([]*schema.MCPSearchTagResp, 0)
for _, tag := range tags {
t := &schema.MCPSearchTagResp{
TagName: tag.SlugName,
DisplayName: tag.DisplayName,
Description: tag.OriginalText,
Link: fmt.Sprintf("%s/tags/%s", siteGeneral.SiteUrl, tag.SlugName),
}
resp = append(resp, t)
}
data, _ := json.Marshal(resp)
return mcp.NewToolResultText(string(data)), nil
}
}
func (c *MCPController) MCPTagDetailsHandler() 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.NewMCPSearchTagCond(request)
siteGeneral, err := c.siteInfoService.GetSiteGeneral(ctx)
if err != nil {
log.Errorf("get site general info failed: %v", err)
return nil, err
}
tag, exist, err := c.tagCommonService.GetTagBySlugName(ctx, cond.TagName)
if err != nil {
log.Errorf("get tag failed: %v", err)
return nil, err
}
if !exist {
return mcp.NewToolResultText("Tag not found."), nil
}
resp := &schema.MCPSearchTagResp{
TagName: tag.SlugName,
DisplayName: tag.DisplayName,
Description: tag.OriginalText,
Link: fmt.Sprintf("%s/tags/%s", siteGeneral.SiteUrl, tag.SlugName),
}
res, _ := json.Marshal(resp)
return mcp.NewToolResultText(string(res)), nil
}
}
func (c *MCPController) MCPUserDetailsHandler() 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.NewMCPSearchUserCond(request)
siteGeneral, err := c.siteInfoService.GetSiteGeneral(ctx)
if err != nil {
log.Errorf("get site general info failed: %v", err)
return nil, err
}
user, exist, err := c.userCommon.GetUserBasicInfoByUserName(ctx, cond.Username)
if err != nil {
log.Errorf("get user failed: %v", err)
return nil, err
}
if !exist {
return mcp.NewToolResultText("User not found."), nil
}
resp := &schema.MCPSearchUserInfoResp{
Username: user.Username,
DisplayName: user.DisplayName,
Avatar: user.Avatar,
Link: fmt.Sprintf("%s/users/%s", siteGeneral.SiteUrl, user.Username),
}
res, _ := json.Marshal(resp)
return mcp.NewToolResultText(string(res)), nil
}
}
func (c *MCPController) MCPSemanticSearchHandler() 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.NewMCPSemanticSearchCond(request)
if len(cond.Query) == 0 {
return mcp.NewToolResultText("Query is required for semantic search."), nil
}
siteGeneral, err := c.siteInfoService.GetSiteGeneral(ctx)
if err != nil {
log.Errorf("get site general info failed: %v", err)
return nil, err
}
results, err := c.embeddingService.SearchSimilar(ctx, cond.Query, cond.TopK)
if err != nil {
log.Errorf("semantic search failed: %v", err)
return mcp.NewToolResultText("Semantic search is not available. Embedding may not be configured."), nil
}
if len(results) == 0 {
return mcp.NewToolResultText("No semantically similar content found."), nil
}
resp := make([]*schema.MCPSemanticSearchResp, 0, len(results))
for _, r := range results {
var meta plugin.VectorSearchMetadata
_ = json.Unmarshal([]byte(r.Metadata), &meta)
item := &schema.MCPSemanticSearchResp{
ObjectID: r.ObjectID,
ObjectType: r.ObjectType,
Score: r.Score,
}
// Compose link from metadata
if r.ObjectType == "answer" && meta.AnswerID != "" {
item.Link = fmt.Sprintf("%s/questions/%s/%s", siteGeneral.SiteUrl, meta.QuestionID, meta.AnswerID)
} else {
item.Link = fmt.Sprintf("%s/questions/%s", siteGeneral.SiteUrl, meta.QuestionID)
}
// Query content from DB using IDs stored in metadata
if r.ObjectType == "question" {
question, qErr := c.questioncommon.Info(ctx, meta.QuestionID, "")
if qErr != nil {
log.Warnf("get question %s for semantic search failed: %v", meta.QuestionID, qErr)
} else {
item.Title = question.Title
item.Content = question.Content
}
// Fetch answers by ID from metadata
for _, a := range meta.Answers {
answerEntity, exist, aErr := c.answerRepo.GetAnswer(ctx, a.AnswerID)
if aErr != nil || !exist {
continue
}
answerItem := &schema.MCPSemanticSearchAnswer{
AnswerID: a.AnswerID,
Content: answerEntity.OriginalText,
}
// Fetch comments on this answer from DB
for _, ac := range a.Comments {
cmt, cExist, cErr := c.commentRepo.GetComment(ctx, ac.CommentID)
if cErr == nil && cExist {
answerItem.Comments = append(answerItem.Comments, &schema.MCPSemanticSearchComment{
CommentID: ac.CommentID,
Content: cmt.OriginalText,
})
}
}
item.Answers = append(item.Answers, answerItem)
}
// Fetch question comments from DB
for _, qc := range meta.Comments {
cmt, cExist, cErr := c.commentRepo.GetComment(ctx, qc.CommentID)
if cErr == nil && cExist {
item.Comments = append(item.Comments, &schema.MCPSemanticSearchComment{
CommentID: qc.CommentID,
Content: cmt.OriginalText,
})
}
}
} else if r.ObjectType == "answer" {
// Fetch question title for context
question, qErr := c.questioncommon.Info(ctx, meta.QuestionID, "")
if qErr == nil {
item.Title = question.Title
}
// Fetch answer content from DB
if meta.AnswerID != "" {
answerEntity, exist, aErr := c.answerRepo.GetAnswer(ctx, meta.AnswerID)
if aErr == nil && exist {
item.Content = answerEntity.OriginalText
}
} else if len(meta.Answers) > 0 {
answerEntity, exist, aErr := c.answerRepo.GetAnswer(ctx, meta.Answers[0].AnswerID)
if aErr == nil && exist {
item.Content = answerEntity.OriginalText
}
}
// Fetch answer comments from DB
if len(meta.Answers) > 0 {
for _, ac := range meta.Answers[0].Comments {
cmt, cExist, cErr := c.commentRepo.GetComment(ctx, ac.CommentID)
if cErr == nil && cExist {
item.Comments = append(item.Comments, &schema.MCPSemanticSearchComment{
CommentID: ac.CommentID,
Content: cmt.OriginalText,
})
}
}
}
}
resp = append(resp, item)
}
data, _ := json.Marshal(resp)
return mcp.NewToolResultText(string(data)), nil
}
}
+84
View File
@@ -0,0 +1,84 @@
/*
* 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 (
"github.com/apache/answer/internal/base/handler"
"github.com/apache/answer/internal/base/middleware"
"github.com/apache/answer/internal/schema"
"github.com/apache/answer/internal/service/meta"
"github.com/apache/answer/pkg/uid"
"github.com/gin-gonic/gin"
)
type MetaController struct {
metaService *meta.MetaService
}
func NewMetaController(
metaService *meta.MetaService,
) *MetaController {
return &MetaController{
metaService: metaService,
}
}
// AddOrUpdateReaction add or update reaction
// @Summary add or update reaction
// @Description update reaction. if not exist, add one
// @Tags Meta
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param data body schema.UpdateReactionReq true "reaction"
// @Success 200 {object} handler.RespBody
// @Router /answer/api/v1/meta/reaction [put]
func (mc *MetaController) AddOrUpdateReaction(ctx *gin.Context) {
req := &schema.UpdateReactionReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.ObjectID = uid.DeShortID(req.ObjectID)
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
resp, err := mc.metaService.AddOrUpdateReaction(ctx, req)
handler.HandleResponse(ctx, err, resp)
}
// GetReaction get reaction
// @Summary get reaction
// @Description get reaction for an object
// @Tags Meta
// @Accept json
// @Produce json
// @Param object_id query string true "object_id"
// @Success 200 {object} handler.RespBody{data=schema.ReactionRespItem}
// @Router /answer/api/v1/meta/reaction [get]
func (mc *MetaController) GetReaction(ctx *gin.Context) {
req := &schema.GetReactionReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.ObjectID = uid.DeShortID(req.ObjectID)
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
resp, err := mc.metaService.GetReactionByObjectId(ctx, req)
handler.HandleResponse(ctx, err, resp)
}
@@ -0,0 +1,173 @@
/*
* 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 (
"github.com/apache/answer/internal/base/handler"
"github.com/apache/answer/internal/base/middleware"
"github.com/apache/answer/internal/schema"
"github.com/apache/answer/internal/service/notification"
"github.com/apache/answer/internal/service/permission"
"github.com/apache/answer/internal/service/rank"
"github.com/gin-gonic/gin"
)
// NotificationController notification controller
type NotificationController struct {
notificationService *notification.NotificationService
rankService *rank.RankService
}
// NewNotificationController new controller
func NewNotificationController(
notificationService *notification.NotificationService,
rankService *rank.RankService,
) *NotificationController {
return &NotificationController{
notificationService: notificationService,
rankService: rankService,
}
}
// GetRedDot
// @Summary GetRedDot
// @Description GetRedDot
// @Tags Notification
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Success 200 {object} handler.RespBody
// @Router /answer/api/v1/notification/status [get]
func (nc *NotificationController) GetRedDot(ctx *gin.Context) {
req := &schema.GetRedDot{}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
canList, err := nc.rankService.CheckOperationPermissions(ctx, req.UserID, []string{
permission.QuestionAudit,
permission.AnswerAudit,
permission.TagAudit,
})
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
req.CanReviewQuestion = canList[0]
req.CanReviewAnswer = canList[1]
req.CanReviewTag = canList[2]
req.IsAdmin = middleware.GetUserIsAdminModerator(ctx)
resp, err := nc.notificationService.GetRedDot(ctx, req)
handler.HandleResponse(ctx, err, resp)
}
// ClearRedDot
// @Summary DelRedDot
// @Description DelRedDot
// @Tags Notification
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param data body schema.NotificationClearRequest true "NotificationClearRequest"
// @Success 200 {object} handler.RespBody
// @Router /answer/api/v1/notification/status [put]
func (nc *NotificationController) ClearRedDot(ctx *gin.Context) {
req := &schema.NotificationClearRequest{}
if handler.BindAndCheck(ctx, req) {
return
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
canList, err := nc.rankService.CheckOperationPermissions(ctx, req.UserID, []string{
permission.QuestionAudit,
permission.AnswerAudit,
permission.TagAudit,
})
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
req.CanReviewQuestion = canList[0]
req.CanReviewAnswer = canList[1]
req.CanReviewTag = canList[2]
resp, err := nc.notificationService.ClearRedDot(ctx, req)
handler.HandleResponse(ctx, err, resp)
}
// ClearUnRead
// @Summary ClearUnRead
// @Description ClearUnRead
// @Tags Notification
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param data body schema.NotificationClearRequest true "NotificationClearRequest"
// @Success 200 {object} handler.RespBody
// @Router /answer/api/v1/notification/read/state/all [put]
func (nc *NotificationController) ClearUnRead(ctx *gin.Context) {
req := &schema.NotificationClearRequest{}
if handler.BindAndCheck(ctx, req) {
return
}
userID := middleware.GetLoginUserIDFromContext(ctx)
err := nc.notificationService.ClearUnRead(ctx, userID, req.NotificationType)
handler.HandleResponse(ctx, err, gin.H{})
}
// ClearIDUnRead
// @Summary ClearUnRead
// @Description ClearUnRead
// @Tags Notification
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param data body schema.NotificationClearIDRequest true "NotificationClearIDRequest"
// @Success 200 {object} handler.RespBody
// @Router /answer/api/v1/notification/read/state [put]
func (nc *NotificationController) ClearIDUnRead(ctx *gin.Context) {
req := &schema.NotificationClearIDRequest{}
if handler.BindAndCheck(ctx, req) {
return
}
userID := middleware.GetLoginUserIDFromContext(ctx)
err := nc.notificationService.ClearIDUnRead(ctx, userID, req.ID)
handler.HandleResponse(ctx, err, gin.H{})
}
// GetList get notification list
// @Summary get notification list
// @Description get notification list
// @Tags Notification
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param page query int false "page size"
// @Param page_size query int false "page size"
// @Param type query string true "type" Enums(inbox,achievement)
// @Param inbox_type query string true "inbox_type" Enums(all,posts,invites,votes)
// @Success 200 {object} handler.RespBody
// @Router /answer/api/v1/notification/page [get]
func (nc *NotificationController) GetList(ctx *gin.Context) {
req := &schema.NotificationSearch{}
if handler.BindAndCheck(ctx, req) {
return
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
resp, err := nc.notificationService.GetNotificationPage(ctx, req)
handler.HandleResponse(ctx, err, resp)
}
@@ -0,0 +1,70 @@
/*
* 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 (
"github.com/apache/answer/internal/base/handler"
"github.com/apache/answer/internal/base/middleware"
"github.com/apache/answer/internal/schema"
"github.com/apache/answer/internal/service/rank"
"github.com/gin-gonic/gin"
)
type PermissionController struct {
rankService *rank.RankService
}
// NewPermissionController new language controller.
func NewPermissionController(rankService *rank.RankService) *PermissionController {
return &PermissionController{rankService: rankService}
}
// GetPermission check user permission
// @Summary check user permission
// @Description check user permission
// @Tags Permission
// @Security ApiKeyAuth
// @Param Authorization header string true "access-token"
// @Produce json
// @Param action query string true "permission key" Enums(question.add, question.edit, question.edit_without_review, question.delete, question.close, question.reopen, question.vote_up, question.vote_down, question.pin, question.unpin, question.hide, question.show, answer.add, answer.edit, answer.edit_without_review, answer.delete, answer.accept, answer.vote_up, answer.vote_down, answer.invite_someone_to_answer, comment.add, comment.edit, comment.delete, comment.vote_up, comment.vote_down, report.add, tag.add, tag.edit, tag.edit_slug_name, tag.edit_without_review, tag.delete, tag.synonym, link.url_limit, vote.detail, answer.audit, question.audit, tag.audit, tag.use_reserved_tag)
// @Success 200 {object} handler.RespBody{data=map[string]bool}
// @Router /answer/api/v1/permission [get]
func (u *PermissionController) GetPermission(ctx *gin.Context) {
req := &schema.GetPermissionReq{}
if handler.BindAndCheck(ctx, req) {
return
}
userID := middleware.GetLoginUserIDFromContext(ctx)
ops, requireRanks, err := u.rankService.CheckOperationPermissionsForRanks(ctx, userID, req.Actions)
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
lang := handler.GetLangByCtx(ctx)
mapping := make(map[string]*schema.GetPermissionResp, len(ops))
for i, action := range req.Actions {
t := &schema.GetPermissionResp{HasPermission: ops[i]}
t.TrTip(lang, requireRanks[i])
mapping[action] = t
}
handler.HandleResponse(ctx, err, mapping)
}
@@ -0,0 +1,53 @@
/*
* 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 (
"encoding/json"
"github.com/apache/answer/internal/base/handler"
"github.com/apache/answer/plugin"
"github.com/gin-gonic/gin"
)
// CaptchaController comment controller
type CaptchaController struct {
}
// NewCaptchaController new controller
func NewCaptchaController() *CaptchaController {
return &CaptchaController{}
}
type GetCaptchaConfigResp struct {
SlugName string `json:"slug_name"`
Config map[string]any `json:"config"`
}
// GetCaptchaConfig get captcha config
func (uc *CaptchaController) GetCaptchaConfig(ctx *gin.Context) {
resp := &GetCaptchaConfigResp{}
_ = plugin.CallCaptcha(func(fn plugin.Captcha) error {
resp.SlugName = fn.Info().SlugName
_ = json.Unmarshal([]byte(fn.GetConfig()), &resp.Config)
return nil
})
handler.HandleResponse(ctx, nil, resp)
}
@@ -0,0 +1,48 @@
/*
* 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 (
"github.com/apache/answer/internal/base/handler"
"github.com/apache/answer/plugin"
"github.com/gin-gonic/gin"
)
// SidebarController is the controller for the sidebar plugin.
type SidebarController struct{}
// NewSidebarController creates a new instance of SidebarController.
func NewSidebarController() *SidebarController {
return &SidebarController{}
}
// GetSidebarConfig retrieves the sidebar configuration from the registered sidebar plugins.
func (uc *SidebarController) GetSidebarConfig(ctx *gin.Context) {
resp := &plugin.SidebarConfig{}
_ = plugin.CallSidebar(func(fn plugin.Sidebar) error {
cfg, err := fn.GetSidebarConfig()
if err != nil {
return err
}
resp = cfg
return nil
})
handler.HandleResponse(ctx, nil, resp)
}
@@ -0,0 +1,215 @@
/*
* 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 (
"fmt"
"net/http"
"github.com/apache/answer/internal/base/handler"
"github.com/apache/answer/internal/base/middleware"
"github.com/apache/answer/internal/schema"
"github.com/apache/answer/internal/service/siteinfo_common"
"github.com/apache/answer/internal/service/user_external_login"
"github.com/apache/answer/plugin"
"github.com/gin-gonic/gin"
"github.com/segmentfault/pacman/log"
)
const (
UserCenterLoginRouter = "/user-center/login/redirect"
UserCenterSignUpRedirectRouter = "/user-center/sign-up/redirect"
)
// UserCenterController comment controller
type UserCenterController struct {
userCenterLoginService *user_external_login.UserCenterLoginService
siteInfoService siteinfo_common.SiteInfoCommonService
}
// NewUserCenterController new controller
func NewUserCenterController(
userCenterLoginService *user_external_login.UserCenterLoginService,
siteInfoService siteinfo_common.SiteInfoCommonService,
) *UserCenterController {
return &UserCenterController{
userCenterLoginService: userCenterLoginService,
siteInfoService: siteInfoService,
}
}
// UserCenterAgent get user center agent info
func (uc *UserCenterController) UserCenterAgent(ctx *gin.Context) {
resp := &schema.UserCenterAgentResp{}
resp.Enabled = plugin.UserCenterEnabled()
if !resp.Enabled {
handler.HandleResponse(ctx, nil, resp)
return
}
siteGeneral, err := uc.siteInfoService.GetSiteGeneral(ctx)
if err != nil {
log.Errorf("get site info failed: %v", err)
ctx.Redirect(http.StatusFound, "/50x")
return
}
resp.AgentInfo = &schema.AgentInfo{}
resp.AgentInfo.LoginRedirectURL = fmt.Sprintf("%s%s%s", siteGeneral.SiteUrl,
commonRouterPrefix, UserCenterLoginRouter)
resp.AgentInfo.SignUpRedirectURL = fmt.Sprintf("%s%s%s", siteGeneral.SiteUrl,
commonRouterPrefix, UserCenterSignUpRedirectRouter)
_ = plugin.CallUserCenter(func(uc plugin.UserCenter) error {
info := uc.Description()
resp.AgentInfo.Name = info.Name
resp.AgentInfo.DisplayName = info.DisplayName.Translate(ctx)
resp.AgentInfo.Icon = info.Icon
resp.AgentInfo.Url = info.Url
resp.AgentInfo.ControlCenterItems = make([]*schema.ControlCenter, 0)
resp.AgentInfo.EnabledOriginalUserSystem = info.EnabledOriginalUserSystem
items := uc.ControlCenterItems()
for _, item := range items {
resp.AgentInfo.ControlCenterItems = append(resp.AgentInfo.ControlCenterItems, &schema.ControlCenter{
Name: item.Name,
Label: item.Label,
Url: item.Url,
})
}
return nil
})
handler.HandleResponse(ctx, nil, resp)
}
// UserCenterPersonalBranding get user center personal user info
func (uc *UserCenterController) UserCenterPersonalBranding(ctx *gin.Context) {
req := &schema.GetOtherUserInfoByUsernameReq{}
if handler.BindAndCheck(ctx, req) {
return
}
resp, err := uc.userCenterLoginService.UserCenterPersonalBranding(ctx, req.Username)
handler.HandleResponse(ctx, err, resp)
}
func (uc *UserCenterController) UserCenterLoginRedirect(ctx *gin.Context) {
var redirectURL string
_ = plugin.CallUserCenter(func(userCenter plugin.UserCenter) error {
info := userCenter.Description()
redirectURL = info.LoginRedirectURL
return nil
})
ctx.Redirect(http.StatusFound, redirectURL)
}
func (uc *UserCenterController) UserCenterSignUpRedirect(ctx *gin.Context) {
var redirectURL string
_ = plugin.CallUserCenter(func(userCenter plugin.UserCenter) error {
info := userCenter.Description()
redirectURL = info.LoginRedirectURL
return nil
})
ctx.Redirect(http.StatusFound, redirectURL)
}
func (uc *UserCenterController) UserCenterLoginCallback(ctx *gin.Context) {
siteGeneral, err := uc.siteInfoService.GetSiteGeneral(ctx)
if err != nil {
log.Errorf("get site info failed: %v", err)
ctx.Redirect(http.StatusFound, "/50x")
return
}
userCenter, ok := plugin.GetUserCenter()
if !ok {
ctx.Redirect(http.StatusFound, "/404")
return
}
userInfo, err := userCenter.LoginCallback(ctx)
if err != nil {
log.Error(err)
if !ctx.IsAborted() {
ctx.Redirect(http.StatusFound, "/50x")
}
return
}
resp, err := uc.userCenterLoginService.ExternalLogin(ctx, userCenter, userInfo)
if err != nil {
log.Errorf("external login failed: %v", err)
ctx.Redirect(http.StatusFound, "/50x")
return
}
if len(resp.ErrMsg) > 0 {
ctx.Redirect(http.StatusFound, fmt.Sprintf("/50x?title=%s&msg=%s", resp.ErrTitle, resp.ErrMsg))
return
}
userCenter.AfterLogin(userInfo.ExternalID, resp.AccessToken)
ctx.Redirect(http.StatusFound, fmt.Sprintf("%s/users/auth-landing?access_token=%s",
siteGeneral.SiteUrl, resp.AccessToken))
}
func (uc *UserCenterController) UserCenterSignUpCallback(ctx *gin.Context) {
siteGeneral, err := uc.siteInfoService.GetSiteGeneral(ctx)
if err != nil {
log.Errorf("get site info failed: %v", err)
ctx.Redirect(http.StatusFound, "/50x")
return
}
userCenter, ok := plugin.GetUserCenter()
if !ok {
ctx.Redirect(http.StatusFound, "/404")
return
}
userInfo, err := userCenter.SignUpCallback(ctx)
if err != nil {
log.Error(err)
ctx.Redirect(http.StatusFound, "/50x")
return
}
resp, err := uc.userCenterLoginService.ExternalLogin(ctx, userCenter, userInfo)
if err != nil {
log.Errorf("external login failed: %v", err)
ctx.Redirect(http.StatusFound, "/50x")
return
}
if len(resp.ErrMsg) > 0 {
ctx.Redirect(http.StatusFound, fmt.Sprintf("/50x?title=%s&msg=%s", resp.ErrTitle, resp.ErrMsg))
return
}
userCenter.AfterLogin(userInfo.ExternalID, resp.AccessToken)
ctx.Redirect(http.StatusFound, fmt.Sprintf("%s/users/auth-landing?access_token=%s",
siteGeneral.SiteUrl, resp.AccessToken))
}
// UserCenterUserSettings user center user settings
func (uc *UserCenterController) UserCenterUserSettings(ctx *gin.Context) {
userID := middleware.GetLoginUserIDFromContext(ctx)
resp, err := uc.userCenterLoginService.UserCenterUserSettings(ctx, userID)
handler.HandleResponse(ctx, err, resp)
}
// UserCenterAdminFunctionAgent user center admin function agent
func (uc *UserCenterController) UserCenterAdminFunctionAgent(ctx *gin.Context) {
resp, err := uc.userCenterLoginService.UserCenterAdminFunctionAgent(ctx)
handler.HandleResponse(ctx, err, resp)
}
+999
View File
@@ -0,0 +1,999 @@
/*
* 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 (
"net/http"
"github.com/apache/answer/internal/base/handler"
"github.com/apache/answer/internal/base/middleware"
"github.com/apache/answer/internal/base/pager"
"github.com/apache/answer/internal/base/reason"
"github.com/apache/answer/internal/base/translator"
"github.com/apache/answer/internal/base/validator"
"github.com/apache/answer/internal/entity"
"github.com/apache/answer/internal/schema"
"github.com/apache/answer/internal/service/action"
"github.com/apache/answer/internal/service/content"
"github.com/apache/answer/internal/service/permission"
"github.com/apache/answer/internal/service/rank"
"github.com/apache/answer/internal/service/siteinfo_common"
"github.com/apache/answer/pkg/uid"
"github.com/gin-gonic/gin"
"github.com/jinzhu/copier"
"github.com/segmentfault/pacman/errors"
)
// QuestionController question controller
type QuestionController struct {
questionService *content.QuestionService
answerService *content.AnswerService
rankService *rank.RankService
siteInfoService siteinfo_common.SiteInfoCommonService
actionService *action.CaptchaService
rateLimitMiddleware *middleware.RateLimitMiddleware
}
// NewQuestionController new controller
func NewQuestionController(
questionService *content.QuestionService,
answerService *content.AnswerService,
rankService *rank.RankService,
siteInfoService siteinfo_common.SiteInfoCommonService,
actionService *action.CaptchaService,
rateLimitMiddleware *middleware.RateLimitMiddleware,
) *QuestionController {
return &QuestionController{
questionService: questionService,
answerService: answerService,
rankService: rankService,
siteInfoService: siteInfoService,
actionService: actionService,
rateLimitMiddleware: rateLimitMiddleware,
}
}
// RemoveQuestion delete question
// @Summary delete question
// @Description delete question
// @Tags Question
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param data body schema.RemoveQuestionReq true "question"
// @Success 200 {object} handler.RespBody
// @Router /answer/api/v1/question [delete]
func (qc *QuestionController) RemoveQuestion(ctx *gin.Context) {
req := &schema.RemoveQuestionReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.ID = uid.DeShortID(req.ID)
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
req.IsAdmin = middleware.GetIsAdminFromContext(ctx)
isAdmin := middleware.GetUserIsAdminModerator(ctx)
if !isAdmin {
captchaPass := qc.actionService.ActionRecordVerifyCaptcha(ctx, entity.CaptchaActionDelete, req.UserID, req.CaptchaID, req.CaptchaCode)
if !captchaPass {
errFields := append([]*validator.FormErrorField{}, &validator.FormErrorField{
ErrorField: "captcha_code",
ErrorMsg: translator.Tr(handler.GetLangByCtx(ctx), reason.CaptchaVerificationFailed),
})
handler.HandleResponse(ctx, errors.BadRequest(reason.CaptchaVerificationFailed), errFields)
return
}
}
can, err := qc.rankService.CheckOperationPermission(ctx, req.UserID, permission.QuestionDelete, req.ID)
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
if !can {
handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil)
return
}
err = qc.questionService.RemoveQuestion(ctx, req)
if !isAdmin {
qc.actionService.ActionRecordAdd(ctx, entity.CaptchaActionDelete, req.UserID)
}
handler.HandleResponse(ctx, err, nil)
}
// OperationQuestion Operation question
// @Summary Operation question
// @Description Operation question \n operation [pin unpin hide show]
// @Tags Question
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param data body schema.OperationQuestionReq true "question"
// @Success 200 {object} handler.RespBody
// @Router /answer/api/v1/question/operation [put]
func (qc *QuestionController) OperationQuestion(ctx *gin.Context) {
req := &schema.OperationQuestionReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.ID = uid.DeShortID(req.ID)
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
canList, err := qc.rankService.CheckOperationPermissions(ctx, req.UserID, []string{
permission.QuestionPin,
permission.QuestionUnPin,
permission.QuestionHide,
permission.QuestionShow,
})
if err != nil {
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 {
handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil)
return
}
err = qc.questionService.OperationQuestion(ctx, req)
handler.HandleResponse(ctx, err, nil)
}
// CloseQuestion Close question
// @Summary Close question
// @Description Close question
// @Tags Question
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param data body schema.CloseQuestionReq true "question"
// @Success 200 {object} handler.RespBody
// @Router /answer/api/v1/question/status [put]
func (qc *QuestionController) CloseQuestion(ctx *gin.Context) {
req := &schema.CloseQuestionReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.ID = uid.DeShortID(req.ID)
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
can, err := qc.rankService.CheckOperationPermission(ctx, req.UserID, permission.QuestionClose, "")
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
if !can {
handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil)
return
}
err = qc.questionService.CloseQuestion(ctx, req)
handler.HandleResponse(ctx, err, nil)
}
// ReopenQuestion reopen question
// @Summary reopen question
// @Description reopen question
// @Tags Question
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param data body schema.ReopenQuestionReq true "question"
// @Success 200 {object} handler.RespBody
// @Router /answer/api/v1/question/reopen [put]
func (qc *QuestionController) ReopenQuestion(ctx *gin.Context) {
req := &schema.ReopenQuestionReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.QuestionID = uid.DeShortID(req.QuestionID)
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
can, err := qc.rankService.CheckOperationPermission(ctx, req.UserID, permission.QuestionReopen, "")
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
if !can {
handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil)
return
}
err = qc.questionService.ReopenQuestion(ctx, req)
handler.HandleResponse(ctx, err, nil)
}
// GetQuestion get question details
// @Summary get question details
// @Description get question details
// @Tags Question
// @Accept json
// @Produce json
// @Param id query string true "Question TagID" default(1)
// @Success 200 {string} string ""
// @Router /answer/api/v1/question/info [get]
func (qc *QuestionController) GetQuestion(ctx *gin.Context) {
id := ctx.Query("id")
id = uid.DeShortID(id)
userID := middleware.GetLoginUserIDFromContext(ctx)
req := schema.QuestionPermission{}
req.IsAdminModerator = middleware.GetUserIsAdminModerator(ctx)
canList, err := qc.rankService.CheckOperationPermissions(ctx, userID, []string{
permission.QuestionEdit,
permission.QuestionDelete,
permission.QuestionClose,
permission.QuestionReopen,
permission.QuestionPin,
permission.QuestionUnPin,
permission.QuestionHide,
permission.QuestionShow,
permission.AnswerInviteSomeoneToAnswer,
permission.QuestionUnDelete,
})
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
objectOwner := qc.rankService.CheckOperationObjectOwner(ctx, userID, id)
req.CanEdit = canList[0] || objectOwner
req.CanDelete = canList[1]
req.CanClose = canList[2]
req.CanReopen = canList[3]
req.CanPin = canList[4]
req.CanUnPin = canList[5]
req.CanHide = canList[6]
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)
}
// GetQuestionInviteUserInfo get question invite user info
// @Summary get question invite user info
// @Description get question invite user info
// @Tags Question
// @Accept json
// @Produce json
// @Param id query string true "Question ID" default(1)
// @Success 200 {string} string ""
// @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)
handler.HandleResponse(ctx, err, resp)
}
// SimilarQuestion godoc
// @Summary Search Similar Question
// @Description Search Similar Question
// @Tags Question
// @Accept json
// @Produce json
// @Param question_id query string true "question_id" default()
// @Success 200 {string} string ""
// @Router /answer/api/v1/question/similar/tag [get]
func (qc *QuestionController) SimilarQuestion(ctx *gin.Context) {
questionID := ctx.Query("question_id")
questionID = uid.DeShortID(questionID)
userID := middleware.GetLoginUserIDFromContext(ctx)
list, count, err := qc.questionService.SimilarQuestion(ctx, questionID, userID)
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
handler.HandleResponse(ctx, nil, gin.H{
"list": list,
"count": count,
})
}
// QuestionPage get questions by page
// @Summary get questions by page
// @Description get questions by page
// @Tags Question
// @Accept json
// @Produce json
// @Param data body schema.QuestionPageReq true "QuestionPageReq"
// @Success 200 {object} handler.RespBody{data=pager.PageModel{list=[]schema.QuestionPageResp}}
// @Router /answer/api/v1/question/page [get]
func (qc *QuestionController) QuestionPage(ctx *gin.Context) {
req := &schema.QuestionPageReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.LoginUserID = middleware.GetLoginUserIDFromContext(ctx)
questions, total, err := qc.questionService.GetQuestionPage(ctx, req)
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
if pager.ValPageOutOfRange(total, req.Page, req.PageSize) {
handler.HandleResponse(ctx, errors.NotFound(reason.RequestFormatError), nil)
return
}
handler.HandleResponse(ctx, nil, pager.NewPageModel(total, questions))
}
// QuestionRecommendPage get recommend questions by page
// @Summary get recommend questions by page
// @Description get recommend questions by page
// @Tags Question
// @Accept json
// @Produce json
// @Param data body schema.QuestionPageReq true "QuestionPageReq"
// @Success 200 {object} handler.RespBody{data=pager.PageModel{list=[]schema.QuestionPageResp}}
// @Router /answer/api/v1/question/recommend/page [get]
func (qc *QuestionController) QuestionRecommendPage(ctx *gin.Context) {
req := &schema.QuestionPageReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.LoginUserID = middleware.GetLoginUserIDFromContext(ctx)
if req.LoginUserID == "" {
handler.HandleResponse(ctx, errors.Unauthorized(reason.UnauthorizedError), nil)
return
}
questions, total, err := qc.questionService.GetRecommendQuestionPage(ctx, req)
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
handler.HandleResponse(ctx, nil, pager.NewPageModel(total, questions))
}
// AddQuestion add question
// @Summary add question
// @Description add question
// @Tags Question
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param data body schema.QuestionAdd true "question"
// @Success 200 {object} handler.RespBody
// @Router /answer/api/v1/question [post]
func (qc *QuestionController) AddQuestion(ctx *gin.Context) {
req := &schema.QuestionAdd{}
errFields := handler.BindAndCheckReturnErr(ctx, req)
if ctx.IsAborted() {
return
}
reject, rejectKey := qc.rateLimitMiddleware.DuplicateRequestRejection(ctx, req)
if reject {
return
}
defer func() {
// If status is not 200 means that the bad request has been returned, so the record should be cleared
if ctx.Writer.Status() != http.StatusOK {
qc.rateLimitMiddleware.DuplicateRequestClear(ctx, rejectKey)
}
}()
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
canList, requireRanks, err := qc.rankService.CheckOperationPermissionsForRanks(ctx, req.UserID, []string{
permission.QuestionAdd,
permission.QuestionEdit,
permission.QuestionDelete,
permission.QuestionClose,
permission.QuestionReopen,
permission.TagUseReservedTag,
permission.TagAdd,
permission.LinkUrlLimit,
})
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
linkUrlLimitUser := canList[7]
isAdmin := middleware.GetUserIsAdminModerator(ctx)
if !isAdmin || !linkUrlLimitUser {
captchaPass := qc.actionService.ActionRecordVerifyCaptcha(ctx, entity.CaptchaActionQuestion, req.UserID, req.CaptchaID, req.CaptchaCode)
if !captchaPass {
errFields := append([]*validator.FormErrorField{}, &validator.FormErrorField{
ErrorField: "captcha_code",
ErrorMsg: translator.Tr(handler.GetLangByCtx(ctx), reason.CaptchaVerificationFailed),
})
handler.HandleResponse(ctx, errors.BadRequest(reason.CaptchaVerificationFailed), errFields)
return
}
}
req.CanAdd = canList[0]
req.CanEdit = canList[1]
req.CanDelete = canList[2]
req.CanClose = canList[3]
req.CanReopen = canList[4]
req.CanUseReservedTag = canList[5]
req.CanAddTag = canList[6]
if !req.CanAdd {
handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil)
return
}
// can add tag
hasNewTag, err := qc.questionService.HasNewTag(ctx, req.Tags)
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
if !req.CanAddTag && hasNewTag {
lang := handler.GetLangByCtx(ctx)
msg := translator.TrWithData(lang, reason.NoEnoughRankToOperate, &schema.PermissionTrTplData{Rank: requireRanks[6]})
handler.HandleResponse(ctx, errors.Forbidden(reason.NoEnoughRankToOperate).WithMsg(msg), nil)
return
}
errList, err := qc.questionService.CheckAddQuestion(ctx, req)
if err != nil {
errlist, ok := errList.([]*validator.FormErrorField)
if ok {
errFields = append(errFields, errlist...)
}
}
if len(errFields) > 0 {
handler.HandleResponse(ctx, errors.BadRequest(reason.RequestFormatError), errFields)
return
}
req.UserAgent = ctx.GetHeader("User-Agent")
req.IP = ctx.ClientIP()
resp, err := qc.questionService.AddQuestion(ctx, req)
if err != nil {
errlist, ok := resp.([]*validator.FormErrorField)
if ok {
errFields = append(errFields, errlist...)
}
}
if len(errFields) > 0 {
handler.HandleResponse(ctx, errors.BadRequest(reason.RequestFormatError), errFields)
return
}
if !isAdmin || !linkUrlLimitUser {
qc.actionService.ActionRecordAdd(ctx, entity.CaptchaActionQuestion, req.UserID)
}
handler.HandleResponse(ctx, err, resp)
}
// AddQuestionByAnswer add question
// @Summary add question and answer
// @Description add question and answer
// @Tags Question
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param data body schema.QuestionAddByAnswer true "question"
// @Success 200 {object} handler.RespBody
// @Router /answer/api/v1/question/answer [post]
func (qc *QuestionController) AddQuestionByAnswer(ctx *gin.Context) {
req := &schema.QuestionAddByAnswer{}
errFields := handler.BindAndCheckReturnErr(ctx, req)
if ctx.IsAborted() {
return
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
canList, err := qc.rankService.CheckOperationPermissions(ctx, req.UserID, []string{
permission.QuestionAdd,
permission.QuestionEdit,
permission.QuestionDelete,
permission.QuestionClose,
permission.QuestionReopen,
permission.TagUseReservedTag,
permission.LinkUrlLimit,
})
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
linkUrlLimitUser := canList[6]
isAdmin := middleware.GetUserIsAdminModerator(ctx)
if !isAdmin || !linkUrlLimitUser {
captchaPass := qc.actionService.ActionRecordVerifyCaptcha(ctx, entity.CaptchaActionQuestion, req.UserID, req.CaptchaID, req.CaptchaCode)
if !captchaPass {
errFields := append([]*validator.FormErrorField{}, &validator.FormErrorField{
ErrorField: "captcha_code",
ErrorMsg: translator.Tr(handler.GetLangByCtx(ctx), reason.CaptchaVerificationFailed),
})
handler.HandleResponse(ctx, errors.BadRequest(reason.CaptchaVerificationFailed), errFields)
return
}
}
req.CanAdd = canList[0]
req.CanEdit = canList[1]
req.CanDelete = canList[2]
req.CanClose = canList[3]
req.CanReopen = canList[4]
req.CanUseReservedTag = canList[5]
if !req.CanAdd {
handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil)
return
}
questionReq := new(schema.QuestionAdd)
err = copier.Copy(questionReq, req)
if err != nil {
handler.HandleResponse(ctx, errors.Forbidden(reason.RequestFormatError), nil)
return
}
errList, err := qc.questionService.CheckAddQuestion(ctx, questionReq)
if err != nil {
errlist, ok := errList.([]*validator.FormErrorField)
if ok {
errFields = append(errFields, errlist...)
}
}
if len(errFields) > 0 {
handler.HandleResponse(ctx, errors.BadRequest(reason.RequestFormatError), errFields)
return
}
req.UserAgent = ctx.GetHeader("User-Agent")
req.IP = ctx.ClientIP()
resp, err := qc.questionService.AddQuestion(ctx, questionReq)
if err != nil {
errlist, ok := resp.([]*validator.FormErrorField)
if ok {
errFields = append(errFields, errlist...)
}
}
if !isAdmin || !linkUrlLimitUser {
qc.actionService.ActionRecordAdd(ctx, entity.CaptchaActionQuestion, req.UserID)
}
if len(errFields) > 0 {
handler.HandleResponse(ctx, errors.BadRequest(reason.RequestFormatError), errFields)
return
}
// add the question id to the answer
questionInfo, ok := resp.(*schema.QuestionInfoResp)
if ok {
answerReq := &schema.AnswerAddReq{}
answerReq.QuestionID = uid.DeShortID(questionInfo.ID)
answerReq.UserID = middleware.GetLoginUserIDFromContext(ctx)
answerReq.Content = req.AnswerContent
answerReq.HTML = req.AnswerHTML
answerID, err := qc.answerService.Insert(ctx, answerReq)
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
info, questionInfo, has, err := qc.answerService.Get(ctx, answerID, req.UserID, isAdmin)
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
if !has {
handler.HandleResponse(ctx, nil, nil)
return
}
handler.HandleResponse(ctx, err, gin.H{
"info": info,
"question": questionInfo,
})
return
}
handler.HandleResponse(ctx, err, resp)
}
// UpdateQuestion update question
// @Summary update question
// @Description update question
// @Tags Question
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param data body schema.QuestionUpdate true "question"
// @Success 200 {object} handler.RespBody
// @Router /answer/api/v1/question [put]
func (qc *QuestionController) UpdateQuestion(ctx *gin.Context) {
req := &schema.QuestionUpdate{}
errFields := handler.BindAndCheckReturnErr(ctx, req)
if ctx.IsAborted() {
return
}
req.ID = uid.DeShortID(req.ID)
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
canList, requireRanks, err := qc.rankService.CheckOperationPermissionsForRanks(ctx, req.UserID, []string{
permission.QuestionEdit,
permission.QuestionDelete,
permission.QuestionEditWithoutReview,
permission.TagUseReservedTag,
permission.TagAdd,
permission.LinkUrlLimit,
})
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
linkUrlLimitUser := canList[5]
isAdmin := middleware.GetUserIsAdminModerator(ctx)
if !isAdmin || !linkUrlLimitUser {
captchaPass := qc.actionService.ActionRecordVerifyCaptcha(ctx, entity.CaptchaActionEdit, req.UserID, req.CaptchaID, req.CaptchaCode)
if !captchaPass {
errFields := append([]*validator.FormErrorField{}, &validator.FormErrorField{
ErrorField: "captcha_code",
ErrorMsg: translator.Tr(handler.GetLangByCtx(ctx), reason.CaptchaVerificationFailed),
})
handler.HandleResponse(ctx, errors.BadRequest(reason.CaptchaVerificationFailed), errFields)
return
}
}
objectOwner := qc.rankService.CheckOperationObjectOwner(ctx, req.UserID, req.ID)
req.CanEdit = canList[0] || objectOwner
req.CanDelete = canList[1]
req.NoNeedReview = canList[2] || objectOwner
req.CanUseReservedTag = canList[3]
req.CanAddTag = canList[4]
if !req.CanEdit {
handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil)
return
}
errlist, err := qc.questionService.UpdateQuestionCheckTags(ctx, req)
if err != nil {
errFields = append(errFields, errlist...)
}
if len(errFields) > 0 {
handler.HandleResponse(ctx, errors.BadRequest(reason.RequestFormatError), errFields)
return
}
// can add tag
hasNewTag, err := qc.questionService.HasNewTag(ctx, req.Tags)
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
if !req.CanAddTag && hasNewTag {
lang := handler.GetLangByCtx(ctx)
msg := translator.TrWithData(lang, reason.NoEnoughRankToOperate, &schema.PermissionTrTplData{Rank: requireRanks[4]})
handler.HandleResponse(ctx, errors.Forbidden(reason.NoEnoughRankToOperate).WithMsg(msg), nil)
return
}
resp, err := qc.questionService.UpdateQuestion(ctx, req)
if err != nil {
handler.HandleResponse(ctx, err, resp)
return
}
respInfo, ok := resp.(*schema.QuestionInfoResp)
if !ok {
handler.HandleResponse(ctx, err, resp)
return
}
if !isAdmin || !linkUrlLimitUser {
qc.actionService.ActionRecordAdd(ctx, entity.CaptchaActionEdit, req.UserID)
}
handler.HandleResponse(ctx, nil, &schema.UpdateQuestionResp{UrlTitle: respInfo.UrlTitle, WaitForReview: !req.NoNeedReview})
}
// QuestionRecover recover deleted question
// @Summary recover deleted question
// @Description recover deleted question
// @Tags Question
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param data body schema.QuestionRecoverReq true "question"
// @Success 200 {object} handler.RespBody
// @Router /answer/api/v1/question/recover [post]
func (qc *QuestionController) QuestionRecover(ctx *gin.Context) {
req := &schema.QuestionRecoverReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.QuestionID = uid.DeShortID(req.QuestionID)
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
canList, err := qc.rankService.CheckOperationPermissions(ctx, req.UserID, []string{
permission.QuestionUnDelete,
})
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
if !canList[0] {
handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil)
return
}
err = qc.questionService.RecoverQuestion(ctx, req)
handler.HandleResponse(ctx, err, nil)
}
// UpdateQuestionInviteUser update question invite user
// @Summary update question invite user
// @Description update question invite user
// @Tags Question
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param data body schema.QuestionUpdateInviteUser true "question"
// @Success 200 {object} handler.RespBody
// @Router /answer/api/v1/question/invite [put]
func (qc *QuestionController) UpdateQuestionInviteUser(ctx *gin.Context) {
req := &schema.QuestionUpdateInviteUser{}
errFields := handler.BindAndCheckReturnErr(ctx, req)
if ctx.IsAborted() {
return
}
if len(errFields) > 0 {
handler.HandleResponse(ctx, errors.BadRequest(reason.RequestFormatError), errFields)
return
}
req.ID = uid.DeShortID(req.ID)
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
isAdmin := middleware.GetUserIsAdminModerator(ctx)
if !isAdmin {
captchaPass := qc.actionService.ActionRecordVerifyCaptcha(ctx, entity.CaptchaActionInvitationAnswer, req.UserID, req.CaptchaID, req.CaptchaCode)
if !captchaPass {
errFields := append([]*validator.FormErrorField{}, &validator.FormErrorField{
ErrorField: "captcha_code",
ErrorMsg: translator.Tr(handler.GetLangByCtx(ctx), reason.CaptchaVerificationFailed),
})
handler.HandleResponse(ctx, errors.BadRequest(reason.CaptchaVerificationFailed), errFields)
return
}
}
canList, err := qc.rankService.CheckOperationPermissions(ctx, req.UserID, []string{
permission.AnswerInviteSomeoneToAnswer,
})
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
req.CanInviteOtherToAnswer = canList[0]
if !req.CanInviteOtherToAnswer {
handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil)
return
}
err = qc.questionService.UpdateQuestionInviteUser(ctx, req)
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
if !isAdmin {
qc.actionService.ActionRecordAdd(ctx, entity.CaptchaActionInvitationAnswer, req.UserID)
}
handler.HandleResponse(ctx, nil, nil)
}
// GetSimilarQuestions fuzzy query similar questions based on title
// @Summary fuzzy query similar questions based on title
// @Description fuzzy query similar questions based on title
// @Tags Question
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param title query string true "title" default(string)
// @Success 200 {object} handler.RespBody
// @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)
handler.HandleResponse(ctx, err, resp)
}
// UserTop godoc
// @Summary UserTop
// @Description UserTop
// @Tags Question
// @Accept json
// @Produce json
// @Param username query string true "username" default(string)
// @Success 200 {object} handler.RespBody
// @Router /answer/api/v1/personal/qa/top [get]
func (qc *QuestionController) UserTop(ctx *gin.Context) {
userName := ctx.Query("username")
userID := middleware.GetLoginUserIDFromContext(ctx)
questionList, answerList, err := qc.questionService.SearchUserTopList(ctx, userName, userID)
handler.HandleResponse(ctx, err, gin.H{
"question": questionList,
"answer": answerList,
})
}
// PersonalQuestionPage list personal questions
// @Summary list personal questions
// @Description list personal questions
// @Tags Personal
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param username query string true "username" default(string)
// @Param order query string true "order" Enums(newest,score)
// @Param page query string true "page" default(0)
// @Param page_size query string true "page_size" default(20)
// @Success 200 {object} handler.RespBody
// @Router /personal/question/page [get]
func (qc *QuestionController) PersonalQuestionPage(ctx *gin.Context) {
req := &schema.PersonalQuestionPageReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.LoginUserID = middleware.GetLoginUserIDFromContext(ctx)
req.IsAdmin = middleware.GetUserIsAdminModerator(ctx)
resp, err := qc.questionService.PersonalQuestionPage(ctx, req)
handler.HandleResponse(ctx, err, resp)
}
// PersonalAnswerPage list personal answers
// @Summary list personal answers
// @Description list personal answers
// @Tags Personal
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param username query string true "username" default(string)
// @Param order query string true "order" Enums(newest,score)
// @Param page query string true "page" default(0)
// @Param page_size query string true "page_size" default(20)
// @Success 200 {object} handler.RespBody
// @Router /answer/api/v1/personal/answer/page [get]
func (qc *QuestionController) PersonalAnswerPage(ctx *gin.Context) {
req := &schema.PersonalAnswerPageReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.LoginUserID = middleware.GetLoginUserIDFromContext(ctx)
req.IsAdmin = middleware.GetUserIsAdminModerator(ctx)
resp, err := qc.questionService.PersonalAnswerPage(ctx, req)
handler.HandleResponse(ctx, err, resp)
}
// PersonalCollectionPage list personal collections
// @Summary list personal collections
// @Description list personal collections
// @Tags Collection
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param page query string true "page" default(0)
// @Param page_size query string true "page_size" default(20)
// @Success 200 {object} handler.RespBody
// @Router /answer/api/v1/personal/collection/page [get]
func (qc *QuestionController) PersonalCollectionPage(ctx *gin.Context) {
req := &schema.PersonalCollectionPageReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
resp, err := qc.questionService.PersonalCollectionPage(ctx, req)
handler.HandleResponse(ctx, err, resp)
}
// AdminQuestionPage admin question page
// @Summary AdminQuestionPage admin question page
// @Description Status:[available,closed,deleted,pending]
// @Tags admin
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param page query int false "page size"
// @Param page_size query int false "page size"
// @Param status query string false "user status" Enums(available, closed, deleted, pending)
// @Param query query string false "question id or title"
// @Success 200 {object} handler.RespBody
// @Router /answer/admin/api/question/page [get]
func (qc *QuestionController) AdminQuestionPage(ctx *gin.Context) {
req := &schema.AdminQuestionPageReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.LoginUserID = middleware.GetLoginUserIDFromContext(ctx)
resp, err := qc.questionService.AdminQuestionPage(ctx, req)
handler.HandleResponse(ctx, err, resp)
}
// AdminAnswerPage admin answer page
// @Summary AdminAnswerPage admin answer page
// @Description Status:[available,deleted,pending]
// @Tags admin
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param page query int false "page size"
// @Param page_size query int false "page size"
// @Param status query string false "user status" Enums(available,deleted,pending)
// @Param query query string false "answer id or question title"
// @Param question_id query string false "question id"
// @Success 200 {object} handler.RespBody
// @Router /answer/admin/api/answer/page [get]
func (qc *QuestionController) AdminAnswerPage(ctx *gin.Context) {
req := &schema.AdminAnswerPageReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.LoginUserID = middleware.GetLoginUserIDFromContext(ctx)
resp, err := qc.questionService.AdminAnswerPage(ctx, req)
handler.HandleResponse(ctx, err, resp)
}
// AdminUpdateQuestionStatus update question status
// @Summary update question status
// @Description update question status
// @Tags admin
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param data body schema.AdminUpdateQuestionStatusReq true "AdminUpdateQuestionStatusReq"
// @Success 200 {object} handler.RespBody
// @Router /answer/admin/api/question/status [put]
func (qc *QuestionController) AdminUpdateQuestionStatus(ctx *gin.Context) {
req := &schema.AdminUpdateQuestionStatusReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.QuestionID = uid.DeShortID(req.QuestionID)
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
err := qc.questionService.AdminSetQuestionStatus(ctx, req)
handler.HandleResponse(ctx, err, nil)
}
// GetQuestionLink get question link
// @Summary get question link
// @Description get question link
// @Tags Question
// @Param data query schema.GetQuestionLinkReq true "GetQuestionLinkReq"
// @Success 200 {object} handler.RespBody{data=pager.PageModel{list=[]schema.QuestionPageResp}}
// @Router /answer/api/v1/question/link [get]
func (qc *QuestionController) GetQuestionLink(ctx *gin.Context) {
req := &schema.GetQuestionLinkReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.LoginUserID = middleware.GetLoginUserIDFromContext(ctx)
req.QuestionID = uid.DeShortID(req.QuestionID)
questions, total, err := qc.questionService.GetQuestionLink(ctx, req)
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
handler.HandleResponse(ctx, nil, pager.NewPageModel(total, questions))
}
+61
View File
@@ -0,0 +1,61 @@
/*
* 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 (
"github.com/apache/answer/internal/base/handler"
"github.com/apache/answer/internal/base/middleware"
"github.com/apache/answer/internal/schema"
"github.com/apache/answer/internal/service/rank"
"github.com/gin-gonic/gin"
)
// RankController rank controller
type RankController struct {
rankService *rank.RankService
}
// NewRankController new controller
func NewRankController(
rankService *rank.RankService) *RankController {
return &RankController{rankService: rankService}
}
// GetRankPersonalWithPage user personal rank list
// @Summary user personal rank list
// @Description user personal rank list
// @Tags Rank
// @Produce json
// @Param page query int false "page"
// @Param page_size query int false "page size"
// @Param username query string false "username"
// @Success 200 {object} handler.RespBody{data=pager.PageModel{list=[]schema.GetRankPersonalPageResp}}
// @Router /answer/api/v1/personal/rank/page [get]
func (cc *RankController) GetRankPersonalWithPage(ctx *gin.Context) {
req := &schema.GetRankPersonalWithPageReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
resp, err := cc.rankService.GetRankPersonalPage(ctx, req)
handler.HandleResponse(ctx, err, resp)
}
+58
View File
@@ -0,0 +1,58 @@
/*
* 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 (
"github.com/apache/answer/internal/base/handler"
"github.com/apache/answer/internal/schema"
"github.com/apache/answer/internal/service/reason"
"github.com/gin-gonic/gin"
)
// ReasonController answer controller
type ReasonController struct {
reasonService *reason.ReasonService
}
// NewReasonController new controller
func NewReasonController(answerService *reason.ReasonService) *ReasonController {
return &ReasonController{reasonService: answerService}
}
// Reasons godoc
// @Summary get reasons by object type and action
// @Description get reasons by object type and action
// @Tags reason
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param object_type query string true "object_type" Enums(question, answer, comment, user)
// @Param action query string true "action" Enums(status, close, flag, review)
// @Success 200 {object} handler.RespBody
// @Router /answer/api/v1/reasons [get]
// @Router /answer/admin/api/reasons [get]
func (rc *ReasonController) Reasons(ctx *gin.Context) {
req := &schema.ReasonReq{}
if handler.BindAndCheck(ctx, req) {
return
}
reasons, err := rc.reasonService.GetReasons(ctx, *req)
handler.HandleResponse(ctx, err, reasons)
}
+52
View File
@@ -0,0 +1,52 @@
/*
* 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 (
"github.com/apache/answer/internal/base/handler"
"github.com/apache/answer/plugin"
"github.com/gin-gonic/gin"
)
type RenderController struct {
}
func NewRenderController() *RenderController {
return &RenderController{}
}
// GetRenderConfig godoc
// @Summary GetRenderConfig
// @Description GetRenderConfig
// @Tags PluginRender
// @Accept json
// @Produce json
// @Router /answer/api/v1/render/config [get]
// @Success 200 {object} handler.RespBody{data=plugin.RenderConfig}
func (c *RenderController) GetRenderConfig(ctx *gin.Context) {
var resp *plugin.RenderConfig
_ = plugin.CallRender(func(render plugin.Render) (err error) {
resp = render.GetRenderConfig(ctx)
return nil
})
handler.HandleResponse(ctx, nil, resp)
}
+154
View File
@@ -0,0 +1,154 @@
/*
* 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 (
"github.com/apache/answer/internal/base/handler"
"github.com/apache/answer/internal/base/middleware"
"github.com/apache/answer/internal/base/reason"
"github.com/apache/answer/internal/base/translator"
"github.com/apache/answer/internal/base/validator"
"github.com/apache/answer/internal/entity"
"github.com/apache/answer/internal/schema"
"github.com/apache/answer/internal/service/action"
"github.com/apache/answer/internal/service/permission"
"github.com/apache/answer/internal/service/rank"
"github.com/apache/answer/internal/service/report"
"github.com/apache/answer/pkg/uid"
"github.com/gin-gonic/gin"
"github.com/segmentfault/pacman/errors"
)
// ReportController report controller
type ReportController struct {
reportService *report.ReportService
rankService *rank.RankService
actionService *action.CaptchaService
}
// NewReportController new controller
func NewReportController(
reportService *report.ReportService,
rankService *rank.RankService,
actionService *action.CaptchaService,
) *ReportController {
return &ReportController{
reportService: reportService,
rankService: rankService,
actionService: actionService,
}
}
// AddReport add report
// @Summary add report
// @Description add report <br> source (question, answer, comment, user)
// @Tags Report
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param data body schema.AddReportReq true "report"
// @Success 200 {object} handler.RespBody
// @Router /answer/api/v1/report [post]
func (rc *ReportController) AddReport(ctx *gin.Context) {
req := &schema.AddReportReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.ObjectID = uid.DeShortID(req.ObjectID)
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
isAdmin := middleware.GetUserIsAdminModerator(ctx)
if !isAdmin {
captchaPass := rc.actionService.ActionRecordVerifyCaptcha(ctx, entity.CaptchaActionReport, req.UserID, req.CaptchaID, req.CaptchaCode)
if !captchaPass {
errFields := append([]*validator.FormErrorField{}, &validator.FormErrorField{
ErrorField: "captcha_code",
ErrorMsg: translator.Tr(handler.GetLangByCtx(ctx), reason.CaptchaVerificationFailed),
})
handler.HandleResponse(ctx, errors.BadRequest(reason.CaptchaVerificationFailed), errFields)
return
}
}
can, err := rc.rankService.CheckOperationPermission(ctx, req.UserID, permission.ReportAdd, "")
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
if !can {
handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil)
return
}
err = rc.reportService.AddReport(ctx, req)
if !isAdmin {
rc.actionService.ActionRecordAdd(ctx, entity.CaptchaActionReport, req.UserID)
}
handler.HandleResponse(ctx, err, nil)
}
// GetUnreviewedReportPostPage get unreviewed report post page
// @Summary get unreviewed report post page
// @Description get unreviewed report post page
// @Tags Report
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param page query int false "page"
// @Success 200 {object} handler.RespBody{data=pager.PageModel{list=[]schema.GetReportListPageResp}}
// @Router /answer/api/v1/report/unreviewed/post [get]
func (rc *ReportController) GetUnreviewedReportPostPage(ctx *gin.Context) {
req := &schema.GetUnreviewedReportPostPageReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
req.IsAdmin = middleware.GetUserIsAdminModerator(ctx)
resp, err := rc.reportService.GetUnreviewedReportPostPage(ctx, req)
handler.HandleResponse(ctx, err, resp)
}
// ReviewReport review report
// @Summary review report
// @Description review report
// @Tags Report
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param data body schema.ReviewReportReq true "flag"
// @Success 200 {object} handler.RespBody
// @Router /answer/api/v1/report/review [put]
func (rc *ReportController) ReviewReport(ctx *gin.Context) {
req := &schema.ReviewReportReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
req.IsAdmin = middleware.GetUserIsAdminModerator(ctx)
if !req.IsAdmin {
handler.HandleResponse(ctx, errors.Forbidden(reason.ForbiddenError), nil)
return
}
err := rc.reportService.ReviewReport(ctx, req)
handler.HandleResponse(ctx, err, nil)
}
+111
View File
@@ -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 controller
import (
"github.com/apache/answer/internal/base/handler"
"github.com/apache/answer/internal/base/middleware"
"github.com/apache/answer/internal/base/reason"
"github.com/apache/answer/internal/schema"
"github.com/apache/answer/internal/service/action"
"github.com/apache/answer/internal/service/rank"
"github.com/apache/answer/internal/service/review"
"github.com/apache/answer/plugin"
"github.com/gin-gonic/gin"
"github.com/segmentfault/pacman/errors"
)
// ReviewController review controller
type ReviewController struct {
reviewService *review.ReviewService
rankService *rank.RankService
actionService *action.CaptchaService
}
// NewReviewController new controller
func NewReviewController(
reviewService *review.ReviewService,
rankService *rank.RankService,
actionService *action.CaptchaService,
) *ReviewController {
return &ReviewController{
reviewService: reviewService,
rankService: rankService,
actionService: actionService,
}
}
// GetUnreviewedPostPage get unreviewed post page
// @Summary get unreviewed post page
// @Description get unreviewed post page
// @Tags Review
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param page query int false "page"
// @Param object_id query string false "object_id"
// @Success 200 {object} handler.RespBody{data=pager.PageModel{list=[]schema.GetUnreviewedPostPageResp}}
// @Router /answer/api/v1/review/pending/post/page [get]
func (rc *ReviewController) GetUnreviewedPostPage(ctx *gin.Context) {
req := &schema.GetUnreviewedPostPageReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
req.IsAdmin = middleware.GetUserIsAdminModerator(ctx)
req.ReviewerMapping = make(map[string]string)
_ = plugin.CallReviewer(func(base plugin.Reviewer) error {
info := base.Info()
req.ReviewerMapping[info.SlugName] = info.Name.Translate(ctx)
return nil
})
resp, err := rc.reviewService.GetUnreviewedPostPage(ctx, req)
handler.HandleResponse(ctx, err, resp)
}
// UpdateReview update review
// @Summary update review
// @Description update review
// @Tags Review
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param data body schema.UpdateReviewReq true "review"
// @Success 200 {object} handler.RespBody
// @Router /answer/api/v1/review/pending/post [put]
func (rc *ReviewController) UpdateReview(ctx *gin.Context) {
req := &schema.UpdateReviewReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
req.IsAdmin = middleware.GetUserIsAdminModerator(ctx)
if !req.IsAdmin {
handler.HandleResponse(ctx, errors.Forbidden(reason.ForbiddenError), nil)
return
}
err := rc.reviewService.UpdateReview(ctx, req)
handler.HandleResponse(ctx, err, nil)
}
+224
View File
@@ -0,0 +1,224 @@
/*
* 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 (
"github.com/apache/answer/internal/base/constant"
"github.com/apache/answer/internal/base/handler"
"github.com/apache/answer/internal/base/middleware"
"github.com/apache/answer/internal/base/reason"
"github.com/apache/answer/internal/entity"
"github.com/apache/answer/internal/schema"
"github.com/apache/answer/internal/service/content"
"github.com/apache/answer/internal/service/permission"
"github.com/apache/answer/internal/service/rank"
"github.com/apache/answer/pkg/obj"
"github.com/apache/answer/pkg/uid"
"github.com/gin-gonic/gin"
"github.com/segmentfault/pacman/errors"
)
// RevisionController revision controller
type RevisionController struct {
revisionListService *content.RevisionService
rankService *rank.RankService
}
// NewRevisionController new controller
func NewRevisionController(
revisionListService *content.RevisionService,
rankService *rank.RankService,
) *RevisionController {
return &RevisionController{
revisionListService: revisionListService,
rankService: rankService,
}
}
// GetRevisionList godoc
// @Summary get revision list
// @Description get revision list
// @Tags Revision
// @Produce json
// @Param object_id query string true "object id"
// @Success 200 {object} handler.RespBody{data=[]schema.GetRevisionResp}
// @Router /answer/api/v1/revisions [get]
func (rc *RevisionController) GetRevisionList(ctx *gin.Context) {
objectID := ctx.Query("object_id")
if objectID == "0" || objectID == "" {
handler.HandleResponse(ctx, errors.BadRequest(reason.RequestFormatError), nil)
return
}
objectID = uid.DeShortID(objectID)
req := &schema.GetRevisionListReq{
ObjectID: objectID,
IsAdmin: middleware.GetUserIsAdminModerator(ctx),
UserID: middleware.GetLoginUserIDFromContext(ctx),
}
resp, err := rc.revisionListService.GetRevisionList(ctx, req)
list := make([]schema.GetRevisionResp, 0)
for _, item := range resp {
if item.Status == entity.RevisionNormalStatus || item.Status == entity.RevisionReviewPassStatus {
list = append(list, item)
}
}
handler.HandleResponse(ctx, err, list)
}
// GetUnreviewedRevisionList godoc
// @Summary get unreviewed revision list
// @Description get unreviewed revision list
// @Tags Revision
// @Produce json
// @Security ApiKeyAuth
// @Param page query string true "page id"
// @Success 200 {object} handler.RespBody{data=pager.PageModel{list=[]schema.GetUnreviewedRevisionResp}}
// @Router /answer/api/v1/revisions/unreviewed [get]
func (rc *RevisionController) GetUnreviewedRevisionList(ctx *gin.Context) {
req := &schema.RevisionSearch{}
if handler.BindAndCheck(ctx, req) {
return
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
canList, err := rc.rankService.CheckOperationPermissions(ctx, req.UserID, []string{
permission.QuestionAudit,
permission.AnswerAudit,
permission.TagAudit,
})
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
req.CanReviewQuestion = canList[0]
req.CanReviewAnswer = canList[1]
req.CanReviewTag = canList[2]
resp, err := rc.revisionListService.GetUnreviewedRevisionPage(ctx, req)
handler.HandleResponse(ctx, err, resp)
}
// RevisionAudit godoc
// @Summary revision audit
// @Description revision audit operation:approve or reject
// @Tags Revision
// @Produce json
// @Security ApiKeyAuth
// @Param data body schema.RevisionAuditReq true "audit"
// @Success 200 {object} handler.RespBody{}
// @Router /answer/api/v1/revisions/audit [put]
func (rc *RevisionController) RevisionAudit(ctx *gin.Context) {
req := &schema.RevisionAuditReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
canList, err := rc.rankService.CheckOperationPermissions(ctx, req.UserID, []string{
permission.QuestionAudit,
permission.AnswerAudit,
permission.TagAudit,
})
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
req.CanReviewQuestion = canList[0]
req.CanReviewAnswer = canList[1]
req.CanReviewTag = canList[2]
err = rc.revisionListService.RevisionAudit(ctx, req)
handler.HandleResponse(ctx, err, gin.H{})
}
// CheckCanUpdateRevision check can update revision
// @Summary check can update revision
// @Description check can update revision
// @Tags Revision
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param id query string true "id" default(string)
// @Success 200 {object} handler.RespBody
// @Router /answer/api/v1/revisions/edit/check [get]
func (rc *RevisionController) CheckCanUpdateRevision(ctx *gin.Context) {
req := &schema.CheckCanQuestionUpdate{}
if handler.BindAndCheck(ctx, req) {
return
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
action := ""
req.ID = uid.DeShortID(req.ID)
objectTypeStr, _ := obj.GetObjectTypeStrByObjectID(req.ID)
switch objectTypeStr {
case constant.QuestionObjectType:
action = permission.QuestionEdit
case constant.AnswerObjectType:
action = permission.AnswerEdit
case constant.TagObjectType:
action = permission.TagEdit
default:
handler.HandleResponse(ctx, errors.BadRequest(reason.ObjectNotFound), nil)
return
}
can, err := rc.rankService.CheckOperationPermission(ctx, req.UserID, action, req.ID)
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
if !can {
handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil)
return
}
resp, err := rc.revisionListService.CheckCanUpdateRevision(ctx, req)
handler.HandleResponse(ctx, err, resp)
}
// GetReviewingType get reviewing type
// @Summary get reviewing type
// @Description get reviewing type
// @Tags Revision
// @Produce json
// @Security ApiKeyAuth
// @Success 200 {object} handler.RespBody{data=[]schema.GetReviewingTypeResp}
// @Router /answer/api/v1/reviewing/type [get]
func (rc *RevisionController) GetReviewingType(ctx *gin.Context) {
req := &schema.GetReviewingTypeReq{}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
canList, err := rc.rankService.CheckOperationPermissions(ctx, req.UserID, []string{
permission.QuestionAudit,
permission.AnswerAudit,
permission.TagAudit,
})
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
req.CanReviewQuestion = canList[0]
req.CanReviewAnswer = canList[1]
req.CanReviewTag = canList[2]
req.IsAdmin = middleware.GetUserIsAdminModerator(ctx)
resp, err := rc.revisionListService.GetReviewingType(ctx, req)
handler.HandleResponse(ctx, err, resp)
}
+115
View File
@@ -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 controller
import (
"github.com/apache/answer/internal/base/handler"
"github.com/apache/answer/internal/base/middleware"
"github.com/apache/answer/internal/base/reason"
"github.com/apache/answer/internal/base/translator"
"github.com/apache/answer/internal/base/validator"
"github.com/apache/answer/internal/entity"
"github.com/apache/answer/internal/schema"
"github.com/apache/answer/internal/service/action"
"github.com/apache/answer/internal/service/content"
"github.com/apache/answer/plugin"
"github.com/gin-gonic/gin"
"github.com/segmentfault/pacman/errors"
)
// SearchController tag controller
type SearchController struct {
searchService *content.SearchService
actionService *action.CaptchaService
}
// NewSearchController new controller
func NewSearchController(
searchService *content.SearchService,
actionService *action.CaptchaService,
) *SearchController {
return &SearchController{
searchService: searchService,
actionService: actionService,
}
}
// Search godoc
// @Summary search object
// @Description search object
// @Tags Search
// @Produce json
// @Security ApiKeyAuth
// @Param q query string true "query string"
// @Param order query string true "order" Enums(newest,active,score,relevance)
// @Success 200 {object} handler.RespBody{data=schema.SearchResp}
// @Router /answer/api/v1/search [get]
func (sc *SearchController) Search(ctx *gin.Context) {
dto := schema.SearchDTO{}
if handler.BindAndCheck(ctx, &dto) {
return
}
dto.UserID = middleware.GetLoginUserIDFromContext(ctx)
unit := ctx.ClientIP()
if dto.UserID != "" {
unit = dto.UserID
}
isAdmin := middleware.GetUserIsAdminModerator(ctx)
if !isAdmin {
captchaPass := sc.actionService.ActionRecordVerifyCaptcha(ctx, entity.CaptchaActionSearch, unit, dto.CaptchaID, dto.CaptchaCode)
if !captchaPass {
errFields := append([]*validator.FormErrorField{}, &validator.FormErrorField{
ErrorField: "captcha_code",
ErrorMsg: translator.Tr(handler.GetLangByCtx(ctx), reason.CaptchaVerificationFailed),
})
handler.HandleResponse(ctx, errors.BadRequest(reason.CaptchaVerificationFailed), errFields)
return
}
}
if !isAdmin {
sc.actionService.ActionRecordAdd(ctx, entity.CaptchaActionSearch, unit)
}
resp, err := sc.searchService.Search(ctx, &dto)
handler.HandleResponse(ctx, err, resp)
}
// SearchDesc get search description
// @Summary get search description
// @Description get search description
// @Tags Search
// @Produce json
// @Success 200 {object} handler.RespBody{data=schema.SearchResp}
// @Router /answer/api/v1/search/desc [get]
func (sc *SearchController) SearchDesc(ctx *gin.Context) {
var finder plugin.Search
_ = plugin.CallSearch(func(search plugin.Search) error {
finder = search
return nil
})
resp := &schema.SearchDescResp{}
if finder != nil {
resp.Name = finder.Info().Name.Translate(ctx)
resp.Icon = finder.Description().Icon
resp.Link = finder.Description().Link
}
handler.HandleResponse(ctx, nil, resp)
}
+182
View File
@@ -0,0 +1,182 @@
/*
* 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 (
"net/http"
"github.com/apache/answer/internal/base/constant"
"github.com/apache/answer/internal/base/handler"
"github.com/apache/answer/internal/schema"
"github.com/apache/answer/internal/service/siteinfo_common"
"github.com/gin-gonic/gin"
"github.com/segmentfault/pacman/log"
)
type SiteInfoController struct {
siteInfoService siteinfo_common.SiteInfoCommonService
}
// NewSiteInfoController new site info controller.
func NewSiteInfoController(siteInfoService siteinfo_common.SiteInfoCommonService) *SiteInfoController {
return &SiteInfoController{
siteInfoService: siteInfoService,
}
}
// GetSiteInfo get site info
// @Summary get site info
// @Description get site info
// @Tags site
// @Produce json
// @Success 200 {object} handler.RespBody{data=schema.SiteInfoResp}
// @Router /answer/api/v1/siteinfo [get]
func (sc *SiteInfoController) GetSiteInfo(ctx *gin.Context) {
var err error
resp := &schema.SiteInfoResp{Version: constant.Version, Revision: constant.Revision}
resp.General, err = sc.siteInfoService.GetSiteGeneral(ctx)
if err != nil {
log.Error(err)
}
resp.Interface, err = sc.siteInfoService.GetSiteInterface(ctx)
if err != nil {
log.Error(err)
}
resp.UsersSettings, err = sc.siteInfoService.GetSiteUsersSettings(ctx)
if err != nil {
log.Error(err)
}
resp.Branding, err = sc.siteInfoService.GetSiteBranding(ctx)
if err != nil {
log.Error(err)
}
resp.Login, err = sc.siteInfoService.GetSiteLogin(ctx)
if err != nil {
log.Error(err)
}
resp.Theme, err = sc.siteInfoService.GetSiteTheme(ctx)
if err != nil {
log.Error(err)
}
resp.CustomCssHtml, err = sc.siteInfoService.GetSiteCustomCssHTML(ctx)
if err != nil {
log.Error(err)
}
resp.SiteSeo, err = sc.siteInfoService.GetSiteSeo(ctx)
if err != nil {
log.Error(err)
}
resp.SiteUsers, err = sc.siteInfoService.GetSiteUsers(ctx)
if err != nil {
log.Error(err)
}
resp.Questions, err = sc.siteInfoService.GetSiteQuestion(ctx)
if err != nil {
log.Error(err)
}
resp.Tags, err = sc.siteInfoService.GetSiteTag(ctx)
if err != nil {
log.Error(err)
}
resp.Advanced, err = sc.siteInfoService.GetSiteAdvanced(ctx)
if err != nil {
log.Error(err)
}
if legal, err := sc.siteInfoService.GetSiteSecurity(ctx); err == nil {
resp.Legal = &schema.SiteLegalSimpleResp{ExternalContentDisplay: legal.ExternalContentDisplay}
}
if security, err := sc.siteInfoService.GetSiteSecurity(ctx); err == nil {
resp.Security = security
}
if aiConf, err := sc.siteInfoService.GetSiteAI(ctx); err == nil {
resp.AIEnabled = aiConf.Enabled
}
if mcpConf, err := sc.siteInfoService.GetSiteMCP(ctx); err == nil {
resp.MCPEnabled = mcpConf.Enabled
}
handler.HandleResponse(ctx, nil, resp)
}
// GetSiteLegalInfo get site legal info
// @Summary get site legal info
// @Description get site legal info
// @Tags site
// @Param info_type query string true "legal information type" Enums(tos, privacy)
// @Produce json
// @Success 200 {object} handler.RespBody{data=schema.GetSiteLegalInfoResp}
// @Router /answer/api/v1/siteinfo/legal [get]
func (sc *SiteInfoController) GetSiteLegalInfo(ctx *gin.Context) {
req := &schema.GetSiteLegalInfoReq{}
if handler.BindAndCheck(ctx, req) {
return
}
siteLegal, err := sc.siteInfoService.GetSitePolicies(ctx)
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
resp := &schema.GetSiteLegalInfoResp{}
if req.IsTOS() {
resp.TermsOfServiceOriginalText = siteLegal.TermsOfServiceOriginalText
resp.TermsOfServiceParsedText = siteLegal.TermsOfServiceParsedText
} else if req.IsPrivacy() {
resp.PrivacyPolicyOriginalText = siteLegal.PrivacyPolicyOriginalText
resp.PrivacyPolicyParsedText = siteLegal.PrivacyPolicyParsedText
}
handler.HandleResponse(ctx, nil, resp)
}
// GetManifestJson get manifest.json
func (sc *SiteInfoController) GetManifestJson(ctx *gin.Context) {
favicon := "favicon.ico"
resp := &schema.GetManifestJsonResp{
ManifestVersion: 3,
Version: constant.Version,
Revision: constant.Revision,
ShortName: "Answer",
Name: "answer.apache.org",
Icons: schema.CreateManifestJsonIcons(favicon),
StartUrl: ".",
Display: "standalone",
ThemeColor: "#000000",
BackgroundColor: "#ffffff",
}
branding, err := sc.siteInfoService.GetSiteBranding(ctx)
if err != nil {
log.Error(err)
} else if len(branding.Favicon) > 0 {
resp.Icons = schema.CreateManifestJsonIcons(branding.Favicon)
}
siteGeneral, err := sc.siteInfoService.GetSiteGeneral(ctx)
if err != nil {
log.Error(err)
} else {
resp.Name = siteGeneral.Name
resp.ShortName = siteGeneral.Name
}
ctx.JSON(http.StatusOK, resp)
}
+386
View File
@@ -0,0 +1,386 @@
/*
* 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 (
"github.com/apache/answer/internal/base/handler"
"github.com/apache/answer/internal/base/middleware"
"github.com/apache/answer/internal/base/pager"
"github.com/apache/answer/internal/base/reason"
"github.com/apache/answer/internal/schema"
"github.com/apache/answer/internal/service/permission"
"github.com/apache/answer/internal/service/rank"
"github.com/apache/answer/internal/service/tag"
"github.com/apache/answer/internal/service/tag_common"
"github.com/gin-gonic/gin"
"github.com/segmentfault/pacman/errors"
)
// TagController tag controller
type TagController struct {
tagService *tag.TagService
tagCommonService *tag_common.TagCommonService
rankService *rank.RankService
}
// NewTagController new controller
func NewTagController(
tagService *tag.TagService,
tagCommonService *tag_common.TagCommonService,
rankService *rank.RankService,
) *TagController {
return &TagController{tagService: tagService, tagCommonService: tagCommonService, rankService: rankService}
}
// SearchTagLike get tag list
// @Summary get tag list
// @Description get tag list
// @Tags Tag
// @Produce json
// @Security ApiKeyAuth
// @Param tag query string false "tag"
// @Success 200 {object} handler.RespBody{data=[]schema.GetTagBasicResp}
// @Router /answer/api/v1/question/tags [get]
func (tc *TagController) SearchTagLike(ctx *gin.Context) {
req := &schema.SearchTagLikeReq{}
if handler.BindAndCheck(ctx, req) {
return
}
resp, err := tc.tagCommonService.SearchTagLike(ctx, req)
handler.HandleResponse(ctx, err, resp)
}
// GetTagsBySlugName get tags list
// @Summary get tags list
// @Description get tags list by slug name
// @Tags Tag
// @Produce json
// @Param tags query []string false "string collection" collectionFormat(csv)
// @Success 200 {object} handler.RespBody{data=[]schema.GetTagBasicResp}
// @Router /answer/api/v1/tags [get]
func (tc *TagController) GetTagsBySlugName(ctx *gin.Context) {
req := &schema.SearchTagsBySlugName{}
if handler.BindAndCheck(ctx, req) {
return
}
resp, err := tc.tagService.GetTagsBySlugName(ctx, req)
handler.HandleResponse(ctx, err, resp)
}
// RemoveTag delete tag
// @Summary delete tag
// @Description delete tag
// @Security ApiKeyAuth
// @Tags Tag
// @Accept json
// @Produce json
// @Param data body schema.RemoveTagReq true "tag"
// @Success 200 {object} handler.RespBody
// @Router /answer/api/v1/tag [delete]
func (tc *TagController) RemoveTag(ctx *gin.Context) {
req := &schema.RemoveTagReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
can, err := tc.rankService.CheckOperationPermission(ctx, req.UserID, permission.TagDelete, "")
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
if !can {
handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil)
return
}
err = tc.tagService.RemoveTag(ctx, req)
handler.HandleResponse(ctx, err, nil)
}
// AddTag add tag
// @Summary add tag
// @Description add tag
// @Security ApiKeyAuth
// @Tags Tag
// @Accept json
// @Produce json
// @Param data body schema.AddTagReq true "tag"
// @Success 200 {object} handler.RespBody
// @Router /answer/api/v1/tag [post]
func (tc *TagController) AddTag(ctx *gin.Context) {
req := &schema.AddTagReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
canList, err := tc.rankService.CheckOperationPermissions(ctx, req.UserID, []string{
permission.TagAdd,
})
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
if !canList[0] {
handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil)
return
}
resp, err := tc.tagCommonService.AddTag(ctx, req)
handler.HandleResponse(ctx, err, resp)
}
// UpdateTag update tag
// @Summary update tag
// @Description update tag
// @Security ApiKeyAuth
// @Tags Tag
// @Accept json
// @Produce json
// @Param data body schema.UpdateTagReq true "tag"
// @Success 200 {object} handler.RespBody
// @Router /answer/api/v1/tag [put]
func (tc *TagController) UpdateTag(ctx *gin.Context) {
req := &schema.UpdateTagReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
canList, err := tc.rankService.CheckOperationPermissions(ctx, req.UserID, []string{
permission.TagEdit,
permission.TagEditWithoutReview,
})
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
if !canList[0] {
handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil)
return
}
req.NoNeedReview = canList[1]
err = tc.tagService.UpdateTag(ctx, req)
if err != nil {
handler.HandleResponse(ctx, err, nil)
} else {
handler.HandleResponse(ctx, err, &schema.UpdateTagResp{WaitForReview: !req.NoNeedReview})
}
}
// RecoverTag recover delete tag
// @Summary recover delete tag
// @Description recover delete tag
// @Security ApiKeyAuth
// @Tags Tag
// @Accept json
// @Produce json
// @Param data body schema.RecoverTagReq true "tag"
// @Success 200 {object} handler.RespBody
// @Router /answer/api/v1/tag/recover [post]
func (tc *TagController) RecoverTag(ctx *gin.Context) {
req := &schema.RecoverTagReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
canList, err := tc.rankService.CheckOperationPermissions(ctx, req.UserID, []string{
permission.TagUnDelete,
})
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
if !canList[0] {
handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil)
return
}
err = tc.tagService.RecoverTag(ctx, req)
handler.HandleResponse(ctx, err, nil)
}
// GetTagInfo get tag one
// @Summary get tag one
// @Description get tag one
// @Tags Tag
// @Accept json
// @Produce json
// @Param tag_id query string true "tag id"
// @Param tag_name query string true "tag name"
// @Success 200 {object} handler.RespBody{data=schema.GetTagResp}
// @Router /answer/api/v1/tag [get]
func (tc *TagController) GetTagInfo(ctx *gin.Context) {
req := &schema.GetTagInfoReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
canList, err := tc.rankService.CheckOperationPermissions(ctx, req.UserID, []string{
permission.TagEdit,
permission.TagDelete,
permission.TagUnDelete,
})
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
req.CanEdit = canList[0]
req.CanDelete = canList[1]
req.CanRecover = canList[2]
req.CanMerge = middleware.GetUserIsAdminModerator(ctx)
resp, err := tc.tagService.GetTagInfo(ctx, req)
handler.HandleResponse(ctx, err, resp)
}
// GetTagWithPage get tag page
// @Summary get tag page
// @Description get tag page
// @Tags Tag
// @Produce json
// @Param page query int false "page size"
// @Param page_size query int false "page size"
// @Param slug_name query string false "slug_name"
// @Param query_cond query string false "query condition" Enums(popular, name, newest)
// @Success 200 {object} handler.RespBody{data=pager.PageModel{list=[]schema.GetTagPageResp}}
// @Router /answer/api/v1/tags/page [get]
func (tc *TagController) GetTagWithPage(ctx *gin.Context) {
req := &schema.GetTagWithPageReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
resp, err := tc.tagService.GetTagWithPage(ctx, req)
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
if pager.ValPageOutOfRange(resp.Count, req.Page, req.PageSize) {
handler.HandleResponse(ctx, errors.NotFound(reason.RequestFormatError), nil)
return
}
handler.HandleResponse(ctx, err, resp)
}
// GetFollowingTags get following tag list
// @Summary get following tag list
// @Description get following tag list
// @Security ApiKeyAuth
// @Tags Tag
// @Produce json
// @Success 200 {object} handler.RespBody{data=[]schema.GetFollowingTagsResp}
// @Router /answer/api/v1/tags/following [get]
func (tc *TagController) GetFollowingTags(ctx *gin.Context) {
userID := middleware.GetLoginUserIDFromContext(ctx)
resp, err := tc.tagService.GetFollowingTags(ctx, userID)
handler.HandleResponse(ctx, err, resp)
}
// GetTagSynonyms get tag synonyms
// @Summary get tag synonyms
// @Description get tag synonyms
// @Tags Tag
// @Produce json
// @Param tag_id query int true "tag id"
// @Success 200 {object} handler.RespBody{data=schema.GetTagSynonymsResp}
// @Router /answer/api/v1/tag/synonyms [get]
func (tc *TagController) GetTagSynonyms(ctx *gin.Context) {
req := &schema.GetTagSynonymsReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
can, err := tc.rankService.CheckOperationPermission(ctx, req.UserID, permission.TagSynonym, "")
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
req.CanEdit = can
resp, err := tc.tagService.GetTagSynonyms(ctx, req)
handler.HandleResponse(ctx, err, resp)
}
// UpdateTagSynonym update tag
// @Summary update tag
// @Description update tag
// @Security ApiKeyAuth
// @Tags Tag
// @Accept json
// @Produce json
// @Param data body schema.UpdateTagSynonymReq true "tag"
// @Success 200 {object} handler.RespBody
// @Router /answer/api/v1/tag/synonym [put]
func (tc *TagController) UpdateTagSynonym(ctx *gin.Context) {
req := &schema.UpdateTagSynonymReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
can, err := tc.rankService.CheckOperationPermission(ctx, req.UserID, permission.TagSynonym, "")
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
if !can {
handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil)
return
}
err = tc.tagService.UpdateTagSynonym(ctx, req)
handler.HandleResponse(ctx, err, nil)
}
// MergeTag merge tag
// @Summary merge tag
// @Description merge tag
// @Security ApiKeyAuth
// @Tags Tag
// @Accept json
// @Produce json
// @Param data body schema.AddTagReq true "tag"
// @Success 200 {object} handler.RespBody
// @Router /answer/api/v1/tag/merge [post]
func (tc *TagController) MergeTag(ctx *gin.Context) {
req := &schema.MergeTagReq{}
if handler.BindAndCheck(ctx, req) {
return
}
isAdminModerator := middleware.GetUserIsAdminModerator(ctx)
if !isAdminModerator {
handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil)
return
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
err := tc.tagService.MergeTag(ctx, req)
handler.HandleResponse(ctx, err, nil)
}
+668
View File
@@ -0,0 +1,668 @@
/*
* 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 (
"encoding/json"
"fmt"
"html/template"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"github.com/apache/answer/internal/base/middleware"
"github.com/apache/answer/internal/base/pager"
"github.com/apache/answer/internal/service/content"
"github.com/apache/answer/internal/service/eventqueue"
"github.com/apache/answer/plugin"
"github.com/apache/answer/internal/base/constant"
"github.com/apache/answer/internal/base/handler"
"github.com/apache/answer/internal/base/translator"
templaterender "github.com/apache/answer/internal/controller/template_render"
"github.com/apache/answer/internal/entity"
"github.com/apache/answer/internal/schema"
"github.com/apache/answer/internal/service/siteinfo_common"
"github.com/apache/answer/pkg/checker"
"github.com/apache/answer/pkg/converter"
"github.com/apache/answer/pkg/htmltext"
"github.com/apache/answer/pkg/obj"
"github.com/apache/answer/pkg/uid"
"github.com/apache/answer/ui"
"github.com/gin-gonic/gin"
"github.com/segmentfault/pacman/log"
)
var SiteUrl = ""
type TemplateController struct {
scriptPath []string
cssPath string
templateRenderController *templaterender.TemplateRenderController
siteInfoService siteinfo_common.SiteInfoCommonService
eventQueueService eventqueue.Service
userService *content.UserService
questionService *content.QuestionService
}
// NewTemplateController new controller
func NewTemplateController(
templateRenderController *templaterender.TemplateRenderController,
siteInfoService siteinfo_common.SiteInfoCommonService,
eventQueueService eventqueue.Service,
userService *content.UserService,
questionService *content.QuestionService,
) *TemplateController {
script, css := GetStyle()
return &TemplateController{
scriptPath: script,
cssPath: css,
templateRenderController: templateRenderController,
siteInfoService: siteInfoService,
eventQueueService: eventQueueService,
userService: userService,
questionService: questionService,
}
}
func GetStyle() (script []string, css string) {
file, err := ui.Build.ReadFile("build/index.html")
if err != nil {
return
}
scriptRegexp := regexp.MustCompile(`<script defer="defer" src="([^"]*)"></script>`)
scriptData := scriptRegexp.FindAllStringSubmatch(string(file), -1)
for _, s := range scriptData {
if len(s) == 2 {
script = append(script, s[1])
}
}
cssRegexp := regexp.MustCompile(`<link href="(.*)" rel="stylesheet">`)
cssListData := cssRegexp.FindStringSubmatch(string(file))
if len(cssListData) == 2 {
css = cssListData[1]
}
return
}
func (tc *TemplateController) SiteInfo(ctx *gin.Context) *schema.TemplateSiteInfoResp {
var err error
resp := &schema.TemplateSiteInfoResp{}
resp.General, err = tc.siteInfoService.GetSiteGeneral(ctx)
if err != nil {
log.Error(err)
}
SiteUrl = resp.General.SiteUrl
resp.Interface, err = tc.siteInfoService.GetSiteInterface(ctx)
if err != nil {
log.Error(err)
}
resp.Branding, err = tc.siteInfoService.GetSiteBranding(ctx)
if err != nil {
log.Error(err)
}
resp.SiteSeo, err = tc.siteInfoService.GetSiteSeo(ctx)
if err != nil {
log.Error(err)
}
resp.CustomCssHtml, err = tc.siteInfoService.GetSiteCustomCssHTML(ctx)
if err != nil {
log.Error(err)
}
resp.Year = fmt.Sprintf("%d", time.Now().Year())
return resp
}
// Index question list
func (tc *TemplateController) Index(ctx *gin.Context) {
req := &schema.QuestionPageReq{
OrderCond: "newest",
}
if handler.BindAndCheck(ctx, req) {
return
}
var page = req.Page
data, count, err := tc.templateRenderController.Index(ctx, req)
if err != nil || (len(data) == 0 && pager.ValPageOutOfRange(count, page, req.PageSize)) {
tc.Page404(ctx)
return
}
hotQuestionReq := &schema.QuestionPageReq{
Page: 1,
PageSize: 6,
OrderCond: "hot",
InDays: 7,
}
hotQuestion, _, _ := tc.templateRenderController.Index(ctx, hotQuestionReq)
siteInfo := tc.SiteInfo(ctx)
siteInfo.Canonical = siteInfo.General.SiteUrl
UrlUseTitle := siteInfo.SiteSeo.Permalink == constant.PermalinkQuestionIDAndTitle ||
siteInfo.SiteSeo.Permalink == constant.PermalinkQuestionIDAndTitleByShortID
siteInfo.Title = ""
tc.html(ctx, http.StatusOK, "question.html", siteInfo, gin.H{
"data": data,
"useTitle": UrlUseTitle,
"page": templaterender.Paginator(page, req.PageSize, count),
"path": "questions",
"hotQuestion": hotQuestion,
})
}
func (tc *TemplateController) QuestionList(ctx *gin.Context) {
req := &schema.QuestionPageReq{
OrderCond: "newest",
}
if handler.BindAndCheck(ctx, req) {
return
}
var page = req.Page
data, count, err := tc.templateRenderController.Index(ctx, req)
if err != nil || (len(data) == 0 && pager.ValPageOutOfRange(count, page, req.PageSize)) {
tc.Page404(ctx)
return
}
hotQuestionReq := &schema.QuestionPageReq{
Page: 1,
PageSize: 6,
OrderCond: "hot",
InDays: 7,
}
hotQuestion, _, _ := tc.templateRenderController.Index(ctx, hotQuestionReq)
siteInfo := tc.SiteInfo(ctx)
siteInfo.Canonical = fmt.Sprintf("%s/questions", siteInfo.General.SiteUrl)
if page > 1 {
siteInfo.Canonical = fmt.Sprintf("%s/questions?page=%d", siteInfo.General.SiteUrl, page)
}
UrlUseTitle := siteInfo.SiteSeo.Permalink == constant.PermalinkQuestionIDAndTitle ||
siteInfo.SiteSeo.Permalink == constant.PermalinkQuestionIDAndTitleByShortID
siteInfo.Title = fmt.Sprintf("%s - %s", translator.Tr(handler.GetLangByCtx(ctx), constant.QuestionsTitleTrKey), siteInfo.General.Name)
tc.html(ctx, http.StatusOK, "question.html", siteInfo, gin.H{
"data": data,
"useTitle": UrlUseTitle,
"page": templaterender.Paginator(page, req.PageSize, count),
"hotQuestion": hotQuestion,
})
}
func (tc *TemplateController) QuestionInfoRedirect(ctx *gin.Context, siteInfo *schema.TemplateSiteInfoResp, correctTitle bool) (jump bool, url string) {
questionID := ctx.Param("id")
title := ctx.Param("title")
answerID := uid.DeShortID(title)
titleIsAnswerID := false
needChangeShortID := false
objectType, err := obj.GetObjectTypeStrByObjectID(answerID)
if err == nil && objectType == constant.AnswerObjectType {
titleIsAnswerID = true
}
siteSeo, err := tc.siteInfoService.GetSiteSeo(ctx)
if err != nil {
return false, ""
}
isShortID := uid.IsShortID(questionID)
if siteSeo.IsShortLink() {
if !isShortID {
questionID = uid.EnShortID(questionID)
needChangeShortID = true
}
if titleIsAnswerID {
answerID = uid.EnShortID(answerID)
}
} else {
if isShortID {
needChangeShortID = true
questionID = uid.DeShortID(questionID)
}
if titleIsAnswerID {
answerID = uid.DeShortID(answerID)
}
}
if _, err := tc.templateRenderController.AnswerDetail(ctx, answerID); err != nil {
answerID = ""
titleIsAnswerID = false
}
url = fmt.Sprintf("%s/questions/%s", siteInfo.General.SiteUrl, questionID)
if siteInfo.SiteSeo.Permalink == constant.PermalinkQuestionID || siteInfo.SiteSeo.Permalink == constant.PermalinkQuestionIDByShortID {
if len(ctx.Request.URL.Query()) > 0 {
url = fmt.Sprintf("%s?%s", url, ctx.Request.URL.RawQuery)
}
if needChangeShortID {
return true, url
}
// not have title
if titleIsAnswerID || len(title) == 0 {
return false, ""
}
return true, url
} else {
detail, err := tc.templateRenderController.QuestionDetail(ctx, questionID)
if err != nil {
tc.Page404(ctx)
return
}
url = fmt.Sprintf("%s/%s", url, htmltext.UrlTitle(detail.Title))
if titleIsAnswerID {
url = fmt.Sprintf("%s/%s", url, answerID)
}
if len(ctx.Request.URL.Query()) > 0 {
url = fmt.Sprintf("%s?%s", url, ctx.Request.URL.RawQuery)
}
// have title
if len(title) > 0 && !titleIsAnswerID && correctTitle {
if needChangeShortID {
return true, url
}
return false, ""
}
return true, url
}
}
// QuestionInfo question and answers info
func (tc *TemplateController) QuestionInfo(ctx *gin.Context) {
id := ctx.Param("id")
title := ctx.Param("title")
answerid := ctx.Param("answerid")
shareUsername := ctx.Query("share")
if checker.IsQuestionsIgnorePath(id) {
// if id == "ask" {
file, err := ui.Build.ReadFile("build/index.html")
if err != nil {
log.Error(err)
tc.Page404(ctx)
return
}
ctx.Header("content-type", "text/html;charset=utf-8")
ctx.String(http.StatusOK, string(file))
return
}
correctTitle := false
detail, err := tc.templateRenderController.QuestionDetail(ctx, id)
if err != nil {
tc.Page404(ctx)
return
}
if len(shareUsername) > 0 {
userInfo, err := tc.userService.GetOtherUserInfoByUsername(
ctx, &schema.GetOtherUserInfoByUsernameReq{Username: shareUsername})
if err == nil {
tc.eventQueueService.Send(ctx, schema.NewEvent(constant.EventUserShare, userInfo.ID).
QID(id, detail.UserID).AID(answerid, ""))
}
}
encodeTitle := htmltext.UrlTitle(detail.Title)
if encodeTitle == title {
correctTitle = true
}
siteInfo := tc.SiteInfo(ctx)
jump, jumpurl := tc.QuestionInfoRedirect(ctx, siteInfo, correctTitle)
if jump {
ctx.Redirect(http.StatusFound, jumpurl)
return
}
// answers
answerReq := &schema.AnswerListReq{
QuestionID: id,
Order: "",
Page: 1,
PageSize: 999,
UserID: "",
}
answers, answerCount, err := tc.templateRenderController.AnswerList(ctx, answerReq)
if err != nil {
tc.Page404(ctx)
return
}
// comments
objectIDs := []string{uid.DeShortID(id)}
for _, answer := range answers {
answerID := uid.DeShortID(answer.ID)
objectIDs = append(objectIDs, answerID)
}
comments, err := tc.templateRenderController.CommentList(ctx, objectIDs)
if err != nil {
tc.Page404(ctx)
return
}
UrlUseTitle := siteInfo.SiteSeo.Permalink == constant.PermalinkQuestionIDAndTitle ||
siteInfo.SiteSeo.Permalink == constant.PermalinkQuestionIDAndTitleByShortID
// related question
userID := middleware.GetLoginUserIDFromContext(ctx)
relatedQuestion, _, _ := tc.questionService.SimilarQuestion(ctx, id, userID)
siteInfo.Canonical = fmt.Sprintf("%s/questions/%s/%s", siteInfo.General.SiteUrl, id, encodeTitle)
if siteInfo.SiteSeo.Permalink == constant.PermalinkQuestionID || siteInfo.SiteSeo.Permalink == constant.PermalinkQuestionIDByShortID {
siteInfo.Canonical = fmt.Sprintf("%s/questions/%s", siteInfo.General.SiteUrl, id)
}
jsonLD := &schema.QAPageJsonLD{}
jsonLD.Context = "https://schema.org"
jsonLD.Type = "QAPage"
jsonLD.MainEntity.Type = "Question"
jsonLD.MainEntity.Name = detail.Title
jsonLD.MainEntity.Text = detail.HTML
jsonLD.MainEntity.AnswerCount = int(answerCount)
jsonLD.MainEntity.UpvoteCount = detail.VoteCount
jsonLD.MainEntity.DateCreated = time.Unix(detail.CreateTime, 0)
jsonLD.MainEntity.Author.Type = "Person"
jsonLD.MainEntity.Author.Name = detail.UserInfo.DisplayName
jsonLD.MainEntity.Author.URL = fmt.Sprintf("%s/users/%s", siteInfo.General.SiteUrl, detail.UserInfo.Username)
answerList := make([]*schema.SuggestedAnswerItem, 0)
for _, answer := range answers {
if answer.Accepted == schema.AnswerAcceptedEnable {
acceptedAnswerItem := &schema.AcceptedAnswerItem{}
acceptedAnswerItem.Type = "Answer"
acceptedAnswerItem.Text = answer.HTML
acceptedAnswerItem.DateCreated = time.Unix(answer.CreateTime, 0)
acceptedAnswerItem.UpvoteCount = answer.VoteCount
acceptedAnswerItem.URL = fmt.Sprintf("%s/%s", siteInfo.Canonical, answer.ID)
acceptedAnswerItem.Author.Type = "Person"
acceptedAnswerItem.Author.Name = answer.UserInfo.DisplayName
acceptedAnswerItem.Author.URL = fmt.Sprintf("%s/users/%s", siteInfo.General.SiteUrl, answer.UserInfo.Username)
jsonLD.MainEntity.AcceptedAnswer = acceptedAnswerItem
} else {
item := &schema.SuggestedAnswerItem{}
item.Type = "Answer"
item.Text = answer.HTML
item.DateCreated = time.Unix(answer.CreateTime, 0)
item.UpvoteCount = answer.VoteCount
item.URL = fmt.Sprintf("%s/%s", siteInfo.Canonical, answer.ID)
item.Author.Type = "Person"
item.Author.Name = answer.UserInfo.DisplayName
item.Author.URL = fmt.Sprintf("%s/users/%s", siteInfo.General.SiteUrl, answer.UserInfo.Username)
answerList = append(answerList, item)
}
}
jsonLD.MainEntity.SuggestedAnswer = answerList
jsonLDStr, err := json.Marshal(jsonLD)
if err == nil {
siteInfo.JsonLD = `<script data-react-helmet="true" type="application/ld+json">` + string(jsonLDStr) + ` </script>`
}
siteInfo.Description = htmltext.FetchExcerpt(detail.HTML, "...", 240)
tags := make([]string, 0)
for _, tag := range detail.Tags {
tags = append(tags, tag.DisplayName)
}
siteInfo.Keywords = strings.ReplaceAll(strings.Trim(fmt.Sprint(tags), "[]"), " ", ",")
siteInfo.Title = fmt.Sprintf("%s - %s", detail.Title, siteInfo.General.Name)
tc.html(ctx, http.StatusOK, "question-detail.html", siteInfo, gin.H{
"id": id,
"answerid": answerid,
"detail": detail,
"answers": answers,
"comments": comments,
"noindex": detail.Show == entity.QuestionHide,
"useTitle": UrlUseTitle,
"relatedQuestion": relatedQuestion,
})
}
// TagList tags list
func (tc *TemplateController) TagList(ctx *gin.Context) {
req := &schema.GetTagWithPageReq{
PageSize: constant.DefaultPageSize,
Page: 1,
}
if handler.BindAndCheck(ctx, req) {
return
}
data, err := tc.templateRenderController.TagList(ctx, req)
if err != nil || pager.ValPageOutOfRange(data.Count, req.Page, req.PageSize) {
tc.Page404(ctx)
return
}
page := templaterender.Paginator(req.Page, req.PageSize, data.Count)
siteInfo := tc.SiteInfo(ctx)
siteInfo.Canonical = fmt.Sprintf("%s/tags", siteInfo.General.SiteUrl)
if req.Page > 1 {
siteInfo.Canonical = fmt.Sprintf("%s/tags?page=%d", siteInfo.General.SiteUrl, req.Page)
}
siteInfo.Title = fmt.Sprintf("%s - %s", translator.Tr(handler.GetLangByCtx(ctx), constant.TagsListTitleTrKey), siteInfo.General.Name)
tc.html(ctx, http.StatusOK, "tags.html", siteInfo, gin.H{
"page": page,
"data": data,
})
}
// TagInfo taginfo
func (tc *TemplateController) TagInfo(ctx *gin.Context) {
tag := ctx.Param("tag")
req := &schema.GetTamplateTagInfoReq{}
if handler.BindAndCheck(ctx, req) {
tc.Page404(ctx)
return
}
nowPage := req.Page
req.Name = tag
tagInfo, questionList, questionCount, err := tc.templateRenderController.TagInfo(ctx, req)
if err != nil {
tc.Page404(ctx)
return
}
page := templaterender.Paginator(nowPage, req.PageSize, questionCount)
siteInfo := tc.SiteInfo(ctx)
siteInfo.Canonical = fmt.Sprintf("%s/tags/%s", siteInfo.General.SiteUrl, tag)
if req.Page > 1 {
siteInfo.Canonical = fmt.Sprintf("%s/tags/%s?page=%d", siteInfo.General.SiteUrl, tag, req.Page)
}
siteInfo.Description = htmltext.FetchExcerpt(tagInfo.ParsedText, "...", 240)
if len(tagInfo.ParsedText) == 0 {
siteInfo.Description = translator.Tr(handler.GetLangByCtx(ctx), constant.TagHasNoDescription)
}
siteInfo.Keywords = tagInfo.DisplayName
UrlUseTitle := siteInfo.SiteSeo.Permalink == constant.PermalinkQuestionIDAndTitle ||
siteInfo.SiteSeo.Permalink == constant.PermalinkQuestionIDAndTitleByShortID
siteInfo.Title = fmt.Sprintf("'%s' %s - %s", tagInfo.DisplayName, translator.Tr(handler.GetLangByCtx(ctx), constant.QuestionsTitleTrKey), siteInfo.General.Name)
tc.html(ctx, http.StatusOK, "tag-detail.html", siteInfo, gin.H{
"tag": tagInfo,
"questionList": questionList,
"questionCount": questionCount,
"useTitle": UrlUseTitle,
"page": page,
})
}
// UserInfo user info
func (tc *TemplateController) UserInfo(ctx *gin.Context) {
username := ctx.Param("username")
if username == "" {
tc.Page404(ctx)
return
}
exist := checker.IsUsersIgnorePath(username)
if exist {
file, err := ui.Build.ReadFile("build/index.html")
if err != nil {
log.Error(err)
tc.Page404(ctx)
return
}
ctx.Header("content-type", "text/html;charset=utf-8")
ctx.String(http.StatusOK, string(file))
return
}
req := &schema.GetOtherUserInfoByUsernameReq{}
req.Username = username
userinfo, err := tc.templateRenderController.UserInfo(ctx, req)
if err != nil {
tc.Page404(ctx)
return
}
questionList, answerList, err := tc.questionService.SearchUserTopList(ctx, req.Username, "")
if err != nil {
tc.Page404(ctx)
return
}
siteInfo := tc.SiteInfo(ctx)
siteInfo.Canonical = fmt.Sprintf("%s/users/%s", siteInfo.General.SiteUrl, username)
siteInfo.Title = fmt.Sprintf("%s - %s", username, siteInfo.General.Name)
tc.html(ctx, http.StatusOK, "homepage.html", siteInfo, gin.H{
"userinfo": userinfo,
"bio": template.HTML(userinfo.BioHTML),
"topQuestions": questionList,
"topAnswers": answerList,
})
}
func (tc *TemplateController) Page404(ctx *gin.Context) {
tc.html(ctx, http.StatusNotFound, "404.html", tc.SiteInfo(ctx), gin.H{})
}
func (tc *TemplateController) html(ctx *gin.Context, code int, tpl string, siteInfo *schema.TemplateSiteInfoResp, data gin.H) {
prefix := ""
cssPath := ""
scriptPath := make([]string, len(tc.scriptPath))
_ = plugin.CallCDN(func(fn plugin.CDN) error {
prefix = fn.GetStaticPrefix()
return nil
})
if prefix != "" {
if prefix[len(prefix)-1:] == "/" {
prefix = strings.TrimSuffix(prefix, "/")
}
cssPath = prefix + tc.cssPath
for i, path := range tc.scriptPath {
scriptPath[i] = prefix + path
}
} else {
cssPath = tc.cssPath
scriptPath = tc.scriptPath
}
data["siteinfo"] = siteInfo
data["baseURL"] = ""
if parsedUrl, err := url.Parse(siteInfo.General.SiteUrl); err == nil {
data["baseURL"] = parsedUrl.Path
}
data["scriptPath"] = scriptPath
data["cssPath"] = cssPath
data["keywords"] = siteInfo.Keywords
if siteInfo.Description == "" {
siteInfo.Description = siteInfo.General.Description
}
data["title"] = siteInfo.Title
if siteInfo.Title == "" {
data["title"] = siteInfo.General.Name
}
data["description"] = siteInfo.Description
data["language"] = handler.GetLangByCtx(ctx)
data["timezone"] = siteInfo.Interface.TimeZone
language := strings.ReplaceAll(siteInfo.Interface.Language, "_", "-")
data["lang"] = language
data["HeadCode"] = siteInfo.CustomCssHtml.CustomHead
data["HeaderCode"] = siteInfo.CustomCssHtml.CustomHeader
data["FooterCode"] = siteInfo.CustomCssHtml.CustomFooter
data["Version"] = constant.Version
data["Revision"] = constant.Revision
_, ok := data["path"]
if !ok {
data["path"] = ""
}
ctx.Header("X-Frame-Options", "DENY")
ctx.HTML(code, tpl, data)
}
func (tc *TemplateController) OpenSearch(ctx *gin.Context) {
if tc.checkPrivateMode(ctx) {
tc.Page404(ctx)
return
}
tc.templateRenderController.OpenSearch(ctx)
}
func (tc *TemplateController) Sitemap(ctx *gin.Context) {
if tc.checkPrivateMode(ctx) {
tc.Page404(ctx)
return
}
tc.templateRenderController.Sitemap(ctx)
}
func (tc *TemplateController) SitemapPage(ctx *gin.Context) {
if tc.checkPrivateMode(ctx) {
tc.Page404(ctx)
return
}
page := 0
pageParam := ctx.Param("page")
pageRegexp := regexp.MustCompile(`question-(.*).xml`)
pageStr := pageRegexp.FindStringSubmatch(pageParam)
if len(pageStr) != 2 {
tc.Page404(ctx)
return
}
page = converter.StringToInt(pageStr[1])
if page == 0 {
tc.Page404(ctx)
return
}
err := tc.templateRenderController.SitemapPage(ctx, page)
if err != nil {
tc.Page404(ctx)
return
}
}
func (tc *TemplateController) checkPrivateMode(ctx *gin.Context) bool {
resp, err := tc.siteInfoService.GetSiteSecurity(ctx)
if err != nil {
log.Error(err)
return false
}
if resp.LoginRequired {
return true
}
return false
}
@@ -0,0 +1,34 @@
/*
* 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 templaterender
import (
"context"
"github.com/apache/answer/internal/schema"
)
func (t *TemplateRenderController) AnswerList(ctx context.Context, req *schema.AnswerListReq) ([]*schema.AnswerInfo, int64, error) {
return t.answerService.SearchList(ctx, req)
}
func (t *TemplateRenderController) AnswerDetail(ctx context.Context, id string) (*schema.AnswerInfo, error) {
return t.answerService.GetDetail(ctx, id)
}
@@ -0,0 +1,57 @@
/*
* 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 templaterender
import (
"context"
"github.com/apache/answer/internal/base/pager"
"github.com/apache/answer/internal/schema"
)
func (t *TemplateRenderController) CommentList(
ctx context.Context,
objectIDs []string,
) (
comments map[string][]*schema.GetCommentResp,
err error,
) {
comments = make(map[string][]*schema.GetCommentResp, len(objectIDs))
for _, objectID := range objectIDs {
var (
req = &schema.GetCommentWithPageReq{
Page: 1,
PageSize: 3,
ObjectID: objectID,
QueryCond: "vote",
UserID: "",
}
pageModel *pager.PageModel
)
pageModel, err = t.commentService.GetCommentWithPage(ctx, req)
if err != nil {
return
}
li := pageModel.List
comments[objectID] = li.([]*schema.GetCommentResp)
}
return
}
@@ -0,0 +1,124 @@
/*
* 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 templaterender
import (
"math"
"github.com/apache/answer/internal/service/content"
questioncommon "github.com/apache/answer/internal/service/question_common"
"github.com/apache/answer/internal/service/comment"
"github.com/apache/answer/internal/service/siteinfo_common"
"github.com/google/wire"
"github.com/apache/answer/internal/schema"
"github.com/apache/answer/internal/service/tag"
)
// ProviderSetTemplateRenderController is template render controller providers.
var ProviderSetTemplateRenderController = wire.NewSet(
NewTemplateRenderController,
)
type TemplateRenderController struct {
questionService *content.QuestionService
userService *content.UserService
tagService *tag.TagService
answerService *content.AnswerService
commentService *comment.CommentService
siteInfoService siteinfo_common.SiteInfoCommonService
questionRepo questioncommon.QuestionRepo
}
func NewTemplateRenderController(
questionService *content.QuestionService,
userService *content.UserService,
tagService *tag.TagService,
answerService *content.AnswerService,
commentService *comment.CommentService,
siteInfoService siteinfo_common.SiteInfoCommonService,
questionRepo questioncommon.QuestionRepo,
) *TemplateRenderController {
return &TemplateRenderController{
questionService: questionService,
userService: userService,
tagService: tagService,
answerService: answerService,
commentService: commentService,
questionRepo: questionRepo,
siteInfoService: siteInfoService,
}
}
// Paginator page
// page : now page
// pageSize : Number per page
// nums : Total
// Returns the contents of the page in the format of 1, 2, 3, 4, and 5. If the contents are less than 5 pages, the page number is returned
func Paginator(page, pageSize int, nums int64) *schema.Paginator {
if pageSize == 0 {
pageSize = 10
}
var prevpage int // Previous page address
var nextpage int // Address on the last page
// Generate the total number of pages based on the total number of nums and the number of prepage pages
totalpages := int(math.Ceil(float64(nums) / float64(pageSize))) // Total number of Pages
if page > totalpages {
page = totalpages
}
if page <= 0 {
page = 1
}
var pages []int
switch {
case page >= totalpages-5 && totalpages > 5: // The last 5 pages
start := totalpages - 5 + 1
prevpage = page - 1
nextpage = int(math.Min(float64(totalpages), float64(page+1)))
pages = make([]int, 5)
for i := range pages {
pages[i] = start + i
}
case page >= 3 && totalpages > 5:
start := page - 3 + 1
pages = make([]int, 5)
for i := range pages {
pages[i] = start + i
}
prevpage = page - 1
nextpage = page + 1
default:
pages = make([]int, int(math.Min(5, float64(totalpages))))
for i := range pages {
pages[i] = i + 1
}
prevpage = int(math.Max(float64(1), float64(page-1)))
nextpage = page + 1
}
paginator := &schema.Paginator{}
paginator.Pages = pages
paginator.Totalpages = totalpages
paginator.Prevpage = prevpage
paginator.Nextpage = nextpage
paginator.Currpage = page
return paginator
}
@@ -0,0 +1,142 @@
/*
* 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 templaterender
import (
"html/template"
"math"
"net/http"
"github.com/apache/answer/internal/base/constant"
"github.com/apache/answer/internal/schema"
"github.com/gin-gonic/gin"
"github.com/segmentfault/pacman/log"
)
func (t *TemplateRenderController) Index(ctx *gin.Context, req *schema.QuestionPageReq) ([]*schema.QuestionPageResp, int64, error) {
return t.questionService.GetQuestionPage(ctx, req)
}
func (t *TemplateRenderController) QuestionDetail(ctx *gin.Context, id string) (resp *schema.QuestionInfoResp, err error) {
return t.questionService.GetQuestion(ctx, id, "", schema.QuestionPermission{})
}
func (t *TemplateRenderController) Sitemap(ctx *gin.Context) {
general, err := t.siteInfoService.GetSiteGeneral(ctx)
if err != nil {
log.Error("get site general failed:", err)
return
}
siteInfo, err := t.siteInfoService.GetSiteSeo(ctx)
if err != nil {
log.Error("get site GetSiteSeo failed:", err)
return
}
questions, err := t.questionRepo.SitemapQuestions(ctx, 1, constant.SitemapMaxSize)
if err != nil {
log.Errorf("get sitemap questions failed: %s", err)
return
}
ctx.Header("Content-Type", "application/xml")
if len(questions) < constant.SitemapMaxSize {
ctx.HTML(
http.StatusOK, "sitemap.xml", gin.H{
"xmlHeader": template.HTML(`<?xml version="1.0" encoding="UTF-8"?>`),
"list": questions,
"general": general,
"hastitle": siteInfo.Permalink == constant.PermalinkQuestionIDAndTitle ||
siteInfo.Permalink == constant.PermalinkQuestionIDAndTitleByShortID,
},
)
return
}
questionNum, err := t.questionRepo.GetQuestionCount(ctx)
if err != nil {
log.Error("GetQuestionCount error", err)
return
}
var pageList []int
totalPages := int(math.Ceil(float64(questionNum) / float64(constant.SitemapMaxSize)))
for i := 1; i <= totalPages; i++ {
pageList = append(pageList, i)
}
ctx.HTML(
http.StatusOK, "sitemap-list.xml", gin.H{
"xmlHeader": template.HTML(`<?xml version="1.0" encoding="UTF-8"?>`),
"page": pageList,
"general": general,
},
)
}
func (t *TemplateRenderController) OpenSearch(ctx *gin.Context) {
general, err := t.siteInfoService.GetSiteGeneral(ctx)
if err != nil {
log.Error("get site general failed:", err)
return
}
favicon := general.SiteUrl + "/favicon.ico"
branding, err := t.siteInfoService.GetSiteBranding(ctx)
if err == nil && len(branding.Favicon) > 0 {
favicon = branding.Favicon
}
ctx.Header("Content-Type", "application/xml")
ctx.HTML(
http.StatusOK, "opensearch.xml", gin.H{
"general": general,
"favicon": favicon,
},
)
}
func (t *TemplateRenderController) SitemapPage(ctx *gin.Context, page int) error {
general, err := t.siteInfoService.GetSiteGeneral(ctx)
if err != nil {
log.Error("get site general failed:", err)
return err
}
siteInfo, err := t.siteInfoService.GetSiteSeo(ctx)
if err != nil {
log.Error("get site GetSiteSeo failed:", err)
return err
}
questions, err := t.questionRepo.SitemapQuestions(ctx, page, constant.SitemapMaxSize)
if err != nil {
log.Errorf("get sitemap questions failed: %s", err)
return err
}
ctx.Header("Content-Type", "application/xml")
ctx.HTML(
http.StatusOK, "sitemap.xml", gin.H{
"xmlHeader": template.HTML(`<?xml version="1.0" encoding="UTF-8"?>`),
"list": questions,
"general": general,
"hastitle": siteInfo.Permalink == constant.PermalinkQuestionIDAndTitle ||
siteInfo.Permalink == constant.PermalinkQuestionIDAndTitleByShortID,
},
)
return nil
}
@@ -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 templaterender
import (
"context"
"github.com/apache/answer/internal/base/pager"
"github.com/apache/answer/internal/schema"
"github.com/jinzhu/copier"
)
func (q *TemplateRenderController) TagList(ctx context.Context, req *schema.GetTagWithPageReq) (resp *pager.PageModel, err error) {
resp, err = q.tagService.GetTagWithPage(ctx, req)
if err != nil {
return
}
return
}
func (q *TemplateRenderController) TagInfo(ctx context.Context, req *schema.GetTamplateTagInfoReq) (resp *schema.GetTagResp, questionList []*schema.QuestionPageResp, questionCount int64, err error) {
dto := &schema.GetTagInfoReq{}
_ = copier.Copy(dto, req)
resp, err = q.tagService.GetTagInfo(ctx, dto)
if err != nil {
return
}
searchQuestion := &schema.QuestionPageReq{}
searchQuestion.Page = req.Page
searchQuestion.PageSize = req.PageSize
searchQuestion.OrderCond = "newest"
searchQuestion.Tag = req.Name
searchQuestion.LoginUserID = req.UserID
questionList, questionCount, err = q.questionService.GetQuestionPage(ctx, searchQuestion)
if err != nil {
return
}
return resp, questionList, questionCount, err
}
@@ -0,0 +1,30 @@
/*
* 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 templaterender
import (
"context"
"github.com/apache/answer/internal/schema"
)
func (q *TemplateRenderController) UserInfo(ctx context.Context, req *schema.GetOtherUserInfoByUsernameReq) (resp *schema.GetOtherUserInfoByUsernameResp, err error) {
return q.userService.GetOtherUserInfoByUsername(ctx, req)
}
+114
View File
@@ -0,0 +1,114 @@
/*
* 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 (
"github.com/apache/answer/internal/base/handler"
"github.com/apache/answer/internal/base/middleware"
"github.com/apache/answer/internal/base/reason"
"github.com/apache/answer/internal/schema"
"github.com/apache/answer/internal/service/uploader"
"github.com/apache/answer/pkg/converter"
"github.com/gin-gonic/gin"
"github.com/segmentfault/pacman/errors"
)
const (
// file is uploaded by markdown(or something else) editor
fileFromPost = "post"
// file is used to upload the post attachment
fileFromPostAttachment = "post_attachment"
// file is used to change the user's avatar
fileFromAvatar = "avatar"
// file is logo/icon images
fileFromBranding = "branding"
)
// UploadController upload controller
type UploadController struct {
uploaderService uploader.UploaderService
}
// NewUploadController new controller
func NewUploadController(uploaderService uploader.UploaderService) *UploadController {
return &UploadController{
uploaderService: uploaderService,
}
}
// UploadFile upload file
// @Summary upload file
// @Description upload file
// @Tags Upload
// @Accept multipart/form-data
// @Security ApiKeyAuth
// @Param source formData string true "identify the source of the file upload" Enums(post, post_attachment, avatar, branding)
// @Param file formData file true "file"
// @Success 200 {object} handler.RespBody{data=string}
// @Router /answer/api/v1/file [post]
func (uc *UploadController) UploadFile(ctx *gin.Context) {
var (
url string
err error
)
source := ctx.PostForm("source")
userID := middleware.GetLoginUserIDFromContext(ctx)
switch source {
case fileFromAvatar:
url, err = uc.uploaderService.UploadAvatarFile(ctx, userID)
case fileFromPost:
url, err = uc.uploaderService.UploadPostFile(ctx, userID)
case fileFromBranding:
if !middleware.GetIsAdminFromContext(ctx) {
handler.HandleResponse(ctx, errors.Forbidden(reason.ForbiddenError), nil)
return
}
url, err = uc.uploaderService.UploadBrandingFile(ctx, userID)
case fileFromPostAttachment:
url, err = uc.uploaderService.UploadPostAttachment(ctx, userID)
default:
handler.HandleResponse(ctx, errors.BadRequest(reason.UploadFileSourceUnsupported), nil)
return
}
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
handler.HandleResponse(ctx, err, url)
}
// PostRender render post content
// @Summary render post content
// @Description render post content
// @Tags Upload
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param data body schema.PostRenderReq true "PostRenderReq"
// @Success 200 {object} handler.RespBody
// @Router /answer/api/v1/post/render [post]
func (uc *UploadController) PostRender(ctx *gin.Context) {
req := &schema.PostRenderReq{}
if handler.BindAndCheck(ctx, req) {
return
}
handler.HandleResponse(ctx, nil, converter.Markdown2HTML(req.Content))
}
+736
View File
@@ -0,0 +1,736 @@
/*
* 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 (
"net/url"
"github.com/apache/answer/internal/base/constant"
"github.com/apache/answer/internal/base/handler"
"github.com/apache/answer/internal/base/middleware"
"github.com/apache/answer/internal/base/reason"
"github.com/apache/answer/internal/base/translator"
"github.com/apache/answer/internal/base/validator"
"github.com/apache/answer/internal/entity"
"github.com/apache/answer/internal/schema"
"github.com/apache/answer/internal/service/action"
"github.com/apache/answer/internal/service/auth"
"github.com/apache/answer/internal/service/content"
"github.com/apache/answer/internal/service/export"
"github.com/apache/answer/internal/service/siteinfo_common"
"github.com/apache/answer/internal/service/user_notification_config"
"github.com/apache/answer/pkg/checker"
"github.com/gin-gonic/gin"
"github.com/segmentfault/pacman/errors"
"github.com/segmentfault/pacman/log"
)
// UserController user controller
type UserController struct {
userService *content.UserService
authService *auth.AuthService
actionService *action.CaptchaService
emailService *export.EmailService
siteInfoCommonService siteinfo_common.SiteInfoCommonService
userNotificationConfigService *user_notification_config.UserNotificationConfigService
}
// NewUserController new controller
func NewUserController(
authService *auth.AuthService,
userService *content.UserService,
actionService *action.CaptchaService,
emailService *export.EmailService,
siteInfoCommonService siteinfo_common.SiteInfoCommonService,
userNotificationConfigService *user_notification_config.UserNotificationConfigService,
) *UserController {
return &UserController{
authService: authService,
userService: userService,
actionService: actionService,
emailService: emailService,
siteInfoCommonService: siteInfoCommonService,
userNotificationConfigService: userNotificationConfigService,
}
}
// GetUserInfoByUserID get user info, if user no login response http code is 200, but user info is null
// @Summary GetUserInfoByUserID
// @Description get user info, if user no login response http code is 200, but user info is null
// @Tags User
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Success 200 {object} handler.RespBody{data=schema.GetCurrentLoginUserInfoResp}
// @Router /answer/api/v1/user/info [get]
func (uc *UserController) GetUserInfoByUserID(ctx *gin.Context) {
token := middleware.ExtractToken(ctx)
if len(token) == 0 {
handler.HandleResponse(ctx, nil, nil)
return
}
// if user is no login return null in data
userInfo, _ := uc.authService.GetUserCacheInfo(ctx, token)
if userInfo == nil {
handler.HandleResponse(ctx, nil, nil)
return
}
resp, err := uc.userService.GetUserInfoByUserID(ctx, token, userInfo.UserID)
uc.setVisitCookies(ctx, userInfo.VisitToken, false)
handler.HandleResponse(ctx, err, resp)
}
// GetOtherUserInfoByUsername godoc
// @Summary GetOtherUserInfoByUsername
// @Description GetOtherUserInfoByUsername
// @Tags User
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param username query string true "username"
// @Success 200 {object} handler.RespBody{data=schema.GetOtherUserInfoResp}
// @Router /answer/api/v1/personal/user/info [get]
func (uc *UserController) GetOtherUserInfoByUsername(ctx *gin.Context) {
req := &schema.GetOtherUserInfoByUsernameReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
req.IsAdmin = middleware.GetUserIsAdminModerator(ctx)
resp, err := uc.userService.GetOtherUserInfoByUsername(ctx, req)
handler.HandleResponse(ctx, err, resp)
}
// UserEmailLogin godoc
// @Summary UserEmailLogin
// @Description UserEmailLogin
// @Tags User
// @Accept json
// @Produce json
// @Param data body schema.UserEmailLoginReq true "UserEmailLogin"
// @Success 200 {object} handler.RespBody{data=schema.UserLoginResp}
// @Router /answer/api/v1/user/login/email [post]
func (uc *UserController) UserEmailLogin(ctx *gin.Context) {
req := &schema.UserEmailLoginReq{}
if handler.BindAndCheck(ctx, req) {
return
}
isAdmin := middleware.GetUserIsAdminModerator(ctx)
if !isAdmin {
captchaPass := uc.actionService.ActionRecordVerifyCaptcha(ctx, entity.CaptchaActionPassword, ctx.ClientIP(), req.CaptchaID, req.CaptchaCode)
if !captchaPass {
errFields := append([]*validator.FormErrorField{}, &validator.FormErrorField{
ErrorField: "captcha_code",
ErrorMsg: translator.Tr(handler.GetLangByCtx(ctx), reason.CaptchaVerificationFailed),
})
handler.HandleResponse(ctx, errors.BadRequest(reason.CaptchaVerificationFailed), errFields)
return
}
}
resp, err := uc.userService.EmailLogin(ctx, req)
if err != nil {
uc.actionService.ActionRecordAdd(ctx, entity.CaptchaActionPassword, ctx.ClientIP())
errFields := append([]*validator.FormErrorField{}, &validator.FormErrorField{
ErrorField: "e_mail",
ErrorMsg: translator.Tr(handler.GetLangByCtx(ctx), reason.EmailOrPasswordWrong),
})
handler.HandleResponse(ctx, errors.BadRequest(reason.EmailOrPasswordWrong), errFields)
return
}
if !isAdmin {
uc.actionService.ActionRecordDel(ctx, entity.CaptchaActionPassword, ctx.ClientIP())
}
if resp.Status == constant.UserSuspended {
handler.HandleResponse(ctx, errors.Forbidden(reason.UserSuspended),
&schema.ForbiddenResp{Type: schema.ForbiddenReasonTypeUserSuspended})
return
}
uc.setVisitCookies(ctx, resp.VisitToken, true)
handler.HandleResponse(ctx, nil, resp)
}
// RetrievePassWord godoc
// @Summary RetrievePassWord
// @Description RetrievePassWord
// @Tags User
// @Accept json
// @Produce json
// @Param data body schema.UserRetrievePassWordRequest true "UserRetrievePassWordRequest"
// @Success 200 {string} string ""
// @Router /answer/api/v1/user/password/reset [post]
func (uc *UserController) RetrievePassWord(ctx *gin.Context) {
req := &schema.UserRetrievePassWordRequest{}
if handler.BindAndCheck(ctx, req) {
return
}
isAdmin := middleware.GetUserIsAdminModerator(ctx)
if !isAdmin {
captchaPass := uc.actionService.ActionRecordVerifyCaptcha(ctx, entity.CaptchaActionEmail, ctx.ClientIP(), req.CaptchaID, req.CaptchaCode)
if !captchaPass {
errFields := append([]*validator.FormErrorField{}, &validator.FormErrorField{
ErrorField: "captcha_code",
ErrorMsg: translator.Tr(handler.GetLangByCtx(ctx), reason.CaptchaVerificationFailed),
})
handler.HandleResponse(ctx, errors.BadRequest(reason.CaptchaVerificationFailed), errFields)
return
}
}
err := uc.userService.RetrievePassWord(ctx, req)
handler.HandleResponse(ctx, err, nil)
}
// UseRePassWord godoc
// @Summary UseRePassWord
// @Description UseRePassWord
// @Tags User
// @Accept json
// @Produce json
// @Param data body schema.UserRePassWordRequest true "UserRePassWordRequest"
// @Success 200 {string} string ""
// @Router /answer/api/v1/user/password/replacement [post]
func (uc *UserController) UseRePassWord(ctx *gin.Context) {
req := &schema.UserRePassWordRequest{}
if handler.BindAndCheck(ctx, req) {
return
}
req.Content = uc.emailService.VerifyUrlExpired(ctx, req.Code)
if len(req.Content) == 0 {
handler.HandleResponse(ctx, errors.Forbidden(reason.EmailVerifyURLExpired),
&schema.ForbiddenResp{Type: schema.ForbiddenReasonTypeURLExpired})
return
}
err := uc.userService.UpdatePasswordWhenForgot(ctx, req)
uc.actionService.ActionRecordDel(ctx, entity.CaptchaActionPassword, ctx.ClientIP())
handler.HandleResponse(ctx, err, nil)
}
// UserLogout user logout
// @Summary user logout
// @Description user logout
// @Security ApiKeyAuth
// @Tags User
// @Accept json
// @Produce json
// @Success 200 {object} handler.RespBody
// @Router /answer/api/v1/user/logout [get]
func (uc *UserController) UserLogout(ctx *gin.Context) {
accessToken := middleware.ExtractToken(ctx)
if len(accessToken) == 0 {
handler.HandleResponse(ctx, nil, nil)
return
}
_ = uc.authService.RemoveUserCacheInfo(ctx, accessToken)
_ = uc.authService.RemoveAdminUserCacheInfo(ctx, accessToken)
visitToken, _ := ctx.Cookie(constant.UserVisitCookiesCacheKey)
_ = uc.authService.RemoveUserVisitCacheInfo(ctx, visitToken)
handler.HandleResponse(ctx, nil, nil)
}
// UserRegisterByEmail godoc
// @Summary UserRegisterByEmail
// @Description UserRegisterByEmail
// @Tags User
// @Accept json
// @Produce json
// @Param data body schema.UserRegisterReq true "UserRegisterReq"
// @Success 200 {object} handler.RespBody{data=schema.UserLoginResp}
// @Router /answer/api/v1/user/register/email [post]
func (uc *UserController) UserRegisterByEmail(ctx *gin.Context) {
// check whether site allow register or not
siteInfo, err := uc.siteInfoCommonService.GetSiteLogin(ctx)
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
if !siteInfo.AllowNewRegistrations || !siteInfo.AllowEmailRegistrations {
handler.HandleResponse(ctx, errors.BadRequest(reason.NotAllowedRegistration), nil)
return
}
req := &schema.UserRegisterReq{}
if handler.BindAndCheck(ctx, req) {
return
}
if !checker.EmailInAllowEmailDomain(req.Email, siteInfo.AllowEmailDomains) {
handler.HandleResponse(ctx, errors.BadRequest(reason.EmailIllegalDomainError), nil)
return
}
req.RequireEmailVerification = siteInfo.RequireEmailVerification
req.IP = ctx.ClientIP()
isAdmin := middleware.GetUserIsAdminModerator(ctx)
if !isAdmin {
captchaPass := uc.actionService.ActionRecordVerifyCaptcha(ctx, entity.CaptchaActionEmail, req.IP, req.CaptchaID, req.CaptchaCode)
if !captchaPass {
errFields := append([]*validator.FormErrorField{}, &validator.FormErrorField{
ErrorField: "captcha_code",
ErrorMsg: translator.Tr(handler.GetLangByCtx(ctx), reason.CaptchaVerificationFailed),
})
handler.HandleResponse(ctx, errors.BadRequest(reason.CaptchaVerificationFailed), errFields)
return
}
}
resp, errFields, err := uc.userService.UserRegisterByEmail(ctx, req)
if len(errFields) > 0 {
for _, field := range errFields {
field.ErrorMsg = translator.
Tr(handler.GetLangByCtx(ctx), field.ErrorMsg)
}
handler.HandleResponse(ctx, err, errFields)
} else {
handler.HandleResponse(ctx, err, resp)
}
}
// UserVerifyEmail godoc
// @Summary UserVerifyEmail
// @Description UserVerifyEmail
// @Tags User
// @Accept json
// @Produce json
// @Param code query string true "code" default()
// @Success 200 {object} handler.RespBody{data=schema.UserLoginResp}
// @Router /answer/api/v1/user/email/verification [post]
func (uc *UserController) UserVerifyEmail(ctx *gin.Context) {
req := &schema.UserVerifyEmailReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.Content = uc.emailService.VerifyUrlExpired(ctx, req.Code)
if len(req.Content) == 0 {
handler.HandleResponse(ctx, errors.Forbidden(reason.EmailVerifyURLExpired),
&schema.ForbiddenResp{Type: schema.ForbiddenReasonTypeURLExpired})
return
}
resp, err := uc.userService.UserVerifyEmail(ctx, req)
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
uc.actionService.ActionRecordDel(ctx, entity.CaptchaActionEmail, ctx.ClientIP())
handler.HandleResponse(ctx, err, resp)
}
// UserVerifyEmailSend godoc
// @Summary UserVerifyEmailSend
// @Description UserVerifyEmailSend
// @Tags User
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param captcha_id query string false "captcha_id" default()
// @Param captcha_code query string false "captcha_code" default()
// @Success 200 {string} string ""
// @Router /answer/api/v1/user/email/verification/send [post]
func (uc *UserController) UserVerifyEmailSend(ctx *gin.Context) {
req := &schema.UserVerifyEmailSendReq{}
if handler.BindAndCheck(ctx, req) {
return
}
userInfo := middleware.GetUserInfoFromContext(ctx)
if userInfo == nil {
handler.HandleResponse(ctx, errors.Unauthorized(reason.UnauthorizedError), nil)
return
}
isAdmin := middleware.GetUserIsAdminModerator(ctx)
if !isAdmin {
captchaPass := uc.actionService.ActionRecordVerifyCaptcha(ctx, entity.CaptchaActionEmail, ctx.ClientIP(), req.CaptchaID, req.CaptchaCode)
if !captchaPass {
errFields := append([]*validator.FormErrorField{}, &validator.FormErrorField{
ErrorField: "captcha_code",
ErrorMsg: translator.Tr(handler.GetLangByCtx(ctx), reason.CaptchaVerificationFailed),
})
handler.HandleResponse(ctx, errors.BadRequest(reason.CaptchaVerificationFailed), errFields)
return
}
}
err := uc.userService.UserVerifyEmailSend(ctx, userInfo.UserID)
handler.HandleResponse(ctx, err, nil)
}
// UserModifyPassWord godoc
// @Summary UserModifyPassWord
// @Description UserModifyPassWord
// @Tags User
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param data body schema.UserModifyPasswordReq true "UserModifyPasswordReq"
// @Success 200 {object} handler.RespBody
// @Router /answer/api/v1/user/password [put]
func (uc *UserController) UserModifyPassWord(ctx *gin.Context) {
req := &schema.UserModifyPasswordReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
req.AccessToken = middleware.ExtractToken(ctx)
isAdmin := middleware.GetUserIsAdminModerator(ctx)
if !isAdmin {
captchaPass := uc.actionService.ActionRecordVerifyCaptcha(ctx, entity.CaptchaActionEditUserinfo, req.UserID,
req.CaptchaID, req.CaptchaCode)
if !captchaPass {
errFields := append([]*validator.FormErrorField{}, &validator.FormErrorField{
ErrorField: "captcha_code",
ErrorMsg: translator.Tr(handler.GetLangByCtx(ctx), reason.CaptchaVerificationFailed),
})
handler.HandleResponse(ctx, errors.BadRequest(reason.CaptchaVerificationFailed), errFields)
return
}
uc.actionService.ActionRecordAdd(ctx, entity.CaptchaActionEditUserinfo, req.UserID)
}
oldPassVerification, err := uc.userService.UserModifyPassWordVerification(ctx, req)
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
if !oldPassVerification {
errFields := append([]*validator.FormErrorField{}, &validator.FormErrorField{
ErrorField: "old_pass",
ErrorMsg: translator.Tr(handler.GetLangByCtx(ctx), reason.OldPasswordVerificationFailed),
})
handler.HandleResponse(ctx, errors.BadRequest(reason.OldPasswordVerificationFailed), errFields)
return
}
if req.OldPass == req.Pass {
errFields := append([]*validator.FormErrorField{}, &validator.FormErrorField{
ErrorField: "pass",
ErrorMsg: translator.Tr(handler.GetLangByCtx(ctx), reason.NewPasswordSameAsPreviousSetting),
})
handler.HandleResponse(ctx, errors.BadRequest(reason.NewPasswordSameAsPreviousSetting), errFields)
return
}
err = uc.userService.UserModifyPassword(ctx, req)
if err == nil {
uc.actionService.ActionRecordDel(ctx, entity.CaptchaActionEditUserinfo, req.UserID)
}
handler.HandleResponse(ctx, err, nil)
}
// UserUpdateInfo update user info
// @Summary UserUpdateInfo update user info
// @Description UserUpdateInfo update user info
// @Tags User
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param Authorization header string true "access-token"
// @Param data body schema.UpdateInfoRequest true "UpdateInfoRequest"
// @Success 200 {object} handler.RespBody
// @Router /answer/api/v1/user/info [put]
func (uc *UserController) UserUpdateInfo(ctx *gin.Context) {
req := &schema.UpdateInfoRequest{}
if handler.BindAndCheck(ctx, req) {
return
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
req.IsAdmin = middleware.GetUserIsAdminModerator(ctx)
errFields, err := uc.userService.UpdateInfo(ctx, req)
for _, field := range errFields {
field.ErrorMsg = translator.Tr(handler.GetLangByCtx(ctx), field.ErrorMsg)
}
handler.HandleResponse(ctx, err, errFields)
}
// UserUpdateInterface update user interface config
// @Summary UserUpdateInterface update user interface config
// @Description UserUpdateInterface update user interface config
// @Tags User
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param Authorization header string true "access-token"
// @Param data body schema.UpdateUserInterfaceRequest true "UpdateInfoRequest"
// @Success 200 {object} handler.RespBody
// @Router /answer/api/v1/user/interface [put]
func (uc *UserController) UserUpdateInterface(ctx *gin.Context) {
req := &schema.UpdateUserInterfaceRequest{}
if handler.BindAndCheck(ctx, req) {
return
}
req.UserId = middleware.GetLoginUserIDFromContext(ctx)
err := uc.userService.UserUpdateInterface(ctx, req)
handler.HandleResponse(ctx, err, nil)
}
// ActionRecord godoc
// @Summary ActionRecord
// @Description ActionRecord
// @Tags User
// @Param action query string true "action" Enums(login, e_mail, find_pass)
// @Security ApiKeyAuth
// @Success 200 {object} handler.RespBody{data=schema.ActionRecordResp}
// @Router /answer/api/v1/user/action/record [get]
func (uc *UserController) ActionRecord(ctx *gin.Context) {
req := &schema.ActionRecordReq{}
if handler.BindAndCheck(ctx, req) {
return
}
userinfo := middleware.GetUserInfoFromContext(ctx)
if userinfo != nil {
req.UserID = userinfo.UserID
}
req.IP = ctx.ClientIP()
resp := &schema.ActionRecordResp{}
isAdmin := middleware.GetUserIsAdminModerator(ctx)
if isAdmin {
resp.Verify = false
handler.HandleResponse(ctx, nil, resp)
} else {
resp, err := uc.actionService.ActionRecord(ctx, req)
handler.HandleResponse(ctx, err, resp)
}
}
// GetUserNotificationConfig get user's notification config
// @Summary get user's notification config
// @Description get user's notification config
// @Tags User
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Success 200 {object} handler.RespBody{data=schema.GetUserNotificationConfigResp}
// @Router /answer/api/v1/user/notification/config [post]
func (uc *UserController) GetUserNotificationConfig(ctx *gin.Context) {
userID := middleware.GetLoginUserIDFromContext(ctx)
resp, err := uc.userNotificationConfigService.GetUserNotificationConfig(ctx, userID)
handler.HandleResponse(ctx, err, resp)
}
// UpdateUserNotificationConfig update user's notification config
// @Summary update user's notification config
// @Description update user's notification config
// @Tags User
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param data body schema.UpdateUserNotificationConfigReq true "UpdateUserNotificationConfigReq"
// @Success 200 {object} handler.RespBody{}
// @Router /answer/api/v1/user/notification/config [put]
func (uc *UserController) UpdateUserNotificationConfig(ctx *gin.Context) {
req := &schema.UpdateUserNotificationConfigReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
err := uc.userNotificationConfigService.UpdateUserNotificationConfig(ctx, req)
handler.HandleResponse(ctx, err, nil)
}
// UserChangeEmailSendCode send email to the user email then change their email
// @Summary send email to the user email then change their email
// @Description send email to the user email then change their email
// @Security ApiKeyAuth
// @Tags User
// @Accept json
// @Produce json
// @Param data body schema.UserChangeEmailSendCodeReq true "UserChangeEmailSendCodeReq"
// @Success 200 {object} handler.RespBody{}
// @Router /answer/api/v1/user/email/change/code [post]
func (uc *UserController) UserChangeEmailSendCode(ctx *gin.Context) {
req := &schema.UserChangeEmailSendCodeReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
// If the user is not logged in, the api cannot be used.
// If the user email is not verified, that also can use this api to modify the email.
if len(req.UserID) == 0 {
handler.HandleResponse(ctx, errors.Unauthorized(reason.UnauthorizedError), nil)
return
}
// check whether email allow register or not
siteInfo, err := uc.siteInfoCommonService.GetSiteLogin(ctx)
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
if !checker.EmailInAllowEmailDomain(req.Email, siteInfo.AllowEmailDomains) {
handler.HandleResponse(ctx, errors.BadRequest(reason.EmailIllegalDomainError), nil)
return
}
isAdmin := middleware.GetUserIsAdminModerator(ctx)
if !isAdmin {
captchaPass := uc.actionService.ActionRecordVerifyCaptcha(ctx, entity.CaptchaActionEditUserinfo, req.UserID, req.CaptchaID, req.CaptchaCode)
uc.actionService.ActionRecordAdd(ctx, entity.CaptchaActionEditUserinfo, req.UserID)
if !captchaPass {
errFields := append([]*validator.FormErrorField{}, &validator.FormErrorField{
ErrorField: "captcha_code",
ErrorMsg: translator.Tr(handler.GetLangByCtx(ctx), reason.CaptchaVerificationFailed),
})
handler.HandleResponse(ctx, errors.BadRequest(reason.CaptchaVerificationFailed), errFields)
return
}
}
resp, err := uc.userService.UserChangeEmailSendCode(ctx, req)
if err != nil {
handler.HandleResponse(ctx, err, resp)
return
}
if !isAdmin {
uc.actionService.ActionRecordDel(ctx, entity.CaptchaActionEditUserinfo, ctx.ClientIP())
}
handler.HandleResponse(ctx, err, nil)
}
// UserChangeEmailVerify user change email verification
// @Summary user change email verification
// @Description user change email verification
// @Tags User
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param data body schema.UserChangeEmailVerifyReq true "UserChangeEmailVerifyReq"
// @Success 200 {object} handler.RespBody{}
// @Router /answer/api/v1/user/email [put]
func (uc *UserController) UserChangeEmailVerify(ctx *gin.Context) {
req := &schema.UserChangeEmailVerifyReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.Content = uc.emailService.VerifyUrlExpired(ctx, req.Code)
if len(req.Content) == 0 {
handler.HandleResponse(ctx, errors.Forbidden(reason.EmailVerifyURLExpired),
&schema.ForbiddenResp{Type: schema.ForbiddenReasonTypeURLExpired})
return
}
resp, err := uc.userService.UserChangeEmailVerify(ctx, req.Content)
uc.actionService.ActionRecordDel(ctx, entity.CaptchaActionEmail, ctx.ClientIP())
handler.HandleResponse(ctx, err, resp)
}
// UserRanking get user ranking
// @Summary get user ranking
// @Description get user ranking
// @Tags User
// @Accept json
// @Produce json
// @Success 200 {object} handler.RespBody{data=schema.UserRankingResp}
// @Router /answer/api/v1/user/ranking [get]
func (uc *UserController) UserRanking(ctx *gin.Context) {
resp, err := uc.userService.UserRanking(ctx)
handler.HandleResponse(ctx, err, resp)
}
// UserStaff get user staff
// @Summary get user staff
// @Description get user staff
// @Tags User
// @Accept json
// @Produce json
// @Param username query string true "username"
// @Param page_size query string true "page_size"
// @Success 200 {object} handler.RespBody{data=schema.GetUserStaffResp}
// @Router /answer/api/v1/user/staff [get]
func (uc *UserController) UserStaff(ctx *gin.Context) {
req := &schema.GetUserStaffReq{}
if handler.BindAndCheck(ctx, req) {
return
}
resp, err := uc.userService.GetUserStaff(ctx, req)
handler.HandleResponse(ctx, err, resp)
}
// UserUnsubscribeNotification unsubscribe notification
// @Summary unsubscribe notification
// @Description unsubscribe notification
// @Tags User
// @Accept json
// @Produce json
// @Param data body schema.UserUnsubscribeNotificationReq true "UserUnsubscribeNotificationReq"
// @Success 200 {object} handler.RespBody{}
// @Router /answer/api/v1/user/notification/unsubscribe [put]
func (uc *UserController) UserUnsubscribeNotification(ctx *gin.Context) {
req := &schema.UserUnsubscribeNotificationReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.Content = uc.emailService.VerifyUrlExpired(ctx, req.Code)
if len(req.Content) == 0 {
handler.HandleResponse(ctx, errors.Forbidden(reason.EmailVerifyURLExpired),
&schema.ForbiddenResp{Type: schema.ForbiddenReasonTypeURLExpired})
return
}
err := uc.userService.UserUnsubscribeNotification(ctx, req)
handler.HandleResponse(ctx, err, nil)
}
// SearchUserListByName godoc
// @Summary SearchUserListByName
// @Description SearchUserListByName
// @Tags User
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param username query string true "username"
// @Success 200 {object} handler.RespBody{data=schema.GetOtherUserInfoResp}
// @Router /answer/api/v1/user/info/search [get]
func (uc *UserController) SearchUserListByName(ctx *gin.Context) {
req := &schema.GetOtherUserInfoByUsernameReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
resp, err := uc.userService.SearchUserListByName(ctx, req)
handler.HandleResponse(ctx, err, resp)
}
func (uc *UserController) setVisitCookies(ctx *gin.Context, visitToken string, force bool) {
if !force {
cookie, _ := ctx.Cookie(constant.UserVisitCookiesCacheKey)
// If the cookie is the same as the visitToken, no need to set it again
if cookie == visitToken {
return
}
}
general, err := uc.siteInfoCommonService.GetSiteGeneral(ctx)
if err != nil {
log.Errorf("get site general error: %v", err)
return
}
parsedURL, err := url.Parse(general.SiteUrl)
if err != nil {
log.Errorf("parse url error: %v", err)
return
}
ctx.SetCookie(constant.UserVisitCookiesCacheKey,
visitToken, constant.UserVisitCacheTime, "/", parsedURL.Hostname(), true, true)
}
@@ -0,0 +1,154 @@
/*
* 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 (
"encoding/json"
"net/http"
"github.com/apache/answer/internal/base/middleware"
"github.com/apache/answer/internal/base/reason"
"github.com/segmentfault/pacman/errors"
"github.com/apache/answer/internal/base/handler"
"github.com/apache/answer/internal/schema"
"github.com/apache/answer/internal/service/plugin_common"
"github.com/apache/answer/plugin"
"github.com/gin-gonic/gin"
)
// UserPluginController role controller
type UserPluginController struct {
pluginCommonService *plugin_common.PluginCommonService
}
// NewUserPluginController new controller
func NewUserPluginController(pluginCommonService *plugin_common.PluginCommonService) *UserPluginController {
return &UserPluginController{pluginCommonService: pluginCommonService}
}
// GetUserPluginList get plugin list that used for user.
// @Summary get plugin list that used for user.
// @Description get plugin list that used for user.
// @Tags UserPlugin
// @Security ApiKeyAuth
// @Accept json
// @Produce json
// @Success 200 {object} handler.RespBody{data=[]schema.GetUserPluginListResp}
// @Router /answer/api/v1/user/plugin/configs [get]
func (pc *UserPluginController) GetUserPluginList(ctx *gin.Context) {
resp := make([]*schema.GetUserPluginListResp, 0)
_ = plugin.CallUserConfig(func(base plugin.UserConfig) error {
info := base.Info()
if plugin.StatusManager.IsEnabled(info.SlugName) {
resp = append(resp, &schema.GetUserPluginListResp{
Name: info.Name.Translate(ctx),
SlugName: info.SlugName,
})
}
return nil
})
handler.HandleResponse(ctx, nil, resp)
}
// GetUserPluginConfig get user plugin config
// @Summary get user plugin config
// @Description get user plugin config
// @Tags UserPlugin
// @Security ApiKeyAuth
// @Produce json
// @Param plugin_slug_name query string true "plugin_slug_name"
// @Success 200 {object} handler.RespBody{data=schema.GetPluginConfigResp}
// @Router /answer/api/v1/user/plugin/config [get]
func (pc *UserPluginController) GetUserPluginConfig(ctx *gin.Context) {
req := &schema.GetUserPluginConfigReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
resp := &schema.GetUserPluginConfigResp{}
_ = plugin.CallUserConfig(func(fn plugin.UserConfig) error {
if fn.Info().SlugName != req.PluginSlugName {
return nil
}
info := fn.Info()
resp.Name = info.Name.Translate(ctx)
resp.SlugName = info.SlugName
resp.SetConfigFields(ctx, fn.UserConfigFields())
return nil
})
configValue, err := pc.pluginCommonService.GetUserPluginConfig(ctx, req)
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
if len(configValue) > 0 {
configValueMapping := make(map[string]any)
_ = json.Unmarshal([]byte(configValue), &configValueMapping)
for _, field := range resp.ConfigFields {
if value, ok := configValueMapping[field.Name]; ok {
field.Value = value
}
}
}
handler.HandleResponse(ctx, err, resp)
}
// UpdatePluginUserConfig update user plugin config
// @Summary update user plugin config
// @Description update user plugin config
// @Tags UserPlugin
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param data body schema.UpdateUserPluginConfigReq true "UpdatePluginConfigReq"
// @Success 200 {object} handler.RespBody
// @Router /answer/api/v1/user/plugin/config [put]
func (pc *UserPluginController) UpdatePluginUserConfig(ctx *gin.Context) {
req := &schema.UpdateUserPluginConfigReq{}
if handler.BindAndCheck(ctx, req) {
return
}
if !plugin.StatusManager.IsEnabled(req.PluginSlugName) {
handler.HandleResponse(ctx, errors.New(http.StatusBadRequest, reason.RequestFormatError), nil)
return
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
configFields, _ := json.Marshal(req.ConfigFields)
err := plugin.CallUserConfig(func(fn plugin.UserConfig) error {
if fn.Info().SlugName == req.PluginSlugName {
return fn.UserConfigReceiver(req.UserID, configFields)
}
return nil
})
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
err = pc.pluginCommonService.UpdatePluginUserConfig(ctx, req)
handler.HandleResponse(ctx, err, nil)
}
+186
View File
@@ -0,0 +1,186 @@
/*
* 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 (
"github.com/apache/answer/internal/base/handler"
"github.com/apache/answer/internal/base/middleware"
"github.com/apache/answer/internal/base/reason"
"github.com/apache/answer/internal/base/translator"
"github.com/apache/answer/internal/base/validator"
"github.com/apache/answer/internal/entity"
"github.com/apache/answer/internal/schema"
"github.com/apache/answer/internal/service/action"
"github.com/apache/answer/internal/service/content"
"github.com/apache/answer/internal/service/rank"
"github.com/apache/answer/pkg/uid"
"github.com/gin-gonic/gin"
"github.com/segmentfault/pacman/errors"
)
// VoteController activity controller
type VoteController struct {
VoteService *content.VoteService
rankService *rank.RankService
actionService *action.CaptchaService
}
// NewVoteController new controller
func NewVoteController(
voteService *content.VoteService,
rankService *rank.RankService,
actionService *action.CaptchaService,
) *VoteController {
return &VoteController{
VoteService: voteService,
rankService: rankService,
actionService: actionService,
}
}
// VoteUp godoc
// @Summary vote up
// @Description add vote
// @Tags Activity
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param data body schema.VoteReq true "vote"
// @Success 200 {object} handler.RespBody{data=schema.VoteResp}
// @Router /answer/api/v1/vote/up [post]
func (vc *VoteController) VoteUp(ctx *gin.Context) {
req := &schema.VoteReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.ObjectID = uid.DeShortID(req.ObjectID)
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
can, needRank, err := vc.rankService.CheckVotePermission(ctx, req.UserID, req.ObjectID, true)
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
if !can {
lang := handler.GetLangByCtx(ctx)
msg := translator.TrWithData(lang, reason.NoEnoughRankToOperate, &schema.PermissionTrTplData{Rank: needRank})
handler.HandleResponse(ctx, errors.Forbidden(reason.NoEnoughRankToOperate).WithMsg(msg), nil)
return
}
isAdmin := middleware.GetUserIsAdminModerator(ctx)
if !isAdmin {
captchaPass := vc.actionService.ActionRecordVerifyCaptcha(ctx, entity.CaptchaActionVote, req.UserID, req.CaptchaID, req.CaptchaCode)
if !captchaPass {
errFields := append([]*validator.FormErrorField{}, &validator.FormErrorField{
ErrorField: "captcha_code",
ErrorMsg: translator.Tr(handler.GetLangByCtx(ctx), reason.CaptchaVerificationFailed),
})
handler.HandleResponse(ctx, errors.BadRequest(reason.CaptchaVerificationFailed), errFields)
return
}
}
if !isAdmin {
vc.actionService.ActionRecordAdd(ctx, entity.CaptchaActionVote, req.UserID)
}
resp, err := vc.VoteService.VoteUp(ctx, req)
if err != nil {
handler.HandleResponse(ctx, err, schema.ErrTypeToast)
} else {
handler.HandleResponse(ctx, err, resp)
}
}
// VoteDown godoc
// @Summary vote down
// @Description add vote
// @Tags Activity
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param data body schema.VoteReq true "vote"
// @Success 200 {object} handler.RespBody{data=schema.VoteResp}
// @Router /answer/api/v1/vote/down [post]
func (vc *VoteController) VoteDown(ctx *gin.Context) {
req := &schema.VoteReq{}
if handler.BindAndCheck(ctx, req) {
return
}
req.ObjectID = uid.DeShortID(req.ObjectID)
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
isAdmin := middleware.GetUserIsAdminModerator(ctx)
can, needRank, err := vc.rankService.CheckVotePermission(ctx, req.UserID, req.ObjectID, false)
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
if !can {
lang := handler.GetLangByCtx(ctx)
msg := translator.TrWithData(lang, reason.NoEnoughRankToOperate, &schema.PermissionTrTplData{Rank: needRank})
handler.HandleResponse(ctx, errors.Forbidden(reason.NoEnoughRankToOperate).WithMsg(msg), nil)
return
}
if !isAdmin {
captchaPass := vc.actionService.ActionRecordVerifyCaptcha(ctx, entity.CaptchaActionVote, req.UserID, req.CaptchaID, req.CaptchaCode)
if !captchaPass {
errFields := append([]*validator.FormErrorField{}, &validator.FormErrorField{
ErrorField: "captcha_code",
ErrorMsg: translator.Tr(handler.GetLangByCtx(ctx), reason.CaptchaVerificationFailed),
})
handler.HandleResponse(ctx, errors.BadRequest(reason.CaptchaVerificationFailed), errFields)
return
}
}
if !isAdmin {
vc.actionService.ActionRecordAdd(ctx, entity.CaptchaActionVote, req.UserID)
}
resp, err := vc.VoteService.VoteDown(ctx, req)
if err != nil {
handler.HandleResponse(ctx, err, schema.ErrTypeToast)
} else {
handler.HandleResponse(ctx, err, resp)
}
}
// UserVotes user votes
// @Summary get user personal votes
// @Description get user personal votes
// @Tags Activity
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param page query int false "page size"
// @Param page_size query int false "page size"
// @Success 200 {object} handler.RespBody{data=pager.PageModel{list=[]schema.GetVoteWithPageResp}}
// @Router /answer/api/v1/personal/vote/page [get]
func (vc *VoteController) UserVotes(ctx *gin.Context) {
req := schema.GetVoteWithPageReq{}
if handler.BindAndCheck(ctx, &req) {
return
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
resp, err := vc.VoteService.ListUserVotes(ctx, req)
handler.HandleResponse(ctx, err, resp)
}