From 3eb9ade572e540df452e2a46bc72c638101c08f2 Mon Sep 17 00:00:00 2001 From: dealerweb Date: Thu, 24 Sep 2026 20:46:58 +0200 Subject: [PATCH] fix: send the WOPI status and launch requests under the base path Behind a reverse proxy at a sub-path (NEXT_PUBLIC_BASE_PATH, e.g. /webmail) both WOPI requests used a bare fetch('/api/wopi/...') and reached the mail server instead of the webmail. The status probe came back 404, so office editing always looked unavailable there - and since #1047 the probe runs for every opened message, logging the 404 each time. Route both through apiFetch like every other API call, and cover the sub-path mount with a test. --- .../files/__tests__/wopi-base-path.test.tsx | 39 +++++++++++++++++++ components/files/wopi-editor.tsx | 3 +- hooks/use-wopi-status.ts | 3 +- 3 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 components/files/__tests__/wopi-base-path.test.tsx diff --git a/components/files/__tests__/wopi-base-path.test.tsx b/components/files/__tests__/wopi-base-path.test.tsx new file mode 100644 index 000000000..9a3fc8957 --- /dev/null +++ b/components/files/__tests__/wopi-base-path.test.tsx @@ -0,0 +1,39 @@ +import { render, renderHook, waitFor, cleanup } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { useWopiStatus } from '@/hooks/use-wopi-status'; +import { WopiEditor } from '@/components/files/wopi-editor'; + +// Behind a reverse proxy at a sub-path (e.g. /webmail) a bare +// fetch('/api/...') reaches the mail server instead of the webmail and comes +// back 404, which left office editing permanently "unavailable" there. Both +// WOPI requests have to carry the mount prefix. + +vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key })); + +let fetchMock: ReturnType; + +beforeEach(() => { + window.history.pushState({}, '', '/webmail/de/mail'); + fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 404, json: async () => ({}) }); + vi.stubGlobal('fetch', fetchMock); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + window.history.pushState({}, '', '/'); +}); + +describe('WOPI requests under a sub-path mount', () => { + it('probes the editor status under the mount prefix', async () => { + renderHook(() => useWopiStatus(true)); + await waitFor(() => expect(fetchMock).toHaveBeenCalled()); + expect(fetchMock.mock.calls[0][0]).toBe('/webmail/api/wopi/status'); + }); + + it('launches the editor under the mount prefix', async () => { + render( {}} />); + await waitFor(() => expect(fetchMock).toHaveBeenCalled()); + expect(fetchMock.mock.calls[0][0]).toBe('/webmail/api/wopi/launch'); + }); +}); diff --git a/components/files/wopi-editor.tsx b/components/files/wopi-editor.tsx index d5f8e758d..ec647d0bf 100644 --- a/components/files/wopi-editor.tsx +++ b/components/files/wopi-editor.tsx @@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react"; import { useTranslations } from "next-intl"; import { Loader2, X } from "@/components/icons"; import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot"; +import { apiFetch } from "@/lib/browser-navigation"; /** What the overlay opens: a Files node, or a mail attachment (read-only, #1047). */ export type WopiDocument = @@ -55,7 +56,7 @@ export function WopiEditor({ target, accountId, slot, onClose }: WopiEditorProps try { const slotHeaders: Record = typeof slot === "number" ? { "X-JMAP-Cookie-Slot": String(slot) } : getActiveAccountSlotHeaders(); - const res = await fetch("/api/wopi/launch", { + const res = await apiFetch("/api/wopi/launch", { method: "POST", headers: { "Content-Type": "application/json", ...slotHeaders }, body: launchBody, diff --git a/hooks/use-wopi-status.ts b/hooks/use-wopi-status.ts index 977fc02d6..e3b3b5521 100644 --- a/hooks/use-wopi-status.ts +++ b/hooks/use-wopi-status.ts @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { IS_LITE } from "@/lib/lite"; +import { apiFetch } from "@/lib/browser-navigation"; /** * Whether a WOPI document editor is configured for this deployment and which @@ -22,7 +23,7 @@ function fetchWopiStatus(): Promise { // The WOPI bridge is a server feature; the static build never has an editor. if (IS_LITE) return Promise.resolve(DISABLED); if (!statusPromise) { - statusPromise = fetch("/api/wopi/status") + statusPromise = apiFetch("/api/wopi/status") .then((res) => (res.ok ? res.json() : DISABLED)) .then((data) => ({ enabled: !!data?.enabled,