package core import ( "crypto/x509" "fmt" "html/template" "io/fs" "net/http" "sync" "time" "github.com/blacked/go-zabbix" "github.com/getsentry/sentry-go" "github.com/gin-gonic/gin" "github.com/gorilla/securecookie" "github.com/gorilla/sessions" metrics "github.com/retailcrm/zabbix-metrics-collector" "go.uber.org/zap" "golang.org/x/text/language" "github.com/retailcrm/mg-transport-core/v2/core/config" "github.com/retailcrm/mg-transport-core/v2/core/db" "github.com/retailcrm/mg-transport-core/v2/core/middleware" "github.com/retailcrm/mg-transport-core/v2/core/util" "github.com/retailcrm/mg-transport-core/v2/core/util/httputil" "github.com/retailcrm/mg-transport-core/v2/core/logger" ) const DefaultHTTPClientTimeout time.Duration = 30 var boolTrue = true // DefaultHTTPClientConfig is a default config for HTTP client. It will be used by Engine for building HTTP client // if HTTP client config is not present in the configuration. var DefaultHTTPClientConfig = &config.HTTPClientConfig{ Timeout: DefaultHTTPClientTimeout, SSLVerification: &boolTrue, } // AppInfo contains information about app version. type AppInfo struct { Version string Commit string Build string BuildDate string } // Release information for Sentry. func (a AppInfo) Release() string { if a.Version == "" { a.Version = "" } if a.Build == "" { a.Build = "" } if a.BuildDate == "" { a.BuildDate = "" } if a.Commit == "" { a.Commit = "" } return fmt.Sprintf("%s (%s, built %s, commit \"%s\")", a.Version, a.Build, a.BuildDate, a.Commit) } // Engine struct. type Engine struct { logger logger.Logger AppInfo AppInfo Sessions sessions.Store Config config.Configuration Zabbix metrics.Transport ginEngine *gin.Engine csrf *middleware.CSRF httpClient *http.Client jobManager *JobManager db.ORM Localizer util.Utils PreloadLanguages []language.Tag Sentry mutex sync.RWMutex prepared bool } // New Engine instance (must be configured manually, gin can be accessed via engine.Router() directly or // engine.ConfigureRouter(...) with callback). func New(appInfo AppInfo) *Engine { return &Engine{ Config: nil, AppInfo: appInfo, Localizer: Localizer{ i18nStorage: &sync.Map{}, loadMutex: &sync.RWMutex{}, }, PreloadLanguages: []language.Tag{}, ORM: db.ORM{}, Sentry: Sentry{}, Utils: util.Utils{}, ginEngine: nil, logger: nil, mutex: sync.RWMutex{}, prepared: false, } } func (e *Engine) initGin() { if !e.Config.IsDebug() { gin.SetMode(gin.ReleaseMode) } r := gin.New() e.buildSentryConfig() e.InitSentrySDK() r.Use(e.SentryMiddlewares()...) r.Use(e.LocalizationMiddleware()) e.ginEngine = r } // Prepare engine for start. func (e *Engine) Prepare() *Engine { if e.prepared { panic("engine already initialized") } if e.Config == nil { panic("engine.Config must be loaded before initializing") } if e.DefaultError == "" { e.DefaultError = "error" } if e.LocaleMatcher == nil { e.LocaleMatcher = DefaultLocalizerMatcher() } if e.isUnd(e.Localizer.Language()) { e.Localizer.LanguageTag = DefaultLanguage } e.LoadTranslations() if len(e.PreloadLanguages) > 0 { e.Localizer.Preload(e.PreloadLanguages) } logFormat := "json" if format := e.Config.GetLogFormat(); format != "" { logFormat = format } e.CreateDB(e.Config.GetDBConfig()) e.ResetUtils(e.Config.GetAWSConfig(), e.Config.IsDebug(), 0) e.SetLogger(logger.NewDefault(logFormat, e.Config.IsDebug())) e.Sentry.Localizer = &e.Localizer e.Utils.Logger = e.Logger() e.Sentry.Logger = e.Logger() e.buildSentryConfig() e.Sentry.InitSentrySDK() e.prepared = true return e } func (e *Engine) UseZabbix(collectors []metrics.Collector) *Engine { if e.Config == nil || e.Config.GetZabbixConfig().Interval == 0 { return e } if e.Zabbix != nil { for _, col := range collectors { e.Zabbix.WithCollector(col) } return e } cfg := e.Config.GetZabbixConfig() sender := zabbix.NewSender(cfg.ServerHost, cfg.ServerPort) e.Zabbix = metrics.NewZabbix(collectors, sender, cfg.Host, cfg.Interval, logger.ZabbixCollectorAdapter(e.Logger())) return e } // HijackGinLogs will take control of GIN debug logs and will convert them into structured logs. // It will also affect default logging middleware. Use logger.GinMiddleware to circumvent this. func (e *Engine) HijackGinLogs() *Engine { if e.Logger() == nil { return e } gin.DefaultWriter = logger.WriterAdapter(e.Logger(), zap.DebugLevel) gin.DefaultErrorWriter = logger.WriterAdapter(e.Logger(), zap.ErrorLevel) gin.DebugPrintRouteFunc = func(httpMethod, absolutePath, handlerName string, nuHandlers int) { e.Logger().Debug("route", zap.String(logger.HTTPMethodAttr, httpMethod), zap.String("path", absolutePath), zap.String(logger.HandlerAttr, handlerName), zap.Int("handlerCount", nuHandlers)) } return e } // TemplateFuncMap combines func map for templates. func (e *Engine) TemplateFuncMap(functions template.FuncMap) template.FuncMap { funcMap := e.LocalizationFuncMap() for name, fn := range functions { funcMap[name] = fn } funcMap["version"] = func() string { return e.Config.GetVersion() } return funcMap } // CreateRenderer with translation function. func (e *Engine) CreateRenderer(callback func(*Renderer), funcs template.FuncMap) Renderer { renderer := NewRenderer(e.TemplateFuncMap(funcs)) callback(&renderer) return renderer } // CreateRendererFS with translation function and embedded files. func (e *Engine) CreateRendererFS( templatesFS fs.FS, callback func(*Renderer), funcs template.FuncMap, ) Renderer { renderer := NewRenderer(e.TemplateFuncMap(funcs)) renderer.TemplatesFS = templatesFS callback(&renderer) return renderer } // Router will return current gin.Engine or panic if it's not present. func (e *Engine) Router() *gin.Engine { if !e.prepared { panic("prepare engine first") } if e.ginEngine == nil { e.initGin() } return e.ginEngine } // JobManager will return singleton JobManager from Engine. func (e *Engine) JobManager() *JobManager { if e.jobManager == nil { e.jobManager = NewJobManager().SetLogger(e.Logger()).SetLogging(e.Config.IsDebug()) } return e.jobManager } // Logger returns current logger. func (e *Engine) Logger() logger.Logger { return e.logger } // SetLogger sets provided logger instance to engine. func (e *Engine) SetLogger(l logger.Logger) *Engine { if l == nil { return e } e.mutex.Lock() defer e.mutex.Unlock() if !e.prepared && e.logger != nil { return e } e.logger = l return e } // BuildHTTPClient builds HTTP client with provided configuration. func (e *Engine) BuildHTTPClient(certs *x509.CertPool, replaceDefault ...bool) *Engine { client, err := httputil.NewHTTPClientBuilder(). WithLogger(e.Logger()). SetLogging(e.Config.IsDebug()). SetCertPool(certs). FromConfig(e.GetHTTPClientConfig()). Build(replaceDefault...) if err != nil { panic(err) } e.httpClient = client return e } // GetHTTPClientConfig returns configuration for HTTP client. func (e *Engine) GetHTTPClientConfig() *config.HTTPClientConfig { if e.Config.GetHTTPClientConfig() != nil { return e.Config.GetHTTPClientConfig() } return DefaultHTTPClientConfig } // SetHTTPClient sets HTTP client to engine. func (e *Engine) SetHTTPClient(client *http.Client) *Engine { if client != nil { e.httpClient = client } return e } // HTTPClient returns inner http client or default http client. func (e *Engine) HTTPClient() *http.Client { if e.httpClient == nil { return http.DefaultClient } return e.httpClient } // WithCookieSessions generates new CookieStore with optional key length. // Default key length is 32 bytes. func (e *Engine) WithCookieSessions(keyLength ...int) *Engine { length := 32 if len(keyLength) > 0 && keyLength[0] > 0 { length = keyLength[0] } e.Sessions = sessions.NewCookieStore(securecookie.GenerateRandomKey(length)) return e } // WithFilesystemSessions generates new FilesystemStore with optional key length. // Default key length is 32 bytes. func (e *Engine) WithFilesystemSessions(path string, keyLength ...int) *Engine { length := 32 if len(keyLength) > 0 && keyLength[0] > 0 { length = keyLength[0] } e.Sessions = sessions.NewFilesystemStore(path, securecookie.GenerateRandomKey(length)) return e } // InitCSRF initializes CSRF middleware. engine.Sessions must be already initialized, // use engine.WithCookieStore or engine.WithFilesystemStore for that. // Syntax is similar to core.NewCSRF, but you shouldn't pass sessionName, store and salt. func (e *Engine) InitCSRF( secret string, abortFunc middleware.CSRFAbortFunc, getter middleware.CSRFTokenGetter) *Engine { if e.Sessions == nil { panic("engine.Sessions must be initialized first") } e.csrf = middleware.NewCSRF("", secret, "", e.Sessions, abortFunc, getter) return e } // VerifyCSRFMiddleware returns CSRF verifier middleware // Usage: // // engine.Router().Use(engine.VerifyCSRFMiddleware(core.DefaultIgnoredMethods)) func (e *Engine) VerifyCSRFMiddleware(ignoredMethods []string) gin.HandlerFunc { if e.csrf == nil { panic("csrf is not initialized") } return e.csrf.VerifyCSRFMiddleware(ignoredMethods) } // GenerateCSRFMiddleware returns CSRF generator middleware // Usage: // // engine.Router().Use(engine.GenerateCSRFMiddleware()) func (e *Engine) GenerateCSRFMiddleware() gin.HandlerFunc { if e.csrf == nil { panic("csrf is not initialized") } return e.csrf.GenerateCSRFMiddleware() } // GetCSRFToken returns CSRF token from provided context. func (e *Engine) GetCSRFToken(c *gin.Context) string { if e.csrf == nil { panic("csrf is not initialized") } return e.csrf.CSRFFromContext(c) } // ConfigureRouter will call provided callback with current gin.Engine, or panic if engine is not present. func (e *Engine) ConfigureRouter(callback func(*gin.Engine)) *Engine { callback(e.Router()) return e } // Run gin.Engine loop, or panic if engine is not present. func (e *Engine) Run() error { if e.Zabbix != nil { go e.Zabbix.Run() } return e.Router().Run(e.Config.GetHTTPConfig().Listen) } // buildSentryConfig from app configuration. func (e *Engine) buildSentryConfig() { if e.AppInfo.Version == "" { e.AppInfo.Version = e.Config.GetVersion() } e.SentryConfig = sentry.ClientOptions{ Dsn: e.Config.GetSentryDSN(), ServerName: e.Config.GetHTTPConfig().Host, Release: e.AppInfo.Release(), AttachStacktrace: true, Debug: e.Config.IsDebug(), } }