mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 08:16:22 +00:00
Merge branch 'feat/desktop-offline' into feat/do-browser-signin
This commit is contained in:
@@ -67,3 +67,26 @@ jobs:
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
# The build the release tarball and a manual install use, with its guard at
|
||||
# the end. Docker and the Lite images build another way (webpack, export), so
|
||||
# nothing else would notice this one breaking before a release does.
|
||||
standalone-build:
|
||||
name: Standalone build and guard
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { act, render, waitFor } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import React from 'react';
|
||||
import type { Editor } from '@tiptap/core';
|
||||
|
||||
import { RichTextEditor } from '../rich-text-editor';
|
||||
import { serializeEditorContent } from '../quoted-html';
|
||||
|
||||
// A reply's quoted inline images load while the caret already waits in the
|
||||
// body, and swapping them in re-sets the editor's content. That threw the caret
|
||||
// onto the quote as a node, where the first key typed replaced the whole quote
|
||||
// and the reply went out without the original or its images, or below the quote.
|
||||
|
||||
const PLACEHOLDER = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
||||
const IMAGE = 'data:image/png;base64,iVBORw0KGgo=';
|
||||
const body = (src: string, below: string) => `<p></p><p data-reply-quote-start="true">On Tuesday, C wrote:</p><div data-quoted-html=""><p>Original text</p><img src="${src}" alt="logo" data-cid="logo@x"></div>${below}`;
|
||||
|
||||
async function openReply(below: string) {
|
||||
let editor: Editor | null = null;
|
||||
const element = (content: string) => (
|
||||
<RichTextEditor content={content} onChange={() => {}} onEditorReady={(ed) => { editor = ed as unknown as Editor; }} />
|
||||
);
|
||||
const view = render(element(body(PLACEHOLDER, below)));
|
||||
await waitFor(() => expect(editor).not.toBeNull());
|
||||
const ed = editor as unknown as Editor;
|
||||
// Where focus('start') puts it; focus itself waits for a frame.
|
||||
act(() => { ed.commands.setTextSelection(1); });
|
||||
return { editor: ed, setContent: (content: string) => view.rerender(element(content)) };
|
||||
}
|
||||
|
||||
const type = (editor: Editor, text: string) => act(() => { editor.view.dispatch(editor.state.tr.insertText(text)); });
|
||||
|
||||
describe.each([
|
||||
['with a line below the quote', '<p></p>'],
|
||||
['with the quote at the very end', ''],
|
||||
])('typing in a reply once its inline images have loaded, %s', (_name, below) => {
|
||||
it('goes above the quote and keeps it and its image', async () => {
|
||||
const { editor, setContent } = await openReply(below);
|
||||
|
||||
setContent(body(IMAGE, below));
|
||||
type(editor, 'My answer');
|
||||
|
||||
const sent = serializeEditorContent(editor);
|
||||
expect(editor.state.doc.child(0).textContent).toBe('My answer');
|
||||
expect(sent).toContain('Original text');
|
||||
expect(sent).toContain(IMAGE);
|
||||
});
|
||||
|
||||
it('carries on where the user was already typing', async () => {
|
||||
const { editor, setContent } = await openReply(below);
|
||||
type(editor, 'Hel');
|
||||
|
||||
setContent(body(IMAGE, `${below}`).replace('<p></p>', '<p>Hel</p>'));
|
||||
type(editor, 'lo');
|
||||
|
||||
expect(editor.state.doc.child(0).textContent).toBe('Hello');
|
||||
expect(serializeEditorContent(editor)).toContain('Original text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('a body replaced by a shorter one', () => {
|
||||
it('leaves the caret inside it', async () => {
|
||||
const { editor, setContent } = await openReply('<p>A long line below the quote</p>');
|
||||
act(() => { editor.commands.setTextSelection(editor.state.doc.content.size - 2); });
|
||||
|
||||
setContent('<p>Short</p>');
|
||||
type(editor, '!');
|
||||
|
||||
expect(editor.state.doc.textContent).toContain('Short');
|
||||
expect(editor.state.doc.textContent).toContain('!');
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,22 @@ const QUOTED_REMOTE_ALLOWED_ATTR = "data-quoted-remote";
|
||||
// Atom blocks typing must never replace just because they were left selected.
|
||||
const ATOM_BLOCKS = new Set(["quotedHtml", "signatureBlock"]);
|
||||
|
||||
/**
|
||||
* Replacing the editor's content throws the caret to where the old selection
|
||||
* maps to: the quote as a node, where the next key typed replaces the whole
|
||||
* quote, or the line below it. There is no focus event for the guard below to
|
||||
* catch: a reply's inline images finish loading while the caret already waits
|
||||
* in the body. It goes back to where it was, or to the start if that is gone.
|
||||
*/
|
||||
export function restoreCaretAfterContentSync(editor: Editor, caret: number): void {
|
||||
const { doc } = editor.state;
|
||||
const near = Selection.near(doc.resolve(Math.min(caret, doc.content.size)));
|
||||
const target = near instanceof TextSelection ? near : Selection.atStart(doc);
|
||||
if (target instanceof TextSelection && !target.eq(editor.state.selection)) {
|
||||
editor.view.dispatch(editor.state.tr.setSelection(target));
|
||||
}
|
||||
}
|
||||
|
||||
// Reusable style for the quote bar when quoting email text (like in a reply).
|
||||
const QUOTE_BAR_STYLE =
|
||||
"border-left:2px solid #c5c5c5;padding-left:12px;margin-top:8px;";
|
||||
|
||||
@@ -24,7 +24,7 @@ import { TableRow } from "@tiptap/extension-table-row";
|
||||
import { TableHeader } from "@tiptap/extension-table-header";
|
||||
import { TableCell } from "@tiptap/extension-table-cell";
|
||||
import { ReplyQuoteCollapse } from "@/components/email/reply-quote-collapse";
|
||||
import { QuotedHtml, serializeEditorContent } from "@/components/email/quoted-html";
|
||||
import { QuotedHtml, restoreCaretAfterContentSync, serializeEditorContent } from "@/components/email/quoted-html";
|
||||
import { keepLoadedParagraphSpacing, paragraphStyleWithoutSpacing, trailingParagraphBreakRule } from "@/components/email/paragraph-breaks";
|
||||
import { SignatureBlock } from "@/components/email/signature-block";
|
||||
import { styledBlockAttributes } from "@/components/email/styled-block-attributes";
|
||||
@@ -614,7 +614,9 @@ export function RichTextEditor({
|
||||
if (!editor) return;
|
||||
const loaded = keepLoadedParagraphSpacing(content);
|
||||
if (loaded !== serializeEditorContent(editor)) {
|
||||
const caret = editor.state.selection.from;
|
||||
editor.commands.setContent(loaded, { emitUpdate: false });
|
||||
restoreCaretAfterContentSync(editor, caret);
|
||||
}
|
||||
}, [content, editor]);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ 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';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
// 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
|
||||
@@ -26,9 +27,9 @@ afterEach(() => {
|
||||
|
||||
describe('WOPI requests under a sub-path mount', () => {
|
||||
it('probes the editor status under the mount prefix', async () => {
|
||||
useAuthStore.setState({ isAuthenticated: true, client: {} as never });
|
||||
renderHook(() => useWopiStatus(true));
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalled());
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('/webmail/api/wopi/status');
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith('/webmail/api/wopi/status', undefined));
|
||||
});
|
||||
|
||||
it('launches the editor under the mount prefix', async () => {
|
||||
|
||||
@@ -3214,6 +3214,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
attachments: [payload.attachment],
|
||||
headers: labelSourceHeaders(email.headers),
|
||||
originalFrom: email.from,
|
||||
// The login holding the message, whose blob this attachment is.
|
||||
accountId: email.sourceClientAccountId ?? viewingAccountId ?? undefined,
|
||||
},
|
||||
});
|
||||
setComposerMode('forward');
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* A reply, forward or forward-as-attachment from a Pro message view names the
|
||||
* login holding the message. The composer defaults its From to that account and
|
||||
* copies the original's parts from it; without the login it takes the message
|
||||
* for the active account's, so the reply goes out from the wrong account, or
|
||||
* fails on a blobId that account doesn't have (#1511).
|
||||
*/
|
||||
|
||||
const viewerProps = vi.hoisted(() => ({ current: null as Record<string, unknown> | null }));
|
||||
|
||||
vi.mock('@/components/email/email-viewer', () => ({
|
||||
EmailViewer: (props: Record<string, unknown>) => {
|
||||
viewerProps.current = props;
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
vi.mock('@/components/error', () => ({
|
||||
ErrorBoundary: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children),
|
||||
EmailViewerErrorFallback: () => null,
|
||||
}));
|
||||
vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
|
||||
vi.mock('@/stores/toast-store', () => ({ toast: { error: () => {}, success: () => {} } }));
|
||||
vi.mock('@/hooks/use-pro-multi-account-identities', async (importOriginal) => ({
|
||||
...await importOriginal<typeof import('@/hooks/use-pro-multi-account-identities')>(),
|
||||
useProMultiAccountIdentities: () => ({ enabled: true, groups: [], allIdentities: [] }),
|
||||
}));
|
||||
|
||||
const { openComposeTab } = vi.hoisted(() => ({ openComposeTab: vi.fn() }));
|
||||
|
||||
function selectorStore<T extends object>(state: T) {
|
||||
const hook = (sel?: (s: T) => unknown) => (typeof sel === 'function' ? sel(state) : state);
|
||||
hook.getState = () => state;
|
||||
return hook;
|
||||
}
|
||||
|
||||
const auth = vi.hoisted(() => ({
|
||||
state: { client: null as unknown, activeAccountId: 'login-a', getClientForAccount: () => undefined },
|
||||
}));
|
||||
vi.mock('@/stores/auth-store', () => ({ useAuthStore: selectorStore(auth.state) }));
|
||||
|
||||
const MESSAGE = vi.hoisted(() => ({
|
||||
id: 'eaaaaab',
|
||||
blobId: 'blob-of-message',
|
||||
threadId: 't1',
|
||||
mailboxIds: { inbox: true },
|
||||
keywords: { $seen: true },
|
||||
from: [{ email: 'sender@example.com', name: 'Sender' }],
|
||||
to: [{ email: 'b@example.org' }],
|
||||
subject: 'Hello',
|
||||
receivedAt: '2026-01-01T00:00:00Z',
|
||||
bodyValues: { '1': { value: '<img src="cid:logo">' } },
|
||||
textBody: [],
|
||||
htmlBody: [{ partId: '1', type: 'text/html' }],
|
||||
attachments: [{ blobId: 'blob-of-logo', name: 'logo.png', type: 'image/png', size: 10, cid: 'logo', disposition: 'inline' }],
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/email-store', () => ({
|
||||
resolveEmailBlobContext: (_email: unknown, client: unknown) => ({ client, clientAccountId: auth.state.activeAccountId }),
|
||||
useEmailStore: selectorStore({
|
||||
fetchEmailContent: async () => MESSAGE,
|
||||
deleteEmail: async () => {},
|
||||
markAsRead: async () => {},
|
||||
toggleStar: async () => {},
|
||||
moveToMailbox: async () => {},
|
||||
setEmailKeywordsLocal: () => {},
|
||||
mailboxes: [],
|
||||
emails: [],
|
||||
selectedEmail: null,
|
||||
}),
|
||||
}));
|
||||
vi.mock('@/stores/identity-store', () => ({ useIdentityStore: selectorStore({ identities: [] }) }));
|
||||
vi.mock('@/stores/settings-store', () => ({ useSettingsStore: selectorStore({ emailDownloadTemplate: '' }) }));
|
||||
vi.mock('@/stores/pro-tab-store', () => ({
|
||||
useProTabStore: selectorStore({ openComposeTab, closeTab: () => {}, updateTabTitle: () => {} }),
|
||||
}));
|
||||
|
||||
import { ProEmailView } from '../pro-email-tab-body';
|
||||
|
||||
const ACTIONS = ['onReply', 'onReplyAll', 'onForward', 'onForwardAsAttachment'] as const;
|
||||
|
||||
async function open(props: { clientAccountId?: string; accountId?: string; override?: boolean }) {
|
||||
const client = { getEmail: vi.fn().mockResolvedValue(MESSAGE), getUsername: () => 'b@example.org', getAccountId: () => 'jmap-b' };
|
||||
const { override = true, ...rest } = props;
|
||||
if (!override) auth.state.client = client;
|
||||
const view = render(<ProEmailView emailId="eaaaaab" client={override ? client as never : undefined} {...rest} onClose={vi.fn()} />);
|
||||
await waitFor(() => expect(viewerProps.current?.email).toBeTruthy());
|
||||
return { ...view, client };
|
||||
}
|
||||
|
||||
function composeFrom(action: typeof ACTIONS[number]) {
|
||||
openComposeTab.mockClear();
|
||||
(viewerProps.current![action] as () => void)();
|
||||
expect(openComposeTab).toHaveBeenCalledTimes(1);
|
||||
return openComposeTab.mock.calls[0][0].replyTo as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe('compose actions of a Pro message view', () => {
|
||||
beforeEach(() => {
|
||||
viewerProps.current = null;
|
||||
openComposeTab.mockClear();
|
||||
auth.state.activeAccountId = 'login-a';
|
||||
auth.state.client = null;
|
||||
});
|
||||
|
||||
it.each(ACTIONS)('%s of another login names that login', async (action) => {
|
||||
await open({ clientAccountId: 'login-b' });
|
||||
|
||||
expect(composeFrom(action).accountId).toBe('login-b');
|
||||
});
|
||||
|
||||
it.each(ACTIONS)('%s of a shared folder of another login names the login, not the owner', async (action) => {
|
||||
await open({ clientAccountId: 'login-b', accountId: 'jmap-team' });
|
||||
|
||||
expect(composeFrom(action).accountId).toBe('login-b');
|
||||
});
|
||||
|
||||
it.each(ACTIONS)('%s of the active login names the active login', async (action) => {
|
||||
await open({ override: false });
|
||||
|
||||
expect(composeFrom(action).accountId).toBe('login-a');
|
||||
});
|
||||
|
||||
it('keeps naming the login the message was read in after the active account changes', async () => {
|
||||
// A message of b isn't foreign while b is active, so it carries no stamp.
|
||||
auth.state.activeAccountId = 'login-b';
|
||||
const { rerender, client } = await open({ clientAccountId: 'login-b' });
|
||||
|
||||
auth.state.activeAccountId = 'login-a';
|
||||
rerender(<ProEmailView emailId="eaaaaab" client={client as never} clientAccountId="login-b" onClose={vi.fn()} />);
|
||||
|
||||
expect(composeFrom('onReply').accountId).toBe('login-b');
|
||||
});
|
||||
});
|
||||
@@ -111,7 +111,17 @@ export function stampProSource(email: Email, source: EmailBlobContext, loginId:
|
||||
return { ...email, sourceClientAccountId: login, sourceAccountId: account };
|
||||
}
|
||||
|
||||
function buildReplyContext(email: Email): ProReplyContext {
|
||||
/**
|
||||
* The login holding the message, for ProReplyContext.accountId: the composer
|
||||
* defaults its From to that account and copies the original's parts from it
|
||||
* (#1104, #1511). email.accountId can't say: it is for display, and a message
|
||||
* read through client.getEmail() has none.
|
||||
*/
|
||||
function replyLogin(email: Email, source: EmailWriteSource): string | undefined {
|
||||
return email.sourceClientAccountId ?? source.clientAccountId;
|
||||
}
|
||||
|
||||
function buildReplyContext(email: Email, source: EmailWriteSource): ProReplyContext {
|
||||
return {
|
||||
from: email.from,
|
||||
replyToAddresses: email.replyTo,
|
||||
@@ -122,7 +132,7 @@ function buildReplyContext(email: Email): ProReplyContext {
|
||||
subject: email.subject,
|
||||
...getQuoteBodies(email),
|
||||
receivedAt: email.receivedAt,
|
||||
accountId: email.accountId,
|
||||
accountId: replyLogin(email, source),
|
||||
attachments: email.attachments,
|
||||
messageId: email.messageId,
|
||||
inReplyTo: email.inReplyTo,
|
||||
@@ -285,7 +295,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
|
||||
if (!email) return;
|
||||
openComposeTab({
|
||||
mode: 'reply',
|
||||
replyTo: buildReplyContext(email),
|
||||
replyTo: buildReplyContext(email, writeSource),
|
||||
sourceEmailId: email.id,
|
||||
sourceEmailSource: writeSource,
|
||||
initialDraftText: draftText,
|
||||
@@ -305,7 +315,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
|
||||
if (!email) return;
|
||||
openComposeTab({
|
||||
mode: 'replyAll',
|
||||
replyTo: buildReplyContext(email),
|
||||
replyTo: buildReplyContext(email, writeSource),
|
||||
sourceEmailId: email.id,
|
||||
sourceEmailSource: writeSource,
|
||||
title: buildReplySubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.reply')),
|
||||
@@ -316,7 +326,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
|
||||
if (!email) return;
|
||||
openComposeTab({
|
||||
mode: 'forward',
|
||||
replyTo: buildReplyContext(email),
|
||||
replyTo: buildReplyContext(email, writeSource),
|
||||
sourceEmailId: email.id,
|
||||
sourceEmailSource: writeSource,
|
||||
title: buildForwardSubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.forward')),
|
||||
@@ -355,6 +365,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
|
||||
attachments: [payload.attachment],
|
||||
headers: labelSourceHeaders(email.headers),
|
||||
originalFrom: email.from,
|
||||
accountId: replyLogin(email, writeSource),
|
||||
},
|
||||
sourceEmailId: email.id,
|
||||
sourceEmailSource: writeSource,
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { renderHook, waitFor, cleanup } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
|
||||
|
||||
// /api/wopi/status needs the sign-in context cookie. The mail and files views
|
||||
// can mount before it exists, so a 401 there says nothing about the editor and
|
||||
// must not leave office editing off until the next page load (#1518).
|
||||
|
||||
const ENABLED = { enabled: true, editExtensions: ['docx', 'odt'], viewExtensions: ['pdf'], newExtensions: ['docx'] };
|
||||
|
||||
const answer = (status: number, body: unknown = {}) => ({ ok: status >= 200 && status < 300, status, json: async () => body });
|
||||
|
||||
let fetchMock: Mock<(url: string, init?: RequestInit) => Promise<unknown>>;
|
||||
let useWopiStatus: typeof import('@/hooks/use-wopi-status').useWopiStatus;
|
||||
let useAuthStore: typeof import('@/stores/auth-store').useAuthStore;
|
||||
|
||||
const signIn = (client: object) => useAuthStore.setState({ isAuthenticated: true, client: client as never });
|
||||
|
||||
beforeEach(async () => {
|
||||
fetchMock = vi.fn();
|
||||
// Only the status route counts: the auth store asks for other things on import.
|
||||
vi.stubGlobal('fetch', (url: string, init?: RequestInit) => (
|
||||
url.includes('/api/wopi/status') ? fetchMock(url, init) : Promise.resolve(answer(404))
|
||||
));
|
||||
// The hook shares its answer through module state, like a page load does.
|
||||
vi.resetModules();
|
||||
({ useWopiStatus } = await import('@/hooks/use-wopi-status'));
|
||||
({ useAuthStore } = await import('@/stores/auth-store'));
|
||||
useAuthStore.setState({ isAuthenticated: false, client: null });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('useWopiStatus', () => {
|
||||
it('asks nothing before sign-in, and asks once sign-in is done', async () => {
|
||||
fetchMock.mockResolvedValue(answer(200, ENABLED));
|
||||
const { result } = renderHook(() => useWopiStatus(true));
|
||||
|
||||
await Promise.resolve();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
signIn({});
|
||||
await waitFor(() => expect(result.current?.enabled).toBe(true));
|
||||
expect(result.current?.editExtensions).toContain('docx');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('asks nothing while the persisted flag says signed in but no client is connected yet', async () => {
|
||||
fetchMock.mockResolvedValue(answer(200, ENABLED));
|
||||
useAuthStore.setState({ isAuthenticated: true, client: null });
|
||||
renderHook(() => useWopiStatus(true));
|
||||
|
||||
await Promise.resolve();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('turns editing on when a 401 is followed by the real client taking over', async () => {
|
||||
// A local copy is on screen before the restore writes the context cookie.
|
||||
fetchMock.mockResolvedValueOnce(answer(401)).mockResolvedValue(answer(200, ENABLED));
|
||||
signIn({ kind: 'local copy' });
|
||||
const { result } = renderHook(() => useWopiStatus(true));
|
||||
await waitFor(() => expect(result.current).not.toBeNull());
|
||||
expect(result.current?.enabled).toBe(false);
|
||||
|
||||
signIn({ kind: 'real' });
|
||||
await waitFor(() => expect(result.current?.enabled).toBe(true));
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not share a 401 still in flight with the client that took over', async () => {
|
||||
let finishFirst: (value: unknown) => void = () => {};
|
||||
fetchMock
|
||||
.mockReturnValueOnce(new Promise((resolve) => { finishFirst = resolve; }))
|
||||
.mockResolvedValue(answer(200, ENABLED));
|
||||
signIn({ kind: 'local copy' });
|
||||
const { result } = renderHook(() => useWopiStatus(true));
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
|
||||
signIn({ kind: 'real' });
|
||||
await waitFor(() => expect(result.current?.enabled).toBe(true));
|
||||
finishFirst(answer(401));
|
||||
|
||||
// The late refusal does not take the answer back, for this view or the next.
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
expect(result.current?.enabled).toBe(true);
|
||||
const next = renderHook(() => useWopiStatus(true));
|
||||
await waitFor(() => expect(next.result.current?.enabled).toBe(true));
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it.each([401, 500, 502])('asks again on the next mount after a %i', async (status) => {
|
||||
fetchMock.mockResolvedValueOnce(answer(status)).mockResolvedValue(answer(200, ENABLED));
|
||||
signIn({});
|
||||
const first = renderHook(() => useWopiStatus(true));
|
||||
await waitFor(() => expect(first.result.current).not.toBeNull());
|
||||
expect(first.result.current?.enabled).toBe(false);
|
||||
|
||||
const second = renderHook(() => useWopiStatus(true));
|
||||
await waitFor(() => expect(second.result.current?.enabled).toBe(true));
|
||||
});
|
||||
|
||||
it('keeps a real answer for the page, across views and account switches', async () => {
|
||||
fetchMock.mockResolvedValue(answer(200, ENABLED));
|
||||
signIn({ account: 'a' });
|
||||
const first = renderHook(() => useWopiStatus(true));
|
||||
await waitFor(() => expect(first.result.current?.enabled).toBe(true));
|
||||
|
||||
const second = renderHook(() => useWopiStatus(true));
|
||||
await waitFor(() => expect(second.result.current?.enabled).toBe(true));
|
||||
signIn({ account: 'b' });
|
||||
await waitFor(() => expect(useAuthStore.getState().client).toEqual({ account: 'b' }));
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
|
||||
expect(first.result.current?.enabled).toBe(true);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps a real "no editor" answer too, so mail views do not keep asking', async () => {
|
||||
fetchMock.mockResolvedValue(answer(200, { enabled: false }));
|
||||
signIn({});
|
||||
const first = renderHook(() => useWopiStatus(true));
|
||||
await waitFor(() => expect(first.result.current?.enabled).toBe(false));
|
||||
|
||||
const second = renderHook(() => useWopiStatus(true));
|
||||
await waitFor(() => expect(second.result.current?.enabled).toBe(false));
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('asks nothing while the caller has it switched off', async () => {
|
||||
fetchMock.mockResolvedValue(answer(200, ENABLED));
|
||||
signIn({});
|
||||
const { result } = renderHook(() => useWopiStatus(false));
|
||||
|
||||
await Promise.resolve();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
});
|
||||
+39
-18
@@ -3,11 +3,12 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { IS_LITE } from "@/lib/lite";
|
||||
import { apiFetch } from "@/lib/browser-navigation";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
|
||||
/**
|
||||
* Whether a WOPI document editor is configured for this deployment and which
|
||||
* file extensions it handles (#425). Fetched once per page load and shared
|
||||
* across consumers via a module-level promise.
|
||||
* file extensions it handles (#425). The answer is the same for every account,
|
||||
* so the first real one is kept for the page load and shared across consumers.
|
||||
*/
|
||||
export interface WopiStatus {
|
||||
enabled: boolean;
|
||||
@@ -19,41 +20,61 @@ export interface WopiStatus {
|
||||
|
||||
const DISABLED: WopiStatus = { enabled: false, editExtensions: [], viewExtensions: [], newExtensions: [] };
|
||||
|
||||
let statusPromise: Promise<WopiStatus> | null = null;
|
||||
let known: WopiStatus | null = null;
|
||||
let pending: Promise<WopiStatus> | null = null;
|
||||
let pendingClient: unknown = null;
|
||||
|
||||
function fetchWopiStatus(): Promise<WopiStatus> {
|
||||
function fetchWopiStatus(client: unknown): 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 = apiFetch("/api/wopi/status")
|
||||
.then((res) => (res.ok ? res.json() : DISABLED))
|
||||
.then((data) => ({
|
||||
if (known) return Promise.resolve(known);
|
||||
// A request sent for an earlier client may be refused for lack of the sign-in
|
||||
// context this one brought, so only the same client shares it.
|
||||
if (pending && pendingClient === client) return pending;
|
||||
|
||||
const request: Promise<WopiStatus> = apiFetch("/api/wopi/status")
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
known = {
|
||||
enabled: !!data?.enabled,
|
||||
editExtensions: Array.isArray(data?.editExtensions) ? data.editExtensions : [],
|
||||
viewExtensions: Array.isArray(data?.viewExtensions) ? data.viewExtensions : [],
|
||||
newExtensions: Array.isArray(data?.newExtensions) ? data.newExtensions : [],
|
||||
}))
|
||||
.catch(() => {
|
||||
statusPromise = null; // allow a retry on the next mount
|
||||
return DISABLED;
|
||||
});
|
||||
}
|
||||
return statusPromise;
|
||||
};
|
||||
return known;
|
||||
})
|
||||
// A refusal or an outage says nothing about the editor (#1518): show no
|
||||
// editor for now and ask again on the next mount or client.
|
||||
.catch(() => DISABLED)
|
||||
.finally(() => {
|
||||
if (pending === request) pending = null;
|
||||
});
|
||||
pending = request;
|
||||
pendingClient = client;
|
||||
return request;
|
||||
}
|
||||
|
||||
export function useWopiStatus(enabled: boolean): WopiStatus | null {
|
||||
const [status, setStatus] = useState<WopiStatus | null>(null);
|
||||
// The route needs the sign-in context cookie, which is written before the
|
||||
// store holds a connected client. `isAuthenticated` alone comes back from
|
||||
// storage on load, and a local copy's client gives way to the real one once
|
||||
// the restore wrote the cookie, so this follows the client itself (#1518).
|
||||
const client = useAuthStore((state) => (state.isAuthenticated ? state.client : null));
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (!enabled || !client) return;
|
||||
let cancelled = false;
|
||||
fetchWopiStatus().then((s) => {
|
||||
fetchWopiStatus(client).then((s) => {
|
||||
if (!cancelled) setStatus(s);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [enabled]);
|
||||
}, [enabled, client]);
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { build } from 'esbuild';
|
||||
import { desktopBuildFlag } from '../../../scripts/desktop-build-flag.mjs';
|
||||
import { DESKTOP_TRUST_MARKER, desktopBuildFlag } from '../../../scripts/desktop-build-flag.mjs';
|
||||
|
||||
const ROOT = path.resolve(__dirname, '../../..');
|
||||
const TRUST_KEY = Symbol.for('bulwark.desktop.trustedJmapOrigins');
|
||||
@@ -112,6 +112,28 @@ describe('the flag is fixed at build time', () => {
|
||||
}
|
||||
});
|
||||
|
||||
/** Compiles the trust check alone, kept as an export the way Turbopack keeps it. */
|
||||
async function trustCheckOfBuild(flag: string): Promise<string> {
|
||||
const result = await build({
|
||||
entryPoints: [path.join(ROOT, 'lib/desktop/trusted-servers.ts')],
|
||||
bundle: true,
|
||||
write: false,
|
||||
minify: true,
|
||||
format: 'esm',
|
||||
platform: 'node',
|
||||
define: { 'process.env.NEXT_PUBLIC_BULWARK_DESKTOP': JSON.stringify(flag) },
|
||||
});
|
||||
return result.outputFiles[0].text;
|
||||
}
|
||||
|
||||
it('a server build holds no trust path, even when a bundler keeps the unused export', async () => {
|
||||
expect(await trustCheckOfBuild('')).not.toContain(DESKTOP_TRUST_MARKER);
|
||||
});
|
||||
|
||||
it('a desktop build still holds it', async () => {
|
||||
expect(await trustCheckOfBuild('1')).toContain(DESKTOP_TRUST_MARKER);
|
||||
});
|
||||
|
||||
it('no image build sets it', () => {
|
||||
for (const file of ['Dockerfile', 'Dockerfile.lite']) {
|
||||
expect(readFileSync(path.join(ROOT, file), 'utf8')).not.toContain('NEXT_PUBLIC_BULWARK_DESKTOP');
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
* or the environment can write it, so script in a page can't aim the login
|
||||
* routes at the LAN.
|
||||
*
|
||||
* Callers guard this with IS_DESKTOP_BUILD, so a server build compiles the
|
||||
* whole thing away. scripts/standalone-guard.mjs checks that it did.
|
||||
* Callers check the flag too, but Turbopack keeps an imported export after the
|
||||
* call is gone. So the check also starts here, spelled out: every bundler then
|
||||
* drops the body from a server build. scripts/standalone-guard.mjs checks it did.
|
||||
*/
|
||||
|
||||
type TrustedOrigins = ReadonlySet<string>;
|
||||
@@ -25,6 +26,7 @@ function originOf(url: string): string | null {
|
||||
}
|
||||
|
||||
export function isDesktopTrustedServer(url: string | null | undefined): boolean {
|
||||
if (process.env.NEXT_PUBLIC_BULWARK_DESKTOP !== '1') return false;
|
||||
if (!url) return false;
|
||||
const origin = originOf(url);
|
||||
if (!origin) return false;
|
||||
|
||||
Reference in New Issue
Block a user