Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 65f0060888 | |||
| 2c0ced322d | |||
| a203e7f608 | |||
| 63a67542f4 | |||
| 2e2c3019a2 | |||
| e8ded23a4a | |||
| 21e0714a08 | |||
| 413bbd0c7f | |||
| 32b451ce87 | |||
| b80ad0a892 | |||
| 1ccb4b7adc | |||
| da622a4927 | |||
| 4488ccc689 | |||
| 98329f3b05 | |||
| ecef4f11dc | |||
| 9df5853942 | |||
| c17e0c94c8 | |||
| b70dda997a | |||
| d10e6aad70 | |||
| e1d58ab635 | |||
| 3b6f981b81 | |||
| d93e31e92a | |||
| 43a91313d8 | |||
| 682811f769 | |||
| cece87f9dc | |||
| e884bb61cb | |||
| 68085ab742 | |||
| 7c210a4855 |
+1
-1
@@ -43,7 +43,7 @@ language_options:
|
||||
progress: 96
|
||||
- label: "Русский"
|
||||
value: "ru_RU"
|
||||
progress: 80
|
||||
progress: 100
|
||||
- label: "简体中文"
|
||||
value: "zh_CN"
|
||||
progress: 100
|
||||
|
||||
+400
-397
File diff suppressed because it is too large
Load Diff
@@ -25,8 +25,12 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const contentSecurityPolicy = "default-src 'self'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'; object-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: http: https:; font-src 'self' data:; connect-src 'self'"
|
||||
|
||||
func HeadersByRequestURI() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Header("Content-Security-Policy", contentSecurityPolicy)
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
if strings.HasPrefix(c.Request.RequestURI, "/static/") {
|
||||
c.Header("cache-control", "public, max-age=31536000")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* 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"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestHeadersByRequestURI(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.Use(HeadersByRequestURI())
|
||||
router.GET("/", func(ctx *gin.Context) { ctx.Status(http.StatusNoContent) })
|
||||
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
|
||||
if got := response.Header().Get("X-Content-Type-Options"); got != "nosniff" {
|
||||
t.Fatalf("X-Content-Type-Options = %q, want nosniff", got)
|
||||
}
|
||||
if got := response.Header().Get("Content-Security-Policy"); got != contentSecurityPolicy {
|
||||
t.Fatalf("Content-Security-Policy = %q, want %q", got, contentSecurityPolicy)
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ func (am *AuthUserMiddleware) VisitAuth() gin.HandlerFunc {
|
||||
|
||||
siteSecurity, err := am.siteInfoCommonService.GetSiteSecurity(ctx)
|
||||
if err != nil {
|
||||
ctx.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !siteSecurity.LoginRequired {
|
||||
|
||||
@@ -78,7 +78,7 @@ func NewHTTPServer(debug bool,
|
||||
rootGroup := r.Group("")
|
||||
swaggerRouter.Register(rootGroup)
|
||||
static := r.Group(uiConf.APIBaseURL)
|
||||
static.Use(avatarMiddleware.AvatarThumb(), authUserMiddleware.VisitAuth())
|
||||
static.Use(authUserMiddleware.VisitAuth(), avatarMiddleware.AvatarThumb())
|
||||
staticRouter.RegisterStaticRouter(static)
|
||||
|
||||
// The route must be available without logging in
|
||||
|
||||
@@ -139,7 +139,7 @@ func (c *MCPController) MCPQuestionDetailHandler() func(ctx context.Context, req
|
||||
}
|
||||
|
||||
question, err := c.questioncommon.Info(ctx, cond.QuestionID, "")
|
||||
if err != nil {
|
||||
if err != nil || !mcpQuestionIsPublic(question) {
|
||||
log.Errorf("get question failed: %v", err)
|
||||
return mcp.NewToolResultText("No question found."), nil
|
||||
}
|
||||
@@ -161,6 +161,9 @@ func (c *MCPController) MCPAnswersHandler() func(ctx context.Context, request mc
|
||||
return nil, err
|
||||
}
|
||||
cond := schema.NewMCPSearchAnswerCond(request)
|
||||
if len(cond.QuestionID) == 0 {
|
||||
return mcp.NewToolResultText("[]"), nil
|
||||
}
|
||||
|
||||
siteGeneral, err := c.siteInfoService.GetSiteGeneral(ctx)
|
||||
if err != nil {
|
||||
@@ -169,6 +172,10 @@ func (c *MCPController) MCPAnswersHandler() func(ctx context.Context, request mc
|
||||
}
|
||||
|
||||
if len(cond.QuestionID) > 0 {
|
||||
question, err := c.questioncommon.Info(ctx, cond.QuestionID, "")
|
||||
if err != nil || !mcpQuestionIsPublic(question) {
|
||||
return mcp.NewToolResultText("[]"), nil
|
||||
}
|
||||
answerList, err := c.answerRepo.GetAnswerList(ctx, &entity.Answer{QuestionID: cond.QuestionID})
|
||||
if err != nil {
|
||||
log.Errorf("get answers failed: %v", err)
|
||||
@@ -214,12 +221,33 @@ func (c *MCPController) MCPAnswersHandler() func(ctx context.Context, request mc
|
||||
}
|
||||
}
|
||||
|
||||
func mcpQuestionIsPublic(question *schema.QuestionInfoResp) bool {
|
||||
return question != nil && question.Show == entity.QuestionShow &&
|
||||
(question.Status == entity.QuestionStatusAvailable || question.Status == entity.QuestionStatusClosed)
|
||||
}
|
||||
|
||||
func (c *MCPController) mcpObjectQuestionIsPublic(ctx context.Context, objectID string) bool {
|
||||
question, err := c.questioncommon.Info(ctx, objectID, "")
|
||||
if err == nil {
|
||||
return mcpQuestionIsPublic(question)
|
||||
}
|
||||
answer, exist, err := c.answerRepo.GetAnswer(ctx, objectID)
|
||||
if err != nil || !exist || answer.Status != entity.AnswerStatusAvailable {
|
||||
return false
|
||||
}
|
||||
question, err = c.questioncommon.Info(ctx, answer.QuestionID, "")
|
||||
return err == nil && mcpQuestionIsPublic(question)
|
||||
}
|
||||
|
||||
func (c *MCPController) MCPCommentsHandler() func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
if err := c.ensureMCPEnabled(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cond := schema.NewMCPSearchCommentCond(request)
|
||||
if len(cond.ObjectID) == 0 || !c.mcpObjectQuestionIsPublic(ctx, cond.ObjectID) {
|
||||
return mcp.NewToolResultText("No comments found."), nil
|
||||
}
|
||||
|
||||
siteGeneral, err := c.siteInfoService.GetSiteGeneral(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -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 controller
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/apache/answer/internal/entity"
|
||||
"github.com/apache/answer/internal/schema"
|
||||
)
|
||||
|
||||
func TestMCPQuestionIsPublic(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
question *schema.QuestionInfoResp
|
||||
want bool
|
||||
}{
|
||||
{"nil", nil, false},
|
||||
{"available", &schema.QuestionInfoResp{Status: entity.QuestionStatusAvailable, Show: entity.QuestionShow}, true},
|
||||
{"closed", &schema.QuestionInfoResp{Status: entity.QuestionStatusClosed, Show: entity.QuestionShow}, true},
|
||||
{"hidden", &schema.QuestionInfoResp{Status: entity.QuestionStatusAvailable, Show: entity.QuestionHide}, false},
|
||||
{"deleted", &schema.QuestionInfoResp{Status: entity.QuestionStatusDeleted, Show: entity.QuestionShow}, false},
|
||||
{"pending", &schema.QuestionInfoResp{Status: entity.QuestionStatusPending, Show: entity.QuestionShow}, false},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
if got := mcpQuestionIsPublic(testCase.question); got != testCase.want {
|
||||
t.Fatalf("mcpQuestionIsPublic() = %v, want %v", got, testCase.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -144,13 +144,7 @@ func (qc *QuestionController) OperationQuestion(ctx *gin.Context) {
|
||||
handler.HandleResponse(ctx, err, nil)
|
||||
return
|
||||
}
|
||||
req.CanPin = canList[0]
|
||||
req.CanList = canList[1]
|
||||
if (req.Operation == schema.QuestionOperationPin || req.Operation == schema.QuestionOperationUnPin) && !req.CanPin {
|
||||
handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil)
|
||||
return
|
||||
}
|
||||
if (req.Operation == schema.QuestionOperationHide || req.Operation == schema.QuestionOperationShow) && !req.CanList {
|
||||
if !canOperateQuestion(req.Operation, canList) {
|
||||
handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil)
|
||||
return
|
||||
}
|
||||
@@ -158,6 +152,21 @@ func (qc *QuestionController) OperationQuestion(ctx *gin.Context) {
|
||||
handler.HandleResponse(ctx, err, nil)
|
||||
}
|
||||
|
||||
func canOperateQuestion(operation string, canList []bool) bool {
|
||||
switch operation {
|
||||
case schema.QuestionOperationPin:
|
||||
return canList[0]
|
||||
case schema.QuestionOperationUnPin:
|
||||
return canList[1]
|
||||
case schema.QuestionOperationHide:
|
||||
return canList[2]
|
||||
case schema.QuestionOperationShow:
|
||||
return canList[3]
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// CloseQuestion Close question
|
||||
// @Summary Close question
|
||||
// @Description Close question
|
||||
@@ -233,6 +242,24 @@ func (qc *QuestionController) GetQuestion(ctx *gin.Context) {
|
||||
id := ctx.Query("id")
|
||||
id = uid.DeShortID(id)
|
||||
userID := middleware.GetLoginUserIDFromContext(ctx)
|
||||
req, err := qc.questionPermission(ctx, userID, id)
|
||||
if err != nil {
|
||||
handler.HandleResponse(ctx, err, nil)
|
||||
return
|
||||
}
|
||||
|
||||
info, err := qc.questionService.GetQuestionAndAddPV(ctx, id, userID, req)
|
||||
if err != nil {
|
||||
handler.HandleResponse(ctx, err, nil)
|
||||
return
|
||||
}
|
||||
if handler.GetEnableShortID(ctx) {
|
||||
info.ID = uid.EnShortID(info.ID)
|
||||
}
|
||||
handler.HandleResponse(ctx, nil, info)
|
||||
}
|
||||
|
||||
func (qc *QuestionController) questionPermission(ctx *gin.Context, userID, questionID string) (schema.QuestionPermission, error) {
|
||||
req := schema.QuestionPermission{}
|
||||
req.IsAdminModerator = middleware.GetUserIsAdminModerator(ctx)
|
||||
canList, err := qc.rankService.CheckOperationPermissions(ctx, userID, []string{
|
||||
@@ -248,10 +275,9 @@ func (qc *QuestionController) GetQuestion(ctx *gin.Context) {
|
||||
permission.QuestionUnDelete,
|
||||
})
|
||||
if err != nil {
|
||||
handler.HandleResponse(ctx, err, nil)
|
||||
return
|
||||
return req, err
|
||||
}
|
||||
objectOwner := qc.rankService.CheckOperationObjectOwner(ctx, userID, id)
|
||||
objectOwner := qc.rankService.CheckOperationObjectOwner(ctx, userID, questionID)
|
||||
|
||||
req.CanEdit = canList[0] || objectOwner
|
||||
req.CanDelete = canList[1]
|
||||
@@ -263,16 +289,7 @@ func (qc *QuestionController) GetQuestion(ctx *gin.Context) {
|
||||
req.CanShow = canList[7]
|
||||
req.CanInviteOtherToAnswer = canList[8]
|
||||
req.CanRecover = canList[9]
|
||||
|
||||
info, err := qc.questionService.GetQuestionAndAddPV(ctx, id, userID, req)
|
||||
if err != nil {
|
||||
handler.HandleResponse(ctx, err, nil)
|
||||
return
|
||||
}
|
||||
if handler.GetEnableShortID(ctx) {
|
||||
info.ID = uid.EnShortID(info.ID)
|
||||
}
|
||||
handler.HandleResponse(ctx, nil, info)
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// GetQuestionInviteUserInfo get question invite user info
|
||||
@@ -286,7 +303,13 @@ func (qc *QuestionController) GetQuestion(ctx *gin.Context) {
|
||||
// @Router /answer/api/v1/question/invite [get]
|
||||
func (qc *QuestionController) GetQuestionInviteUserInfo(ctx *gin.Context) {
|
||||
questionID := uid.DeShortID(ctx.Query("id"))
|
||||
resp, err := qc.questionService.InviteUserInfo(ctx, questionID)
|
||||
userID := middleware.GetLoginUserIDFromContext(ctx)
|
||||
per, err := qc.questionPermission(ctx, userID, questionID)
|
||||
if err != nil {
|
||||
handler.HandleResponse(ctx, err, nil)
|
||||
return
|
||||
}
|
||||
resp, err := qc.questionService.InviteUserInfo(ctx, questionID, userID, per)
|
||||
handler.HandleResponse(ctx, err, resp)
|
||||
}
|
||||
|
||||
@@ -989,6 +1012,7 @@ func (qc *QuestionController) GetQuestionLink(ctx *gin.Context) {
|
||||
return
|
||||
}
|
||||
req.LoginUserID = middleware.GetLoginUserIDFromContext(ctx)
|
||||
req.IsAdminModerator = middleware.GetUserIsAdminModerator(ctx)
|
||||
req.QuestionID = uid.DeShortID(req.QuestionID)
|
||||
questions, total, err := qc.questionService.GetQuestionLink(ctx, req)
|
||||
if err != nil {
|
||||
|
||||
@@ -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 controller
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/apache/answer/internal/schema"
|
||||
)
|
||||
|
||||
func TestCanOperateQuestionUsesMatchingPermission(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
operation string
|
||||
canList []bool
|
||||
want bool
|
||||
}{
|
||||
{"pin", schema.QuestionOperationPin, []bool{true, false, false, false}, true},
|
||||
{"unpin", schema.QuestionOperationUnPin, []bool{false, true, false, false}, true},
|
||||
{"hide", schema.QuestionOperationHide, []bool{false, false, true, false}, true},
|
||||
{"show", schema.QuestionOperationShow, []bool{false, false, false, true}, true},
|
||||
{"unpin does not authorize hide", schema.QuestionOperationHide, []bool{false, true, false, false}, false},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
if got := canOperateQuestion(testCase.operation, testCase.canList); got != testCase.want {
|
||||
t.Fatalf("canOperateQuestion(%q, %v) = %v, want %v", testCase.operation, testCase.canList, got, testCase.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,7 @@ var migrations = []Migration{
|
||||
NewMigration("v2.0.1", "change avatar type to text", updateAvatarType, false),
|
||||
NewMigration("v2.0.2", "add reasoning content to ai conversation record", addAIConversationReasoningContent, false),
|
||||
NewMigration("v2.0.3", "add require email verification login setting", addRequireEmailVerification, true),
|
||||
NewMigration("v2.0.4", "repair missing advanced site settings", repairAdvancedSiteInfo, true),
|
||||
}
|
||||
|
||||
func GetMigrations() []Migration {
|
||||
|
||||
@@ -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 migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/apache/answer/internal/base/constant"
|
||||
"github.com/apache/answer/internal/entity"
|
||||
"github.com/apache/answer/internal/schema"
|
||||
"xorm.io/builder"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
func repairAdvancedSiteInfo(ctx context.Context, x *xorm.Engine) error {
|
||||
advanced := &entity.SiteInfo{}
|
||||
exists, err := x.Context(ctx).Where(builder.Eq{"type": constant.SiteTypeAdvanced}).Get(advanced)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
write := &entity.SiteInfo{}
|
||||
exists, err = x.Context(ctx).Where(builder.Eq{"type": constant.SiteTypeWrite}).Get(write)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
siteWrite := &schema.SiteWriteResp{}
|
||||
if err := json.Unmarshal([]byte(write.Content), siteWrite); err != nil {
|
||||
return err
|
||||
}
|
||||
content, err := json.Marshal(&schema.SiteAdvancedResp{
|
||||
MaxImageSize: siteWrite.MaxImageSize,
|
||||
MaxAttachmentSize: siteWrite.MaxAttachmentSize,
|
||||
MaxImageMegapixel: siteWrite.MaxImageMegapixel,
|
||||
AuthorizedImageExtensions: siteWrite.AuthorizedImageExtensions,
|
||||
AuthorizedAttachmentExtensions: siteWrite.AuthorizedAttachmentExtensions,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = x.Context(ctx).Insert(&entity.SiteInfo{
|
||||
Type: constant.SiteTypeAdvanced,
|
||||
Content: string(content),
|
||||
Status: 1,
|
||||
})
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* 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 migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/apache/answer/internal/base/constant"
|
||||
"github.com/apache/answer/internal/entity"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
func TestRepairAdvancedSiteInfoAddsMissingSettings(t *testing.T) {
|
||||
x, err := xorm.NewEngine("sqlite", ":memory:")
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
_ = x.Close()
|
||||
}()
|
||||
require.NoError(t, x.Sync(new(entity.SiteInfo)))
|
||||
|
||||
_, err = x.Insert(&entity.SiteInfo{
|
||||
Type: constant.SiteTypeWrite,
|
||||
Content: `{"max_image_size":5}`,
|
||||
Status: 1,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
var repairMigration Migration
|
||||
for _, m := range GetMigrations() {
|
||||
if m.Version() == "v2.0.4" {
|
||||
repairMigration = m
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNil(t, repairMigration)
|
||||
require.NoError(t, repairMigration.Migrate(context.Background(), x))
|
||||
|
||||
advanced := &entity.SiteInfo{}
|
||||
exists, err := x.Where("type = ?", constant.SiteTypeAdvanced).Get(advanced)
|
||||
require.NoError(t, err)
|
||||
require.True(t, exists)
|
||||
assert.JSONEq(t, `{
|
||||
"max_image_size": 5,
|
||||
"max_attachment_size": 0,
|
||||
"max_image_megapixel": 0,
|
||||
"authorized_image_extensions": null,
|
||||
"authorized_attachment_extensions": null
|
||||
}`, advanced.Content)
|
||||
}
|
||||
|
||||
func TestRepairAdvancedSiteInfoPreservesExistingSettings(t *testing.T) {
|
||||
x, err := xorm.NewEngine("sqlite", ":memory:")
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
_ = x.Close()
|
||||
}()
|
||||
require.NoError(t, x.Sync(new(entity.SiteInfo)))
|
||||
|
||||
const existingContent = `{"max_image_size":99}`
|
||||
_, err = x.Insert(
|
||||
&entity.SiteInfo{
|
||||
Type: constant.SiteTypeWrite,
|
||||
Content: `{invalid`,
|
||||
Status: 1,
|
||||
},
|
||||
&entity.SiteInfo{
|
||||
Type: constant.SiteTypeAdvanced,
|
||||
Content: existingContent,
|
||||
Status: 1,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, repairAdvancedSiteInfo(context.Background(), x))
|
||||
|
||||
advanced := &entity.SiteInfo{}
|
||||
exists, err := x.Where("type = ?", constant.SiteTypeAdvanced).Get(advanced)
|
||||
require.NoError(t, err)
|
||||
require.True(t, exists)
|
||||
assert.JSONEq(t, existingContent, advanced.Content)
|
||||
}
|
||||
@@ -817,10 +817,9 @@ func (qr *questionRepo) UpdateQuestionLinkStatus(ctx context.Context, status int
|
||||
}
|
||||
|
||||
// GetQuestionLink get linked question to questionID
|
||||
func (qr *questionRepo) GetQuestionLink(ctx context.Context, page, pageSize int, questionID string, orderCond string, inDays int) (questionList []*entity.Question, total int64, err error) {
|
||||
func (qr *questionRepo) GetQuestionLink(ctx context.Context, page, pageSize int, questionID, loginUserID string, isAdminModerator bool, orderCond string, inDays int) (questionList []*entity.Question, total int64, err error) {
|
||||
questionList = make([]*entity.Question, 0)
|
||||
questionID = uid.DeShortID(questionID)
|
||||
questionStatus := []int{entity.QuestionStatusAvailable, entity.QuestionStatusClosed, entity.QuestionStatusPending}
|
||||
if questionID == "0" {
|
||||
return nil, 0, errors.InternalServer(reason.DatabaseError).WithError(
|
||||
fmt.Errorf("questionID is empty"),
|
||||
@@ -833,8 +832,15 @@ func (qr *questionRepo) GetQuestionLink(ctx context.Context, page, pageSize int,
|
||||
Where("question_link.to_question_id = ? AND question.show = ?", questionID, entity.QuestionShow).
|
||||
Distinct("question.id").
|
||||
Where("question_link.status = ?", entity.QuestionLinkStatusAvailable).
|
||||
Select("question.*").
|
||||
In("question.status", questionStatus)
|
||||
Select("question.*")
|
||||
switch {
|
||||
case isAdminModerator:
|
||||
session.Where("question.status IN (?, ?, ?)", entity.QuestionStatusAvailable, entity.QuestionStatusClosed, entity.QuestionStatusPending)
|
||||
case loginUserID != "":
|
||||
session.Where("(question.status IN (?, ?) OR (question.status = ? AND question.user_id = ?))", entity.QuestionStatusAvailable, entity.QuestionStatusClosed, entity.QuestionStatusPending, loginUserID)
|
||||
default:
|
||||
session.In("question.status", []int{entity.QuestionStatusAvailable, entity.QuestionStatusClosed})
|
||||
}
|
||||
|
||||
switch orderCond {
|
||||
case "newest":
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* 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 repo_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/apache/answer/internal/entity"
|
||||
"github.com/apache/answer/internal/repo/question"
|
||||
"github.com/apache/answer/internal/repo/unique"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestQuestionRepoGetQuestionLinkRespectsPendingVisibility(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
questionRepo := question.NewQuestionRepo(testDataSource, unique.NewUniqueIDRepo(testDataSource))
|
||||
|
||||
newQuestion := func(userID, title string, status, show int) *entity.Question {
|
||||
q := &entity.Question{
|
||||
UserID: userID,
|
||||
Title: title,
|
||||
OriginalText: title,
|
||||
ParsedText: title,
|
||||
Status: status,
|
||||
Show: show,
|
||||
}
|
||||
require.NoError(t, questionRepo.AddQuestion(ctx, q))
|
||||
return q
|
||||
}
|
||||
|
||||
target := newQuestion("link-target-owner", "link target", entity.QuestionStatusAvailable, entity.QuestionShow)
|
||||
available := newQuestion("link-author", "available", entity.QuestionStatusAvailable, entity.QuestionShow)
|
||||
pendingOwner := newQuestion("link-author", "pending owner", entity.QuestionStatusPending, entity.QuestionShow)
|
||||
pendingOther := newQuestion("link-other", "pending other", entity.QuestionStatusPending, entity.QuestionShow)
|
||||
hidden := newQuestion("link-author", "hidden", entity.QuestionStatusAvailable, entity.QuestionHide)
|
||||
questions := []*entity.Question{target, available, pendingOwner, pendingOther, hidden}
|
||||
|
||||
for _, from := range questions[1:] {
|
||||
_, err := testDataSource.DB.Context(ctx).Insert(&entity.QuestionLink{
|
||||
FromQuestionID: from.ID,
|
||||
ToQuestionID: target.ID,
|
||||
Status: entity.QuestionLinkStatusAvailable,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = testDataSource.DB.Context(ctx).Where("to_question_id = ?", target.ID).Delete(&entity.QuestionLink{})
|
||||
for _, q := range questions {
|
||||
_, _ = testDataSource.DB.Context(ctx).ID(q.ID).Delete(&entity.Question{})
|
||||
}
|
||||
})
|
||||
|
||||
assertLinkedIDs := func(loginUserID string, isAdminModerator bool, want ...string) {
|
||||
got, _, err := questionRepo.GetQuestionLink(ctx, 1, 20, target.ID, loginUserID, isAdminModerator, "newest", 0)
|
||||
require.NoError(t, err)
|
||||
gotIDs := make([]string, 0, len(got))
|
||||
for _, q := range got {
|
||||
gotIDs = append(gotIDs, q.ID)
|
||||
}
|
||||
require.ElementsMatch(t, want, gotIDs)
|
||||
}
|
||||
|
||||
assertLinkedIDs("", false, available.ID)
|
||||
assertLinkedIDs("link-author", false, available.ID, pendingOwner.ID)
|
||||
assertLinkedIDs("link-other", false, available.ID, pendingOther.ID)
|
||||
assertLinkedIDs("link-moderator", true, available.ID, pendingOwner.ID, pendingOther.ID)
|
||||
}
|
||||
@@ -21,7 +21,6 @@ package tag_common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -171,10 +170,20 @@ func (tr *tagCommonRepo) GetTagPage(ctx context.Context, page, pageSize int, tag
|
||||
session := tr.data.DB.Context(ctx)
|
||||
|
||||
if len(tag.SlugName) > 0 {
|
||||
// Both sides lowered, so the search is case-insensitive.
|
||||
//
|
||||
// This previously read LOWER(%s) formatted against the *search term*,
|
||||
// which put the function name into the value: the query became
|
||||
// slug_name LIKE '%LOWER(coco)%' and could never match. Only the
|
||||
// display_name clause did anything, and that is case-sensitive on
|
||||
// Postgres, so typing a tag in lower case -- which is how tags are
|
||||
// written and therefore how anyone types them -- returned nothing at all
|
||||
// and read as "no such tag".
|
||||
search := searchTermForTag(tag.SlugName)
|
||||
mainTagCond := builder.And(
|
||||
builder.Or(
|
||||
builder.Like{"slug_name", fmt.Sprintf("LOWER(%s)", tag.SlugName)},
|
||||
builder.Like{"display_name", tag.SlugName},
|
||||
builder.Like{"LOWER(slug_name)", search},
|
||||
builder.Like{"LOWER(display_name)", search},
|
||||
),
|
||||
builder.Eq{"main_tag_id": 0},
|
||||
)
|
||||
@@ -293,3 +302,10 @@ func (tr *tagCommonRepo) UpdateTagsAttribute(ctx context.Context, tags []string,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// searchTermForTag normalises a tag search term. Lowering it here, and lowering
|
||||
// the columns in the query, is what makes the search case-insensitive: tags are
|
||||
// written in lower case, so that is how people type them.
|
||||
func searchTermForTag(term string) string {
|
||||
return strings.ToLower(strings.TrimSpace(term))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* 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 tag_common
|
||||
|
||||
import "testing"
|
||||
|
||||
// The bug: the search term was formatted into LOWER(%s), which put the function
|
||||
// name into the value rather than applying it to the column, so the query became
|
||||
// slug_name LIKE '%LOWER(coco)%' and matched nothing. Only display_name did any
|
||||
// work, and that is case-sensitive on Postgres -- so typing a tag the way tags
|
||||
// are actually written returned "no such tag".
|
||||
func TestSearchTermIsLoweredNotWrapped(t *testing.T) {
|
||||
for _, in := range []string{"Coco", "COCO", "coco"} {
|
||||
got := searchTermForTag(in)
|
||||
if got != "coco" {
|
||||
t.Errorf("searchTermForTag(%q) = %q, want %q", in, got, "coco")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,10 +53,11 @@ func (a *StaticRouter) RegisterStaticRouter(r *gin.RouterGroup) {
|
||||
filePath := c.Param("filepath")
|
||||
// The original filename is 123.pdf
|
||||
originalFilename := filepath.Base(filePath)
|
||||
// The real filename is hash.pdf
|
||||
realFilename := strings.TrimSuffix(filePath, "/"+originalFilename) + filepath.Ext(originalFilename)
|
||||
// The file local path is /uploads/files/post/hash.pdf
|
||||
fileLocalPath := filepath.Join(a.serviceConfig.UploadPath, constant.FilesPostSubPath, realFilename)
|
||||
fileLocalPath, ok := attachmentFileLocalPath(a.serviceConfig.UploadPath, filePath, originalFilename)
|
||||
if !ok {
|
||||
c.Redirect(http.StatusFound, "/404")
|
||||
return
|
||||
}
|
||||
// If the file is not exist, return 404
|
||||
if !dir.CheckFileExist(fileLocalPath) {
|
||||
c.Redirect(http.StatusFound, "/404")
|
||||
@@ -65,3 +66,14 @@ func (a *StaticRouter) RegisterStaticRouter(r *gin.RouterGroup) {
|
||||
c.FileAttachment(fileLocalPath, originalFilename)
|
||||
})
|
||||
}
|
||||
|
||||
func attachmentFileLocalPath(uploadPath, requestPath, originalFilename string) (string, bool) {
|
||||
realFilename := strings.TrimSuffix(requestPath, "/"+originalFilename) + filepath.Ext(originalFilename)
|
||||
attachmentRoot := filepath.Join(uploadPath, constant.FilesPostSubPath)
|
||||
fileLocalPath := filepath.Join(attachmentRoot, realFilename)
|
||||
relPath, err := filepath.Rel(attachmentRoot, fileLocalPath)
|
||||
if err != nil || filepath.IsAbs(relPath) || relPath == ".." || strings.HasPrefix(relPath, ".."+string(filepath.Separator)) {
|
||||
return "", false
|
||||
}
|
||||
return fileLocalPath, true
|
||||
}
|
||||
|
||||
@@ -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 router
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/apache/answer/internal/base/constant"
|
||||
)
|
||||
|
||||
func TestAttachmentFileLocalPathRejectsTraversal(t *testing.T) {
|
||||
uploadPath := t.TempDir()
|
||||
|
||||
filePath, ok := attachmentFileLocalPath(uploadPath, "/hash/report.pdf", "report.pdf")
|
||||
if !ok {
|
||||
t.Fatal("valid attachment path was rejected")
|
||||
}
|
||||
want := filepath.Join(uploadPath, constant.FilesPostSubPath, "hash.pdf")
|
||||
if filePath != want {
|
||||
t.Fatalf("attachment path = %q, want %q", filePath, want)
|
||||
}
|
||||
|
||||
for _, requestPath := range []string{"/../../outside/secret.txt", "/hash/../../../secret.txt"} {
|
||||
if _, ok := attachmentFileLocalPath(uploadPath, requestPath, filepath.Base(requestPath)); ok {
|
||||
t.Fatalf("traversal path %q was accepted", requestPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ package schema
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
|
||||
"github.com/apache/answer/internal/base/constant"
|
||||
)
|
||||
@@ -28,7 +29,7 @@ import (
|
||||
const (
|
||||
AccountActivationSourceType EmailSourceType = "account-activation"
|
||||
PasswordResetSourceType EmailSourceType = "password-reset"
|
||||
ConfirmNewEmailSourceType EmailSourceType = "password-reset"
|
||||
ConfirmNewEmailSourceType EmailSourceType = "confirm-new-email"
|
||||
UnsubscribeSourceType EmailSourceType = "unsubscribe"
|
||||
BindingSourceType EmailSourceType = "binding"
|
||||
)
|
||||
@@ -56,6 +57,10 @@ func (r *EmailCodeContent) FromJSONString(data string) error {
|
||||
return json.Unmarshal([]byte(data), &r)
|
||||
}
|
||||
|
||||
func (r *EmailCodeContent) IsSourceType(sourceTypes ...EmailSourceType) bool {
|
||||
return slices.Contains(sourceTypes, r.SourceType)
|
||||
}
|
||||
|
||||
type RegisterTemplateData struct {
|
||||
SiteName string
|
||||
RegisterUrl string
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* 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 "testing"
|
||||
|
||||
func TestEmailCodeContentIsSourceType(t *testing.T) {
|
||||
if PasswordResetSourceType == ConfirmNewEmailSourceType {
|
||||
t.Fatal("password reset and confirm new email source types must be distinct")
|
||||
}
|
||||
|
||||
passwordResetCode := &EmailCodeContent{SourceType: PasswordResetSourceType}
|
||||
if !passwordResetCode.IsSourceType(PasswordResetSourceType) {
|
||||
t.Fatal("password reset code should match the password reset source type")
|
||||
}
|
||||
if passwordResetCode.IsSourceType(UnsubscribeSourceType, ConfirmNewEmailSourceType) {
|
||||
t.Fatal("password reset code must not match another source type")
|
||||
}
|
||||
|
||||
unsubscribeCode := &EmailCodeContent{SourceType: UnsubscribeSourceType}
|
||||
if unsubscribeCode.IsSourceType(PasswordResetSourceType, AccountActivationSourceType) {
|
||||
t.Fatal("unsubscribe code must not match an account credential source type")
|
||||
}
|
||||
}
|
||||
@@ -514,13 +514,13 @@ type PersonalCollectionPageReq struct {
|
||||
}
|
||||
|
||||
type GetQuestionLinkReq struct {
|
||||
Page int `validate:"omitempty,min=1" form:"page"`
|
||||
PageSize int `validate:"omitempty,min=1,max=100" form:"page_size"`
|
||||
QuestionID string `validate:"required" form:"question_id"`
|
||||
OrderCond string `validate:"omitempty,oneof=newest active hot score unanswered recommend frequent" form:"order"`
|
||||
InDays int `validate:"omitempty,min=1" form:"in_days"`
|
||||
|
||||
LoginUserID string `json:"-"`
|
||||
Page int `validate:"omitempty,min=1" form:"page"`
|
||||
PageSize int `validate:"omitempty,min=1,max=100" form:"page_size"`
|
||||
QuestionID string `validate:"required" form:"question_id"`
|
||||
OrderCond string `validate:"omitempty,oneof=newest active hot score unanswered recommend frequent" form:"order"`
|
||||
InDays int `validate:"omitempty,min=1" form:"in_days"`
|
||||
LoginUserID string `json:"-"`
|
||||
IsAdminModerator bool `json:"-"`
|
||||
}
|
||||
|
||||
type GetQuestionLinkResp struct {
|
||||
|
||||
@@ -26,7 +26,6 @@ import (
|
||||
"github.com/apache/answer/internal/service/apikey"
|
||||
"github.com/apache/answer/pkg/token"
|
||||
"github.com/apache/answer/plugin"
|
||||
"github.com/segmentfault/pacman/log"
|
||||
)
|
||||
|
||||
// AuthRepo auth repository
|
||||
@@ -198,9 +197,7 @@ func (as *AuthService) AuthAPIKey(ctx context.Context, read bool, apiKey string)
|
||||
}
|
||||
// If the request is not read-only, check if the API key has write permissions
|
||||
if !read && apiKeyInfo.Scope == "read-only" {
|
||||
log.Warnf("API key %s does not have write permissions", apiKeyInfo.AccessKey)
|
||||
return false, nil
|
||||
}
|
||||
log.Infof("API key %s is valid, scope: %s", apiKeyInfo.AccessKey, apiKeyInfo.Scope)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* 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 auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/apache/answer/internal/entity"
|
||||
"github.com/segmentfault/pacman/log"
|
||||
)
|
||||
|
||||
func TestAuthAPIKeyDoesNotLogAccessKey(t *testing.T) {
|
||||
const accessKey = "sk_sensitive-api-key-must-not-be-logged"
|
||||
|
||||
logger := &authTestLogger{}
|
||||
previousLogger := log.GetLogger()
|
||||
log.SetLogger(logger)
|
||||
t.Cleanup(func() { log.SetLogger(previousLogger) })
|
||||
|
||||
service := NewAuthService(nil, &authTestAPIKeyRepo{
|
||||
key: &entity.APIKey{AccessKey: accessKey, Scope: "read-only"},
|
||||
})
|
||||
|
||||
pass, err := service.AuthAPIKey(context.Background(), true, accessKey)
|
||||
if err != nil || !pass {
|
||||
t.Fatalf("read-only API key should authenticate read request: pass=%v err=%v", pass, err)
|
||||
}
|
||||
|
||||
pass, err = service.AuthAPIKey(context.Background(), false, accessKey)
|
||||
if err != nil || pass {
|
||||
t.Fatalf("read-only API key should not authenticate write request: pass=%v err=%v", pass, err)
|
||||
}
|
||||
|
||||
if logs := logger.String(); strings.Contains(logs, accessKey) {
|
||||
t.Fatalf("authentication logs contain API key: %s", logs)
|
||||
}
|
||||
}
|
||||
|
||||
type authTestAPIKeyRepo struct {
|
||||
key *entity.APIKey
|
||||
}
|
||||
|
||||
func (r *authTestAPIKeyRepo) GetAPIKeyList(context.Context) ([]*entity.APIKey, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *authTestAPIKeyRepo) GetAPIKey(context.Context, string) (*entity.APIKey, bool, error) {
|
||||
return r.key, true, nil
|
||||
}
|
||||
|
||||
func (r *authTestAPIKeyRepo) UpdateAPIKey(context.Context, entity.APIKey) error { return nil }
|
||||
|
||||
func (r *authTestAPIKeyRepo) AddAPIKey(context.Context, entity.APIKey) error { return nil }
|
||||
|
||||
func (r *authTestAPIKeyRepo) DeleteAPIKey(context.Context, int) error { return nil }
|
||||
|
||||
func (r *authTestAPIKeyRepo) DeleteAPIKeysByUserID(context.Context, string) error { return nil }
|
||||
|
||||
type authTestLogger struct {
|
||||
entries []string
|
||||
}
|
||||
|
||||
func (l *authTestLogger) Debug(v ...any) { l.entries = append(l.entries, fmt.Sprint(v...)) }
|
||||
func (l *authTestLogger) Debugf(format string, v ...any) {
|
||||
l.entries = append(l.entries, fmt.Sprintf(format, v...))
|
||||
}
|
||||
func (l *authTestLogger) Info(v ...any) { l.entries = append(l.entries, fmt.Sprint(v...)) }
|
||||
func (l *authTestLogger) Infof(format string, v ...any) {
|
||||
l.entries = append(l.entries, fmt.Sprintf(format, v...))
|
||||
}
|
||||
func (l *authTestLogger) Warn(v ...any) { l.entries = append(l.entries, fmt.Sprint(v...)) }
|
||||
func (l *authTestLogger) Warnf(format string, v ...any) {
|
||||
l.entries = append(l.entries, fmt.Sprintf(format, v...))
|
||||
}
|
||||
func (l *authTestLogger) Error(v ...any) { l.entries = append(l.entries, fmt.Sprint(v...)) }
|
||||
func (l *authTestLogger) Errorf(format string, v ...any) {
|
||||
l.entries = append(l.entries, fmt.Sprintf(format, v...))
|
||||
}
|
||||
func (l *authTestLogger) String() string { return strings.Join(l.entries, "\n") }
|
||||
@@ -1091,13 +1091,8 @@ func (qs *QuestionService) GetQuestion(ctx context.Context, questionID, userID s
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// If the question is deleted or pending, only the administrator and the author can view it
|
||||
if (question.Status == entity.QuestionStatusDeleted ||
|
||||
question.Status == entity.QuestionStatusPending) && !per.CanReopen && question.UserID != userID {
|
||||
return nil, errors.NotFound(reason.QuestionNotFound)
|
||||
}
|
||||
if question.Show == entity.QuestionHide && !per.IsAdminModerator && question.UserID != userID {
|
||||
return nil, errors.NotFound(reason.QuestionNotFound)
|
||||
if err = checkQuestionVisibility(question, userID, per); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if question.Status != entity.QuestionStatusClosed {
|
||||
per.CanReopen = false
|
||||
@@ -1142,6 +1137,19 @@ func (qs *QuestionService) GetQuestion(ctx context.Context, questionID, userID s
|
||||
return question, nil
|
||||
}
|
||||
|
||||
func checkQuestionVisibility(question *schema.QuestionInfoResp, userID string, per schema.QuestionPermission) error {
|
||||
// Deleted and pending questions are visible only to their author or users who can reopen them.
|
||||
if (question.Status == entity.QuestionStatusDeleted ||
|
||||
question.Status == entity.QuestionStatusPending) && !per.CanReopen && question.UserID != userID {
|
||||
return errors.NotFound(reason.QuestionNotFound)
|
||||
}
|
||||
// Hidden questions are visible only to their author or an administrator/moderator.
|
||||
if question.Show == entity.QuestionHide && !per.IsAdminModerator && question.UserID != userID {
|
||||
return errors.NotFound(reason.QuestionNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetQuestionAndAddPV get question one
|
||||
func (qs *QuestionService) GetQuestionAndAddPV(ctx context.Context, questionID, loginUserID string,
|
||||
per schema.QuestionPermission) (
|
||||
@@ -1153,7 +1161,11 @@ func (qs *QuestionService) GetQuestionAndAddPV(ctx context.Context, questionID,
|
||||
return qs.GetQuestion(ctx, questionID, loginUserID, per)
|
||||
}
|
||||
|
||||
func (qs *QuestionService) InviteUserInfo(ctx context.Context, questionID string) (inviteList []*schema.UserBasicInfo, err error) {
|
||||
func (qs *QuestionService) InviteUserInfo(ctx context.Context, questionID, userID string,
|
||||
per schema.QuestionPermission) (inviteList []*schema.UserBasicInfo, err error) {
|
||||
if _, err = qs.GetQuestion(ctx, questionID, userID, per); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return qs.questioncommon.InviteUserInfo(ctx, questionID)
|
||||
}
|
||||
|
||||
@@ -1748,7 +1760,7 @@ func (qs *QuestionService) GetQuestionLink(ctx context.Context, req *schema.GetQ
|
||||
req.InDays = schema.HotInDays
|
||||
}
|
||||
|
||||
questionList, total, err := qs.questionRepo.GetQuestionLink(ctx, req.Page, req.PageSize, req.QuestionID, req.OrderCond, req.InDays)
|
||||
questionList, total, err := qs.questionRepo.GetQuestionLink(ctx, req.Page, req.PageSize, req.QuestionID, req.LoginUserID, req.IsAdminModerator, req.OrderCond, req.InDays)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* 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 content
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/apache/answer/internal/entity"
|
||||
"github.com/apache/answer/internal/schema"
|
||||
)
|
||||
|
||||
func TestCheckQuestionVisibility(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
status int
|
||||
show int
|
||||
userID string
|
||||
viewer string
|
||||
per schema.QuestionPermission
|
||||
allow bool
|
||||
}{
|
||||
{"public question", entity.QuestionStatusAvailable, entity.QuestionShow, "author", "", schema.QuestionPermission{}, true},
|
||||
{"pending question anonymous", entity.QuestionStatusPending, entity.QuestionShow, "author", "", schema.QuestionPermission{}, false},
|
||||
{"pending question author", entity.QuestionStatusPending, entity.QuestionShow, "author", "author", schema.QuestionPermission{}, true},
|
||||
{"pending question reviewer", entity.QuestionStatusPending, entity.QuestionShow, "author", "reviewer", schema.QuestionPermission{CanReopen: true}, true},
|
||||
{"deleted question anonymous", entity.QuestionStatusDeleted, entity.QuestionShow, "author", "", schema.QuestionPermission{}, false},
|
||||
{"hidden question anonymous", entity.QuestionStatusAvailable, entity.QuestionHide, "author", "", schema.QuestionPermission{}, false},
|
||||
{"hidden question author", entity.QuestionStatusAvailable, entity.QuestionHide, "author", "author", schema.QuestionPermission{}, true},
|
||||
{"hidden question moderator", entity.QuestionStatusAvailable, entity.QuestionHide, "author", "moderator", schema.QuestionPermission{IsAdminModerator: true}, true},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
question := &schema.QuestionInfoResp{
|
||||
Status: testCase.status,
|
||||
Show: testCase.show,
|
||||
UserID: testCase.userID,
|
||||
}
|
||||
err := checkQuestionVisibility(question, testCase.viewer, testCase.per)
|
||||
if testCase.allow && err != nil {
|
||||
t.Fatalf("visibility unexpectedly denied: %v", err)
|
||||
}
|
||||
if !testCase.allow && err == nil {
|
||||
t.Fatal("visibility unexpectedly allowed")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -227,8 +227,9 @@ func (us *UserService) RetrievePassWord(ctx context.Context, req *schema.UserRet
|
||||
|
||||
// send email
|
||||
data := &schema.EmailCodeContent{
|
||||
Email: req.Email,
|
||||
UserID: userInfo.ID,
|
||||
SourceType: schema.PasswordResetSourceType,
|
||||
Email: req.Email,
|
||||
UserID: userInfo.ID,
|
||||
}
|
||||
code := token.GenerateToken()
|
||||
verifyEmailURL := fmt.Sprintf("%s/users/password-reset?code=%s", us.getSiteUrl(ctx), code)
|
||||
@@ -247,6 +248,9 @@ func (us *UserService) UpdatePasswordWhenForgot(ctx context.Context, req *schema
|
||||
if err != nil {
|
||||
return errors.BadRequest(reason.EmailVerifyURLExpired)
|
||||
}
|
||||
if !data.IsSourceType(schema.PasswordResetSourceType) {
|
||||
return errors.BadRequest(reason.EmailVerifyURLExpired)
|
||||
}
|
||||
|
||||
userInfo, exist, err := us.userRepo.GetByEmail(ctx, data.Email)
|
||||
if err != nil {
|
||||
@@ -598,8 +602,9 @@ func applyRegistrationVerification(
|
||||
|
||||
func (us *UserService) sendRegistrationActivationEmail(ctx context.Context, userInfo *entity.User) error {
|
||||
data := &schema.EmailCodeContent{
|
||||
Email: userInfo.EMail,
|
||||
UserID: userInfo.ID,
|
||||
SourceType: schema.AccountActivationSourceType,
|
||||
Email: userInfo.EMail,
|
||||
UserID: userInfo.ID,
|
||||
}
|
||||
code := token.GenerateToken()
|
||||
verifyEmailURL := fmt.Sprintf("%s/users/account-activation?code=%s", us.getSiteUrl(ctx), code)
|
||||
@@ -621,8 +626,9 @@ func (us *UserService) UserVerifyEmailSend(ctx context.Context, userID string) e
|
||||
}
|
||||
|
||||
data := &schema.EmailCodeContent{
|
||||
Email: userInfo.EMail,
|
||||
UserID: userInfo.ID,
|
||||
SourceType: schema.AccountActivationSourceType,
|
||||
Email: userInfo.EMail,
|
||||
UserID: userInfo.ID,
|
||||
}
|
||||
code := token.GenerateToken()
|
||||
verifyEmailURL := fmt.Sprintf("%s/users/account-activation?code=%s", us.getSiteUrl(ctx), code)
|
||||
@@ -640,6 +646,9 @@ func (us *UserService) UserVerifyEmail(ctx context.Context, req *schema.UserVeri
|
||||
if err != nil {
|
||||
return nil, errors.BadRequest(reason.EmailVerifyURLExpired)
|
||||
}
|
||||
if !data.IsSourceType(schema.AccountActivationSourceType, schema.BindingSourceType) {
|
||||
return nil, errors.BadRequest(reason.EmailVerifyURLExpired)
|
||||
}
|
||||
|
||||
userInfo, has, err := us.userRepo.GetByEmail(ctx, data.Email)
|
||||
if err != nil {
|
||||
@@ -736,8 +745,9 @@ func (us *UserService) UserChangeEmailSendCode(ctx context.Context, req *schema.
|
||||
}
|
||||
|
||||
data := &schema.EmailCodeContent{
|
||||
Email: req.Email,
|
||||
UserID: req.UserID,
|
||||
SourceType: schema.ConfirmNewEmailSourceType,
|
||||
Email: req.Email,
|
||||
UserID: req.UserID,
|
||||
}
|
||||
code := token.GenerateToken()
|
||||
var title, body string
|
||||
@@ -763,6 +773,9 @@ func (us *UserService) UserChangeEmailVerify(ctx context.Context, content string
|
||||
if err != nil {
|
||||
return nil, errors.BadRequest(reason.EmailVerifyURLExpired)
|
||||
}
|
||||
if !data.IsSourceType(schema.ConfirmNewEmailSourceType) {
|
||||
return nil, errors.BadRequest(reason.EmailVerifyURLExpired)
|
||||
}
|
||||
|
||||
_, exist, err := us.userRepo.GetByEmail(ctx, data.Email)
|
||||
if err != nil {
|
||||
@@ -896,7 +909,7 @@ func (us *UserService) UserUnsubscribeNotification(
|
||||
ctx context.Context, req *schema.UserUnsubscribeNotificationReq) (err error) {
|
||||
data := &schema.EmailCodeContent{}
|
||||
err = data.FromJSONString(req.Content)
|
||||
if err != nil || len(data.UserID) == 0 {
|
||||
if err != nil || len(data.UserID) == 0 || !data.IsSourceType(schema.UnsubscribeSourceType) {
|
||||
return errors.BadRequest(reason.EmailVerifyURLExpired)
|
||||
}
|
||||
|
||||
|
||||
@@ -20,14 +20,57 @@
|
||||
package content
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/apache/answer/internal/entity"
|
||||
"github.com/apache/answer/internal/schema"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestEmailCodePurposeIsEnforcedBeforeUserMutation(t *testing.T) {
|
||||
service := &UserService{}
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("password reset rejects a code issued for another purpose", func(t *testing.T) {
|
||||
content := (&schema.EmailCodeContent{
|
||||
SourceType: schema.UnsubscribeSourceType,
|
||||
Email: "user@example.test",
|
||||
}).ToJSONString()
|
||||
|
||||
err := service.UpdatePasswordWhenForgot(ctx, &schema.UserRePassWordRequest{Content: content})
|
||||
if err == nil {
|
||||
t.Fatal("password reset accepted an unsubscribe code")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("email activation rejects a password reset code", func(t *testing.T) {
|
||||
content := (&schema.EmailCodeContent{
|
||||
SourceType: schema.PasswordResetSourceType,
|
||||
Email: "user@example.test",
|
||||
}).ToJSONString()
|
||||
|
||||
_, err := service.UserVerifyEmail(ctx, &schema.UserVerifyEmailReq{Content: content})
|
||||
if err == nil {
|
||||
t.Fatal("email activation accepted a password reset code")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("change email rejects a password reset code", func(t *testing.T) {
|
||||
content := (&schema.EmailCodeContent{
|
||||
SourceType: schema.PasswordResetSourceType,
|
||||
Email: "user@example.test",
|
||||
}).ToJSONString()
|
||||
|
||||
_, err := service.UserChangeEmailVerify(ctx, content)
|
||||
if err == nil {
|
||||
t.Fatal("change email accepted a password reset code")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestApplyRegistrationVerification(t *testing.T) {
|
||||
t.Run("required sends activation email and leaves email pending", func(t *testing.T) {
|
||||
userInfo := &entity.User{}
|
||||
|
||||
@@ -32,6 +32,7 @@ import (
|
||||
"github.com/apache/answer/internal/service/permission"
|
||||
"github.com/apache/answer/internal/service/rank"
|
||||
usercommon "github.com/apache/answer/internal/service/user_common"
|
||||
"github.com/apache/answer/pkg/converter"
|
||||
"github.com/apache/answer/plugin"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/segmentfault/pacman/errors"
|
||||
@@ -70,7 +71,7 @@ func (ip *ImporterService) NewImporterFunc() plugin.ImporterFunc {
|
||||
}
|
||||
|
||||
func (ip *ImporterService) ImportQuestion(ctx context.Context, questionInfo plugin.QuestionImporterInfo) (err error) {
|
||||
req := &schema.QuestionAdd{}
|
||||
req := newImportedQuestionRequest(questionInfo)
|
||||
errFields := make([]*validator.FormErrorField, 0)
|
||||
// To limit rate, remove the following code from comment: Part 1/2
|
||||
// reject, rejectKey := ipc.rateLimitMiddleware.DuplicateRequestRejection(ctx, req)
|
||||
@@ -94,16 +95,6 @@ func (ip *ImporterService) ImportQuestion(ctx context.Context, questionInfo plug
|
||||
// }
|
||||
// }()
|
||||
req.UserID = userInfo.ID
|
||||
req.Title = questionInfo.Title
|
||||
req.Content = questionInfo.Content
|
||||
req.HTML = "<p>" + questionInfo.Content + "</p>"
|
||||
req.Tags = make([]*schema.TagItem, len(questionInfo.Tags))
|
||||
for i, tag := range questionInfo.Tags {
|
||||
req.Tags[i] = &schema.TagItem{
|
||||
SlugName: tag,
|
||||
DisplayName: tag,
|
||||
}
|
||||
}
|
||||
canList, requireRanks, err := ip.rankService.CheckOperationPermissionsForRanks(ctx, req.UserID, []string{
|
||||
permission.QuestionAdd,
|
||||
permission.QuestionEdit,
|
||||
@@ -169,3 +160,19 @@ func (ip *ImporterService) ImportQuestion(ctx context.Context, questionInfo plug
|
||||
log.Info("Add Question Successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
func newImportedQuestionRequest(questionInfo plugin.QuestionImporterInfo) *schema.QuestionAdd {
|
||||
req := &schema.QuestionAdd{
|
||||
Title: questionInfo.Title,
|
||||
Content: questionInfo.Content,
|
||||
HTML: converter.Markdown2HTML(questionInfo.Content),
|
||||
Tags: make([]*schema.TagItem, len(questionInfo.Tags)),
|
||||
}
|
||||
for i, tag := range questionInfo.Tags {
|
||||
req.Tags[i] = &schema.TagItem{
|
||||
SlugName: tag,
|
||||
DisplayName: tag,
|
||||
}
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* 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 importer
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/apache/answer/plugin"
|
||||
)
|
||||
|
||||
func TestNewImportedQuestionRequestSanitizesContent(t *testing.T) {
|
||||
request := newImportedQuestionRequest(plugin.QuestionImporterInfo{
|
||||
Title: "Imported question",
|
||||
Content: `<img src=x onerror=alert(1)><script>alert(1)</script>`,
|
||||
Tags: []string{"security"},
|
||||
})
|
||||
|
||||
for _, unsafeContent := range []string{"onerror", "<script"} {
|
||||
if strings.Contains(strings.ToLower(request.HTML), unsafeContent) {
|
||||
t.Fatalf("imported question HTML contains unsafe content %q: %s", unsafeContent, request.HTML)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,7 @@ func GetTagPermission(ctx context.Context, status int, canEdit, canDelete, canMe
|
||||
})
|
||||
}
|
||||
|
||||
if canRecover && status == entity.QuestionStatusDeleted {
|
||||
if canRecover && status == entity.TagStatusDeleted {
|
||||
actions = append(actions, &schema.PermissionMemberAction{
|
||||
Action: "undelete",
|
||||
Name: translator.Tr(lang, undeleteActionName),
|
||||
|
||||
@@ -88,7 +88,7 @@ type QuestionRepo interface {
|
||||
RemoveQuestionLink(ctx context.Context, link ...*entity.QuestionLink) (err error)
|
||||
RecoverQuestionLink(ctx context.Context, link ...*entity.QuestionLink) (err error)
|
||||
UpdateQuestionLinkStatus(ctx context.Context, status int, links ...*entity.QuestionLink) (err error)
|
||||
GetQuestionLink(ctx context.Context, page, pageSize int, questionID string, orderCond string, inDays int) (questions []*entity.Question, total int64, err error)
|
||||
GetQuestionLink(ctx context.Context, page, pageSize int, questionID, loginUserID string, isAdminModerator bool, orderCond string, inDays int) (questions []*entity.Question, total int64, err error)
|
||||
}
|
||||
|
||||
// QuestionCommon user service
|
||||
|
||||
@@ -319,13 +319,13 @@ func (us *uploaderService) uploadImageFile(ctx *gin.Context, file *multipart.Fil
|
||||
if err := ctx.SaveUploadedFile(file, filePath); err != nil {
|
||||
return "", errors.InternalServer(reason.UnknownError).WithError(err).WithStack()
|
||||
}
|
||||
|
||||
src, err := file.Open()
|
||||
if err != nil {
|
||||
return "", errors.InternalServer(reason.UnknownError).WithError(err).WithStack()
|
||||
}
|
||||
saved := false
|
||||
defer func() {
|
||||
_ = src.Close()
|
||||
if !saved {
|
||||
if removeErr := os.Remove(filePath); removeErr != nil && !os.IsNotExist(removeErr) {
|
||||
log.Errorf("remove failed uploaded file failed: %v", removeErr)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
if !checker.DecodeAndCheckImageFile(filePath, siteAdvanced.GetMaxImageMegapixel()) {
|
||||
@@ -337,6 +337,7 @@ func (us *uploaderService) uploadImageFile(ctx *gin.Context, file *multipart.Fil
|
||||
}
|
||||
|
||||
url = fmt.Sprintf("%s/uploads/%s", siteGeneral.SiteUrl, fileSubPath)
|
||||
saved = true
|
||||
return url, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -597,8 +597,9 @@ func (us *UserAdminService) GetUserActivation(ctx context.Context, req *schema.G
|
||||
}
|
||||
|
||||
data := &schema.EmailCodeContent{
|
||||
Email: userInfo.EMail,
|
||||
UserID: userInfo.ID,
|
||||
SourceType: schema.AccountActivationSourceType,
|
||||
Email: userInfo.EMail,
|
||||
UserID: userInfo.ID,
|
||||
}
|
||||
code := token.GenerateToken()
|
||||
us.emailService.SaveCode(ctx, userInfo.ID, code, data.ToJSONString())
|
||||
@@ -624,8 +625,9 @@ func (us *UserAdminService) SendUserActivation(ctx context.Context, req *schema.
|
||||
}
|
||||
|
||||
data := &schema.EmailCodeContent{
|
||||
Email: userInfo.EMail,
|
||||
UserID: userInfo.ID,
|
||||
SourceType: schema.AccountActivationSourceType,
|
||||
Email: userInfo.EMail,
|
||||
UserID: userInfo.ID,
|
||||
}
|
||||
code := token.GenerateToken()
|
||||
verifyEmailURL := fmt.Sprintf("%s/users/account-activation?code=%s", general.SiteUrl, code)
|
||||
|
||||
@@ -61,6 +61,8 @@ func DecodeAndCheckImageFile(localFilePath string, maxImageMegapixel int) bool {
|
||||
if !decodeAndCheckImageFile(localFilePath, maxImageMegapixel, ext, webpImageCheck) {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* 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 checker
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDecodeAndCheckImageFileRejectsUnsupportedExtension(t *testing.T) {
|
||||
filePath := filepath.Join(t.TempDir(), "not-an-image.svg")
|
||||
if err := os.WriteFile(filePath, []byte("<svg></svg>"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if DecodeAndCheckImageFile(filePath, 1_000_000) {
|
||||
t.Fatal("unsupported image extensions must be rejected")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* 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 converter
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestRenderLinkIsUrl(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"absolute http URL", "http://example.com/path?q=1#f", true},
|
||||
{"absolute https URL", "https://example.com", true},
|
||||
{"ftp URL", "ftp://example.com/file", true},
|
||||
{"uppercase scheme and host", "HTTP://EXAMPLE.COM", false},
|
||||
{"bare domain", "example.com", true},
|
||||
{"bare domain with path", "example.com/questions/123", true},
|
||||
{"www subdomain", "www.example.com", true},
|
||||
{"bare IP", "10.0.0.1", true},
|
||||
{"IP with port and path", "10.0.0.1:8080/a", true},
|
||||
{"host with port", "localhost:8080", true},
|
||||
{"domain with port and path", "example.com:8080/x", true},
|
||||
{"IPv6 with port", "[::1]:8080", true},
|
||||
{"userinfo", "user:pass@example.com", true},
|
||||
{"mailto", "mailto:a@b.com", true},
|
||||
{"email-like destination", "a@b.co", true},
|
||||
{"userinfo without scheme", "user@h.co", true},
|
||||
{"trailing dot FQDN", "example.com.", true},
|
||||
{"empty", "", false},
|
||||
{"single word", "foo", false},
|
||||
{"path segment no dot", "questions/123", false},
|
||||
{"absolute path", "/questions/123", true},
|
||||
{"scheme-less authority path", "//cdn.example.com/x", true},
|
||||
{"anchor", "#section", false},
|
||||
{"leading dot", ".hidden", false},
|
||||
{"javascript scheme", "javascript:alert(1)", false},
|
||||
{"tel scheme", "tel:+1234", false},
|
||||
{"host with leading dot", "http://.example.com", false},
|
||||
{"trailing colon", "example.com:", false},
|
||||
{"single label with scheme", "http://localhost", true},
|
||||
{"single label no scheme no port", "localhost", false},
|
||||
{"not a url", "not a url", false},
|
||||
{"whitespace in path", "h.co/p q", false},
|
||||
{"label with leading hyphen", "-ex.com", false},
|
||||
{"label with trailing hyphen", "ex-.com", false},
|
||||
{"invalid IPv4 quad", "999.1.1.1", false},
|
||||
{"IPv4 with leading zeros", "01.2.3.4", false},
|
||||
{"three letter domain", "a.b", false},
|
||||
}
|
||||
r := &DangerousHTMLRenderer{}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.want, r.renderLinkIsUrl(tc.in))
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user