Files
Stirling-PDF/frontend/src/core/services/httpErrorHandler.ts
T
c294e9b2cb fix file sharing bug (#6161)
# Description of Changes

Fixes share-link navigation for SSO users. Reported on v2.9.2 with
`SSOAutoLogin: true`: clicking a `/share/<token>` link in an email
redirected the user to the home page after SSO instead of the shared
file.

## Root cause

Three compounding issues had to be fixed together; the first was the
initial symptom but the other two only surfaced during live
verification.

1. **Spring Security blocked `/share/<token>` for unauthenticated
users.** The route wasn't in `RequestUriUtils.isPublicAuthEndpoint`, so
the server 302'd straight to `/login` before React could load
`ShareLinkPage`. The share URL was lost because `NullRequestCache` is
configured and never persisted the original destination.

2. **`httpErrorHandler` full-page-redirected to `/login?from=<path>` on
any unhandled 401** (fired by `LicenseContext`, `AppConfig`, etc. during
normal ShareLinkPage mount). That *did* preserve the return path — but
**Spring Security strips query strings from `/login`** (302 to bare
`/login`), so `?from=` never reached React. Confirmed via `curl -i
http://localhost:8080/login?from=xyz` → `Location: /login`.

3. **`AuthCallback.tsx` unconditionally `navigate("/")`** after the
SAML/OAuth round-trip, discarding any intended destination.

## Fix

**Backend** — make `/share/<token>` a public SPA bootstrap, data APIs
stay protected:
- `RequestUriUtils.isPublicAuthEndpoint` — permits `^/share/[^/]+/?$`
(tight regex, single token segment only; `/share/<token>/anything` stays
protected).
- `ReactRoutingController` — dedicated `@GetMapping("/share/{token}")`
mirroring `/auth/callback`.
- `/api/v1/storage/share-links/**` remains behind Spring Security with
its existing `canAccessShareLink` check.

**Frontend** — persist the return path across full-page redirects via
`sessionStorage` (same-origin, survives the SSO round-trip):
- `httpErrorHandler.ts` — stashes current pathname to
`stirling_post_login_path` before the 401 → `/login` redirect.
- `springAuthClient.ts` — new `isSafePostLoginRedirect` /
`setPostLoginRedirectPath` / `consumePostLoginRedirectPath` helpers
(rejects protocol-relative URLs and auth-plumbing paths to guard against
open-redirect abuse).
- `Login.tsx` — on explicit user sign-in, read path from
`location.state` or `?from=` query and stash it; don't clobber an
already-stashed value.
- `AuthCallback.tsx` — consume the stashed path (single-use) and
`navigate(target)` instead of always `/`.


---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.

---------

Co-authored-by: EthanHealy01 <[email protected]>
2026-04-23 14:52:25 +01:00

160 lines
5.3 KiB
TypeScript

// frontend/src/services/httpErrorHandler.ts
import { alert } from "@app/components/toast";
import {
broadcastErroredFiles,
extractErrorFileIds,
normalizeAxiosErrorData,
} from "@app/services/errorUtils";
import { showSpecialErrorToast } from "@app/services/specialErrorToasts";
import { handleSaaSError } from "@app/services/saasErrorInterceptor";
import {
clampText,
extractAxiosErrorMessage,
} from "@app/services/httpErrorUtils";
// Module-scoped state to reduce global variable usage
const recentSpecialByEndpoint: Record<string, number> = {};
const SPECIAL_SUPPRESS_MS = 1500; // brief window to suppress generic duplicate after special toast
// Mirrors the key in proprietary/auth/springAuthClient.ts; AuthCallback consumes it.
const POST_LOGIN_REDIRECT_STORAGE_KEY = "stirling_post_login_path";
function isSafePostLoginPath(path: string): boolean {
if (
!path.startsWith("/") ||
path.startsWith("//") ||
path.startsWith("/\\")
) {
return false;
}
const lowered = path.toLowerCase();
return (
!lowered.startsWith("/login") &&
!lowered.startsWith("/auth/") &&
!lowered.startsWith("/oauth2") &&
!lowered.startsWith("/saml2")
);
}
function stashPostLoginRedirect(path: string): void {
try {
if (typeof window === "undefined" || !isSafePostLoginPath(path)) return;
window.sessionStorage.setItem(POST_LOGIN_REDIRECT_STORAGE_KEY, path);
} catch {
// sessionStorage unavailable (private mode) — fail open
}
}
/**
* Handles HTTP errors with toast notifications and file error broadcasting
* Returns true if the error should be suppressed (deduplicated), false otherwise
*/
export async function handleHttpError(error: any): Promise<boolean> {
const skipAuthRedirect = error?.config?.skipAuthRedirect === true;
// Check if this error should skip the global toast (component will handle it)
if (error?.config?.suppressErrorToast === true) {
return false; // Don't show global toast, but continue rejection
}
// Handle 401 authentication errors
const status: number | undefined = error?.response?.status;
if (status === 401) {
const pathname = window.location.pathname;
// Check if we're already on an auth page
const isAuthPage =
pathname.includes("/login") ||
pathname.includes("/signup") ||
pathname.includes("/auth/") ||
pathname.includes("/invite/");
// If not on auth page, redirect to login with expired session message
if (!isAuthPage && !skipAuthRedirect) {
console.debug("[httpErrorHandler] 401 detected, redirecting to login");
// Spring 302-strips the ?from= query from /login, so stash the return
// path in sessionStorage (AuthCallback reads it after SSO round-trip).
const currentLocation = window.location.pathname + window.location.search;
stashPostLoginRedirect(currentLocation);
let hadStoredJwt = false;
try {
hadStoredJwt = Boolean(localStorage.getItem("stirling_jwt"));
} catch {
// ignore storage access failures
}
const expiredPrefix = hadStoredJwt ? "expired=true&" : "";
window.location.href = `/login?${expiredPrefix}from=${encodeURIComponent(currentLocation)}`;
return true; // Suppress toast since we're redirecting
}
// On auth pages, suppress the toast (user is already trying to authenticate)
console.debug("[httpErrorHandler] Suppressing 401 on auth page:", pathname);
return true;
}
if (handleSaaSError(error)) return true;
// Compute title/body (friendly) from the error object
const { title, body } = extractAxiosErrorMessage(error);
// Normalize response data ONCE, reuse for both ID extraction and special-toast matching
const raw = error?.response?.data as any;
let normalized: unknown = raw;
try {
normalized = await normalizeAxiosErrorData(raw);
} catch (e) {
console.debug("normalizeAxiosErrorData", e);
}
// 1) If server sends structured file IDs for failures, also mark them errored in UI
try {
const ids = extractErrorFileIds(normalized);
if (ids && ids.length > 0) {
broadcastErroredFiles(ids);
}
} catch (e) {
console.debug("extractErrorFileIds", e);
}
// 2) Generic-vs-special dedupe by endpoint
const url: string | undefined = error?.config?.url;
const now = Date.now();
const isSpecial =
status === 422 ||
status === 409 || // often actionable conflicts
/Failed files:/.test(body) ||
/invalid\/corrupted file\(s\)/i.test(body);
if (isSpecial && url) {
recentSpecialByEndpoint[url] = now;
}
if (!isSpecial && url) {
const last = recentSpecialByEndpoint[url] || 0;
if (now - last < SPECIAL_SUPPRESS_MS) {
return true; // Suppress this error (deduplicated)
}
}
// 3) Show specialized friendly toasts if matched; otherwise show the generic one
let rawString: string | undefined;
try {
rawString =
typeof normalized === "string" ? normalized : JSON.stringify(normalized);
} catch (e) {
console.debug("extractErrorFileIds", e);
}
const handled = showSpecialErrorToast(rawString, { status });
if (!handled) {
const displayBody = clampText(body);
alert({
alertType: "error",
title,
body: displayBody,
expandable: true,
isPersistentPopup: false,
});
}
return false; // Error was handled with toast, continue normal rejection
}