401 lines
10 KiB
Go
401 lines
10 KiB
Go
package middleware
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
"yintai-api/app/common/cache"
|
|
"yintai-api/app/common/dao"
|
|
"yintai-api/app/common/model"
|
|
"yintai-api/base/global"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
log "github.com/sirupsen/logrus"
|
|
)
|
|
|
|
const (
|
|
operateBodyLimit = 8000
|
|
operateQueryLimit = 1024
|
|
operateHeaderLimit = 8000
|
|
operateMessageLimit = 512
|
|
operateAccountLimit = 64
|
|
operateForwardLimit = 512
|
|
operatePathLimit = 255
|
|
operateActionLimit = 64
|
|
)
|
|
|
|
// 写操作、登录、退出、获取验证码,以及所有失败请求都记。成功的普通查询不记。
|
|
var operateActions = map[string]string{
|
|
"POST /yt/user/login": "登录",
|
|
"GET /yt/user/logout": "退出",
|
|
"GET /yt/user/captcha": "获取验证码",
|
|
"POST /yt/user": "创建用户",
|
|
"PUT /yt/user": "修改用户",
|
|
"DELETE /yt/user": "删除用户",
|
|
"GET /yt/user": "查询用户",
|
|
"POST /yt/resource": "创建资源",
|
|
"PUT /yt/resource": "修改资源",
|
|
"DELETE /yt/resource": "删除资源",
|
|
"GET /yt/resource": "查询资源",
|
|
"POST /yt/role": "创建角色",
|
|
"PUT /yt/role": "修改角色",
|
|
"DELETE /yt/role": "删除角色",
|
|
"GET /yt/role": "查询角色",
|
|
"GET /yt/role/:id": "查询角色",
|
|
"GET /yt/role/account/:id": "查询角色账号",
|
|
"GET /yt/translate": "翻译",
|
|
"POST /yt/category": "创建分类",
|
|
"PUT /yt/category": "修改分类",
|
|
"DELETE /yt/category": "删除分类",
|
|
"GET /yt/category": "查询分类",
|
|
"POST /yt/process": "创建历程",
|
|
"PUT /yt/process": "修改历程",
|
|
"DELETE /yt/process": "删除历程",
|
|
"GET /yt/process": "查询历程",
|
|
"GET /yt/process/:id": "查询历程",
|
|
"POST /yt/news": "创建新闻",
|
|
"PUT /yt/news": "修改新闻",
|
|
"DELETE /yt/news": "删除新闻",
|
|
"GET /yt/news": "查询新闻",
|
|
"GET /yt/news/:id": "查询新闻",
|
|
"POST /yt/job": "创建招聘",
|
|
"PUT /yt/job": "修改招聘",
|
|
"DELETE /yt/job": "删除招聘",
|
|
"GET /yt/job": "查询招聘",
|
|
"GET /yt/job/:id": "查询招聘",
|
|
"POST /yt/doc": "创建文档",
|
|
"PUT /yt/doc": "修改文档",
|
|
"DELETE /yt/doc": "删除文档",
|
|
"GET /yt/doc": "查询文档",
|
|
"GET /yt/doc/:id": "查询文档",
|
|
"POST /yt/page": "创建页面",
|
|
"PUT /yt/page": "修改页面",
|
|
"DELETE /yt/page": "删除页面",
|
|
"GET /yt/page": "查询页面",
|
|
"POST /yt/upload": "上传文件",
|
|
"GET /yt/upload/token": "获取上传凭证",
|
|
}
|
|
|
|
var saveOperateLog = func(row *model.AdminOperateLog) error {
|
|
if global.GetDB() == nil {
|
|
return errDBNotReady
|
|
}
|
|
return dao.NewAdminOperateLog().Create(row)
|
|
}
|
|
|
|
var loadOperateUser = func(token string) *model.UserWithRole {
|
|
token = strings.TrimSpace(token)
|
|
if token == "" {
|
|
return nil
|
|
}
|
|
client := global.GetRedis()
|
|
if client == nil {
|
|
return nil
|
|
}
|
|
val := client.Get(cache.NewKeys().User() + token).Val()
|
|
if val == "" {
|
|
return nil
|
|
}
|
|
var user model.UserWithRole
|
|
if err := json.Unmarshal([]byte(val), &user); err != nil {
|
|
return nil
|
|
}
|
|
return &user
|
|
}
|
|
|
|
type operateDBError string
|
|
|
|
func (e operateDBError) Error() string { return string(e) }
|
|
|
|
const errDBNotReady operateDBError = "admin operate log: db not ready"
|
|
|
|
type bodyWriter struct {
|
|
gin.ResponseWriter
|
|
buf bytes.Buffer
|
|
}
|
|
|
|
func (w *bodyWriter) Write(b []byte) (int, error) {
|
|
w.buf.Write(b)
|
|
return w.ResponseWriter.Write(b)
|
|
}
|
|
|
|
func (w *bodyWriter) WriteString(s string) (int, error) {
|
|
w.buf.WriteString(s)
|
|
return w.ResponseWriter.WriteString(s)
|
|
}
|
|
|
|
// OperateLog 必须注册在 Recovery 之前。业务错误是 panic 抛出的,
|
|
// Recovery 接住并写完响应之后,这里才能把失败也记下来。
|
|
func OperateLog() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
start := time.Now()
|
|
reqBody := readOperateBody(c)
|
|
user := loadOperateUser(c.GetHeader("token"))
|
|
writer := &bodyWriter{ResponseWriter: c.Writer}
|
|
c.Writer = writer
|
|
c.Next()
|
|
|
|
resp := writer.buf.Bytes()
|
|
if user == nil && c.FullPath() == "/yt/user/login" {
|
|
if code, _, ok := parseOperateResult(resp); ok && code == 0 {
|
|
if token := loginToken(resp); token != "" {
|
|
if loaded := loadOperateUser(token); loaded != nil {
|
|
user = loaded
|
|
}
|
|
}
|
|
}
|
|
}
|
|
row := collectOperateLog(c, start, reqBody, resp, user)
|
|
if row == nil {
|
|
return
|
|
}
|
|
if err := saveOperateLog(row); err != nil {
|
|
log.Errorf("admin operate log %s %s: %v", row.Method, row.Path, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func collectOperateLog(c *gin.Context, start time.Time, reqBody, resp []byte, user *model.UserWithRole) *model.AdminOperateLog {
|
|
route := c.FullPath()
|
|
if route == "" {
|
|
route = c.Request.URL.Path
|
|
}
|
|
code, message, _ := parseOperateResult(resp)
|
|
success := 0
|
|
if code == 0 {
|
|
success = 1
|
|
}
|
|
if !shouldRecord(c.Request.Method, route, success == 1) {
|
|
return nil
|
|
}
|
|
|
|
account := ""
|
|
var userID, roleID int64
|
|
if user != nil {
|
|
account = user.Account
|
|
userID = user.ID
|
|
roleID = user.RoleID
|
|
} else {
|
|
account = accountFromBody(reqBody)
|
|
}
|
|
|
|
cost := int(time.Since(start).Milliseconds())
|
|
if cost < 0 {
|
|
cost = 0
|
|
}
|
|
return &model.AdminOperateLog{
|
|
Account: cut(strings.TrimSpace(account), operateAccountLimit),
|
|
UserID: userID,
|
|
RoleID: roleID,
|
|
CreateTime: start.Unix(),
|
|
CostMs: cost,
|
|
IP: cut(c.ClientIP(), 64),
|
|
ForwardedFor: cut(c.GetHeader("X-Forwarded-For"), operateForwardLimit),
|
|
Method: cut(c.Request.Method, 16),
|
|
Path: cut(c.Request.URL.Path, operatePathLimit),
|
|
Query: cut(c.Request.URL.RawQuery, operateQueryLimit),
|
|
Request: requestText(c, reqBody),
|
|
Action: cut(operateAction(c.Request.Method, route), operateActionLimit),
|
|
Code: code,
|
|
Message: cut(message, operateMessageLimit),
|
|
Response: responseText(route, resp),
|
|
Success: success,
|
|
Header: headerText(c.Request.Header),
|
|
}
|
|
}
|
|
|
|
func shouldRecord(method, route string, success bool) bool {
|
|
if !success {
|
|
return true
|
|
}
|
|
switch method {
|
|
case http.MethodPost, http.MethodPut, http.MethodDelete:
|
|
return true
|
|
}
|
|
return route == "/yt/user/logout" || route == "/yt/user/captcha"
|
|
}
|
|
|
|
func operateAction(method, route string) string {
|
|
if name, ok := operateActions[method+" "+route]; ok {
|
|
return name
|
|
}
|
|
return method + " " + route
|
|
}
|
|
|
|
func readOperateBody(c *gin.Context) []byte {
|
|
if strings.HasPrefix(strings.ToLower(c.GetHeader("Content-Type")), "multipart/") {
|
|
return nil
|
|
}
|
|
if c.Request.Body == nil {
|
|
return nil
|
|
}
|
|
body, err := io.ReadAll(c.Request.Body)
|
|
_ = c.Request.Body.Close()
|
|
if err != nil {
|
|
c.Request.Body = io.NopCloser(bytes.NewReader(nil))
|
|
return nil
|
|
}
|
|
c.Request.Body = io.NopCloser(bytes.NewReader(body))
|
|
return body
|
|
}
|
|
|
|
func requestText(c *gin.Context, body []byte) string {
|
|
if strings.HasPrefix(strings.ToLower(c.GetHeader("Content-Type")), "multipart/") {
|
|
return cut(multipartRequest(c), operateBodyLimit)
|
|
}
|
|
return maskAndCut(body, false)
|
|
}
|
|
|
|
func responseText(route string, body []byte) string {
|
|
return maskAndCut(body, route == "/yt/user/captcha")
|
|
}
|
|
|
|
func multipartRequest(c *gin.Context) string {
|
|
form := c.Request.MultipartForm
|
|
if form == nil {
|
|
return ""
|
|
}
|
|
fields := make(map[string][]string, len(form.Value))
|
|
for key, values := range form.Value {
|
|
copied := append([]string(nil), values...)
|
|
if strings.EqualFold(key, "token") {
|
|
for i := range copied {
|
|
copied[i] = maskToken(copied[i])
|
|
}
|
|
}
|
|
fields[key] = copied
|
|
}
|
|
files := make(map[string][]map[string]any, len(form.File))
|
|
for key, headers := range form.File {
|
|
items := make([]map[string]any, 0, len(headers))
|
|
for _, header := range headers {
|
|
items = append(items, map[string]any{
|
|
"filename": header.Filename,
|
|
"size": header.Size,
|
|
})
|
|
}
|
|
files[key] = items
|
|
}
|
|
raw, err := json.Marshal(map[string]any{"fields": fields, "files": files})
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return string(raw)
|
|
}
|
|
|
|
func parseOperateResult(body []byte) (int, string, bool) {
|
|
if len(bytes.TrimSpace(body)) == 0 {
|
|
return -1, "", false
|
|
}
|
|
var rsp struct {
|
|
Code int `json:"code"`
|
|
Message string `json:"message"`
|
|
}
|
|
if err := json.Unmarshal(body, &rsp); err != nil {
|
|
return -1, "", false
|
|
}
|
|
return rsp.Code, rsp.Message, true
|
|
}
|
|
|
|
func accountFromBody(body []byte) string {
|
|
var req struct {
|
|
Account string `json:"account"`
|
|
}
|
|
if err := json.Unmarshal(body, &req); err != nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(req.Account)
|
|
}
|
|
|
|
func loginToken(body []byte) string {
|
|
var rsp struct {
|
|
Data struct {
|
|
Token string `json:"token"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(body, &rsp); err != nil {
|
|
return ""
|
|
}
|
|
return rsp.Data.Token
|
|
}
|
|
|
|
func maskAndCut(body []byte, omitImage bool) string {
|
|
if len(bytes.TrimSpace(body)) == 0 {
|
|
return ""
|
|
}
|
|
var value any
|
|
if err := json.Unmarshal(body, &value); err != nil {
|
|
return cut(string(body), operateBodyLimit)
|
|
}
|
|
maskJSON(value, omitImage)
|
|
raw, err := json.Marshal(value)
|
|
if err != nil {
|
|
return cut(string(body), operateBodyLimit)
|
|
}
|
|
return cut(string(raw), operateBodyLimit)
|
|
}
|
|
|
|
func maskJSON(value any, omitImage bool) {
|
|
switch node := value.(type) {
|
|
case map[string]any:
|
|
for key, item := range node {
|
|
switch {
|
|
case strings.EqualFold(key, "token"):
|
|
node[key] = maskToken(asString(item))
|
|
case omitImage && strings.EqualFold(key, "image"):
|
|
node[key] = "[omitted]"
|
|
default:
|
|
maskJSON(item, omitImage)
|
|
}
|
|
}
|
|
case []any:
|
|
for _, item := range node {
|
|
maskJSON(item, omitImage)
|
|
}
|
|
}
|
|
}
|
|
|
|
func headerText(header http.Header) string {
|
|
copied := make(map[string]string, len(header))
|
|
for key, values := range header {
|
|
val := strings.Join(values, ",")
|
|
switch strings.ToLower(key) {
|
|
case "token", "cookie", "authorization", "set-cookie":
|
|
val = maskToken(val)
|
|
}
|
|
copied[key] = val
|
|
}
|
|
raw, err := json.Marshal(copied)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return cut(string(raw), operateHeaderLimit)
|
|
}
|
|
|
|
func maskToken(token string) string {
|
|
if len(token) <= 12 {
|
|
return "****"
|
|
}
|
|
return token[:4] + "****" + token[len(token)-4:]
|
|
}
|
|
|
|
func asString(value any) string {
|
|
text, _ := value.(string)
|
|
return text
|
|
}
|
|
|
|
func cut(s string, n int) string {
|
|
if len(s) <= n {
|
|
return s
|
|
}
|
|
b := []byte(s[:n])
|
|
for len(b) > 0 && !utf8.Valid(b) {
|
|
b = b[:len(b)-1]
|
|
}
|
|
return string(b)
|
|
}
|