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.
This commit is contained in:
dealerweb
2026-09-24 21:08:17 +02:00
parent e33ab899b0
commit 3eb9ade572
3 changed files with 43 additions and 2 deletions
@@ -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<typeof vi.fn>;
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(<WopiEditor target={{ kind: 'file', id: 'f1', name: 'report.docx' }} onClose={() => {}} />);
await waitFor(() => expect(fetchMock).toHaveBeenCalled());
expect(fetchMock.mock.calls[0][0]).toBe('/webmail/api/wopi/launch');
});
});
+2 -1
View File
@@ -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<string, string> =
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,
+2 -1
View File
@@ -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<WopiStatus> {
// 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,