From ae7072ddeed39925d489f7bb8d9b7c6a660dfda6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Kv=C3=A1=C4=8Dek?= Date: Mon, 14 Sep 2026 11:08:22 +0200 Subject: [PATCH] bugfixing --- process.yml | 6 ++ quasar.config.js | 7 +- src/boot/apollo.js | 173 ++++++++++++++++++++++++--------------- src/boot/cookies.js | 33 ++++++-- src/boot/sentry.js | 5 ++ src/stores/Auth.js | 18 ++-- src/stores/LoggedUser.js | 1 - src/stores/Pilots.js | 1 - 8 files changed, 167 insertions(+), 77 deletions(-) diff --git a/process.yml b/process.yml index 4fcd19f..b6f1755 100644 --- a/process.yml +++ b/process.yml @@ -3,3 +3,9 @@ apps: script: /app/index.js exec_mode: cluster instances: 3 + # Pojistka proti bodu 1 (a podobným leakům): pokud paměť workeru + # přeroste limit, PM2 ho sám restartuje dřív, než dojde k OOM na + # celém VPS. Hodnotu je potřeba doladit dle reálné spotřeby jednoho + # SSR workeru po opravě bodu 1 (pm2 monit / pm2 list) - viz + # handoffs/05-pm2-docker-memory-limits.md. + max_memory_restart: '400M' diff --git a/quasar.config.js b/quasar.config.js index 6691bc5..f7dff5b 100644 --- a/quasar.config.js +++ b/quasar.config.js @@ -28,7 +28,12 @@ module.exports = configure(function (ctx) { // --> boot files are part of "main.js" // https://v2.quasar.dev/quasar-cli/boot-files boot: [ - "sentry", + // client-only: Sentry browser SDK (browserTracingIntegration, + // replayIntegration) is DOM-only and has no business running per SSR + // request - server: false means Quasar excludes this boot file from + // the server bundle entirely (not just a runtime guard). + // See handoffs/04-sentry-ssr-guard.md. + {path: "sentry", server: false}, "cookies", "axios", "apollo", diff --git a/src/boot/apollo.js b/src/boot/apollo.js index 867d4b4..892119f 100644 --- a/src/boot/apollo.js +++ b/src/boot/apollo.js @@ -1,5 +1,6 @@ import {ApolloClient, ApolloLink, InMemoryCache} from '@apollo/client/core' import {ErrorLink} from "@apollo/client/link/error"; +import {defineStore} from "pinia"; import {useAppStore} from "stores/App"; import {Notify} from "quasar"; import {errorObject, warningObject} from "src/composables/dialog"; @@ -29,76 +30,120 @@ const handleRefreshToken = (store, router, forward, operation) => { })) } -export const apolloWrapper = {}; -export default (({store, router}) => { - const uploadLink = new UploadHttpLink({ - uri: process.env.API_URL + '/graphql', - credentials: 'include' - }) +// SSR: boot() runs again for every request. The client/query/mutate below +// used to live on a shared module-level `apolloWrapper` object that every +// request's boot() reassigned in place - under concurrent SSR requests, one +// request could end up calling into another request's (still-being-set-up +// or already-overwritten) apolloClient/store, i.e. querying/mutating with +// the wrong user's auth context. See +// handoffs/02-apollo-wrapper-race-condition.md for the full analysis. +// +// Fix: make this a Pinia store instead of a plain shared object. `init()` +// is called from boot() with the request's own `store`/`router` explicitly +// (same proven pattern as useAuthStore(store) elsewhere in this file), so +// the closures below always close over the correct request. `query`/ +// `mutate` are then read back through Pinia's own SSR-safe per-request +// resolution (useApolloStore() with no arg, called from preFetch/ +// onServerPrefetch-triggered store actions - the same mechanism every +// other useXStore() call in this codebase already relies on), instead of +// through a shared mutable object. +export const useApolloStore = defineStore('Apollo', () => { + let client = null + let queryImpl = null + let mutateImpl = null - const authLink = new ApolloLink((operation, forward) => { - const accessToken = useAuthStore(store).getAccessToken(); - if (accessToken) { - operation.setContext({ - headers: {"Authorization": `Bearer ${accessToken}`}, - }); - } - return forward(operation); - }); + const init = ({store, router}) => { + if (client) return // boot() already initialized this request's store - const errorLink = new ErrorLink(({graphQLErrors, networkError, operation, forward}) => { - if (networkError?.statusCode === 401) { - return handleRefreshToken(store, router, forward, operation); - } + const uploadLink = new UploadHttpLink({ + uri: process.env.API_URL + '/graphql', + credentials: 'include' + }) - if (graphQLErrors) { - for (const error of graphQLErrors) { - if (error.message.includes("401") || error.message.includes("Not authorized")) { - return handleRefreshToken(store, router, forward, operation); - } else if (error.message.includes("404") || error.message.includes("Not found")) { - useAppStore(store).appStatus = 404; - } else { - Notify.create(errorObject("API error: " + error.message)); + const authLink = new ApolloLink((operation, forward) => { + const accessToken = useAuthStore(store).getAccessToken(); + if (accessToken) { + operation.setContext({ + headers: {"Authorization": `Bearer ${accessToken}`}, + }); + } + return forward(operation); + }); + + const errorLink = new ErrorLink(({graphQLErrors, networkError, operation, forward}) => { + if (networkError?.statusCode === 401) { + return handleRefreshToken(store, router, forward, operation); + } + + if (graphQLErrors) { + for (const error of graphQLErrors) { + if (error.message.includes("401") || error.message.includes("Not authorized")) { + return handleRefreshToken(store, router, forward, operation); + } else if (error.message.includes("404") || error.message.includes("Not found")) { + useAppStore(store).appStatus = 404; + } else { + Notify.create(errorObject("API error: " + error.message)); + } } } - } - }) - - const apolloClient = new ApolloClient({ - link: ApolloLink.from([authLink, errorLink, uploadLink]), - cache: new InMemoryCache(), - connectToDevTools: true, - ssrMode: true, - }); - - apolloWrapper.query = (query, variables = {}) => { - return apolloClient.query({ - query: query, - fetchPolicy: 'no-cache', - variables: variables }) - .then((response) => response) - .catch(err => { - const errStr = err.toString(); - if (errStr.includes('Not found') || errStr.includes("No row was found")) { - useAppStore(store).appStatus = 404; - return Promise.resolve(); - } else if (!errStr.includes("401") && !errStr.includes("Not authorized")) { - console.error("Error during query: ", err) - } - return Promise.reject(err); - }) - }; - apolloWrapper.mutate = (mutation, variables) => { - return apolloClient - .mutate({ - mutation: mutation, + + client = new ApolloClient({ + link: ApolloLink.from([authLink, errorLink, uploadLink]), + cache: new InMemoryCache(), + connectToDevTools: true, + ssrMode: true, + }); + + queryImpl = (query, variables = {}) => { + return client.query({ + query: query, fetchPolicy: 'no-cache', - variables: variables, + variables: variables }) - .then((response) => response) - .catch(err => { - console.error("Error during mutation: ", err); - }) - }; + .then((response) => response) + .catch(err => { + const errStr = err.toString(); + if (errStr.includes('Not found') || errStr.includes("No row was found")) { + useAppStore(store).appStatus = 404; + return Promise.resolve(); + } else if (!errStr.includes("401") && !errStr.includes("Not authorized")) { + console.error("Error during query: ", err) + } + return Promise.reject(err); + }) + }; + + mutateImpl = (mutation, variables) => { + return client + .mutate({ + mutation: mutation, + fetchPolicy: 'no-cache', + variables: variables, + }) + .then((response) => response) + .catch(err => { + console.error("Error during mutation: ", err); + }) + }; + } + + const query = (query, variables) => queryImpl(query, variables) + const mutate = (mutation, variables) => mutateImpl(mutation, variables) + + return {init, query, mutate} +}) + +// Backward-compatible shape so existing call sites +// (`apolloWrapper.query(...)`, `apolloWrapper.mutate(...)` in +// stores/helpers.js and stores/Organizations.js) keep working unchanged, +// now delegating to the request-scoped store above instead of a shared +// mutable singleton. +export const apolloWrapper = { + query: (...args) => useApolloStore().query(...args), + mutate: (...args) => useApolloStore().mutate(...args), +} + +export default (({store, router}) => { + useApolloStore(store).init({store, router}) }); diff --git a/src/boot/cookies.js b/src/boot/cookies.js index bda4ef9..5776720 100644 --- a/src/boot/cookies.js +++ b/src/boot/cookies.js @@ -1,9 +1,32 @@ import {Cookies} from 'quasar' import {boot} from "quasar/wrappers"; +import {defineStore} from 'pinia' -let cookies = Cookies; -export default boot(({ssrContext}) => { - cookies = process.env.SERVER ? Cookies.parseSSR(ssrContext) : Cookies +// SSR: boot() runs again for every request. `cookies` used to be a single +// shared module-level variable reassigned in place by every request's +// boot() - under concurrent SSR requests, one request's +// Cookies.parseSSR(ssrContext) result (which carries that request's own +// accessToken) could overwrite another still-in-flight request's +// `cookies` before it finished reading it, leaking one user's access +// token to another. See handoffs/03-cookies-shared-state.md. +// +// Fix: make this a Pinia store, initialized explicitly per request from +// boot() with that request's own `store` (same proven pattern as +// boot/apollo.js), instead of a shared mutable variable. +export const useCookiesStore = defineStore('Cookies', () => { + let instance = null + + const init = (ssrContext) => { + instance = process.env.SERVER ? Cookies.parseSSR(ssrContext) : Cookies + } + + const get = (...args) => instance.get(...args) + const set = (...args) => instance.set(...args) + const remove = (...args) => instance.remove(...args) + + return {init, get, set, remove} +}) + +export default boot(({store, ssrContext}) => { + useCookiesStore(store).init(ssrContext) }); - -export {cookies} diff --git a/src/boot/sentry.js b/src/boot/sentry.js index cf9e23b..8838303 100644 --- a/src/boot/sentry.js +++ b/src/boot/sentry.js @@ -1,7 +1,12 @@ import {boot} from "quasar/wrappers"; import * as Sentry from "@sentry/browser"; +// Client-only boot file (see quasar.config.js: {path: "sentry", server: +// false} - excludes this from the server bundle entirely). The guard below +// is a defensive second layer in case that config entry is ever changed +// back to a plain string. See handoffs/04-sentry-ssr-guard.md. export default boot(({app, router}) => { + if (process.env.SERVER) return; if (process.env.NODE_ENV !== 'production') return; Sentry.init({ diff --git a/src/stores/Auth.js b/src/stores/Auth.js index ba3c5d2..8df0b8d 100644 --- a/src/stores/Auth.js +++ b/src/stores/Auth.js @@ -1,9 +1,17 @@ -import {defineStore} from 'pinia' +import {defineStore, getActivePinia} from 'pinia' import {api} from "boot/axios"; -import {cookies} from "boot/cookies"; +import {useCookiesStore} from "boot/cookies"; import {useFlightsStore} from "stores/Flights"; export const useAuthStore = defineStore('Auth', () => { + // Captured synchronously at setup time (see handoffs/03-cookies-shared-state.md): + // getActivePinia() here always resolves to the same pinia instance this + // store was created for (Pinia sets it up right before running setup()), + // so closing over it now and passing it explicitly to useCookiesStore() + // later keeps cookie access correctly scoped to this request even if + // other concurrent SSR requests are being set up in between. + const pinia = getActivePinia(); + const login = async (formData) => { return await api.post("/login", formData).then(({data}) => { setTokens(data); @@ -18,18 +26,18 @@ export const useAuthStore = defineStore('Auth', () => { }; const unsetTokens = () => { - cookies.remove("accessToken") + useCookiesStore(pinia).remove("accessToken") }; const setTokens = ({access_token, access_token_validity}) => { - cookies.set("accessToken", access_token, { + useCookiesStore(pinia).set("accessToken", access_token, { expires: `${access_token_validity}s`, sameSite: 'Strict' }); }; const getAccessToken = () => { - return cookies.get("accessToken"); + return useCookiesStore(pinia).get("accessToken"); }; const hasAccessToken = () => !!getAccessToken(); diff --git a/src/stores/LoggedUser.js b/src/stores/LoggedUser.js index 43764d8..e10e849 100644 --- a/src/stores/LoggedUser.js +++ b/src/stores/LoggedUser.js @@ -4,7 +4,6 @@ import {EDIT_USER} from "src/graphql/mutations/user"; import {LOGGED_USER_DETAIL, PILOT_DETAIL} from "src/graphql/queries/user"; import {ref} from "vue"; import {mutate, query} from "stores/helpers"; -import {cookies} from "boot/cookies"; export const useLoggedUserStore = defineStore('LoggedUser', () => { const user = ref(null); diff --git a/src/stores/Pilots.js b/src/stores/Pilots.js index 5d93ea0..cbffb0c 100644 --- a/src/stores/Pilots.js +++ b/src/stores/Pilots.js @@ -4,7 +4,6 @@ import {EDIT_USER} from "src/graphql/mutations/user"; import {LOGGED_USER_DETAIL, PILOT_DETAIL} from "src/graphql/queries/user"; import {ref} from "vue"; import {mutate, query} from "stores/helpers"; -import {cookies} from "boot/cookies"; export const usePilotsStore = defineStore('Pilots', () => { const pilot = ref(null);