mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
Every non-handler call site that wrapped a value in slog.String/Int/Bool/Any/Float64/Uint64 was passing an Attr through the variadic any... slot of (*slog.Logger).Info/Warn/Error/Debug; the same slot accepts the bare "key", value pair with identical semantics. Drop the wrapper so the surface is one level less indirect and so the next two commits can swap (*slog.Logger) for a small Logger interface without rewriting every attr expression as well. 23 files, 156 call sites collapsed; no behavior change. Five files — tools/logger/log.go, tools/logger/batch_handler.go +_test, and core/log_printer.go +_test — are the handler-implementation layer and legitimately reference slog.Attr/Level/Group; they were skipped.
680 lines
21 KiB
Go
680 lines
21 KiB
Go
package apis
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"runtime"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/hanzoai/base/core"
|
|
"github.com/hanzoai/base/tools/hook"
|
|
"github.com/hanzoai/base/tools/list"
|
|
"github.com/hanzoai/base/tools/router"
|
|
"github.com/hanzoai/base/tools/routine"
|
|
"github.com/hanzoai/base/tools/security"
|
|
"github.com/spf13/cast"
|
|
)
|
|
|
|
// Common request event store keys used by the middlewares and api handlers.
|
|
const (
|
|
RequestEventKeyLogMeta = "baseLogMeta" // extra data to store with the request activity log
|
|
|
|
requestEventKeyExecStart = "__execStart" // the value must be time.Time
|
|
requestEventKeySkipSuccessActivityLog = "__skipSuccessActivityLogger" // the value must be bool
|
|
)
|
|
|
|
const (
|
|
DefaultWWWRedirectMiddlewarePriority = -99999
|
|
DefaultWWWRedirectMiddlewareId = "baseWWWRedirect"
|
|
|
|
DefaultActivityLoggerMiddlewarePriority = DefaultRateLimitMiddlewarePriority - 40
|
|
DefaultActivityLoggerMiddlewareId = "baseActivityLogger"
|
|
DefaultSkipSuccessActivityLogMiddlewareId = "baseSkipSuccessActivityLog"
|
|
DefaultEnableAuthIdActivityLog = "baseEnableAuthIdActivityLog"
|
|
|
|
DefaultPanicRecoverMiddlewarePriority = DefaultRateLimitMiddlewarePriority - 30
|
|
DefaultPanicRecoverMiddlewareId = "basePanicRecover"
|
|
|
|
DefaultLoadAuthTokenMiddlewarePriority = DefaultRateLimitMiddlewarePriority - 20
|
|
DefaultLoadAuthTokenMiddlewareId = "baseLoadAuthToken"
|
|
|
|
DefaultSecurityHeadersMiddlewarePriority = DefaultRateLimitMiddlewarePriority - 10
|
|
DefaultSecurityHeadersMiddlewareId = "baseSecurityHeaders"
|
|
|
|
DefaultRequireGuestOnlyMiddlewareId = "baseRequireGuestOnly"
|
|
DefaultRequireAuthMiddlewareId = "baseRequireAuth"
|
|
DefaultRequireSuperuserAuthMiddlewareId = "baseRequireSuperuserAuth"
|
|
DefaultRequireSuperuserOrOwnerAuthMiddlewareId = "baseRequireSuperuserOrOwnerAuth"
|
|
DefaultRequireSameCollectionContextAuthMiddlewareId = "baseRequireSameCollectionContextAuth"
|
|
)
|
|
|
|
// RequireGuestOnly middleware requires a request to NOT have a valid
|
|
// Authorization header.
|
|
//
|
|
// This middleware is the opposite of [apis.RequireAuth()].
|
|
func RequireGuestOnly() *hook.Handler[*core.RequestEvent] {
|
|
return &hook.Handler[*core.RequestEvent]{
|
|
Id: DefaultRequireGuestOnlyMiddlewareId,
|
|
Func: func(e *core.RequestEvent) error {
|
|
if e.Auth != nil {
|
|
return router.NewBadRequestError("The request can be accessed only by guests.", nil)
|
|
}
|
|
|
|
return e.Next()
|
|
},
|
|
}
|
|
}
|
|
|
|
// RequireAuth middleware requires a request to have a valid record Authorization header.
|
|
//
|
|
// The auth record could be from any collection.
|
|
// You can further filter the allowed record auth collections by specifying their names.
|
|
//
|
|
// Example:
|
|
//
|
|
// apis.RequireAuth() // any auth collection
|
|
// apis.RequireAuth("_superusers", "users") // only the listed auth collections
|
|
func RequireAuth(optCollectionNames ...string) *hook.Handler[*core.RequestEvent] {
|
|
return &hook.Handler[*core.RequestEvent]{
|
|
Id: DefaultRequireAuthMiddlewareId,
|
|
Func: requireAuth(optCollectionNames...),
|
|
}
|
|
}
|
|
|
|
func requireAuth(optCollectionNames ...string) func(*core.RequestEvent) error {
|
|
return func(e *core.RequestEvent) error {
|
|
if e.Auth == nil {
|
|
return e.UnauthorizedError("The request requires valid record authorization token.", nil)
|
|
}
|
|
|
|
// check record collection name
|
|
if len(optCollectionNames) > 0 && !slices.Contains(optCollectionNames, e.Auth.Collection().Name) {
|
|
return e.ForbiddenError("The authorized record is not allowed to perform this action.", nil)
|
|
}
|
|
|
|
return e.Next()
|
|
}
|
|
}
|
|
|
|
// RequireSuperuserAuth middleware requires a request to have
|
|
// a valid superuser Authorization header.
|
|
func RequireSuperuserAuth() *hook.Handler[*core.RequestEvent] {
|
|
return &hook.Handler[*core.RequestEvent]{
|
|
Id: DefaultRequireSuperuserAuthMiddlewareId,
|
|
Func: requireAuth(core.CollectionNameSuperusers),
|
|
}
|
|
}
|
|
|
|
// RequireSuperuserOrOwnerAuth middleware requires a request to have
|
|
// a valid superuser or regular record owner Authorization header set.
|
|
//
|
|
// This middleware is similar to [apis.RequireAuth()] but
|
|
// for the auth record token expects to have the same id as the path
|
|
// parameter ownerIdPathParam (default to "id" if empty).
|
|
func RequireSuperuserOrOwnerAuth(ownerIdPathParam string) *hook.Handler[*core.RequestEvent] {
|
|
return &hook.Handler[*core.RequestEvent]{
|
|
Id: DefaultRequireSuperuserOrOwnerAuthMiddlewareId,
|
|
Func: func(e *core.RequestEvent) error {
|
|
if e.Auth == nil {
|
|
return e.UnauthorizedError("The request requires superuser or record authorization token.", nil)
|
|
}
|
|
|
|
if e.Auth.IsSuperuser() {
|
|
return e.Next()
|
|
}
|
|
|
|
if ownerIdPathParam == "" {
|
|
ownerIdPathParam = "id"
|
|
}
|
|
ownerId := e.Request.PathValue(ownerIdPathParam)
|
|
|
|
// note: it is considered "safe" to compare only the record id
|
|
// since the auth record ids are treated as unique across all auth collections
|
|
if e.Auth.Id != ownerId {
|
|
return e.ForbiddenError("You are not allowed to perform this request.", nil)
|
|
}
|
|
|
|
return e.Next()
|
|
},
|
|
}
|
|
}
|
|
|
|
// RequireSameCollectionContextAuth middleware requires a request to have
|
|
// a valid record Authorization header and the auth record's collection to
|
|
// match the one from the route path parameter (default to "collection" if collectionParam is empty).
|
|
func RequireSameCollectionContextAuth(collectionPathParam string) *hook.Handler[*core.RequestEvent] {
|
|
return &hook.Handler[*core.RequestEvent]{
|
|
Id: DefaultRequireSameCollectionContextAuthMiddlewareId,
|
|
Func: func(e *core.RequestEvent) error {
|
|
if e.Auth == nil {
|
|
return e.UnauthorizedError("The request requires valid record authorization token.", nil)
|
|
}
|
|
|
|
if collectionPathParam == "" {
|
|
collectionPathParam = "collection"
|
|
}
|
|
|
|
collection, _ := e.App.FindCachedCollectionByNameOrId(e.Request.PathValue(collectionPathParam))
|
|
if collection == nil || e.Auth.Collection().Id != collection.Id {
|
|
return e.ForbiddenError(fmt.Sprintf("The request requires auth record from %s collection.", e.Auth.Collection().Name), nil)
|
|
}
|
|
|
|
return e.Next()
|
|
},
|
|
}
|
|
}
|
|
|
|
// Store keys for OIDC/JWKS-based external auth provider integration.
|
|
// Set these via app.Store() from the platform plugin or manually.
|
|
const (
|
|
// StoreKeyJWKSURL is the JWKS endpoint URL for the identity provider
|
|
// (e.g., "https://auth.example.com/.well-known/jwks").
|
|
// When set, loadAuthToken validates bearer tokens against this endpoint.
|
|
StoreKeyJWKSURL = "jwksURL"
|
|
|
|
// StoreKeyAuthUsersCollection is the name of the auth collection to
|
|
// find/create externally-authenticated user records in (default: "users").
|
|
StoreKeyAuthUsersCollection = "authUsersCollection"
|
|
|
|
// StoreKeyExternalAuthOnly controls whether the external identity
|
|
// provider (OIDC/JWKS via Hanzo IAM) is the exclusive auth source.
|
|
// In Hanzo Base this is always true once the platform plugin
|
|
// registers — the legacy local-password / OTP / MFA / impersonate
|
|
// surfaces have been removed (returning 404 from the router).
|
|
// Only the _superusers collection has a transitional exemption
|
|
// (admin-panel login), tracked for removal once admin → IAM
|
|
// redirect login lands.
|
|
StoreKeyExternalAuthOnly = "externalAuthOnly"
|
|
)
|
|
|
|
// shared JWKS cache for external token validation (10 minute TTL on keys).
|
|
var jwksCache = security.NewJWKSCache(10 * time.Minute)
|
|
|
|
// loadAuthToken attempts to load the auth context based on the "Authorization: TOKEN" header value.
|
|
//
|
|
// This middleware does nothing in case of:
|
|
// - missing, invalid or expired token
|
|
// - e.Auth is already loaded by another middleware
|
|
//
|
|
// This middleware is registered by default for all routes.
|
|
//
|
|
// When app.Store() contains a "jwksURL" value, the middleware validates bearer
|
|
// tokens against the external identity provider's JWKS endpoint. If the
|
|
// validation succeeds, a corresponding user record is found or auto-created
|
|
// in the auth collection (configurable via "authUsersCollection" store key,
|
|
// default: "users").
|
|
//
|
|
// When StoreKeyExternalAuthOnly is true (set by the platform plugin), JWKS is
|
|
// the primary auth mechanism. Local Base tokens are only accepted for the
|
|
// _superusers collection (admin panel sessions). All other local tokens are
|
|
// rejected — users must authenticate via the configured identity provider.
|
|
//
|
|
// Note: We don't throw an error on invalid or expired token to allow
|
|
// users to extend with their own custom handling in external middleware(s).
|
|
func loadAuthToken() *hook.Handler[*core.RequestEvent] {
|
|
return &hook.Handler[*core.RequestEvent]{
|
|
Id: DefaultLoadAuthTokenMiddlewareId,
|
|
Priority: DefaultLoadAuthTokenMiddlewarePriority,
|
|
Func: func(e *core.RequestEvent) error {
|
|
// already loaded by another middleware
|
|
if e.Auth != nil {
|
|
return e.Next()
|
|
}
|
|
|
|
token := getAuthTokenFromRequest(e)
|
|
if token == "" {
|
|
return e.Next()
|
|
}
|
|
|
|
externalOnly, _ := e.App.Store().Get(StoreKeyExternalAuthOnly).(bool)
|
|
jwksURL, _ := e.App.Store().Get(StoreKeyJWKSURL).(string)
|
|
|
|
if externalOnly {
|
|
// External-only mode: IAM (JWKS) is the primary auth mechanism.
|
|
// Non-superuser collections must authenticate via IAM.
|
|
// _superusers collection tokens are always accepted as fallback
|
|
// (admin panel sessions, seeding, CLI tools).
|
|
|
|
// Try JWKS first if configured.
|
|
if jwksURL != "" {
|
|
record, jwksErr := resolveJWKSToken(e, token, jwksURL)
|
|
if jwksErr == nil && record != nil {
|
|
e.Auth = record
|
|
return e.Next()
|
|
}
|
|
if jwksErr != nil {
|
|
e.App.Logger().Debug("loadAuthToken: IAM JWKS validation failed",
|
|
"error", jwksErr,
|
|
)
|
|
}
|
|
}
|
|
|
|
// JWKS not configured or failed — fall back to local token
|
|
// for _superusers only. Admin panel sessions and CLI tools
|
|
// issue local Base tokens that cannot be validated via JWKS.
|
|
localRecord, localErr := e.App.FindAuthRecordByToken(token, core.TokenTypeAuth)
|
|
if localErr == nil && localRecord != nil && localRecord.Collection().Name == core.CollectionNameSuperusers {
|
|
e.Auth = localRecord
|
|
return e.Next()
|
|
}
|
|
|
|
return e.Next()
|
|
}
|
|
|
|
// Standard mode: try local first, fall back to JWKS.
|
|
record, err := e.App.FindAuthRecordByToken(token, core.TokenTypeAuth)
|
|
if err == nil && record != nil {
|
|
e.Auth = record
|
|
return e.Next()
|
|
}
|
|
|
|
// Local validation failed — try JWKS if configured.
|
|
if jwksURL == "" {
|
|
if err != nil {
|
|
e.App.Logger().Debug("loadAuthToken: local token validation failed", "error", err)
|
|
}
|
|
return e.Next()
|
|
}
|
|
|
|
jwksRecord, jwksErr := resolveJWKSToken(e, token, jwksURL)
|
|
if jwksErr != nil {
|
|
e.App.Logger().Warn("loadAuthToken: JWKS validation failed",
|
|
"localError", err,
|
|
"jwksError", jwksErr,
|
|
)
|
|
} else if jwksRecord != nil {
|
|
e.Auth = jwksRecord
|
|
}
|
|
|
|
return e.Next()
|
|
},
|
|
}
|
|
}
|
|
|
|
// resolveJWKSToken validates a JWT against the configured JWKS endpoint and
|
|
// returns the corresponding Base user record (creating one if it doesn't exist).
|
|
//
|
|
// Standard OIDC claims extracted: sub, email, name, preferred_username, owner.
|
|
func resolveJWKSToken(e *core.RequestEvent, token, jwksURL string) (*core.Record, error) {
|
|
ctx, cancel := context.WithTimeout(e.Request.Context(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
claims, err := security.ParseJWTWithJWKS(ctx, token, jwksURL, jwksCache)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("jwks validation: %w", err)
|
|
}
|
|
|
|
// Extract standard OIDC claims.
|
|
// "sub" is the primary identifier (RFC 7519). Fall back to
|
|
// "preferred_username" then "name" for compatibility.
|
|
sub, _ := claims["sub"].(string)
|
|
if sub == "" {
|
|
sub, _ = claims["preferred_username"].(string)
|
|
}
|
|
if sub == "" {
|
|
sub, _ = claims["name"].(string)
|
|
}
|
|
if sub == "" {
|
|
return nil, errors.New("token missing sub/preferred_username/name claim")
|
|
}
|
|
|
|
owner, _ := claims["owner"].(string)
|
|
email, _ := claims["email"].(string)
|
|
name, _ := claims["name"].(string)
|
|
displayName, _ := claims["displayName"].(string)
|
|
if name == "" {
|
|
name = displayName
|
|
}
|
|
|
|
// Check if this IAM user is an admin/superuser.
|
|
// Sources: isAdmin claim, isGlobalAdmin claim, or built-in/superuser org membership.
|
|
isAdmin, _ := claims["isAdmin"].(bool)
|
|
if !isAdmin {
|
|
if v, ok := claims["isAdmin"].(string); ok && v == "true" {
|
|
isAdmin = true
|
|
}
|
|
}
|
|
if !isAdmin {
|
|
if v, _ := claims["isGlobalAdmin"].(bool); v {
|
|
isAdmin = true
|
|
}
|
|
}
|
|
if !isAdmin {
|
|
// IAM's built-in org = superuser org. Users in built-in have full admin access.
|
|
if owner == "built-in" || owner == "superuser" {
|
|
isAdmin = true
|
|
}
|
|
}
|
|
|
|
// Store resolved claims on the request for downstream middleware.
|
|
e.Set("authSub", sub)
|
|
e.Set("authOwner", owner)
|
|
e.Set("authEmail", email)
|
|
e.Set("authName", name)
|
|
|
|
// When IAM is active, Base does NOT store user records. IAM is the user store.
|
|
// We create an ephemeral (unsaved) auth record from JWT claims so Base's
|
|
// rule engine can evaluate it. No writes to _superusers or users collections.
|
|
|
|
collectionName := core.CollectionNameSuperusers
|
|
if !isAdmin {
|
|
collectionName = "users"
|
|
if v, _ := e.App.Store().Get(StoreKeyAuthUsersCollection).(string); v != "" {
|
|
collectionName = v
|
|
}
|
|
}
|
|
|
|
collection, err := e.App.FindCachedCollectionByNameOrId(collectionName)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("auth collection %q not found: %w", collectionName, err)
|
|
}
|
|
|
|
// Ephemeral record — NOT persisted. IAM is the user store, not Base.
|
|
// The record exists only for this request so Base's rule engine can evaluate it.
|
|
record := core.NewRecord(collection)
|
|
record.Id = subToRecordID(sub)
|
|
record.Set("email", email)
|
|
if name != "" {
|
|
record.Set("name", name)
|
|
}
|
|
if owner != "" && collection.Fields.GetByName("org_id") != nil {
|
|
record.Set("org_id", owner)
|
|
}
|
|
record.SetVerified(true)
|
|
|
|
return record, nil
|
|
}
|
|
|
|
// subToRecordID converts an OIDC sub claim (which may be a UUID, slug, or
|
|
// other identifier) into a valid Base record ID (exactly 15 lowercase
|
|
// alphanumeric chars).
|
|
//
|
|
// Short subs (< 15 chars) are padded with underscores (kept for backward compat).
|
|
// Long or non-alphanumeric subs are SHA-256 hashed and truncated to 24 hex chars
|
|
// (96 bits of entropy) to reduce collision risk.
|
|
func subToRecordID(sub string) string {
|
|
// Fast path: if already a valid 15-char lowercase alphanumeric ID, use as-is.
|
|
if len(sub) == 15 && isLowerAlphanumeric(sub) {
|
|
return sub
|
|
}
|
|
|
|
// For short subs that are alphanumeric, pad to 15 (backward compat).
|
|
if len(sub) < 15 && isLowerAlphanumeric(sub) {
|
|
for len(sub) < 15 {
|
|
sub += "_"
|
|
}
|
|
return sub
|
|
}
|
|
|
|
// For UUIDs, long strings, or non-alphanumeric subs: deterministic hash.
|
|
// SHA-256 the original sub and take the first 24 hex chars (96 bits).
|
|
h := sha256.Sum256([]byte(sub))
|
|
return hex.EncodeToString(h[:])[:24]
|
|
}
|
|
|
|
// isLowerAlphanumeric returns true if s contains only [a-z0-9_].
|
|
func isLowerAlphanumeric(s string) bool {
|
|
for _, c := range s {
|
|
if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_') {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func getAuthTokenFromRequest(e *core.RequestEvent) string {
|
|
token := e.Request.Header.Get("Authorization")
|
|
|
|
// Fall back to X-Authorization (alias when Authorization is consumed by a proxy/CDN).
|
|
if token == "" {
|
|
token = e.Request.Header.Get("X-Authorization")
|
|
}
|
|
|
|
// Fall back to legacy X-Auth-Token header.
|
|
if token == "" {
|
|
token = e.Request.Header.Get("X-Auth-Token")
|
|
}
|
|
|
|
// Strip optional "Bearer " prefix for compatibility with standard HTTP clients.
|
|
if len(token) > 7 && strings.EqualFold(token[:7], "Bearer ") {
|
|
return token[7:]
|
|
}
|
|
|
|
return token
|
|
}
|
|
|
|
// wwwRedirect performs www->non-www redirect(s) if the request host
|
|
// matches with one of the values in redirectHosts.
|
|
//
|
|
// This middleware is registered by default on Serve for all routes.
|
|
func wwwRedirect(redirectHosts []string) *hook.Handler[*core.RequestEvent] {
|
|
return &hook.Handler[*core.RequestEvent]{
|
|
Id: DefaultWWWRedirectMiddlewareId,
|
|
Priority: DefaultWWWRedirectMiddlewarePriority,
|
|
Func: func(e *core.RequestEvent) error {
|
|
host := e.Request.Host
|
|
|
|
if strings.HasPrefix(host, "www.") && list.ExistInSlice(host, redirectHosts) {
|
|
// note: e.Request.URL.Scheme would be empty
|
|
schema := "http://"
|
|
if e.IsTLS() {
|
|
schema = "https://"
|
|
}
|
|
|
|
return e.Redirect(
|
|
http.StatusTemporaryRedirect,
|
|
(schema + host[4:] + e.Request.RequestURI),
|
|
)
|
|
}
|
|
|
|
return e.Next()
|
|
},
|
|
}
|
|
}
|
|
|
|
// panicRecover returns a default panic-recover handler.
|
|
func panicRecover() *hook.Handler[*core.RequestEvent] {
|
|
return &hook.Handler[*core.RequestEvent]{
|
|
Id: DefaultPanicRecoverMiddlewareId,
|
|
Priority: DefaultPanicRecoverMiddlewarePriority,
|
|
Func: func(e *core.RequestEvent) (err error) {
|
|
// panic-recover
|
|
defer func() {
|
|
recoverResult := recover()
|
|
if recoverResult == nil {
|
|
return
|
|
}
|
|
|
|
recoverErr, ok := recoverResult.(error)
|
|
if !ok {
|
|
recoverErr = fmt.Errorf("%v", recoverResult)
|
|
} else if errors.Is(recoverErr, http.ErrAbortHandler) {
|
|
// don't recover ErrAbortHandler so the response to the client can be aborted
|
|
panic(recoverResult)
|
|
}
|
|
|
|
stack := make([]byte, 2<<10) // 2 KB
|
|
length := runtime.Stack(stack, true)
|
|
err = e.InternalServerError("", fmt.Errorf("[PANIC RECOVER] %w %s", recoverErr, stack[:length]))
|
|
}()
|
|
|
|
err = e.Next()
|
|
|
|
return err
|
|
},
|
|
}
|
|
}
|
|
|
|
// securityHeaders middleware adds common security headers to the response.
|
|
//
|
|
// This middleware is registered by default for all routes.
|
|
func securityHeaders() *hook.Handler[*core.RequestEvent] {
|
|
return &hook.Handler[*core.RequestEvent]{
|
|
Id: DefaultSecurityHeadersMiddlewareId,
|
|
Priority: DefaultSecurityHeadersMiddlewarePriority,
|
|
Func: func(e *core.RequestEvent) error {
|
|
e.Response.Header().Set("X-XSS-Protection", "1; mode=block")
|
|
e.Response.Header().Set("X-Content-Type-Options", "nosniff")
|
|
e.Response.Header().Set("X-Frame-Options", "SAMEORIGIN")
|
|
|
|
// @todo consider a default HSTS?
|
|
// (see also https://webkit.org/blog/8146/protecting-against-hsts-abuse/)
|
|
|
|
return e.Next()
|
|
},
|
|
}
|
|
}
|
|
|
|
// SkipSuccessActivityLog is a helper middleware that instructs the global
|
|
// activity logger to log only requests that have failed/returned an error.
|
|
func SkipSuccessActivityLog() *hook.Handler[*core.RequestEvent] {
|
|
return &hook.Handler[*core.RequestEvent]{
|
|
Id: DefaultSkipSuccessActivityLogMiddlewareId,
|
|
Func: func(e *core.RequestEvent) error {
|
|
e.Set(requestEventKeySkipSuccessActivityLog, true)
|
|
return e.Next()
|
|
},
|
|
}
|
|
}
|
|
|
|
// activityLogger middleware takes care to save the request information
|
|
// into the logs database.
|
|
//
|
|
// This middleware is registered by default for all routes.
|
|
//
|
|
// The middleware does nothing if the app logs retention period is zero
|
|
// (aka. app.Settings().Logs.MaxDays = 0).
|
|
//
|
|
// Users can attach the [apis.SkipSuccessActivityLog()] middleware if
|
|
// you want to log only the failed requests.
|
|
func activityLogger() *hook.Handler[*core.RequestEvent] {
|
|
return &hook.Handler[*core.RequestEvent]{
|
|
Id: DefaultActivityLoggerMiddlewareId,
|
|
Priority: DefaultActivityLoggerMiddlewarePriority,
|
|
Func: func(e *core.RequestEvent) error {
|
|
e.Set(requestEventKeyExecStart, time.Now())
|
|
|
|
err := e.Next()
|
|
|
|
logRequest(e, err)
|
|
|
|
return err
|
|
},
|
|
}
|
|
}
|
|
|
|
func logRequest(event *core.RequestEvent, err error) {
|
|
// no logs retention
|
|
if event.App.Settings().Logs.MaxDays == 0 {
|
|
return
|
|
}
|
|
|
|
// the non-error route has explicitly disabled the activity logger
|
|
if err == nil && event.Get(requestEventKeySkipSuccessActivityLog) != nil {
|
|
return
|
|
}
|
|
|
|
attrs := make([]any, 0, 15)
|
|
|
|
attrs = append(attrs, "type", "request")
|
|
|
|
started := cast.ToTime(event.Get(requestEventKeyExecStart))
|
|
if !started.IsZero() {
|
|
attrs = append(attrs, "execTime", float64(time.Since(started))/float64(time.Millisecond))
|
|
}
|
|
|
|
if meta := event.Get(RequestEventKeyLogMeta); meta != nil {
|
|
attrs = append(attrs, "meta", meta)
|
|
}
|
|
|
|
status := event.Status()
|
|
method := cutStr(strings.ToUpper(event.Request.Method), 50)
|
|
requestUri := cutStr(event.Request.URL.RequestURI(), 3000)
|
|
|
|
// parse the request error
|
|
if err != nil {
|
|
apiErr, isPlainApiError := err.(*router.ApiError)
|
|
if isPlainApiError || errors.As(err, &apiErr) {
|
|
// the status header wasn't written yet
|
|
if status == 0 {
|
|
status = apiErr.Status
|
|
}
|
|
|
|
var errMsg string
|
|
if isPlainApiError {
|
|
errMsg = apiErr.Message
|
|
} else {
|
|
// wrapped ApiError -> add the full serialized version
|
|
// of the original error since it could contain more information
|
|
errMsg = err.Error()
|
|
}
|
|
|
|
attrs = append(
|
|
attrs,
|
|
"error", errMsg,
|
|
"details", apiErr.RawData(),
|
|
)
|
|
} else {
|
|
attrs = append(attrs, "error", err.Error())
|
|
}
|
|
}
|
|
|
|
attrs = append(
|
|
attrs,
|
|
"url", requestUri,
|
|
"method", method,
|
|
"status", status,
|
|
"referer", cutStr(event.Request.Referer(), 2000),
|
|
"userAgent", cutStr(event.Request.UserAgent(), 2000),
|
|
)
|
|
|
|
if event.Auth != nil {
|
|
attrs = append(attrs, "auth", event.Auth.Collection().Name)
|
|
|
|
if event.App.Settings().Logs.LogAuthId {
|
|
attrs = append(attrs, "authId", event.Auth.Id)
|
|
}
|
|
} else {
|
|
attrs = append(attrs, "auth", "")
|
|
}
|
|
|
|
if event.App.Settings().Logs.LogIP {
|
|
attrs = append(
|
|
attrs,
|
|
"userIP", event.RealIP(),
|
|
"remoteIP", event.RemoteIP(),
|
|
)
|
|
}
|
|
|
|
// don't block on logs write
|
|
routine.FireAndForget(func() {
|
|
message := method + " "
|
|
|
|
if escaped, unescapeErr := url.PathUnescape(requestUri); unescapeErr == nil {
|
|
message += escaped
|
|
} else {
|
|
message += requestUri
|
|
}
|
|
|
|
if err != nil {
|
|
event.App.Logger().Error(message, attrs...)
|
|
} else {
|
|
event.App.Logger().Info(message, attrs...)
|
|
}
|
|
})
|
|
}
|
|
|
|
func cutStr(str string, max int) string {
|
|
if len(str) > max {
|
|
return str[:max] + "..."
|
|
}
|
|
return str
|
|
}
|