mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
Fix SaaS mobile scanner being auth-gated under /app base path (#6642)
This commit is contained in:
@@ -17,8 +17,9 @@ import "@app/styles/index.css";
|
|||||||
// Import file ID debugging helpers (development only)
|
// Import file ID debugging helpers (development only)
|
||||||
import "@app/utils/fileIdSafety";
|
import "@app/utils/fileIdSafety";
|
||||||
|
|
||||||
// Minimal providers for mobile scanner - no API calls, no authentication
|
// Minimal providers for the public, no-auth mobile-scanner page - no API
|
||||||
function MobileScannerProviders({ children }: { children: React.ReactNode }) {
|
// calls, no authentication
|
||||||
|
function PublicRouteProviders({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<PreferencesProvider>
|
<PreferencesProvider>
|
||||||
<RainbowThemeProvider>{children}</RainbowThemeProvider>
|
<RainbowThemeProvider>{children}</RainbowThemeProvider>
|
||||||
@@ -34,9 +35,9 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="/mobile-scanner"
|
path="/mobile-scanner"
|
||||||
element={
|
element={
|
||||||
<MobileScannerProviders>
|
<PublicRouteProviders>
|
||||||
<MobileScannerPage />
|
<MobileScannerPage />
|
||||||
</MobileScannerProviders>
|
</PublicRouteProviders>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import ErrorRoundedIcon from "@mui/icons-material/ErrorRounded";
|
|||||||
import CheckRoundedIcon from "@mui/icons-material/CheckRounded";
|
import CheckRoundedIcon from "@mui/icons-material/CheckRounded";
|
||||||
import WarningRoundedIcon from "@mui/icons-material/WarningRounded";
|
import WarningRoundedIcon from "@mui/icons-material/WarningRounded";
|
||||||
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from "@app/styles/zIndex";
|
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from "@app/styles/zIndex";
|
||||||
import { withBasePath } from "@app/constants/app";
|
import { BASE_PATH } from "@app/constants/app";
|
||||||
|
import { buildMobileScannerUrl } from "@app/utils/mobileScannerUrl";
|
||||||
import { convertImageToPdf, isImageFile } from "@app/utils/imageToPdfUtils";
|
import { convertImageToPdf, isImageFile } from "@app/utils/imageToPdfUtils";
|
||||||
import apiClient from "@app/services/apiClient";
|
import apiClient from "@app/services/apiClient";
|
||||||
|
|
||||||
@@ -83,27 +84,16 @@ export default function MobileUploadModal({
|
|||||||
const timerIntervalRef = useRef<number | null>(null);
|
const timerIntervalRef = useRef<number | null>(null);
|
||||||
const processedFiles = useRef<Set<string>>(new Set());
|
const processedFiles = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
// A configured server_url/frontendUrl already includes any subpath, so append
|
// Build the QR-code URL the phone opens. It must land on the public
|
||||||
// the route directly; only the bare-origin fallback needs withBasePath.
|
// /mobile-scanner route under the app's base path, otherwise the phone hits
|
||||||
const configuredUrl = (
|
// the auth-gated catch-all route and is bounced to the login page.
|
||||||
localStorage.getItem("server_url") ||
|
const mobileUrl = buildMobileScannerUrl({
|
||||||
config?.frontendUrl ||
|
configuredUrl:
|
||||||
""
|
localStorage.getItem("server_url") || config?.frontendUrl || "",
|
||||||
).trim();
|
sessionId,
|
||||||
let mobileBase = "";
|
origin: window.location.origin,
|
||||||
if (configuredUrl) {
|
basePath: BASE_PATH,
|
||||||
try {
|
});
|
||||||
const parsed = new URL(configuredUrl);
|
|
||||||
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
|
|
||||||
mobileBase = configuredUrl.replace(/\/$/, "");
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// invalid configured URL — fall back to origin
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const mobileUrl = mobileBase
|
|
||||||
? `${mobileBase}/mobile-scanner?session=${sessionId}`
|
|
||||||
: `${window.location.origin}${withBasePath("/mobile-scanner")}?session=${sessionId}`;
|
|
||||||
|
|
||||||
// Create session on backend
|
// Create session on backend
|
||||||
const createSession = useCallback(
|
const createSession = useCallback(
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for buildMobileScannerUrl.
|
||||||
|
*
|
||||||
|
* Regression guard: the SaaS frontend is served under a base path (e.g.
|
||||||
|
* `/app`), and `/mobile-scanner` is a public route that only matches under that
|
||||||
|
* base path. The backend advertises `frontendUrl` as a bare origin (no
|
||||||
|
* subpath), so the generated QR URL must still carry the base path. Dropping it
|
||||||
|
* sent phones to the auth-gated catch-all route / login page.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, test, expect } from "vitest";
|
||||||
|
import { buildMobileScannerUrl } from "@app/utils/mobileScannerUrl";
|
||||||
|
|
||||||
|
const sessionId = "abc-123";
|
||||||
|
|
||||||
|
describe("buildMobileScannerUrl", () => {
|
||||||
|
test("origin-only frontendUrl keeps the app base path (SaaS web regression)", () => {
|
||||||
|
expect(
|
||||||
|
buildMobileScannerUrl({
|
||||||
|
configuredUrl: "https://app.stirlingpdf.com",
|
||||||
|
sessionId,
|
||||||
|
origin: "https://app.stirlingpdf.com",
|
||||||
|
basePath: "/app",
|
||||||
|
}),
|
||||||
|
).toBe("https://app.stirlingpdf.com/app/mobile-scanner?session=abc-123");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("origin-only frontendUrl with a trailing slash keeps the app base path", () => {
|
||||||
|
expect(
|
||||||
|
buildMobileScannerUrl({
|
||||||
|
configuredUrl: "https://app.stirlingpdf.com/",
|
||||||
|
sessionId,
|
||||||
|
origin: "https://app.stirlingpdf.com",
|
||||||
|
basePath: "/app",
|
||||||
|
}),
|
||||||
|
).toBe("https://app.stirlingpdf.com/app/mobile-scanner?session=abc-123");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("configured URL that already carries the subpath is used verbatim (no doubled base)", () => {
|
||||||
|
expect(
|
||||||
|
buildMobileScannerUrl({
|
||||||
|
configuredUrl: "https://app.stirlingpdf.com/app",
|
||||||
|
sessionId,
|
||||||
|
origin: "https://elsewhere.example",
|
||||||
|
basePath: "/app",
|
||||||
|
}),
|
||||||
|
).toBe("https://app.stirlingpdf.com/app/mobile-scanner?session=abc-123");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("configured URL subpath with trailing slash is normalized", () => {
|
||||||
|
expect(
|
||||||
|
buildMobileScannerUrl({
|
||||||
|
configuredUrl: "https://host.example/app/",
|
||||||
|
sessionId,
|
||||||
|
origin: "https://host.example",
|
||||||
|
basePath: "",
|
||||||
|
}),
|
||||||
|
).toBe("https://host.example/app/mobile-scanner?session=abc-123");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no base path and no configured URL uses the current origin", () => {
|
||||||
|
expect(
|
||||||
|
buildMobileScannerUrl({
|
||||||
|
configuredUrl: "",
|
||||||
|
sessionId,
|
||||||
|
origin: "http://localhost:5173",
|
||||||
|
basePath: "",
|
||||||
|
}),
|
||||||
|
).toBe("http://localhost:5173/mobile-scanner?session=abc-123");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty configured URL falls back to origin + base path", () => {
|
||||||
|
expect(
|
||||||
|
buildMobileScannerUrl({
|
||||||
|
configuredUrl: " ",
|
||||||
|
sessionId,
|
||||||
|
origin: "https://app.stirlingpdf.com",
|
||||||
|
basePath: "/app",
|
||||||
|
}),
|
||||||
|
).toBe("https://app.stirlingpdf.com/app/mobile-scanner?session=abc-123");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("custom port (LAN/desktop) is preserved", () => {
|
||||||
|
expect(
|
||||||
|
buildMobileScannerUrl({
|
||||||
|
configuredUrl: "http://192.168.1.50:8080",
|
||||||
|
sessionId,
|
||||||
|
origin: "http://localhost:8080",
|
||||||
|
basePath: "",
|
||||||
|
}),
|
||||||
|
).toBe("http://192.168.1.50:8080/mobile-scanner?session=abc-123");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("invalid configured URL falls back to the current origin + base path", () => {
|
||||||
|
expect(
|
||||||
|
buildMobileScannerUrl({
|
||||||
|
configuredUrl: "not a url",
|
||||||
|
sessionId,
|
||||||
|
origin: "https://app.stirlingpdf.com",
|
||||||
|
basePath: "/app",
|
||||||
|
}),
|
||||||
|
).toBe("https://app.stirlingpdf.com/app/mobile-scanner?session=abc-123");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("non-http(s) configured URL is ignored (no javascript: injection)", () => {
|
||||||
|
expect(
|
||||||
|
buildMobileScannerUrl({
|
||||||
|
configuredUrl: "javascript:alert(1)",
|
||||||
|
sessionId,
|
||||||
|
origin: "https://app.stirlingpdf.com",
|
||||||
|
basePath: "/app",
|
||||||
|
}),
|
||||||
|
).toBe("https://app.stirlingpdf.com/app/mobile-scanner?session=abc-123");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* Build the URL a phone opens (via the QR code) to reach the SPA's
|
||||||
|
* `/mobile-scanner` route.
|
||||||
|
*
|
||||||
|
* That route is a public, top-level route. It lives under the app's base path,
|
||||||
|
* which is the router's `basename`. If the generated URL omits the base path,
|
||||||
|
* the phone loads a path the router can't match, falls through to the
|
||||||
|
* auth-gated catch-all route, and gets bounced to the login page. So the base
|
||||||
|
* path must always be present.
|
||||||
|
*
|
||||||
|
* A configured `server_url`/`frontendUrl` supplies the host the phone should
|
||||||
|
* reach (desktop / LAN / reverse proxy):
|
||||||
|
* - origin only (no subpath): apply the app's base path. The backend's
|
||||||
|
* `resolveFrontendUrl` advertises a bare origin with no subpath, so this is
|
||||||
|
* the common SaaS web case (frontend served under e.g. `/app`).
|
||||||
|
* - already carries a subpath: it points at the target SPA's base directly,
|
||||||
|
* so use it verbatim and do not add the base path again (no doubled base).
|
||||||
|
*
|
||||||
|
* With no usable configured URL, fall back to the current origin + base path.
|
||||||
|
*/
|
||||||
|
export function buildMobileScannerUrl(params: {
|
||||||
|
configuredUrl: string;
|
||||||
|
sessionId: string;
|
||||||
|
origin: string;
|
||||||
|
basePath: string;
|
||||||
|
}): string {
|
||||||
|
const { configuredUrl, sessionId, origin, basePath } = params;
|
||||||
|
const query = `?session=${sessionId}`;
|
||||||
|
const route = `${basePath}/mobile-scanner`;
|
||||||
|
|
||||||
|
const trimmed = configuredUrl.trim();
|
||||||
|
if (trimmed) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(trimmed);
|
||||||
|
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
|
||||||
|
const subpath = parsed.pathname.replace(/\/+$/, "");
|
||||||
|
return subpath
|
||||||
|
? `${parsed.origin}${subpath}/mobile-scanner${query}`
|
||||||
|
: `${parsed.origin}${route}${query}`;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// invalid configured URL — fall through to the current-origin default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${origin}${route}${query}`;
|
||||||
|
}
|
||||||
@@ -26,8 +26,9 @@ import "@app/styles/auth-theme.css";
|
|||||||
// Import file ID debugging helpers (development only)
|
// Import file ID debugging helpers (development only)
|
||||||
import "@app/utils/fileIdSafety";
|
import "@app/utils/fileIdSafety";
|
||||||
|
|
||||||
// Minimal providers for mobile scanner - no API calls, no authentication
|
// Minimal providers for public, no-auth pages (mobile scanner, participant
|
||||||
function MobileScannerProviders({ children }: { children: React.ReactNode }) {
|
// signing) - no API calls, no authentication
|
||||||
|
function PublicRouteProviders({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<PreferencesProvider>
|
<PreferencesProvider>
|
||||||
<RainbowThemeProvider>{children}</RainbowThemeProvider>
|
<RainbowThemeProvider>{children}</RainbowThemeProvider>
|
||||||
@@ -50,9 +51,9 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="/mobile-scanner"
|
path="/mobile-scanner"
|
||||||
element={
|
element={
|
||||||
<MobileScannerProviders>
|
<PublicRouteProviders>
|
||||||
<MobileScannerPage />
|
<MobileScannerPage />
|
||||||
</MobileScannerProviders>
|
</PublicRouteProviders>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -60,9 +61,9 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="/workflow/sign/:token"
|
path="/workflow/sign/:token"
|
||||||
element={
|
element={
|
||||||
<MobileScannerProviders>
|
<PublicRouteProviders>
|
||||||
<ParticipantViewPage />
|
<ParticipantViewPage />
|
||||||
</MobileScannerProviders>
|
</PublicRouteProviders>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Suspense } from "react";
|
import { Suspense, type ReactNode } from "react";
|
||||||
import { Routes, Route, useLocation } from "react-router-dom";
|
import { Routes, Route, useLocation } from "react-router-dom";
|
||||||
import { isAuthRoute } from "@app/utils/pathUtils";
|
import { isAuthRoute } from "@app/utils/pathUtils";
|
||||||
import { AppProviders } from "@app/components/AppProviders";
|
import { AppProviders } from "@app/components/AppProviders";
|
||||||
|
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
|
||||||
|
import { RainbowThemeProvider } from "@app/components/shared/RainbowThemeProvider";
|
||||||
import { setBaseUrl } from "@app/constants/app";
|
import { setBaseUrl } from "@app/constants/app";
|
||||||
import type { AppConfig } from "@app/contexts/AppConfigContext";
|
import type { AppConfig } from "@app/contexts/AppConfigContext";
|
||||||
import { AppLayout } from "@app/components/AppLayout";
|
import { AppLayout } from "@app/components/AppLayout";
|
||||||
@@ -13,6 +15,8 @@ import Signup from "@app/routes/Signup";
|
|||||||
import AuthCallback from "@app/routes/AuthCallback";
|
import AuthCallback from "@app/routes/AuthCallback";
|
||||||
import ResetPassword from "@app/routes/ResetPassword";
|
import ResetPassword from "@app/routes/ResetPassword";
|
||||||
import OAuthConsent from "@app/routes/OAuthConsent";
|
import OAuthConsent from "@app/routes/OAuthConsent";
|
||||||
|
import ShareLinkPage from "@app/routes/ShareLinkPage";
|
||||||
|
import MobileScannerPage from "@app/pages/MobileScannerPage";
|
||||||
import OnboardingBootstrap from "@app/components/OnboardingBootstrap";
|
import OnboardingBootstrap from "@app/components/OnboardingBootstrap";
|
||||||
import TrialExpiredBootstrap from "@app/components/TrialExpiredBootstrap";
|
import TrialExpiredBootstrap from "@app/components/TrialExpiredBootstrap";
|
||||||
import SignupRequiredBootstrap from "@app/components/SignupRequiredBootstrap";
|
import SignupRequiredBootstrap from "@app/components/SignupRequiredBootstrap";
|
||||||
@@ -31,6 +35,17 @@ function handleConfigLoaded(config: AppConfig) {
|
|||||||
if (config.baseUrl) setBaseUrl(config.baseUrl);
|
if (config.baseUrl) setBaseUrl(config.baseUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Minimal providers for the public, no-auth mobile-scanner page. Just theme +
|
||||||
|
// preferences, no AppProviders, so no auth and no backend bootstrap - it
|
||||||
|
// renders without a logged-in session.
|
||||||
|
function PublicRouteProviders({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<PreferencesProvider>
|
||||||
|
<RainbowThemeProvider>{children}</RainbowThemeProvider>
|
||||||
|
</PreferencesProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Onboarding and trial-expired modals must never cover auth-flow pages
|
* Onboarding and trial-expired modals must never cover auth-flow pages
|
||||||
* (login, signup, OAuth consent): they steal focus from the task the user
|
* (login, signup, OAuth consent): they steal focus from the task the user
|
||||||
@@ -54,22 +69,47 @@ function NonAuthBootstraps() {
|
|||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<LoadingFallback />}>
|
<Suspense fallback={<LoadingFallback />}>
|
||||||
<AppProviders
|
<Routes>
|
||||||
appConfigProviderProps={{ onConfigLoaded: handleConfigLoaded }}
|
{/* Mobile scanner - public, no auth. Opened on a phone via the QR code,
|
||||||
>
|
so it must render without a logged-in session. Kept outside
|
||||||
<AppLayout>
|
AppProviders or it falls through to the auth-gated catch-all. */}
|
||||||
<NonAuthBootstraps />
|
<Route
|
||||||
<Routes>
|
path="/mobile-scanner"
|
||||||
<Route path="/login" element={<Login />} />
|
element={
|
||||||
<Route path="/signup" element={<Signup />} />
|
<PublicRouteProviders>
|
||||||
<Route path="/auth/callback" element={<AuthCallback />} />
|
<MobileScannerPage />
|
||||||
<Route path="/auth/reset" element={<ResetPassword />} />
|
</PublicRouteProviders>
|
||||||
<Route path="/oauth/consent" element={<OAuthConsent />} />
|
}
|
||||||
<Route path="/*" element={<Landing />} />
|
/>
|
||||||
</Routes>
|
|
||||||
<OnboardingTour />
|
{/* Everything else needs the auth/backend providers. */}
|
||||||
</AppLayout>
|
<Route
|
||||||
</AppProviders>
|
path="*"
|
||||||
|
element={
|
||||||
|
<AppProviders
|
||||||
|
appConfigProviderProps={{ onConfigLoaded: handleConfigLoaded }}
|
||||||
|
>
|
||||||
|
<AppLayout>
|
||||||
|
<NonAuthBootstraps />
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/signup" element={<Signup />} />
|
||||||
|
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||||
|
<Route path="/auth/reset" element={<ResetPassword />} />
|
||||||
|
<Route path="/oauth/consent" element={<OAuthConsent />} />
|
||||||
|
{/* Shared-file links. Team invites are NOT routed here: on
|
||||||
|
SaaS they are accepted in-app via the Supabase team
|
||||||
|
invitation banner, not the Spring password-based
|
||||||
|
/invite/:token page used by the self-hosted build. */}
|
||||||
|
<Route path="/share/:token" element={<ShareLinkPage />} />
|
||||||
|
<Route path="/*" element={<Landing />} />
|
||||||
|
</Routes>
|
||||||
|
<OnboardingTour />
|
||||||
|
</AppLayout>
|
||||||
|
</AppProviders>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user