package main import ( "embed" "github.com/lpar/gzipped/v2" "github.com/muety/wakapi/models" "github.com/muety/wakapi/routes/relay" "io/fs" "log" "net" "net/http" "os" "strconv" "time" "github.com/emvi/logbuch" "github.com/gorilla/handlers" conf "github.com/muety/wakapi/config" "github.com/muety/wakapi/migrations" "github.com/muety/wakapi/repositories" "github.com/muety/wakapi/routes/api" "github.com/muety/wakapi/services/mail" fsutils "github.com/muety/wakapi/utils/fs" "gorm.io/gorm/logger" "github.com/gorilla/mux" "github.com/muety/wakapi/middlewares" "github.com/muety/wakapi/routes" shieldsV1Routes "github.com/muety/wakapi/routes/compat/shields/v1" wtV1Routes "github.com/muety/wakapi/routes/compat/wakatime/v1" "github.com/muety/wakapi/services" _ "gorm.io/driver/mysql" _ "gorm.io/driver/postgres" _ "gorm.io/driver/sqlite" "gorm.io/gorm" ) // Embed version.txt //go:embed version.txt var version string // Embed static files //go:embed static var staticFiles embed.FS var ( db *gorm.DB config *conf.Config ) var ( aliasRepository repositories.IAliasRepository heartbeatRepository repositories.IHeartbeatRepository userRepository repositories.IUserRepository languageMappingRepository repositories.ILanguageMappingRepository projectLabelRepository repositories.IProjectLabelRepository summaryRepository repositories.ISummaryRepository keyValueRepository repositories.IKeyValueRepository diagnosticsRepository repositories.IDiagnosticsRepository ) var ( aliasService services.IAliasService heartbeatService services.IHeartbeatService userService services.IUserService languageMappingService services.ILanguageMappingService projectLabelService services.IProjectLabelService durationService services.IDurationService summaryService services.ISummaryService aggregationService services.IAggregationService mailService services.IMailService keyValueService services.IKeyValueService reportService services.IReportService diagnosticsService services.IDiagnosticsService miscService services.IMiscService ) // TODO: Refactor entire project to be structured after business domains // @title Wakapi API // @version 1.0 // @description REST API to interact with [Wakapi](https://wakapi.dev) // @description // @description ## Authentication // @description Set header `Authorization` to your API Key encoded as Base64 and prefixed with `Basic` // @description **Example:** `Basic ODY2NDhkNzQtMTljNS00NTJiLWJhMDEtZmIzZWM3MGQ0YzJmCg==` // @contact.name Ferdinand Mütsch // @contact.url https://github.com/muety // @contact.email ferdinand@muetsch.io // @license.name GPL-3.0 // @license.url https://github.com/muety/wakapi/blob/master/LICENSE // @securitydefinitions.apikey ApiKeyAuth // @in header // @name Authorization // @BasePath /api func main() { config = conf.Load(version) // Set log level if config.IsDev() { logbuch.SetLevel(logbuch.LevelDebug) } else { logbuch.SetLevel(logbuch.LevelInfo) } // Set up GORM gormLogger := logger.New( log.New(os.Stdout, "", log.LstdFlags), logger.Config{ SlowThreshold: time.Minute, Colorful: false, LogLevel: logger.Silent, }, ) // Connect to database var err error db, err = gorm.Open(config.Db.GetDialector(), &gorm.Config{Logger: gormLogger}) if config.Db.IsSQLite() { db.Exec("PRAGMA foreign_keys = ON;") } if config.IsDev() { db = db.Debug() } sqlDb, err := db.DB() sqlDb.SetMaxIdleConns(int(config.Db.MaxConn)) sqlDb.SetMaxOpenConns(int(config.Db.MaxConn)) if err != nil { logbuch.Error(err.Error()) logbuch.Fatal("could not connect to database") } defer sqlDb.Close() // Migrate database schema migrations.Run(db, config) // Repositories aliasRepository = repositories.NewAliasRepository(db) heartbeatRepository = repositories.NewHeartbeatRepository(db) userRepository = repositories.NewUserRepository(db) languageMappingRepository = repositories.NewLanguageMappingRepository(db) projectLabelRepository = repositories.NewProjectLabelRepository(db) summaryRepository = repositories.NewSummaryRepository(db) keyValueRepository = repositories.NewKeyValueRepository(db) diagnosticsRepository = repositories.NewDiagnosticsRepository(db) // Services mailService = mail.NewMailService() aliasService = services.NewAliasService(aliasRepository) userService = services.NewUserService(mailService, userRepository) languageMappingService = services.NewLanguageMappingService(languageMappingRepository) projectLabelService = services.NewProjectLabelService(projectLabelRepository) heartbeatService = services.NewHeartbeatService(heartbeatRepository, languageMappingService) durationService = services.NewDurationService(heartbeatService) summaryService = services.NewSummaryService(summaryRepository, durationService, aliasService, projectLabelService) aggregationService = services.NewAggregationService(userService, summaryService, heartbeatService) keyValueService = services.NewKeyValueService(keyValueRepository) reportService = services.NewReportService(summaryService, userService, mailService) diagnosticsService = services.NewDiagnosticsService(diagnosticsRepository) miscService = services.NewMiscService(userService, summaryService, keyValueService) // Schedule background tasks if !config.QuickStart { go aggregationService.Schedule() go miscService.ScheduleCountTotalTime() go reportService.Schedule() } routes.Init() // API Handlers healthApiHandler := api.NewHealthApiHandler(db) heartbeatApiHandler := api.NewHeartbeatApiHandler(userService, heartbeatService, languageMappingService) summaryApiHandler := api.NewSummaryApiHandler(userService, summaryService) metricsHandler := api.NewMetricsHandler(userService, summaryService, heartbeatService, keyValueService) diagnosticsHandler := api.NewDiagnosticsApiHandler(userService, diagnosticsService) // Compat Handlers wakatimeV1StatusBarHandler := wtV1Routes.NewStatusBarHandler(userService, summaryService) wakatimeV1AllHandler := wtV1Routes.NewAllTimeHandler(userService, summaryService) wakatimeV1SummariesHandler := wtV1Routes.NewSummariesHandler(userService, summaryService) wakatimeV1StatsHandler := wtV1Routes.NewStatsHandler(userService, summaryService) wakatimeV1UsersHandler := wtV1Routes.NewUsersHandler(userService, heartbeatService) wakatimeV1ProjectsHandler := wtV1Routes.NewProjectsHandler(userService, heartbeatService) shieldV1BadgeHandler := shieldsV1Routes.NewBadgeHandler(summaryService, userService) // MVC Handlers summaryHandler := routes.NewSummaryHandler(summaryService, userService) settingsHandler := routes.NewSettingsHandler(userService, heartbeatService, summaryService, aliasService, aggregationService, languageMappingService, projectLabelService, keyValueService, mailService) homeHandler := routes.NewHomeHandler(keyValueService) loginHandler := routes.NewLoginHandler(userService, mailService) imprintHandler := routes.NewImprintHandler(keyValueService) // Other Handlers relayHandler := relay.NewRelayHandler() // Setup Routers router := mux.NewRouter() rootRouter := router.PathPrefix("/").Subrouter() apiRouter := router.PathPrefix("/api").Subrouter().StrictSlash(true) // https://github.com/gorilla/mux/issues/416 router.NotFoundHandler = router.NewRoute().BuildOnly().HandlerFunc(http.NotFound).GetHandler() router.NotFoundHandler = middlewares.NewLoggingMiddleware(logbuch.Info, []string{ "/assets", "/favicon", "/service-worker.js", })(router.NotFoundHandler) // Globally used middlewares router.Use(middlewares.NewPrincipalMiddleware()) router.Use(middlewares.NewLoggingMiddleware(logbuch.Info, []string{"/assets", "/api/health"})) router.Use(handlers.RecoveryHandler()) if config.Sentry.Dsn != "" { router.Use(middlewares.NewSentryMiddleware()) } rootRouter.Use(middlewares.NewSecurityMiddleware()) // Route registrations homeHandler.RegisterRoutes(rootRouter) loginHandler.RegisterRoutes(rootRouter) imprintHandler.RegisterRoutes(rootRouter) summaryHandler.RegisterRoutes(rootRouter) settingsHandler.RegisterRoutes(rootRouter) relayHandler.RegisterRoutes(rootRouter) // API route registrations summaryApiHandler.RegisterRoutes(apiRouter) healthApiHandler.RegisterRoutes(apiRouter) heartbeatApiHandler.RegisterRoutes(apiRouter) metricsHandler.RegisterRoutes(apiRouter) diagnosticsHandler.RegisterRoutes(apiRouter) wakatimeV1StatusBarHandler.RegisterRoutes(apiRouter) wakatimeV1AllHandler.RegisterRoutes(apiRouter) wakatimeV1SummariesHandler.RegisterRoutes(apiRouter) wakatimeV1StatsHandler.RegisterRoutes(apiRouter) wakatimeV1UsersHandler.RegisterRoutes(apiRouter) wakatimeV1ProjectsHandler.RegisterRoutes(apiRouter) shieldV1BadgeHandler.RegisterRoutes(apiRouter) // Static Routes // https://github.com/golang/go/issues/43431 embeddedStatic, _ := fs.Sub(staticFiles, "static") static := conf.ChooseFS("static", embeddedStatic) fileServer := gzipped.FileServer(fsutils.NewExistsHttpFS( fsutils.NewExistsFS( fsutils.NeuteredFileSystem{ FS: static, }).WithCache(!config.IsDev()), )) router.PathPrefix("/contribute.json").Handler(fileServer) router.PathPrefix("/assets").Handler(fileServer) router.PathPrefix("/swagger-ui").Handler(fileServer) router.PathPrefix("/docs").Handler( middlewares.NewFileTypeFilterMiddleware([]string{".go"})(fileServer), ) // Miscellaneous // Pre-warm projects cache if !config.IsDev() { allUsers, err := userService.GetAll() if err == nil { logbuch.Info("pre-warming user project cache") for _, u := range allUsers { go heartbeatService.GetEntitySetByUser(models.SummaryProject, u) } } } // Listen HTTP listen(router) } func listen(handler http.Handler) { var s4, s6, sSocket *http.Server // IPv4 if config.Server.ListenIpV4 != "" { bindString4 := config.Server.ListenIpV4 + ":" + strconv.Itoa(config.Server.Port) s4 = &http.Server{ Handler: handler, Addr: bindString4, ReadTimeout: time.Duration(config.Server.TimeoutSec) * time.Second, WriteTimeout: time.Duration(config.Server.TimeoutSec) * time.Second, } } // IPv6 if config.Server.ListenIpV6 != "" { bindString6 := "[" + config.Server.ListenIpV6 + "]:" + strconv.Itoa(config.Server.Port) s6 = &http.Server{ Handler: handler, Addr: bindString6, ReadTimeout: time.Duration(config.Server.TimeoutSec) * time.Second, WriteTimeout: time.Duration(config.Server.TimeoutSec) * time.Second, } } // UNIX domain socket if config.Server.ListenSocket != "" { // Remove if exists if _, err := os.Stat(config.Server.ListenSocket); err == nil { logbuch.Info("--> Removing unix socket %s", config.Server.ListenSocket) if err := os.Remove(config.Server.ListenSocket); err != nil { logbuch.Fatal(err.Error()) } } sSocket = &http.Server{ Handler: handler, ReadTimeout: time.Duration(config.Server.TimeoutSec) * time.Second, WriteTimeout: time.Duration(config.Server.TimeoutSec) * time.Second, } } if config.UseTLS() { if s4 != nil { logbuch.Info("--> Listening for HTTPS on %s... ✅", s4.Addr) go func() { if err := s4.ListenAndServeTLS(config.Server.TlsCertPath, config.Server.TlsKeyPath); err != nil { logbuch.Fatal(err.Error()) } }() } if s6 != nil { logbuch.Info("--> Listening for HTTPS on %s... ✅", s6.Addr) go func() { if err := s6.ListenAndServeTLS(config.Server.TlsCertPath, config.Server.TlsKeyPath); err != nil { logbuch.Fatal(err.Error()) } }() } if sSocket != nil { logbuch.Info("--> Listening for HTTPS on %s... ✅", config.Server.ListenSocket) go func() { unixListener, err := net.Listen("unix", config.Server.ListenSocket) if err != nil { logbuch.Fatal(err.Error()) } if err := sSocket.ServeTLS(unixListener, config.Server.TlsCertPath, config.Server.TlsKeyPath); err != nil { logbuch.Fatal(err.Error()) } }() } } else { if s4 != nil { logbuch.Info("--> Listening for HTTP on %s... ✅", s4.Addr) go func() { if err := s4.ListenAndServe(); err != nil { logbuch.Fatal(err.Error()) } }() } if s6 != nil { logbuch.Info("--> Listening for HTTP on %s... ✅", s6.Addr) go func() { if err := s6.ListenAndServe(); err != nil { logbuch.Fatal(err.Error()) } }() } if sSocket != nil { logbuch.Info("--> Listening for HTTP on %s... ✅", config.Server.ListenSocket) go func() { unixListener, err := net.Listen("unix", config.Server.ListenSocket) if err != nil { logbuch.Fatal(err.Error()) } if err := sSocket.Serve(unixListener); err != nil { logbuch.Fatal(err.Error()) } }() } } <-make(chan interface{}, 1) }