mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 22:06:38 +08:00
1f1f4efec7
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.
279 lines
7.2 KiB
Go
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
|
|
}
|
|
}
|
|
}
|