Files
Artur Iusupov 3a5a15fe77 fix(site): require explicit email verification setting
Replace OptionalBool with an explicit require_email_verification value for the login settings save request while keeping legacy read defaults intact.

Use positive RequireEmailVerification naming through the registration flow and inline the site setting mapping.

Add validation, save-path, and registration coverage for the explicit email verification setting.
2026-07-10 10:19:39 +08:00

597 lines
22 KiB
Go

/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package schema
import (
"context"
"fmt"
"net/mail"
"net/url"
"path/filepath"
"strings"
"github.com/apache/answer/internal/base/constant"
"github.com/apache/answer/internal/base/handler"
"github.com/apache/answer/internal/base/reason"
"github.com/apache/answer/internal/base/translator"
"github.com/apache/answer/internal/base/validator"
"github.com/segmentfault/pacman/errors"
)
// SiteGeneralReq site general request
type SiteGeneralReq struct {
Name string `validate:"required,sanitizer,gt=1,lte=128" form:"name" json:"name"`
ShortDescription string `validate:"omitempty,sanitizer,gt=3,lte=255" form:"short_description" json:"short_description"`
Description string `validate:"omitempty,sanitizer,gt=3,lte=2000" form:"description" json:"description"`
SiteUrl string `validate:"required,sanitizer,gt=1,lte=512,url" form:"site_url" json:"site_url"`
ContactEmail string `validate:"required,sanitizer,gt=1,lte=512,email" form:"contact_email" json:"contact_email"`
}
func (r *SiteGeneralReq) FormatSiteUrl() {
parsedUrl, err := url.Parse(r.SiteUrl)
if err != nil {
return
}
r.SiteUrl = fmt.Sprintf("%s://%s", parsedUrl.Scheme, parsedUrl.Host)
if len(parsedUrl.Path) > 0 {
r.SiteUrl += parsedUrl.Path
r.SiteUrl = strings.TrimSuffix(r.SiteUrl, "/")
}
}
// SiteInterfaceReq site interface request
type SiteInterfaceReq struct {
Language string `validate:"required,gt=1,lte=128" form:"language" json:"language"`
TimeZone string `validate:"required,gt=1,lte=128" form:"time_zone" json:"time_zone"`
// Deperecated: use SiteUsersSettingsReq instead
DefaultAvatar string `validate:"omitempty" json:"-"`
// Deperecated: use SiteUsersSettingsReq instead
GravatarBaseURL string `validate:"omitempty" json:"-"`
}
// SiteInterfaceSettingsReq site interface settings request
type SiteInterfaceSettingsReq struct {
Language string `validate:"required,gt=1,lte=128" json:"language"`
TimeZone string `validate:"required,gt=1,lte=128" json:"time_zone"`
}
type SiteInterfaceSettingsResp SiteInterfaceSettingsReq
type SiteUsersSettingsReq struct {
DefaultAvatar string `validate:"required,oneof=system gravatar" json:"default_avatar"`
GravatarBaseURL string `validate:"omitempty" json:"gravatar_base_url"`
}
type SiteUsersSettingsResp SiteUsersSettingsReq
// SiteBrandingReq site branding request
type SiteBrandingReq struct {
Logo string `validate:"omitempty,gt=0,lte=512" form:"logo" json:"logo"`
MobileLogo string `validate:"omitempty,gt=0,lte=512" form:"mobile_logo" json:"mobile_logo"`
SquareIcon string `validate:"omitempty,gt=0,lte=512" form:"square_icon" json:"square_icon"`
Favicon string `validate:"omitempty,gt=0,lte=512" form:"favicon" json:"favicon"`
}
// SiteWriteReq site write request use SiteQuestionsReq, SiteAdvancedReq and SiteTagsReq instead
type SiteWriteReq struct {
MinimumContent int `validate:"omitempty,gte=0,lte=65535" json:"min_content"`
RestrictAnswer bool `validate:"omitempty" json:"restrict_answer"`
MinimumTags int `validate:"omitempty,gte=0,lte=5" json:"min_tags"`
RequiredTag bool `validate:"omitempty" json:"required_tag"`
RecommendTags []*SiteWriteTag `validate:"omitempty,dive" json:"recommend_tags"`
ReservedTags []*SiteWriteTag `validate:"omitempty,dive" json:"reserved_tags"`
MaxImageSize int `validate:"omitempty,gt=0" json:"max_image_size"`
MaxAttachmentSize int `validate:"omitempty,gt=0" json:"max_attachment_size"`
MaxImageMegapixel int `validate:"omitempty,gt=0" json:"max_image_megapixel"`
AuthorizedImageExtensions []string `validate:"omitempty" json:"authorized_image_extensions"`
AuthorizedAttachmentExtensions []string `validate:"omitempty" json:"authorized_attachment_extensions"`
UserID string `json:"-"`
}
type SiteWriteResp SiteWriteReq
// SiteQuestionsReq site questions settings request
type SiteQuestionsReq struct {
MinimumTags int `validate:"omitempty,gte=0,lte=5" json:"min_tags"`
MinimumContent int `validate:"omitempty,gte=0,lte=65535" json:"min_content"`
RestrictAnswer bool `validate:"omitempty" json:"restrict_answer"`
}
// SiteAdvancedReq site advanced settings request
type SiteAdvancedReq struct {
MaxImageSize int `validate:"omitempty,gt=0" json:"max_image_size"`
MaxAttachmentSize int `validate:"omitempty,gt=0" json:"max_attachment_size"`
MaxImageMegapixel int `validate:"omitempty,gt=0" json:"max_image_megapixel"`
AuthorizedImageExtensions []string `validate:"omitempty" json:"authorized_image_extensions"`
AuthorizedAttachmentExtensions []string `validate:"omitempty" json:"authorized_attachment_extensions"`
}
// SiteTagsReq site tags settings request
type SiteTagsReq struct {
ReservedTags []*SiteWriteTag `validate:"omitempty,dive" json:"reserved_tags"`
RecommendTags []*SiteWriteTag `validate:"omitempty,dive" json:"recommend_tags"`
RequiredTag bool `validate:"omitempty" json:"required_tag"`
UserID string `json:"-"`
}
func (s *SiteAdvancedResp) GetMaxImageSize() int64 {
if s.MaxImageSize <= 0 {
return constant.DefaultMaxImageSize
}
return int64(s.MaxImageSize) * 1024 * 1024
}
func (s *SiteAdvancedResp) GetMaxAttachmentSize() int64 {
if s.MaxAttachmentSize <= 0 {
return constant.DefaultMaxAttachmentSize
}
return int64(s.MaxAttachmentSize) * 1024 * 1024
}
func (s *SiteAdvancedResp) GetMaxImageMegapixel() int {
if s.MaxImageMegapixel <= 0 {
return constant.DefaultMaxImageMegapixel
}
return s.MaxImageMegapixel * 1000 * 1000
}
// SiteWriteTag site write response tag
type SiteWriteTag struct {
SlugName string `validate:"required" json:"slug_name"`
DisplayName string `json:"display_name"`
}
// SiteLegalReq site branding request use SitePoliciesReq and SiteSecurityReq instead
type SiteLegalReq struct {
TermsOfServiceOriginalText string `json:"terms_of_service_original_text"`
TermsOfServiceParsedText string `json:"terms_of_service_parsed_text"`
PrivacyPolicyOriginalText string `json:"privacy_policy_original_text"`
PrivacyPolicyParsedText string `json:"privacy_policy_parsed_text"`
ExternalContentDisplay string `validate:"required,oneof=always_display ask_before_display" json:"external_content_display"`
}
type SitePoliciesReq struct {
TermsOfServiceOriginalText string `json:"terms_of_service_original_text"`
TermsOfServiceParsedText string `json:"terms_of_service_parsed_text"`
PrivacyPolicyOriginalText string `json:"privacy_policy_original_text"`
PrivacyPolicyParsedText string `json:"privacy_policy_parsed_text"`
}
type SiteSecurityReq struct {
LoginRequired bool `json:"login_required"`
ExternalContentDisplay string `validate:"required,oneof=always_display ask_before_display" json:"external_content_display"`
CheckUpdate bool `validate:"omitempty,sanitizer" form:"check_update" json:"check_update"`
}
type SitePoliciesResp SitePoliciesReq
type SiteSecurityResp SiteSecurityReq
// GetSiteLegalInfoReq site site legal request
type GetSiteLegalInfoReq struct {
InfoType string `validate:"required,oneof=tos privacy" form:"info_type"`
}
func (r *GetSiteLegalInfoReq) IsTOS() bool {
return r.InfoType == "tos"
}
func (r *GetSiteLegalInfoReq) IsPrivacy() bool {
return r.InfoType == "privacy"
}
// GetSiteLegalInfoResp get site legal info response
type GetSiteLegalInfoResp struct {
TermsOfServiceOriginalText string `json:"terms_of_service_original_text,omitempty"`
TermsOfServiceParsedText string `json:"terms_of_service_parsed_text,omitempty"`
PrivacyPolicyOriginalText string `json:"privacy_policy_original_text,omitempty"`
PrivacyPolicyParsedText string `json:"privacy_policy_parsed_text,omitempty"`
}
// SiteUsersReq site users config request
type SiteUsersReq struct {
DefaultAvatar string `validate:"required,oneof=system gravatar" json:"default_avatar"`
GravatarBaseURL string `json:"gravatar_base_url"`
AllowUpdateDisplayName bool `json:"allow_update_display_name"`
AllowUpdateUsername bool `json:"allow_update_username"`
AllowUpdateAvatar bool `json:"allow_update_avatar"`
AllowUpdateBio bool `json:"allow_update_bio"`
AllowUpdateWebsite bool `json:"allow_update_website"`
AllowUpdateLocation bool `json:"allow_update_location"`
}
// SiteLoginReq site login request
type SiteLoginReq struct {
AllowNewRegistrations bool `json:"allow_new_registrations"`
AllowEmailRegistrations bool `json:"allow_email_registrations"`
AllowPasswordLogin bool `json:"allow_password_login"`
AllowEmailDomains []string `json:"allow_email_domains"`
RequireEmailVerification *bool `validate:"required" json:"require_email_verification" swaggertype:"boolean"`
}
// SiteLoginResp site login response
type SiteLoginResp struct {
AllowNewRegistrations bool `json:"allow_new_registrations"`
AllowEmailRegistrations bool `json:"allow_email_registrations"`
AllowPasswordLogin bool `json:"allow_password_login"`
AllowEmailDomains []string `json:"allow_email_domains"`
RequireEmailVerification bool `json:"require_email_verification"`
}
// SiteCustomCssHTMLReq site custom css html
type SiteCustomCssHTMLReq struct {
CustomHead string `validate:"omitempty,gt=0,lte=65536" json:"custom_head"`
CustomCss string `validate:"omitempty,gt=0,lte=65536" json:"custom_css"`
CustomHeader string `validate:"omitempty,gt=0,lte=65536" json:"custom_header"`
CustomFooter string `validate:"omitempty,gt=0,lte=65536" json:"custom_footer"`
CustomSideBar string `validate:"omitempty,gt=0,lte=65536" json:"custom_sidebar"`
}
// SiteThemeReq site theme config
type SiteThemeReq struct {
Theme string `validate:"required,gt=0,lte=255" json:"theme"`
ThemeConfig map[string]any `validate:"omitempty" json:"theme_config"`
ColorScheme string `validate:"omitempty,gt=0,lte=100" json:"color_scheme"`
Layout string `validate:"omitempty,oneof=Full-width Fixed-width" json:"layout"`
}
type SiteSeoReq struct {
Permalink int `validate:"required,lte=4,gte=0" form:"permalink" json:"permalink"`
Robots string `validate:"required" form:"robots" json:"robots"`
}
func (s *SiteSeoResp) IsShortLink() bool {
return s.Permalink == constant.PermalinkQuestionIDAndTitleByShortID ||
s.Permalink == constant.PermalinkQuestionIDByShortID
}
// AIPromptConfig AI prompt configuration for different languages
type AIPromptConfig struct {
ZhCN string `json:"zh_cn"`
EnUS string `json:"en_us"`
}
// SiteAIReq AI configuration request
type SiteAIReq struct {
Enabled bool `validate:"omitempty" form:"enabled" json:"enabled"`
ChosenProvider string `validate:"omitempty,lte=50" form:"chosen_provider" json:"chosen_provider"`
SiteAIProviders []*SiteAIProvider `validate:"omitempty,dive" form:"ai_providers" json:"ai_providers"`
PromptConfig *AIPromptConfig `validate:"omitempty" form:"prompt_config" json:"prompt_config,omitempty"`
}
func (s *SiteAIResp) GetProvider() *SiteAIProvider {
if !s.Enabled || s.ChosenProvider == "" {
return &SiteAIProvider{}
}
if len(s.SiteAIProviders) == 0 {
return &SiteAIProvider{}
}
for _, provider := range s.SiteAIProviders {
if provider.Provider == s.ChosenProvider {
return provider
}
}
return &SiteAIProvider{}
}
type SiteAIProvider struct {
Provider string `validate:"omitempty,lte=50" form:"provider" json:"provider"`
APIHost string `validate:"omitempty,lte=512" form:"api_host" json:"api_host"`
APIKey string `validate:"omitempty,lte=256" form:"api_key" json:"api_key"`
Model string `validate:"omitempty,lte=100" form:"model" json:"model"`
}
// SiteAIResp AI configuration response
type SiteAIResp SiteAIReq
type SiteMCPReq struct {
Enabled bool `validate:"omitempty" form:"enabled" json:"enabled"`
}
type SiteMCPResp struct {
Enabled bool `json:"enabled"`
Type string `json:"type"`
URL string `json:"url"`
HTTPHeader string `json:"http_header"`
}
// SiteGeneralResp site general response
type SiteGeneralResp SiteGeneralReq
// SiteInterfaceResp site interface response
type SiteInterfaceResp SiteInterfaceReq
// SiteBrandingResp site branding response
type SiteBrandingResp SiteBrandingReq
// SiteCustomCssHTMLResp site custom css html response
type SiteCustomCssHTMLResp SiteCustomCssHTMLReq
// SiteUsersResp site users response
type SiteUsersResp SiteUsersReq
// SiteThemeResp site theme response
type SiteThemeResp struct {
ThemeOptions []*ThemeOption `json:"theme_options"`
Theme string `json:"theme"`
ThemeConfig map[string]any `json:"theme_config"`
ColorScheme string `json:"color_scheme"`
Layout string `json:"layout"`
}
func (s *SiteThemeResp) TrTheme(ctx context.Context) {
la := handler.GetLangByCtx(ctx)
for _, option := range s.ThemeOptions {
tr := translator.Tr(la, option.Value)
// if tr is equal the option value means not found translation, so use the original label
if tr != option.Value {
option.Label = tr
}
}
}
// ThemeOption get label option
type ThemeOption struct {
Label string `json:"label"`
Value string `json:"value"`
}
type SiteQuestionsResp SiteQuestionsReq
type SiteAdvancedResp SiteAdvancedReq
type SiteTagsResp SiteTagsReq
// SiteLegalResp site write response use SitePoliciesResp and SiteSecurityResp instead
type SiteLegalResp SiteLegalReq
// SiteLegalSimpleResp site write response
type SiteLegalSimpleResp struct {
ExternalContentDisplay string `validate:"required,oneof=always_display ask_before_display" json:"external_content_display"`
}
// SiteSeoResp site write response
type SiteSeoResp SiteSeoReq
// SiteInfoResp get site info response
type SiteInfoResp struct {
General *SiteGeneralResp `json:"general"`
Interface *SiteInterfaceSettingsResp `json:"interface"`
UsersSettings *SiteUsersSettingsResp `json:"users_settings"`
Branding *SiteBrandingResp `json:"branding"`
Login *SiteLoginResp `json:"login"`
Theme *SiteThemeResp `json:"theme"`
CustomCssHtml *SiteCustomCssHTMLResp `json:"custom_css_html"`
SiteSeo *SiteSeoResp `json:"site_seo"`
SiteUsers *SiteUsersResp `json:"site_users"`
Advanced *SiteAdvancedResp `json:"site_advanced"`
Questions *SiteQuestionsResp `json:"site_questions"`
Tags *SiteTagsResp `json:"site_tags"`
Legal *SiteLegalSimpleResp `json:"site_legal"`
Security *SiteSecurityResp `json:"site_security"`
Version string `json:"version"`
Revision string `json:"revision"`
AIEnabled bool `json:"ai_enabled"`
MCPEnabled bool `json:"mcp_enabled"`
}
type TemplateSiteInfoResp struct {
General *SiteGeneralResp `json:"general"`
Interface *SiteInterfaceSettingsResp `json:"interface"`
Branding *SiteBrandingResp `json:"branding"`
SiteSeo *SiteSeoResp `json:"site_seo"`
CustomCssHtml *SiteCustomCssHTMLResp `json:"custom_css_html"`
Title string
Year string
Canonical string
JsonLD string
Keywords string
Description string
}
// UpdateSMTPConfigReq get smtp config request
type UpdateSMTPConfigReq struct {
FromEmail string `validate:"omitempty,gt=0,lte=256" json:"from_email"`
FromName string `validate:"omitempty,gt=0,lte=256" json:"from_name"`
SMTPHost string `validate:"omitempty,gt=0,lte=256" json:"smtp_host"`
SMTPPort int `validate:"omitempty,min=1,max=65535" json:"smtp_port"`
Encryption string `validate:"omitempty,oneof=SSL TLS" json:"encryption"` // "" SSL TLS
SMTPUsername string `validate:"omitempty,gt=0,lte=256" json:"smtp_username"`
SMTPPassword string `validate:"omitempty,gt=0,lte=256" json:"smtp_password"`
SMTPAuthentication bool `validate:"omitempty" json:"smtp_authentication"`
TestEmailRecipient string `validate:"omitempty,email" json:"test_email_recipient"`
}
func (r *UpdateSMTPConfigReq) Check() (errField []*validator.FormErrorField, err error) {
_, err = mail.ParseAddress(r.FromName)
if err == nil {
return append(errField, &validator.FormErrorField{
ErrorField: "from_name",
ErrorMsg: reason.SMTPConfigFromNameCannotBeEmail,
}), errors.BadRequest(reason.SMTPConfigFromNameCannotBeEmail)
}
return nil, nil
}
// GetSMTPConfigResp get smtp config response
type GetSMTPConfigResp struct {
FromEmail string `json:"from_email"`
FromName string `json:"from_name"`
SMTPHost string `json:"smtp_host"`
SMTPPort int `json:"smtp_port"`
Encryption string `json:"encryption"` // "" SSL TLS
SMTPUsername string `json:"smtp_username"`
SMTPPassword string `json:"smtp_password"`
SMTPAuthentication bool `json:"smtp_authentication"`
}
// GetManifestJsonResp get manifest json response
type GetManifestJsonResp struct {
ManifestVersion int `json:"manifest_version"`
Version string `json:"version"`
Revision string `json:"revision"`
ShortName string `json:"short_name"`
Name string `json:"name"`
Icons []ManifestJsonIcon `json:"icons"`
StartUrl string `json:"start_url"`
Display string `json:"display"`
ThemeColor string `json:"theme_color"`
BackgroundColor string `json:"background_color"`
}
type ManifestJsonIcon struct {
Src string `json:"src"`
Sizes string `json:"sizes"`
Type string `json:"type"`
}
func CreateManifestJsonIcons(icon string) []ManifestJsonIcon {
ext := filepath.Ext(icon)
if ext == "" {
ext = "png"
} else {
ext = strings.ToLower(ext[1:])
}
iconType := fmt.Sprintf("image/%s", ext)
return []ManifestJsonIcon{
{
Src: icon,
Sizes: "16x16",
Type: iconType,
},
{
Src: icon,
Sizes: "32x32",
Type: iconType,
},
{
Src: icon,
Sizes: "48x48",
Type: iconType,
},
{
Src: icon,
Sizes: "128x128",
Type: iconType,
},
}
}
const (
// PrivilegeLevel1 low
PrivilegeLevel1 PrivilegeLevel = 1
// PrivilegeLevel2 medium
PrivilegeLevel2 PrivilegeLevel = 2
// PrivilegeLevel3 high
PrivilegeLevel3 PrivilegeLevel = 3
// PrivilegeLevelCustom custom
PrivilegeLevelCustom PrivilegeLevel = 99
)
type PrivilegeLevel int
type PrivilegeOptions []*PrivilegeOption
func (p PrivilegeOptions) Choose(level PrivilegeLevel) (option *PrivilegeOption) {
for _, op := range p {
if op.Level == level {
return op
}
}
return nil
}
// GetPrivilegesConfigResp get privileges config response
type GetPrivilegesConfigResp struct {
Options []*PrivilegeOption `json:"options"`
SelectedLevel PrivilegeLevel `json:"selected_level"`
}
// PrivilegeOption privilege option
type PrivilegeOption struct {
Level PrivilegeLevel `json:"level"`
LevelDesc string `json:"level_desc"`
Privileges []*constant.Privilege `validate:"dive" json:"privileges"`
}
// UpdatePrivilegesConfigReq update privileges config request
type UpdatePrivilegesConfigReq struct {
Level PrivilegeLevel `validate:"required,min=1,max=3|eq=99" json:"level"`
CustomPrivileges []*constant.Privilege `validate:"dive" json:"custom_privileges"`
}
var (
DefaultPrivilegeOptions PrivilegeOptions
DefaultCustomPrivilegeOption *PrivilegeOption
privilegeOptionsLevelMapping = map[string][]int{
constant.RankQuestionAddKey: {1, 1, 1},
constant.RankAnswerAddKey: {1, 1, 1},
constant.RankCommentAddKey: {1, 1, 1},
constant.RankReportAddKey: {1, 1, 1},
constant.RankCommentVoteUpKey: {1, 1, 1},
constant.RankLinkUrlLimitKey: {1, 10, 10},
constant.RankQuestionVoteUpKey: {1, 8, 15},
constant.RankAnswerVoteUpKey: {1, 8, 15},
constant.RankQuestionVoteDownKey: {125, 125, 125},
constant.RankAnswerVoteDownKey: {125, 125, 125},
constant.RankInviteSomeoneToAnswerKey: {1, 500, 1000},
constant.RankTagAddKey: {1, 750, 1500},
constant.RankTagEditKey: {1, 50, 100},
constant.RankQuestionEditKey: {1, 100, 200},
constant.RankAnswerEditKey: {1, 100, 200},
constant.RankQuestionEditWithoutReviewKey: {1, 1000, 2000},
constant.RankAnswerEditWithoutReviewKey: {1, 1000, 2000},
constant.RankQuestionAuditKey: {1, 1000, 2000},
constant.RankAnswerAuditKey: {1, 1000, 2000},
constant.RankTagAuditKey: {1, 2500, 5000},
constant.RankTagEditWithoutReviewKey: {1, 10000, 20000},
constant.RankTagSynonymKey: {1, 10000, 20000},
}
)
func init() {
DefaultPrivilegeOptions = append(DefaultPrivilegeOptions, &PrivilegeOption{
Level: PrivilegeLevel1,
LevelDesc: reason.PrivilegeLevel1Desc,
}, &PrivilegeOption{
Level: PrivilegeLevel2,
LevelDesc: reason.PrivilegeLevel2Desc,
}, &PrivilegeOption{
Level: PrivilegeLevel3,
LevelDesc: reason.PrivilegeLevel3Desc,
})
for _, option := range DefaultPrivilegeOptions {
for _, privilege := range constant.RankAllPrivileges {
if len(privilegeOptionsLevelMapping[privilege.Key]) == 0 {
continue
}
option.Privileges = append(option.Privileges, &constant.Privilege{
Label: privilege.Label,
Value: privilegeOptionsLevelMapping[privilege.Key][option.Level-1],
Key: privilege.Key,
})
}
}
// set up default custom privilege option
DefaultCustomPrivilegeOption = &PrivilegeOption{
Level: PrivilegeLevelCustom,
LevelDesc: reason.PrivilegeLevelCustomDesc,
Privileges: DefaultPrivilegeOptions[0].Privileges,
}
}