diff --git a/frontend/components/common/markdown/ContentRender.tsx b/frontend/components/common/markdown/ContentRender.tsx
index 0c6be11..b13beee 100644
--- a/frontend/components/common/markdown/ContentRender.tsx
+++ b/frontend/components/common/markdown/ContentRender.tsx
@@ -274,13 +274,24 @@ const markdownComponents: Components = {
const target = e.target as HTMLImageElement;
const errorDiv = document.createElement('div');
errorDiv.className = 'bg-muted border border-border rounded-lg p-4 text-center text-muted-foreground my-6';
- errorDiv.innerHTML = `
+
+ const iconDiv = document.createElement('div');
+ iconDiv.innerHTML = `
-
图片加载失败
- ${alt || src}
- `;
+ `;
+
+ const msgDiv = document.createElement('div');
+ msgDiv.className = 'text-sm';
+ msgDiv.textContent = '图片加载失败';
+
+ const detailDiv = document.createElement('div');
+ detailDiv.className = 'text-xs text-gray-400 dark:text-gray-500 mt-1';
+ detailDiv.textContent = alt || src || '';
+
+ errorDiv.appendChild(iconDiv);
+ errorDiv.appendChild(msgDiv);
+ errorDiv.appendChild(detailDiv);
target.parentNode?.replaceChild(errorDiv, target);
}}
/>
diff --git a/frontend/lib/services/core/api-client.ts b/frontend/lib/services/core/api-client.ts
index 22c9832..761af5a 100644
--- a/frontend/lib/services/core/api-client.ts
+++ b/frontend/lib/services/core/api-client.ts
@@ -178,6 +178,7 @@ function showRiskBlockedDialog(riskInfo: RiskInfo): void {
apiClient.interceptors.request.use(
(config) => {
config.withCredentials = true;
+ config.headers['X-Requested-With'] = 'XMLHttpRequest';
return config;
},
(error) => Promise.reject(error),
diff --git a/frontend/middleware.ts b/frontend/middleware.ts
index ce81975..046c91e 100644
--- a/frontend/middleware.ts
+++ b/frontend/middleware.ts
@@ -126,6 +126,7 @@ async function handleReceiveRequest(request: NextRequest, pathname: string): Pro
'User-Agent': 'CDK-Frontend-Middleware',
'Referer': request.headers.get('referer') || '',
'Origin': request.headers.get('origin') || '',
+ 'X-Requested-With': 'XMLHttpRequest',
},
body: Object.keys(backendBody).length > 0 ? JSON.stringify(backendBody) : undefined,
credentials: 'include',
diff --git a/internal/apps/oauth/err.go b/internal/apps/oauth/err.go
index e10d18a..0e7e6dc 100644
--- a/internal/apps/oauth/err.go
+++ b/internal/apps/oauth/err.go
@@ -25,7 +25,8 @@
package oauth
const (
- UnAuthorized = "未登录"
- InvalidState = "非法登录请求"
- BannedAccount = "账号已被封禁"
+ UnAuthorized = "未登录"
+ InvalidState = "非法登录请求"
+ BannedAccount = "账号已被封禁"
+ UsernameConflict = "用户名冲突,请稍后重试或联系管理员"
)
diff --git a/internal/apps/oauth/utils.go b/internal/apps/oauth/utils.go
index 91b0a33..8fa38c1 100644
--- a/internal/apps/oauth/utils.go
+++ b/internal/apps/oauth/utils.go
@@ -31,11 +31,8 @@ import (
"io"
"time"
- "fmt"
-
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
- "github.com/google/uuid"
"github.com/linux-do/cdk/internal/config"
"github.com/linux-do/cdk/internal/db"
"github.com/linux-do/cdk/internal/otel_trace"
@@ -113,14 +110,7 @@ func doOAuth(ctx context.Context, code string) (*User, error) {
err = db.DB(ctx).Transaction(func(tx *gorm.DB) error {
var holder User
if conflictErr := tx.Where("username = ? AND id != ?", userInfo.Username, userInfo.Id).First(&holder).Error; conflictErr == nil {
- // 存在冲突 -> 将占用者改名并注销
- newParams := map[string]interface{}{
- "username": fmt.Sprintf("%s已注销: %s", holder.Username, uuid.NewString()),
- "is_active": false,
- }
- if updateErr := tx.Model(&holder).Updates(newParams).Error; updateErr != nil {
- return updateErr
- }
+ return errors.New(UsernameConflict)
}
// 根据 ID 处理当前用户的 更新 或 创建
diff --git a/internal/router/middlewares.go b/internal/router/middlewares.go
index 1bad4ee..3a32238 100644
--- a/internal/router/middlewares.go
+++ b/internal/router/middlewares.go
@@ -25,7 +25,9 @@
package router
import (
+ "net/http"
"strconv"
+ "strings"
"time"
"github.com/gin-gonic/gin"
@@ -36,6 +38,26 @@ import (
"go.opentelemetry.io/otel/trace"
)
+// csrfMiddleware 校验状态变更请求必须携带 X-Requested-With 头,
+// 防止跨站请求伪造(CSRF)。OAuth callback 和支付回调因来源为外部跳转,放行。
+func csrfMiddleware() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ method := c.Request.Method
+ if method == "POST" || method == "PUT" || method == "DELETE" || method == "PATCH" {
+ path := c.Request.URL.Path
+ if strings.HasSuffix(path, "/oauth/callback") || strings.HasSuffix(path, "/payment/notify") {
+ c.Next()
+ return
+ }
+ if c.GetHeader("X-Requested-With") != "XMLHttpRequest" {
+ c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error_msg": "CSRF 验证失败", "data": nil})
+ return
+ }
+ }
+ c.Next()
+ }
+}
+
func loggerMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// 初始化 Trace
diff --git a/internal/router/router.go b/internal/router/router.go
index 51f100e..d63cf0d 100644
--- a/internal/router/router.go
+++ b/internal/router/router.go
@@ -28,6 +28,7 @@ import (
"context"
"fmt"
"log"
+ "net/http"
"strconv"
"github.com/gin-contrib/sessions"
@@ -78,12 +79,12 @@ func Serve() {
Domain: config.Config.App.SessionDomain,
MaxAge: config.Config.App.SessionAge,
HttpOnly: config.Config.App.SessionHttpOnly,
- Secure: config.Config.App.SessionSecure, // 若用 HTTPS 可以设 true
+ Secure: config.Config.App.SessionSecure,
+ SameSite: http.SameSiteLaxMode,
},
)
r.Use(sessions.Sessions(config.Config.App.SessionCookieName, sessionStore))
-
- // 补充中间件
+ r.Use(csrfMiddleware())
r.Use(otelgin.Middleware(config.Config.App.AppName), loggerMiddleware())
apiGroup := r.Group(config.Config.App.APIPrefix)