From 2917a7d42d89ca706c3bd22b3f74eb29a9ec8828 Mon Sep 17 00:00:00 2001 From: ze0s <43699394+zze0s@users.noreply.github.com> Date: Sun, 5 Feb 2023 18:44:11 +0100 Subject: [PATCH] feat: show new updates in dashboard (#690) * feat: show new update banner * feat(http): add request logger * refactor: updates checker * feat: make update check optional * fix: empty releases * add toggle switch for update checks * feat: toggle updates check from settings * feat: toggle updates check from settings * feat: check on toggle enabled --------- Co-authored-by: soup --- .gitignore | 1 + Makefile | 2 +- cmd/autobrr/main.go | 31 ++++---- config.toml | 6 ++ internal/config/config.go | 53 ++++++++++++++ internal/config/config_test.go | 55 +++++++++++++++ internal/domain/config.go | 10 +++ internal/http/config.go | 72 +++++++++++++------ internal/http/middleware.go | 55 ++++++++++++++- internal/http/server.go | 30 ++++---- internal/http/update.go | 50 +++++++++++++ internal/scheduler/jobs.go | 30 ++++---- internal/scheduler/service.go | 32 +++++---- internal/server/server.go | 28 ++++++-- internal/update/update.go | 71 +++++++++++++++++++ pkg/version/version.go | 90 ++++++++++++++++++++---- pkg/version/version_test.go | 10 +-- web/src/api/APIClient.ts | 8 ++- web/src/screens/Base.tsx | 25 ++++++- web/src/screens/settings/Application.tsx | 70 +++++++++++++++++- web/src/types/Config.d.ts | 20 ++++++ web/src/types/Irc.d.ts | 11 --- web/src/types/Update.d.ts | 46 ++++++++++++ web/src/utils/Context.ts | 2 + 24 files changed, 687 insertions(+), 121 deletions(-) create mode 100644 internal/config/config_test.go create mode 100644 internal/http/update.go create mode 100644 internal/update/update.go create mode 100644 web/src/types/Config.d.ts create mode 100644 web/src/types/Update.d.ts diff --git a/.gitignore b/.gitignore index d1422da..eb45ae7 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ web/build bin/ log/ dist/ +.run/ # If needed, package-lock.json shall be added # manually using an explicit git add command. package-lock.json diff --git a/Makefile b/Makefile index b65e678..0d2b0a2 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ .SUFFIXES: GIT_COMMIT := $(shell git rev-parse HEAD 2> /dev/null) -GIT_TAG := $(shell git tag --points-at HEAD 2> /dev/null | head -n 1) +GIT_TAG := $(shell git describe --abbrev=0 --tags) SERVICE = autobrr GO = go diff --git a/cmd/autobrr/main.go b/cmd/autobrr/main.go index f56b078..ae8b17c 100644 --- a/cmd/autobrr/main.go +++ b/cmd/autobrr/main.go @@ -4,10 +4,7 @@ import ( "os" "os/signal" "syscall" - - "github.com/asaskevich/EventBus" - "github.com/r3labs/sse/v2" - "github.com/spf13/pflag" + _ "time/tzdata" "github.com/autobrr/autobrr/internal/action" "github.com/autobrr/autobrr/internal/api" @@ -26,9 +23,12 @@ import ( "github.com/autobrr/autobrr/internal/release" "github.com/autobrr/autobrr/internal/scheduler" "github.com/autobrr/autobrr/internal/server" + "github.com/autobrr/autobrr/internal/update" "github.com/autobrr/autobrr/internal/user" - _ "time/tzdata" + "github.com/asaskevich/EventBus" + "github.com/r3labs/sse/v2" + "github.com/spf13/pflag" ) var ( @@ -69,11 +69,11 @@ func main() { } log.Info().Msgf("Starting autobrr") - log.Info().Msgf("Version: %v", version) - log.Info().Msgf("Commit: %v", commit) - log.Info().Msgf("Build date: %v", date) - log.Info().Msgf("Log-level: %v", cfg.Config.LogLevel) - log.Info().Msgf("Using database: %v", db.Driver) + log.Info().Msgf("Version: %s", version) + log.Info().Msgf("Commit: %s", commit) + log.Info().Msgf("Build date: %s", date) + log.Info().Msgf("Log-level: %s", cfg.Config.LogLevel) + log.Info().Msgf("Using database: %s", db.Driver) // setup repos var ( @@ -94,7 +94,8 @@ func main() { var ( apiService = api.NewService(log, apikeyRepo) notificationService = notification.NewService(log, notificationRepo) - schedulingService = scheduler.NewService(log, version, notificationService) + updateService = update.NewUpdate(log, cfg.Config) + schedulingService = scheduler.NewService(log, cfg.Config, notificationService, updateService) indexerAPIService = indexer.NewAPIService(log) userService = user.NewService(userRepo) authService = auth.NewService(log, userService) @@ -115,7 +116,7 @@ func main() { go func() { httpServer := http.NewServer( log, - cfg.Config, + cfg, serverEvents, db, version, @@ -131,17 +132,15 @@ func main() { ircService, notificationService, releaseService, + updateService, ) errorChannel <- httpServer.Open() }() - srv := server.NewServer(log, ircService, indexerService, feedService, schedulingService) - srv.Hostname = cfg.Config.Host - srv.Port = cfg.Config.Port - sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGHUP, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGKILL, syscall.SIGTERM) + srv := server.NewServer(log, cfg.Config, ircService, indexerService, feedService, schedulingService, updateService) if err := srv.Start(); err != nil { log.Fatal().Stack().Err(err).Msg("could not start server") return diff --git a/config.toml b/config.toml index 8de3375..1c77e58 100644 --- a/config.toml +++ b/config.toml @@ -51,6 +51,12 @@ logLevel = "TRACE" # #logMaxBackups = 3 +# Check for updates +# +# Default: true +# +checkForUpdates = true + # Session secret # sessionSecret = "secret-session-key" diff --git a/internal/config/config.go b/internal/config/config.go index c353b05..01c51d6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,6 +2,7 @@ package config import ( "bytes" + "fmt" "log" "os" "path" @@ -72,6 +73,10 @@ logLevel = "DEBUG" # #logMaxBackups = 3 +# Check for updates +# +checkForUpdates = true + # Session secret # sessionSecret = "{{ .sessionSecret }}" @@ -148,6 +153,7 @@ func writeConfig(configPath string, configFile string) error { } type Config interface { + UpdateConfig() error DynamicReload(log logger.Logger) } @@ -179,6 +185,7 @@ func (c *AppConfig) defaults() { BaseURL: "/", SessionSecret: "secret-session-key", CustomDefinitions: "", + CheckForUpdates: true, DatabaseType: "sqlite", PostgresHost: "", PostgresPort: 0, @@ -240,6 +247,9 @@ func (c *AppConfig) DynamicReload(log logger.Logger) { logPath := viper.GetString("logPath") c.Config.LogPath = logPath + checkUpdates := viper.GetBool("checkForUpdates") + c.Config.CheckForUpdates = checkUpdates + log.Debug().Msg("config file reloaded!") c.m.Unlock() @@ -248,3 +258,46 @@ func (c *AppConfig) DynamicReload(log logger.Logger) { return } + +func (c *AppConfig) UpdateConfig() error { + file := path.Join(c.Config.ConfigPath, "config.toml") + + f, err := os.ReadFile(file) + if err != nil { + return errors.Wrap(err, "could not read config file: %s", file) + } + + lines := strings.Split(string(f), "\n") + lines = c.processLines(lines) + + output := strings.Join(lines, "\n") + if err := os.WriteFile(file, []byte(output), 0644); err != nil { + return errors.Wrap(err, "could not write config file: %s", file) + } + + return nil +} + +func (c *AppConfig) processLines(lines []string) []string { + // keep track of not found values to append at bottom + var ( + foundLineUpdate = false + ) + + for i, line := range lines { + // set checkForUpdates + if !foundLineUpdate && strings.Contains(line, "checkForUpdates =") { + lines[i] = fmt.Sprintf("checkForUpdates = %t", c.Config.CheckForUpdates) + foundLineUpdate = true + } + } + + // append missing vars to bottom + if !foundLineUpdate { + lines = append(lines, "# Check for updates") + lines = append(lines, "#") + lines = append(lines, fmt.Sprintf("checkForUpdates = %t", c.Config.CheckForUpdates)) + } + + return lines +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..e1e8d97 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,55 @@ +package config + +import ( + "reflect" + "sync" + "testing" + + "github.com/autobrr/autobrr/internal/domain" +) + +func TestAppConfig_processLines(t *testing.T) { + type fields struct { + Config *domain.Config + m sync.Mutex + } + type args struct { + lines []string + } + tests := []struct { + name string + fields fields + args args + want []string + }{ + { + name: "append missing", + fields: fields{ + Config: &domain.Config{CheckForUpdates: true}, + m: sync.Mutex{}, + }, + args: args{[]string{}}, + want: []string{"# Check for updates", "#", "checkForUpdates = true"}, + }, + { + name: "update existing", + fields: fields{ + Config: &domain.Config{CheckForUpdates: true}, + m: sync.Mutex{}, + }, + args: args{[]string{"# Check for updates", "#", "#checkForUpdates = false"}}, + want: []string{"# Check for updates", "#", "checkForUpdates = true"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &AppConfig{ + Config: tt.fields.Config, + m: tt.fields.m, + } + if got := c.processLines(tt.args.lines); !reflect.DeepEqual(got, tt.want) { + t.Errorf("processLines() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/domain/config.go b/internal/domain/config.go index 1473b59..dc34b39 100644 --- a/internal/domain/config.go +++ b/internal/domain/config.go @@ -12,6 +12,7 @@ type Config struct { BaseURL string `toml:"baseUrl"` SessionSecret string `toml:"sessionSecret"` CustomDefinitions string `toml:"customDefinitions"` + CheckForUpdates bool `toml:"checkForUpdates"` DatabaseType string `toml:"databaseType"` PostgresHost string `toml:"postgresHost"` PostgresPort int `toml:"postgresPort"` @@ -19,3 +20,12 @@ type Config struct { PostgresUser string `toml:"postgresUser"` PostgresPass string `toml:"postgresPass"` } + +type ConfigUpdate struct { + Host *string `json:"host,omitempty"` + Port *int `json:"port,omitempty"` + LogLevel *string `json:"log_level,omitempty"` + LogPath *string `json:"log_path,omitempty"` + BaseURL *string `json:"base_url,omitempty"` + CheckForUpdates *bool `json:"check_for_updates,omitempty"` +} diff --git a/internal/http/config.go b/internal/http/config.go index 8fd8cde..65d2379 100644 --- a/internal/http/config.go +++ b/internal/http/config.go @@ -1,52 +1,84 @@ package http import ( + "encoding/json" "net/http" + "github.com/autobrr/autobrr/internal/config" + "github.com/autobrr/autobrr/internal/domain" + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" ) type configJson struct { - Host string `json:"host"` - Port int `json:"port"` - LogLevel string `json:"log_level"` - LogPath string `json:"log_path"` - BaseURL string `json:"base_url"` - Version string `json:"version"` - Commit string `json:"commit"` - Date string `json:"date"` + Host string `json:"host"` + Port int `json:"port"` + LogLevel string `json:"log_level"` + LogPath string `json:"log_path"` + BaseURL string `json:"base_url"` + CheckForUpdates bool `json:"check_for_updates"` + Version string `json:"version"` + Commit string `json:"commit"` + Date string `json:"date"` } type configHandler struct { encoder encoder + cfg *config.AppConfig server Server } -func newConfigHandler(encoder encoder, server Server) *configHandler { +func newConfigHandler(encoder encoder, server Server, cfg *config.AppConfig) *configHandler { return &configHandler{ encoder: encoder, + cfg: cfg, server: server, } } func (h configHandler) Routes(r chi.Router) { r.Get("/", h.getConfig) + r.Patch("/", h.updateConfig) } func (h configHandler) getConfig(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - conf := configJson{ - Host: h.server.config.Host, - Port: h.server.config.Port, - LogLevel: h.server.config.LogLevel, - LogPath: h.server.config.LogPath, - BaseURL: h.server.config.BaseURL, - Version: h.server.version, - Commit: h.server.commit, - Date: h.server.date, + Host: h.cfg.Config.Host, + Port: h.cfg.Config.Port, + LogLevel: h.cfg.Config.LogLevel, + LogPath: h.cfg.Config.LogPath, + BaseURL: h.cfg.Config.BaseURL, + CheckForUpdates: h.cfg.Config.CheckForUpdates, + Version: h.server.version, + Commit: h.server.commit, + Date: h.server.date, } - h.encoder.StatusResponse(ctx, w, conf, http.StatusOK) + render.JSON(w, r, conf) +} + +func (h configHandler) updateConfig(w http.ResponseWriter, r *http.Request) { + var data domain.ConfigUpdate + + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { + h.encoder.Error(w, err) + return + } + + if data.CheckForUpdates != nil { + h.cfg.Config.CheckForUpdates = *data.CheckForUpdates + } + + if err := h.cfg.UpdateConfig(); err != nil { + render.Status(r, http.StatusInternalServerError) + render.JSON(w, r, errorResponse{ + Message: err.Error(), + Status: http.StatusInternalServerError, + }) + return + } + + render.NoContent(w, r) } diff --git a/internal/http/middleware.go b/internal/http/middleware.go index b5b1448..5b470ff 100644 --- a/internal/http/middleware.go +++ b/internal/http/middleware.go @@ -1,6 +1,13 @@ package http -import "net/http" +import ( + "net/http" + "runtime/debug" + "time" + + "github.com/go-chi/chi/v5/middleware" + "github.com/rs/zerolog" +) func (s Server) IsAuthenticated(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -31,3 +38,49 @@ func (s Server) IsAuthenticated(next http.Handler) http.Handler { next.ServeHTTP(w, r) }) } + +func LoggerMiddleware(logger *zerolog.Logger) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + log := logger.With().Logger() + + ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) + + t1 := time.Now() + defer func() { + t2 := time.Now() + + // Recover and record stack traces in case of a panic + if rec := recover(); rec != nil { + log.Error(). + Str("type", "error"). + Timestamp(). + Interface("recover_info", rec). + Bytes("debug_stack", debug.Stack()). + Msg("log system error") + http.Error(ww, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + + // log end request + log.Info(). + Str("type", "access"). + Timestamp(). + Fields(map[string]interface{}{ + "remote_ip": r.RemoteAddr, + "url": r.URL.Path, + "proto": r.Proto, + "method": r.Method, + "user_agent": r.Header.Get("User-Agent"), + "status": ww.Status(), + "latency_ms": float64(t2.Sub(t1).Nanoseconds()) / 1000000.0, + "bytes_in": r.Header.Get("Content-Length"), + "bytes_out": ww.BytesWritten(), + }). + Msg("incoming_request") + }() + + next.ServeHTTP(ww, r) + } + return http.HandlerFunc(fn) + } +} diff --git a/internal/http/server.go b/internal/http/server.go index 5fa417d..e673369 100644 --- a/internal/http/server.go +++ b/internal/http/server.go @@ -6,8 +6,8 @@ import ( "net" "net/http" + "github.com/autobrr/autobrr/internal/config" "github.com/autobrr/autobrr/internal/database" - "github.com/autobrr/autobrr/internal/domain" "github.com/autobrr/autobrr/internal/logger" "github.com/autobrr/autobrr/web" @@ -24,7 +24,7 @@ type Server struct { sse *sse.Server db *database.DB - config *domain.Config + config *config.AppConfig cookieStore *sessions.CookieStore version string @@ -41,9 +41,10 @@ type Server struct { ircService ircService notificationService notificationService releaseService releaseService + updateService updateService } -func NewServer(log logger.Logger, config *domain.Config, sse *sse.Server, db *database.DB, version string, commit string, date string, actionService actionService, apiService apikeyService, authService authService, downloadClientSvc downloadClientService, filterSvc filterService, feedSvc feedService, indexerSvc indexerService, ircSvc ircService, notificationSvc notificationService, releaseSvc releaseService) Server { +func NewServer(log logger.Logger, config *config.AppConfig, sse *sse.Server, db *database.DB, version string, commit string, date string, actionService actionService, apiService apikeyService, authService authService, downloadClientSvc downloadClientService, filterSvc filterService, feedSvc feedService, indexerSvc indexerService, ircSvc ircService, notificationSvc notificationService, releaseSvc releaseService, updateSvc updateService) Server { return Server{ log: log.With().Str("module", "http").Logger(), config: config, @@ -53,7 +54,7 @@ func NewServer(log logger.Logger, config *domain.Config, sse *sse.Server, db *da commit: commit, date: date, - cookieStore: sessions.NewCookieStore([]byte(config.SessionSecret)), + cookieStore: sessions.NewCookieStore([]byte(config.Config.SessionSecret)), actionService: actionService, apiService: apiService, @@ -65,11 +66,12 @@ func NewServer(log logger.Logger, config *domain.Config, sse *sse.Server, db *da ircService: ircSvc, notificationService: notificationSvc, releaseService: releaseSvc, + updateService: updateSvc, } } func (s Server) Open() error { - addr := fmt.Sprintf("%v:%v", s.config.Host, s.config.Port) + addr := fmt.Sprintf("%v:%v", s.config.Config.Host, s.config.Config.Port) listener, err := net.Listen("tcp", addr) if err != nil { return err @@ -79,6 +81,8 @@ func (s Server) Open() error { Handler: s.Handler(), } + s.log.Info().Msgf("Starting server. Listening on %s", listener.Addr().String()) + return server.Serve(listener) } @@ -88,6 +92,7 @@ func (s Server) Handler() http.Handler { r.Use(middleware.RequestID) r.Use(middleware.RealIP) r.Use(middleware.Recoverer) + r.Use(LoggerMiddleware(&s.log)) c := cors.New(cors.Options{ AllowCredentials: true, @@ -113,15 +118,15 @@ func (s Server) Handler() http.Handler { fileSystem.ServeHTTP(w, r) }) - r.Route("/api/auth", newAuthHandler(encoder, s.log, s.config, s.cookieStore, s.authService).Routes) - r.Route("/api/healthz", newHealthHandler(encoder, s.db).Routes) + r.Route("/api", func(r chi.Router) { + r.Route("/auth", newAuthHandler(encoder, s.log, s.config.Config, s.cookieStore, s.authService).Routes) + r.Route("/healthz", newHealthHandler(encoder, s.db).Routes) - r.Group(func(r chi.Router) { - r.Use(s.IsAuthenticated) + r.Group(func(r chi.Router) { + r.Use(s.IsAuthenticated) - r.Route("/api", func(r chi.Router) { r.Route("/actions", newActionHandler(encoder, s.actionService).Routes) - r.Route("/config", newConfigHandler(encoder, s).Routes) + r.Route("/config", newConfigHandler(encoder, s, s.config).Routes) r.Route("/download_clients", newDownloadClientHandler(encoder, s.downloadClientService).Routes) r.Route("/filters", newFilterHandler(encoder, s.filterService).Routes) r.Route("/feeds", newFeedHandler(encoder, s.feedService).Routes) @@ -130,6 +135,7 @@ func (s Server) Handler() http.Handler { r.Route("/keys", newAPIKeyHandler(encoder, s.apiService).Routes) r.Route("/notification", newNotificationHandler(encoder, s.notificationService).Routes) r.Route("/release", newReleaseHandler(encoder, s.releaseService).Routes) + r.Route("/updates", newUpdateHandler(encoder, s.updateService).Routes) r.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) { @@ -157,7 +163,7 @@ func (s Server) index(w http.ResponseWriter, r *http.Request) { p := web.IndexParams{ Title: "Dashboard", Version: s.version, - BaseUrl: s.config.BaseURL, + BaseUrl: s.config.Config.BaseURL, } web.Index(w, p) } diff --git a/internal/http/update.go b/internal/http/update.go new file mode 100644 index 0000000..19ccc42 --- /dev/null +++ b/internal/http/update.go @@ -0,0 +1,50 @@ +package http + +import ( + "context" + "net/http" + + "github.com/autobrr/autobrr/pkg/version" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" +) + +type updateService interface { + CheckUpdates(ctx context.Context) + GetLatestRelease(ctx context.Context) *version.Release +} + +type updateHandler struct { + encoder encoder + service updateService +} + +func newUpdateHandler(encoder encoder, service updateService) *updateHandler { + return &updateHandler{ + encoder: encoder, + service: service, + } +} + +func (h updateHandler) Routes(r chi.Router) { + r.Get("/latest", h.getLatest) + r.Get("/check", h.checkUpdates) +} + +func (h updateHandler) getLatest(w http.ResponseWriter, r *http.Request) { + latest := h.service.GetLatestRelease(r.Context()) + if latest != nil { + render.Status(r, http.StatusOK) + render.JSON(w, r, latest) + return + } + + render.NoContent(w, r) +} + +func (h updateHandler) checkUpdates(w http.ResponseWriter, r *http.Request) { + h.service.CheckUpdates(r.Context()) + + render.NoContent(w, r) +} diff --git a/internal/scheduler/jobs.go b/internal/scheduler/jobs.go index db363d6..5932ae3 100644 --- a/internal/scheduler/jobs.go +++ b/internal/scheduler/jobs.go @@ -6,46 +6,42 @@ import ( "github.com/autobrr/autobrr/internal/domain" "github.com/autobrr/autobrr/internal/notification" - "github.com/autobrr/autobrr/pkg/version" + "github.com/autobrr/autobrr/internal/update" "github.com/rs/zerolog" ) type CheckUpdatesJob struct { - Name string - Log zerolog.Logger - Version string - NotifSvc notification.Service + Name string + Log zerolog.Logger + Version string + NotifSvc notification.Service + updateService *update.Service lastCheckVersion string } func (j *CheckUpdatesJob) Run() { - v := version.Checker{ - Owner: "autobrr", - Repo: "autobrr", - } - - newAvailable, newVersion, err := v.CheckNewVersion(context.TODO(), j.Version) + newRelease, err := j.updateService.CheckUpdateAvailable(context.TODO()) if err != nil { j.Log.Error().Err(err).Msg("could not check for new release") return } - if newAvailable { - j.Log.Info().Msgf("a new release has been found: %v Consider updating.", newVersion) - + if newRelease != nil { // this is not persisted so this can trigger more than once // lets check if we have different versions between runs - if newVersion != j.lastCheckVersion { + if newRelease.TagName != j.lastCheckVersion { + j.Log.Info().Msgf("a new release has been found: %v Consider updating.", newRelease.TagName) + j.NotifSvc.Send(domain.NotificationEventAppUpdateAvailable, domain.NotificationPayload{ Subject: "New update available!", - Message: newVersion, + Message: newRelease.TagName, Event: domain.NotificationEventAppUpdateAvailable, Timestamp: time.Now(), }) } - j.lastCheckVersion = newVersion + j.lastCheckVersion = newRelease.TagName } } diff --git a/internal/scheduler/service.go b/internal/scheduler/service.go index f68b173..a3c3334 100644 --- a/internal/scheduler/service.go +++ b/internal/scheduler/service.go @@ -4,8 +4,10 @@ import ( "sync" "time" + "github.com/autobrr/autobrr/internal/domain" "github.com/autobrr/autobrr/internal/logger" "github.com/autobrr/autobrr/internal/notification" + "github.com/autobrr/autobrr/internal/update" "github.com/robfig/cron/v3" "github.com/rs/zerolog" @@ -21,19 +23,22 @@ type Service interface { type service struct { log zerolog.Logger + config *domain.Config version string notificationSvc notification.Service + updateSvc *update.Service cron *cron.Cron jobs map[string]cron.EntryID m sync.RWMutex } -func NewService(log logger.Logger, version string, notificationSvc notification.Service) Service { +func NewService(log logger.Logger, config *domain.Config, notificationSvc notification.Service, updateSvc *update.Service) Service { return &service{ log: log.With().Str("module", "scheduler").Logger(), - version: version, + config: config, notificationSvc: notificationSvc, + updateSvc: updateSvc, cron: cron.New(cron.WithChain( cron.Recover(cron.DefaultLogger), )), @@ -56,16 +61,19 @@ func (s *service) Start() { func (s *service) addAppJobs() { time.Sleep(5 * time.Second) - checkUpdates := &CheckUpdatesJob{ - Name: "app-check-updates", - Log: s.log.With().Str("job", "app-check-updates").Logger(), - Version: s.version, - NotifSvc: s.notificationSvc, - lastCheckVersion: "", - } + if s.config.CheckForUpdates { + checkUpdates := &CheckUpdatesJob{ + Name: "app-check-updates", + Log: s.log.With().Str("job", "app-check-updates").Logger(), + Version: s.version, + NotifSvc: s.notificationSvc, + updateService: s.updateSvc, + lastCheckVersion: s.version, + } - if id, err := s.AddJob(checkUpdates, time.Duration(36*time.Hour), "app-check-updates"); err != nil { - s.log.Error().Err(err).Msgf("scheduler.addAppJobs: error adding job: %v", id) + if id, err := s.AddJob(checkUpdates, 2*time.Hour, "app-check-updates"); err != nil { + s.log.Error().Err(err).Msgf("scheduler.addAppJobs: error adding job: %v", id) + } } } @@ -81,7 +89,7 @@ func (s *service) AddJob(job cron.Job, interval time.Duration, identifier string cron.SkipIfStillRunning(cron.DiscardLogger)).Then(job), ) - s.log.Debug().Msgf("scheduler.AddJob: job successfully added: %v", id) + s.log.Debug().Msgf("scheduler.AddJob: job successfully added: %s id %d", identifier, id) s.m.Lock() // add to job map diff --git a/internal/server/server.go b/internal/server/server.go index 945ea98..600c093 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1,43 +1,49 @@ package server import ( + "context" "sync" + "time" - "github.com/rs/zerolog" - + "github.com/autobrr/autobrr/internal/domain" "github.com/autobrr/autobrr/internal/feed" "github.com/autobrr/autobrr/internal/indexer" "github.com/autobrr/autobrr/internal/irc" "github.com/autobrr/autobrr/internal/logger" "github.com/autobrr/autobrr/internal/scheduler" + "github.com/autobrr/autobrr/internal/update" + + "github.com/rs/zerolog" ) type Server struct { - log zerolog.Logger - Hostname string - Port int + log zerolog.Logger + config *domain.Config indexerService indexer.Service ircService irc.Service feedService feed.Service scheduler scheduler.Service + updateService *update.Service stopWG sync.WaitGroup lock sync.Mutex } -func NewServer(log logger.Logger, ircSvc irc.Service, indexerSvc indexer.Service, feedSvc feed.Service, scheduler scheduler.Service) *Server { +func NewServer(log logger.Logger, config *domain.Config, ircSvc irc.Service, indexerSvc indexer.Service, feedSvc feed.Service, scheduler scheduler.Service, updateSvc *update.Service) *Server { return &Server{ log: log.With().Str("module", "server").Logger(), + config: config, indexerService: indexerSvc, ircService: ircSvc, feedService: feedSvc, scheduler: scheduler, + updateService: updateSvc, } } func (s *Server) Start() error { - s.log.Info().Msgf("Starting server. Listening on %v:%v", s.Hostname, s.Port) + go s.checkUpdates() // start cron scheduler s.scheduler.Start() @@ -68,3 +74,11 @@ func (s *Server) Shutdown() { // stop cron scheduler s.scheduler.Stop() } + +func (s *Server) checkUpdates() { + if s.config.CheckForUpdates { + time.Sleep(1 * time.Second) + + s.updateService.CheckUpdates(context.Background()) + } +} diff --git a/internal/update/update.go b/internal/update/update.go new file mode 100644 index 0000000..5f14fe6 --- /dev/null +++ b/internal/update/update.go @@ -0,0 +1,71 @@ +package update + +import ( + "context" + "sync" + + "github.com/autobrr/autobrr/internal/domain" + "github.com/autobrr/autobrr/internal/logger" + "github.com/autobrr/autobrr/pkg/version" + + "github.com/rs/zerolog" +) + +type Service struct { + log zerolog.Logger + config *domain.Config + + m sync.RWMutex + releaseChecker *version.Checker + latestRelease *version.Release +} + +func NewUpdate(log logger.Logger, config *domain.Config) *Service { + return &Service{ + log: log.With().Str("module", "update").Logger(), + config: config, + releaseChecker: version.NewChecker("autobrr", "autobrr", config.Version), + } +} + +func (s *Service) GetLatestRelease(ctx context.Context) *version.Release { + s.m.RLock() + defer s.m.RUnlock() + return s.latestRelease +} + +func (s *Service) CheckUpdates(ctx context.Context) { + if _, err := s.CheckUpdateAvailable(ctx); err != nil { + s.log.Error().Err(err).Msg("error checking new release") + return + } + + return +} + +func (s *Service) CheckUpdateAvailable(ctx context.Context) (*version.Release, error) { + s.log.Trace().Msg("checking for updates...") + + newAvailable, newVersion, err := s.releaseChecker.CheckNewVersion(ctx, s.config.Version) + if err != nil { + s.log.Error().Err(err).Msg("could not check for new release") + return nil, nil + } + + if newAvailable { + s.log.Info().Msgf("autobrr outdated, found newer release: %s", newVersion.TagName) + + s.m.Lock() + defer s.m.Unlock() + + if s.latestRelease != nil && s.latestRelease.TagName == newVersion.TagName { + return nil, nil + } + + s.latestRelease = newVersion + + return newVersion, nil + } + + return nil, nil +} diff --git a/pkg/version/version.go b/pkg/version/version.go index d0999b0..bab6417 100644 --- a/pkg/version/version.go +++ b/pkg/version/version.go @@ -5,6 +5,8 @@ import ( "encoding/json" "fmt" "net/http" + "runtime" + "time" "github.com/autobrr/autobrr/pkg/errors" @@ -13,16 +15,50 @@ import ( // Release is a GitHub release type Release struct { - TagName string `json:"tag_name,omitempty"` - TargetCommitish *string `json:"target_commitish,omitempty"` - Name *string `json:"name,omitempty"` - Body *string `json:"body,omitempty"` - Draft *bool `json:"draft,omitempty"` - Prerelease *bool `json:"prerelease,omitempty"` + ID int64 `json:"id,omitempty"` + NodeID string `json:"node_id,omitempty"` + URL string `json:"url,omitempty"` + HtmlURL string `json:"html_url,omitempty"` + TagName string `json:"tag_name,omitempty"` + TargetCommitish string `json:"target_commitish,omitempty"` + Name *string `json:"name,omitempty"` + Body *string `json:"body,omitempty"` + Draft bool `json:"draft,omitempty"` + Prerelease bool `json:"prerelease,omitempty"` + CreatedAt time.Time `json:"created_at"` + PublishedAt time.Time `json:"published_at"` + Author Author `json:"author"` + Assets []Asset `json:"assets"` +} + +type Author struct { + Login string `json:"login"` + Id int64 `json:"id"` + NodeId string `json:"node_id"` + AvatarUrl string `json:"avatar_url"` + GravatarId string `json:"gravatar_id"` + Url string `json:"url"` + HtmlUrl string `json:"html_url"` + Type string `json:"type"` +} +type Asset struct { + Url string `json:"url"` + Id int64 `json:"id"` + NodeId string `json:"node_id"` + Name string `json:"name"` + Label string `json:"label"` + Uploader Author `json:"uploader"` + ContentType string `json:"content_type"` + State string `json:"state"` + Size int64 `json:"size"` + DownloadCount int64 `json:"download_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + BrowserDownloadUrl string `json:"browser_download_url"` } func (r *Release) IsPreOrDraft() bool { - if *r.Draft || *r.Prerelease { + if r.Draft || r.Prerelease { return true } return false @@ -30,12 +66,21 @@ func (r *Release) IsPreOrDraft() bool { type Checker struct { // user/repo-name or org/repo-name - Owner string - Repo string + Owner string + Repo string + CurrentVersion string +} + +func NewChecker(owner, repo, currentVersion string) *Checker { + return &Checker{ + Owner: owner, + Repo: repo, + CurrentVersion: currentVersion, + } } func (c *Checker) get(ctx context.Context) (*Release, error) { - url := fmt.Sprintf("https://api.github.com/repos/%v/%v/releases/latest", c.Owner, c.Repo) + url := fmt.Sprintf("https://api.autobrr.com/repos/%s/%s/releases/latest", c.Owner, c.Repo) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { @@ -43,6 +88,8 @@ func (c *Checker) get(ctx context.Context) (*Release, error) { } req.Header.Set("Accept", "application/vnd.github.v3+json") + req.Header.Set("User-Agent", c.buildUserAgent()) + client := http.DefaultClient resp, err := client.Do(req) @@ -64,17 +111,26 @@ func (c *Checker) get(ctx context.Context) (*Release, error) { return &release, nil } -func (c *Checker) CheckNewVersion(ctx context.Context, version string) (bool, string, error) { +func (c *Checker) CheckNewVersion(ctx context.Context, version string) (bool, *Release, error) { if isDevelop(version) { - return false, "", nil + return false, nil, nil } release, err := c.get(ctx) if err != nil { - return false, "", err + return false, nil, err } - return c.checkNewVersion(version, release) + newAvailable, _, err := c.checkNewVersion(version, release) + if err != nil { + return false, nil, err + } + + if !newAvailable { + return false, nil, nil + } + + return true, release, nil } func (c *Checker) checkNewVersion(version string, release *Release) (bool, string, error) { @@ -100,8 +156,12 @@ func (c *Checker) checkNewVersion(version string, release *Release) (bool, strin return false, "", nil } +func (c *Checker) buildUserAgent() string { + return fmt.Sprintf("autobrr/%s (%s %s)", c.CurrentVersion, runtime.GOOS, runtime.GOARCH) +} + func isDevelop(version string) bool { - tags := []string{"dev", "develop", "master", "latest"} + tags := []string{"dev", "develop", "master", "latest", ""} for _, tag := range tags { if version == tag { diff --git a/pkg/version/version_test.go b/pkg/version/version_test.go index f2a379a..bc22c91 100644 --- a/pkg/version/version_test.go +++ b/pkg/version/version_test.go @@ -29,7 +29,7 @@ func TestGitHubReleaseChecker_checkNewVersion(t *testing.T) { version: "v0.2.0", release: &Release{ TagName: "v0.3.0", - TargetCommitish: nil, + TargetCommitish: "", }, }, wantNew: true, @@ -43,7 +43,7 @@ func TestGitHubReleaseChecker_checkNewVersion(t *testing.T) { version: "v0.2.0", release: &Release{ TagName: "v0.2.0", - TargetCommitish: nil, + TargetCommitish: "", }, }, wantNew: false, @@ -57,7 +57,7 @@ func TestGitHubReleaseChecker_checkNewVersion(t *testing.T) { version: "v0.3.0", release: &Release{ TagName: "v0.2.0", - TargetCommitish: nil, + TargetCommitish: "", }, }, wantNew: false, @@ -71,7 +71,7 @@ func TestGitHubReleaseChecker_checkNewVersion(t *testing.T) { version: "v0.3.0", release: &Release{ TagName: "v0.3.0-rc1", - TargetCommitish: nil, + TargetCommitish: "", }, }, wantNew: false, @@ -85,7 +85,7 @@ func TestGitHubReleaseChecker_checkNewVersion(t *testing.T) { version: "v0.3.0-RC1", release: &Release{ TagName: "v0.3.0-RC2", - TargetCommitish: nil, + TargetCommitish: "", }, }, wantNew: true, diff --git a/web/src/api/APIClient.ts b/web/src/api/APIClient.ts index 644ba5a..8dd5955 100644 --- a/web/src/api/APIClient.ts +++ b/web/src/api/APIClient.ts @@ -1,5 +1,6 @@ import { baseUrl, sseBaseUrl } from "../utils"; import { AuthContext } from "../utils/Context"; +import { GithubRelease } from "../types/Update"; interface ConfigType { body?: BodyInit | Record | unknown; @@ -80,7 +81,8 @@ export const APIClient = { delete: (key: string) => appClient.Delete(`api/keys/${key}`) }, config: { - get: () => appClient.Get("api/config") + get: () => appClient.Get("api/config"), + update: (config: ConfigUpdate) => appClient.Patch("api/config", config) }, download_clients: { getAll: () => appClient.Get("api/download_clients"), @@ -180,5 +182,9 @@ export const APIClient = { indexerOptions: () => appClient.Get("api/release/indexers"), stats: () => appClient.Get("api/release/stats"), delete: () => appClient.Delete("api/release/all") + }, + updates: { + check: () => appClient.Get("api/updates/check"), + getLatestRelease: () => appClient.Get("api/updates/latest") } }; diff --git a/web/src/screens/Base.tsx b/web/src/screens/Base.tsx index 95b4ce0..5256786 100644 --- a/web/src/screens/Base.tsx +++ b/web/src/screens/Base.tsx @@ -2,11 +2,13 @@ import { Fragment } from "react"; import { Link, NavLink, Outlet } from "react-router-dom"; import { Disclosure, Menu, Transition } from "@headlessui/react"; import { BookOpenIcon, UserIcon } from "@heroicons/react/24/solid"; -import { Bars3Icon, XMarkIcon } from "@heroicons/react/24/outline"; +import { Bars3Icon, XMarkIcon, MegaphoneIcon } from "@heroicons/react/24/outline"; import { AuthContext } from "../utils/Context"; import logo from "../logo.png"; +import { useQuery } from "react-query"; +import { APIClient } from "../api/APIClient"; interface NavItem { name: string; @@ -27,6 +29,17 @@ export default function Base() { { name: "Logs", path: "/logs" } ]; + + const { data } = useQuery( + ["updates"], + () => APIClient.updates.getLatestRelease(), + { + retry: false, + refetchOnWindowFocus: false, + onError: err => console.log(err) + } + ); + return (
+ + {data && data.html_url && ( + +
+ + New update available! + {data?.name} +
+
+ )} diff --git a/web/src/screens/settings/Application.tsx b/web/src/screens/settings/Application.tsx index 6dee184..6d5bca0 100644 --- a/web/src/screens/settings/Application.tsx +++ b/web/src/screens/settings/Application.tsx @@ -1,12 +1,17 @@ -import { useQuery } from "react-query"; +import { useMutation, useQuery } from "react-query"; import { APIClient } from "../../api/APIClient"; import { Checkbox } from "../../components/Checkbox"; import { SettingsContext } from "../../utils/Context"; +import { GithubRelease } from "../../types/Update"; +import { toast } from "react-hot-toast"; +import Toast from "../../components/notifications/Toast"; +import { queryClient } from "../../App"; interface RowItemProps { label: string; value?: string; title?: string; + newUpdate?: GithubRelease; } const RowItem = ({ label, value, title }: RowItemProps) => { @@ -23,6 +28,25 @@ const RowItem = ({ label, value, title }: RowItemProps) => { ); }; +const RowItemVersion = ({ label, value, title, newUpdate }: RowItemProps) => { + if (!value) + return null; + + return ( +
+
{label}:
+
+ {value} + {newUpdate && newUpdate.html_url && ( + + {newUpdate.name} available! + + )} +
+
+ ); +}; + function ApplicationSettings() { const [settings, setSettings] = SettingsContext.use(); @@ -36,6 +60,38 @@ function ApplicationSettings() { } ); + const { data: updateData } = useQuery( + ["updates"], + () => APIClient.updates.getLatestRelease(), + { + retry: false, + refetchOnWindowFocus: false, + onError: err => console.log(err) + } + ); + + const checkUpdateMutation = useMutation( + () => APIClient.updates.check(), + { + onSuccess: () => { + queryClient.invalidateQueries(["updates"]); + } + } + ); + + const toggleCheckUpdateMutation = useMutation( + (value: boolean) => APIClient.config.update({ check_for_updates: value }), + { + onSuccess: () => { + toast.custom((t) => ); + + queryClient.invalidateQueries(["config"]); + + checkUpdateMutation.mutate(); + } + } + ); + return (
@@ -98,7 +154,7 @@ function ApplicationSettings() {
- + @@ -117,6 +173,16 @@ function ApplicationSettings() { })} />
+
+ { + toggleCheckUpdateMutation.mutate(newValue); + }} + /> +
( interface SettingsType { debug: boolean; + checkForUpdates: boolean; darkTheme: boolean; scrollOnNewLog: boolean; indentLogLines: boolean; @@ -54,6 +55,7 @@ interface SettingsType { export const SettingsContext = newRidgeState( { debug: false, + checkForUpdates: true, darkTheme: true, scrollOnNewLog: false, indentLogLines: false,