From 322ede0e503aebafc78e52193aa9eb9abda93762 Mon Sep 17 00:00:00 2001 From: Olly Stedall <642592+saltedlolly@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:15:09 +0100 Subject: [PATCH 1/2] feat: configure system app automatic updates in UI --- README.md | 2 +- internal/server/auth.go | 13 ++-- internal/server/handlers_system.go | 44 ++++++++---- internal/server/handlers_system_test.go | 53 +++++++++++++++ internal/server/handlers_user.go | 31 +++++++++ internal/server/handlers_user_test.go | 89 +++++++++++++++++++++++++ internal/server/helpers.go | 9 +-- internal/server/server.go | 11 ++- internal/server/server_test.go | 29 +++++++- web/i18n/de.json | 21 ++++++ web/i18n/en.json | 21 ++++++ web/static/css/settings-framework.css | 5 +- web/templates/settings/content.html | 85 +++++++++++++++++++++++ 13 files changed, 385 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index a4baa06c..c85722a6 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ The server can be configured via environment variables or `.env` file: * `MAX_USERS`: Maximum number of user accounts (default: `0` / unlimited). * `SINGLE_USER_AUTO_LOGIN`: Skip login when only one user exists (default: `false`). * `SYSTEM_APPS_REPO`: Git repository URL for system apps (default: `https://github.com/tronbyt/apps.git`). -* `SYSTEM_APPS_AUTO_REFRESH`: Automatically refresh the system apps repository (default: `false`). +* `SYSTEM_APPS_AUTO_REFRESH`: Initial default for refreshing the system apps repository every 12 hours (default: `false`). Administrators can change this under **Settings → Content and Firmware**; a saved web preference takes precedence on later starts. * `CUSTOM_APPS_AUTO_REFRESH`: Automatically refresh every user's custom apps repository, every 12h (default: `false`). * `NIBLET_CLOUD_URL`: Source of the install counts shown on system app cards, refreshed at most daily (default: `https://cloud.heyniblet.com`). Set to an empty string to disable. * `GITHUB_TOKEN`: GitHub token for private app repositories (optional). diff --git a/internal/server/auth.go b/internal/server/auth.go index afc8eeda..671a7733 100644 --- a/internal/server/auth.go +++ b/internal/server/auth.go @@ -62,12 +62,13 @@ func (s *Server) getSettingsContentData(user *data.User) TemplateData { } return TemplateData{ - User: user, - FirmwareVersion: firmwareVersion, - SystemRepoInfo: systemRepoInfo, - UserRepoInfo: userRepoInfo, - GlobalSystemRepoURL: s.Config.SystemAppsRepo, - SettingsSection: "content", + User: user, + FirmwareVersion: firmwareVersion, + SystemRepoInfo: systemRepoInfo, + UserRepoInfo: userRepoInfo, + GlobalSystemRepoURL: s.Config.SystemAppsRepo, + SystemAppsAutoRefresh: s.isSystemAppsAutoRefreshEnabled(), + SettingsSection: "content", } } diff --git a/internal/server/handlers_system.go b/internal/server/handlers_system.go index 5047f2b8..2ccea01e 100644 --- a/internal/server/handlers_system.go +++ b/internal/server/handlers_system.go @@ -63,27 +63,43 @@ func (s *Server) checkForUpdates(ctx context.Context) { } } -func (s *Server) autoRefreshSystemRepo() { - if !s.Config.SystemAppsAutoRefresh { - return - } - - slog.Info("Scheduled system apps auto-refresh enabled (every 12h)") +const ( + systemAppsAutoRefreshInterval = 12 * time.Hour + systemAppsAutoRefreshSettingKey = "system_apps_auto_refresh" +) - ticker := time.NewTicker(12 * time.Hour) +func (s *Server) autoRefreshSystemRepo(ctx context.Context) { + ticker := time.NewTicker(systemAppsAutoRefreshInterval) defer ticker.Stop() - for range ticker.C { - if !s.Config.SystemAppsAutoRefresh { - slog.Info("System apps auto-refresh disabled, stopping ticker") + s.runSystemAppsAutoRefresh(ctx, ticker.C, s.refreshSystemRepo) +} + +func (s *Server) runSystemAppsAutoRefresh(ctx context.Context, ticks <-chan time.Time, refresh func() error) { + for { + select { + case <-ctx.Done(): return + case <-ticks: + s.refreshSystemAppsIfEnabled(refresh) } - slog.Info("Performing scheduled system apps refresh") - if err := s.refreshSystemRepo(); err != nil { - slog.Error("Scheduled refresh of system repo failed", "error", err) - } } } +func (s *Server) refreshSystemAppsIfEnabled(refresh func() error) { + if !s.systemAppsAutoRefresh.Load() { + return + } + + slog.Info("Performing scheduled system apps refresh") + if err := refresh(); err != nil { + slog.Error("Scheduled refresh of system repo failed", "error", err) + } +} + +func (s *Server) isSystemAppsAutoRefreshEnabled() bool { + return s.systemAppsAutoRefresh.Load() +} + func (s *Server) refreshSystemRepo() error { return s.withSystemAppsRefresh(func() error { repoURL := s.Config.SystemAppsRepo diff --git a/internal/server/handlers_system_test.go b/internal/server/handlers_system_test.go index 1533d900..84f84398 100644 --- a/internal/server/handlers_system_test.go +++ b/internal/server/handlers_system_test.go @@ -1,6 +1,7 @@ package server import ( + "context" "errors" "net/http" "net/http/httptest" @@ -85,3 +86,55 @@ func TestWithSystemAppsRefreshReturnsRefreshError(t *testing.T) { require.ErrorIs(t, err, want) } + +func TestRefreshSystemAppsIfEnabledFollowsRuntimePreference(t *testing.T) { + s := newTestServer(t) + var refreshes atomic.Int32 + refresh := func() error { + refreshes.Add(1) + return nil + } + + s.refreshSystemAppsIfEnabled(refresh) + require.Equal(t, int32(0), refreshes.Load()) + + s.systemAppsAutoRefresh.Store(true) + s.refreshSystemAppsIfEnabled(refresh) + require.Equal(t, int32(1), refreshes.Load()) + + s.systemAppsAutoRefresh.Store(false) + s.refreshSystemAppsIfEnabled(refresh) + require.Equal(t, int32(1), refreshes.Load()) +} + +func TestRunSystemAppsAutoRefreshRefreshesOnTick(t *testing.T) { + s := newTestServer(t, withSystemAppsAutoRefresh(true)) + ctx, cancel := context.WithCancel(context.Background()) + ticks := make(chan time.Time) + refreshed := make(chan struct{}, 1) + done := make(chan struct{}) + + go func() { + defer close(done) + s.runSystemAppsAutoRefresh(ctx, ticks, func() error { + refreshed <- struct{}{} + return nil + }) + }() + + require.Equal(t, 12*time.Hour, systemAppsAutoRefreshInterval) + ticks <- time.Now() + + select { + case <-refreshed: + case <-time.After(time.Second): + t.Fatal("scheduled system apps refresh did not run after a tick") + } + + cancel() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("scheduled system apps refresh did not stop after cancellation") + } +} diff --git a/internal/server/handlers_user.go b/internal/server/handlers_user.go index 9c34169a..64f9a0d2 100644 --- a/internal/server/handlers_user.go +++ b/internal/server/handlers_user.go @@ -9,6 +9,7 @@ import ( "net/http" "os" "path/filepath" + "strconv" "strings" "time" @@ -521,3 +522,33 @@ func (s *Server) handleRefreshSystemRepo(w http.ResponseWriter, r *http.Request) http.Redirect(w, r, "/settings/content", http.StatusSeeOther) } + +func (s *Server) handleSetSystemAppsAutoRefresh(w http.ResponseWriter, r *http.Request) { + user := GetUser(r) + if !user.IsAdmin { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + wantsJSON := r.Header.Get("Accept") == "application/json" + enabled := r.FormValue("system_apps_auto_refresh") == "1" + if err := s.setSetting(systemAppsAutoRefreshSettingKey, strconv.FormatBool(enabled)); err != nil { + slog.Error("Failed to save system apps auto-refresh setting", "error", err) + if wantsJSON { + http.Error(w, "Failed to save automatic update preference", http.StatusInternalServerError) + return + } + s.flashAndRedirect(w, r, "Failed to save automatic update preference.", "/settings/content", http.StatusSeeOther) + return + } + + s.systemAppsAutoRefresh.Store(enabled) + if wantsJSON { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(map[string]bool{"enabled": enabled}); err != nil { + slog.Error("Failed to encode system apps auto-refresh response", "error", err) + } + return + } + s.flashAndRedirect(w, r, "Automatic update preference saved.", "/settings/content", http.StatusSeeOther) +} diff --git a/internal/server/handlers_user_test.go b/internal/server/handlers_user_test.go index d25293b2..59b893b2 100644 --- a/internal/server/handlers_user_test.go +++ b/internal/server/handlers_user_test.go @@ -59,6 +59,95 @@ func TestHandleRefreshSystemRepoReturnsJSONError(t *testing.T) { require.Contains(t, rr.Body.String(), "Failed to refresh system repository") } +func TestHandleSetSystemAppsAutoRefresh(t *testing.T) { + s := newTestServer(t) + admin := data.User{Username: "admin", IsAdmin: true} + + form := url.Values{"system_apps_auto_refresh": {"1"}} + req := httptest.NewRequest(http.MethodPost, "/settings/system-apps-auto-refresh", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + req = req.WithContext(context.WithValue(req.Context(), userContextKey, &admin)) + rr := httptest.NewRecorder() + + s.handleSetSystemAppsAutoRefresh(rr, req) + + require.Equal(t, http.StatusOK, rr.Code) + require.JSONEq(t, `{"enabled":true}`, rr.Body.String()) + require.True(t, s.isSystemAppsAutoRefreshEnabled()) + stored, err := s.getSetting(systemAppsAutoRefreshSettingKey) + require.NoError(t, err) + require.Equal(t, "true", stored) + + req = httptest.NewRequest(http.MethodPost, "/settings/system-apps-auto-refresh", nil) + req = req.WithContext(context.WithValue(req.Context(), userContextKey, &admin)) + rr = httptest.NewRecorder() + s.handleSetSystemAppsAutoRefresh(rr, req) + + require.Equal(t, http.StatusSeeOther, rr.Code) + require.False(t, s.isSystemAppsAutoRefreshEnabled()) + stored, err = s.getSetting(systemAppsAutoRefreshSettingKey) + require.NoError(t, err) + require.Equal(t, "false", stored) +} + +func TestHandleSetSystemAppsAutoRefreshRequiresAdmin(t *testing.T) { + s := newTestServer(t) + user := data.User{Username: "user"} + + form := url.Values{"system_apps_auto_refresh": {"1"}} + req := httptest.NewRequest(http.MethodPost, "/settings/system-apps-auto-refresh", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req = req.WithContext(context.WithValue(req.Context(), userContextKey, &user)) + rr := httptest.NewRecorder() + + s.handleSetSystemAppsAutoRefresh(rr, req) + + require.Equal(t, http.StatusForbidden, rr.Code) + require.False(t, s.isSystemAppsAutoRefreshEnabled()) + stored, err := s.getSetting(systemAppsAutoRefreshSettingKey) + require.NoError(t, err) + require.Empty(t, stored) +} + +func TestSettingsContentDataIncludesSystemAppsAutoRefresh(t *testing.T) { + s := newTestServer(t, withSystemAppsAutoRefresh(true)) + admin := data.User{Username: "admin", IsAdmin: true} + + pageData := s.getSettingsContentData(&admin) + + require.True(t, pageData.SystemAppsAutoRefresh) +} + +func TestSettingsContentRendersSystemAppsAutoRefreshControl(t *testing.T) { + s := newTestServer(t, withSystemAppsAutoRefresh(true)) + admin := data.User{Username: "admin", IsAdmin: true, APIKey: "admin-api-key"} + require.NoError(t, s.DB.Create(&admin).Error) + + seedReq := httptest.NewRequest(http.MethodGet, "/settings/content", nil) + seedRR := httptest.NewRecorder() + session, _ := s.Store.Get(seedReq, "session-name") + session.Values["username"] = admin.Username + require.NoError(t, s.saveSession(seedRR, seedReq, session)) + + req := httptest.NewRequest(http.MethodGet, "/settings/content", nil) + for _, cookie := range seedRR.Result().Cookies() { + req.AddCookie(cookie) + } + rr := httptest.NewRecorder() + + s.ServeHTTP(rr, req) + + require.Equal(t, http.StatusOK, rr.Code) + require.Contains(t, rr.Body.String(), `id="system_apps_auto_refresh"`) + require.Contains(t, rr.Body.String(), "Automatically update system apps every 12 hours") + require.Contains(t, rr.Body.String(), "Automatic updates may change or break apps already installed on your devices.") + require.Contains(t, rr.Body.String(), "checked") + require.Contains(t, rr.Body.String(), `onchange="saveSystemAppsAutoRefresh(this)"`) + require.Contains(t, rr.Body.String(), `id="system-apps-auto-refresh-status"`) + require.Contains(t, rr.Body.String(), `action="/refresh_system_repo"`) +} + func TestHandleEditUserPostUpdatesEmail(t *testing.T) { s := newTestServer(t) diff --git a/internal/server/helpers.go b/internal/server/helpers.go index 84dad00f..ce4ea13a 100644 --- a/internal/server/helpers.go +++ b/internal/server/helpers.go @@ -84,9 +84,10 @@ type TemplateData struct { Form CreateDeviceFormData // Repo Info for Admin/User Settings - SystemRepoInfo *gitutils.RepoInfo - UserRepoInfo *gitutils.RepoInfo - GlobalSystemRepoURL string + SystemRepoInfo *gitutils.RepoInfo + UserRepoInfo *gitutils.RepoInfo + GlobalSystemRepoURL string + SystemAppsAutoRefresh bool // App Config App *data.App @@ -155,7 +156,7 @@ func (s *Server) renderTemplate(w http.ResponseWriter, r *http.Request, name str tmplData.Config = &config.TemplateConfig{ EnableUserRegistration: s.Config.EnableUserRegistration, SingleUserAutoLogin: s.Config.SingleUserAutoLogin, - SystemAppsAutoRefresh: s.Config.SystemAppsAutoRefresh, + SystemAppsAutoRefresh: s.isSystemAppsAutoRefreshEnabled(), Production: s.Config.Production, } } diff --git a/internal/server/server.go b/internal/server/server.go index 82c5c0aa..b7e69f46 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -17,6 +17,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "time" "tronbyt-server/internal/apps" @@ -54,6 +55,7 @@ type Server struct { systemAppsCache []apps.AppMetadata systemAppsCacheMutex sync.RWMutex systemAppsRefreshMutex sync.Mutex + systemAppsAutoRefresh atomic.Bool // SchemaCache, when set, allows forcing a one-shot refetch of an app's // cached HTTP responses so dynamic schema data (e.g. dropdown options @@ -134,6 +136,12 @@ func NewServer(db *gorm.DB, cfg *config.Settings) *Server { if err == nil && repo != "" { cfg.SystemAppsRepo = repo } + if val, err := s.getSetting(systemAppsAutoRefreshSettingKey); err != nil { + slog.Warn("Failed to load system apps auto-refresh setting", "error", err) + } else if val != "" { + cfg.SystemAppsAutoRefresh = val == "true" + } + s.systemAppsAutoRefresh.Store(cfg.SystemAppsAutoRefresh) // Load OIDC settings from DB if val, err := s.getSetting("oidc_enabled"); err == nil { @@ -245,7 +253,7 @@ func NewServer(db *gorm.DB, cfg *config.Settings) *Server { } go s.checkForUpdates(context.Background()) - go s.autoRefreshSystemRepo() + go s.autoRefreshSystemRepo(context.Background()) go s.autoRefreshCustomAppsRepos() if cfg.NibletCloudURL != "" { go s.runNibletSync() @@ -340,6 +348,7 @@ func (s *Server) routes() { s.Router.HandleFunc("POST /set_system_repo", s.RequireLogin(s.handleSetSystemRepo)) s.Router.HandleFunc("POST /refresh_system_repo", s.RequireLogin(s.handleRefreshSystemRepo)) + s.Router.HandleFunc("POST /settings/system-apps-auto-refresh", s.RequireLogin(s.handleSetSystemAppsAutoRefresh)) s.Router.HandleFunc("POST /update_firmware", s.RequireLogin(s.handleUpdateFirmware)) // App broken status (development only) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index d5adb5d5..408d2a22 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -24,6 +24,12 @@ func withPprof(value bool) option { } } +func withSystemAppsAutoRefresh(value bool) option { + return func(s *config.Settings) { + s.SystemAppsAutoRefresh = value + } +} + func newTestServer(t *testing.T, opts ...option) *Server { dbName := fmt.Sprintf("file:%s?mode=memory&cache=private&_busy_timeout=5000", t.Name()) db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{}) @@ -31,7 +37,7 @@ func newTestServer(t *testing.T, opts ...option) *Server { t.Fatalf("Failed to open DB: %v", err) } - if err := db.AutoMigrate(&data.User{}, &data.Device{}, &data.App{}, &data.WebAuthnCredential{}, &data.Setting{}); err != nil { + if err := db.AutoMigrate(&data.User{}, &data.Device{}, &data.App{}, &data.WebAuthnCredential{}, &data.OIDCIdentity{}, &data.Setting{}); err != nil { t.Fatalf("Failed to migrate DB: %v", err) } @@ -53,6 +59,27 @@ func newTestServer(t *testing.T, opts ...option) *Server { return s } +func TestSystemAppsAutoRefreshUsesEnvironmentDefault(t *testing.T) { + s := newTestServer(t, withSystemAppsAutoRefresh(true)) + + require.True(t, s.isSystemAppsAutoRefreshEnabled()) +} + +func TestSavedSystemAppsAutoRefreshOverridesEnvironmentDefault(t *testing.T) { + s := newTestServer(t) + require.NoError(t, s.setSetting(systemAppsAutoRefreshSettingKey, "false")) + + cfg := &config.Settings{ + DataDir: t.TempDir(), + Production: false, + EnableUpdateChecks: false, + SystemAppsAutoRefresh: true, + } + restarted := NewServer(s.DB, cfg) + + require.False(t, restarted.isSystemAppsAutoRefreshEnabled()) +} + func TestLoginRedirectToRegisterIfNoUsers(t *testing.T) { s := newTestServer(t) diff --git a/web/i18n/de.json b/web/i18n/de.json index c9c466d6..f2a1c89b 100644 --- a/web/i18n/de.json +++ b/web/i18n/de.json @@ -2170,5 +2170,26 @@ }, "Popularity": { "other": "Beliebtheit" + }, + "Automatic Updates": { + "other": "Automatische Updates" + }, + "Update Schedule": { + "other": "Aktualisierungszeitplan" + }, + "Automatically update system apps every 12 hours": { + "other": "System-Apps automatisch alle 12 Stunden aktualisieren" + }, + "Automatic updates may change or break apps already installed on your devices.": { + "other": "Automatische Updates können bereits auf Ihren Geräten installierte Apps verändern oder funktionsunfähig machen." + }, + "Failed to save automatic update preference.": { + "other": "Die Einstellung für automatische Updates konnte nicht gespeichert werden." + }, + "Automatic update preference saved.": { + "other": "Die Einstellung für automatische Updates wurde gespeichert." + }, + "Saving...": { + "other": "Wird gespeichert..." } } diff --git a/web/i18n/en.json b/web/i18n/en.json index 77fe32e4..df9cf769 100644 --- a/web/i18n/en.json +++ b/web/i18n/en.json @@ -2173,5 +2173,26 @@ }, "Popularity": { "other": "Popularity" + }, + "Automatic Updates": { + "other": "Automatic Updates" + }, + "Update Schedule": { + "other": "Update Schedule" + }, + "Automatically update system apps every 12 hours": { + "other": "Automatically update system apps every 12 hours" + }, + "Automatic updates may change or break apps already installed on your devices.": { + "other": "Automatic updates may change or break apps already installed on your devices." + }, + "Failed to save automatic update preference.": { + "other": "Failed to save automatic update preference." + }, + "Automatic update preference saved.": { + "other": "Automatic update preference saved." + }, + "Saving...": { + "other": "Saving..." } } diff --git a/web/static/css/settings-framework.css b/web/static/css/settings-framework.css index b9134dcb..e8d22b36 100644 --- a/web/static/css/settings-framework.css +++ b/web/static/css/settings-framework.css @@ -211,6 +211,10 @@ color: var(--danger-text); } +.settings-save-status:empty { + display: none; +} + .settings-grid-compact { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); @@ -339,4 +343,3 @@ align-self: stretch; } } - diff --git a/web/templates/settings/content.html b/web/templates/settings/content.html index 5032ec1f..ac328657 100644 --- a/web/templates/settings/content.html +++ b/web/templates/settings/content.html @@ -57,6 +57,45 @@