Files
OpenFlare/backend/plugins/domain/admin/service/log.go
T
ryan 1f1f4efec7 fix(admin): stop console Intl errors and log websocket drops
Use raw i18n for push template hints so ICU does not parse
{{placeholders}}. Pass total into the user list record count.
Allow log websocket origins behind the Next rewrite, skip the
proxy on Upgrade, and do not open a socket after unmount.
2026-09-02 17:52:57 +08:00

279 lines
7.2 KiB
Go

// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package service
import (
"Wavelet/core/contracts"
"Wavelet/pkg/logger"
"Wavelet/plugins/domain/admin/errs"
"Wavelet/plugins/domain/admin/model"
"Wavelet/plugins/domain/admin/repository"
"context"
"fmt"
"net"
"net/url"
"strings"
"time"
)
const (
analyticsDays = 7
denyingRobotsFile = "User-Agent: *\nDisallow: /\n"
allowingRobotsFile = "User-Agent: *\nAllow: /\n"
)
// RecentSystemLogs reads a page of the process log ring buffer.
func RecentSystemLogs(cursor, limit int) model.LogsResponse {
entries, hasMore := logger.GlobalRingBuffer.Query(cursor, limit)
resp := model.LogsResponse{
Lines: entries,
HasMore: hasMore,
}
if len(entries) > 0 {
resp.NextCursor = entries[0].Index
}
return resp
}
// RobotsTxtBody resolves the robots.txt payload from the indexing setting.
func RobotsTxtBody(ctx context.Context) string {
enabled, err := repository.GetBoolByKey(ctx, model.ConfigKeySearchEngineIndexingEnabled)
if err == nil && enabled {
return allowingRobotsFile
}
return denyingRobotsFile
}
// IsAllowedLogOrigin reports whether a WebSocket handshake origin may subscribe to logs.
// extraHosts are reverse-proxy hosts such as X-Forwarded-Host (the browser origin
// when Next.js rewrites /api to the backend).
func IsAllowedLogOrigin(ctx context.Context, origin, host string, extraHosts ...string) bool {
if origin == "" {
return true
}
u, err := url.Parse(origin)
if err == nil {
if originMatchesHost(u.Host, host, extraHosts...) {
return true
}
}
sc, cfgErr := repository.GetSystemConfigByKey(ctx, model.ConfigKeyServerAddress)
if cfgErr != nil || sc.Value == "" {
return false
}
originToCheck := strings.TrimRight(strings.TrimSpace(origin), "/")
for _, allowed := range strings.Split(sc.Value, ",") {
allowed = strings.TrimRight(strings.TrimSpace(allowed), "/")
if allowed != "" && strings.EqualFold(allowed, originToCheck) {
return true
}
}
return false
}
func originMatchesHost(originHost, host string, extraHosts ...string) bool {
if originHost == "" {
return false
}
if hostMatches(originHost, host) {
return true
}
for _, extra := range extraHosts {
if hostMatches(originHost, extra) {
return true
}
}
return false
}
func hostMatches(originHost, candidate string) bool {
candidate = strings.TrimSpace(candidate)
if candidate == "" {
return false
}
if strings.EqualFold(originHost, candidate) {
return true
}
// Reverse-proxy / local Next rewrite: Origin is :3000, backend Host is :8000.
return strings.EqualFold(hostName(originHost), hostName(candidate))
}
func hostName(hostport string) string {
h, _, err := net.SplitHostPort(hostport)
if err != nil {
return hostport
}
return h
}
// AccessLogs queries the analytical access log store and decorates rows with user names.
func AccessLogs(ctx context.Context, q model.AccessLogQuery) (model.AccessLogsResponse, error) {
rc := GetRiskControlService(ctx)
if rc == nil {
return model.AccessLogsResponse{}, errs.ErrLogStoreUnavailable
}
filter, err := buildAccessLogFilter(ctx, q)
if err != nil {
return model.AccessLogsResponse{}, err
}
if filter.UserIDs != nil && len(filter.UserIDs) == 0 {
return model.AccessLogsResponse{Total: 0, List: []model.AccessLogItem{}}, nil
}
logs, total, err := rc.QueryAccessLogs(ctx, filter, q.Page, q.PageSize)
if err != nil {
return model.AccessLogsResponse{}, err
}
if total == 0 {
return model.AccessLogsResponse{Total: 0, List: []model.AccessLogItem{}}, nil
}
list := make([]model.AccessLogItem, len(logs))
for i, logItem := range logs {
list[i] = model.AccessLogItem{
ID: logItem.ID,
UserID: logItem.UserID,
Path: logItem.Path,
Method: logItem.Method,
IP: logItem.IP,
UserAgent: logItem.UserAgent,
Status: logItem.Status,
Latency: logItem.Latency,
CreatedAt: logItem.CreatedAt.Format(time.RFC3339),
}
}
enrichAccessLogsWithUsers(ctx, list)
return model.AccessLogsResponse{Total: total, List: list}, nil
}
// AccessLogAnalytics aggregates the daily trend of the access log store.
func AccessLogAnalytics(ctx context.Context) (model.LogsAnalyticsResponse, error) {
rc := GetRiskControlService(ctx)
if rc == nil {
return model.LogsAnalyticsResponse{}, errs.ErrLogStoreUnavailable
}
stats, err := rc.QueryAccessLogStats(ctx, analyticsDays)
if err != nil {
return model.LogsAnalyticsResponse{}, fmt.Errorf("%s%w", errs.ErrQueryAccessTrendFailed, err)
}
trendList := make([]model.TrendItem, len(stats))
for i, st := range stats {
trendList[i] = model.TrendItem{
Date: st.Date,
Count: st.PV,
}
}
return model.LogsAnalyticsResponse{
Trend: trendList,
Browsers: []model.BrowserItem{},
TopUsers: []model.TopUserItem{},
}, nil
}
// findUserIDsByUsername resolves the user id filter behind a username search term.
func findUserIDsByUsername(ctx context.Context, username string) ([]uint64, error) {
if userSvc := GetUserService(ctx); userSvc != nil {
users, _, err := userSvc.ListUsers(ctx, 1, userQueryMaxLimit, username)
if err != nil {
return nil, fmt.Errorf(errs.ErrQueryUserFailed, err)
}
ids := make([]uint64, 0, len(users))
for _, u := range users {
ids = append(ids, u.ID)
}
return ids, nil
}
ids, err := repository.SearchUserIDsByUsername(ctx, username)
if err != nil {
return nil, fmt.Errorf(errs.ErrQueryUserFailed, err)
}
return ids, nil
}
const userQueryMaxLimit = 100
func buildAccessLogFilter(ctx context.Context, q model.AccessLogQuery) (contracts.AccessLogFilterDTO, error) {
filter := contracts.AccessLogFilterDTO{}
if q.Username != "" {
userIDs, err := findUserIDsByUsername(ctx, q.Username)
if err != nil {
return filter, err
}
filter.UserIDs = userIDs
}
if q.Path != "" {
filter.Path = q.Path
}
if q.StartTime != "" {
if t, err := parseAccessLogTime(q.StartTime); err == nil {
filter.StartTime = &t
}
}
if q.EndTime != "" {
if t, err := parseAccessLogTime(q.EndTime); err == nil {
filter.EndTime = &t
}
}
return filter, nil
}
func parseAccessLogTime(value string) (time.Time, error) {
if t, err := time.Parse(time.RFC3339, value); err == nil {
return t, nil
}
return time.Parse("2006-01-02 15:04:05", value)
}
// enrichAccessLogsWithUsers attaches usernames and nicknames to access log rows.
func enrichAccessLogsWithUsers(ctx context.Context, list []model.AccessLogItem) {
if len(list) == 0 {
return
}
userIDs := make([]uint64, 0, len(list))
seen := make(map[uint64]struct{}, len(list))
for _, item := range list {
if _, ok := seen[item.UserID]; ok {
continue
}
seen[item.UserID] = struct{}{}
userIDs = append(userIDs, item.UserID)
}
userMap := make(map[uint64]repository.UserDisplayName, len(userIDs))
if userSvc := GetUserService(ctx); userSvc != nil {
if users, err := userSvc.GetUsersByIDs(ctx, userIDs); err == nil {
for _, u := range users {
if u != nil {
userMap[u.ID] = repository.UserDisplayName{Username: u.Username, Nickname: u.Nickname}
}
}
}
} else if names, err := repository.LoadUserDisplayNames(ctx, userIDs); err == nil {
userMap = names
}
for i := range list {
if info, ok := userMap[list[i].UserID]; ok {
list[i].Username = info.Username
list[i].Nickname = info.Nickname
}
}
}