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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:22:32 +08:00
commit 39dbe3a57d
1131 changed files with 272709 additions and 0 deletions
+128
View File
@@ -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())
}
+79
View File
@@ -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"
)
+51
View File
@@ -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.`
)
+58
View File
@@ -0,0 +1,58 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package 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
)
+26
View File
@@ -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
)
+71
View File
@@ -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",
}
+32
View File
@@ -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)
)
+46
View File
@@ -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"
)
+75
View File
@@ -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
)
+24
View File
@@ -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"
)
+103
View File
@@ -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,
}
)
+58
View File
@@ -0,0 +1,58 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package 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"
)
+91
View File
@@ -0,0 +1,91 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package 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},
}
)
+27
View File
@@ -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"
)
+42
View File
@@ -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"
)
+44
View File
@@ -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"
)
+59
View File
@@ -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
)
+49
View File
@@ -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"
)
+29
View File
@@ -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"
)
+52
View File
@@ -0,0 +1,52 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package 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
}
+119
View File
@@ -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()
}
+29
View File
@@ -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,
)
+34
View File
@@ -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"`
}
+138
View File
@@ -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
}
+92
View File
@@ -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
}
+47
View File
@@ -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
}
+72
View File
@@ -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,
}
}
+47
View File
@@ -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)
}
+51
View File
@@ -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()
}
}
+327
View File
@@ -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 ")
}
+94
View File
@@ -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()
}
}
+34
View File
@@ -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")
}
}
}
+47
View File
@@ -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)
}
}
+32
View File
@@ -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,
)
+73
View File
@@ -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())
}
}
+62
View File
@@ -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()
}
}
+120
View File
@@ -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)
}
}
+48
View File
@@ -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
}
}
}
+40
View File
@@ -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)
}
+76
View File
@@ -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)
}
+53
View File
@@ -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)
}
+128
View File
@@ -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)
}
}
+251
View File
@@ -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()
})
}
}
+50
View File
@@ -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"
)
+127
View File
@@ -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"
)
+31
View File
@@ -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"`
}
+126
View File
@@ -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
}
+154
View File
@@ -0,0 +1,154 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package 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
}
+25
View File
@@ -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)
+25
View File
@@ -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"`
}
+325
View File
@@ -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()
}
+283
View File
@@ -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()), "&amp;", "&")
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
}