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