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
4 changes: 4 additions & 0 deletions client/src/api/client/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import createClient from "openapi-fetch";

import { pendingRequestsMiddleware } from "@/api/client/pendingRequestsMiddleware";
import { createRateLimiterMiddleware } from "@/api/client/rateLimiter";
import type { GalaxyApiPaths } from "@/api/schema";
import { getAppRoot } from "@/onload/loadConfig";
Expand All @@ -12,6 +13,9 @@ function getBaseUrl() {
function apiClientFactory() {
const client = createClient<GalaxyApiPaths>({ baseUrl: getBaseUrl() });

// Registered first so aborted requests bypass the rate-limiter queue.
client.use(pendingRequestsMiddleware);

// TODO: Adjust based on server limits (maybe this goes in Galaxy config?)
client.use(
createRateLimiterMiddleware({
Expand Down
24 changes: 24 additions & 0 deletions client/src/api/client/pendingRequestsMiddleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { Middleware } from "openapi-fetch";

import { getPendingAbortSignal, SKIP_PENDING_REQUESTS_HEADER } from "@/api/pendingRequests";

/**
* Attaches the shared pending-requests signal to every ``GalaxyApi`` request.
* The ``openapi-fetch`` client uses native ``fetch()`` so the axios
* interceptor does not apply; without this middleware, login/register
* navigations cannot cancel in-flight ``/api/...`` calls and a late
* anonymous-cookie response can clobber the authenticated ``galaxysession``
* cookie. See ``client/src/api/pendingRequests.ts`` for the race.
*/
export const pendingRequestsMiddleware: Middleware = {
async onRequest({ request }) {
if (request.headers.has(SKIP_PENDING_REQUESTS_HEADER)) {
const headers = new Headers(request.headers);
headers.delete(SKIP_PENDING_REQUESTS_HEADER);
return new Request(request, { headers });
}
const shared = getPendingAbortSignal();
const signal = typeof AbortSignal.any === "function" ? AbortSignal.any([request.signal, shared]) : shared;
return new Request(request, { signal });
},
};
58 changes: 58 additions & 0 deletions client/src/api/pendingRequests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* Shared ``AbortController`` used by both the axios interceptor and the
* ``openapi-fetch`` middleware so we can cancel every in-flight request in
* one shot right before a login/register navigation.
*
* Why this exists: when the server processes a request that carries the old
* anonymous ``galaxysession`` cookie *after* ``handle_user_login`` has marked
* that session ``is_valid=False``, it creates a fresh anonymous session and
* responds with ``Set-Cookie: galaxysession=<new>``. Delivered into the cookie
* jar after the login response but before the new page loads, it overwrites
* the just-issued authenticated cookie — so the new page loads anonymous and
* ``wait_for_logged_in`` times out in selenium. Aborting the TCP connection
* before the response headers are parsed prevents that ``Set-Cookie`` from
* ever applying.
*/
import axios, { type InternalAxiosRequestConfig } from "axios";

let activeController = new AbortController();

/** Explicit opt-out header that the login/register POST itself sets. */
export const SKIP_PENDING_REQUESTS_HEADER = "x-galaxy-skip-pending-abort";

/**
* The signal every request should ride on by default. Read lazily so the
* ``openapi-fetch`` middleware picks up the fresh signal after each
* ``cancelPendingRequests()`` rotation.
*/
export function getPendingAbortSignal(): AbortSignal {
return activeController.signal;
}

/**
* Install a request interceptor that attaches the shared signal to every
* outgoing axios request that didn't set one itself. Call once at app boot.
*/
export function installPendingRequestsInterceptor() {
axios.interceptors.request.use((config: InternalAxiosRequestConfig) => {
if (config.signal !== undefined) {
return config;
}
if (config.headers?.[SKIP_PENDING_REQUESTS_HEADER]) {
delete config.headers[SKIP_PENDING_REQUESTS_HEADER];
return config;
}
config.signal = activeController.signal;
return config;
});
}

/**
* Abort every request that is using the shared signal (both axios via the
* interceptor and ``openapi-fetch`` via its middleware) and install a fresh
* controller so subsequent requests can still go out.
*/
export function cancelPendingRequests() {
activeController.abort();
activeController = new AbortController();
}
23 changes: 17 additions & 6 deletions client/src/components/Login/LoginForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {
import { computed, ref } from "vue";
import { useRouter } from "vue-router/composables";

import { SKIP_PENDING_REQUESTS_HEADER } from "@/api/pendingRequests";
import { discardActiveConnectionsBeforeAuthNavigation } from "@/composables/useAuthNavigation";
import localize from "@/utils/localization";
import { withPrefix } from "@/utils/redirect";
import { errorMessageAsString } from "@/utils/simple-error";
Expand Down Expand Up @@ -86,13 +88,22 @@ async function submitLogin() {
redirect = props.redirect ?? null;
}

// Stop polling and abort in-flight axios/GalaxyApi before sending the
// login POST — otherwise a late anonymous-cookie response can overwrite
// the authenticated cookie we're about to receive.
discardActiveConnectionsBeforeAuthNavigation();

try {
const response = await axios.post(withPrefix("/user/login"), {
login: login.value,
password: password.value,
redirect: redirect,
session_csrf_token: props.sessionCsrfToken,
});
const response = await axios.post(
withPrefix("/user/login"),
{
login: login.value,
password: password.value,
redirect: redirect,
session_csrf_token: props.sessionCsrfToken,
},
{ headers: { [SKIP_PENDING_REQUESTS_HEADER]: "1" } },
);

if (response.data.message && response.data.status) {
alert(response.data.message);
Expand Down
27 changes: 19 additions & 8 deletions client/src/components/Register/RegisterForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ import {
} from "bootstrap-vue";
import { computed, type Ref, ref } from "vue";

import { SKIP_PENDING_REQUESTS_HEADER } from "@/api/pendingRequests";
import { getOIDCIdpsWithRegistration, type OIDCConfig } from "@/components/User/ExternalIdentities/ExternalIDHelper";
import { Toast } from "@/composables/toast";
import { discardActiveConnectionsBeforeAuthNavigation } from "@/composables/useAuthNavigation";
import localize from "@/utils/localization";
import { withPrefix } from "@/utils/redirect";
import { errorMessageAsString } from "@/utils/simple-error";
Expand Down Expand Up @@ -70,15 +72,24 @@ const registerColumnDisplay = computed(() => Boolean(props.termsUrl));
async function submit() {
disableCreate.value = true;

// Stop polling and abort in-flight axios/GalaxyApi before sending the
// register POST — otherwise a late anonymous-cookie response can overwrite
// the authenticated cookie we're about to receive.
discardActiveConnectionsBeforeAuthNavigation();

try {
const response = await axios.post(withPrefix("/user/create"), {
email: email.value,
username: username.value,
password: password.value,
confirm: confirm.value,
subscribe: subscribe.value,
session_csrf_token: props.sessionCsrfToken,
});
const response = await axios.post(
withPrefix("/user/create"),
{
email: email.value,
username: username.value,
password: password.value,
confirm: confirm.value,
subscribe: subscribe.value,
session_csrf_token: props.sessionCsrfToken,
},
{ headers: { [SKIP_PENDING_REQUESTS_HEADER]: "1" } },
);

if (response.data.message && response.data.status) {
Toast.info(response.data.message);
Expand Down
25 changes: 25 additions & 0 deletions client/src/composables/useAuthNavigation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { cancelPendingRequests } from "@/api/pendingRequests";
import { useEntryPointStore } from "@/stores/entryPointStore";
import { useHistoryStore } from "@/stores/historyStore";
import { useNotificationsStore } from "@/stores/notificationsStore";

/**
* Tear down every long-lived connection and cancel every in-flight request
* (both axios and ``openapi-fetch``/GalaxyApi) so that nothing issued under
* the old anonymous ``galaxysession`` cookie can land on the server after
* ``handle_user_login`` has invalidated it. See
* ``client/src/api/pendingRequests.ts`` for the race this guards against.
*
* Call this synchronously as the first step of a login or registration
* submit, before the authenticating POST goes out. The shared abort
* controller is rotated, so the login/register POST (issued right after)
* will use a fresh signal and is not affected.
*/
export function discardActiveConnectionsBeforeAuthNavigation() {
// Stop polling watchers first so they can't kick off new fetches, then
// abort any requests still in flight via the shared AbortController.
useHistoryStore().stopWatchingHistory();
useEntryPointStore().stopWatchingEntryPoints();
useNotificationsStore().stopWatchingNotifications();
cancelPendingRequests();
}
7 changes: 7 additions & 0 deletions client/src/entry/analysis/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { createPinia, PiniaVuePlugin } from "pinia";
import Vue from "vue";

import { installPendingRequestsInterceptor } from "@/api/pendingRequests";
import { initGalaxyInstance } from "@/app";
import { initSentry } from "@/app/addons/sentry";
import { initWebhooks } from "@/app/addons/webhooks";
Expand All @@ -13,6 +14,12 @@ import App from "./App.vue";
Vue.use(PiniaVuePlugin);
const pinia = createPinia();

// Attach the shared AbortController signal to every outgoing axios request
// so we can cancel in-flight anonymous-cookie requests before login/register
// navigates — otherwise their late ``Set-Cookie: galaxysession=<anon>`` can
// clobber the authenticated cookie.
installPendingRequestsInterceptor();

window.addEventListener("load", async () => {
// Create Galaxy object
const Galaxy = await initGalaxyInstance();
Expand Down
10 changes: 6 additions & 4 deletions client/src/stores/entryPointStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,11 @@ interface EntryPoint {
}

export const useEntryPointStore = defineStore("entryPointStore", () => {
const { startWatchingResource: startWatchingEntryPoints } = useResourceWatcher(fetchEntryPoints, {
shortPollingInterval: ACTIVE_POLLING_INTERVAL,
enableBackgroundPolling: false, // No need to poll in the background
});
const { startWatchingResource: startWatchingEntryPoints, stopWatchingResource: stopWatchingEntryPoints } =
useResourceWatcher(fetchEntryPoints, {
shortPollingInterval: ACTIVE_POLLING_INTERVAL,
enableBackgroundPolling: false, // No need to poll in the background
});

const entryPoints = ref<EntryPoint[]>([]);

Expand Down Expand Up @@ -92,5 +93,6 @@ export const useEntryPointStore = defineStore("entryPointStore", () => {
updateEntryPoints,
removeEntryPoint,
startWatchingEntryPoints,
stopWatchingEntryPoints,
};
});
16 changes: 9 additions & 7 deletions client/src/stores/historyStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,13 +379,14 @@ export const useHistoryStore = defineStore("historyStore", () => {
return watchHistorySuppliedApp(app);
}

const { startWatchingResource: startWatchingHistory, isWatchingResource: isWatchingHistory } = useResourceWatcher(
watchHistory,
{
shortPollingInterval: ACTIVE_POLLING_INTERVAL,
longPollingInterval: INACTIVE_POLLING_INTERVAL,
},
);
const {
startWatchingResource: startWatchingHistory,
stopWatchingResource: stopWatchingHistory,
isWatchingResource: isWatchingHistory,
} = useResourceWatcher(watchHistory, {
shortPollingInterval: ACTIVE_POLLING_INTERVAL,
longPollingInterval: INACTIVE_POLLING_INTERVAL,
});

async function loadHistoryById(historyId: string): Promise<HistorySummaryExtended | undefined> {
if (!isLoadingHistory.has(historyId)) {
Expand Down Expand Up @@ -508,6 +509,7 @@ export const useHistoryStore = defineStore("historyStore", () => {
restoreHistories,
handleTotalCountChange,
startWatchingHistory,
stopWatchingHistory,
isWatchingHistory,
loadCurrentHistory,
loadCurrentHistoryId,
Expand Down
10 changes: 6 additions & 4 deletions client/src/stores/notificationsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@ const ACTIVE_POLLING_INTERVAL = 30000; // 30 seconds
const INACTIVE_POLLING_INTERVAL = ACTIVE_POLLING_INTERVAL * 20; // 10 minutes

export const useNotificationsStore = defineStore("notificationsStore", () => {
const { startWatchingResource: startWatchingNotifications } = useResourceWatcher(getNotificationStatus, {
shortPollingInterval: ACTIVE_POLLING_INTERVAL,
longPollingInterval: INACTIVE_POLLING_INTERVAL,
});
const { startWatchingResource: startWatchingNotifications, stopWatchingResource: stopWatchingNotifications } =
useResourceWatcher(getNotificationStatus, {
shortPollingInterval: ACTIVE_POLLING_INTERVAL,
longPollingInterval: INACTIVE_POLLING_INTERVAL,
});
const broadcastsStore = useBroadcastsStore();

const totalUnreadCount = ref<number>(0);
Expand Down Expand Up @@ -106,5 +107,6 @@ export const useNotificationsStore = defineStore("notificationsStore", () => {
updateNotification,
updateBatchNotification,
startWatchingNotifications,
stopWatchingNotifications,
};
});
Loading