chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* 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 conf
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/apache/answer/internal/base/data"
|
||||
"github.com/apache/answer/internal/base/path"
|
||||
"github.com/apache/answer/internal/base/server"
|
||||
"github.com/apache/answer/internal/base/translator"
|
||||
"github.com/apache/answer/internal/router"
|
||||
"github.com/apache/answer/internal/service/service_config"
|
||||
"github.com/apache/answer/pkg/writer"
|
||||
"github.com/segmentfault/pacman/contrib/conf/viper"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// AllConfig all config
|
||||
type AllConfig struct {
|
||||
Debug bool `json:"debug" mapstructure:"debug" yaml:"debug"`
|
||||
Server *Server `json:"server" mapstructure:"server" yaml:"server"`
|
||||
Data *Data `json:"data" mapstructure:"data" yaml:"data"`
|
||||
I18n *translator.I18n `json:"i18n" mapstructure:"i18n" yaml:"i18n"`
|
||||
ServiceConfig *service_config.ServiceConfig `json:"service_config" mapstructure:"service_config" yaml:"service_config"`
|
||||
Swaggerui *router.SwaggerConfig `json:"swaggerui" mapstructure:"swaggerui" yaml:"swaggerui"`
|
||||
UI *server.UI `json:"ui" mapstructure:"ui" yaml:"ui"`
|
||||
}
|
||||
|
||||
type envConfigOverrides struct {
|
||||
SwaggerHost string
|
||||
SwaggerAddressPort string
|
||||
SiteAddr string
|
||||
}
|
||||
|
||||
func loadEnvs() (envOverrides *envConfigOverrides) {
|
||||
return &envConfigOverrides{
|
||||
SwaggerHost: os.Getenv("SWAGGER_HOST"),
|
||||
SwaggerAddressPort: os.Getenv("SWAGGER_ADDRESS_PORT"),
|
||||
SiteAddr: os.Getenv("SITE_ADDR"),
|
||||
}
|
||||
}
|
||||
|
||||
type PathIgnore struct {
|
||||
Users []string `yaml:"users"`
|
||||
}
|
||||
|
||||
// Server server config
|
||||
type Server struct {
|
||||
HTTP *server.HTTP `json:"http" mapstructure:"http" yaml:"http"`
|
||||
}
|
||||
|
||||
// Data data config
|
||||
type Data struct {
|
||||
Database *data.Database `json:"database" mapstructure:"database" yaml:"database"`
|
||||
Cache *data.CacheConf `json:"cache" mapstructure:"cache" yaml:"cache"`
|
||||
}
|
||||
|
||||
// SetDefault set default config
|
||||
func (c *AllConfig) SetDefault() {
|
||||
if c.UI == nil {
|
||||
c.UI = &server.UI{}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *AllConfig) SetEnvironmentOverrides() {
|
||||
envs := loadEnvs()
|
||||
if envs.SiteAddr != "" {
|
||||
c.Server.HTTP.Addr = envs.SiteAddr
|
||||
}
|
||||
if envs.SwaggerHost != "" {
|
||||
c.Swaggerui.Host = envs.SwaggerHost
|
||||
}
|
||||
if envs.SwaggerAddressPort != "" {
|
||||
c.Swaggerui.Address = envs.SwaggerAddressPort
|
||||
}
|
||||
}
|
||||
|
||||
// ReadConfig read config
|
||||
func ReadConfig(configFilePath string) (c *AllConfig, err error) {
|
||||
if len(configFilePath) == 0 {
|
||||
configFilePath = filepath.Join(path.ConfigFileDir, path.DefaultConfigFileName)
|
||||
}
|
||||
c = &AllConfig{}
|
||||
config, err := viper.NewWithPath(configFilePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = config.Parse(&c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.SetDefault()
|
||||
c.SetEnvironmentOverrides()
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// RewriteConfig rewrite config file path
|
||||
func RewriteConfig(configFilePath string, allConfig *AllConfig) error {
|
||||
buf := bytes.Buffer{}
|
||||
enc := yaml.NewEncoder(&buf)
|
||||
defer func() {
|
||||
_ = enc.Close()
|
||||
}()
|
||||
enc.SetIndent(2)
|
||||
if err := enc.Encode(allConfig); err != nil {
|
||||
return err
|
||||
}
|
||||
return writer.ReplaceFile(configFilePath, buf.String())
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* 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 constant
|
||||
|
||||
type ActivityTypeKey string
|
||||
|
||||
const (
|
||||
ActEdited = "edited"
|
||||
ActClosed = "closed"
|
||||
ActVotedDown = "voted_down"
|
||||
ActVotedUp = "voted_up"
|
||||
ActVoteDown = "vote_down"
|
||||
ActVoteUp = "vote_up"
|
||||
ActUpVote = "upvote"
|
||||
ActDownVote = "downvote"
|
||||
ActFollow = "follow"
|
||||
ActAccepted = "accepted"
|
||||
ActAccept = "accept"
|
||||
ActPin = "pin"
|
||||
ActUnPin = "unpin"
|
||||
ActShow = "show"
|
||||
ActHide = "hide"
|
||||
)
|
||||
|
||||
const (
|
||||
ActQuestionAsked ActivityTypeKey = "question.asked"
|
||||
ActQuestionClosed ActivityTypeKey = "question.closed"
|
||||
ActQuestionReopened ActivityTypeKey = "question.reopened"
|
||||
ActQuestionAnswered ActivityTypeKey = "question.answered"
|
||||
ActQuestionCommented ActivityTypeKey = "question.commented"
|
||||
ActQuestionAccept ActivityTypeKey = "question.accept"
|
||||
ActQuestionUpvote ActivityTypeKey = "question.upvote"
|
||||
ActQuestionDownVote ActivityTypeKey = "question.downvote"
|
||||
ActQuestionEdited ActivityTypeKey = "question.edited"
|
||||
ActQuestionRollback ActivityTypeKey = "question.rollback"
|
||||
ActQuestionDeleted ActivityTypeKey = "question.deleted"
|
||||
ActQuestionUndeleted ActivityTypeKey = "question.undeleted"
|
||||
ActQuestionPin ActivityTypeKey = "question.pin"
|
||||
ActQuestionUnPin ActivityTypeKey = "question.unpin"
|
||||
ActQuestionHide ActivityTypeKey = "question.hide"
|
||||
ActQuestionShow ActivityTypeKey = "question.show"
|
||||
)
|
||||
|
||||
const (
|
||||
ActAnswerAnswered ActivityTypeKey = "answer.answered"
|
||||
ActAnswerCommented ActivityTypeKey = "answer.commented"
|
||||
ActAnswerAccept ActivityTypeKey = "answer.accept"
|
||||
ActAnswerUpvote ActivityTypeKey = "answer.upvote"
|
||||
ActAnswerDownVote ActivityTypeKey = "answer.downvote"
|
||||
ActAnswerEdited ActivityTypeKey = "answer.edited"
|
||||
ActAnswerRollback ActivityTypeKey = "answer.rollback"
|
||||
ActAnswerDeleted ActivityTypeKey = "answer.deleted"
|
||||
ActAnswerUndeleted ActivityTypeKey = "answer.undeleted"
|
||||
)
|
||||
|
||||
const (
|
||||
ActTagCreated ActivityTypeKey = "tag.created"
|
||||
ActTagEdited ActivityTypeKey = "tag.edited"
|
||||
ActTagRollback ActivityTypeKey = "tag.rollback"
|
||||
ActTagDeleted ActivityTypeKey = "tag.deleted"
|
||||
ActTagUndeleted ActivityTypeKey = "tag.undeleted"
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* 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 constant
|
||||
|
||||
const (
|
||||
AIConfigProvider = "ai_config.provider"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultAIPromptConfigZhCN = `你是一个智能助手,可以帮助用户查询系统中的信息。用户问题:%s
|
||||
|
||||
你可以使用以下工具来查询系统信息:
|
||||
- get_questions: 搜索系统中已存在的问题,使用这个工具可以获取问题列表后注意需要使用 get_answers_by_question_id 获取问题的答案
|
||||
- get_answers_by_question_id: 根据问题ID获取该问题的所有答案
|
||||
- get_comments: 搜索评论信息
|
||||
- get_tags: 搜索标签信息
|
||||
- get_tag_detail: 获取特定标签的详细信息
|
||||
- get_user: 搜索用户信息
|
||||
- semantic_search: 通过语义相似度搜索问题和答案。当用户的问题与现有内容概念相关但可能不匹配确切关键词时使用此工具。当 get_questions 关键词搜索返回较差结果时,请使用 semantic_search。
|
||||
|
||||
请根据用户的问题智能地使用这些工具来提供准确的答案。如果需要查询系统信息,请先使用相应的工具获取数据。`
|
||||
DefaultAIPromptConfigEnUS = `You are an intelligent assistant that can help users query information in the system. User question: %s
|
||||
|
||||
You can use the following tools to query system information:
|
||||
- get_questions: Search for existing questions in the system. After using this tool to get the question list, you need to use get_answers_by_question_id to get the answers to the questions
|
||||
- get_answers_by_question_id: Get all answers for a question based on question ID
|
||||
- get_comments: Search for comment information
|
||||
- get_tags: Search for tag information
|
||||
- get_tag_detail: Get detailed information about a specific tag
|
||||
- get_user: Search for user information
|
||||
- semantic_search: Search questions and answers by semantic meaning. Use this when the user's question relates conceptually to existing content but may not match exact keywords. When get_questions keyword search returns poor results, use semantic_search instead.
|
||||
|
||||
Please intelligently use these tools based on the user's question to provide accurate answers. If you need to query system information, please use the appropriate tools to get the data first.`
|
||||
)
|
||||
@@ -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 constant
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
UserStatusChangedCacheKey = "answer:user:status:"
|
||||
UserStatusChangedCacheTime = 7 * 24 * time.Hour
|
||||
UserTokenCacheKey = "answer:user:token:"
|
||||
UserTokenCacheTime = 7 * 24 * time.Hour
|
||||
UserVisitTokenCacheKey = "answer:user:visit:"
|
||||
UserVisitCacheTime = 7 * 24 * 60 * 60
|
||||
UserVisitCookiesCacheKey = "visit"
|
||||
AdminTokenCacheKey = "answer:admin:token:"
|
||||
AdminTokenCacheTime = 7 * 24 * time.Hour
|
||||
UserTokenMappingCacheKey = "answer:user-token:mapping:"
|
||||
UserEmailCodeCacheKey = "answer:user:email-code:"
|
||||
UserEmailCodeCacheTime = 10 * time.Minute
|
||||
UserLatestEmailCodeCacheKey = "answer:user-id:email-code:"
|
||||
SiteInfoCacheKey = "answer:site-info:"
|
||||
SiteInfoCacheTime = 1 * time.Hour
|
||||
ConfigID2KEYCacheKeyPrefix = "answer:config:id:"
|
||||
ConfigKEY2ContentCacheKeyPrefix = "answer:config:key:"
|
||||
ConfigCacheTime = 1 * time.Hour
|
||||
ConnectorUserExternalInfoCacheKey = "answer:connector:"
|
||||
ConnectorUserExternalInfoCacheTime = 10 * time.Minute
|
||||
ConnectorOAuthStateCacheKey = "answer:connector:oauth-state:"
|
||||
ConnectorOAuthStateCacheTime = 10 * time.Minute
|
||||
ConnectorOAuthBindStateCacheTime = 5 * time.Minute
|
||||
SiteMapQuestionCacheKeyPrefix = "answer:sitemap:question:%d"
|
||||
SiteMapQuestionCacheTime = time.Hour
|
||||
SitemapMaxSize = 50000
|
||||
NewQuestionNotificationLimitCacheKeyPrefix = "answer:new-question-notification-limit:"
|
||||
NewQuestionNotificationLimitCacheTime = 7 * 24 * time.Hour
|
||||
NewQuestionNotificationLimitMax = 50
|
||||
RateLimitCacheKeyPrefix = "answer:rate-limit:"
|
||||
RateLimitCacheTime = 5 * time.Minute
|
||||
RedDotCacheKey = "answer:red-dot:%s:%s"
|
||||
RedDotCacheTime = 30 * 24 * time.Hour
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* 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 constant
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
CommentEditDeadline = time.Minute * 5
|
||||
)
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package constant
|
||||
|
||||
const (
|
||||
DefaultPageSize = 20 // Default number of pages
|
||||
DefaultBulkUser = 5000
|
||||
)
|
||||
|
||||
var (
|
||||
Version = ""
|
||||
Revision = ""
|
||||
GoVersion = ""
|
||||
)
|
||||
|
||||
var Timezones = []string{
|
||||
// Americas
|
||||
"America/New_York",
|
||||
"America/Chicago",
|
||||
"America/Los_Angeles",
|
||||
"America/Toronto",
|
||||
"America/Vancouver",
|
||||
"America/Mexico_City",
|
||||
"America/Sao_Paulo",
|
||||
"America/Buenos_Aires",
|
||||
|
||||
// Europe
|
||||
"Europe/London",
|
||||
"Europe/Paris",
|
||||
"Europe/Berlin",
|
||||
"Europe/Madrid",
|
||||
"Europe/Rome",
|
||||
"Europe/Moscow",
|
||||
|
||||
// Asia
|
||||
"Asia/Shanghai",
|
||||
"Asia/Tokyo",
|
||||
"Asia/Singapore",
|
||||
"Asia/Dubai",
|
||||
"Asia/Hong_Kong",
|
||||
"Asia/Seoul",
|
||||
"Asia/Bangkok",
|
||||
"Asia/Kolkata",
|
||||
|
||||
// Pacific
|
||||
"Australia/Sydney",
|
||||
"Australia/Melbourne",
|
||||
"Pacific/Auckland",
|
||||
|
||||
// Africa
|
||||
"Africa/Cairo",
|
||||
"Africa/Johannesburg",
|
||||
"Africa/Lagos",
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* 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 constant
|
||||
|
||||
const (
|
||||
AcceptLanguageFlag = "Accept-Language"
|
||||
ShortIDFlag = "Short-ID-Enabled"
|
||||
)
|
||||
|
||||
type ContextKey string
|
||||
|
||||
const (
|
||||
AcceptLanguageContextKey ContextKey = ContextKey(AcceptLanguageFlag)
|
||||
ShortIDContextKey ContextKey = ContextKey(ShortIDFlag)
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package constant
|
||||
|
||||
const (
|
||||
EmailTplKeyChangeEmailTitle = "email_tpl.change_email.title"
|
||||
EmailTplKeyChangeEmailBody = "email_tpl.change_email.body"
|
||||
|
||||
EmailTplKeyNewAnswerTitle = "email_tpl.new_answer.title"
|
||||
EmailTplKeyNewAnswerBody = "email_tpl.new_answer.body"
|
||||
|
||||
EmailTplKeyNewCommentTitle = "email_tpl.new_comment.title"
|
||||
EmailTplKeyNewCommentBody = "email_tpl.new_comment.body"
|
||||
|
||||
EmailTplKeyPassResetTitle = "email_tpl.pass_reset.title"
|
||||
EmailTplKeyPassResetBody = "email_tpl.pass_reset.body"
|
||||
|
||||
EmailTplKeyRegisterTitle = "email_tpl.register.title"
|
||||
EmailTplKeyRegisterBody = "email_tpl.register.body"
|
||||
|
||||
EmailTplKeyTestTitle = "email_tpl.test.title"
|
||||
EmailTplKeyTestBody = "email_tpl.test.body"
|
||||
|
||||
EmailTplKeyInvitedAnswerTitle = "email_tpl.invited_you_to_answer.title"
|
||||
EmailTplKeyInvitedAnswerBody = "email_tpl.invited_you_to_answer.body"
|
||||
|
||||
EmailTplKeyNewQuestionTitle = "email_tpl.new_question.title"
|
||||
EmailTplKeyNewQuestionBody = "email_tpl.new_question.body"
|
||||
)
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* 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 constant
|
||||
|
||||
// EventType event type. It is used to define the type of event. Such as object.action
|
||||
type EventType string
|
||||
|
||||
// event object
|
||||
const (
|
||||
eventQuestion = "question"
|
||||
eventAnswer = "answer"
|
||||
eventComment = "comment"
|
||||
eventUser = "user"
|
||||
)
|
||||
|
||||
// event action
|
||||
const (
|
||||
eventCreate = "create"
|
||||
eventUpdate = "update"
|
||||
eventDelete = "delete"
|
||||
eventVote = "vote"
|
||||
eventAccept = "accept" // only question have the accept event
|
||||
eventShare = "share" // the object share link has been clicked
|
||||
eventFlag = "flag"
|
||||
eventReact = "react"
|
||||
)
|
||||
|
||||
const (
|
||||
EventUserUpdate EventType = eventUser + "." + eventUpdate
|
||||
EventUserShare EventType = eventUser + "." + eventShare
|
||||
)
|
||||
|
||||
const (
|
||||
EventQuestionCreate EventType = eventQuestion + "." + eventCreate
|
||||
EventQuestionUpdate EventType = eventQuestion + "." + eventUpdate
|
||||
EventQuestionDelete EventType = eventQuestion + "." + eventDelete
|
||||
EventQuestionVote EventType = eventQuestion + "." + eventVote
|
||||
EventQuestionAccept EventType = eventQuestion + "." + eventAccept
|
||||
EventQuestionFlag EventType = eventQuestion + "." + eventFlag
|
||||
EventQuestionReact EventType = eventQuestion + "." + eventReact
|
||||
)
|
||||
|
||||
const (
|
||||
EventAnswerCreate EventType = eventAnswer + "." + eventCreate
|
||||
EventAnswerUpdate EventType = eventAnswer + "." + eventUpdate
|
||||
EventAnswerDelete EventType = eventAnswer + "." + eventDelete
|
||||
EventAnswerVote EventType = eventAnswer + "." + eventVote
|
||||
EventAnswerFlag EventType = eventAnswer + "." + eventFlag
|
||||
EventAnswerReact EventType = eventAnswer + "." + eventReact
|
||||
)
|
||||
|
||||
const (
|
||||
EventCommentCreate EventType = eventComment + "." + eventCreate
|
||||
EventCommentUpdate EventType = eventComment + "." + eventUpdate
|
||||
EventCommentDelete EventType = eventComment + "." + eventDelete
|
||||
EventCommentVote EventType = eventComment + "." + eventVote
|
||||
EventCommentFlag EventType = eventComment + "." + eventFlag
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* 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 constant
|
||||
|
||||
const (
|
||||
ReactionTooltipLabel = "reaction.tooltip"
|
||||
)
|
||||
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package constant
|
||||
|
||||
const (
|
||||
// NotificationUpdateQuestion update question
|
||||
NotificationUpdateQuestion = "notification.action.update_question"
|
||||
// NotificationAnswerTheQuestion answer the question
|
||||
NotificationAnswerTheQuestion = "notification.action.answer_the_question"
|
||||
// NotificationUpVotedTheQuestion up voted the question
|
||||
NotificationUpVotedTheQuestion = "notification.action.up_voted_question"
|
||||
// NotificationDownVotedTheQuestion down voted the question
|
||||
NotificationDownVotedTheQuestion = "notification.action.down_voted_question"
|
||||
// NotificationUpdateAnswer update answer
|
||||
NotificationUpdateAnswer = "notification.action.update_answer"
|
||||
// NotificationAcceptAnswer accept answer
|
||||
NotificationAcceptAnswer = "notification.action.accept_answer"
|
||||
// NotificationUpVotedTheAnswer up voted the answer
|
||||
NotificationUpVotedTheAnswer = "notification.action.up_voted_answer"
|
||||
// NotificationDownVotedTheAnswer down voted the answer
|
||||
NotificationDownVotedTheAnswer = "notification.action.down_voted_answer"
|
||||
// NotificationCommentQuestion comment question
|
||||
NotificationCommentQuestion = "notification.action.comment_question"
|
||||
// NotificationCommentAnswer comment answer
|
||||
NotificationCommentAnswer = "notification.action.comment_answer"
|
||||
// NotificationUpVotedTheComment up voted the comment
|
||||
NotificationUpVotedTheComment = "notification.action.up_voted_comment"
|
||||
// NotificationReplyToYou reply to you
|
||||
NotificationReplyToYou = "notification.action.reply_to_you"
|
||||
// NotificationMentionYou mention you
|
||||
NotificationMentionYou = "notification.action.mention_you"
|
||||
// NotificationYourQuestionIsClosed your question is closed
|
||||
NotificationYourQuestionIsClosed = "notification.action.your_question_is_closed"
|
||||
// NotificationYourQuestionWasDeleted your question was deleted
|
||||
NotificationYourQuestionWasDeleted = "notification.action.your_question_was_deleted"
|
||||
// NotificationYourAnswerWasDeleted your answer was deleted
|
||||
NotificationYourAnswerWasDeleted = "notification.action.your_answer_was_deleted"
|
||||
// NotificationYourCommentWasDeleted your comment was deleted
|
||||
NotificationYourCommentWasDeleted = "notification.action.your_comment_was_deleted"
|
||||
// NotificationInvitedYouToAnswer invited you to answer
|
||||
NotificationInvitedYouToAnswer = "notification.action.invited_you_to_answer"
|
||||
// NotificationEarnedBadge earned badge
|
||||
NotificationEarnedBadge = "notification.action.earned_badge"
|
||||
)
|
||||
|
||||
type NotificationChannelKey string
|
||||
type NotificationSource string
|
||||
|
||||
const (
|
||||
InboxSource NotificationSource = "inbox"
|
||||
AllNewQuestionSource NotificationSource = "all_new_question"
|
||||
AllNewQuestionForFollowingTagsSource NotificationSource = "all_new_question_for_following_tags"
|
||||
)
|
||||
|
||||
const (
|
||||
EmailChannel NotificationChannelKey = "email"
|
||||
)
|
||||
|
||||
const (
|
||||
NotificationTypeInbox = "inbox"
|
||||
NotificationTypeAchievement = "achievement"
|
||||
NotificationTypeBadgeAchievement = "badge"
|
||||
)
|
||||
|
||||
var (
|
||||
NotificationMsgTypeMapping = map[string]int{
|
||||
NotificationUpdateQuestion: 1,
|
||||
NotificationAnswerTheQuestion: 1,
|
||||
NotificationUpVotedTheQuestion: 2,
|
||||
NotificationDownVotedTheQuestion: 2,
|
||||
NotificationUpdateAnswer: 1,
|
||||
NotificationAcceptAnswer: 1,
|
||||
NotificationUpVotedTheAnswer: 2,
|
||||
NotificationDownVotedTheAnswer: 2,
|
||||
NotificationCommentQuestion: 1,
|
||||
NotificationCommentAnswer: 1,
|
||||
NotificationUpVotedTheComment: 2,
|
||||
NotificationReplyToYou: 1,
|
||||
NotificationMentionYou: 1,
|
||||
NotificationYourQuestionIsClosed: 1,
|
||||
NotificationYourQuestionWasDeleted: 1,
|
||||
NotificationYourAnswerWasDeleted: 1,
|
||||
NotificationYourCommentWasDeleted: 1,
|
||||
NotificationInvitedYouToAnswer: 3,
|
||||
}
|
||||
)
|
||||
@@ -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 constant
|
||||
|
||||
const (
|
||||
QuestionObjectType = "question"
|
||||
AnswerObjectType = "answer"
|
||||
TagObjectType = "tag"
|
||||
UserObjectType = "user"
|
||||
CollectionObjectType = "collection"
|
||||
CommentObjectType = "comment"
|
||||
ReportObjectType = "report"
|
||||
BadgeObjectType = "badge"
|
||||
BadgeAwardObjectType = "badge_award"
|
||||
)
|
||||
|
||||
var (
|
||||
ObjectTypeStrMapping = map[string]int{
|
||||
QuestionObjectType: 1,
|
||||
AnswerObjectType: 2,
|
||||
TagObjectType: 3,
|
||||
UserObjectType: 4,
|
||||
CollectionObjectType: 6,
|
||||
CommentObjectType: 7,
|
||||
ReportObjectType: 8,
|
||||
BadgeObjectType: 9,
|
||||
BadgeAwardObjectType: 10,
|
||||
}
|
||||
|
||||
ObjectTypeNumberMapping = map[int]string{
|
||||
1: QuestionObjectType,
|
||||
2: AnswerObjectType,
|
||||
3: TagObjectType,
|
||||
4: UserObjectType,
|
||||
6: CollectionObjectType,
|
||||
7: CommentObjectType,
|
||||
8: ReportObjectType,
|
||||
9: BadgeObjectType,
|
||||
10: BadgeAwardObjectType,
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* 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 constant
|
||||
|
||||
const (
|
||||
PluginStatus = "plugin.status"
|
||||
)
|
||||
@@ -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 constant
|
||||
|
||||
import "github.com/apache/answer/internal/base/reason"
|
||||
|
||||
type Privilege struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Value int `validate:"gte=1" json:"value"`
|
||||
}
|
||||
|
||||
const (
|
||||
RankQuestionAddKey = "rank.question.add"
|
||||
RankQuestionEditKey = "rank.question.edit"
|
||||
RankQuestionDeleteKey = "rank.question.delete"
|
||||
RankQuestionVoteUpKey = "rank.question.vote_up"
|
||||
RankQuestionVoteDownKey = "rank.question.vote_down"
|
||||
RankAnswerAddKey = "rank.answer.add"
|
||||
RankAnswerEditKey = "rank.answer.edit"
|
||||
RankAnswerDeleteKey = "rank.answer.delete"
|
||||
RankAnswerAcceptKey = "rank.answer.accept"
|
||||
RankAnswerVoteUpKey = "rank.answer.vote_up"
|
||||
RankAnswerVoteDownKey = "rank.answer.vote_down"
|
||||
RankInviteSomeoneToAnswerKey = "rank.answer.invite_someone_to_answer"
|
||||
RankCommentAddKey = "rank.comment.add"
|
||||
RankCommentEditKey = "rank.comment.edit"
|
||||
RankCommentDeleteKey = "rank.comment.delete"
|
||||
RankReportAddKey = "rank.report.add"
|
||||
RankTagAddKey = "rank.tag.add"
|
||||
RankTagEditKey = "rank.tag.edit"
|
||||
RankTagDeleteKey = "rank.tag.delete"
|
||||
RankTagSynonymKey = "rank.tag.synonym"
|
||||
RankLinkUrlLimitKey = "rank.link.url_limit"
|
||||
RankVoteDetailKey = "rank.vote.detail"
|
||||
RankCommentVoteUpKey = "rank.comment.vote_up"
|
||||
RankCommentVoteDownKey = "rank.comment.vote_down"
|
||||
RankQuestionEditWithoutReviewKey = "rank.question.edit_without_review"
|
||||
RankAnswerEditWithoutReviewKey = "rank.answer.edit_without_review"
|
||||
RankTagEditWithoutReviewKey = "rank.tag.edit_without_review"
|
||||
RankAnswerAuditKey = "rank.answer.audit"
|
||||
RankQuestionAuditKey = "rank.question.audit"
|
||||
RankTagAuditKey = "rank.tag.audit"
|
||||
RankQuestionCloseKey = "rank.question.close"
|
||||
RankQuestionReopenKey = "rank.question.reopen"
|
||||
RankTagUseReservedTagKey = "rank.tag.use_reserved_tag"
|
||||
)
|
||||
|
||||
var (
|
||||
RankAllPrivileges = []*Privilege{
|
||||
{Label: reason.RankQuestionAddLabel, Key: RankQuestionAddKey},
|
||||
{Label: reason.RankAnswerAddLabel, Key: RankAnswerAddKey},
|
||||
{Label: reason.RankCommentAddLabel, Key: RankCommentAddKey},
|
||||
{Label: reason.RankReportAddLabel, Key: RankReportAddKey},
|
||||
{Label: reason.RankCommentVoteUpLabel, Key: RankCommentVoteUpKey},
|
||||
{Label: reason.RankLinkUrlLimitLabel, Key: RankLinkUrlLimitKey},
|
||||
{Label: reason.RankQuestionVoteUpLabel, Key: RankQuestionVoteUpKey},
|
||||
{Label: reason.RankAnswerVoteUpLabel, Key: RankAnswerVoteUpKey},
|
||||
{Label: reason.RankQuestionVoteDownLabel, Key: RankQuestionVoteDownKey},
|
||||
{Label: reason.RankAnswerVoteDownLabel, Key: RankAnswerVoteDownKey},
|
||||
{Label: reason.RankInviteSomeoneToAnswerLabel, Key: RankInviteSomeoneToAnswerKey},
|
||||
{Label: reason.RankTagAddLabel, Key: RankTagAddKey},
|
||||
{Label: reason.RankTagEditLabel, Key: RankTagEditKey},
|
||||
{Label: reason.RankQuestionEditLabel, Key: RankQuestionEditKey},
|
||||
{Label: reason.RankAnswerEditLabel, Key: RankAnswerEditKey},
|
||||
{Label: reason.RankQuestionEditWithoutReviewLabel, Key: RankQuestionEditWithoutReviewKey},
|
||||
{Label: reason.RankAnswerEditWithoutReviewLabel, Key: RankAnswerEditWithoutReviewKey},
|
||||
{Label: reason.RankQuestionAuditLabel, Key: RankQuestionAuditKey},
|
||||
{Label: reason.RankAnswerAuditLabel, Key: RankAnswerAuditKey},
|
||||
{Label: reason.RankTagAuditLabel, Key: RankTagAuditKey},
|
||||
{Label: reason.RankTagEditWithoutReviewLabel, Key: RankTagEditWithoutReviewKey},
|
||||
{Label: reason.RankTagSynonymLabel, Key: RankTagSynonymKey},
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* 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 constant
|
||||
|
||||
const (
|
||||
DeletedQuestionTitleTrKey = "question.deleted_title"
|
||||
QuestionsTitleTrKey = "question.questions_title"
|
||||
TagsListTitleTrKey = "tag.tags_title"
|
||||
TagHasNoDescription = "tag.no_description"
|
||||
)
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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 constant
|
||||
|
||||
const (
|
||||
ReasonSpam = "reason.spam"
|
||||
ReasonRudeOrAbusive = "reason.rude_or_abusive"
|
||||
ReasonSomething = "reason.something"
|
||||
ReasonADuplicate = "reason.a_duplicate"
|
||||
ReasonNotAAnswer = "reason.not_a_answer"
|
||||
ReasonNoLongerNeeded = "reason.no_longer_needed"
|
||||
ReasonCommunitySpecific = "reason.community_specific"
|
||||
ReasonNotClarity = "reason.not_clarity"
|
||||
ReasonNormal = "reason.normal"
|
||||
ReasonNormalUser = "reason.normal.user"
|
||||
ReasonClosed = "reason.closed"
|
||||
ReasonDeleted = "reason.deleted"
|
||||
ReasonDeletedUser = "reason.deleted.user"
|
||||
ReasonSuspended = "reason.suspended"
|
||||
ReasonInactive = "reason.inactive"
|
||||
ReasonLooksOk = "reason.looks_ok"
|
||||
ReasonNeedsEdit = "reason.needs_edit"
|
||||
ReasonNeedsClose = "reason.needs_close"
|
||||
ReasonNeedsDelete = "reason.needs_delete"
|
||||
)
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package constant
|
||||
|
||||
type ReviewingType string
|
||||
|
||||
const (
|
||||
QueuedPost ReviewingType = "queued_post"
|
||||
QueuedUser ReviewingType = "queued_user"
|
||||
FlaggedPost ReviewingType = "flagged_post"
|
||||
FlaggedUser ReviewingType = "flagged_user"
|
||||
SuggestedPostEdit ReviewingType = "suggested_post_edit"
|
||||
)
|
||||
|
||||
const (
|
||||
ReportOperationEditPost = "edit_post"
|
||||
ReportOperationClosePost = "close_post"
|
||||
ReportOperationDeletePost = "delete_post"
|
||||
ReportOperationUnlistPost = "unlist_post"
|
||||
ReportOperationIgnoreReport = "ignore_report"
|
||||
)
|
||||
|
||||
const (
|
||||
ReviewQueuedPostLabel = "review.queued_post"
|
||||
ReviewFlaggedPostLabel = "review.flagged_post"
|
||||
ReviewSuggestedPostEditLabel = "review.suggested_post_edit"
|
||||
)
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* 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 constant
|
||||
|
||||
const (
|
||||
DefaultGravatarBaseURL = "https://www.gravatar.com/avatar/"
|
||||
DefaultAvatar = "system"
|
||||
AvatarTypeDefault = "default"
|
||||
AvatarTypeGravatar = "gravatar"
|
||||
AvatarTypeCustom = "custom"
|
||||
)
|
||||
|
||||
const (
|
||||
// PermalinkQuestionIDAndTitle /questions/10010000000000001/post-title
|
||||
PermalinkQuestionIDAndTitle = iota + 1
|
||||
// PermalinkQuestionID /questions/10010000000000001
|
||||
PermalinkQuestionID
|
||||
// PermalinkQuestionIDAndTitleByShortID /questions/11/post-title
|
||||
PermalinkQuestionIDAndTitleByShortID
|
||||
// PermalinkQuestionIDByShortID /questions/11
|
||||
PermalinkQuestionIDByShortID
|
||||
)
|
||||
|
||||
const (
|
||||
ColorSchemeDefault = "default"
|
||||
ColorSchemeLight = "light"
|
||||
ColorSchemeDark = "dark"
|
||||
ColorSchemeSystem = "system"
|
||||
|
||||
ThemeLayoutFullWidth = "Full-width"
|
||||
ThemeLayoutFixedWidth = "Fixed-width"
|
||||
)
|
||||
|
||||
const (
|
||||
EmailConfigKey = "email.config"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultMaxImageMegapixel = 40 * 1000 * 1000
|
||||
DefaultMaxImageSize = 4 * 1024 * 1024
|
||||
DefaultMaxAttachmentSize = 8 * 1024 * 1024
|
||||
)
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package constant
|
||||
|
||||
const (
|
||||
// SiteTypeLegal\SiteTypeLegal\SiteTypeWrite The following items will no longer be used.
|
||||
SiteTypeLegal = "legal"
|
||||
SiteTypeInterface = "interface"
|
||||
SiteTypeWrite = "write"
|
||||
|
||||
SiteTypeGeneral = "general"
|
||||
SiteTypeBranding = "branding"
|
||||
SiteTypeSeo = "seo"
|
||||
SiteTypeLogin = "login"
|
||||
SiteTypeCustomCssHTML = "css-html"
|
||||
SiteTypeTheme = "theme"
|
||||
SiteTypePrivileges = "privileges"
|
||||
SiteTypeUsers = "users"
|
||||
|
||||
SiteTypeAdvanced = "advanced"
|
||||
SiteTypeQuestions = "questions"
|
||||
SiteTypeTags = "tags"
|
||||
|
||||
SiteTypeUsersSettings = "users_settings"
|
||||
SiteTypeInterfaceSettings = "interface_settings"
|
||||
|
||||
SiteTypePolicies = "policies"
|
||||
SiteTypeSecurity = "security"
|
||||
SiteTypeAI = "ai"
|
||||
SiteTypeFeatureToggle = "feature-toggle"
|
||||
SiteTypeMCP = "mcp"
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* 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 constant
|
||||
|
||||
const (
|
||||
AvatarSubPath = "avatar"
|
||||
AvatarThumbSubPath = "avatar_thumb"
|
||||
PostSubPath = "post"
|
||||
BrandingSubPath = "branding"
|
||||
FilesPostSubPath = "files/post"
|
||||
DeletedSubPath = "deleted"
|
||||
)
|
||||
@@ -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 constant
|
||||
|
||||
const (
|
||||
UserNormal = "normal"
|
||||
UserSuspended = "suspended"
|
||||
UserDeleted = "deleted"
|
||||
UserInactive = "inactive"
|
||||
)
|
||||
const (
|
||||
EmailStatusAvailable = 1
|
||||
EmailStatusToBeVerified = 2
|
||||
)
|
||||
|
||||
const (
|
||||
DeletePermanentlyUsers = "users"
|
||||
DeletePermanentlyQuestions = "questions"
|
||||
DeletePermanentlyAnswers = "answers"
|
||||
)
|
||||
|
||||
func ConvertUserStatus(status, mailStatus int) string {
|
||||
switch status {
|
||||
case 1:
|
||||
if mailStatus == EmailStatusToBeVerified {
|
||||
return UserInactive
|
||||
}
|
||||
return UserNormal
|
||||
case 9:
|
||||
return UserSuspended
|
||||
case 10:
|
||||
return UserDeleted
|
||||
}
|
||||
return UserNormal
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* 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 cron
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/apache/answer/internal/service/content"
|
||||
"github.com/apache/answer/internal/service/file_record"
|
||||
"github.com/apache/answer/internal/service/service_config"
|
||||
"github.com/apache/answer/internal/service/siteinfo_common"
|
||||
"github.com/apache/answer/internal/service/user_admin"
|
||||
"github.com/robfig/cron/v3"
|
||||
"github.com/segmentfault/pacman/log"
|
||||
)
|
||||
|
||||
// ScheduledTaskManager scheduled task manager
|
||||
type ScheduledTaskManager struct {
|
||||
siteInfoService siteinfo_common.SiteInfoCommonService
|
||||
questionService *content.QuestionService
|
||||
fileRecordService *file_record.FileRecordService
|
||||
userAdminService *user_admin.UserAdminService
|
||||
serviceConfig *service_config.ServiceConfig
|
||||
}
|
||||
|
||||
// NewScheduledTaskManager new scheduled task manager
|
||||
func NewScheduledTaskManager(
|
||||
siteInfoService siteinfo_common.SiteInfoCommonService,
|
||||
questionService *content.QuestionService,
|
||||
fileRecordService *file_record.FileRecordService,
|
||||
userAdminService *user_admin.UserAdminService,
|
||||
serviceConfig *service_config.ServiceConfig,
|
||||
) *ScheduledTaskManager {
|
||||
manager := &ScheduledTaskManager{
|
||||
siteInfoService: siteInfoService,
|
||||
questionService: questionService,
|
||||
fileRecordService: fileRecordService,
|
||||
userAdminService: userAdminService,
|
||||
serviceConfig: serviceConfig,
|
||||
}
|
||||
return manager
|
||||
}
|
||||
|
||||
func (s *ScheduledTaskManager) Run() {
|
||||
log.Infof("cron job manager start")
|
||||
|
||||
s.questionService.SitemapCron(context.Background())
|
||||
c := cron.New()
|
||||
_, err := c.AddFunc("0 */1 * * *", func() {
|
||||
ctx := context.Background()
|
||||
log.Infof("sitemap cron execution")
|
||||
s.questionService.SitemapCron(ctx)
|
||||
})
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
}
|
||||
|
||||
_, err = c.AddFunc("0 */1 * * *", func() {
|
||||
ctx := context.Background()
|
||||
log.Infof("refresh hottest cron execution")
|
||||
s.questionService.RefreshHottestCron(ctx)
|
||||
})
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
}
|
||||
|
||||
// Check for expired user suspensions every 10 minutes
|
||||
_, err = c.AddFunc("*/10 * * * *", func() {
|
||||
ctx := context.Background()
|
||||
log.Infof("checking expired user suspensions")
|
||||
err := s.userAdminService.CheckAndUnsuspendExpiredUsers(ctx)
|
||||
if err != nil {
|
||||
log.Errorf("failed to check expired user suspensions: %v", err)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
}
|
||||
|
||||
if s.serviceConfig.CleanUpUploads {
|
||||
log.Infof("clean up uploads cron enabled")
|
||||
|
||||
conf := s.serviceConfig
|
||||
_, err = c.AddFunc(fmt.Sprintf("0 */%d * * *", conf.CleanOrphanUploadsPeriodHours), func() {
|
||||
log.Infof("clean orphan upload files cron execution")
|
||||
s.fileRecordService.CleanOrphanUploadFiles(context.Background())
|
||||
})
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
}
|
||||
|
||||
_, err = c.AddFunc(fmt.Sprintf("0 0 */%d * *", conf.PurgeDeletedFilesPeriodDays), func() {
|
||||
log.Infof("purge deleted files cron execution")
|
||||
s.fileRecordService.PurgeDeletedFiles(context.Background())
|
||||
})
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
}
|
||||
}
|
||||
c.Start()
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* 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 cron
|
||||
|
||||
import (
|
||||
"github.com/google/wire"
|
||||
)
|
||||
|
||||
// ProviderSetService is providers.
|
||||
var ProviderSetService = wire.NewSet(
|
||||
NewScheduledTaskManager,
|
||||
)
|
||||
@@ -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 data
|
||||
|
||||
// Database database config
|
||||
type Database struct {
|
||||
Driver string `json:"driver" mapstructure:"driver" yaml:"driver"`
|
||||
Connection string `json:"connection" mapstructure:"connection" yaml:"connection"`
|
||||
ConnMaxLifeTime int `json:"conn_max_life_time" mapstructure:"conn_max_life_time" yaml:"conn_max_life_time,omitempty"`
|
||||
MaxOpenConn int `json:"max_open_conn" mapstructure:"max_open_conn" yaml:"max_open_conn,omitempty"`
|
||||
MaxIdleConn int `json:"max_idle_conn" mapstructure:"max_idle_conn" yaml:"max_idle_conn,omitempty"`
|
||||
}
|
||||
|
||||
// CacheConf cache
|
||||
type CacheConf struct {
|
||||
FilePath string `json:"file_path" mapstructure:"file_path" yaml:"file_path"`
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* 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 data
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/apache/answer/pkg/dir"
|
||||
"github.com/apache/answer/plugin"
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
_ "github.com/lib/pq"
|
||||
"github.com/segmentfault/pacman/cache"
|
||||
"github.com/segmentfault/pacman/contrib/cache/memory"
|
||||
"github.com/segmentfault/pacman/log"
|
||||
_ "modernc.org/sqlite"
|
||||
"xorm.io/xorm"
|
||||
ormlog "xorm.io/xorm/log"
|
||||
"xorm.io/xorm/names"
|
||||
"xorm.io/xorm/schemas"
|
||||
)
|
||||
|
||||
// Data data
|
||||
type Data struct {
|
||||
DB *xorm.Engine
|
||||
Cache cache.Cache
|
||||
}
|
||||
|
||||
// NewData new data instance
|
||||
func NewData(db *xorm.Engine, cache cache.Cache) (*Data, func(), error) {
|
||||
cleanup := func() {
|
||||
log.Info("closing the data resources")
|
||||
_ = db.Close()
|
||||
}
|
||||
return &Data{DB: db, Cache: cache}, cleanup, nil
|
||||
}
|
||||
|
||||
// NewDB new database instance
|
||||
func NewDB(debug bool, dataConf *Database) (*xorm.Engine, error) {
|
||||
if dataConf.Driver == "" {
|
||||
dataConf.Driver = string(schemas.MYSQL)
|
||||
}
|
||||
if dataConf.Driver == string(schemas.SQLITE) {
|
||||
dataConf.Driver = "sqlite"
|
||||
dbFileDir := filepath.Dir(dataConf.Connection)
|
||||
log.Debugf("try to create database directory %s", dbFileDir)
|
||||
if err := dir.CreateDirIfNotExist(dbFileDir); err != nil {
|
||||
log.Errorf("create database dir failed: %s", err)
|
||||
}
|
||||
dataConf.MaxOpenConn = 1
|
||||
}
|
||||
engine, err := xorm.NewEngine(dataConf.Driver, dataConf.Connection)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if debug {
|
||||
engine.ShowSQL(true)
|
||||
} else {
|
||||
engine.SetLogLevel(ormlog.LOG_ERR)
|
||||
}
|
||||
|
||||
if err = engine.Ping(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if dataConf.MaxIdleConn > 0 {
|
||||
engine.SetMaxIdleConns(dataConf.MaxIdleConn)
|
||||
}
|
||||
if dataConf.MaxOpenConn > 0 {
|
||||
engine.SetMaxOpenConns(dataConf.MaxOpenConn)
|
||||
}
|
||||
if dataConf.ConnMaxLifeTime > 0 {
|
||||
engine.SetConnMaxLifetime(time.Duration(dataConf.ConnMaxLifeTime) * time.Second)
|
||||
}
|
||||
engine.SetColumnMapper(names.GonicMapper{})
|
||||
return engine, nil
|
||||
}
|
||||
|
||||
// NewCache new cache instance
|
||||
func NewCache(c *CacheConf) (cache.Cache, func(), error) {
|
||||
var pluginCache plugin.Cache
|
||||
_ = plugin.CallCache(func(fn plugin.Cache) error {
|
||||
pluginCache = fn
|
||||
return nil
|
||||
})
|
||||
if pluginCache != nil {
|
||||
return pluginCache, func() {}, nil
|
||||
}
|
||||
|
||||
// TODO What cache type should be initialized according to the configuration file
|
||||
memCache := memory.NewCache()
|
||||
|
||||
if len(c.FilePath) > 0 {
|
||||
cacheFileDir := filepath.Dir(c.FilePath)
|
||||
log.Debugf("try to create cache directory %s", cacheFileDir)
|
||||
err := dir.CreateDirIfNotExist(cacheFileDir)
|
||||
if err != nil {
|
||||
log.Errorf("create cache dir failed: %s", err)
|
||||
}
|
||||
log.Infof("try to load cache file from %s", c.FilePath)
|
||||
if err := memory.Load(memCache, c.FilePath); err != nil {
|
||||
log.Warn(err)
|
||||
}
|
||||
go func() {
|
||||
ticker := time.Tick(time.Minute)
|
||||
for range ticker {
|
||||
if err := memory.Save(memCache, c.FilePath); err != nil {
|
||||
log.Warn(err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
cleanup := func() {
|
||||
log.Infof("try to save cache file to %s", c.FilePath)
|
||||
if err := memory.Save(memCache, c.FilePath); err != nil {
|
||||
log.Warn(err)
|
||||
}
|
||||
}
|
||||
return memCache, cleanup, nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/apache/answer/internal/base/reason"
|
||||
"github.com/apache/answer/internal/base/validator"
|
||||
"github.com/gin-gonic/gin"
|
||||
myErrors "github.com/segmentfault/pacman/errors"
|
||||
"github.com/segmentfault/pacman/log"
|
||||
)
|
||||
|
||||
// HandleResponse Handle response body
|
||||
func HandleResponse(ctx *gin.Context, err error, data any) {
|
||||
lang := GetLangByCtx(ctx)
|
||||
// no error
|
||||
if err == nil {
|
||||
ctx.JSON(http.StatusOK, NewRespBodyData(http.StatusOK, reason.Success, data).TrMsg(lang))
|
||||
return
|
||||
}
|
||||
|
||||
var myErr *myErrors.Error
|
||||
// unknown error
|
||||
if !errors.As(err, &myErr) {
|
||||
log.Error(err, "\n", myErrors.LogStack(2, 5))
|
||||
ctx.JSON(http.StatusInternalServerError, NewRespBody(
|
||||
http.StatusInternalServerError, reason.UnknownError).TrMsg(lang))
|
||||
return
|
||||
}
|
||||
|
||||
// log internal server error
|
||||
if myErrors.IsInternalServer(myErr) {
|
||||
log.Error(myErr)
|
||||
}
|
||||
|
||||
respBody := NewRespBodyFromError(myErr).TrMsg(lang)
|
||||
if data != nil {
|
||||
respBody.Data = data
|
||||
}
|
||||
ctx.JSON(myErr.Code, respBody)
|
||||
}
|
||||
|
||||
// BindAndCheck bind request and check
|
||||
func BindAndCheck(ctx *gin.Context, data any) bool {
|
||||
lang := GetLangByCtx(ctx)
|
||||
if err := ctx.ShouldBind(data); err != nil {
|
||||
log.Errorf("http_handle BindAndCheck fail, %s", err.Error())
|
||||
HandleResponse(ctx, myErrors.New(http.StatusBadRequest, reason.RequestFormatError), nil)
|
||||
return true
|
||||
}
|
||||
|
||||
errField, err := validator.GetValidatorByLang(lang).Check(data)
|
||||
if err != nil {
|
||||
HandleResponse(ctx, err, errField)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// BindAndCheckReturnErr bind request and check
|
||||
func BindAndCheckReturnErr(ctx *gin.Context, data any) (errFields []*validator.FormErrorField) {
|
||||
lang := GetLangByCtx(ctx)
|
||||
if err := ctx.ShouldBind(data); err != nil {
|
||||
log.Errorf("http_handle BindAndCheck fail, %s", err.Error())
|
||||
HandleResponse(ctx, myErrors.New(http.StatusBadRequest, reason.RequestFormatError), nil)
|
||||
ctx.Abort()
|
||||
return nil
|
||||
}
|
||||
|
||||
errFields, _ = validator.GetValidatorByLang(lang).Check(data)
|
||||
return errFields
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* 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 handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/apache/answer/internal/base/constant"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/segmentfault/pacman/i18n"
|
||||
)
|
||||
|
||||
// GetLangByCtx get language from header
|
||||
func GetLangByCtx(ctx context.Context) i18n.Language {
|
||||
if ginCtx, ok := ctx.(*gin.Context); ok {
|
||||
acceptLanguage, ok := ginCtx.Get(constant.AcceptLanguageFlag)
|
||||
if ok {
|
||||
if acceptLanguage, ok := acceptLanguage.(i18n.Language); ok {
|
||||
return acceptLanguage
|
||||
}
|
||||
return i18n.DefaultLanguage
|
||||
}
|
||||
}
|
||||
|
||||
acceptLanguage, ok := ctx.Value(constant.AcceptLanguageContextKey).(i18n.Language)
|
||||
if ok {
|
||||
return acceptLanguage
|
||||
}
|
||||
return i18n.DefaultLanguage
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/apache/answer/internal/base/translator"
|
||||
"github.com/segmentfault/pacman/errors"
|
||||
"github.com/segmentfault/pacman/i18n"
|
||||
)
|
||||
|
||||
// RespBody response body.
|
||||
type RespBody struct {
|
||||
// http code
|
||||
Code int `json:"code"`
|
||||
// reason key
|
||||
Reason string `json:"reason"`
|
||||
// response message
|
||||
Message string `json:"msg"`
|
||||
// response data
|
||||
Data any `json:"data"`
|
||||
}
|
||||
|
||||
// TrMsg translate the reason cause as a message
|
||||
func (r *RespBody) TrMsg(lang i18n.Language) *RespBody {
|
||||
if len(r.Message) == 0 {
|
||||
r.Message = translator.Tr(lang, r.Reason)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// NewRespBody new response body
|
||||
func NewRespBody(code int, reason string) *RespBody {
|
||||
return &RespBody{
|
||||
Code: code,
|
||||
Reason: reason,
|
||||
}
|
||||
}
|
||||
|
||||
// NewRespBodyFromError new response body from error
|
||||
func NewRespBodyFromError(e *errors.Error) *RespBody {
|
||||
return &RespBody{
|
||||
Code: e.Code,
|
||||
Reason: e.Reason,
|
||||
Message: e.Message,
|
||||
}
|
||||
}
|
||||
|
||||
// NewRespBodyData new response body with data
|
||||
func NewRespBodyData(code int, reason string, data any) *RespBody {
|
||||
return &RespBody{
|
||||
Code: code,
|
||||
Reason: reason,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* 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 handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/apache/answer/internal/base/constant"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetEnableShortID get short id flag from context
|
||||
func GetEnableShortID(ctx context.Context) bool {
|
||||
// Check gin context first (set by ShortIDMiddleware via ctx.Set)
|
||||
if ginCtx, ok := ctx.(*gin.Context); ok {
|
||||
flag, ok := ginCtx.Get(constant.ShortIDFlag)
|
||||
if ok {
|
||||
if flag, ok := flag.(bool); ok {
|
||||
return flag
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
// Fallback for non-gin contexts (e.g., SitemapCron uses context.WithValue)
|
||||
flag, ok := ctx.Value(constant.ShortIDContextKey).(bool)
|
||||
if ok {
|
||||
return flag
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/apache/answer/internal/base/constant"
|
||||
"github.com/apache/answer/internal/base/translator"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/segmentfault/pacman/i18n"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
const maxAcceptLanguageLength = 256
|
||||
|
||||
// ExtractAndSetAcceptLanguage extract accept language from header and set to context
|
||||
func ExtractAndSetAcceptLanguage(ctx *gin.Context) {
|
||||
// The language of our front-end configuration, like en_US
|
||||
acceptLanguage := ctx.GetHeader(constant.AcceptLanguageFlag)
|
||||
if len(acceptLanguage) > maxAcceptLanguageLength {
|
||||
ctx.Set(constant.AcceptLanguageFlag, i18n.LanguageEnglish)
|
||||
return
|
||||
}
|
||||
tag, _, err := language.ParseAcceptLanguage(acceptLanguage)
|
||||
if err != nil || len(tag) == 0 {
|
||||
ctx.Set(constant.AcceptLanguageFlag, i18n.LanguageEnglish)
|
||||
return
|
||||
}
|
||||
|
||||
acceptLang := strings.ReplaceAll(tag[0].String(), "-", "_")
|
||||
|
||||
for _, option := range translator.LanguageOptions {
|
||||
if option.Value == acceptLang {
|
||||
ctx.Set(constant.AcceptLanguageFlag, i18n.Language(acceptLang))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// default language
|
||||
ctx.Set(constant.AcceptLanguageFlag, i18n.LanguageEnglish)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/apache/answer/internal/base/handler"
|
||||
"github.com/apache/answer/internal/base/reason"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/segmentfault/pacman/errors"
|
||||
)
|
||||
|
||||
// AuthAPIKey middleware to authenticate API key
|
||||
func (am *AuthUserMiddleware) AuthAPIKey() gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
token := ExtractToken(ctx)
|
||||
if len(token) == 0 {
|
||||
handler.HandleResponse(ctx, errors.Unauthorized(reason.UnauthorizedError), nil)
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
pass, err := am.authService.AuthAPIKey(ctx, ctx.Request.Method == "GET", token)
|
||||
if err != nil {
|
||||
handler.HandleResponse(ctx, errors.Unauthorized(reason.UnauthorizedError), nil)
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
if !pass {
|
||||
handler.HandleResponse(ctx, errors.Unauthorized(reason.UnauthorizedError), nil)
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
ctx.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/apache/answer/internal/schema"
|
||||
"github.com/apache/answer/internal/service/role"
|
||||
"github.com/apache/answer/internal/service/siteinfo_common"
|
||||
"github.com/apache/answer/ui"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/apache/answer/internal/base/handler"
|
||||
"github.com/apache/answer/internal/base/reason"
|
||||
"github.com/apache/answer/internal/entity"
|
||||
"github.com/apache/answer/internal/service/auth"
|
||||
"github.com/apache/answer/pkg/converter"
|
||||
"github.com/segmentfault/pacman/errors"
|
||||
"github.com/segmentfault/pacman/log"
|
||||
)
|
||||
|
||||
var ctxUUIDKey = "ctxUuidKey"
|
||||
|
||||
// AuthUserMiddleware auth user middleware
|
||||
type AuthUserMiddleware struct {
|
||||
authService *auth.AuthService
|
||||
siteInfoCommonService siteinfo_common.SiteInfoCommonService
|
||||
}
|
||||
|
||||
// NewAuthUserMiddleware new auth user middleware
|
||||
func NewAuthUserMiddleware(
|
||||
authService *auth.AuthService,
|
||||
siteInfoCommonService siteinfo_common.SiteInfoCommonService) *AuthUserMiddleware {
|
||||
return &AuthUserMiddleware{
|
||||
authService: authService,
|
||||
siteInfoCommonService: siteInfoCommonService,
|
||||
}
|
||||
}
|
||||
|
||||
// Auth get token and auth user, set user info to context if user is already login
|
||||
func (am *AuthUserMiddleware) Auth() gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
token := ExtractToken(ctx)
|
||||
if len(token) == 0 {
|
||||
ctx.Next()
|
||||
return
|
||||
}
|
||||
userInfo, err := am.authService.GetUserCacheInfo(ctx, token)
|
||||
if err != nil {
|
||||
ctx.Next()
|
||||
return
|
||||
}
|
||||
if userInfo != nil {
|
||||
ctx.Set(ctxUUIDKey, userInfo)
|
||||
}
|
||||
ctx.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// EjectUserBySiteInfo if admin config the site can access by nologin user, eject user.
|
||||
func (am *AuthUserMiddleware) EjectUserBySiteInfo() gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
mustLogin := false
|
||||
siteInfo, _ := am.siteInfoCommonService.GetSiteSecurity(ctx)
|
||||
if siteInfo != nil {
|
||||
mustLogin = siteInfo.LoginRequired
|
||||
}
|
||||
if !mustLogin {
|
||||
ctx.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// If site in private mode, user must login.
|
||||
userInfo := GetUserInfoFromContext(ctx)
|
||||
if userInfo == nil {
|
||||
handler.HandleResponse(ctx, errors.Unauthorized(reason.UnauthorizedError), nil)
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
// If user is not active, eject user.
|
||||
if userInfo.EmailStatus != entity.EmailStatusAvailable {
|
||||
handler.HandleResponse(ctx, errors.Forbidden(reason.EmailNeedToBeVerified),
|
||||
&schema.ForbiddenResp{Type: schema.ForbiddenReasonTypeInactive})
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
ctx.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// MustAuthWithoutAccountAvailable auth user info, any login user can access though user is not active.
|
||||
func (am *AuthUserMiddleware) MustAuthWithoutAccountAvailable() gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
token := ExtractToken(ctx)
|
||||
if len(token) == 0 {
|
||||
handler.HandleResponse(ctx, errors.Unauthorized(reason.UnauthorizedError), nil)
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
// Check API key scope
|
||||
if am.AuthAPIKeyScope(ctx, token) {
|
||||
return
|
||||
}
|
||||
userInfo, err := am.authService.GetUserCacheInfo(ctx, token)
|
||||
if err != nil || userInfo == nil {
|
||||
handler.HandleResponse(ctx, errors.Unauthorized(reason.UnauthorizedError), nil)
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
if userInfo.UserStatus == entity.UserStatusDeleted {
|
||||
handler.HandleResponse(ctx, errors.Unauthorized(reason.UnauthorizedError), nil)
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
ctx.Set(ctxUUIDKey, userInfo)
|
||||
ctx.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// MustAuthAndAccountAvailable auth user info and check user status, only allow active user access.
|
||||
func (am *AuthUserMiddleware) MustAuthAndAccountAvailable() gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
token := ExtractToken(ctx)
|
||||
if len(token) == 0 {
|
||||
handler.HandleResponse(ctx, errors.Unauthorized(reason.UnauthorizedError), nil)
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
// Check API key scope
|
||||
if am.AuthAPIKeyScope(ctx, token) {
|
||||
return
|
||||
}
|
||||
userInfo, err := am.authService.GetUserCacheInfo(ctx, token)
|
||||
if err != nil || userInfo == nil {
|
||||
handler.HandleResponse(ctx, errors.Unauthorized(reason.UnauthorizedError), nil)
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
if userInfo.EmailStatus != entity.EmailStatusAvailable {
|
||||
handler.HandleResponse(ctx, errors.Forbidden(reason.EmailNeedToBeVerified),
|
||||
&schema.ForbiddenResp{Type: schema.ForbiddenReasonTypeInactive})
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
if userInfo.UserStatus == entity.UserStatusSuspended {
|
||||
handler.HandleResponse(ctx, errors.Forbidden(reason.UserSuspended),
|
||||
&schema.ForbiddenResp{Type: schema.ForbiddenReasonTypeUserSuspended})
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
if userInfo.UserStatus == entity.UserStatusDeleted {
|
||||
handler.HandleResponse(ctx, errors.Unauthorized(reason.UnauthorizedError), nil)
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
ctx.Set(ctxUUIDKey, userInfo)
|
||||
ctx.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func (am *AuthUserMiddleware) AdminAuth() gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
token := ExtractToken(ctx)
|
||||
if len(token) == 0 {
|
||||
handler.HandleResponse(ctx, errors.Unauthorized(reason.UnauthorizedError), nil)
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
userInfo, err := am.authService.GetAdminUserCacheInfo(ctx, token)
|
||||
if err != nil || userInfo == nil {
|
||||
handler.HandleResponse(ctx, errors.Forbidden(reason.UnauthorizedError), nil)
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
if userInfo != nil {
|
||||
if userInfo.EmailStatus == entity.EmailStatusToBeVerified {
|
||||
_ = am.authService.RemoveAdminUserCacheInfo(ctx, token)
|
||||
handler.HandleResponse(ctx, errors.Forbidden(reason.EmailNeedToBeVerified),
|
||||
&schema.ForbiddenResp{Type: schema.ForbiddenReasonTypeInactive})
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
if userInfo.UserStatus == entity.UserStatusSuspended {
|
||||
_ = am.authService.RemoveAdminUserCacheInfo(ctx, token)
|
||||
handler.HandleResponse(ctx, errors.Forbidden(reason.UserSuspended),
|
||||
&schema.ForbiddenResp{Type: schema.ForbiddenReasonTypeUserSuspended})
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
if userInfo.UserStatus == entity.UserStatusDeleted {
|
||||
_ = am.authService.RemoveAdminUserCacheInfo(ctx, token)
|
||||
handler.HandleResponse(ctx, errors.Unauthorized(reason.UnauthorizedError), nil)
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
ctx.Set(ctxUUIDKey, userInfo)
|
||||
}
|
||||
ctx.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func (am *AuthUserMiddleware) CheckPrivateMode() gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
resp, err := am.siteInfoCommonService.GetSiteSecurity(ctx)
|
||||
if err != nil {
|
||||
ShowIndexPage(ctx)
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
if resp.LoginRequired {
|
||||
ShowIndexPage(ctx)
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
ctx.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func (am *AuthUserMiddleware) AuthAPIKeyScope(ctx *gin.Context, accessToken string) (apiHaveNoScope bool) {
|
||||
if !strings.HasPrefix(accessToken, "sk_") {
|
||||
return false
|
||||
}
|
||||
var err error
|
||||
pass, err := am.authService.AuthAPIKey(ctx, ctx.Request.Method == "GET", accessToken)
|
||||
if err != nil {
|
||||
handler.HandleResponse(ctx, errors.Forbidden(reason.ForbiddenError), nil)
|
||||
ctx.Abort()
|
||||
return true
|
||||
}
|
||||
if !pass {
|
||||
handler.HandleResponse(ctx, errors.Forbidden(reason.ForbiddenError), nil)
|
||||
ctx.Abort()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func ShowIndexPage(ctx *gin.Context) {
|
||||
ctx.Header("content-type", "text/html;charset=utf-8")
|
||||
ctx.Header("X-Frame-Options", "DENY")
|
||||
file, err := ui.Build.ReadFile("build/index.html")
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
ctx.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
ctx.String(http.StatusOK, string(file))
|
||||
}
|
||||
|
||||
// GetLoginUserIDFromContext get user id from context
|
||||
func GetLoginUserIDFromContext(ctx *gin.Context) (userID string) {
|
||||
userInfo := GetUserInfoFromContext(ctx)
|
||||
if userInfo == nil {
|
||||
return ""
|
||||
}
|
||||
return userInfo.UserID
|
||||
}
|
||||
|
||||
// GetIsAdminFromContext get user is admin from context
|
||||
func GetIsAdminFromContext(ctx *gin.Context) (isAdmin bool) {
|
||||
userInfo := GetUserInfoFromContext(ctx)
|
||||
if userInfo == nil {
|
||||
return false
|
||||
}
|
||||
return userInfo.RoleID == role.RoleAdminID
|
||||
}
|
||||
|
||||
// GetUserInfoFromContext get user info from context
|
||||
func GetUserInfoFromContext(ctx *gin.Context) (u *entity.UserCacheInfo) {
|
||||
userInfo, exist := ctx.Get(ctxUUIDKey)
|
||||
if !exist {
|
||||
return nil
|
||||
}
|
||||
u, ok := userInfo.(*entity.UserCacheInfo)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func GetUserIsAdminModerator(ctx *gin.Context) (isAdminModerator bool) {
|
||||
userInfo, exist := ctx.Get(ctxUUIDKey)
|
||||
if !exist {
|
||||
return false
|
||||
}
|
||||
u, ok := userInfo.(*entity.UserCacheInfo)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if u.RoleID == role.RoleAdminID || u.RoleID == role.RoleModeratorID {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func GetLoginUserIDInt64FromContext(ctx *gin.Context) (userID int64) {
|
||||
userIDStr := GetLoginUserIDFromContext(ctx)
|
||||
return converter.StringToInt64(userIDStr)
|
||||
}
|
||||
|
||||
// ExtractToken extract token from context
|
||||
func ExtractToken(ctx *gin.Context) (token string) {
|
||||
token = ctx.GetHeader("Authorization")
|
||||
if len(token) == 0 {
|
||||
token = ctx.Query("Authorization")
|
||||
}
|
||||
return strings.TrimPrefix(token, "Bearer ")
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/apache/answer/internal/service/service_config"
|
||||
"github.com/apache/answer/internal/service/uploader"
|
||||
"github.com/apache/answer/pkg/converter"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/segmentfault/pacman/log"
|
||||
)
|
||||
|
||||
type AvatarMiddleware struct {
|
||||
serviceConfig *service_config.ServiceConfig
|
||||
uploaderService uploader.UploaderService
|
||||
}
|
||||
|
||||
// NewAvatarMiddleware new auth user middleware
|
||||
func NewAvatarMiddleware(serviceConfig *service_config.ServiceConfig,
|
||||
uploaderService uploader.UploaderService,
|
||||
) *AvatarMiddleware {
|
||||
return &AvatarMiddleware{
|
||||
serviceConfig: serviceConfig,
|
||||
uploaderService: uploaderService,
|
||||
}
|
||||
}
|
||||
|
||||
func (am *AvatarMiddleware) AvatarThumb() gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
uri := ctx.Request.RequestURI
|
||||
if strings.Contains(uri, "/uploads/avatar/") {
|
||||
size := converter.StringToInt(ctx.Query("s"))
|
||||
uriWithoutQuery, _ := url.Parse(uri)
|
||||
filename := filepath.Base(uriWithoutQuery.Path)
|
||||
filePath := fmt.Sprintf("%s/avatar/%s", am.serviceConfig.UploadPath, filename)
|
||||
var err error
|
||||
if size != 0 {
|
||||
filePath, err = am.uploaderService.AvatarThumbFile(ctx, filename, size)
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
ctx.AbortWithStatus(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
avatarFile, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
ctx.Header("content-type", fmt.Sprintf("image/%s", strings.TrimLeft(path.Ext(filePath), ".")))
|
||||
_, err = ctx.Writer.Write(avatarFile)
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
}
|
||||
ctx.Abort()
|
||||
return
|
||||
} else {
|
||||
urlInfo, err := url.Parse(uri)
|
||||
if err != nil {
|
||||
ctx.Next()
|
||||
return
|
||||
}
|
||||
ext := strings.TrimPrefix(filepath.Ext(urlInfo.Path), ".")
|
||||
ctx.Header("content-type", fmt.Sprintf("image/%s", ext))
|
||||
}
|
||||
ctx.Next()
|
||||
}
|
||||
}
|
||||
@@ -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 middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func HeadersByRequestURI() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if strings.HasPrefix(c.Request.RequestURI, "/static/") {
|
||||
c.Header("cache-control", "public, max-age=31536000")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/apache/answer/internal/base/handler"
|
||||
"github.com/apache/answer/internal/base/reason"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/segmentfault/pacman/errors"
|
||||
"github.com/segmentfault/pacman/log"
|
||||
)
|
||||
|
||||
// AuthMcpEnable check mcp is enabled
|
||||
func (am *AuthUserMiddleware) AuthMcpEnable() gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
mcpConfig, err := am.siteInfoCommonService.GetSiteMCP(ctx)
|
||||
if err != nil {
|
||||
handler.HandleResponse(ctx, errors.InternalServer(reason.UnknownError), nil)
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
if mcpConfig != nil && mcpConfig.Enabled {
|
||||
ctx.Next()
|
||||
return
|
||||
}
|
||||
handler.HandleResponse(ctx, errors.Forbidden(reason.ForbiddenError), nil)
|
||||
ctx.Abort()
|
||||
log.Error("abort mcp auth middleware, get mcp config error: ", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/google/wire"
|
||||
)
|
||||
|
||||
// ProviderSetMiddleware is providers.
|
||||
var ProviderSetMiddleware = wire.NewSet(
|
||||
NewAuthUserMiddleware,
|
||||
NewAvatarMiddleware,
|
||||
NewShortIDMiddleware,
|
||||
NewRateLimitMiddleware,
|
||||
)
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/apache/answer/internal/base/handler"
|
||||
"github.com/apache/answer/internal/base/reason"
|
||||
"github.com/apache/answer/internal/repo/limit"
|
||||
"github.com/apache/answer/pkg/encryption"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/segmentfault/pacman/errors"
|
||||
"github.com/segmentfault/pacman/log"
|
||||
)
|
||||
|
||||
type RateLimitMiddleware struct {
|
||||
limitRepo *limit.LimitRepo
|
||||
}
|
||||
|
||||
// NewRateLimitMiddleware new rate limit middleware
|
||||
func NewRateLimitMiddleware(limitRepo *limit.LimitRepo) *RateLimitMiddleware {
|
||||
return &RateLimitMiddleware{
|
||||
limitRepo: limitRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// DuplicateRequestRejection detects and rejects duplicate requests
|
||||
// It only works for the requests that post content. Such as add question, add answer, comment etc.
|
||||
func (rm *RateLimitMiddleware) DuplicateRequestRejection(ctx *gin.Context, req any) (reject bool, key string) {
|
||||
userID := GetLoginUserIDFromContext(ctx)
|
||||
fullPath := ctx.FullPath()
|
||||
reqJson, _ := json.Marshal(req)
|
||||
key = encryption.MD5(fmt.Sprintf("%s:%s:%s", userID, fullPath, string(reqJson)))
|
||||
var err error
|
||||
reject, err = rm.limitRepo.CheckAndRecord(ctx, key)
|
||||
if err != nil {
|
||||
log.Errorf("check and record rate limit error: %s", err.Error())
|
||||
return false, key
|
||||
}
|
||||
if !reject {
|
||||
return false, key
|
||||
}
|
||||
log.Debugf("duplicate request: [%s] %s", fullPath, string(reqJson))
|
||||
handler.HandleResponse(ctx, errors.BadRequest(reason.DuplicateRequestError), nil)
|
||||
return true, key
|
||||
}
|
||||
|
||||
// DuplicateRequestClear clear duplicate request record
|
||||
func (rm *RateLimitMiddleware) DuplicateRequestClear(ctx *gin.Context, key string) {
|
||||
err := rm.limitRepo.ClearRecord(ctx, key)
|
||||
if err != nil {
|
||||
log.Errorf("clear rate limit error: %s", err.Error())
|
||||
}
|
||||
}
|
||||
@@ -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 middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
|
||||
"github.com/apache/answer/internal/base/handler"
|
||||
"github.com/apache/answer/internal/base/reason"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/segmentfault/pacman/log"
|
||||
)
|
||||
|
||||
func Recovery(apiPrefixes ...string) gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
log.Errorf("panic recovered: %v\n%s", err, debug.Stack())
|
||||
|
||||
// Headers/body already flushed (SSE or any streamed response).
|
||||
// We can no longer rewrite the response cleanly; just stop the chain.
|
||||
if ctx.Writer.Written() {
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
path := ctx.Request.URL.Path
|
||||
for _, p := range apiPrefixes {
|
||||
if strings.HasPrefix(path, p) {
|
||||
ctx.AbortWithStatusJSON(http.StatusInternalServerError,
|
||||
handler.NewRespBody(http.StatusInternalServerError, reason.UnknownError).
|
||||
TrMsg(handler.GetLangByCtx(ctx)),
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx.AbortWithStatus(http.StatusInternalServerError)
|
||||
}
|
||||
}()
|
||||
ctx.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Panic on an API path returns the project's unified JSON 500.
|
||||
func TestRecovery_APIPathPanic(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(Recovery("/api"))
|
||||
r.GET("/api/panic", func(ctx *gin.Context) {
|
||||
panic("test panic")
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodGet, "/api/panic", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500, got %d", w.Code)
|
||||
}
|
||||
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("response is not valid JSON: %v", err)
|
||||
}
|
||||
if body["reason"] != "base.unknown" {
|
||||
t.Errorf("unexpected reason: %v", body["reason"])
|
||||
}
|
||||
}
|
||||
|
||||
// Panic on a non-API path returns a bare 500 with no body, so the browser can
|
||||
// render its own error page instead of showing raw JSON.
|
||||
func TestRecovery_NonAPIPathPanic(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(Recovery("/api"))
|
||||
r.GET("/page", func(ctx *gin.Context) {
|
||||
panic("test panic")
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodGet, "/page", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500, got %d", w.Code)
|
||||
}
|
||||
if w.Body.Len() != 0 {
|
||||
t.Errorf("expected empty body for non-API path, got: %q", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Panic after the response has already started writing (SSE / streamed
|
||||
// responses). The middleware must not touch the response — status and body
|
||||
// already on the wire stay untouched, no JSON gets appended.
|
||||
func TestRecovery_PanicAfterResponseStarted(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(Recovery("/api"))
|
||||
r.GET("/api/stream", func(ctx *gin.Context) {
|
||||
ctx.Writer.WriteHeader(http.StatusOK)
|
||||
_, _ = ctx.Writer.Write([]byte("partial data"))
|
||||
panic("test panic after write")
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodGet, "/api/stream", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status to remain 200 (already flushed), got %d", w.Code)
|
||||
}
|
||||
if w.Body.String() != "partial data" {
|
||||
t.Errorf("expected body to remain 'partial data' (no error JSON appended), got: %q", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Normal requests pass through unaffected.
|
||||
func TestRecovery_NoPanic(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(Recovery("/api"))
|
||||
r.GET("/api/ok", func(ctx *gin.Context) {
|
||||
ctx.String(http.StatusOK, "ok")
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodGet, "/api/ok", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -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 middleware
|
||||
|
||||
import (
|
||||
"github.com/apache/answer/internal/base/constant"
|
||||
"github.com/apache/answer/internal/service/siteinfo_common"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/segmentfault/pacman/log"
|
||||
)
|
||||
|
||||
type ShortIDMiddleware struct {
|
||||
siteInfoService siteinfo_common.SiteInfoCommonService
|
||||
}
|
||||
|
||||
func NewShortIDMiddleware(siteInfoService siteinfo_common.SiteInfoCommonService) *ShortIDMiddleware {
|
||||
return &ShortIDMiddleware{
|
||||
siteInfoService: siteInfoService,
|
||||
}
|
||||
}
|
||||
|
||||
func (sm *ShortIDMiddleware) SetShortIDFlag() gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
siteSeo, err := sm.siteInfoService.GetSiteSeo(ctx)
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
return
|
||||
}
|
||||
ctx.Set(constant.ShortIDFlag, siteSeo.IsShortLink())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/apache/answer/internal/base/handler"
|
||||
"github.com/apache/answer/internal/base/reason"
|
||||
"github.com/apache/answer/plugin"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/segmentfault/pacman/errors"
|
||||
)
|
||||
|
||||
// BanAPIForUserCenter ban api for user center
|
||||
func BanAPIForUserCenter(ctx *gin.Context) {
|
||||
uc, ok := plugin.GetUserCenter()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !uc.Description().EnabledOriginalUserSystem {
|
||||
handler.HandleResponse(ctx, errors.Forbidden(reason.ForbiddenError), nil)
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
ctx.Next()
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/apache/answer/internal/base/constant"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// VisitAuth when user visit the site image, check visit token. This only for private mode.
|
||||
func (am *AuthUserMiddleware) VisitAuth() gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
if len(os.Getenv("SKIP_FILE_ACCESS_VERIFY")) > 0 {
|
||||
ctx.Next()
|
||||
return
|
||||
}
|
||||
// If visit brand image, no need to check visit token. Because the brand image is public.
|
||||
if strings.HasPrefix(ctx.Request.URL.Path, "/uploads/branding/") {
|
||||
ctx.Next()
|
||||
return
|
||||
}
|
||||
|
||||
siteSecurity, err := am.siteInfoCommonService.GetSiteSecurity(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if !siteSecurity.LoginRequired {
|
||||
ctx.Next()
|
||||
return
|
||||
}
|
||||
|
||||
visitToken, err := ctx.Cookie(constant.UserVisitCookiesCacheKey)
|
||||
if err != nil || len(visitToken) == 0 {
|
||||
ctx.Abort()
|
||||
ctx.Redirect(http.StatusFound, "/403")
|
||||
return
|
||||
}
|
||||
|
||||
if !am.authService.CheckUserVisitToken(ctx, visitToken) {
|
||||
ctx.Abort()
|
||||
ctx.Redirect(http.StatusFound, "/403")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* 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 pager
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
// Help xorm page helper
|
||||
func Help(page, pageSize int, rowsSlicePtr any, rowElement any, session *xorm.Session) (total int64, err error) {
|
||||
page, pageSize = ValPageAndPageSize(page, pageSize)
|
||||
|
||||
sliceValue := reflect.Indirect(reflect.ValueOf(rowsSlicePtr))
|
||||
if sliceValue.Kind() != reflect.Slice {
|
||||
return 0, errors.New("not a slice")
|
||||
}
|
||||
|
||||
startNum := (page - 1) * pageSize
|
||||
return session.Limit(pageSize, startNum).FindAndCount(rowsSlicePtr, rowElement)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* 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 pager
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
)
|
||||
|
||||
// PageModel page model
|
||||
type PageModel struct {
|
||||
Count int64 `json:"count"`
|
||||
List any `json:"list"`
|
||||
}
|
||||
|
||||
// PageCond page condition
|
||||
type PageCond struct {
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
// NewPageModel new page model
|
||||
func NewPageModel(totalRecords int64, records any) *PageModel {
|
||||
sliceValue := reflect.Indirect(reflect.ValueOf(records))
|
||||
if sliceValue.Kind() != reflect.Slice {
|
||||
panic("not a slice")
|
||||
}
|
||||
|
||||
if totalRecords < 0 {
|
||||
totalRecords = 0
|
||||
}
|
||||
|
||||
return &PageModel{
|
||||
Count: totalRecords,
|
||||
List: records,
|
||||
}
|
||||
}
|
||||
|
||||
// ValPageAndPageSize validate page pageSize
|
||||
func ValPageAndPageSize(page, pageSize int) (int, int) {
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = 10
|
||||
}
|
||||
return page, pageSize
|
||||
}
|
||||
|
||||
// ValPageOutOfRange validate page out of range
|
||||
func ValPageOutOfRange(total int64, page, pageSize int) bool {
|
||||
if total <= 0 {
|
||||
return false
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
return true
|
||||
}
|
||||
totalPages := (total + int64(pageSize) - 1) / int64(pageSize)
|
||||
return page < 1 || page > int(totalPages)
|
||||
}
|
||||
@@ -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 path
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultConfigFileName = "config.yaml"
|
||||
DefaultCacheFileName = "cache.db"
|
||||
DefaultReservedUsernamesConfigFileName = "reserved-usernames.json"
|
||||
)
|
||||
|
||||
var (
|
||||
ConfigFileDir = "/conf/"
|
||||
UploadFilePath = "/uploads/"
|
||||
I18nPath = "/i18n/"
|
||||
CacheDir = "/cache/"
|
||||
formatAllPathOnce sync.Once
|
||||
)
|
||||
|
||||
func FormatAllPath(dataDirPath string) {
|
||||
formatAllPathOnce.Do(func() {
|
||||
ConfigFileDir = filepath.Join(dataDirPath, ConfigFileDir)
|
||||
UploadFilePath = filepath.Join(dataDirPath, UploadFilePath)
|
||||
I18nPath = filepath.Join(dataDirPath, I18nPath)
|
||||
CacheDir = filepath.Join(dataDirPath, CacheDir)
|
||||
})
|
||||
}
|
||||
|
||||
// GetConfigFilePath get config file path
|
||||
func GetConfigFilePath() string {
|
||||
return filepath.Join(ConfigFileDir, DefaultConfigFileName)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* 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 queue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/segmentfault/pacman/log"
|
||||
)
|
||||
|
||||
type Service[T any] interface {
|
||||
// Send enqueues a message to be processed asynchronously.
|
||||
Send(ctx context.Context, msg T)
|
||||
|
||||
// RegisterHandler sets the handler function for processing messages.
|
||||
RegisterHandler(handler func(ctx context.Context, msg T) error)
|
||||
|
||||
// Close gracefully shuts down the queue, waiting for pending messages to be processed.
|
||||
Close()
|
||||
}
|
||||
|
||||
// Queue is a generic message queue service that processes messages asynchronously.
|
||||
// It is thread-safe and supports graceful shutdown.
|
||||
type Queue[T any] struct {
|
||||
name string
|
||||
queue chan T
|
||||
handler func(ctx context.Context, msg T) error
|
||||
mu sync.RWMutex
|
||||
closed bool
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// New creates a new queue with the given name and buffer size.
|
||||
func New[T any](name string, bufferSize int) *Queue[T] {
|
||||
q := &Queue[T]{
|
||||
name: name,
|
||||
queue: make(chan T, bufferSize),
|
||||
}
|
||||
q.startWorker()
|
||||
return q
|
||||
}
|
||||
|
||||
// Send enqueues a message to be processed asynchronously.
|
||||
// It will block if the queue is full.
|
||||
func (q *Queue[T]) Send(ctx context.Context, msg T) {
|
||||
q.mu.RLock()
|
||||
defer q.mu.RUnlock()
|
||||
|
||||
if q.closed {
|
||||
log.Warnf("[%s] queue is closed, dropping message", q.name)
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case q.queue <- msg:
|
||||
log.Debugf("[%s] enqueued message: %+v", q.name, msg)
|
||||
case <-ctx.Done():
|
||||
log.Warnf("[%s] context cancelled while sending message", q.name)
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterHandler sets the handler function for processing messages.
|
||||
// This is thread-safe and can be called at any time.
|
||||
func (q *Queue[T]) RegisterHandler(handler func(ctx context.Context, msg T) error) {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
q.handler = handler
|
||||
}
|
||||
|
||||
// Close gracefully shuts down the queue, waiting for pending messages to be processed.
|
||||
func (q *Queue[T]) Close() {
|
||||
q.mu.Lock()
|
||||
if q.closed {
|
||||
q.mu.Unlock()
|
||||
return
|
||||
}
|
||||
q.closed = true
|
||||
q.mu.Unlock()
|
||||
|
||||
close(q.queue)
|
||||
q.wg.Wait()
|
||||
log.Infof("[%s] queue closed", q.name)
|
||||
}
|
||||
|
||||
// startWorker starts the background goroutine that processes messages.
|
||||
func (q *Queue[T]) startWorker() {
|
||||
q.wg.Go(func() {
|
||||
for msg := range q.queue {
|
||||
q.processMessage(msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// processMessage handles a single message with proper synchronization.
|
||||
func (q *Queue[T]) processMessage(msg T) {
|
||||
q.mu.RLock()
|
||||
handler := q.handler
|
||||
q.mu.RUnlock()
|
||||
|
||||
if handler == nil {
|
||||
log.Warnf("[%s] no handler registered, dropping message: %+v", q.name, msg)
|
||||
return
|
||||
}
|
||||
|
||||
// Use background context for async processing
|
||||
// TODO: Consider adding timeout or using a derived context
|
||||
if err := handler(context.TODO(), msg); err != nil {
|
||||
log.Errorf("[%s] handler error: %v", q.name, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
/*
|
||||
* 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 queue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type testMessage struct {
|
||||
ID int
|
||||
Data string
|
||||
}
|
||||
|
||||
func TestQueue_SendAndReceive(t *testing.T) {
|
||||
q := New[*testMessage]("test", 10)
|
||||
defer q.Close()
|
||||
|
||||
received := make(chan *testMessage, 1)
|
||||
q.RegisterHandler(func(ctx context.Context, msg *testMessage) error {
|
||||
received <- msg
|
||||
return nil
|
||||
})
|
||||
|
||||
msg := &testMessage{ID: 1, Data: "hello"}
|
||||
q.Send(context.Background(), msg)
|
||||
|
||||
select {
|
||||
case r := <-received:
|
||||
if r.ID != msg.ID || r.Data != msg.Data {
|
||||
t.Errorf("received message mismatch: got %+v, want %+v", r, msg)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timeout waiting for message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueue_MultipleMessages(t *testing.T) {
|
||||
q := New[*testMessage]("test", 10)
|
||||
defer q.Close()
|
||||
|
||||
var count atomic.Int32
|
||||
var wg sync.WaitGroup
|
||||
numMessages := 100
|
||||
wg.Add(numMessages)
|
||||
|
||||
q.RegisterHandler(func(ctx context.Context, msg *testMessage) error {
|
||||
count.Add(1)
|
||||
wg.Done()
|
||||
return nil
|
||||
})
|
||||
|
||||
for i := range numMessages {
|
||||
q.Send(context.Background(), &testMessage{ID: i})
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
if int(count.Load()) != numMessages {
|
||||
t.Errorf("expected %d messages, got %d", numMessages, count.Load())
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatalf("timeout: only received %d of %d messages", count.Load(), numMessages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueue_NoHandlerDropsMessage(t *testing.T) {
|
||||
q := New[*testMessage]("test", 10)
|
||||
defer q.Close()
|
||||
|
||||
// Send without handler - should not panic
|
||||
q.Send(context.Background(), &testMessage{ID: 1})
|
||||
|
||||
// Give time for the message to be processed (dropped)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
func TestQueue_RegisterHandlerAfterSend(t *testing.T) {
|
||||
q := New[*testMessage]("test", 10)
|
||||
defer q.Close()
|
||||
|
||||
received := make(chan *testMessage, 1)
|
||||
|
||||
// Send first
|
||||
q.Send(context.Background(), &testMessage{ID: 1})
|
||||
|
||||
// Small delay then register handler
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
q.RegisterHandler(func(ctx context.Context, msg *testMessage) error {
|
||||
received <- msg
|
||||
return nil
|
||||
})
|
||||
|
||||
// Send another message that should be received
|
||||
q.Send(context.Background(), &testMessage{ID: 2})
|
||||
|
||||
select {
|
||||
case r := <-received:
|
||||
if r.ID != 2 {
|
||||
// First message was dropped (no handler), second should be received
|
||||
t.Logf("received message ID: %d", r.ID)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timeout waiting for message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueue_Close(t *testing.T) {
|
||||
q := New[*testMessage]("test", 10)
|
||||
|
||||
var count atomic.Int32
|
||||
q.RegisterHandler(func(ctx context.Context, msg *testMessage) error {
|
||||
count.Add(1)
|
||||
return nil
|
||||
})
|
||||
|
||||
// Send some messages
|
||||
for i := range 5 {
|
||||
q.Send(context.Background(), &testMessage{ID: i})
|
||||
}
|
||||
|
||||
// Close and wait
|
||||
q.Close()
|
||||
|
||||
// All messages should have been processed
|
||||
if count.Load() != 5 {
|
||||
t.Errorf("expected 5 messages processed, got %d", count.Load())
|
||||
}
|
||||
|
||||
// Sending after close should not panic
|
||||
q.Send(context.Background(), &testMessage{ID: 99})
|
||||
}
|
||||
|
||||
func TestQueue_ConcurrentSend(t *testing.T) {
|
||||
q := New[*testMessage]("test", 100)
|
||||
defer q.Close()
|
||||
|
||||
var count atomic.Int32
|
||||
q.RegisterHandler(func(ctx context.Context, msg *testMessage) error {
|
||||
count.Add(1)
|
||||
return nil
|
||||
})
|
||||
|
||||
var wg sync.WaitGroup
|
||||
numGoroutines := 10
|
||||
messagesPerGoroutine := 100
|
||||
|
||||
for i := range numGoroutines {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
for j := range messagesPerGoroutine {
|
||||
q.Send(context.Background(), &testMessage{ID: id*1000 + j})
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Wait for processing
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
expected := int32(numGoroutines * messagesPerGoroutine)
|
||||
if count.Load() != expected {
|
||||
t.Errorf("expected %d messages, got %d", expected, count.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueue_ConcurrentRegisterHandler(t *testing.T) {
|
||||
q := New[*testMessage]("test", 10)
|
||||
defer q.Close()
|
||||
|
||||
// Concurrently register handlers - should not race
|
||||
var wg sync.WaitGroup
|
||||
for range 10 {
|
||||
wg.Go(func() {
|
||||
q.RegisterHandler(func(ctx context.Context, msg *testMessage) error {
|
||||
return nil
|
||||
})
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// TestQueue_SendCloseRace is a regression test for the race condition between
|
||||
// Send and Close. Without proper synchronization, concurrent Send and Close
|
||||
// calls could cause a "send on closed channel" panic.
|
||||
// Run with: go test -race -run TestQueue_SendCloseRace
|
||||
func TestQueue_SendCloseRace(t *testing.T) {
|
||||
for i := range 100 {
|
||||
t.Run(fmt.Sprintf("iteration_%d", i), func(t *testing.T) {
|
||||
// Use large buffer to avoid blocking on channel send while holding RLock
|
||||
q := New[*testMessage]("test-race", 1000)
|
||||
q.RegisterHandler(func(ctx context.Context, msg *testMessage) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Use cancellable context so senders can exit when Close is called
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
// Start multiple senders
|
||||
for j := range 10 {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
for k := range 100 {
|
||||
q.Send(ctx, &testMessage{ID: id*1000 + k})
|
||||
}
|
||||
}(j)
|
||||
}
|
||||
|
||||
// Close while senders are still running
|
||||
go func() {
|
||||
time.Sleep(time.Microsecond * 10)
|
||||
cancel() // Cancel context to unblock any waiting senders
|
||||
q.Close()
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package reason
|
||||
|
||||
const (
|
||||
PrivilegeLevel1Desc = "privilege.level_1.description"
|
||||
PrivilegeLevel2Desc = "privilege.level_2.description"
|
||||
PrivilegeLevel3Desc = "privilege.level_3.description"
|
||||
PrivilegeLevelCustomDesc = "privilege.level_custom.description"
|
||||
|
||||
RankQuestionAddLabel = "privilege.rank_question_add_label"
|
||||
RankAnswerAddLabel = "privilege.rank_answer_add_label"
|
||||
RankCommentAddLabel = "privilege.rank_comment_add_label"
|
||||
RankReportAddLabel = "privilege.rank_report_add_label"
|
||||
RankCommentVoteUpLabel = "privilege.rank_comment_vote_up_label"
|
||||
RankLinkUrlLimitLabel = "privilege.rank_link_url_limit_label"
|
||||
RankQuestionVoteUpLabel = "privilege.rank_question_vote_up_label"
|
||||
RankAnswerVoteUpLabel = "privilege.rank_answer_vote_up_label"
|
||||
RankQuestionVoteDownLabel = "privilege.rank_question_vote_down_label"
|
||||
RankAnswerVoteDownLabel = "privilege.rank_answer_vote_down_label"
|
||||
RankInviteSomeoneToAnswerLabel = "privilege.rank_invite_someone_to_answer_label"
|
||||
RankTagAddLabel = "privilege.rank_tag_add_label"
|
||||
RankTagEditLabel = "privilege.rank_tag_edit_label"
|
||||
RankQuestionEditLabel = "privilege.rank_question_edit_label"
|
||||
RankAnswerEditLabel = "privilege.rank_answer_edit_label"
|
||||
RankQuestionEditWithoutReviewLabel = "privilege.rank_question_edit_without_review_label"
|
||||
RankAnswerEditWithoutReviewLabel = "privilege.rank_answer_edit_without_review_label"
|
||||
RankQuestionAuditLabel = "privilege.rank_question_audit_label"
|
||||
RankAnswerAuditLabel = "privilege.rank_answer_audit_label"
|
||||
RankTagAuditLabel = "privilege.rank_tag_audit_label"
|
||||
RankTagEditWithoutReviewLabel = "privilege.rank_tag_edit_without_review_label"
|
||||
RankTagSynonymLabel = "privilege.rank_tag_synonym_label"
|
||||
)
|
||||
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* 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 reason
|
||||
|
||||
const (
|
||||
// Success .
|
||||
Success = "base.success"
|
||||
// UnknownError unknown error
|
||||
UnknownError = "base.unknown"
|
||||
// RequestFormatError request format error
|
||||
RequestFormatError = "base.request_format_error"
|
||||
// UnauthorizedError unauthorized error
|
||||
UnauthorizedError = "base.unauthorized_error"
|
||||
// DatabaseError database error
|
||||
DatabaseError = "base.database_error"
|
||||
// ForbiddenError forbidden error
|
||||
ForbiddenError = "base.forbidden_error"
|
||||
// DuplicateRequestError duplicate request error
|
||||
DuplicateRequestError = "base.duplicate_request_error"
|
||||
)
|
||||
|
||||
const (
|
||||
EmailOrPasswordWrong = "error.object.email_or_password_incorrect"
|
||||
CommentNotFound = "error.comment.not_found"
|
||||
CommentCannotEditAfterDeadline = "error.comment.cannot_edit_after_deadline"
|
||||
QuestionNotFound = "error.question.not_found"
|
||||
QuestionCannotDeleted = "error.question.cannot_deleted"
|
||||
QuestionCannotClose = "error.question.cannot_close"
|
||||
QuestionCannotUpdate = "error.question.cannot_update"
|
||||
QuestionAlreadyDeleted = "error.question.already_deleted"
|
||||
QuestionUnderReview = "error.question.under_review"
|
||||
QuestionContentCannotEmpty = "error.question.content_cannot_empty"
|
||||
QuestionContentLessThanMinimum = "error.question.content_less_than_minimum"
|
||||
AnswerNotFound = "error.answer.not_found"
|
||||
AnswerCannotDeleted = "error.answer.cannot_deleted"
|
||||
AnswerCannotUpdate = "error.answer.cannot_update"
|
||||
AnswerCannotAddByClosedQuestion = "error.answer.question_closed_cannot_add"
|
||||
AnswerRestrictAnswer = "error.answer.restrict_answer"
|
||||
AnswerContentCannotEmpty = "error.answer.content_cannot_empty"
|
||||
CommentEditWithoutPermission = "error.comment.edit_without_permission"
|
||||
CommentContentCannotEmpty = "error.comment.content_cannot_empty"
|
||||
DisallowVote = "error.object.disallow_vote"
|
||||
DisallowFollow = "error.object.disallow_follow"
|
||||
DisallowVoteYourSelf = "error.object.disallow_vote_your_self"
|
||||
CaptchaVerificationFailed = "error.object.captcha_verification_failed"
|
||||
OldPasswordVerificationFailed = "error.object.old_password_verification_failed"
|
||||
NewPasswordSameAsPreviousSetting = "error.object.new_password_same_as_previous_setting"
|
||||
NewObjectAlreadyDeleted = "error.object.already_deleted"
|
||||
UserNotFound = "error.user.not_found"
|
||||
UsernameInvalid = "error.user.username_invalid"
|
||||
UsernameDuplicate = "error.user.username_duplicate"
|
||||
UserSetAvatar = "error.user.set_avatar"
|
||||
EmailDuplicate = "error.email.duplicate"
|
||||
EmailVerifyURLExpired = "error.email.verify_url_expired"
|
||||
EmailNeedToBeVerified = "error.email.need_to_be_verified"
|
||||
EmailIllegalDomainError = "error.email.illegal_email_domain_error"
|
||||
UserSuspended = "error.user.suspended"
|
||||
ObjectNotFound = "error.object.not_found"
|
||||
TagNotFound = "error.tag.not_found"
|
||||
TagNotContainSynonym = "error.tag.not_contain_synonym_tags"
|
||||
TagCannotUpdate = "error.tag.cannot_update"
|
||||
TagIsUsedCannotDelete = "error.tag.is_used_cannot_delete"
|
||||
TagAlreadyExist = "error.tag.already_exist"
|
||||
TagMinCount = "error.tag.minimum_count"
|
||||
RankFailToMeetTheCondition = "error.rank.fail_to_meet_the_condition"
|
||||
VoteRankFailToMeetTheCondition = "error.rank.vote_fail_to_meet_the_condition"
|
||||
NoEnoughRankToOperate = "error.rank.no_enough_rank_to_operate"
|
||||
ThemeNotFound = "error.theme.not_found"
|
||||
LangNotFound = "error.lang.not_found"
|
||||
ReportHandleFailed = "error.report.handle_failed"
|
||||
ReportNotFound = "error.report.not_found"
|
||||
ReadConfigFailed = "error.config.read_config_failed"
|
||||
DatabaseConnectionFailed = "error.database.connection_failed"
|
||||
InstallCreateTableFailed = "error.database.create_table_failed"
|
||||
InstallConfigFailed = "error.install.create_config_failed"
|
||||
SiteInfoConfigNotFound = "error.site_info.config_not_found"
|
||||
UploadFileSourceUnsupported = "error.upload.source_unsupported"
|
||||
UploadFileUnsupportedFileFormat = "error.upload.unsupported_file_format"
|
||||
RecommendTagNotExist = "error.tag.recommend_tag_not_found"
|
||||
RecommendTagEnter = "error.tag.recommend_tag_enter"
|
||||
RevisionReviewUnderway = "error.revision.review_underway"
|
||||
RevisionNoPermission = "error.revision.no_permission"
|
||||
UserCannotUpdateYourRole = "error.user.cannot_update_your_role"
|
||||
TagCannotSetSynonymAsItself = "error.tag.cannot_set_synonym_as_itself"
|
||||
NotAllowedRegistration = "error.user.not_allowed_registration"
|
||||
NotAllowedLoginViaPassword = "error.user.not_allowed_login_via_password"
|
||||
SMTPConfigFromNameCannotBeEmail = "error.smtp.config_from_name_cannot_be_email"
|
||||
AdminCannotUpdateTheirPassword = "error.admin.cannot_update_their_password"
|
||||
AdminCannotEditTheirProfile = "error.admin.cannot_edit_their_profile"
|
||||
AdminCannotModifySelfStatus = "error.admin.cannot_modify_self_status"
|
||||
UserAccessDenied = "error.user.access_denied"
|
||||
UserPageAccessDenied = "error.user.page_access_denied"
|
||||
AddBulkUsersFormatError = "error.user.add_bulk_users_format_error"
|
||||
AddBulkUsersAmountError = "error.user.add_bulk_users_amount_error"
|
||||
InvalidURLError = "error.common.invalid_url"
|
||||
MetaObjectNotFound = "error.meta.object_not_found"
|
||||
BadgeObjectNotFound = "error.badge.object_not_found"
|
||||
StatusInvalid = "error.common.status_invalid"
|
||||
UserStatusInactive = "error.user.status_inactive"
|
||||
UserStatusSuspendedForever = "error.user.status_suspended_forever"
|
||||
UserStatusSuspendedUntil = "error.user.status_suspended_until"
|
||||
UserStatusDeleted = "error.user.status_deleted"
|
||||
ErrFeatureDisabled = "error.feature.disabled"
|
||||
)
|
||||
|
||||
// user external login reasons
|
||||
const (
|
||||
UserExternalLoginUnbindingForbidden = "error.user.external_login_unbinding_forbidden"
|
||||
UserExternalLoginMissingUserID = "error.user.external_login_missing_user_id"
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* 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 server
|
||||
|
||||
// HTTP http config
|
||||
type HTTP struct {
|
||||
Addr string `json:"addr" mapstructure:"addr"`
|
||||
}
|
||||
|
||||
// UI ui config
|
||||
type UI struct {
|
||||
BaseURL string `json:"base_url" mapstructure:"base_url" yaml:"base_url"`
|
||||
APIBaseURL string `json:"api_base_url" mapstructure:"api_base_url" yaml:"api_base_url"`
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* 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 server
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
brotli "github.com/anargu/gin-brotli"
|
||||
"github.com/apache/answer/internal/base/middleware"
|
||||
"github.com/apache/answer/internal/router"
|
||||
"github.com/apache/answer/plugin"
|
||||
"github.com/apache/answer/ui"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// NewHTTPServer new http server.
|
||||
func NewHTTPServer(debug bool,
|
||||
staticRouter *router.StaticRouter,
|
||||
answerRouter *router.AnswerAPIRouter,
|
||||
swaggerRouter *router.SwaggerRouter,
|
||||
viewRouter *router.UIRouter,
|
||||
authUserMiddleware *middleware.AuthUserMiddleware,
|
||||
avatarMiddleware *middleware.AvatarMiddleware,
|
||||
shortIDMiddleware *middleware.ShortIDMiddleware,
|
||||
templateRouter *router.TemplateRouter,
|
||||
pluginAPIRouter *router.PluginAPIRouter,
|
||||
uiConf *UI,
|
||||
) *gin.Engine {
|
||||
if debug {
|
||||
gin.SetMode(gin.DebugMode)
|
||||
} else {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
r := gin.New()
|
||||
r.Use(middleware.Recovery(
|
||||
uiConf.APIBaseURL+"/answer/api/v1",
|
||||
uiConf.APIBaseURL+"/answer/admin/api",
|
||||
))
|
||||
r.Use(func(ctx *gin.Context) {
|
||||
if strings.Contains(ctx.Request.URL.Path, "/chat/completions") {
|
||||
return
|
||||
}
|
||||
brotli.Brotli(brotli.DefaultCompression)(ctx)
|
||||
}, middleware.ExtractAndSetAcceptLanguage, shortIDMiddleware.SetShortIDFlag())
|
||||
r.GET("/healthz", func(ctx *gin.Context) { ctx.String(200, "OK") })
|
||||
|
||||
templatePath := os.Getenv("ANSWER_TEMPLATE_PATH")
|
||||
if templatePath != "" {
|
||||
r.LoadHTMLGlob(templatePath)
|
||||
} else {
|
||||
html, _ := fs.Sub(ui.Template, "template")
|
||||
htmlTemplate := template.Must(template.New("").Funcs(funcMap).ParseFS(html, "*"))
|
||||
r.SetHTMLTemplate(htmlTemplate)
|
||||
}
|
||||
r.Use(middleware.HeadersByRequestURI())
|
||||
viewRouter.Register(r, uiConf.BaseURL)
|
||||
|
||||
rootGroup := r.Group("")
|
||||
swaggerRouter.Register(rootGroup)
|
||||
static := r.Group(uiConf.APIBaseURL)
|
||||
static.Use(avatarMiddleware.AvatarThumb(), authUserMiddleware.VisitAuth())
|
||||
staticRouter.RegisterStaticRouter(static)
|
||||
|
||||
// The route must be available without logging in
|
||||
mustUnAuthV1 := r.Group(uiConf.APIBaseURL + "/answer/api/v1")
|
||||
answerRouter.RegisterMustUnAuthAnswerAPIRouter(authUserMiddleware, mustUnAuthV1)
|
||||
|
||||
// register api that no need to login
|
||||
unAuthV1 := r.Group(uiConf.APIBaseURL + "/answer/api/v1")
|
||||
unAuthV1.Use(authUserMiddleware.Auth(), authUserMiddleware.EjectUserBySiteInfo())
|
||||
answerRouter.RegisterUnAuthAnswerAPIRouter(unAuthV1)
|
||||
|
||||
// register api that must be authenticated but no need to check account status
|
||||
authWithoutStatusV1 := r.Group(uiConf.APIBaseURL + "/answer/api/v1")
|
||||
authWithoutStatusV1.Use(authUserMiddleware.MustAuthWithoutAccountAvailable())
|
||||
answerRouter.RegisterAuthUserWithAnyStatusAnswerAPIRouter(authWithoutStatusV1)
|
||||
|
||||
// register api that must be authenticated
|
||||
authV1 := r.Group(uiConf.APIBaseURL + "/answer/api/v1")
|
||||
authV1.Use(authUserMiddleware.MustAuthAndAccountAvailable())
|
||||
answerRouter.RegisterAnswerAPIRouter(authV1)
|
||||
|
||||
adminauthV1 := r.Group(uiConf.APIBaseURL + "/answer/admin/api")
|
||||
adminauthV1.Use(authUserMiddleware.AdminAuth())
|
||||
answerRouter.RegisterAnswerAdminAPIRouter(adminauthV1)
|
||||
|
||||
templateRouter.RegisterTemplateRouter(rootGroup, uiConf.BaseURL)
|
||||
|
||||
// plugin routes
|
||||
pluginAPIRouter.RegisterUnAuthConnectorRouter(mustUnAuthV1)
|
||||
pluginAPIRouter.RegisterAuthUserConnectorRouter(authV1)
|
||||
pluginAPIRouter.RegisterAuthAdminConnectorRouter(adminauthV1)
|
||||
|
||||
_ = plugin.CallAgent(func(agent plugin.Agent) error {
|
||||
agent.RegisterUnAuthRouter(mustUnAuthV1)
|
||||
agent.RegisterAuthUserRouter(authV1)
|
||||
agent.RegisterAuthAdminRouter(adminauthV1)
|
||||
return nil
|
||||
})
|
||||
|
||||
// mcp
|
||||
mcpAPIGroup := r.Group(uiConf.APIBaseURL + "/answer/api/v1")
|
||||
mcpAPIGroup.Use(authUserMiddleware.AuthMcpEnable(), authUserMiddleware.AuthAPIKey())
|
||||
answerRouter.RegisterMCPRouter(mcpAPIGroup)
|
||||
return r
|
||||
}
|
||||
@@ -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 server
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/apache/answer/internal/base/translator"
|
||||
"github.com/apache/answer/internal/controller"
|
||||
"github.com/apache/answer/internal/schema"
|
||||
"github.com/apache/answer/pkg/converter"
|
||||
"github.com/apache/answer/pkg/day"
|
||||
"github.com/apache/answer/pkg/htmltext"
|
||||
"github.com/segmentfault/pacman/i18n"
|
||||
)
|
||||
|
||||
var funcMap = template.FuncMap{
|
||||
"replaceHTMLTag": func(src string, tags ...string) string {
|
||||
p := `(?U)<(\d+)>.+</(\d+)>`
|
||||
|
||||
re := regexp.MustCompile(p)
|
||||
ms := re.FindAllStringSubmatch(src, -1)
|
||||
for _, mi := range ms {
|
||||
if mi[1] == mi[2] {
|
||||
i, err := strconv.Atoi(mi[1])
|
||||
if err != nil || len(tags) < i {
|
||||
break
|
||||
}
|
||||
|
||||
src = strings.ReplaceAll(src, mi[0], tags[i-1])
|
||||
}
|
||||
}
|
||||
|
||||
return src
|
||||
},
|
||||
"join": func(sep string, elems ...string) string {
|
||||
return strings.Join(elems, sep)
|
||||
},
|
||||
"templateHTML": func(data string) template.HTML {
|
||||
return template.HTML(data)
|
||||
},
|
||||
"formatLinkNofollow": func(data string) template.HTML {
|
||||
return template.HTML(FormatLinkNofollow(data))
|
||||
},
|
||||
"translator": func(la i18n.Language, data string, params ...any) string {
|
||||
trans := translator.GlobalTrans.Tr(la, data)
|
||||
|
||||
if len(params) > 0 && len(params)%2 == 0 {
|
||||
for i := 0; i < len(params); i += 2 {
|
||||
k := converter.InterfaceToString(params[i])
|
||||
v := converter.InterfaceToString(params[i+1])
|
||||
trans = strings.ReplaceAll(trans, "{{ "+k+" }}", v)
|
||||
trans = strings.ReplaceAll(trans, "{{"+k+"}}", v)
|
||||
}
|
||||
}
|
||||
|
||||
return trans
|
||||
},
|
||||
"timeFormatISO": func(tz string, timestamp int64) string {
|
||||
_, _ = time.LoadLocation(tz)
|
||||
return time.Unix(timestamp, 0).Format("2006-01-02T15:04:05.000Z")
|
||||
},
|
||||
"translatorTimeFormatLongDate": func(la i18n.Language, tz string, timestamp int64) string {
|
||||
trans := translator.GlobalTrans.Tr(la, "ui.dates.long_date_with_time")
|
||||
return day.Format(timestamp, trans, tz)
|
||||
},
|
||||
"translatorTimeFormat": func(la i18n.Language, tz string, timestamp int64) string {
|
||||
var (
|
||||
now = time.Now().Unix()
|
||||
between int64 = 0
|
||||
trans string
|
||||
)
|
||||
_, _ = time.LoadLocation(tz)
|
||||
if now > timestamp {
|
||||
between = now - timestamp
|
||||
}
|
||||
|
||||
if between <= 1 {
|
||||
return translator.GlobalTrans.Tr(la, "ui.dates.now")
|
||||
}
|
||||
|
||||
if between > 1 && between < 60 {
|
||||
trans = translator.GlobalTrans.Tr(la, "ui.dates.x_seconds_ago")
|
||||
return strings.ReplaceAll(trans, "{{count}}", converter.IntToString(between))
|
||||
}
|
||||
|
||||
if between >= 60 && between < 3600 {
|
||||
min := between / 60
|
||||
trans = translator.GlobalTrans.Tr(la, "ui.dates.x_minutes_ago")
|
||||
return strings.ReplaceAll(trans, "{{count}}", strconv.FormatInt(min, 10))
|
||||
}
|
||||
|
||||
if between >= 3600 && between < 3600*24 {
|
||||
h := between / 3600
|
||||
trans = translator.GlobalTrans.Tr(la, "ui.dates.x_hours_ago")
|
||||
return strings.ReplaceAll(trans, "{{count}}", strconv.FormatInt(h, 10))
|
||||
}
|
||||
|
||||
if between >= 3600*24 &&
|
||||
between < 3600*24*366 &&
|
||||
time.Unix(timestamp, 0).Format("2006") == time.Unix(now, 0).Format("2006") {
|
||||
trans = translator.GlobalTrans.Tr(la, "ui.dates.long_date")
|
||||
return day.Format(timestamp, trans, tz)
|
||||
}
|
||||
|
||||
trans = translator.GlobalTrans.Tr(la, "ui.dates.long_date_with_year")
|
||||
return day.Format(timestamp, trans, tz)
|
||||
},
|
||||
"wrapComments": func(comments []*schema.GetCommentResp, la i18n.Language, tz string) map[string]any {
|
||||
return map[string]any{
|
||||
"comments": comments,
|
||||
"language": la,
|
||||
"timezone": tz,
|
||||
}
|
||||
},
|
||||
"urlTitle": htmltext.UrlTitle,
|
||||
}
|
||||
|
||||
func FormatLinkNofollow(html string) string {
|
||||
var hrefRegexp = regexp.MustCompile("(?m)<a.*?[^<]>.*?</a>")
|
||||
match := hrefRegexp.FindAllString(html, -1)
|
||||
for _, v := range match {
|
||||
hasNofollow := strings.Contains(v, "rel=\"nofollow\"")
|
||||
hasSiteUrl := strings.Contains(v, controller.SiteUrl)
|
||||
if !hasSiteUrl {
|
||||
if !hasNofollow {
|
||||
nofollowUrl := strings.Replace(v, "<a", "<a rel=\"nofollow\"", 1)
|
||||
html = strings.Replace(html, v, nofollowUrl, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
return html
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* 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 server
|
||||
|
||||
import "github.com/google/wire"
|
||||
|
||||
// ProviderSetServer is providers.
|
||||
var ProviderSetServer = wire.NewSet(NewHTTPServer)
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* 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 translator
|
||||
|
||||
// I18n i18n config
|
||||
type I18n struct {
|
||||
BundleDir string `json:"bundle_dir" mapstructure:"bundle_dir" yaml:"bundle_dir"`
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
/*
|
||||
* 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 translator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/google/wire"
|
||||
myTran "github.com/segmentfault/pacman/contrib/i18n"
|
||||
"github.com/segmentfault/pacman/i18n"
|
||||
"github.com/segmentfault/pacman/log"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// ProviderSet is providers.
|
||||
var ProviderSet = wire.NewSet(NewTranslator)
|
||||
var GlobalTrans i18n.Translator
|
||||
|
||||
// LangOption language option
|
||||
type LangOption struct {
|
||||
Label string `json:"label"`
|
||||
Value string `json:"value"`
|
||||
// Translation completion percentage
|
||||
Progress int `json:"progress"`
|
||||
}
|
||||
|
||||
// DefaultLangOption default language option. If user config the language is default, the language option is admin choose.
|
||||
const DefaultLangOption = "Default"
|
||||
|
||||
var (
|
||||
// LanguageOptions language
|
||||
LanguageOptions []*LangOption
|
||||
)
|
||||
|
||||
// NewTranslator new a translator
|
||||
func NewTranslator(c *I18n) (tr i18n.Translator, err error) {
|
||||
entries, err := os.ReadDir(c.BundleDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// read the Bundle resources file from entries
|
||||
for _, file := range entries {
|
||||
// ignore directory
|
||||
if file.IsDir() {
|
||||
continue
|
||||
}
|
||||
// ignore non-YAML file
|
||||
if filepath.Ext(file.Name()) != ".yaml" && file.Name() != "i18n.yaml" {
|
||||
continue
|
||||
}
|
||||
log.Debugf("try to read file: %s", file.Name())
|
||||
buf, err := os.ReadFile(filepath.Join(c.BundleDir, file.Name()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read file failed: %s %s", file.Name(), err)
|
||||
}
|
||||
|
||||
// parse the backend translation
|
||||
originalTr := struct {
|
||||
Backend map[string]map[string]any `yaml:"backend"`
|
||||
UI map[string]any `yaml:"ui"`
|
||||
Plugin map[string]any `yaml:"plugin"`
|
||||
}{}
|
||||
if err = yaml.Unmarshal(buf, &originalTr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
translation := make(map[string]any, 0)
|
||||
for k, v := range originalTr.Backend {
|
||||
translation[k] = v
|
||||
}
|
||||
translation["backend"] = originalTr.Backend
|
||||
translation["ui"] = originalTr.UI
|
||||
translation["plugin"] = originalTr.Plugin
|
||||
|
||||
content, err := yaml.Marshal(translation)
|
||||
if err != nil {
|
||||
log.Debugf("marshal translation content failed: %s %s", file.Name(), err)
|
||||
continue
|
||||
}
|
||||
|
||||
// add translator use backend translation
|
||||
if err = myTran.AddTranslator(content, file.Name()); err != nil {
|
||||
log.Debugf("add translator failed: %s %s", file.Name(), err)
|
||||
reportTranslatorFormatError(file.Name(), buf)
|
||||
continue
|
||||
}
|
||||
}
|
||||
GlobalTrans = myTran.GlobalTrans
|
||||
|
||||
i18nFile, err := os.ReadFile(filepath.Join(c.BundleDir, "i18n.yaml"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read i18n file failed: %s", err)
|
||||
}
|
||||
|
||||
s := struct {
|
||||
LangOption []*LangOption `yaml:"language_options"`
|
||||
}{}
|
||||
err = yaml.Unmarshal(i18nFile, &s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("i18n file parsing failed: %s", err)
|
||||
}
|
||||
LanguageOptions = s.LangOption
|
||||
for _, option := range LanguageOptions {
|
||||
option.Label = fmt.Sprintf("%s (%d%%)", option.Label, option.Progress)
|
||||
}
|
||||
return GlobalTrans, err
|
||||
}
|
||||
|
||||
// CheckLanguageIsValid check user input language is valid
|
||||
func CheckLanguageIsValid(lang string) bool {
|
||||
if lang == DefaultLangOption {
|
||||
return true
|
||||
}
|
||||
for _, option := range LanguageOptions {
|
||||
if option.Value == lang {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Tr use language to translate data. If this language translation is not available, return default english translation.
|
||||
func Tr(lang i18n.Language, data string) string {
|
||||
if GlobalTrans == nil {
|
||||
return data
|
||||
}
|
||||
translation := GlobalTrans.Tr(lang, data)
|
||||
if translation == data {
|
||||
return GlobalTrans.Tr(i18n.DefaultLanguage, data)
|
||||
}
|
||||
return translation
|
||||
}
|
||||
|
||||
// TrWithData translate key with template data, it will replace the template data {{ .PlaceHolder }} in the translation.
|
||||
func TrWithData(lang i18n.Language, key string, templateData any) string {
|
||||
if GlobalTrans == nil {
|
||||
return key
|
||||
}
|
||||
translation := GlobalTrans.TrWithData(lang, key, templateData)
|
||||
if translation == key {
|
||||
return GlobalTrans.TrWithData(i18n.DefaultLanguage, key, templateData)
|
||||
}
|
||||
return translation
|
||||
}
|
||||
|
||||
// reportTranslatorFormatError re-parses the YAML file to locate the invalid entry
|
||||
// when go-i18n fails to add the translator.
|
||||
func reportTranslatorFormatError(fileName string, content []byte) {
|
||||
var raw any
|
||||
if err := yaml.Unmarshal(content, &raw); err != nil {
|
||||
log.Errorf("parse translator file %s failed when diagnosing format error: %s", fileName, err)
|
||||
return
|
||||
}
|
||||
if err := inspectTranslatorNode(raw, nil, true); err != nil {
|
||||
log.Errorf("translator file %s invalid: %s", fileName, err)
|
||||
}
|
||||
}
|
||||
|
||||
func inspectTranslatorNode(node any, path []string, isRoot bool) error {
|
||||
switch data := node.(type) {
|
||||
case nil:
|
||||
if isRoot {
|
||||
return fmt.Errorf("root value is empty")
|
||||
}
|
||||
return fmt.Errorf("%s contains an empty value", formatTranslationPath(path))
|
||||
case string:
|
||||
if isRoot {
|
||||
return fmt.Errorf("root value must be an object but found string")
|
||||
}
|
||||
return nil
|
||||
case bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64:
|
||||
if isRoot {
|
||||
return fmt.Errorf("root value must be an object but found %T", data)
|
||||
}
|
||||
return fmt.Errorf("%s expects a string translation but found %T", formatTranslationPath(path), data)
|
||||
case map[string]any:
|
||||
if isMessageMap(data) {
|
||||
return nil
|
||||
}
|
||||
keys := make([]string, 0, len(data))
|
||||
for key := range data {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
if err := inspectTranslatorNode(data[key], append(path, key), false); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
case map[string]string:
|
||||
mapped := make(map[string]any, len(data))
|
||||
for k, v := range data {
|
||||
mapped[k] = v
|
||||
}
|
||||
return inspectTranslatorNode(mapped, path, isRoot)
|
||||
case map[any]any:
|
||||
if isMessageMap(data) {
|
||||
return nil
|
||||
}
|
||||
type kv struct {
|
||||
key string
|
||||
val any
|
||||
}
|
||||
items := make([]kv, 0, len(data))
|
||||
for key, val := range data {
|
||||
strKey, ok := key.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s uses a non-string key %#v", formatTranslationPath(path), key)
|
||||
}
|
||||
items = append(items, kv{key: strKey, val: val})
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
return items[i].key < items[j].key
|
||||
})
|
||||
for _, item := range items {
|
||||
if err := inspectTranslatorNode(item.val, append(path, item.key), false); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
case []any:
|
||||
for idx, child := range data {
|
||||
if err := inspectTranslatorNode(child, append(path, fmt.Sprintf("[%d]", idx)), false); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
case []map[string]any:
|
||||
for idx, child := range data {
|
||||
if err := inspectTranslatorNode(child, append(path, fmt.Sprintf("[%d]", idx)), false); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
if isRoot {
|
||||
return fmt.Errorf("root value must be an object but found %T", data)
|
||||
}
|
||||
return fmt.Errorf("%s contains unsupported value type %T", formatTranslationPath(path), data)
|
||||
}
|
||||
}
|
||||
|
||||
var translatorReservedKeys = []string{
|
||||
"id", "description", "hash", "leftdelim", "rightdelim",
|
||||
"zero", "one", "two", "few", "many", "other",
|
||||
}
|
||||
|
||||
func isMessageMap(data any) bool {
|
||||
switch v := data.(type) {
|
||||
case map[string]any:
|
||||
for _, key := range translatorReservedKeys {
|
||||
val, ok := v[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := val.(string); ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case map[string]string:
|
||||
for _, key := range translatorReservedKeys {
|
||||
val, ok := v[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if val != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case map[any]any:
|
||||
for _, key := range translatorReservedKeys {
|
||||
val, ok := v[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := val.(string); ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func formatTranslationPath(path []string) string {
|
||||
if len(path) == 0 {
|
||||
return "root"
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, part := range path {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if part[0] == '[' {
|
||||
b.WriteString(part)
|
||||
continue
|
||||
}
|
||||
if b.Len() > 0 {
|
||||
b.WriteByte('.')
|
||||
}
|
||||
b.WriteString(part)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
/*
|
||||
* 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 validator
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/apache/answer/internal/base/reason"
|
||||
"github.com/apache/answer/internal/base/translator"
|
||||
"github.com/go-playground/locales"
|
||||
german "github.com/go-playground/locales/de"
|
||||
english "github.com/go-playground/locales/en"
|
||||
spanish "github.com/go-playground/locales/es"
|
||||
french "github.com/go-playground/locales/fr"
|
||||
italian "github.com/go-playground/locales/it"
|
||||
japanese "github.com/go-playground/locales/ja"
|
||||
korean "github.com/go-playground/locales/ko"
|
||||
portuguese "github.com/go-playground/locales/pt"
|
||||
russian "github.com/go-playground/locales/ru"
|
||||
vietnamese "github.com/go-playground/locales/vi"
|
||||
chinese "github.com/go-playground/locales/zh"
|
||||
chineseTraditional "github.com/go-playground/locales/zh_Hant_TW"
|
||||
ut "github.com/go-playground/universal-translator"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/go-playground/validator/v10/translations/en"
|
||||
"github.com/go-playground/validator/v10/translations/es"
|
||||
"github.com/go-playground/validator/v10/translations/fr"
|
||||
"github.com/go-playground/validator/v10/translations/it"
|
||||
"github.com/go-playground/validator/v10/translations/ja"
|
||||
"github.com/go-playground/validator/v10/translations/pt"
|
||||
"github.com/go-playground/validator/v10/translations/ru"
|
||||
"github.com/go-playground/validator/v10/translations/vi"
|
||||
"github.com/go-playground/validator/v10/translations/zh"
|
||||
"github.com/go-playground/validator/v10/translations/zh_tw"
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
myErrors "github.com/segmentfault/pacman/errors"
|
||||
"github.com/segmentfault/pacman/i18n"
|
||||
"github.com/segmentfault/pacman/log"
|
||||
)
|
||||
|
||||
type TranslatorLocal struct {
|
||||
La i18n.Language
|
||||
Lo locales.Translator
|
||||
RegisterFunc func(v *validator.Validate, trans ut.Translator) (err error)
|
||||
}
|
||||
|
||||
var (
|
||||
allLanguageTranslators = []*TranslatorLocal{
|
||||
{La: i18n.LanguageChinese, Lo: chinese.New(), RegisterFunc: zh.RegisterDefaultTranslations},
|
||||
{La: i18n.LanguageChineseTraditional, Lo: chineseTraditional.New(), RegisterFunc: zh_tw.RegisterDefaultTranslations},
|
||||
{La: i18n.LanguageEnglish, Lo: english.New(), RegisterFunc: en.RegisterDefaultTranslations},
|
||||
{La: i18n.LanguageGerman, Lo: german.New(), RegisterFunc: nil},
|
||||
{La: i18n.LanguageSpanish, Lo: spanish.New(), RegisterFunc: es.RegisterDefaultTranslations},
|
||||
{La: i18n.LanguageFrench, Lo: french.New(), RegisterFunc: fr.RegisterDefaultTranslations},
|
||||
{La: i18n.LanguageItalian, Lo: italian.New(), RegisterFunc: it.RegisterDefaultTranslations},
|
||||
{La: i18n.LanguageJapanese, Lo: japanese.New(), RegisterFunc: ja.RegisterDefaultTranslations},
|
||||
{La: i18n.LanguageKorean, Lo: korean.New(), RegisterFunc: nil},
|
||||
{La: i18n.LanguagePortuguese, Lo: portuguese.New(), RegisterFunc: pt.RegisterDefaultTranslations},
|
||||
{La: i18n.LanguageRussian, Lo: russian.New(), RegisterFunc: ru.RegisterDefaultTranslations},
|
||||
{La: i18n.LanguageVietnamese, Lo: vietnamese.New(), RegisterFunc: vi.RegisterDefaultTranslations},
|
||||
}
|
||||
)
|
||||
|
||||
// MyValidator my validator
|
||||
type MyValidator struct {
|
||||
Validate *validator.Validate
|
||||
Tran ut.Translator
|
||||
Lang i18n.Language
|
||||
}
|
||||
|
||||
// FormErrorField indicates the current form error content. which field is error and error message.
|
||||
type FormErrorField struct {
|
||||
ErrorField string `json:"error_field"`
|
||||
ErrorMsg string `json:"error_msg"`
|
||||
}
|
||||
|
||||
// GlobalValidatorMapping is a mapping from validator to translator used
|
||||
var GlobalValidatorMapping = make(map[i18n.Language]*MyValidator, 0)
|
||||
|
||||
func init() {
|
||||
for _, t := range allLanguageTranslators {
|
||||
tran, val := getTran(t.Lo), createDefaultValidator(t.La)
|
||||
if t.RegisterFunc != nil {
|
||||
if err := t.RegisterFunc(val, tran); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
GlobalValidatorMapping[t.La] = &MyValidator{Validate: val, Tran: tran, Lang: t.La}
|
||||
}
|
||||
}
|
||||
|
||||
func getTran(lo locales.Translator) ut.Translator {
|
||||
tran, ok := ut.New(lo, lo).GetTranslator(lo.Locale())
|
||||
if !ok {
|
||||
panic(fmt.Sprintf("not found translator %s", lo.Locale()))
|
||||
}
|
||||
return tran
|
||||
}
|
||||
|
||||
func NotBlank(fl validator.FieldLevel) (res bool) {
|
||||
field := fl.Field()
|
||||
switch field.Kind() {
|
||||
case reflect.String:
|
||||
trimSpace := strings.TrimSpace(field.String())
|
||||
res := len(trimSpace) > 0
|
||||
if !res {
|
||||
field.SetString(trimSpace)
|
||||
}
|
||||
return true
|
||||
case reflect.Chan, reflect.Map, reflect.Slice, reflect.Array:
|
||||
return field.Len() > 0
|
||||
case reflect.Ptr, reflect.Interface, reflect.Func:
|
||||
return !field.IsNil()
|
||||
default:
|
||||
return field.IsValid() && field.Interface() != reflect.Zero(field.Type()).Interface()
|
||||
}
|
||||
}
|
||||
|
||||
func Sanitizer(fl validator.FieldLevel) (res bool) {
|
||||
field := fl.Field()
|
||||
switch field.Kind() {
|
||||
case reflect.String:
|
||||
filter := bluemonday.UGCPolicy()
|
||||
content := strings.ReplaceAll(filter.Sanitize(field.String()), "&", "&")
|
||||
field.SetString(content)
|
||||
return true
|
||||
case reflect.Chan, reflect.Map, reflect.Slice, reflect.Array:
|
||||
return field.Len() > 0
|
||||
case reflect.Ptr, reflect.Interface, reflect.Func:
|
||||
return !field.IsNil()
|
||||
default:
|
||||
return field.IsValid() && field.Interface() != reflect.Zero(field.Type()).Interface()
|
||||
}
|
||||
}
|
||||
|
||||
func createDefaultValidator(la i18n.Language) *validator.Validate {
|
||||
validate := validator.New()
|
||||
// _ = validate.RegisterValidation("notblank", validators.NotBlank)
|
||||
_ = validate.RegisterValidation("notblank", NotBlank)
|
||||
_ = validate.RegisterValidation("sanitizer", Sanitizer)
|
||||
validate.RegisterTagNameFunc(func(fld reflect.StructField) (res string) {
|
||||
defer func() {
|
||||
if len(res) > 0 {
|
||||
res = translator.Tr(la, res)
|
||||
}
|
||||
}()
|
||||
if jsonTag := fld.Tag.Get("json"); len(jsonTag) > 0 {
|
||||
if jsonTag == "-" {
|
||||
return ""
|
||||
}
|
||||
return jsonTag
|
||||
}
|
||||
if formTag := fld.Tag.Get("form"); len(formTag) > 0 {
|
||||
return formTag
|
||||
}
|
||||
return fld.Name
|
||||
})
|
||||
return validate
|
||||
}
|
||||
|
||||
func GetValidatorByLang(lang i18n.Language) *MyValidator {
|
||||
if GlobalValidatorMapping[lang] != nil {
|
||||
return GlobalValidatorMapping[lang]
|
||||
}
|
||||
return GlobalValidatorMapping[i18n.DefaultLanguage]
|
||||
}
|
||||
|
||||
// Check /
|
||||
func (m *MyValidator) Check(value any) (errFields []*FormErrorField, err error) {
|
||||
defer func() {
|
||||
if len(errFields) == 0 {
|
||||
return
|
||||
}
|
||||
for _, field := range errFields {
|
||||
if len(field.ErrorField) == 0 {
|
||||
continue
|
||||
}
|
||||
firstRune := []rune(field.ErrorMsg)[0]
|
||||
if !unicode.IsLetter(firstRune) || !unicode.Is(unicode.Latin, firstRune) {
|
||||
continue
|
||||
}
|
||||
upperFirstRune := unicode.ToUpper(firstRune)
|
||||
field.ErrorMsg = string(upperFirstRune) + field.ErrorMsg[1:]
|
||||
if !strings.HasSuffix(field.ErrorMsg, ".") {
|
||||
field.ErrorMsg += "."
|
||||
}
|
||||
}
|
||||
}()
|
||||
err = m.Validate.Struct(value)
|
||||
if err != nil {
|
||||
var valErrors validator.ValidationErrors
|
||||
if !errors.As(err, &valErrors) {
|
||||
log.Error(err)
|
||||
return nil, errors.New("validate check exception")
|
||||
}
|
||||
|
||||
for _, fieldError := range valErrors {
|
||||
errField := &FormErrorField{
|
||||
ErrorField: fieldError.Field(),
|
||||
ErrorMsg: fieldError.Translate(m.Tran),
|
||||
}
|
||||
|
||||
// get original tag name from value for set err field key.
|
||||
structNamespace := fieldError.StructNamespace()
|
||||
_, fieldName, found := strings.Cut(structNamespace, ".")
|
||||
if found {
|
||||
originalTag := getObjectTagByFieldName(value, fieldName)
|
||||
if len(originalTag) > 0 {
|
||||
errField.ErrorField = originalTag
|
||||
}
|
||||
}
|
||||
errFields = append(errFields, errField)
|
||||
}
|
||||
if len(errFields) > 0 {
|
||||
errMsg := ""
|
||||
if len(errFields) == 1 {
|
||||
errMsg = errFields[0].ErrorMsg
|
||||
}
|
||||
return errFields, myErrors.BadRequest(reason.RequestFormatError).WithMsg(errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
if v, ok := value.(Checker); ok {
|
||||
errFields, err = v.Check()
|
||||
if err == nil {
|
||||
return nil, nil
|
||||
}
|
||||
errMsg := ""
|
||||
for _, errField := range errFields {
|
||||
errField.ErrorMsg = translator.Tr(m.Lang, errField.ErrorMsg)
|
||||
errMsg = errField.ErrorMsg
|
||||
}
|
||||
return errFields, myErrors.BadRequest(reason.RequestFormatError).WithMsg(errMsg)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Checker .
|
||||
type Checker interface {
|
||||
Check() (errField []*FormErrorField, err error)
|
||||
}
|
||||
|
||||
func getObjectTagByFieldName(obj any, fieldName string) (tag string) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
log.Error(err)
|
||||
}
|
||||
}()
|
||||
|
||||
objT := reflect.TypeOf(obj)
|
||||
objT = objT.Elem()
|
||||
|
||||
structField, exists := objT.FieldByName(fieldName)
|
||||
if !exists {
|
||||
return ""
|
||||
}
|
||||
tag = structField.Tag.Get("json")
|
||||
if len(tag) == 0 {
|
||||
return structField.Tag.Get("form")
|
||||
}
|
||||
return tag
|
||||
}
|
||||
@@ -0,0 +1,621 @@
|
||||
/*
|
||||
* 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 cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/Masterminds/semver/v3"
|
||||
"github.com/apache/answer/pkg/dir"
|
||||
"github.com/apache/answer/pkg/writer"
|
||||
"github.com/segmentfault/pacman/log"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
mainGoTpl = `package main
|
||||
|
||||
import (
|
||||
answercmd "github.com/apache/answer/cmd"
|
||||
|
||||
// remote plugins
|
||||
{{- range .remote_plugins}}
|
||||
_ "{{.}}"
|
||||
{{- end}}
|
||||
|
||||
// local plugins
|
||||
{{- range .local_plugins}}
|
||||
_ "answer/{{.}}"
|
||||
{{- end}}
|
||||
)
|
||||
|
||||
func main() {
|
||||
answercmd.Main()
|
||||
}
|
||||
`
|
||||
goModTpl = `module answer
|
||||
|
||||
go 1.23
|
||||
`
|
||||
)
|
||||
|
||||
type answerBuilder struct {
|
||||
buildingMaterial *buildingMaterial
|
||||
BuildError error
|
||||
}
|
||||
|
||||
type buildingMaterial struct {
|
||||
answerModuleReplacement string
|
||||
plugins []*pluginInfo
|
||||
outputPath string
|
||||
tmpDir string
|
||||
originalAnswerInfo OriginalAnswerInfo
|
||||
}
|
||||
|
||||
type OriginalAnswerInfo struct {
|
||||
Version string
|
||||
Revision string
|
||||
Time string
|
||||
}
|
||||
|
||||
type pluginInfo struct {
|
||||
// Name of the plugin e.g. github.com/apache/answer-plugins/github-connector
|
||||
Name string
|
||||
// Path to the plugin. If path exist, read plugin from local filesystem
|
||||
Path string
|
||||
// Version of the plugin
|
||||
Version string
|
||||
}
|
||||
|
||||
func newAnswerBuilder(buildDir, outputPath string, plugins []string, originalAnswerInfo OriginalAnswerInfo) *answerBuilder {
|
||||
material := &buildingMaterial{originalAnswerInfo: originalAnswerInfo}
|
||||
parentDir, _ := filepath.Abs(".")
|
||||
if buildDir != "" {
|
||||
material.tmpDir = filepath.Join(parentDir, buildDir)
|
||||
} else {
|
||||
material.tmpDir, _ = os.MkdirTemp(parentDir, "answer_build")
|
||||
}
|
||||
if len(outputPath) == 0 {
|
||||
outputPath = filepath.Join(parentDir, "new_answer")
|
||||
}
|
||||
material.outputPath, _ = filepath.Abs(outputPath)
|
||||
material.plugins = formatPlugins(plugins)
|
||||
material.answerModuleReplacement = os.Getenv("ANSWER_MODULE")
|
||||
return &answerBuilder{
|
||||
buildingMaterial: material,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *answerBuilder) DoTask(task func(b *buildingMaterial) error) {
|
||||
if a.BuildError != nil {
|
||||
return
|
||||
}
|
||||
a.BuildError = task(a.buildingMaterial)
|
||||
}
|
||||
|
||||
// BuildNewAnswer builds a new answer with specified plugins
|
||||
func BuildNewAnswer(buildDir, outputPath string, plugins []string, originalAnswerInfo OriginalAnswerInfo) (err error) {
|
||||
builder := newAnswerBuilder(buildDir, outputPath, plugins, originalAnswerInfo)
|
||||
builder.DoTask(createMainGoFile)
|
||||
builder.DoTask(downloadGoModFile)
|
||||
builder.DoTask(movePluginToVendor)
|
||||
builder.DoTask(copyUIFiles)
|
||||
builder.DoTask(buildUI)
|
||||
builder.DoTask(mergeI18nFiles)
|
||||
builder.DoTask(buildBinary)
|
||||
builder.DoTask(cleanByproduct)
|
||||
return builder.BuildError
|
||||
}
|
||||
|
||||
func formatPlugins(plugins []string) (formatted []*pluginInfo) {
|
||||
for _, plugin := range plugins {
|
||||
plugin = strings.TrimSpace(plugin)
|
||||
// plugin description like this 'github.com/apache/answer-plugins/github-connector@latest=/local/path'
|
||||
info := &pluginInfo{}
|
||||
plugin, info.Path, _ = strings.Cut(plugin, "=")
|
||||
info.Name, info.Version, _ = strings.Cut(plugin, "@")
|
||||
// Resolve local path to absolute since build runs in a temp directory
|
||||
if len(info.Path) > 0 {
|
||||
if absPath, err := filepath.Abs(info.Path); err == nil {
|
||||
info.Path = absPath
|
||||
}
|
||||
}
|
||||
formatted = append(formatted, info)
|
||||
}
|
||||
return formatted
|
||||
}
|
||||
|
||||
// createMainGoFile creates main.go file in tmp dir that content is mainGoTpl
|
||||
func createMainGoFile(b *buildingMaterial) (err error) {
|
||||
fmt.Printf("[build] build dir: %s\n", b.tmpDir)
|
||||
err = dir.CreateDirIfNotExist(b.tmpDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var (
|
||||
remotePlugins []string
|
||||
)
|
||||
for _, p := range b.plugins {
|
||||
remotePlugins = append(remotePlugins, versionedModulePath(p.Name, p.Version))
|
||||
}
|
||||
|
||||
mainGoFile := &bytes.Buffer{}
|
||||
tmpl, err := template.New("main").Parse(mainGoTpl)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = tmpl.Execute(mainGoFile, map[string]any{
|
||||
"remote_plugins": remotePlugins,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = writer.WriteFile(filepath.Join(b.tmpDir, "main.go"), mainGoFile.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = writer.WriteFile(filepath.Join(b.tmpDir, "go.mod"), goModTpl)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, p := range b.plugins {
|
||||
// If user set a path, use it to replace the module with local path
|
||||
if len(p.Path) > 0 {
|
||||
var replacement string
|
||||
if len(p.Version) > 0 {
|
||||
replacement = fmt.Sprintf("%s@%s=%s", p.Name, p.Version, p.Path)
|
||||
} else {
|
||||
replacement = fmt.Sprintf("%s=%s", p.Name, p.Path)
|
||||
}
|
||||
err = b.newExecCmd("go", "mod", "edit", "-replace", replacement).Run()
|
||||
} else if len(p.Version) > 0 {
|
||||
// If user specify a version, use it to get specific version of the module
|
||||
err = b.newExecCmd("go", "get", fmt.Sprintf("%s@%s", p.Name, p.Version)).Run()
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// downloadGoModFile run go mod commands to download dependencies
|
||||
func downloadGoModFile(b *buildingMaterial) (err error) {
|
||||
answerReplacement := b.answerModuleReplacement
|
||||
|
||||
// If no replacement specified and current binary is v2+, auto-determine replacement.
|
||||
// This is needed because go mod tidy would otherwise resolve github.com/apache/answer
|
||||
// to the latest v1.x version, causing v2+ features (e.g. AI/MCP) to disappear.
|
||||
if len(answerReplacement) == 0 && b.originalAnswerInfo.Version != "" {
|
||||
ver, verErr := semver.NewVersion(strings.TrimPrefix(b.originalAnswerInfo.Version, "v"))
|
||||
if verErr == nil && ver.Major() >= 2 {
|
||||
answerReplacement = fmt.Sprintf("github.com/apache/answer@%s", b.originalAnswerInfo.Version)
|
||||
}
|
||||
}
|
||||
|
||||
if len(answerReplacement) > 0 {
|
||||
// For v2+ versioned module paths (e.g. github.com/apache/answer@v2.0.0),
|
||||
// go mod tidy rejects the version because the module path lacks a /v2 suffix.
|
||||
// Work around this by cloning the repo locally and using a local path replacement.
|
||||
localPath, resolveErr := resolveAnswerModuleReplacement(answerReplacement, b.tmpDir)
|
||||
if resolveErr != nil {
|
||||
return resolveErr
|
||||
}
|
||||
replacement := fmt.Sprintf("%s=%s", "github.com/apache/answer", localPath)
|
||||
err = b.newExecCmd("go", "mod", "edit", "-replace", replacement).Run()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = b.newExecCmd("go", "mod", "tidy").Run()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = b.newExecCmd("go", "mod", "vendor").Run()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// resolveAnswerModuleReplacement resolves the ANSWER_MODULE value to a usable local path or
|
||||
// remote replacement string. For v2+ versioned module paths (e.g. github.com/apache/answer@v2.0.0),
|
||||
// Go module system rejects the version because the module path has no /v2 suffix. In that case
|
||||
// the repository is cloned locally and the local path is returned instead.
|
||||
func resolveAnswerModuleReplacement(replacement, tmpDir string) (string, error) {
|
||||
// Local paths can be used as-is.
|
||||
if strings.HasPrefix(replacement, "/") || strings.HasPrefix(replacement, "./") || strings.HasPrefix(replacement, "../") {
|
||||
return replacement, nil
|
||||
}
|
||||
|
||||
// Parse module@version format.
|
||||
moduleName, version, hasVersion := strings.Cut(replacement, "@")
|
||||
if !hasVersion {
|
||||
return replacement, nil
|
||||
}
|
||||
|
||||
// Only handle v2+ versions on module paths without the /vN suffix.
|
||||
ver, err := semver.StrictNewVersion(strings.TrimPrefix(version, "v"))
|
||||
if err != nil || ver.Major() < 2 {
|
||||
return replacement, nil
|
||||
}
|
||||
if strings.HasSuffix(moduleName, fmt.Sprintf("/v%d", ver.Major())) {
|
||||
return replacement, nil
|
||||
}
|
||||
|
||||
// Clone the repo to a local directory and return its path.
|
||||
gitURL := "https://" + moduleName
|
||||
tag := "v" + strings.TrimPrefix(version, "v")
|
||||
localPath := filepath.Join(filepath.Dir(tmpDir), fmt.Sprintf("answer_src_%s", strings.ReplaceAll(version, ".", "_")))
|
||||
|
||||
if _, statErr := os.Stat(localPath); statErr == nil {
|
||||
fmt.Printf("[build] using cached local clone at %s\n", localPath)
|
||||
return localPath, nil
|
||||
}
|
||||
|
||||
fmt.Printf("[build] v2+ module detected, cloning %s@%s to local path %s...\n", moduleName, version, localPath)
|
||||
cloneCmd := exec.Command("git", "clone", "--depth=1", "--branch="+tag, gitURL, localPath)
|
||||
cloneCmd.Stdout = os.Stdout
|
||||
cloneCmd.Stderr = os.Stderr
|
||||
if err = cloneCmd.Run(); err != nil {
|
||||
return "", fmt.Errorf(
|
||||
"failed to clone %s@%s: %w\nTip: set ANSWER_MODULE to a local checkout path instead, e.g. ANSWER_MODULE=/path/to/answer",
|
||||
moduleName, version, err,
|
||||
)
|
||||
}
|
||||
|
||||
fmt.Printf("[build] successfully cloned to %s\n", localPath)
|
||||
return localPath, nil
|
||||
}
|
||||
|
||||
// movePluginToVendor move plugin to vendor dir
|
||||
// Traverse the plugins, and if the plugin path is not github.com/apache/answer-plugins, move the contents of the current plugin to the vendor/github.com/apache/answer-plugins/ directory.
|
||||
func movePluginToVendor(b *buildingMaterial) (err error) {
|
||||
pluginsDir := filepath.Join(b.tmpDir, "vendor/github.com/apache/answer-plugins/")
|
||||
for _, p := range b.plugins {
|
||||
pluginDir := filepath.Join(b.tmpDir, "vendor/", p.Name)
|
||||
pluginName := filepath.Base(p.Name)
|
||||
if !strings.HasPrefix(p.Name, "github.com/apache/answer-plugins/") {
|
||||
fmt.Printf("try to copy dir from %s to %s\n", pluginDir, filepath.Join(pluginsDir, pluginName))
|
||||
err = copyDirEntries(os.DirFS(pluginDir), ".", filepath.Join(pluginsDir, pluginName), "node_modules")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyUIFiles copy ui files from answer module to tmp dir
|
||||
func copyUIFiles(b *buildingMaterial) (err error) {
|
||||
goListCmd := b.newExecCmd("go", "list", "-mod=mod", "-m", "-f", "{{.Dir}}", "github.com/apache/answer")
|
||||
buf := new(bytes.Buffer)
|
||||
goListCmd.Stdout = buf
|
||||
if err = goListCmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to run go list: %w", err)
|
||||
}
|
||||
|
||||
answerDir := strings.TrimSpace(buf.String())
|
||||
goModUIDir := filepath.Join(answerDir, "ui")
|
||||
localUIBuildDir := filepath.Join(b.tmpDir, "vendor/github.com/apache/answer/ui/")
|
||||
// The node_modules folder generated during development will interfere packaging, so it needs to be ignored.
|
||||
if err = copyDirEntries(os.DirFS(goModUIDir), ".", localUIBuildDir, "node_modules"); err != nil {
|
||||
return fmt.Errorf("failed to copy ui files: %w", err)
|
||||
}
|
||||
|
||||
pluginsDir := filepath.Join(b.tmpDir, "vendor/github.com/apache/answer-plugins/")
|
||||
localUIPluginDir := filepath.Join(localUIBuildDir, "src/plugins/")
|
||||
|
||||
// copy plugins dir
|
||||
fmt.Printf("try to copy dir from %s to %s\n", pluginsDir, localUIPluginDir)
|
||||
|
||||
// if plugins dir not exist means no plugins
|
||||
if !dir.CheckDirExist(pluginsDir) {
|
||||
return nil
|
||||
}
|
||||
|
||||
pluginsDirEntries, err := os.ReadDir(pluginsDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read plugins dir: %w", err)
|
||||
}
|
||||
for _, entry := range pluginsDirEntries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
sourcePluginDir := filepath.Join(pluginsDir, entry.Name())
|
||||
// check if plugin is a ui plugin
|
||||
packageJsonPath := filepath.Join(sourcePluginDir, "package.json")
|
||||
fmt.Printf("check if %s is a ui plugin\n", packageJsonPath)
|
||||
if !dir.CheckFileExist(packageJsonPath) {
|
||||
continue
|
||||
}
|
||||
|
||||
pnpmInstallCmd := b.newExecCmd("pnpm", "install")
|
||||
pnpmInstallCmd.Dir = sourcePluginDir
|
||||
if err = pnpmInstallCmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to install plugin dependencies: %w", err)
|
||||
}
|
||||
|
||||
localPluginDir := filepath.Join(localUIPluginDir, entry.Name())
|
||||
fmt.Printf("try to copy dir from %s to %s\n", sourcePluginDir, localPluginDir)
|
||||
if err = copyDirEntries(os.DirFS(sourcePluginDir), ".", localPluginDir, "node_modules"); err != nil {
|
||||
return fmt.Errorf("failed to copy ui files: %w", err)
|
||||
}
|
||||
}
|
||||
formatUIPluginsDirName(localUIPluginDir)
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildUI run pnpm install and pnpm build commands to build ui
|
||||
func buildUI(b *buildingMaterial) (err error) {
|
||||
localUIBuildDir := filepath.Join(b.tmpDir, "vendor/github.com/apache/answer/ui")
|
||||
|
||||
pnpmInstallCmd := b.newExecCmd("pnpm", "pre-install")
|
||||
pnpmInstallCmd.Dir = localUIBuildDir
|
||||
if err = pnpmInstallCmd.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pnpmBuildCmd := b.newExecCmd("pnpm", "build")
|
||||
pnpmBuildCmd.Dir = localUIBuildDir
|
||||
if err = pnpmBuildCmd.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeI18nFiles merge i18n files
|
||||
func mergeI18nFiles(b *buildingMaterial) (err error) {
|
||||
fmt.Printf("try to merge i18n files\n")
|
||||
|
||||
type YamlPluginContent struct {
|
||||
Plugin map[string]any `yaml:"plugin"`
|
||||
}
|
||||
|
||||
pluginAllTranslations := make(map[string]*YamlPluginContent)
|
||||
for _, plugin := range b.plugins {
|
||||
i18nDir := filepath.Join(b.tmpDir, fmt.Sprintf("vendor/%s/i18n", plugin.Name))
|
||||
fmt.Println("i18n dir: ", i18nDir)
|
||||
if !dir.CheckDirExist(i18nDir) {
|
||||
continue
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(i18nDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, file := range entries {
|
||||
// ignore directory
|
||||
if file.IsDir() {
|
||||
continue
|
||||
}
|
||||
// ignore non-YAML file
|
||||
if filepath.Ext(file.Name()) != ".yaml" {
|
||||
continue
|
||||
}
|
||||
buf, err := os.ReadFile(filepath.Join(i18nDir, file.Name()))
|
||||
if err != nil {
|
||||
log.Debugf("read translation file failed: %s %s", file.Name(), err)
|
||||
continue
|
||||
}
|
||||
|
||||
translation := &YamlPluginContent{}
|
||||
if err = yaml.Unmarshal(buf, translation); err != nil {
|
||||
log.Debugf("unmarshal translation file failed: %s %s", file.Name(), err)
|
||||
continue
|
||||
}
|
||||
|
||||
if pluginAllTranslations[file.Name()] == nil {
|
||||
pluginAllTranslations[file.Name()] = &YamlPluginContent{Plugin: make(map[string]any)}
|
||||
}
|
||||
for k, v := range translation.Plugin {
|
||||
pluginAllTranslations[file.Name()].Plugin[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
originalI18nDir := filepath.Join(b.tmpDir, "vendor/github.com/apache/answer/i18n")
|
||||
entries, err := os.ReadDir(originalI18nDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, file := range entries {
|
||||
// ignore directory
|
||||
if file.IsDir() {
|
||||
continue
|
||||
}
|
||||
// ignore non-YAML file
|
||||
filename := file.Name()
|
||||
if filepath.Ext(filename) != ".yaml" && filename != "i18n.yaml" {
|
||||
continue
|
||||
}
|
||||
|
||||
// if plugin don't have this translation file, ignore it
|
||||
if pluginAllTranslations[filename] == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
out, _ := yaml.Marshal(pluginAllTranslations[filename])
|
||||
|
||||
buf, err := os.OpenFile(filepath.Join(originalI18nDir, filename), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
log.Debugf("read translation file failed: %s %s", filename, err)
|
||||
continue
|
||||
}
|
||||
|
||||
_, _ = buf.WriteString("\n")
|
||||
_, _ = buf.Write(out)
|
||||
_ = buf.Close()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func copyDirEntries(sourceFs fs.FS, sourceDir, targetDir string, ignoreDir ...string) (err error) {
|
||||
err = dir.CreateDirIfNotExist(targetDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ignoreThisDir := func(path string) bool {
|
||||
for _, s := range ignoreDir {
|
||||
if strings.HasPrefix(path, s) {
|
||||
return true
|
||||
}
|
||||
// Also ignore nested occurrences, e.g. src/plugins/foo/node_modules
|
||||
if strings.Contains(path, string(filepath.Separator)+s) {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(path, "/"+s) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
err = fs.WalkDir(sourceFs, sourceDir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ignoreThisDir(path) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Convert the path to use forward slashes, important because we use embedded FS which always uses forward slashes
|
||||
path = filepath.ToSlash(path)
|
||||
|
||||
// Construct the absolute path for the source file/directory
|
||||
srcPath := filepath.Join(sourceDir, path)
|
||||
srcPath = filepath.ToSlash(srcPath)
|
||||
|
||||
// Construct the absolute path for the destination file/directory
|
||||
dstPath := filepath.Join(targetDir, path)
|
||||
|
||||
if d.IsDir() {
|
||||
// Create the directory in the destination
|
||||
err := os.MkdirAll(dstPath, os.ModePerm)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create directory %s: %w", dstPath, err)
|
||||
}
|
||||
} else {
|
||||
// Open the source file
|
||||
srcFile, err := sourceFs.Open(srcPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open source file %s: %w", srcPath, err)
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
// Create the destination file
|
||||
dstFile, err := os.Create(dstPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create destination file %s: %w", dstPath, err)
|
||||
}
|
||||
defer dstFile.Close()
|
||||
|
||||
// Copy the file contents
|
||||
_, err = io.Copy(dstFile, srcFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to copy file contents from %s to %s: %w", srcPath, dstPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// format plugins dir name from dash to underline
|
||||
func formatUIPluginsDirName(dirPath string) {
|
||||
entries, err := os.ReadDir(dirPath)
|
||||
if err != nil {
|
||||
fmt.Printf("read ui plugins dir failed: [%s] %s\n", dirPath, err)
|
||||
return
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() || !strings.Contains(entry.Name(), "-") {
|
||||
continue
|
||||
}
|
||||
newName := strings.ReplaceAll(entry.Name(), "-", "_")
|
||||
if err := os.Rename(filepath.Join(dirPath, entry.Name()), filepath.Join(dirPath, newName)); err != nil {
|
||||
fmt.Printf("rename ui plugins dir failed: [%s] %s\n", dirPath, err)
|
||||
} else {
|
||||
fmt.Printf("rename ui plugins dir success: [%s] -> [%s]\n", entry.Name(), newName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// buildBinary build binary file
|
||||
func buildBinary(b *buildingMaterial) (err error) {
|
||||
versionInfo := b.originalAnswerInfo
|
||||
cmdPkg := "github.com/apache/answer/cmd"
|
||||
ldflags := fmt.Sprintf("-X %s.Version=%s -X %s.Revision=%s -X %s.Time=%s",
|
||||
cmdPkg, versionInfo.Version, cmdPkg, versionInfo.Revision, cmdPkg, versionInfo.Time)
|
||||
err = b.newExecCmd("go", "build",
|
||||
"-ldflags", ldflags, "-o", b.outputPath, ".").Run()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// cleanByproduct delete tmp dir
|
||||
func cleanByproduct(b *buildingMaterial) (err error) {
|
||||
return os.RemoveAll(b.tmpDir)
|
||||
}
|
||||
|
||||
func (b *buildingMaterial) newExecCmd(command string, args ...string) *exec.Cmd {
|
||||
cmd := exec.Command(command, args...)
|
||||
fmt.Println(cmd.Args)
|
||||
cmd.Dir = b.tmpDir
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd
|
||||
}
|
||||
|
||||
func versionedModulePath(modulePath, moduleVersion string) string {
|
||||
if moduleVersion == "" {
|
||||
return modulePath
|
||||
}
|
||||
ver, err := semver.StrictNewVersion(strings.TrimPrefix(moduleVersion, "v"))
|
||||
if err != nil {
|
||||
return modulePath
|
||||
}
|
||||
major := ver.Major()
|
||||
if major > 1 {
|
||||
modulePath += fmt.Sprintf("/v%d", major)
|
||||
}
|
||||
return path.Clean(modulePath)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* 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 cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/apache/answer/internal/base/constant"
|
||||
"github.com/apache/answer/internal/base/data"
|
||||
"github.com/apache/answer/internal/entity"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
type ConfigField struct {
|
||||
AllowPasswordLogin bool `json:"allow_password_login"`
|
||||
// The slug name of plugin that you want to deactivate
|
||||
DeactivatePluginSlugName string `json:"deactivate_plugin_slug_name"`
|
||||
}
|
||||
|
||||
// SetDefaultConfig set default config
|
||||
func SetDefaultConfig(dbConf *data.Database, cacheConf *data.CacheConf, field *ConfigField) error {
|
||||
db, err := data.NewDB(false, dbConf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = db.Close()
|
||||
}()
|
||||
|
||||
cache, cacheCleanup, err := data.NewCache(cacheConf)
|
||||
if err != nil {
|
||||
fmt.Println("new cache failed")
|
||||
}
|
||||
defer func() {
|
||||
if cache != nil {
|
||||
_ = cache.Flush(context.Background())
|
||||
cacheCleanup()
|
||||
}
|
||||
}()
|
||||
|
||||
if field.AllowPasswordLogin {
|
||||
return defaultLoginConfig(db)
|
||||
}
|
||||
if len(field.DeactivatePluginSlugName) > 0 {
|
||||
return deactivatePlugin(db, field.DeactivatePluginSlugName)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func defaultLoginConfig(x *xorm.Engine) (err error) {
|
||||
fmt.Println("set default login config")
|
||||
|
||||
loginSiteInfo := &entity.SiteInfo{
|
||||
Type: constant.SiteTypeLogin,
|
||||
}
|
||||
exist, err := x.Get(loginSiteInfo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get config failed: %w", err)
|
||||
}
|
||||
if exist {
|
||||
var content map[string]any
|
||||
_ = json.Unmarshal([]byte(loginSiteInfo.Content), &content)
|
||||
content["allow_password_login"] = true
|
||||
dataByte, _ := json.Marshal(content)
|
||||
loginSiteInfo.Content = string(dataByte)
|
||||
_, err = x.ID(loginSiteInfo.ID).Cols("content").Update(loginSiteInfo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update site info failed: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func deactivatePlugin(x *xorm.Engine, pluginSlugName string) (err error) {
|
||||
fmt.Printf("try to deactivate plugin: %s\n", pluginSlugName)
|
||||
|
||||
item := &entity.Config{Key: constant.PluginStatus}
|
||||
exist, err := x.Get(item)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get config failed: %w", err)
|
||||
}
|
||||
if !exist {
|
||||
return nil
|
||||
}
|
||||
|
||||
pluginStatusMapping := make(map[string]bool)
|
||||
_ = json.Unmarshal([]byte(item.Value), &pluginStatusMapping)
|
||||
status, ok := pluginStatusMapping[pluginSlugName]
|
||||
if !ok {
|
||||
fmt.Printf("plugin %s not exist\n", pluginSlugName)
|
||||
return nil
|
||||
}
|
||||
if !status {
|
||||
fmt.Printf("plugin %s already deactivated\n", pluginSlugName)
|
||||
return nil
|
||||
}
|
||||
|
||||
pluginStatusMapping[pluginSlugName] = false
|
||||
dataByte, _ := json.Marshal(pluginStatusMapping)
|
||||
item.Value = string(dataByte)
|
||||
_, err = x.ID(item.ID).Cols("value").Update(item)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update plugin status failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/apache/answer/internal/base/data"
|
||||
"xorm.io/xorm/schemas"
|
||||
)
|
||||
|
||||
// DumpAllData dump all database data to sql
|
||||
func DumpAllData(dataConf *data.Database, dumpDataPath string) error {
|
||||
db, err := data.NewDB(false, dataConf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = db.Close()
|
||||
}()
|
||||
if err = db.Ping(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
name := filepath.Join(dumpDataPath, fmt.Sprintf("answer_dump_data_%s.sql", time.Now().Format("2006-01-02")))
|
||||
return db.DumpAllToFile(name, schemas.MYSQL)
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/*
|
||||
* 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 cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/apache/answer/i18n"
|
||||
"github.com/apache/answer/pkg/dir"
|
||||
"github.com/apache/answer/pkg/writer"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type YamlPluginContent struct {
|
||||
Plugin map[string]any `yaml:"plugin"`
|
||||
}
|
||||
|
||||
// ReplaceI18nFilesLocal replace i18n files
|
||||
func ReplaceI18nFilesLocal(i18nDir string) error {
|
||||
i18nList, err := i18n.I18n.ReadDir(".")
|
||||
if err != nil {
|
||||
fmt.Println(err.Error())
|
||||
return err
|
||||
}
|
||||
fmt.Printf("[i18n] find i18n bundle %d\n", len(i18nList))
|
||||
for _, item := range i18nList {
|
||||
path := filepath.Join(i18nDir, item.Name())
|
||||
content, err := i18n.I18n.ReadFile(item.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
exist := dir.CheckFileExist(path)
|
||||
if exist {
|
||||
fmt.Printf("[i18n] install %s file exist, try to replace it\n", item.Name())
|
||||
if err = os.Remove(path); err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
}
|
||||
fmt.Printf("[i18n] install %s bundle...\n", item.Name())
|
||||
err = writer.WriteFile(path, string(content))
|
||||
if err != nil {
|
||||
fmt.Printf("[i18n] install %s bundle fail: %s\n", item.Name(), err.Error())
|
||||
} else {
|
||||
fmt.Printf("[i18n] install %s bundle success\n", item.Name())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MergeI18nFilesLocal merge i18n files
|
||||
func MergeI18nFilesLocal(originalI18nDir, targetI18nDir string) (err error) {
|
||||
pluginAllTranslations := make(map[string]*YamlPluginContent)
|
||||
|
||||
err = findI18nFileInDir(pluginAllTranslations, targetI18nDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(originalI18nDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, file := range entries {
|
||||
// ignore directory
|
||||
if file.IsDir() {
|
||||
continue
|
||||
}
|
||||
// ignore non-YAML file
|
||||
filename := file.Name()
|
||||
if filepath.Ext(filename) != ".yaml" && filename != "i18n.yaml" {
|
||||
continue
|
||||
}
|
||||
|
||||
// if plugin don't have this translation file, ignore it
|
||||
if pluginAllTranslations[filename] == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
out, _ := yaml.Marshal(pluginAllTranslations[filename])
|
||||
|
||||
buf, err := os.OpenFile(filepath.Join(originalI18nDir, filename), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
fmt.Printf("[i18n] read translation file failed: %s %s\n", filename, err)
|
||||
continue
|
||||
}
|
||||
|
||||
_, _ = buf.WriteString("\n")
|
||||
_, _ = buf.Write(out)
|
||||
_ = buf.Close()
|
||||
fmt.Printf("[i18n] merge i18n file: %s success\n", filename)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// find i18n file in dir
|
||||
func findI18nFileInDir(pluginAllTranslations map[string]*YamlPluginContent, i18nDir string) error {
|
||||
// if i18n dir is not i18n, find deeper
|
||||
dirBase := filepath.Base(i18nDir)
|
||||
if dirBase != "i18n" {
|
||||
if strings.HasPrefix(dirBase, ".") {
|
||||
return nil
|
||||
}
|
||||
// find all i18n dir in target dir
|
||||
targetDirs, err := os.ReadDir(i18nDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, targetDir := range targetDirs {
|
||||
if targetDir.IsDir() {
|
||||
if err := findI18nFileInDir(pluginAllTranslations, filepath.Join(i18nDir, targetDir.Name())); err != nil {
|
||||
fmt.Printf("[i18n] find i18n file in dir failed: %s %s\n", targetDir.Name(), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("[i18n] find i18n file in dir: %s\n", i18nDir)
|
||||
|
||||
// if i18nDir is i18n, find all yaml files
|
||||
entries, err := os.ReadDir(i18nDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, file := range entries {
|
||||
// ignore directory
|
||||
if file.IsDir() {
|
||||
continue
|
||||
}
|
||||
// ignore non-YAML file
|
||||
if filepath.Ext(file.Name()) != ".yaml" {
|
||||
continue
|
||||
}
|
||||
buf, err := os.ReadFile(filepath.Join(i18nDir, file.Name()))
|
||||
if err != nil {
|
||||
fmt.Printf("[i18n] read translation file failed: %s %s\n", file.Name(), err)
|
||||
continue
|
||||
}
|
||||
|
||||
translation := &YamlPluginContent{}
|
||||
if err = yaml.Unmarshal(buf, translation); err != nil {
|
||||
fmt.Printf("[i18n] unmarshal translation file failed: %s %s\n", file.Name(), err)
|
||||
continue
|
||||
}
|
||||
|
||||
if pluginAllTranslations[file.Name()] == nil {
|
||||
pluginAllTranslations[file.Name()] = &YamlPluginContent{Plugin: make(map[string]any)}
|
||||
}
|
||||
for k, v := range translation.Plugin {
|
||||
pluginAllTranslations[file.Name()].Plugin[k] = v
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* 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 cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/apache/answer/configs"
|
||||
"github.com/apache/answer/i18n"
|
||||
"github.com/apache/answer/internal/base/path"
|
||||
"github.com/apache/answer/pkg/dir"
|
||||
"github.com/apache/answer/pkg/writer"
|
||||
)
|
||||
|
||||
// InstallAllInitialEnvironment install all initial environment
|
||||
func InstallAllInitialEnvironment(dataDirPath string) {
|
||||
path.FormatAllPath(dataDirPath)
|
||||
installUploadDir()
|
||||
InstallI18nBundle(false)
|
||||
fmt.Println("install all initial environment done")
|
||||
}
|
||||
|
||||
func InstallConfigFile(configFilePath string) error {
|
||||
if len(configFilePath) == 0 {
|
||||
configFilePath = filepath.Join(path.ConfigFileDir, path.DefaultConfigFileName)
|
||||
}
|
||||
fmt.Println("[config-file] try to create at ", configFilePath)
|
||||
|
||||
// if config file already exists do nothing.
|
||||
if CheckConfigFile(configFilePath) {
|
||||
fmt.Printf("[config-file] %s already exists\n", configFilePath)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := dir.CreateDirIfNotExist(path.ConfigFileDir); err != nil {
|
||||
fmt.Printf("[config-file] create directory fail %s\n", err.Error())
|
||||
return fmt.Errorf("create directory fail %s", err.Error())
|
||||
}
|
||||
fmt.Printf("[config-file] create directory success, config file is %s\n", configFilePath)
|
||||
|
||||
if err := writer.WriteFile(configFilePath, string(configs.Config)); err != nil {
|
||||
fmt.Printf("[config-file] install fail %s\n", err.Error())
|
||||
return fmt.Errorf("write file failed %s", err)
|
||||
}
|
||||
fmt.Printf("[config-file] install success\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
func installUploadDir() {
|
||||
fmt.Println("[upload-dir] try to install...")
|
||||
if err := dir.CreateDirIfNotExist(path.UploadFilePath); err != nil {
|
||||
fmt.Printf("[upload-dir] install fail %s\n", err.Error())
|
||||
} else {
|
||||
fmt.Printf("[upload-dir] install success, upload directory is %s\n", path.UploadFilePath)
|
||||
}
|
||||
}
|
||||
|
||||
func InstallI18nBundle(replace bool) {
|
||||
fmt.Println("[i18n] try to install i18n bundle...")
|
||||
// if SKIP_REPLACE_I18N is set, skip replace i18n bundles
|
||||
if len(os.Getenv("SKIP_REPLACE_I18N")) > 0 {
|
||||
replace = false
|
||||
}
|
||||
if err := dir.CreateDirIfNotExist(path.I18nPath); err != nil {
|
||||
fmt.Println(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
i18nList, err := i18n.I18n.ReadDir(".")
|
||||
if err != nil {
|
||||
fmt.Println(err.Error())
|
||||
return
|
||||
}
|
||||
fmt.Printf("[i18n] find i18n bundle %d\n", len(i18nList))
|
||||
for _, item := range i18nList {
|
||||
path := filepath.Join(path.I18nPath, item.Name())
|
||||
content, err := i18n.I18n.ReadFile(item.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
exist := dir.CheckFileExist(path)
|
||||
if exist && !replace {
|
||||
continue
|
||||
}
|
||||
if exist {
|
||||
fmt.Printf("[i18n] install %s file exist, try to replace it\n", item.Name())
|
||||
if err = os.Remove(path); err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
}
|
||||
fmt.Printf("[i18n] install %s bundle...\n", item.Name())
|
||||
err = writer.WriteFile(path, string(content))
|
||||
if err != nil {
|
||||
fmt.Printf("[i18n] install %s bundle fail: %s\n", item.Name(), err.Error())
|
||||
} else {
|
||||
fmt.Printf("[i18n] install %s bundle success\n", item.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* 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 cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/apache/answer/internal/base/data"
|
||||
"github.com/apache/answer/internal/base/path"
|
||||
"github.com/apache/answer/internal/entity"
|
||||
"github.com/apache/answer/pkg/dir"
|
||||
)
|
||||
|
||||
func CheckConfigFile(configPath string) bool {
|
||||
return dir.CheckFileExist(configPath)
|
||||
}
|
||||
|
||||
func CheckUploadDir() bool {
|
||||
return dir.CheckDirExist(path.UploadFilePath)
|
||||
}
|
||||
|
||||
// CheckDBConnection check database whether the connection is normal
|
||||
func CheckDBConnection(dataConf *data.Database) bool {
|
||||
db, err := data.NewDB(false, dataConf)
|
||||
if err != nil {
|
||||
fmt.Printf("connection database failed: %s\n", err)
|
||||
return false
|
||||
}
|
||||
defer func() {
|
||||
_ = db.Close()
|
||||
}()
|
||||
if err = db.Ping(); err != nil {
|
||||
fmt.Printf("connection ping database failed: %s\n", err)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// CheckDBTableExist check database whether the table is already exists
|
||||
func CheckDBTableExist(dataConf *data.Database) bool {
|
||||
db, err := data.NewDB(false, dataConf)
|
||||
if err != nil {
|
||||
fmt.Printf("connection database failed: %s\n", err)
|
||||
return false
|
||||
}
|
||||
defer func() {
|
||||
_ = db.Close()
|
||||
}()
|
||||
if err = db.Ping(); err != nil {
|
||||
fmt.Printf("connection ping database failed: %s\n", err)
|
||||
return false
|
||||
}
|
||||
|
||||
exist, err := db.IsTableExist(&entity.Version{})
|
||||
if err != nil {
|
||||
fmt.Printf("check table exist failed: %s\n", err)
|
||||
return false
|
||||
}
|
||||
if !exist {
|
||||
fmt.Printf("check table not exist\n")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
/*
|
||||
* 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 cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/apache/answer/internal/base/conf"
|
||||
"github.com/apache/answer/internal/base/data"
|
||||
"github.com/apache/answer/internal/base/path"
|
||||
"github.com/apache/answer/internal/repo/api_key"
|
||||
"github.com/apache/answer/internal/repo/auth"
|
||||
"github.com/apache/answer/internal/repo/user"
|
||||
authService "github.com/apache/answer/internal/service/auth"
|
||||
"github.com/apache/answer/pkg/checker"
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
_ "github.com/lib/pq"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"golang.org/x/term"
|
||||
_ "modernc.org/sqlite"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
const (
|
||||
charsetLower = "abcdefghijklmnopqrstuvwxyz"
|
||||
charsetUpper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
charsetDigits = "0123456789"
|
||||
charsetSpecial = "!@#$%^&*~?_-"
|
||||
maxRetries = 10
|
||||
defaultRandomPasswordLength = 12
|
||||
)
|
||||
|
||||
var charset = []string{
|
||||
charsetLower,
|
||||
charsetUpper,
|
||||
charsetDigits,
|
||||
charsetSpecial,
|
||||
}
|
||||
|
||||
type ResetPasswordOptions struct {
|
||||
Email string
|
||||
Password string
|
||||
}
|
||||
|
||||
func ResetPassword(ctx context.Context, dataDirPath string, opts *ResetPasswordOptions) error {
|
||||
path.FormatAllPath(dataDirPath)
|
||||
|
||||
config, err := conf.ReadConfig(path.GetConfigFilePath())
|
||||
if err != nil {
|
||||
return fmt.Errorf("read config file failed: %w", err)
|
||||
}
|
||||
|
||||
db, err := initDatabase(config.Data.Database.Driver, config.Data.Database.Connection)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect database failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = db.Close()
|
||||
}()
|
||||
|
||||
cache, cacheCleanup, err := data.NewCache(config.Data.Cache)
|
||||
if err != nil {
|
||||
return fmt.Errorf("initialize cache failed: %w", err)
|
||||
}
|
||||
defer cacheCleanup()
|
||||
|
||||
dataData, dataCleanup, err := data.NewData(db, cache)
|
||||
if err != nil {
|
||||
return fmt.Errorf("initialize data layer failed: %w", err)
|
||||
}
|
||||
defer dataCleanup()
|
||||
|
||||
userRepo := user.NewUserRepo(dataData)
|
||||
authRepo := auth.NewAuthRepo(dataData)
|
||||
apiKeyRepo := api_key.NewAPIKeyRepo(dataData)
|
||||
authSvc := authService.NewAuthService(authRepo, apiKeyRepo)
|
||||
|
||||
email := strings.TrimSpace(opts.Email)
|
||||
if email == "" {
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
fmt.Print("Please input user email: ")
|
||||
emailInput, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return fmt.Errorf("read email input failed: %w", err)
|
||||
}
|
||||
email = strings.TrimSpace(emailInput)
|
||||
}
|
||||
|
||||
userInfo, exist, err := userRepo.GetByEmail(ctx, email)
|
||||
if err != nil {
|
||||
return fmt.Errorf("query user failed: %w", err)
|
||||
}
|
||||
if !exist {
|
||||
return fmt.Errorf("user not found: %s", email)
|
||||
}
|
||||
|
||||
fmt.Printf("You are going to reset password for user: %s\n", email)
|
||||
|
||||
password := strings.TrimSpace(opts.Password)
|
||||
|
||||
if password != "" {
|
||||
printWarning("Passing password via command line may be recorded in shell history")
|
||||
if err := checker.CheckPassword(password); err != nil {
|
||||
return fmt.Errorf("password validation failed: %w", err)
|
||||
}
|
||||
} else {
|
||||
password, err = promptForPassword()
|
||||
if err != nil {
|
||||
return fmt.Errorf("password input failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if !confirmAction(fmt.Sprintf("This will reset password for user '[%s]%s'. Continue?", userInfo.DisplayName, email)) {
|
||||
fmt.Println("Operation cancelled")
|
||||
return nil
|
||||
}
|
||||
|
||||
hashPwd, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encrypt password failed: %w", err)
|
||||
}
|
||||
|
||||
if err = userRepo.UpdatePass(ctx, userInfo.ID, string(hashPwd)); err != nil {
|
||||
return fmt.Errorf("update password failed: %w", err)
|
||||
}
|
||||
|
||||
authSvc.RemoveUserAllTokens(ctx, userInfo.ID)
|
||||
|
||||
fmt.Printf("Password has been successfully updated for user: %s\n", email)
|
||||
fmt.Println("All login sessions have been cleared")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// promptForPassword prompts for a password
|
||||
func promptForPassword() (string, error) {
|
||||
for {
|
||||
input, err := getPasswordInput("Please input new password (empty to generate random password): ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if input == "" {
|
||||
password, err := generateRandomPasswordWithRetry()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("generate random password failed: %w", err)
|
||||
}
|
||||
fmt.Printf("Generated random password: %s\n", password)
|
||||
fmt.Println("Please save this password in a secure location")
|
||||
return password, nil
|
||||
}
|
||||
|
||||
if err := checker.CheckPassword(input); err != nil {
|
||||
fmt.Printf("Password validation failed: %v\n", err)
|
||||
fmt.Println("Please try again")
|
||||
continue
|
||||
}
|
||||
|
||||
confirmPwd, err := getPasswordInput("Please confirm new password: ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if input != confirmPwd {
|
||||
fmt.Println("Passwords do not match, please try again")
|
||||
continue
|
||||
}
|
||||
|
||||
return input, nil
|
||||
}
|
||||
}
|
||||
|
||||
func generateRandomPasswordWithRetry() (string, error) {
|
||||
var password string
|
||||
var err error
|
||||
|
||||
for range maxRetries {
|
||||
password, err = generateRandomPassword(defaultRandomPasswordLength)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if err := checker.CheckPassword(password); err == nil {
|
||||
return password, nil
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "", fmt.Errorf("failed to generate valid password after %d retries", maxRetries)
|
||||
}
|
||||
|
||||
func getPasswordInput(prompt string) (string, error) {
|
||||
fmt.Print(prompt)
|
||||
password, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
fmt.Println()
|
||||
return string(password), nil
|
||||
}
|
||||
|
||||
func generateRandomPassword(length int) (string, error) {
|
||||
if length < len(charset) {
|
||||
return "", fmt.Errorf("password length must be at least %d", len(charset))
|
||||
}
|
||||
|
||||
bytes := make([]byte, length)
|
||||
for i, charsetItem := range charset {
|
||||
charIndex, err := rand.Int(rand.Reader, big.NewInt(int64(len(charsetItem))))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
bytes[i] = charsetItem[charIndex.Int64()]
|
||||
}
|
||||
|
||||
fullCharset := strings.Join(charset, "")
|
||||
for i := len(charset); i < length; i++ {
|
||||
charIndex, err := rand.Int(rand.Reader, big.NewInt(int64(len(fullCharset))))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
bytes[i] = fullCharset[charIndex.Int64()]
|
||||
}
|
||||
|
||||
for i := len(bytes) - 1; i > 0; i-- {
|
||||
j, err := rand.Int(rand.Reader, big.NewInt(int64(i+1)))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
bytes[i], bytes[j.Int64()] = bytes[j.Int64()], bytes[i]
|
||||
}
|
||||
|
||||
return string(bytes), nil
|
||||
}
|
||||
|
||||
func initDatabase(driver, connection string) (*xorm.Engine, error) {
|
||||
dataConf := &data.Database{Driver: driver, Connection: connection}
|
||||
if !CheckDBConnection(dataConf) {
|
||||
return nil, fmt.Errorf("database connection check failed")
|
||||
}
|
||||
|
||||
engine, err := data.NewDB(false, dataConf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return engine, nil
|
||||
}
|
||||
|
||||
func printWarning(msg string) {
|
||||
if runtime.GOOS == "windows" {
|
||||
fmt.Printf("[WARNING] %s\n", msg)
|
||||
} else {
|
||||
fmt.Printf("\033[31m[WARNING] %s\033[0m\n", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func confirmAction(prompt string) bool {
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
fmt.Printf("%s [y/N]: ", prompt)
|
||||
response, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
response = strings.ToLower(strings.TrimSpace(response))
|
||||
return response == "y" || response == "yes"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user