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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ The server can be configured via environment variables or `.env` file:
* `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`).
* `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).
* `REDIS_URL`: Redis connection string for caching (optional).
* `LOG_LEVEL`: Logging verbosity: `DEBUG`, `INFO`, `WARN`, `ERROR` (default: `INFO`).
Expand Down
1 change: 1 addition & 0 deletions internal/apps/apps.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ type AppMetadata struct {
Manifest

// Fields populated by logic
InstallCount *int64 // nil when no count is known
Path string
IsInstalled bool
Date string
Expand Down
5 changes: 5 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
)

type Settings struct {
NibletCloudURL string `env:"NIBLET_CLOUD_URL" envDefault:"https://cloud.heyniblet.com"`
DBDSN string `env:"DB_DSN" envDefault:"data/tronbyt.db"`
DataDir string `env:"DATA_DIR" envDefault:"data"`
Production bool `env:"PRODUCTION" envDefault:"true"`
Expand Down Expand Up @@ -77,5 +78,9 @@ func LoadSettings() (*Settings, error) {
return nil, err
}

// An explicitly empty URL disables Niblet network activity.
if value, set := os.LookupEnv("NIBLET_CLOUD_URL"); set {
cfg.NibletCloudURL = value
}
return &cfg, nil
}
8 changes: 8 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,11 @@ func TestLoadSettings(t *testing.T) {
require.Equal(t, "testdata", cfg.DataDir)
require.Equal(t, "groups roles", cfg.OIDCAdditionalScopes)
}

// envDefault must not defeat an explicit network opt-out.
func TestNibletURLCanBeDisabled(t *testing.T) {
t.Setenv("NIBLET_CLOUD_URL", "")
cfg, err := LoadSettings()
require.NoError(t, err)
require.Empty(t, cfg.NibletCloudURL)
}
37 changes: 37 additions & 0 deletions internal/server/funcmap.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ func getFuncMap() template.FuncMap {
"webauthn_icon": tmplWebAuthnIcon,
"installationID": tmplInstallationID,
"panelAspect": tmplPanelAspect,
"thousands": tmplThousands,
"installCount": tmplInstallCount,
}
}

Expand Down Expand Up @@ -292,3 +294,38 @@ func tmplWebAuthnIcon(authenticator string, dark bool) template.URL {
}
return template.URL(metadata.IconLight)
}

// tmplThousands formats a count with comma separators, e.g. 12345 -> "12,345".
func tmplThousands(value any) string {
var n int64
switch v := value.(type) {
case int64:
n = v
case *int64:
if v == nil {
return ""
}
n = *v
case int:
n = int64(v)
default:
return fmt.Sprint(value)
}
sign := ""
if n < 0 {
sign, n = "-", -n
}
digits := fmt.Sprint(n)
for i := len(digits) - 3; i > 0; i -= 3 {
digits = digits[:i] + "," + digits[i:]
}
return sign + digits
}

// tmplInstallCount formats an install count for display, showing a dash for zero.
func tmplInstallCount(value any) string {
if label := tmplThousands(value); label != "0" {
return label
}
return "–"
}
8 changes: 8 additions & 0 deletions internal/server/handlers_app.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@ func (s *Server) handleAddAppGet(w http.ResponseWriter, r *http.Request) {
device := GetDevice(r)

systemApps := s.ListSystemApps()
// Use cached install counts; browsing never calls Niblet Cloud.
s.systemAppsCacheMutex.RLock()
for i := range systemApps {
if count, ok := s.installCounts[systemApps[i].ID]; ok {
systemApps[i].InstallCount = &count
}
}
s.systemAppsCacheMutex.RUnlock()
customApps := apps.ListUserApps(s.DataDir, user.Username)

s.markInstalledApps(device, systemApps, customApps)
Expand Down
123 changes: 123 additions & 0 deletions internal/server/install_counts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package server

// Install counts for system apps are fetched from Niblet Cloud's public
// app-install-counts.v1 endpoint and cached locally.

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
)

type nibletCountResponse struct {
ProtocolVersion string `json:"protocol_version"`
Niblet map[string]int64 `json:"niblet"`
}

func (s *Server) fetchNibletCounts(ctx context.Context) ([]byte, error) {
base, err := url.Parse(s.Config.NibletCloudURL)
if err != nil || base == nil || base.Host == "" || base.User != nil || base.RawQuery != "" || base.Fragment != "" ||
(base.Scheme != "https" && (base.Scheme != "http" || (base.Hostname() != "127.0.0.1" && base.Hostname() != "localhost" && base.Hostname() != "::1"))) {
return nil, errors.New("niblet cloud requires HTTPS (HTTP is allowed only on loopback)")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(base.String(), "/")+"/v1/catalog/install-counts", nil)
if err != nil {
return nil, err
}
client := &http.Client{Timeout: 10 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
res, err := client.Do(req)
if err != nil {
return nil, errors.New("niblet cloud request failed")
}
defer func() {
if err := res.Body.Close(); err != nil {
slog.Debug("Failed to close Niblet Cloud response", "error", err)
}
}()
if res.StatusCode < 200 || res.StatusCode >= 300 {
return nil, fmt.Errorf("niblet cloud returned HTTP %d", res.StatusCode)
}
payload, err := io.ReadAll(io.LimitReader(res.Body, 1024*1024+1))
if len(payload) > 1024*1024 {
return nil, errors.New("niblet response too large")
}
return payload, err
}

func (s *Server) runNibletSync() {
// Existing server background jobs live for the process lifetime.
ticker := time.NewTicker(time.Hour)
defer ticker.Stop()
for {
if err := s.syncNiblet(context.Background()); err != nil {
slog.Warn("Niblet install counts sync deferred", "error", err)
}
<-ticker.C
}
}

func parseNibletCounts(payload []byte) (map[string]int64, error) {
var counts nibletCountResponse
if err := json.Unmarshal(payload, &counts); err != nil {
return nil, err
}
if counts.ProtocolVersion != "app-install-counts.v1" || counts.Niblet == nil || len(counts.Niblet) > 5000 {
return nil, errors.New("invalid niblet count response")
}
for _, count := range counts.Niblet {
if count < 0 {
return nil, errors.New("invalid niblet count")
}
}
return counts.Niblet, nil
}

func (s *Server) setNibletCounts(counts map[string]int64) {
s.systemAppsCacheMutex.Lock()
s.installCounts = counts
s.systemAppsCacheMutex.Unlock()
}

// syncNiblet refreshes counts at most every 24 hours, keeps the last good
// counts across restarts, and backs off for an hour after failures.
func (s *Server) syncNiblet(ctx context.Context) error {
cached, err := s.getSetting("niblet_counts")
if err != nil {
return err
}
if counts, err := parseNibletCounts([]byte(cached)); err == nil {
s.setNibletCounts(counts)
}
next, err := s.getSetting("niblet_next_sync")
if err != nil {
return err
}
due, _ := time.Parse(time.RFC3339, next)
if time.Now().Before(due) {
return nil
}
// Persist backoff before networking so restarts cannot spam Cloud.
if err := s.setSetting("niblet_next_sync", time.Now().Add(time.Hour).UTC().Format(time.RFC3339)); err != nil {
return err
}
payload, err := s.fetchNibletCounts(ctx)
if err != nil {
return err
}
counts, err := parseNibletCounts(payload)
if err != nil {
return err
}
if err := s.setSetting("niblet_counts", string(payload)); err != nil {
return err
}
s.setNibletCounts(counts)
return s.setSetting("niblet_next_sync", time.Now().Add(24*time.Hour).UTC().Format(time.RFC3339))
}
109 changes: 109 additions & 0 deletions internal/server/install_counts_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package server

import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/nicksnyder/go-i18n/v2/i18n"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"tronbyt-server/internal/apps"
)

func TestNibletSyncCachesForADay(t *testing.T) {
s := newTestServer(t)
calls := 0
cloud := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
assert.Equal(t, "GET", r.Method)
assert.Equal(t, "/v1/catalog/install-counts", r.URL.Path)
assert.Empty(t, r.Header.Get("Authorization"))
_, _ = w.Write([]byte(`{"protocol_version":"app-install-counts.v1","niblet":{"clock":42}}`))
}))
defer cloud.Close()
s.Config.NibletCloudURL = cloud.URL
require.NoError(t, s.syncNiblet(context.Background()))
require.NoError(t, s.syncNiblet(context.Background()))
assert.Equal(t, 1, calls, "cached counts must not trigger another request")
assert.Equal(t, int64(42), s.installCounts["clock"])
next, err := s.getSetting("niblet_next_sync")
require.NoError(t, err)
due, err := time.Parse(time.RFC3339, next)
require.NoError(t, err)
assert.InDelta(t, float64(24*time.Hour), float64(time.Until(due)), float64(2*time.Second))

// A restarted server loads the persisted counts without calling Cloud.
s.installCounts = nil
require.NoError(t, s.syncNiblet(context.Background()))
assert.Equal(t, 1, calls)
assert.Equal(t, int64(42), s.installCounts["clock"])
}

func TestNibletRetryBackoffAndCacheReplacement(t *testing.T) {
s := newTestServer(t)
calls := 0
status := 200
payload := `{"protocol_version":"app-install-counts.v1","niblet":{"clock":42}}`
cloud := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
w.WriteHeader(status)
_, _ = w.Write([]byte(payload))
}))
defer cloud.Close()
s.Config.NibletCloudURL = cloud.URL
require.NoError(t, s.syncNiblet(context.Background()))
payload = `{"protocol_version":"app-install-counts.v1","niblet":{"weather":0}}`
require.NoError(t, s.setSetting("niblet_next_sync", ""))
require.NoError(t, s.syncNiblet(context.Background()))
assert.Equal(t, map[string]int64{"weather": 0}, s.installCounts, "removed IDs must disappear")
status = 503
require.NoError(t, s.setSetting("niblet_next_sync", ""))
require.Error(t, s.syncNiblet(context.Background()))
before := calls
require.NoError(t, s.syncNiblet(context.Background()))
assert.Equal(t, before, calls, "failures must respect the persistent hourly backoff")
assert.Equal(t, map[string]int64{"weather": 0}, s.installCounts, "failures keep the last good counts")
}

func TestNibletRejectsRemotePlainHTTP(t *testing.T) {
s := newTestServer(t)
s.Config.NibletCloudURL = "http://cloud.example.com"
_, err := s.fetchNibletCounts(context.Background())
require.Error(t, err)
}

func TestNibletCountCardRendersFullNumbers(t *testing.T) {
s := newTestServer(t)
for count, want := range map[int64]string{0: "–", 433: "433", 12345: "12,345", 1234567: "1,234,567"} {
var output bytes.Buffer
err := s.BaseTemplates.ExecuteTemplate(&output, "app_card_grid_item", map[string]any{
"App": apps.AppMetadata{Manifest: apps.Manifest{ID: "clock", Name: "Clock"}, InstallCount: &count},
"Localizer": i18n.NewLocalizer(s.Bundle, "en"),
"IsCustom": false, "ConfigProduction": true, "DeviceID": "test",
})
require.NoError(t, err)
assert.Contains(t, output.String(), "<span>"+want+"</span>")
label := want
if count == 0 {
label = "0" // Screen readers hear the number, not a dash.
}
assert.Contains(t, output.String(), `aria-label="Number of installs: `+label+`"`)
assert.NotContains(t, output.String(), "Niblet")
}
}

func TestNibletCountCardHiddenWithoutCount(t *testing.T) {
s := newTestServer(t)
var output bytes.Buffer
err := s.BaseTemplates.ExecuteTemplate(&output, "app_card_grid_item", map[string]any{
"App": apps.AppMetadata{Manifest: apps.Manifest{ID: "custom", Name: "Custom"}},
"Localizer": i18n.NewLocalizer(s.Bundle, "en"),
"IsCustom": true, "ConfigProduction": true, "DeviceID": "test",
})
require.NoError(t, err)
assert.NotContains(t, output.String(), "app-item-installs")
}
4 changes: 4 additions & 0 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import (
)

type Server struct {
installCounts map[string]int64 // public app ID -> Niblet install count
DB *gorm.DB
Router *http.ServeMux
DataDir string
Expand Down Expand Up @@ -245,6 +246,9 @@ func NewServer(db *gorm.DB, cfg *config.Settings) *Server {
go s.checkForUpdates(context.Background())
go s.autoRefreshSystemRepo()
go s.autoRefreshCustomAppsRepos()
if cfg.NibletCloudURL != "" {
go s.runNibletSync()
}

s.routes()
return s
Expand Down
6 changes: 6 additions & 0 deletions web/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -2164,5 +2164,11 @@
},
"You are accessing this server via localhost. For proper firmware generation you should use:": {
"other": "Sie greifen über localhost auf diesen Server zu. Für eine korrekte Firmware-Generierung sollten Sie Folgendes verwenden:"
},
"Number of installs": {
"other": "Anzahl der Installationen"
},
"Popularity": {
"other": "Beliebtheit"
}
}
6 changes: 6 additions & 0 deletions web/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2167,5 +2167,11 @@
},
"You are accessing this server via localhost. For proper firmware generation you should use:": {
"other": "You are accessing this server via localhost. For proper firmware generation you should use:"
},
"Number of installs": {
"other": "Number of installs"
},
"Popularity": {
"other": "Popularity"
}
}
Loading
Loading