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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:22:32 +08:00
commit 39dbe3a57d
1131 changed files with 272709 additions and 0 deletions
@@ -0,0 +1,59 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package middleware
import (
"strings"
"github.com/apache/answer/internal/base/constant"
"github.com/apache/answer/internal/base/translator"
"github.com/gin-gonic/gin"
"github.com/segmentfault/pacman/i18n"
"golang.org/x/text/language"
)
const maxAcceptLanguageLength = 256
// ExtractAndSetAcceptLanguage extract accept language from header and set to context
func ExtractAndSetAcceptLanguage(ctx *gin.Context) {
// The language of our front-end configuration, like en_US
acceptLanguage := ctx.GetHeader(constant.AcceptLanguageFlag)
if len(acceptLanguage) > maxAcceptLanguageLength {
ctx.Set(constant.AcceptLanguageFlag, i18n.LanguageEnglish)
return
}
tag, _, err := language.ParseAcceptLanguage(acceptLanguage)
if err != nil || len(tag) == 0 {
ctx.Set(constant.AcceptLanguageFlag, i18n.LanguageEnglish)
return
}
acceptLang := strings.ReplaceAll(tag[0].String(), "-", "_")
for _, option := range translator.LanguageOptions {
if option.Value == acceptLang {
ctx.Set(constant.AcceptLanguageFlag, i18n.Language(acceptLang))
return
}
}
// default language
ctx.Set(constant.AcceptLanguageFlag, i18n.LanguageEnglish)
}
+51
View File
@@ -0,0 +1,51 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package middleware
import (
"github.com/apache/answer/internal/base/handler"
"github.com/apache/answer/internal/base/reason"
"github.com/gin-gonic/gin"
"github.com/segmentfault/pacman/errors"
)
// AuthAPIKey middleware to authenticate API key
func (am *AuthUserMiddleware) AuthAPIKey() gin.HandlerFunc {
return func(ctx *gin.Context) {
token := ExtractToken(ctx)
if len(token) == 0 {
handler.HandleResponse(ctx, errors.Unauthorized(reason.UnauthorizedError), nil)
ctx.Abort()
return
}
pass, err := am.authService.AuthAPIKey(ctx, ctx.Request.Method == "GET", token)
if err != nil {
handler.HandleResponse(ctx, errors.Unauthorized(reason.UnauthorizedError), nil)
ctx.Abort()
return
}
if !pass {
handler.HandleResponse(ctx, errors.Unauthorized(reason.UnauthorizedError), nil)
ctx.Abort()
return
}
ctx.Next()
}
}
+327
View File
@@ -0,0 +1,327 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package middleware
import (
"net/http"
"strings"
"github.com/apache/answer/internal/schema"
"github.com/apache/answer/internal/service/role"
"github.com/apache/answer/internal/service/siteinfo_common"
"github.com/apache/answer/ui"
"github.com/gin-gonic/gin"
"github.com/apache/answer/internal/base/handler"
"github.com/apache/answer/internal/base/reason"
"github.com/apache/answer/internal/entity"
"github.com/apache/answer/internal/service/auth"
"github.com/apache/answer/pkg/converter"
"github.com/segmentfault/pacman/errors"
"github.com/segmentfault/pacman/log"
)
var ctxUUIDKey = "ctxUuidKey"
// AuthUserMiddleware auth user middleware
type AuthUserMiddleware struct {
authService *auth.AuthService
siteInfoCommonService siteinfo_common.SiteInfoCommonService
}
// NewAuthUserMiddleware new auth user middleware
func NewAuthUserMiddleware(
authService *auth.AuthService,
siteInfoCommonService siteinfo_common.SiteInfoCommonService) *AuthUserMiddleware {
return &AuthUserMiddleware{
authService: authService,
siteInfoCommonService: siteInfoCommonService,
}
}
// Auth get token and auth user, set user info to context if user is already login
func (am *AuthUserMiddleware) Auth() gin.HandlerFunc {
return func(ctx *gin.Context) {
token := ExtractToken(ctx)
if len(token) == 0 {
ctx.Next()
return
}
userInfo, err := am.authService.GetUserCacheInfo(ctx, token)
if err != nil {
ctx.Next()
return
}
if userInfo != nil {
ctx.Set(ctxUUIDKey, userInfo)
}
ctx.Next()
}
}
// EjectUserBySiteInfo if admin config the site can access by nologin user, eject user.
func (am *AuthUserMiddleware) EjectUserBySiteInfo() gin.HandlerFunc {
return func(ctx *gin.Context) {
mustLogin := false
siteInfo, _ := am.siteInfoCommonService.GetSiteSecurity(ctx)
if siteInfo != nil {
mustLogin = siteInfo.LoginRequired
}
if !mustLogin {
ctx.Next()
return
}
// If site in private mode, user must login.
userInfo := GetUserInfoFromContext(ctx)
if userInfo == nil {
handler.HandleResponse(ctx, errors.Unauthorized(reason.UnauthorizedError), nil)
ctx.Abort()
return
}
// If user is not active, eject user.
if userInfo.EmailStatus != entity.EmailStatusAvailable {
handler.HandleResponse(ctx, errors.Forbidden(reason.EmailNeedToBeVerified),
&schema.ForbiddenResp{Type: schema.ForbiddenReasonTypeInactive})
ctx.Abort()
return
}
ctx.Next()
}
}
// MustAuthWithoutAccountAvailable auth user info, any login user can access though user is not active.
func (am *AuthUserMiddleware) MustAuthWithoutAccountAvailable() gin.HandlerFunc {
return func(ctx *gin.Context) {
token := ExtractToken(ctx)
if len(token) == 0 {
handler.HandleResponse(ctx, errors.Unauthorized(reason.UnauthorizedError), nil)
ctx.Abort()
return
}
// Check API key scope
if am.AuthAPIKeyScope(ctx, token) {
return
}
userInfo, err := am.authService.GetUserCacheInfo(ctx, token)
if err != nil || userInfo == nil {
handler.HandleResponse(ctx, errors.Unauthorized(reason.UnauthorizedError), nil)
ctx.Abort()
return
}
if userInfo.UserStatus == entity.UserStatusDeleted {
handler.HandleResponse(ctx, errors.Unauthorized(reason.UnauthorizedError), nil)
ctx.Abort()
return
}
ctx.Set(ctxUUIDKey, userInfo)
ctx.Next()
}
}
// MustAuthAndAccountAvailable auth user info and check user status, only allow active user access.
func (am *AuthUserMiddleware) MustAuthAndAccountAvailable() gin.HandlerFunc {
return func(ctx *gin.Context) {
token := ExtractToken(ctx)
if len(token) == 0 {
handler.HandleResponse(ctx, errors.Unauthorized(reason.UnauthorizedError), nil)
ctx.Abort()
return
}
// Check API key scope
if am.AuthAPIKeyScope(ctx, token) {
return
}
userInfo, err := am.authService.GetUserCacheInfo(ctx, token)
if err != nil || userInfo == nil {
handler.HandleResponse(ctx, errors.Unauthorized(reason.UnauthorizedError), nil)
ctx.Abort()
return
}
if userInfo.EmailStatus != entity.EmailStatusAvailable {
handler.HandleResponse(ctx, errors.Forbidden(reason.EmailNeedToBeVerified),
&schema.ForbiddenResp{Type: schema.ForbiddenReasonTypeInactive})
ctx.Abort()
return
}
if userInfo.UserStatus == entity.UserStatusSuspended {
handler.HandleResponse(ctx, errors.Forbidden(reason.UserSuspended),
&schema.ForbiddenResp{Type: schema.ForbiddenReasonTypeUserSuspended})
ctx.Abort()
return
}
if userInfo.UserStatus == entity.UserStatusDeleted {
handler.HandleResponse(ctx, errors.Unauthorized(reason.UnauthorizedError), nil)
ctx.Abort()
return
}
ctx.Set(ctxUUIDKey, userInfo)
ctx.Next()
}
}
func (am *AuthUserMiddleware) AdminAuth() gin.HandlerFunc {
return func(ctx *gin.Context) {
token := ExtractToken(ctx)
if len(token) == 0 {
handler.HandleResponse(ctx, errors.Unauthorized(reason.UnauthorizedError), nil)
ctx.Abort()
return
}
userInfo, err := am.authService.GetAdminUserCacheInfo(ctx, token)
if err != nil || userInfo == nil {
handler.HandleResponse(ctx, errors.Forbidden(reason.UnauthorizedError), nil)
ctx.Abort()
return
}
if userInfo != nil {
if userInfo.EmailStatus == entity.EmailStatusToBeVerified {
_ = am.authService.RemoveAdminUserCacheInfo(ctx, token)
handler.HandleResponse(ctx, errors.Forbidden(reason.EmailNeedToBeVerified),
&schema.ForbiddenResp{Type: schema.ForbiddenReasonTypeInactive})
ctx.Abort()
return
}
if userInfo.UserStatus == entity.UserStatusSuspended {
_ = am.authService.RemoveAdminUserCacheInfo(ctx, token)
handler.HandleResponse(ctx, errors.Forbidden(reason.UserSuspended),
&schema.ForbiddenResp{Type: schema.ForbiddenReasonTypeUserSuspended})
ctx.Abort()
return
}
if userInfo.UserStatus == entity.UserStatusDeleted {
_ = am.authService.RemoveAdminUserCacheInfo(ctx, token)
handler.HandleResponse(ctx, errors.Unauthorized(reason.UnauthorizedError), nil)
ctx.Abort()
return
}
ctx.Set(ctxUUIDKey, userInfo)
}
ctx.Next()
}
}
func (am *AuthUserMiddleware) CheckPrivateMode() gin.HandlerFunc {
return func(ctx *gin.Context) {
resp, err := am.siteInfoCommonService.GetSiteSecurity(ctx)
if err != nil {
ShowIndexPage(ctx)
ctx.Abort()
return
}
if resp.LoginRequired {
ShowIndexPage(ctx)
ctx.Abort()
return
}
ctx.Next()
}
}
func (am *AuthUserMiddleware) AuthAPIKeyScope(ctx *gin.Context, accessToken string) (apiHaveNoScope bool) {
if !strings.HasPrefix(accessToken, "sk_") {
return false
}
var err error
pass, err := am.authService.AuthAPIKey(ctx, ctx.Request.Method == "GET", accessToken)
if err != nil {
handler.HandleResponse(ctx, errors.Forbidden(reason.ForbiddenError), nil)
ctx.Abort()
return true
}
if !pass {
handler.HandleResponse(ctx, errors.Forbidden(reason.ForbiddenError), nil)
ctx.Abort()
return true
}
return false
}
func ShowIndexPage(ctx *gin.Context) {
ctx.Header("content-type", "text/html;charset=utf-8")
ctx.Header("X-Frame-Options", "DENY")
file, err := ui.Build.ReadFile("build/index.html")
if err != nil {
log.Error(err)
ctx.Status(http.StatusNotFound)
return
}
ctx.String(http.StatusOK, string(file))
}
// GetLoginUserIDFromContext get user id from context
func GetLoginUserIDFromContext(ctx *gin.Context) (userID string) {
userInfo := GetUserInfoFromContext(ctx)
if userInfo == nil {
return ""
}
return userInfo.UserID
}
// GetIsAdminFromContext get user is admin from context
func GetIsAdminFromContext(ctx *gin.Context) (isAdmin bool) {
userInfo := GetUserInfoFromContext(ctx)
if userInfo == nil {
return false
}
return userInfo.RoleID == role.RoleAdminID
}
// GetUserInfoFromContext get user info from context
func GetUserInfoFromContext(ctx *gin.Context) (u *entity.UserCacheInfo) {
userInfo, exist := ctx.Get(ctxUUIDKey)
if !exist {
return nil
}
u, ok := userInfo.(*entity.UserCacheInfo)
if !ok {
return nil
}
return u
}
func GetUserIsAdminModerator(ctx *gin.Context) (isAdminModerator bool) {
userInfo, exist := ctx.Get(ctxUUIDKey)
if !exist {
return false
}
u, ok := userInfo.(*entity.UserCacheInfo)
if !ok {
return false
}
if u.RoleID == role.RoleAdminID || u.RoleID == role.RoleModeratorID {
return true
}
return false
}
func GetLoginUserIDInt64FromContext(ctx *gin.Context) (userID int64) {
userIDStr := GetLoginUserIDFromContext(ctx)
return converter.StringToInt64(userIDStr)
}
// ExtractToken extract token from context
func ExtractToken(ctx *gin.Context) (token string) {
token = ctx.GetHeader("Authorization")
if len(token) == 0 {
token = ctx.Query("Authorization")
}
return strings.TrimPrefix(token, "Bearer ")
}
+94
View File
@@ -0,0 +1,94 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package middleware
import (
"fmt"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"strings"
"github.com/apache/answer/internal/service/service_config"
"github.com/apache/answer/internal/service/uploader"
"github.com/apache/answer/pkg/converter"
"github.com/gin-gonic/gin"
"github.com/segmentfault/pacman/log"
)
type AvatarMiddleware struct {
serviceConfig *service_config.ServiceConfig
uploaderService uploader.UploaderService
}
// NewAvatarMiddleware new auth user middleware
func NewAvatarMiddleware(serviceConfig *service_config.ServiceConfig,
uploaderService uploader.UploaderService,
) *AvatarMiddleware {
return &AvatarMiddleware{
serviceConfig: serviceConfig,
uploaderService: uploaderService,
}
}
func (am *AvatarMiddleware) AvatarThumb() gin.HandlerFunc {
return func(ctx *gin.Context) {
uri := ctx.Request.RequestURI
if strings.Contains(uri, "/uploads/avatar/") {
size := converter.StringToInt(ctx.Query("s"))
uriWithoutQuery, _ := url.Parse(uri)
filename := filepath.Base(uriWithoutQuery.Path)
filePath := fmt.Sprintf("%s/avatar/%s", am.serviceConfig.UploadPath, filename)
var err error
if size != 0 {
filePath, err = am.uploaderService.AvatarThumbFile(ctx, filename, size)
if err != nil {
log.Error(err)
ctx.AbortWithStatus(http.StatusNotFound)
return
}
}
avatarFile, err := os.ReadFile(filePath)
if err != nil {
log.Error(err)
ctx.Abort()
return
}
ctx.Header("content-type", fmt.Sprintf("image/%s", strings.TrimLeft(path.Ext(filePath), ".")))
_, err = ctx.Writer.Write(avatarFile)
if err != nil {
log.Error(err)
}
ctx.Abort()
return
} else {
urlInfo, err := url.Parse(uri)
if err != nil {
ctx.Next()
return
}
ext := strings.TrimPrefix(filepath.Ext(urlInfo.Path), ".")
ctx.Header("content-type", fmt.Sprintf("image/%s", ext))
}
ctx.Next()
}
}
+34
View File
@@ -0,0 +1,34 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package middleware
import (
"strings"
"github.com/gin-gonic/gin"
)
func HeadersByRequestURI() gin.HandlerFunc {
return func(c *gin.Context) {
if strings.HasPrefix(c.Request.RequestURI, "/static/") {
c.Header("cache-control", "public, max-age=31536000")
}
}
}
+47
View File
@@ -0,0 +1,47 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package middleware
import (
"github.com/apache/answer/internal/base/handler"
"github.com/apache/answer/internal/base/reason"
"github.com/gin-gonic/gin"
"github.com/segmentfault/pacman/errors"
"github.com/segmentfault/pacman/log"
)
// AuthMcpEnable check mcp is enabled
func (am *AuthUserMiddleware) AuthMcpEnable() gin.HandlerFunc {
return func(ctx *gin.Context) {
mcpConfig, err := am.siteInfoCommonService.GetSiteMCP(ctx)
if err != nil {
handler.HandleResponse(ctx, errors.InternalServer(reason.UnknownError), nil)
ctx.Abort()
return
}
if mcpConfig != nil && mcpConfig.Enabled {
ctx.Next()
return
}
handler.HandleResponse(ctx, errors.Forbidden(reason.ForbiddenError), nil)
ctx.Abort()
log.Error("abort mcp auth middleware, get mcp config error: ", err)
}
}
+32
View File
@@ -0,0 +1,32 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package middleware
import (
"github.com/google/wire"
)
// ProviderSetMiddleware is providers.
var ProviderSetMiddleware = wire.NewSet(
NewAuthUserMiddleware,
NewAvatarMiddleware,
NewShortIDMiddleware,
NewRateLimitMiddleware,
)
+73
View File
@@ -0,0 +1,73 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package middleware
import (
"encoding/json"
"fmt"
"github.com/apache/answer/internal/base/handler"
"github.com/apache/answer/internal/base/reason"
"github.com/apache/answer/internal/repo/limit"
"github.com/apache/answer/pkg/encryption"
"github.com/gin-gonic/gin"
"github.com/segmentfault/pacman/errors"
"github.com/segmentfault/pacman/log"
)
type RateLimitMiddleware struct {
limitRepo *limit.LimitRepo
}
// NewRateLimitMiddleware new rate limit middleware
func NewRateLimitMiddleware(limitRepo *limit.LimitRepo) *RateLimitMiddleware {
return &RateLimitMiddleware{
limitRepo: limitRepo,
}
}
// DuplicateRequestRejection detects and rejects duplicate requests
// It only works for the requests that post content. Such as add question, add answer, comment etc.
func (rm *RateLimitMiddleware) DuplicateRequestRejection(ctx *gin.Context, req any) (reject bool, key string) {
userID := GetLoginUserIDFromContext(ctx)
fullPath := ctx.FullPath()
reqJson, _ := json.Marshal(req)
key = encryption.MD5(fmt.Sprintf("%s:%s:%s", userID, fullPath, string(reqJson)))
var err error
reject, err = rm.limitRepo.CheckAndRecord(ctx, key)
if err != nil {
log.Errorf("check and record rate limit error: %s", err.Error())
return false, key
}
if !reject {
return false, key
}
log.Debugf("duplicate request: [%s] %s", fullPath, string(reqJson))
handler.HandleResponse(ctx, errors.BadRequest(reason.DuplicateRequestError), nil)
return true, key
}
// DuplicateRequestClear clear duplicate request record
func (rm *RateLimitMiddleware) DuplicateRequestClear(ctx *gin.Context, key string) {
err := rm.limitRepo.ClearRecord(ctx, key)
if err != nil {
log.Errorf("clear rate limit error: %s", err.Error())
}
}
+62
View File
@@ -0,0 +1,62 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package middleware
import (
"net/http"
"runtime/debug"
"strings"
"github.com/apache/answer/internal/base/handler"
"github.com/apache/answer/internal/base/reason"
"github.com/gin-gonic/gin"
"github.com/segmentfault/pacman/log"
)
func Recovery(apiPrefixes ...string) gin.HandlerFunc {
return func(ctx *gin.Context) {
defer func() {
if err := recover(); err != nil {
log.Errorf("panic recovered: %v\n%s", err, debug.Stack())
// Headers/body already flushed (SSE or any streamed response).
// We can no longer rewrite the response cleanly; just stop the chain.
if ctx.Writer.Written() {
ctx.Abort()
return
}
path := ctx.Request.URL.Path
for _, p := range apiPrefixes {
if strings.HasPrefix(path, p) {
ctx.AbortWithStatusJSON(http.StatusInternalServerError,
handler.NewRespBody(http.StatusInternalServerError, reason.UnknownError).
TrMsg(handler.GetLangByCtx(ctx)),
)
return
}
}
ctx.AbortWithStatus(http.StatusInternalServerError)
}
}()
ctx.Next()
}
}
+120
View File
@@ -0,0 +1,120 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package middleware
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
// Panic on an API path returns the project's unified JSON 500.
func TestRecovery_APIPathPanic(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(Recovery("/api"))
r.GET("/api/panic", func(ctx *gin.Context) {
panic("test panic")
})
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/api/panic", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected 500, got %d", w.Code)
}
var body map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("response is not valid JSON: %v", err)
}
if body["reason"] != "base.unknown" {
t.Errorf("unexpected reason: %v", body["reason"])
}
}
// Panic on a non-API path returns a bare 500 with no body, so the browser can
// render its own error page instead of showing raw JSON.
func TestRecovery_NonAPIPathPanic(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(Recovery("/api"))
r.GET("/page", func(ctx *gin.Context) {
panic("test panic")
})
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/page", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected 500, got %d", w.Code)
}
if w.Body.Len() != 0 {
t.Errorf("expected empty body for non-API path, got: %q", w.Body.String())
}
}
// Panic after the response has already started writing (SSE / streamed
// responses). The middleware must not touch the response — status and body
// already on the wire stay untouched, no JSON gets appended.
func TestRecovery_PanicAfterResponseStarted(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(Recovery("/api"))
r.GET("/api/stream", func(ctx *gin.Context) {
ctx.Writer.WriteHeader(http.StatusOK)
_, _ = ctx.Writer.Write([]byte("partial data"))
panic("test panic after write")
})
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/api/stream", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status to remain 200 (already flushed), got %d", w.Code)
}
if w.Body.String() != "partial data" {
t.Errorf("expected body to remain 'partial data' (no error JSON appended), got: %q", w.Body.String())
}
}
// Normal requests pass through unaffected.
func TestRecovery_NoPanic(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(Recovery("/api"))
r.GET("/api/ok", func(ctx *gin.Context) {
ctx.String(http.StatusOK, "ok")
})
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/api/ok", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
}
+48
View File
@@ -0,0 +1,48 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package middleware
import (
"github.com/apache/answer/internal/base/constant"
"github.com/apache/answer/internal/service/siteinfo_common"
"github.com/gin-gonic/gin"
"github.com/segmentfault/pacman/log"
)
type ShortIDMiddleware struct {
siteInfoService siteinfo_common.SiteInfoCommonService
}
func NewShortIDMiddleware(siteInfoService siteinfo_common.SiteInfoCommonService) *ShortIDMiddleware {
return &ShortIDMiddleware{
siteInfoService: siteInfoService,
}
}
func (sm *ShortIDMiddleware) SetShortIDFlag() gin.HandlerFunc {
return func(ctx *gin.Context) {
siteSeo, err := sm.siteInfoService.GetSiteSeo(ctx)
if err != nil {
log.Error(err)
return
}
ctx.Set(constant.ShortIDFlag, siteSeo.IsShortLink())
}
}
@@ -0,0 +1,42 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package middleware
import (
"github.com/apache/answer/internal/base/handler"
"github.com/apache/answer/internal/base/reason"
"github.com/apache/answer/plugin"
"github.com/gin-gonic/gin"
"github.com/segmentfault/pacman/errors"
)
// BanAPIForUserCenter ban api for user center
func BanAPIForUserCenter(ctx *gin.Context) {
uc, ok := plugin.GetUserCenter()
if !ok {
return
}
if !uc.Description().EnabledOriginalUserSystem {
handler.HandleResponse(ctx, errors.Forbidden(reason.ForbiddenError), nil)
ctx.Abort()
return
}
ctx.Next()
}
@@ -0,0 +1,66 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package middleware
import (
"net/http"
"os"
"strings"
"github.com/apache/answer/internal/base/constant"
"github.com/gin-gonic/gin"
)
// VisitAuth when user visit the site image, check visit token. This only for private mode.
func (am *AuthUserMiddleware) VisitAuth() gin.HandlerFunc {
return func(ctx *gin.Context) {
if len(os.Getenv("SKIP_FILE_ACCESS_VERIFY")) > 0 {
ctx.Next()
return
}
// If visit brand image, no need to check visit token. Because the brand image is public.
if strings.HasPrefix(ctx.Request.URL.Path, "/uploads/branding/") {
ctx.Next()
return
}
siteSecurity, err := am.siteInfoCommonService.GetSiteSecurity(ctx)
if err != nil {
return
}
if !siteSecurity.LoginRequired {
ctx.Next()
return
}
visitToken, err := ctx.Cookie(constant.UserVisitCookiesCacheKey)
if err != nil || len(visitToken) == 0 {
ctx.Abort()
ctx.Redirect(http.StatusFound, "/403")
return
}
if !am.authService.CheckUserVisitToken(ctx, visitToken) {
ctx.Abort()
ctx.Redirect(http.StatusFound, "/403")
return
}
}
}