diff --git a/.github/workflows/nginx.yml b/.github/workflows/nginx.yml
new file mode 100644
index 00000000..02585466
--- /dev/null
+++ b/.github/workflows/nginx.yml
@@ -0,0 +1,59 @@
+name: Nginx Config
+
+# Validates app/nginx.conf via scripts/validate-nginx.sh (single source of truth,
+# also run locally). `nginx -t` syntax errors block; gixy security findings are
+# advisory (surfaced as a warning), mirroring the Trivy pattern in docker.yml.
+
+on:
+ workflow_dispatch:
+ inputs:
+ reason:
+ description: 'Optional reason for manual run'
+ required: false
+ default: 'manual trigger'
+ push:
+ branches: ["main", "develop", "release/*"]
+ paths:
+ - 'app/nginx.conf'
+ - 'app/Dockerfile' # script auto-detects the pinned nginx image from here
+ - 'scripts/validate-nginx.sh'
+ - '.github/workflows/nginx.yml'
+ pull_request:
+ branches: ["main", "develop", "release/*"]
+ paths:
+ - 'app/nginx.conf'
+ - 'app/Dockerfile'
+ - 'scripts/validate-nginx.sh'
+ - '.github/workflows/nginx.yml'
+
+permissions: read-all
+
+jobs:
+ validate:
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ permissions:
+ contents: read
+ steps:
+ - name: Checkout
+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ with:
+ persist-credentials: false
+
+ # validate-nginx.sh exit codes:
+ # 0 ok | 1 syntax fail | 2 missing prereq | 3 gixy findings | 4 gixy could not run
+ # We block on syntax/prereq (1/2) and on gixy failing to run (4 — a scan that
+ # never executed must not pass silently). Only real gixy findings (3) are
+ # downgraded to an advisory warning, mirroring docker.yml's Trivy pattern.
+ - name: Validate nginx config (syntax blocking, gixy advisory)
+ run: |
+ set +e
+ ./scripts/validate-nginx.sh
+ code=$?
+ set -e
+ case "${code}" in
+ 0) echo "nginx config valid and clean" ;;
+ 3) echo "::warning title=gixy::nginx security findings (advisory) — see log above" ;;
+ 4) echo "::error title=gixy::gixy could not run — scan did not execute"; exit 1 ;;
+ *) exit "${code}" ;;
+ esac
diff --git a/.gitignore b/.gitignore
index 846982cd..ef97607e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,3 +11,4 @@ blocklists/.env
libs/vendor
tests/docker_logs
.qodo
+.nginx-validate.*
diff --git a/api/api/server.go b/api/api/server.go
index 80190362..92c1a6f4 100644
--- a/api/api/server.go
+++ b/api/api/server.go
@@ -6,6 +6,7 @@ import (
"time"
"github.com/gofiber/fiber/v2"
+ "github.com/gofiber/fiber/v2/middleware/compress"
"github.com/gofiber/fiber/v2/middleware/healthcheck"
"github.com/gofiber/fiber/v2/middleware/helmet"
"github.com/gofiber/fiber/v2/middleware/limiter"
@@ -69,6 +70,7 @@ func NewServer(config *config.Config, service service.Service, db db.Db, cache c
func (s *APIServer) setupMiddlewares() {
s.App.Use(middleware.SentryFiber())
s.App.Use(middleware.Recover())
+ s.App.Use(compress.New(compress.Config{Level: compress.LevelBestSpeed}))
s.App.Use(requestid.New())
s.App.Use(logger.New(logger.Config{
Next: func(c *fiber.Ctx) bool {
diff --git a/api/db/mongodb/migrations/018_profiles_account_id_index.down.json b/api/db/mongodb/migrations/018_profiles_account_id_index.down.json
new file mode 100644
index 00000000..656f6d83
--- /dev/null
+++ b/api/db/mongodb/migrations/018_profiles_account_id_index.down.json
@@ -0,0 +1,6 @@
+[
+ {
+ "dropIndexes": "profiles",
+ "index": "account_id"
+ }
+]
diff --git a/api/db/mongodb/migrations/018_profiles_account_id_index.up.json b/api/db/mongodb/migrations/018_profiles_account_id_index.up.json
new file mode 100644
index 00000000..e81da147
--- /dev/null
+++ b/api/db/mongodb/migrations/018_profiles_account_id_index.up.json
@@ -0,0 +1,12 @@
+[{
+ "createIndexes": "profiles",
+ "indexes": [
+ {
+ "key": {
+ "account_id": 1
+ },
+ "name": "account_id",
+ "background": true
+ }
+ ]
+}]
diff --git a/api/docs/docs.go b/api/docs/docs.go
index ba1e148b..b10c8b6d 100644
--- a/api/docs/docs.go
+++ b/api/docs/docs.go
@@ -2553,9 +2553,6 @@ const docTemplate = `{
"items": {
"type": "string"
}
- },
- "queries": {
- "type": "integer"
}
}
},
diff --git a/api/docs/swagger.json b/api/docs/swagger.json
index d0a6de98..f89adfe3 100644
--- a/api/docs/swagger.json
+++ b/api/docs/swagger.json
@@ -2545,9 +2545,6 @@
"items": {
"type": "string"
}
- },
- "queries": {
- "type": "integer"
}
}
},
diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml
index 3150a4de..8649022e 100644
--- a/api/docs/swagger.yaml
+++ b/api/docs/swagger.yaml
@@ -104,8 +104,6 @@ definitions:
items:
type: string
type: array
- queries:
- type: integer
type: object
model.AccountUpdate:
properties:
diff --git a/api/mocks/account_servicer.go b/api/mocks/account_servicer.go
index c579aaf2..811bae64 100644
--- a/api/mocks/account_servicer.go
+++ b/api/mocks/account_servicer.go
@@ -314,80 +314,6 @@ func (_c *AccountServicer_GetAccount_Call) RunAndReturn(run func(ctx context.Con
return _c
}
-// GetAccountMetrics provides a mock function for the type AccountServicer
-func (_mock *AccountServicer) GetAccountMetrics(ctx context.Context, account *model.Account, timespan string) (*model.StatisticsAggregated, error) {
- ret := _mock.Called(ctx, account, timespan)
-
- if len(ret) == 0 {
- panic("no return value specified for GetAccountMetrics")
- }
-
- var r0 *model.StatisticsAggregated
- var r1 error
- if returnFunc, ok := ret.Get(0).(func(context.Context, *model.Account, string) (*model.StatisticsAggregated, error)); ok {
- return returnFunc(ctx, account, timespan)
- }
- if returnFunc, ok := ret.Get(0).(func(context.Context, *model.Account, string) *model.StatisticsAggregated); ok {
- r0 = returnFunc(ctx, account, timespan)
- } else {
- if ret.Get(0) != nil {
- r0 = ret.Get(0).(*model.StatisticsAggregated)
- }
- }
- if returnFunc, ok := ret.Get(1).(func(context.Context, *model.Account, string) error); ok {
- r1 = returnFunc(ctx, account, timespan)
- } else {
- r1 = ret.Error(1)
- }
- return r0, r1
-}
-
-// AccountServicer_GetAccountMetrics_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAccountMetrics'
-type AccountServicer_GetAccountMetrics_Call struct {
- *mock.Call
-}
-
-// GetAccountMetrics is a helper method to define mock.On call
-// - ctx context.Context
-// - account *model.Account
-// - timespan string
-func (_e *AccountServicer_Expecter) GetAccountMetrics(ctx interface{}, account interface{}, timespan interface{}) *AccountServicer_GetAccountMetrics_Call {
- return &AccountServicer_GetAccountMetrics_Call{Call: _e.mock.On("GetAccountMetrics", ctx, account, timespan)}
-}
-
-func (_c *AccountServicer_GetAccountMetrics_Call) Run(run func(ctx context.Context, account *model.Account, timespan string)) *AccountServicer_GetAccountMetrics_Call {
- _c.Call.Run(func(args mock.Arguments) {
- var arg0 context.Context
- if args[0] != nil {
- arg0 = args[0].(context.Context)
- }
- var arg1 *model.Account
- if args[1] != nil {
- arg1 = args[1].(*model.Account)
- }
- var arg2 string
- if args[2] != nil {
- arg2 = args[2].(string)
- }
- run(
- arg0,
- arg1,
- arg2,
- )
- })
- return _c
-}
-
-func (_c *AccountServicer_GetAccountMetrics_Call) Return(statisticsAggregated *model.StatisticsAggregated, err error) *AccountServicer_GetAccountMetrics_Call {
- _c.Call.Return(statisticsAggregated, err)
- return _c
-}
-
-func (_c *AccountServicer_GetAccountMetrics_Call) RunAndReturn(run func(ctx context.Context, account *model.Account, timespan string) (*model.StatisticsAggregated, error)) *AccountServicer_GetAccountMetrics_Call {
- _c.Call.Return(run)
- return _c
-}
-
// GetUnfinishedSignupOrPostAccount provides a mock function for the type AccountServicer
func (_mock *AccountServicer) GetUnfinishedSignupOrPostAccount(ctx context.Context, email string, password string, subscriptionID string, sessionID string) (*model.Account, error) {
ret := _mock.Called(ctx, email, password, subscriptionID, sessionID)
diff --git a/api/mocks/servicer.go b/api/mocks/servicer.go
index 0045f93b..a7bd35fa 100644
--- a/api/mocks/servicer.go
+++ b/api/mocks/servicer.go
@@ -2218,80 +2218,6 @@ func (_c *Servicer_GetAccount_Call) RunAndReturn(run func(ctx context.Context, a
return _c
}
-// GetAccountMetrics provides a mock function for the type Servicer
-func (_mock *Servicer) GetAccountMetrics(ctx context.Context, account *model.Account, timespan string) (*model.StatisticsAggregated, error) {
- ret := _mock.Called(ctx, account, timespan)
-
- if len(ret) == 0 {
- panic("no return value specified for GetAccountMetrics")
- }
-
- var r0 *model.StatisticsAggregated
- var r1 error
- if returnFunc, ok := ret.Get(0).(func(context.Context, *model.Account, string) (*model.StatisticsAggregated, error)); ok {
- return returnFunc(ctx, account, timespan)
- }
- if returnFunc, ok := ret.Get(0).(func(context.Context, *model.Account, string) *model.StatisticsAggregated); ok {
- r0 = returnFunc(ctx, account, timespan)
- } else {
- if ret.Get(0) != nil {
- r0 = ret.Get(0).(*model.StatisticsAggregated)
- }
- }
- if returnFunc, ok := ret.Get(1).(func(context.Context, *model.Account, string) error); ok {
- r1 = returnFunc(ctx, account, timespan)
- } else {
- r1 = ret.Error(1)
- }
- return r0, r1
-}
-
-// Servicer_GetAccountMetrics_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAccountMetrics'
-type Servicer_GetAccountMetrics_Call struct {
- *mock.Call
-}
-
-// GetAccountMetrics is a helper method to define mock.On call
-// - ctx context.Context
-// - account *model.Account
-// - timespan string
-func (_e *Servicer_Expecter) GetAccountMetrics(ctx interface{}, account interface{}, timespan interface{}) *Servicer_GetAccountMetrics_Call {
- return &Servicer_GetAccountMetrics_Call{Call: _e.mock.On("GetAccountMetrics", ctx, account, timespan)}
-}
-
-func (_c *Servicer_GetAccountMetrics_Call) Run(run func(ctx context.Context, account *model.Account, timespan string)) *Servicer_GetAccountMetrics_Call {
- _c.Call.Run(func(args mock.Arguments) {
- var arg0 context.Context
- if args[0] != nil {
- arg0 = args[0].(context.Context)
- }
- var arg1 *model.Account
- if args[1] != nil {
- arg1 = args[1].(*model.Account)
- }
- var arg2 string
- if args[2] != nil {
- arg2 = args[2].(string)
- }
- run(
- arg0,
- arg1,
- arg2,
- )
- })
- return _c
-}
-
-func (_c *Servicer_GetAccountMetrics_Call) Return(statisticsAggregated *model.StatisticsAggregated, err error) *Servicer_GetAccountMetrics_Call {
- _c.Call.Return(statisticsAggregated, err)
- return _c
-}
-
-func (_c *Servicer_GetAccountMetrics_Call) RunAndReturn(run func(ctx context.Context, account *model.Account, timespan string) (*model.StatisticsAggregated, error)) *Servicer_GetAccountMetrics_Call {
- _c.Call.Return(run)
- return _c
-}
-
// GetBlocklist provides a mock function for the type Servicer
func (_mock *Servicer) GetBlocklist(ctx context.Context, filter map[string]any, sortBy string) ([]*model.Blocklist, error) {
ret := _mock.Called(ctx, filter, sortBy)
diff --git a/api/model/account.go b/api/model/account.go
index 00fcdb9a..073bbe66 100644
--- a/api/model/account.go
+++ b/api/model/account.go
@@ -10,10 +10,6 @@ import (
"golang.org/x/crypto/bcrypt"
)
-const (
- QUERIES_NUMBER_LIMIT = 300000
-)
-
const (
AuthMethodPassword = "password"
AuthMethodPasskey = "passkey"
@@ -29,7 +25,6 @@ type Account struct {
Tokens []Token `json:"-" bson:"tokens"`
Password *string `json:"-" bson:"password,omitempty"`
Profiles []string `json:"profiles" bson:"profiles"`
- Queries int `json:"queries" bson:"-"`
ErrorReportsConsent bool `json:"error_reports_consent" bson:"error_reports_consent"`
MFA MFASettings `json:"mfa" bson:"mfa"`
AuthMethods []string `json:"auth_methods,omitempty" bson:"-"`
@@ -70,10 +65,6 @@ func NewAccount(email, password, accountId, profileId string) (*Account, error)
return acc, nil
}
-func (a *Account) IsQueriesNumberExceeded() bool {
- return a.Queries > QUERIES_NUMBER_LIMIT
-}
-
// WebAuthnID implements webauthn.User
func (a *Account) WebAuthnID() []byte {
return []byte(a.ID.Hex())
diff --git a/api/service/account/account.go b/api/service/account/account.go
index f7df68d9..c978b613 100644
--- a/api/service/account/account.go
+++ b/api/service/account/account.go
@@ -317,11 +317,6 @@ func (p *AccountService) GetAccount(ctx context.Context, accountId string) (*mod
return nil, err
}
- stats, err := p.GetAccountMetrics(ctx, account, model.LAST_MONTH)
- if err != nil {
- return nil, err
- }
- account.Queries = stats.Total
if err := p.populateAuthMethods(ctx, account); err != nil {
return nil, err
}
@@ -348,21 +343,6 @@ func (a *AccountService) populateAuthMethods(ctx context.Context, acc *model.Acc
return nil
}
-// GetAccountStatistics returns profile DNS statistics data
-func (a *AccountService) GetAccountMetrics(ctx context.Context, account *model.Account, timespan string) (*model.StatisticsAggregated, error) {
- accMetricsAggregated := &model.StatisticsAggregated{}
- for _, profileId := range account.Profiles {
- profileStats, err := a.ProfileService.GetStatistics(ctx, account.ID.Hex(), profileId, timespan)
- if err != nil {
- return nil, err
- }
-
- accMetricsAggregated.Total += profileStats[0].Total
- }
-
- return accMetricsAggregated, nil
-}
-
// UpdateAccount updates account data
func (a *AccountService) UpdateAccount(ctx context.Context, accountId string, updates []model.AccountUpdate, mfa *model.MfaData) error {
var profileUpdates []model.AccountUpdate
diff --git a/api/service/account/service_test.go b/api/service/account/service_test.go
index 518e0624..baa7f563 100644
--- a/api/service/account/service_test.go
+++ b/api/service/account/service_test.go
@@ -367,15 +367,6 @@ func (suite *AccountTestSuite) TestGetAccount() {
suite.mockAccountRepo.On("GetAccountById", context.Background(), tt.accountID).Return(nil, tt.repoError)
} else {
suite.mockAccountRepo.On("GetAccountById", context.Background(), tt.accountID).Return(tt.account, nil)
- // Mock GetAccountMetrics dependencies
- for _, profileID := range tt.account.Profiles {
- stats := []model.StatisticsAggregated{
- {
- Total: 100,
- },
- }
- suite.mockStatsRepo.On("GetProfileStatistics", context.Background(), profileID, 720).Return(stats, nil)
- }
}
// Execute the method
@@ -395,94 +386,6 @@ func (suite *AccountTestSuite) TestGetAccount() {
}
}
-// TestGetAccountMetrics tests the GetAccountMetrics method
-func (suite *AccountTestSuite) TestGetAccountMetrics() {
- tests := []struct {
- name string
- account *model.Account
- timespan string
- statsError error
- expectedError string
- expectSuccess bool
- }{
- {
- name: "Successful metrics retrieval",
- account: &model.Account{
- ID: primitive.NewObjectID(),
- Email: "test@example.com",
- Profiles: []string{"profile1", "profile2"},
- },
- timespan: "LAST_1_DAY",
- expectSuccess: true,
- },
- {
- name: "Statistics error",
- account: &model.Account{
- ID: primitive.NewObjectID(),
- Email: "test@example.com",
- Profiles: []string{"profile1"},
- },
- timespan: "LAST_1_DAY",
- statsError: errors.New("stats error"),
- expectedError: "stats error",
- },
- {
- name: "No profiles",
- account: &model.Account{
- ID: primitive.NewObjectID(),
- Email: "test@example.com",
- Profiles: []string{},
- },
- timespan: "LAST_1_DAY",
- expectSuccess: true,
- },
- }
-
- for _, tt := range tests {
- suite.Run(tt.name, func() {
- // Reset mock expectations
- suite.mockStatsRepo.ExpectedCalls = nil
- suite.mockProfileRepo.ExpectedCalls = nil
-
- // Mock statistics calls for each profile
- for _, profileID := range tt.account.Profiles {
- stats := []model.StatisticsAggregated{
- {
- Total: 100,
- },
- }
-
- // Mock profile validation in profile service (always successful)
- profile := &model.Profile{
- ProfileId: profileID,
- AccountId: tt.account.ID.Hex(),
- }
- suite.mockProfileRepo.On("GetProfileById", context.Background(), profileID).Return(profile, nil)
-
- if tt.statsError != nil {
- suite.mockStatsRepo.On("GetProfileStatistics", context.Background(), profileID, 24).Return(nil, tt.statsError)
- break // Only need one error to trigger the test condition
- } else {
- suite.mockStatsRepo.On("GetProfileStatistics", context.Background(), profileID, 24).Return(stats, nil)
- }
- }
-
- // Execute the method
- result, err := suite.service.GetAccountMetrics(context.Background(), tt.account, tt.timespan)
-
- // Verify results
- if tt.expectedError != "" {
- suite.Error(err)
- suite.Contains(err.Error(), tt.expectedError)
- suite.Nil(result)
- } else {
- suite.NoError(err)
- suite.NotNil(result)
- }
- })
- }
-}
-
// TestSendResetPasswordEmail tests the SendResetPasswordEmail method
func (suite *AccountTestSuite) TestSendResetPasswordEmail() {
tests := []struct {
diff --git a/api/service/service.go b/api/service/service.go
index 85ae8f63..877026e8 100644
--- a/api/service/service.go
+++ b/api/service/service.go
@@ -106,7 +106,6 @@ type SessionServicer interface {
type AccountServicer interface {
GetAccount(ctx context.Context, accountId string) (*model.Account, error)
- GetAccountMetrics(ctx context.Context, account *model.Account, timespan string) (*model.StatisticsAggregated, error)
UpdateAccount(ctx context.Context, accountId string, updates []model.AccountUpdate, mfa *model.MfaData) error
DeleteAccount(ctx context.Context, accountId string, req requests.AccountDeletionRequest, mfa *model.MfaData) error
GenerateDeletionCode(ctx context.Context, accountId string) (*responses.DeletionCodeResponse, error)
diff --git a/app/nginx.conf b/app/nginx.conf
index f6b8bda9..55410d6c 100644
--- a/app/nginx.conf
+++ b/app/nginx.conf
@@ -5,6 +5,36 @@ events {
}
http {
+ # Don't advertise the nginx version in the Server header or error pages —
+ # it hands attackers a version to match against known CVEs (gixy: version_disclosure).
+ server_tokens off;
+
+ # Compression — shrinks text assets (JS/CSS/JSON/SVG) 70-90% on the wire.
+ # Already-compressed formats (woff2, png, jpg) are intentionally excluded.
+ gzip on;
+ gzip_comp_level 6;
+ gzip_min_length 1024;
+ gzip_vary on;
+ gzip_proxied any;
+ gzip_types
+ text/plain
+ text/css
+ application/javascript
+ application/json
+ application/manifest+json
+ image/svg+xml
+ application/wasm;
+
+ # Cache-Control by file type, computed once so it can be set with a single
+ # server-level add_header (see below). Content-hashed assets (Vite emits e.g.
+ # index-AbC123.js) are immutable for a year; everything else — notably the
+ # un-hashed index.html entry point — must revalidate so deploys take effect.
+ # $uri is the normalized path (decoded, query string stripped).
+ map $uri $cache_control {
+ default "no-cache";
+ ~*\.(?:js|mjs|css|woff2?|ttf|eot|svg|png|jpe?g|gif|webp|avif|ico|wasm|map)$ "public, max-age=31536000, immutable";
+ }
+
server {
listen 80;
root /usr/share/nginx/html/app;
@@ -19,18 +49,33 @@ http {
add_header Cross-Origin-Resource-Policy "same-origin";
add_header Origin-Agent-Cluster "?1";
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
+
# CSP: the sha256 hash whitelists the inline theme bootstrap script in index.html.
# If that script changes, regenerate with:
# python3 -c "import hashlib,base64,re;h=re.search(r'',open('index.html').read(),re.DOTALL).group(1);print(base64.b64encode(hashlib.sha256(h.encode()).digest()).decode())"
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'sha256-cdVSBJUTkdt+/1Hv5fQ1ypvOVNP5cRMEKnfA9q6dHo4='; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self' ${VITE_API_URL} https://*.${VITE_DNS_CHECK_DOMAIN} ; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
+
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
+ # Per-request Cache-Control (map above). Set here at server level so the
+ # security headers above are NOT reset: nginx's add_header inherits into a
+ # location only if that location declares no add_header of its own
+ # (ngx_http_headers_module). Keeping every add_header at this level avoids
+ # that footgun — the asset location below intentionally adds none.
+ add_header Cache-Control $cache_control always;
# Access Control
include /etc/nginx/access_rules.conf;
+ # Content-hashed static assets get long-lived caching via $cache_control;
+ # this block only enforces that a missing asset 404s instead of falling
+ # through to the SPA's index.html. No add_header here — see note above.
+ location ~* \.(?:js|mjs|css|woff2?|ttf|eot|svg|png|jpe?g|gif|webp|avif|ico|wasm|map)$ {
+ try_files $uri =404;
+ }
+
location / {
alias /usr/share/nginx/html/app/;
try_files $uri $uri/ /index.html;
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/App.tsx b/app/src/App.tsx
index 051de80a..77d89c55 100644
--- a/app/src/App.tsx
+++ b/app/src/App.tsx
@@ -29,6 +29,19 @@ const MobileconfigDownload = lazyWithRetry(() => import('@/pages/mobileconfig/Mo
const HomeScreen = lazyWithRetry(() => import('./pages/home/HomeScreen'));
const Landing = lazyWithRetry(() => import('./pages/landing/Landing'));
+// Maps a nav route to its lazy chunk's preloader. Nav components call this on
+// hover/focus/touch so the route's JS is warm by the time the user clicks —
+// turning first-visit-per-session tab switches from "download then render" into
+// "render immediately". Keys must stay in sync with NavigationMenu/BottomNav.
+const routePreload: Partial void>> = {
+ '/setup': () => { void Setup.preload(); },
+ '/blocklists': () => { void Blocklists.preload(); },
+ '/custom-rules': () => { void CustomRules.preload(); },
+ '/query-logs': () => { void Logs.preload(); },
+ '/settings': () => { void Settings.preload(); },
+ '/account-preferences': () => { void AccountPreferences.preload(); },
+};
+
import { createBrowserRouter, RouterProvider, Navigate, Outlet, useLoaderData, useLocation, useNavigate, redirect, ScrollRestoration } from 'react-router-dom';
import { ThemeProvider } from "@/components/theme-provider"
import api from "@/api/api";
@@ -155,6 +168,44 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
);
};
+// Treats 401/404 from an API call as a session-expiry signal.
+function isAuthExpiryError(error: unknown): boolean {
+ const err = error as Record;
+ const status = (err?.response as Record)?.status ?? err?.status;
+ return status === 401 || status === 404;
+}
+
+// Background refresh of account+profiles for warm-cache navigations. Updates the
+// Zustand store in place and surfaces session expiry through the same event the
+// blocking loader path uses, so a stale-cache navigation still logs the user out
+// if their session died.
+function revalidateAccountAndProfiles() {
+ void Promise.allSettled([
+ api.Client.accountsApi.apiV1AccountsCurrentGet(),
+ api.Client.profilesApi.apiV1ProfilesGet(),
+ ]).then(([accountResult, profilesResult]) => {
+ const { setAccount, setProfiles, restoreActiveProfile } = useAppStore.getState();
+ if (accountResult.status === 'fulfilled') {
+ setAccount(accountResult.value.data as ModelAccount);
+ }
+ if (profilesResult.status === 'fulfilled') {
+ const profiles = profilesResult.value.data as ModelProfile[];
+ setProfiles(profiles);
+ restoreActiveProfile(profiles);
+ }
+ // A 401/404 from EITHER call means the session died — surface it once so a
+ // warm-cache navigation still logs the user out even if only the profiles
+ // call failed. (A 403 from /profiles in pending_delete state is intentionally
+ // not treated as expiry by isAuthExpiryError, so it won't trip this.)
+ const sessionExpired =
+ (accountResult.status === 'rejected' && isAuthExpiryError(accountResult.reason)) ||
+ (profilesResult.status === 'rejected' && isAuthExpiryError(profilesResult.reason));
+ if (sessionExpired) {
+ dispatch({ type: 'auth/sessionExpired' });
+ }
+ });
+}
+
// Loader for protected routes that need both account and profiles data
async function rootLoader() {
try {
@@ -165,6 +216,19 @@ async function rootLoader() {
throw redirect("/login");
}
+ // Render-from-cache: on a warm client navigation (store already hydrated by
+ // a prior load), return the cached data synchronously and revalidate in the
+ // background. This removes the blocking account+profiles round-trip from
+ // every tab switch — the fetch below only runs on cold start / hard reload,
+ // where account/profiles are not persisted and the store is empty.
+ if (typeof window !== "undefined") {
+ const cached = useAppStore.getState();
+ if (cached.account && cached.profiles.length > 0) {
+ revalidateAccountAndProfiles();
+ return { account: cached.account, profiles: cached.profiles };
+ }
+ }
+
// Use allSettled so a partial failure (e.g. /profiles returning 403 in
// pending_delete subscription state, where /accounts/current is still
// allowlisted by the server's subscription guard) does not collapse the
@@ -237,6 +301,23 @@ async function profilesOnlyLoader() {
throw redirect("/login");
}
+ // Render-from-cache: serve the hydrated store synchronously on warm
+ // navigations and revalidate profiles in the background (see rootLoader).
+ if (typeof window !== "undefined") {
+ const cached = useAppStore.getState();
+ if (cached.profiles.length > 0) {
+ void api.Client.profilesApi.apiV1ProfilesGet()
+ .then((res) => {
+ const { setProfiles, restoreActiveProfile } = useAppStore.getState();
+ const fresh = res.data as ModelProfile[];
+ setProfiles(fresh);
+ restoreActiveProfile(fresh);
+ })
+ .catch((e) => { if (isAuthExpiryError(e)) dispatch({ type: 'auth/sessionExpired' }); });
+ return { account: null, profiles: cached.profiles };
+ }
+ }
+
const profilesRes = await api.Client.profilesApi.apiV1ProfilesGet();
// Save to Zustand store
@@ -671,4 +752,4 @@ function App() {
export default App;
// eslint-disable-next-line react-refresh/only-export-components
-export { useAuth, AuthContext, RootIndexRedirect };
+export { useAuth, AuthContext, RootIndexRedirect, routePreload };
diff --git a/app/src/__tests__/mocks/apiMocks.ts b/app/src/__tests__/mocks/apiMocks.ts
index aa22c01f..3e8399f3 100644
--- a/app/src/__tests__/mocks/apiMocks.ts
+++ b/app/src/__tests__/mocks/apiMocks.ts
@@ -40,7 +40,6 @@ export function createMockAccount(overrides: Partial = {}): ModelA
error_reports_consent: false,
mfa: { totp: { enabled: false } },
profiles: ['p1'],
- queries: 0,
...overrides
};
return base;
diff --git a/app/src/api/client/api.ts b/app/src/api/client/api.ts
index a31846ae..830dc763 100644
--- a/app/src/api/client/api.ts
+++ b/app/src/api/client/api.ts
@@ -212,12 +212,6 @@ export interface ModelAccount {
* @memberof ModelAccount
*/
'profiles'?: Array;
- /**
- *
- * @type {number}
- * @memberof ModelAccount
- */
- 'queries'?: number;
}
/**
*
diff --git a/app/src/components/navigation/BottomNav.tsx b/app/src/components/navigation/BottomNav.tsx
index 870f778e..bb6dcfb3 100644
--- a/app/src/components/navigation/BottomNav.tsx
+++ b/app/src/components/navigation/BottomNav.tsx
@@ -1,6 +1,7 @@
import { useLocation, useNavigate } from "react-router-dom";
import { GlobeIcon, ShieldIcon, ListIcon, FilterX, Menu } from "lucide-react";
import { cn } from "@/lib/utils";
+import { routePreload } from "@/App";
interface BottomNavProps {
onMoreClick: () => void;
@@ -29,6 +30,9 @@ export default function BottomNav({ onMoreClick }: BottomNavProps) {