后端: - 种子数据:超级管理员(admin/admin123)、super_admin 角色、22 条菜单(含按钮权限码)、角色绑定 - admin_menu 扩展 icon/component/hidden 字段(003_schema_ext.sql),手动补齐 entity/do/table - auth 扩展:/auth/info、/auth/codes(仅登录),路由拆分公开/Profile/受保护三组 - 菜单路由:/menu/routes 返回 vben backend 动态路由树(按角色过滤、排序) - RBAC 管理:/admins、/roles、/menus CRUD(含角色绑定、重置密码、菜单授权) - 日志监控:/log/files、/log/tail(尾部读取+关键词过滤+路径穿越防护) - 安全加固:接口鉴权改为「方法+路径→权限码」自动映射,杜绝任意权限码越权 - 修复:gf v2.10.2 不自动替换→cmd 注入;MySQL driver 需显式引入 contrib - 全链路冒烟测试通过(含越权拒绝 30003)
168 lines
4.1 KiB
Go
168 lines
4.1 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/gogf/gf/v2/errors/gerror"
|
|
"github.com/gogf/gf/v2/frame/g"
|
|
"github.com/gogf/gf/v2/os/gfile"
|
|
|
|
"service.xpcool.com/internal/consts"
|
|
"service.xpcool.com/internal/library/response"
|
|
"service.xpcool.com/internal/model/dto"
|
|
)
|
|
|
|
// ILogManage reads server log files for the monitoring page.
|
|
type ILogManage interface {
|
|
Files(context.Context) (string, []*dto.LogFile, error)
|
|
Tail(context.Context, string, int, string) ([]string, error)
|
|
}
|
|
|
|
type logManage struct{}
|
|
|
|
func NewLogManage() ILogManage { return &logManage{} }
|
|
|
|
var localLogManage ILogManage
|
|
|
|
func LogManage() ILogManage {
|
|
if localLogManage == nil {
|
|
panic("LogManage implementation not registered")
|
|
}
|
|
return localLogManage
|
|
}
|
|
func RegisterLogManage(i ILogManage) { localLogManage = i }
|
|
|
|
// logDir returns the configured log directory (config logger.path), default "log".
|
|
func logDir(ctx context.Context) string {
|
|
dir := g.Cfg().MustGet(ctx, "logger.path").String()
|
|
if dir == "" {
|
|
dir = "log"
|
|
}
|
|
return dir
|
|
}
|
|
|
|
// resolvePath 校验 file 为日志目录内文件,防止路径穿越。
|
|
func resolvePath(ctx context.Context, file string) (string, error) {
|
|
dir := logDir(ctx)
|
|
absDir, err := filepath.Abs(dir)
|
|
if err != nil {
|
|
return "", gerror.Wrap(err, "resolve log dir")
|
|
}
|
|
full := filepath.Join(absDir, filepath.Clean(file))
|
|
if !strings.HasPrefix(full, absDir+string(os.PathSeparator)) && full != absDir {
|
|
return "", response.Error(consts.CodeInvalidParam, "invalid log file path")
|
|
}
|
|
if !gfile.Exists(full) || gfile.IsDir(full) {
|
|
return "", response.Error(consts.CodeInvalidParam, "log file not found")
|
|
}
|
|
return full, nil
|
|
}
|
|
|
|
func (s *logManage) Files(ctx context.Context) (string, []*dto.LogFile, error) {
|
|
dir := logDir(ctx)
|
|
if !gfile.Exists(dir) {
|
|
return dir, nil, nil
|
|
}
|
|
paths, err := gfile.ScanDirFile(dir, "*.log", true)
|
|
if err != nil {
|
|
return "", nil, gerror.Wrap(err, "scan log files")
|
|
}
|
|
files := make([]*dto.LogFile, 0, len(paths))
|
|
for _, p := range paths {
|
|
info, err := os.Stat(p)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
files = append(files, &dto.LogFile{
|
|
Name: info.Name(),
|
|
Path: strings.TrimPrefix(filepath.ToSlash(p), filepath.ToSlash(dir)+"/"),
|
|
Size: info.Size(),
|
|
ModTime: info.ModTime().Format("2006-01-02 15:04:05"),
|
|
})
|
|
}
|
|
sort.Slice(files, func(i, j int) bool { return files[i].ModTime > files[j].ModTime })
|
|
return dir, files, nil
|
|
}
|
|
|
|
func (s *logManage) Tail(ctx context.Context, file string, lines int, keyword string) ([]string, error) {
|
|
full, err := resolvePath(ctx, file)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
content, err := tailLines(full, lines)
|
|
if err != nil {
|
|
return nil, gerror.Wrap(err, "read log tail")
|
|
}
|
|
if keyword != "" {
|
|
var filtered []string
|
|
for _, l := range content {
|
|
if strings.Contains(l, keyword) {
|
|
filtered = append(filtered, l)
|
|
}
|
|
}
|
|
return filtered, nil
|
|
}
|
|
return content, nil
|
|
}
|
|
|
|
// tailLines reads the last n lines of a file efficiently (reverse chunk scan).
|
|
func tailLines(path string, n int) ([]string, error) {
|
|
if n <= 0 {
|
|
n = 200
|
|
}
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
info, err := f.Stat()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
size := info.Size()
|
|
if size == 0 {
|
|
return nil, nil
|
|
}
|
|
const chunkSize = 8 << 10 // 8KB
|
|
var (
|
|
offset int64 = size
|
|
buf []byte
|
|
lines []string
|
|
)
|
|
for offset > 0 && len(lines) < n {
|
|
readSize := int64(chunkSize)
|
|
if offset < readSize {
|
|
readSize = offset
|
|
}
|
|
offset -= readSize
|
|
chunk := make([]byte, readSize)
|
|
if _, err = f.ReadAt(chunk, offset); err != nil {
|
|
return nil, err
|
|
}
|
|
combined := append(chunk, buf...)
|
|
buf = nil
|
|
idx := len(combined) - 1
|
|
for idx >= 0 && len(lines) < n {
|
|
j := bytes.LastIndexByte(combined[:idx+1], '\n')
|
|
if j < 0 {
|
|
buf = append([]byte(nil), combined[:idx+1]...)
|
|
break
|
|
}
|
|
lines = append(lines, string(combined[j+1:idx+1]))
|
|
idx = j - 1
|
|
}
|
|
}
|
|
if len(buf) > 0 && len(lines) < n {
|
|
lines = append(lines, string(buf))
|
|
}
|
|
for i, j := 0, len(lines)-1; i < j; i, j = i+1, j-1 {
|
|
lines[i], lines[j] = lines[j], lines[i]
|
|
}
|
|
return lines, nil
|
|
}
|