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