初始提交:OCI 面板后端(含 GenAI 网关一期)
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"oci-portal/internal/model"
|
||||
"oci-portal/internal/service"
|
||||
)
|
||||
|
||||
// usernameKey 是鉴权通过后写入 gin.Context 的用户名键。
|
||||
const usernameKey = "username"
|
||||
|
||||
// RequireAuth 校验 Authorization: Bearer 令牌,通过后把用户名放进上下文。
|
||||
func RequireAuth(auth *service.AuthService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token, ok := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer ")
|
||||
if !ok || token == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing bearer token"})
|
||||
return
|
||||
}
|
||||
username, err := auth.ParseToken(token)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"})
|
||||
return
|
||||
}
|
||||
c.Set(usernameKey, username)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// systemLogMiddleware 把写请求(POST/PUT/DELETE)异步记入系统日志,只读请求跳过。
|
||||
// 只记方法、路径等元数据,绝不读请求体——请求体可能含私钥 / 口令等敏感数据。
|
||||
func systemLogMiddleware(logs *service.SystemLogService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if !isWriteMethod(c.Request.Method) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
start := time.Now()
|
||||
tee := &teeWriter{ResponseWriter: c.Writer}
|
||||
c.Writer = tee
|
||||
c.Next()
|
||||
logs.Record(model.SystemLog{
|
||||
Username: c.GetString(usernameKey),
|
||||
Method: c.Request.Method,
|
||||
Path: requestPath(c),
|
||||
Status: c.Writer.Status(),
|
||||
DurationMs: time.Since(start).Milliseconds(),
|
||||
ClientIP: requestIP(c),
|
||||
UserAgent: truncateLogField(c.Request.UserAgent(), 256),
|
||||
ErrMsg: errMsgOf(c.Writer.Status(), tee.head),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// teeWriter 旁路捕获响应体前 512 字节,供失败留痕提取 error 文案;不改写响应。
|
||||
type teeWriter struct {
|
||||
gin.ResponseWriter
|
||||
head []byte
|
||||
}
|
||||
|
||||
func (w *teeWriter) Write(b []byte) (int, error) {
|
||||
if room := 512 - len(w.head); room > 0 {
|
||||
if len(b) < room {
|
||||
room = len(b)
|
||||
}
|
||||
w.head = append(w.head, b[:room]...)
|
||||
}
|
||||
return w.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
// errMsgOf 从失败响应(>=400)的 JSON 中提取 error 字段;错误响应均由
|
||||
// respondError 系列生成、不含敏感信息,成功响应一律不记。
|
||||
func errMsgOf(status int, head []byte) string {
|
||||
if status < 400 || len(head) == 0 {
|
||||
return ""
|
||||
}
|
||||
var body struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if json.Unmarshal(head, &body) != nil || body.Error == "" {
|
||||
return ""
|
||||
}
|
||||
return truncateLogField(body.Error, 256)
|
||||
}
|
||||
|
||||
// truncateLogField 按字节截断留痕字段,防超长 UA / 错误串撑爆列宽。
|
||||
func truncateLogField(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max]
|
||||
}
|
||||
|
||||
// isWriteMethod 判断是否需要留痕的写方法。
|
||||
func isWriteMethod(m string) bool {
|
||||
return m == http.MethodPost || m == http.MethodPut || m == http.MethodDelete
|
||||
}
|
||||
|
||||
// requestPath 优先取注册的路由模板(含 :id 占位符,避免把敏感 query 记入日志),
|
||||
// 未匹配到路由时退回原始路径;webhook 前缀的原始路径把 secret 段脱敏。
|
||||
func requestPath(c *gin.Context) string {
|
||||
if p := c.FullPath(); p != "" {
|
||||
return p
|
||||
}
|
||||
return sanitizeLogPath(c.Request.URL.Path)
|
||||
}
|
||||
|
||||
// sanitizeLogPath 把回传 webhook 路径的最后一段(secret)替换为 ***;
|
||||
// 正常留痕走路由模板不经此处,这里是未匹配路由时的防御兜底。
|
||||
func sanitizeLogPath(path string) string {
|
||||
const prefix = "/api/v1/webhooks/"
|
||||
if !strings.HasPrefix(path, prefix) {
|
||||
return path
|
||||
}
|
||||
if i := strings.LastIndex(path, "/"); i >= len(prefix) {
|
||||
return path[:i+1] + "***"
|
||||
}
|
||||
return path
|
||||
}
|
||||
Reference in New Issue
Block a user