Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
13 changes: 7 additions & 6 deletions internal/server/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
}

Expand Down
44 changes: 30 additions & 14 deletions internal/server/handlers_system.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions internal/server/handlers_system_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package server

import (
"context"
"errors"
"net/http"
"net/http/httptest"
Expand Down Expand Up @@ -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")
}
}
41 changes: 41 additions & 0 deletions internal/server/handlers_user.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"

Expand Down Expand Up @@ -521,3 +522,43 @@ 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.setSystemAppsAutoRefresh(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
}

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)
}

func (s *Server) setSystemAppsAutoRefresh(enabled bool) error {
s.systemAppsAutoRefreshPreferenceMutex.Lock()
defer s.systemAppsAutoRefreshPreferenceMutex.Unlock()

if err := s.setSetting(systemAppsAutoRefreshSettingKey, strconv.FormatBool(enabled)); err != nil {
return err
}
s.systemAppsAutoRefresh.Store(enabled)
return nil
}
118 changes: 118 additions & 0 deletions internal/server/handlers_user_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"net/url"
"strings"
"testing"
"time"

"tronbyt-server/internal/data"

Expand Down Expand Up @@ -59,6 +60,123 @@ 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 TestSetSystemAppsAutoRefreshSerializesPreferenceTransitions(t *testing.T) {
s := newTestServer(t)
s.systemAppsAutoRefreshPreferenceMutex.Lock()
started := make(chan struct{})
result := make(chan error, 1)

go func() {
close(started)
result <- s.setSystemAppsAutoRefresh(true)
}()

<-started
select {
case err := <-result:
s.systemAppsAutoRefreshPreferenceMutex.Unlock()
require.NoError(t, err)
t.Fatal("preference transition completed while its serialization mutex was held")
case <-time.After(100 * time.Millisecond):
}

s.systemAppsAutoRefreshPreferenceMutex.Unlock()
require.NoError(t, <-result)
require.True(t, s.isSystemAppsAutoRefreshEnabled())
stored, err := s.getSetting(systemAppsAutoRefreshSettingKey)
require.NoError(t, err)
require.Equal(t, "true", 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)

Expand Down
9 changes: 5 additions & 4 deletions internal/server/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
}
}
Expand Down
Loading
Loading