diff --git a/config.yml.sample b/config.yml.sample index 2dc761499..d38114d52 100644 --- a/config.yml.sample +++ b/config.yml.sample @@ -40,8 +40,6 @@ service: enabletaskcomments: true # Whether totp is enabled. In most cases you want to leave that enabled. enabletotp: true - # If not empty, enables logging of crashes and unhandled errors in sentry. - sentrydsn: '' # If not empty, this will enable `/test/{table}` endpoints which allow to put any content in the database. # Used to reset the db before frontend tests. Because this is quite a dangerous feature allowing for lots of harm, # each request made to this endpoint needs to provide an `Authorization: ` header with the token from below.
@@ -61,6 +59,18 @@ service: # You probably don't need to set this value, it was created specifically for usage on [try](https://try.vikunja.io). demomode: false +sentry: + # If set to true, enables anonymous error tracking of api errors via Sentry. This allows us to gather more + # information about errors in order to debug and fix it. + enabled: false + # Configure the Sentry dsn used for api error tracking. Only used when Sentry is enabled for the api. + dsn: "https://440eedc957d545a795c17bbaf477497c@o1047380.ingest.sentry.io/4504254983634944" + # If set to true, enables anonymous error tracking of frontend errors via Sentry. This allows us to gather more + # information about errors in order to debug and fix it. + frontendenabled: false + # Configure the Sentry dsn used for frontend error tracking. Only used when Sentry is enabled for the frontend. + frontenddsn: "https://85694a2d757547cbbc90cd4b55c5a18d@o1047380.ingest.sentry.io/6024480" + database: # Database type to use. Supported types are mysql, postgres and sqlite. type: "sqlite" diff --git a/frontend/index.html b/frontend/index.html index 700a8bc3e..3c378a039 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -23,10 +23,6 @@ // It has to be the full url, including the last /api/v1 part and port. // You can change this if your api is not reachable on the same port as the frontend. window.API_URL = 'http://localhost:3456/api/v1' - // Enable error tracking with sentry. If this is set to true, will send anonymized data to - // our sentry instance to notify us of potential problems. - window.SENTRY_ENABLED = false - window.SENTRY_DSN = 'https://85694a2d757547cbbc90cd4b55c5a18d@o1047380.ingest.sentry.io/6024480' // If enabled, allows the user to nest projects infinitely, instead of the default 2 levels. // This setting might change in the future or be removed completely. window.PROJECT_INFINITE_NESTING_ENABLED = false diff --git a/frontend/src/main.ts b/frontend/src/main.ts index e5d288c83..716b8e12d 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -18,8 +18,8 @@ import {getBrowserLanguage, i18n, setLanguage} from './i18n' declare global { interface Window { API_URL: string; - SENTRY_ENABLED: boolean; - SENTRY_DSN: string; + SENTRY_ENABLED?: boolean; + SENTRY_DSN?: string; PROJECT_INFINITE_NESTING_ENABLED: boolean; ALLOW_ICON_CHANGES: boolean; CUSTOM_LOGO_URL?: string; diff --git a/frontend/src/sentry.ts b/frontend/src/sentry.ts index 747f8aec9..d48769dbf 100644 --- a/frontend/src/sentry.ts +++ b/frontend/src/sentry.ts @@ -8,7 +8,7 @@ export default async function setupSentry(app: App, router: Router) { Sentry.init({ app, - dsn: window.SENTRY_DSN, + dsn: window.SENTRY_DSN ?? '', release: import.meta.env.VITE_PLUGIN_SENTRY_CONFIG.release, dist: import.meta.env.VITE_PLUGIN_SENTRY_CONFIG.dist, integrations: [ diff --git a/pkg/config/config.go b/pkg/config/config.go index 06da020b1..8dd3d4801 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -58,12 +58,16 @@ const ( ServiceTimeZone Key = `service.timezone` ServiceEnableTaskComments Key = `service.enabletaskcomments` ServiceEnableTotp Key = `service.enabletotp` - ServiceSentryDsn Key = `service.sentrydsn` ServiceTestingtoken Key = `service.testingtoken` ServiceEnableEmailReminders Key = `service.enableemailreminders` ServiceEnableUserDeletion Key = `service.enableuserdeletion` ServiceMaxAvatarSize Key = `service.maxavatarsize` + SentryEnabled Key = `sentry.enabled` + SentryDsn Key = `sentry.dsn` + SentryFrontendEnabled Key = `sentry.frontendenabled` + SentryFrontendDsn Key = `sentry.frontenddsn` + AuthLocalEnabled Key = `auth.local.enabled` AuthOpenIDEnabled Key = `auth.openid.enabled` AuthOpenIDProviders Key = `auth.openid.providers` @@ -306,6 +310,10 @@ func InitDefaultConfig() { ServiceMaxAvatarSize.setDefault(1024) ServiceDemoMode.setDefault(false) + // Sentry + SentryDsn.setDefault("https://440eedc957d545a795c17bbaf477497c@o1047380.ingest.sentry.io/4504254983634944") + SentryFrontendDsn.setDefault("https://85694a2d757547cbbc90cd4b55c5a18d@o1047380.ingest.sentry.io/6024480") + // Auth AuthLocalEnabled.setDefault(true) AuthOpenIDEnabled.setDefault(false) diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index baf30da6d..88e40211a 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -114,39 +114,7 @@ func NewEcho() *echo.Echo { // panic recover e.Use(middleware.Recover()) - if config.ServiceSentryDsn.GetString() != "" { - if err := sentry.Init(sentry.ClientOptions{ - Dsn: config.ServiceSentryDsn.GetString(), - AttachStacktrace: true, - Release: version.Version, - }); err != nil { - log.Criticalf("Sentry init failed: %s", err) - } - defer sentry.Flush(5 * time.Second) - - e.Use(sentryecho.New(sentryecho.Options{ - Repanic: true, - })) - - e.HTTPErrorHandler = func(err error, c echo.Context) { - // Only capture errors not already handled by echo - var herr *echo.HTTPError - if errors.As(err, &herr) && herr.Code > 403 { - hub := sentryecho.GetHubFromContext(c) - if hub != nil { - hub.WithScope(func(scope *sentry.Scope) { - scope.SetExtra("url", c.Request().URL) - hub.CaptureException(err) - }) - } else { - sentry.CaptureException(err) - log.Debugf("Could not add context for sending error '%s' to sentry", err.Error()) - } - log.Debugf("Error '%s' sent to sentry", err.Error()) - } - e.DefaultHTTPErrorHandler(err, c) - } - } + setupSentry(e) // Validation e.Validator = &CustomValidator{} @@ -162,6 +130,44 @@ func NewEcho() *echo.Echo { return e } +func setupSentry(e *echo.Echo) { + if !config.SentryEnabled.GetBool() { + return + } + + if err := sentry.Init(sentry.ClientOptions{ + Dsn: config.SentryDsn.GetString(), + AttachStacktrace: true, + Release: version.Version, + }); err != nil { + log.Criticalf("Sentry init failed: %s", err) + } + defer sentry.Flush(5 * time.Second) + + e.Use(sentryecho.New(sentryecho.Options{ + Repanic: true, + })) + + e.HTTPErrorHandler = func(err error, c echo.Context) { + // Only capture errors not already handled by echo + var herr *echo.HTTPError + if errors.As(err, &herr) && herr.Code > 403 { + hub := sentryecho.GetHubFromContext(c) + if hub != nil { + hub.WithScope(func(scope *sentry.Scope) { + scope.SetExtra("url", c.Request().URL) + hub.CaptureException(err) + }) + } else { + sentry.CaptureException(err) + log.Debugf("Could not add context for sending error '%s' to sentry", err.Error()) + } + log.Debugf("Error '%s' sent to sentry", err.Error()) + } + e.DefaultHTTPErrorHandler(err, c) + } +} + // RegisterRoutes registers all routes for the application func RegisterRoutes(e *echo.Echo) { diff --git a/pkg/routes/static.go b/pkg/routes/static.go index b45f05390..3ed3d121b 100644 --- a/pkg/routes/static.go +++ b/pkg/routes/static.go @@ -2,6 +2,7 @@ package routes import ( "bytes" + "code.vikunja.io/api/pkg/config" "errors" "fmt" "io" @@ -13,6 +14,7 @@ import ( "path/filepath" "strings" "sync" + "text/template" "code.vikunja.io/api/frontend" @@ -22,20 +24,88 @@ import ( ) const ( - indexFile = `index.html` - rootPath = `dist/` - cacheControlMax = `max-age=315360000, public, max-age=31536000, s-maxage=31536000, immutable` - cacheControlNone = `public, max-age=0, s-maxage=0, must-revalidate` + indexFile = `index.html` + rootPath = `dist/` + cacheControlMax = `max-age=315360000, public, max-age=31536000, s-maxage=31536000, immutable` + cacheControlNone = `public, max-age=0, s-maxage=0, must-revalidate` + configScriptTagTemplate = ` +` ) // Because the files are embedded into the final binary, we can be absolutely sure the etag will never change // and we can cache its generation pretty heavily. var etagCache map[string]string var etagLock sync.Mutex +var scriptConfigString string +var scriptConfigStringLock sync.Mutex func init() { etagCache = make(map[string]string) etagLock = sync.Mutex{} + scriptConfigStringLock = sync.Mutex{} +} + +func serveIndexFile(c echo.Context, assetFs http.FileSystem) (err error) { + index, err := assetFs.Open(path.Join(rootPath, indexFile)) + if err != nil { + return err + } + defer index.Close() + + if scriptConfigString == "" { + + scriptConfigStringLock.Lock() + defer scriptConfigStringLock.Unlock() + + // replace config variables + tmpl, err := template.New("config").Parse(configScriptTagTemplate) + if err != nil { + return err + } + var tplOutput bytes.Buffer + data := make(map[string]string) + + data["SENTRY_ENABLED"] = "false" + if config.SentryFrontendEnabled.GetBool() { + data["SENTRY_ENABLED"] = "true" + } + data["SENTRY_DSN"] = config.SentryFrontendDsn.GetString() + data["ALLOW_ICON_CHANGES"] = "true" // TODO + data["CUSTOM_LOGO_URL"] = "" // TODO + + err = tmpl.Execute(&tplOutput, data) + if err != nil { + return err + } + scriptConfig := tplOutput.String() + + buf := bytes.Buffer{} + _, err = buf.ReadFrom(index) + if err != nil { + return err + } + + scriptConfigString = strings.ReplaceAll(buf.String(), `
`, `
`+scriptConfig) + } + + reader := strings.NewReader(scriptConfigString) + + info, err := index.Stat() + if err != nil { + return err + } + + etag, err := generateEtag(index, info.Name()) + if err != nil { + return err + } + + return serveFile(c, reader, info, etag) } // Copied from echo's middleware.StaticWithConfig simplified and adjusted for caching @@ -71,10 +141,8 @@ func static() echo.MiddlewareFunc { return err } - file, err = assetFs.Open(path.Join(rootPath, indexFile)) - if err != nil { - return err - } + // Handle all other requests with the index file + return serveIndexFile(c, assetFs) } defer file.Close() @@ -85,24 +153,7 @@ func static() echo.MiddlewareFunc { } if info.IsDir() { - index, err := assetFs.Open(path.Join(name, indexFile)) - if err != nil { - return next(c) - } - - defer index.Close() - - info, err = index.Stat() - if err != nil { - return err - } - - etag, err := generateEtag(index, name) - if err != nil { - return err - } - - return serveFile(c, index, info, etag) + return serveIndexFile(c, assetFs) } etag, err := generateEtag(file, name) @@ -133,7 +184,7 @@ func generateEtag(file http.File, name string) (etag string, err error) { } // copied from http.serveContent -func getMimeType(name string, file http.File) (mineType string, err error) { +func getMimeType(name string, file io.ReadSeeker) (mineType string, err error) { mineType = mime.TypeByExtension(filepath.Ext(name)) if mineType == "" { // read a chunk to decide between utf-8 text and binary @@ -149,7 +200,7 @@ func getMimeType(name string, file http.File) (mineType string, err error) { return mineType, nil } -func getCacheControlHeader(info os.FileInfo, file http.File) (header string, err error) { +func getCacheControlHeader(info os.FileInfo, file io.ReadSeeker) (header string, err error) { // Don't cache service worker and related files if info.Name() == "robots.txt" || info.Name() == "sw.js" || @@ -187,7 +238,7 @@ func getCacheControlHeader(info os.FileInfo, file http.File) (header string, err return cacheControlNone, nil } -func serveFile(c echo.Context, file http.File, info os.FileInfo, etag string) error { +func serveFile(c echo.Context, file io.ReadSeeker, info os.FileInfo, etag string) error { c.Response().Header().Set("Server", "Vikunja") c.Response().Header().Set("Vary", "Accept-Encoding")