From f716ea66eea94b504ded5aad059999141cbf2389 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 07:07:55 +0100 Subject: [PATCH] fix: ask before a share starts a message and save nothing until the first edit Any site could POST a form to the share target on load: with no clicks the composer opened with its subject and file, and autosave stored a real draft on the server 2 seconds later. The worker can't see where a POST came from, and Chromium's link capturing accepts form submissions, so an installed-app window proves nothing either. - every share landing now asks first, in every window and browser; a text landing address gets the same question. Nothing goes up before the answer. - handed-in text and files are the composer's starting state: no autosave, page-close save or sign-out flush before the first real edit. - the worker ignores frames, keeps at most 5 entries and 100 MB for 15 minutes, sweeps on activate, on each share and from a page timer, and seals what it keeps with a key passed in the landing fragment. Offline wipes clear it too. - the server fallback uses the fragment and caps by encoded length: 3000 Japanese characters made a 27 KB `Location` and a 431. - a second compose handoff waits until the composer closes, also one set aside by reading a message, where it used to replace the first. - a launch leaving the mail page saves what was just typed first. - an expired handoff or a missing stored file now says so; shares get the control-character stripping and limits of `parseMailto`. Existing tests changed because the landing moved from the query to the fragment, stored entries gained a sealed part, and a share now asks first. --- app/(main)/protocol/share/route.ts | 49 +- .../composer-attach-from-files.test.tsx | 62 +++ components/email/email-composer.tsx | 53 ++- components/mail/mail-app.tsx | 89 ++-- .../protocol-launch-handler-provider.test.tsx | 159 ++++++- .../protocol-launch-handler-provider.tsx | 104 ++++- integration/tests/23-do-pwa-launch.spec.ts | 227 ++++++++- lib/__tests__/launch-handoff.test.ts | 238 ++++++++-- lib/__tests__/manifest-launch.test.ts | 33 +- lib/__tests__/sw-harness.ts | 2 +- lib/__tests__/sw-share-target.test.ts | 187 ++++++-- lib/launch-handoff.ts | 436 +++++++++++++----- lib/offline/__tests__/handoff-wipes.test.ts | 64 +++ lib/offline/offline-mode.ts | 4 + lib/protocol-handlers/mailto.ts | 14 +- locales/ar/common.json | 13 +- locales/ca/common.json | 13 +- locales/cs/common.json | 13 +- locales/da/common.json | 13 +- locales/de/common.json | 13 +- locales/en/common.json | 13 +- locales/es/common.json | 13 +- locales/et/common.json | 13 +- locales/fa/common.json | 13 +- locales/fr/common.json | 13 +- locales/he/common.json | 13 +- locales/hu/common.json | 13 +- locales/it/common.json | 13 +- locales/ja/common.json | 13 +- locales/ko/common.json | 13 +- locales/lv/common.json | 13 +- locales/mn/common.json | 13 +- locales/nb/common.json | 13 +- locales/nl/common.json | 13 +- locales/pl/common.json | 13 +- locales/pt/common.json | 13 +- locales/ro/common.json | 13 +- locales/ru/common.json | 13 +- locales/sk/common.json | 13 +- locales/th/common.json | 13 +- locales/tr/common.json | 13 +- locales/uk/common.json | 13 +- locales/vi/common.json | 13 +- locales/zh-TW/common.json | 13 +- locales/zh/common.json | 13 +- public/sw.js | 115 ++++- 46 files changed, 1928 insertions(+), 298 deletions(-) create mode 100644 lib/offline/__tests__/handoff-wipes.test.ts diff --git a/app/(main)/protocol/share/route.ts b/app/(main)/protocol/share/route.ts index ea0ed58c5..851f7a1ba 100644 --- a/app/(main)/protocol/share/route.ts +++ b/app/(main)/protocol/share/route.ts @@ -1,8 +1,9 @@ /** * The manifest's share_target, when no service worker took it (public/sw.js * normally answers first): the first visit, a browser without one, or a worker - * from before this existed. Nothing keeps files here, so the text and links go - * on in the address and the page says the files are gone + * from before this existed. Nothing keeps files here, so text and links go on + * in the address's fragment, which stays out of access logs, and the page asks + * the user and says the files are gone * (components/protocol/protocol-launch-handler-provider.tsx). * * Open to any origin, like a mailto: link: it writes nothing and acts as nobody. @@ -12,11 +13,32 @@ const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/, ""); // A form with text and links is small. Anything bigger carries files, and // isn't read into memory just to throw them away. const MAX_READ_BYTES = 256 * 1024; +// Encoded lengths: a proxy in front has to pass the Location header, and one +// Japanese character takes 9 bytes in it. +const BUDGET = { title: 400, url: 1000, text: 1800 }; export const dynamic = "force-dynamic"; -function land(query: string): Response { - return new Response(null, { status: 303, headers: { Location: `${BASE_PATH}/${query}` } }); +function land(fragment: string): Response { + return new Response(null, { status: 303, headers: { Location: `${BASE_PATH}/${fragment}` } }); +} + +/** As much of `value` as takes up to `budget` bytes once encoded, whole characters only. */ +function fitEncoded(value: string, budget: number): { value: string; cut: boolean } { + let used = 0; + let out = ""; + for (const character of value) { + let size: number; + try { + size = encodeURIComponent(character).length; + } catch { + continue; // A lone surrogate. + } + used += size; + if (used > budget) return { value: out, cut: true }; + out += character; + } + return { value: out, cut: false }; } export async function POST(request: Request): Promise { @@ -24,25 +46,26 @@ export async function POST(request: Request): Promise { const params = new URLSearchParams({ share: "unavailable" }); if (!Number.isFinite(length) || length <= 0 || length > MAX_READ_BYTES) { params.set("share_files", "1"); - return land(`?${params}`); + return land(`#${params}`); } try { const form = await request.formData(); - const field = (name: string, max: number) => { + let cut = false; + for (const name of ["title", "text", "url"] as const) { const value = form.get(name); - if (typeof value === "string" && value) params.set(`share_${name}`, value.slice(0, max)); - }; - // Short enough for what proxies in front allow in an address. - field("title", 300); - field("text", 4000); - field("url", 2000); + if (typeof value !== "string" || !value) continue; + const fitted = fitEncoded(value, BUDGET[name]); + params.set(`share_${name}`, fitted.value); + cut ||= fitted.cut; + } + if (cut) params.set("share_cut", "1"); if (form.getAll("files").some((file) => typeof file !== "string" && file.size > 0)) { params.set("share_files", "1"); } } catch { params.set("share_files", "1"); } - return land(`?${params}`); + return land(`#${params}`); } /** Nothing to share: the app. */ diff --git a/components/email/__tests__/composer-attach-from-files.test.tsx b/components/email/__tests__/composer-attach-from-files.test.tsx index 5135d298f..c8510b72c 100644 --- a/components/email/__tests__/composer-attach-from-files.test.tsx +++ b/components/email/__tests__/composer-attach-from-files.test.tsx @@ -241,6 +241,68 @@ describe('files handed to a new composer (a share, a launch)', () => { const uploading = () => filesClient({ uploadBlob: vi.fn(() => new Promise(() => {})), getMaxSizeUpload: () => 0 }); + const handedIn = (extra: Record = {}) => ({ + to: '', cc: '', bcc: '', subject: 'Shared subject', body: 'Shared text', showCc: false, showBcc: false, + selectedIdentityId: null, subAddressTag: '', mode: 'compose', draftId: null, unedited: true, ...extra, + }) as never; + const settled = (client: Record) => ({ + ...client, + uploadBlob: vi.fn(async () => ({ blobId: 'blob-shared', size: 1, type: 'text/plain' })), + getMaxSizeUpload: () => 0, + }); + + it('saves no draft from handed-in text and files until the first real edit', async () => { + const client = settled(filesClient()) as unknown as ReturnType; + useAuthStore.setState({ client: client as never }); + vi.useFakeTimers(); + render(); + await act(async () => { await vi.advanceTimersByTimeAsync(5000); }); + expect(screen.getByText('shared.pdf')).toBeInTheDocument(); + expect(client.createDraft).not.toHaveBeenCalled(); + + // Closing the page doesn't save it either. + await act(async () => { window.dispatchEvent(new Event('beforeunload')); await vi.advanceTimersByTimeAsync(100); }); + expect(client.createDraft).not.toHaveBeenCalled(); + + fireEvent.change(screen.getByPlaceholderText(/subject/i), { target: { value: 'Shared subject, edited' } }); + await act(async () => { await vi.advanceTimersByTimeAsync(2500); }); + expect(client.createDraft).toHaveBeenCalledTimes(1); + vi.useRealTimers(); + }); + + it('closes without asking while nothing is the user\'s own', async () => { + const onClose = vi.fn(); + useAuthStore.setState({ client: settled(filesClient()) as never }); + const ref = { current: null as ((afterClose?: () => void) => void) | null }; + render(); + await screen.findByText('shared.pdf'); + await act(async () => { ref.current?.(); }); + expect(onClose).toHaveBeenCalled(); + }); + + it('counts a removed attachment as an edit', async () => { + const client = settled(filesClient()) as unknown as ReturnType; + useAuthStore.setState({ client: client as never }); + vi.useFakeTimers(); + render(); + await act(async () => { await vi.advanceTimersByTimeAsync(100); }); + const chip = screen.getByText('shared.pdf').closest('[data-testid], div')!.parentElement!; + const remove = chip.querySelector('button[aria-label]') ?? chip.querySelector('button'); + fireEvent.click(remove!); + await act(async () => { await vi.advanceTimersByTimeAsync(2500); }); + expect(client.createDraft).toHaveBeenCalled(); + vi.useRealTimers(); + }); + + it('hands its unedited state back to a remount, without asking to save', async () => { + useAuthStore.setState({ client: settled(filesClient()) as never }); + const onSaveState = vi.fn(); + const view = render(); + await screen.findByText('shared.pdf'); + view.unmount(); + expect(onSaveState).toHaveBeenCalledWith(expect.objectContaining({ unedited: true, unsaved: false, subject: 'Shared subject' })); + }); + it('attaches them when it opens', async () => { useAuthStore.setState({ client: uploading() as never }); render(); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 93932e246..13418b806 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -217,6 +217,12 @@ type SuggestionItem = { name: string; email: string; group?: { id: string; membe const attachedInitialFiles = new WeakSet(); export interface ComposerDraftData { + /** + * Handed in from outside (a share, the desktop app) and not touched yet. + * It is only where the composer starts: no draft on the server until the + * user's first real edit. + */ + unedited?: boolean; to: string; cc: string; bcc: string; @@ -1770,6 +1776,16 @@ export function EmailComposer({ } changeVersionRef.current += 1; }, [toStr, ccStr, bccStr, subject, body, attachments.length]); + // Handed-in text and files aren't the user's until they touch them. Nothing + // counts as dirty until then, so autosave, the page-close save and the flush + // before a sign-out all leave them alone. + const uneditedRef = useRef(initialData?.unedited === true); + const [firstEditSeen, setFirstEditSeen] = useState(false); + const markEdited = useCallback(() => { + if (!uneditedRef.current) return; + uneditedRef.current = false; + setFirstEditSeen(true); + }, []); const isDirtyRef = useRef(false); isDirtyRef.current = toStr !== initialValuesRef.current.to || ccStr !== initialValuesRef.current.cc || bccStr !== initialValuesRef.current.bcc || subject !== initialValuesRef.current.subject || @@ -1781,6 +1797,19 @@ export function EmailComposer({ if (priority !== initialPriorityRef.current) isDirtyRef.current = true; const initialLabelChoiceRef = useRef(classification.choice); if (classification.choice !== initialLabelChoiceRef.current) isDirtyRef.current = true; + if (uneditedRef.current) isDirtyRef.current = false; + // Changes only the user makes, also without typing: a picked recipient, a + // removed attachment, a priority. Handed-in files move the count they're + // compared with (addFiles). The body isn't here, a signature can change it; + // typing in it comes through the composer root's input events. + useEffect(() => { + if (!uneditedRef.current) return; + const start = initialValuesRef.current; + if (toStr !== start.to || ccStr !== start.cc || bccStr !== start.bcc || subject !== start.subject + || attachments.length !== start.attachmentCount || priority !== initialPriorityRef.current) { + markEdited(); + } + }, [toStr, ccStr, bccStr, subject, attachments.length, priority, markEdited]); const firstLabelRunRef = useRef(true); useEffect(() => { if (firstLabelRunRef.current) { @@ -1837,7 +1866,9 @@ export function EmailComposer({ useEffect(() => { return () => { if (explicitCloseRef.current) return; - if (onSaveState && isDirtyRef.current) { + // Kept in the host's memory across a remount, handed-in or not. Only + // the server stays out of it. + if (onSaveState && (isDirtyRef.current || uneditedRef.current)) { const s = stateRef.current; onSaveState({ ...s, @@ -1848,7 +1879,8 @@ export function EmailComposer({ classification: classificationRef.current.draftChoice, // Edits the last save didn't cover; a minimised composer with // none needs no save before an automatic sign-out. - unsaved: changeVersionRef.current !== savedVersionRef.current || saveFailedRef.current, + unsaved: !uneditedRef.current && (changeVersionRef.current !== savedVersionRef.current || saveFailedRef.current), + ...(uneditedRef.current ? { unedited: true } : {}), }); } }; @@ -2304,7 +2336,7 @@ export function EmailComposer({ return () => window.removeEventListener('keydown', handleTemplateKey); }, [templatesEnabled]); - const addFiles = useCallback(async (files: File[]) => { + const addFiles = useCallback(async (files: File[], opts?: { handedIn?: boolean }) => { if (!client || files.length === 0) return; // Let plugins veto each upload before it's queued. @@ -2358,6 +2390,8 @@ export function EmailComposer({ abortController: controller, }; }); + // Files handed in are where the composer starts, not an edit. + if (opts?.handedIn) initialValuesRef.current.attachmentCount += newAttachments.length; setAttachments(prev => [...prev, ...newAttachments]); for (let i = 0; i < files.length; i++) { @@ -2474,7 +2508,7 @@ export function EmailComposer({ useEffect(() => { if (!client || !initialFiles || initialFiles.length === 0 || attachedInitialFiles.has(initialFiles)) return; attachedInitialFiles.add(initialFiles); - void addFiles(initialFiles); + void addFiles(initialFiles, { handedIn: true }); }, [client, initialFiles, addFiles]); const handleFileSelect = async (event: React.ChangeEvent) => { @@ -2930,7 +2964,7 @@ export function EmailComposer({ // login page can say a draft wasn't saved. useEffect(() => registerComposeFlush(async () => { while (isSendingRef.current) await new Promise((resolve) => setTimeout(resolve, SEND_WAIT_POLL_MS)); - if (sentRef.current) return; + if (sentRef.current || uneditedRef.current) return; const unsaved = changeVersionRef.current !== savedVersionRef.current || saveFailedRef.current; if (!unsaved) return; if (saveTimeoutRef.current) { @@ -3283,7 +3317,7 @@ export function EmailComposer({ } }; // eslint-disable-next-line react-hooks/exhaustive-deps -- saveDraft reads current state when called, not when effect is set up - }, [toStr, ccStr, bccStr, subject, body, attachments, priority]); + }, [toStr, ccStr, bccStr, subject, body, attachments, priority, firstEditSeen]); useEffect(() => { return () => { @@ -4290,6 +4324,13 @@ export function EmailComposer({ data-testid="email-composer" className={cn("flex h-full bg-background relative", className)} onKeyDownCapture={handleComposerKeyDown} + // Bubble phase, in the same batch as the field's own onChange. A state + // change in the capture phase renders first and puts the old value back. + onInput={markEdited} + onChange={markEdited} + onPaste={markEdited} + onCut={markEdited} + onDrop={markEdited} >
{liveMessage && {liveMessage.text}} diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 52f90a2f6..4e49fb555 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -142,7 +142,7 @@ import { usePluginStore } from "@/stores/plugin-store"; import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot"; import { canDownload } from "@/lib/device-policy"; import { consumePendingMailto, subscribeToPendingMailto } from "@/lib/protocol-handlers/session"; -import { subscribeHandoff, takeHandoff } from "@/lib/launch-handoff"; +import { pendingHandoffs, subscribeHandoff, takeHandoff, type Handoff } from "@/lib/launch-handoff"; import { INTERNAL_MAILTO_EVENT, type ParsedMailto } from "@/lib/protocol-handlers/mailto"; import { PLUGIN_REPLY_EVENT, type PluginReplyRequest } from "@/lib/plugin-sandbox/host-api-compose"; import { onOpenEmail, type OpenEmailHandler, type OpenEmailOutcome } from "@/lib/plugin-sandbox/open-email"; @@ -232,7 +232,7 @@ const UNIFIED_LIST_PUSH = { }; /** A draft from outside: a mailto:, or a share with files. ParsedMailto stays JSON, for sessionStorage. */ -type DraftRequest = ParsedMailto & { initialFiles?: File[] }; +type DraftRequest = ParsedMailto & { initialFiles?: File[]; unedited?: boolean }; export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // Static Lite build: the route params are empty, read the link from the URL. @@ -1479,7 +1479,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { suppressComposerStateSaveSessionRef.current = composerSessionId; } setComposerSessionId((id) => id + 1); - setPendingDraft(mailtoComposerDraft(pending, useSettingsStore.getState().plainTextMode)); + setPendingDraft({ + ...mailtoComposerDraft(pending, useSettingsStore.getState().plainTextMode), + ...(pending.unedited ? { unedited: true } : {}), + }); setComposerInitialFiles(pending.initialFiles ?? null); setComposerMode("compose"); setShowComposer(true); @@ -1530,7 +1533,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { }, []); // A draft that comes from outside: which account it goes out from. - const routeOutsideDraft = useCallback((pending: DraftRequest) => { + const routeOutsideDraft = useCallback(async (pending: DraftRequest): Promise => { const protocolAccounts = getMailtoProtocolAccounts(); if (protocolAccounts.length > 1) { setPendingMailtoAccountChoice(pending); @@ -1539,11 +1542,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const accountId = protocolAccounts[0]?.id ?? activeAccountId; if (accountId) { - void openMailtoForAccount(pending, accountId); + await openMailtoForAccount(pending, accountId); return; } - openMailtoDraft(pending); + await openMailtoDraft(pending); }, [activeAccountId, getMailtoProtocolAccounts, openMailtoDraft, openMailtoForAccount]); const handleMailtoProtocolRequest = useCallback(async (pending: ParsedMailto) => { @@ -1553,7 +1556,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { openMailtoDraft(pending); return; } - routeOutsideDraft(pending); + void routeOutsideDraft(pending); }, [mailtoAllowed, openMailtoDraft, routeOutsideDraft]); // A new draft from a shortcut, a share or the desktop app @@ -1561,35 +1564,65 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // it; onBeforeCompose still runs. The standard page that renders while the // Pro shell takes over must leave it for the Pro one. const proTakingOver = proInterfaceActive && !isEmbedded; + // One at a time. A composer that is open holds the user's text, so what comes + // next stays queued until it closes, and a second one can't replace the + // first. So does one set aside by reading a message, while the server hasn't + // got what it holds. Under Pro a composer moves to its own tab, so nothing + // waits long. + const handoffOpeningRef = useRef(false); + const handoffToldWaitingRef = useRef(new Set()); + const composerInUseRef = useRef(false); + const composerInUse = showComposer || pendingMailtoAccountChoice !== null + || pendingDraft?.unsaved === true || pendingDraft?.unedited === true; + composerInUseRef.current = composerInUse; + const [handoffSettled, setHandoffSettled] = useState(0); useEffect(() => { if (!isAuthenticated || !client || proTakingOver) return; - const openHandoff = () => { - for (let handoff = takeHandoff('compose'); handoff; handoff = takeHandoff('compose')) { - if (!handoff.subject && !handoff.body && handoff.files.length === 0) { - void withComposeGate('compose', {}, () => { - startFreshComposerSession(); - setComposerMode('compose'); - setShowComposer(true); - if (isMobile) setActiveView('viewer'); - }); - continue; - } - routeOutsideDraft({ - to: [], - cc: [], - bcc: [], - subject: handoff.subject ?? '', - body: handoff.body ?? '', - initialFiles: handoff.files, + const open = async (handoff: Handoff) => { + if (!handoff.subject && !handoff.body && handoff.files.length === 0) { + await withComposeGate('compose', {}, () => { + startFreshComposerSession(); + setComposerMode('compose'); + setShowComposer(true); + if (isMobile) setActiveView('viewer'); }); + return; } + await routeOutsideDraft({ + to: [], + cc: [], + bcc: [], + subject: handoff.subject ?? '', + body: handoff.body ?? '', + initialFiles: handoff.files, + unedited: true, + }); }; - openHandoff(); - return subscribeHandoff('compose', openHandoff); + const openNext = () => { + if (handoffOpeningRef.current) return; + if (composerInUseRef.current) { + const waiting = pendingHandoffs().find((handoff) => handoff.kind === 'compose'); + if (waiting && !handoffToldWaitingRef.current.has(waiting.id)) { + handoffToldWaitingRef.current.add(waiting.id); + toast.info(t('pwaLaunch.compose_waiting')); + } + return; + } + const handoff = takeHandoff('compose'); + if (!handoff) return; + handoffOpeningRef.current = true; + void open(handoff).finally(() => { + handoffOpeningRef.current = false; + setHandoffSettled((n) => n + 1); + }); + }; + + openNext(); + return subscribeHandoff('compose', openNext); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isAuthenticated, client, proTakingOver, routeOutsideDraft, withComposeGate, isMobile]); + }, [isAuthenticated, client, proTakingOver, routeOutsideDraft, withComposeGate, isMobile, composerInUse, handoffSettled]); useEffect(() => { if (!isAuthenticated || !client) return; diff --git a/components/protocol/__tests__/protocol-launch-handler-provider.test.tsx b/components/protocol/__tests__/protocol-launch-handler-provider.test.tsx index 49c451447..15ffa7cf2 100644 --- a/components/protocol/__tests__/protocol-launch-handler-provider.test.tsx +++ b/components/protocol/__tests__/protocol-launch-handler-provider.test.tsx @@ -1,4 +1,4 @@ -import { act, render, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import React from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { FakeCaches } from '@/lib/__tests__/sw-harness'; @@ -10,6 +10,11 @@ const hoisted = vi.hoisted(() => ({ push: vi.fn(), pathname: { current: '/' }, toastError: vi.fn(), + toastInfo: vi.fn(), + flushComposers: vi.fn(async () => ({ total: 0, settled: 0, failed: 0, timedOut: false })), + peek: vi.fn(), + adopt: vi.fn(), + discard: vi.fn(async () => {}), savePendingMailto: vi.fn(), protocolOpenMode: 'active-session', })); @@ -19,7 +24,14 @@ vi.mock('@/i18n/navigation', () => ({ usePathname: () => hoisted.pathname.current, })); vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Bulwark' }) })); -vi.mock('@/stores/toast-store', () => ({ toast: { error: hoisted.toastError, info: vi.fn(), success: vi.fn() } })); +vi.mock('@/stores/toast-store', () => ({ toast: { error: hoisted.toastError, info: hoisted.toastInfo, success: vi.fn() } })); +vi.mock('@/lib/compose-flush-registry', () => ({ flushComposers: hoisted.flushComposers })); +vi.mock('@/lib/launch-handoff', async (importOriginal) => ({ + ...(await importOriginal()), + peekStoredHandoff: hoisted.peek, + adoptStoredHandoff: hoisted.adopt, + discardStoredHandoff: hoisted.discard, +})); vi.mock('@/stores/settings-store', () => ({ useSettingsStore: Object.assign(() => undefined, { getState: () => ({ protocolOpenMode: hoisted.protocolOpenMode }) }), })); @@ -50,7 +62,7 @@ vi.mock('@/components/files/file-preview-modal', () => ({ }, })); -const { clearHandoffs, pendingHandoffs, takeHandoff } = await import('@/lib/launch-handoff'); +const { clearHandoffs, handOffFiles, pendingHandoffs, takeHandoff, HANDOFF_TTL_MS } = await import('@/lib/launch-handoff'); const { setDownloadGuard } = await import('@/lib/download-guard'); const { ProtocolLaunchHandlerProvider } = await import('../protocol-launch-handler-provider'); @@ -70,6 +82,12 @@ beforeEach(() => { setConsumer.mockClear(); hoisted.push.mockClear(); hoisted.toastError.mockClear(); + hoisted.toastInfo.mockClear(); + hoisted.flushComposers.mockReset(); + hoisted.flushComposers.mockResolvedValue({ total: 0, settled: 0, failed: 0, timedOut: false }); + hoisted.peek.mockReset(); + hoisted.adopt.mockReset(); + hoisted.discard.mockClear(); hoisted.savePendingMailto.mockClear(); hoisted.pathname.current = '/'; hoisted.protocolOpenMode = 'active-session'; @@ -212,6 +230,7 @@ describe('launches that are not files', () => { it('goes to the calendar from its shortcut in an open window', async () => { mount(); consumer!({ targetURL: `${window.location.origin}/calendar` }); + await flush(); expect(hoisted.push).toHaveBeenCalledWith('/calendar'); }); @@ -238,6 +257,51 @@ describe('launches that are not files', () => { }); }); +describe('leaving a composer for a launched file', () => { + it('saves what was typed first, then goes', async () => { + const order: string[] = []; + hoisted.flushComposers.mockImplementation(async () => { order.push('flush'); return { total: 1, settled: 1, failed: 0, timedOut: false }; }); + hoisted.push.mockImplementation(() => { order.push('push'); }); + mount(); + consumer!({ files: [handle('people.vcf')] }); + await flush(); + expect(order).toEqual(['flush', 'push']); + hoisted.push.mockReset(); + }); + + it.each([ + ['the server refused the save', { total: 1, settled: 1, failed: 1, timedOut: false }], + ['the save took too long', { total: 1, settled: 0, failed: 0, timedOut: true }], + ])('stays when %s, with the file still waiting', async (_name, result) => { + hoisted.flushComposers.mockResolvedValue(result); + mount(); + consumer!({ files: [handle('people.vcf')] }); + await flush(); + expect(hoisted.push).not.toHaveBeenCalled(); + expect(hoisted.toastError).toHaveBeenCalledWith('save_first_failed'); + expect(pendingHandoffs().map((h) => h.kind)).toEqual(['contacts']); + }); + + it('does not save for a .eml, which leaves nothing', async () => { + mount(); + consumer!({ files: [handle('note.eml')] }); + await screen.findByTestId('eml-viewer'); + expect(hoisted.flushComposers).not.toHaveBeenCalled(); + }); +}); + +describe('a handoff that goes without arriving', () => { + it('says so', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + mount(); + handOffFiles([new File(['x'], 'a.ics')], 'calendar'); + vi.advanceTimersByTime(HANDOFF_TTL_MS + 1); + takeHandoff('calendar'); + vi.useRealTimers(); + expect(hoisted.toastError).toHaveBeenCalledWith('handoff_expired'); + }); +}); + describe('the address a launch lands on', () => { it('opens a new message once and removes the parameter', async () => { window.history.replaceState(null, '', '/?compose=1&slot=2#x'); @@ -256,21 +320,100 @@ describe('the address a launch lands on', () => { expect(takeHandoff('compose')).not.toBeNull(); expect(takeHandoff('compose')).toBeNull(); }); +}); - it('turns text in the address into a draft and says the files were lost', async () => { - window.history.replaceState(null, '', '/?share=unavailable&share_title=T&share_text=B&share_files=1'); +describe('a share from an address anyone can make', () => { + const dialogText = () => screen.getByTestId('share-confirm-details').textContent ?? ''; + const agree = () => fireEvent.click(screen.getByRole('button', { name: 'share_confirm_accept' })); + const refuse = () => fireEvent.click(screen.getByRole('button', { name: 'share_confirm_decline' })); + + it('asks about text in the address, and starts nothing before the answer', async () => { + window.history.replaceState(null, '', '/#share=unavailable&share_title=T&share_text=B&share_files=1&share_cut=1'); mount(); - expect(window.location.search).toBe(''); + expect(window.location.hash).toBe(''); + await screen.findByText('share_confirm_message'); + expect(dialogText()).toContain('T'); + expect(pendingHandoffs()).toEqual([]); + expect(hoisted.push).not.toHaveBeenCalled(); + + agree(); + await flush(); expect(takeHandoff('compose')).toMatchObject({ subject: 'T', body: 'B' }); expect(hoisted.toastError).toHaveBeenCalledWith('share_files_lost'); + expect(hoisted.toastInfo).toHaveBeenCalledWith('share_cut'); + }); + + it('starts nothing when refused', async () => { + window.history.replaceState(null, '', '/#share=unavailable&share_text=B'); + mount(); + await screen.findByText('share_confirm_message'); + refuse(); + await flush(); + expect(pendingHandoffs()).toEqual([]); + }); + + it('only reports lost files when there was no text to ask about', async () => { + window.history.replaceState(null, '', '/#share=unavailable&share_files=1'); + mount(); + await flush(); + expect(screen.queryByText('share_confirm_message')).toBeNull(); + expect(hoisted.toastError).toHaveBeenCalledWith('share_files_lost'); + expect(pendingHandoffs()).toEqual([]); + }); + + it('ignores it in the query, which reaches logs and any link', async () => { + window.history.replaceState(null, '', '/?share=unavailable&share_text=B'); + mount(); + await flush(); + expect(screen.queryByText('share_confirm_message')).toBeNull(); + expect(pendingHandoffs()).toEqual([]); + }); + + it('asks about a stored share, naming the files, before anything is taken in', async () => { + hoisted.peek.mockResolvedValue({ subject: 'S', body: '', files: ['invoice.exe'] }); + hoisted.adopt.mockResolvedValue(true); + window.history.replaceState(null, '', '/#share=abc&key=k1'); + mount(); + expect(window.location.hash).toBe(''); + await screen.findByText('share_confirm_message'); + expect(hoisted.peek).toHaveBeenCalledWith('abc', 'k1'); + expect(dialogText()).toContain('invoice.exe'); + expect(hoisted.adopt).not.toHaveBeenCalledWith('abc', 'k1'); + + agree(); + await waitFor(() => expect(hoisted.adopt).toHaveBeenCalledWith('abc', 'k1')); + expect(hoisted.discard).not.toHaveBeenCalled(); + }); + + it('asks in an installed-app window as well: link capturing can bring a POST there', async () => { + const original = window.matchMedia; + window.matchMedia = ((query: string) => ({ matches: query.includes('standalone'), media: query, addEventListener: () => {}, removeEventListener: () => {} })) as never; + hoisted.peek.mockResolvedValue({ subject: 'S', body: '', files: [] }); + window.history.replaceState(null, '', '/#share=abc&key=k1'); + mount(); + await screen.findByText('share_confirm_message'); + expect(hoisted.adopt).not.toHaveBeenCalledWith('abc', 'k1'); + window.matchMedia = original; + }); + + it('drops a stored share when refused', async () => { + hoisted.peek.mockResolvedValue({ subject: 'S', body: '', files: ['invoice.exe'] }); + window.history.replaceState(null, '', '/#share=abc&key=k1'); + mount(); + await screen.findByText('share_confirm_message'); + refuse(); + await waitFor(() => expect(hoisted.discard).toHaveBeenCalledWith('abc')); + expect(hoisted.adopt).not.toHaveBeenCalledWith('abc', 'k1'); }); it('says so when what was shared is gone', async () => { - window.history.replaceState(null, '', '/?share=abc'); + hoisted.peek.mockResolvedValue(null); + window.history.replaceState(null, '', '/#share=abc&key=k1'); mount(); - expect(window.location.search).toBe(''); + expect(window.location.hash).toBe(''); await flush(); expect(hoisted.toastError).toHaveBeenCalledWith('share_gone'); + expect(screen.queryByText('share_confirm_message')).toBeNull(); }); }); diff --git a/components/protocol/protocol-launch-handler-provider.tsx b/components/protocol/protocol-launch-handler-provider.tsx index 69dcf5eb3..f052600d5 100644 --- a/components/protocol/protocol-launch-handler-provider.tsx +++ b/components/protocol/protocol-launch-handler-provider.tsx @@ -1,23 +1,29 @@ "use client"; -import { useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { ReactNode } from "react"; import { useTranslations } from "next-intl"; import { usePathname, useRouter } from "@/i18n/navigation"; +import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { useConfig } from "@/hooks/use-config"; +import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; +import { flushComposers } from "@/lib/compose-flush-registry"; import { getPathPrefix } from "@/lib/browser-navigation"; import { matchSurface, type AppSurface } from "@/lib/deep-links"; import { adoptStoredHandoff, + discardStoredHandoff, handOffDraft, handOffLaunchFiles, handOffNewMessage, parseLaunchLanding, + peekStoredHandoff, pendingHandoffs, restoreStoredHandoffs, shortcutFromTargetUrl, stripLandingParams, subscribeHandoff, + subscribeHandoffLost, type HandoffKind, } from "@/lib/launch-handoff"; import { productName } from "@/lib/product-name"; @@ -77,6 +83,14 @@ const HANDOFF_TARGET: Partial()); + const { dialogProps, confirm } = useConfirmDialog(); + const [offer, setOffer] = useState(null); + + // Anyone can POST to the share target or link to a landing address, and + // Chromium's link capturing can bring a cross-site form POST into an + // installed app's window, so nothing tells a real share from a planted one. + // Nothing goes into an account until the user says yes. + const askAboutShare = useCallback(async (shared: ShareOffer) => { + setOffer(shared); + const agreed = await confirm({ + title: tLaunch("share_confirm_title"), + message: tLaunch("share_confirm_message"), + confirmText: tLaunch("share_confirm_accept"), + cancelText: tLaunch("share_confirm_decline"), + }); + setOffer(null); + return agreed; + }, [confirm, tLaunch]); // What the page's own address asks for: a shortcut's new message, or what // was shared. Once: it leaves the address before anything else happens. useEffect(() => { - const landing = parseLaunchLanding(window.location.search); + const stopLossNotes = subscribeHandoffLost((loss) => { + toast.error(tLaunch(loss === "missing" ? "share_files_lost" : "handoff_expired")); + }); + const { search, hash, pathname: path } = window.location; + const landing = parseLaunchLanding(search, hash); if (landing) { - window.history.replaceState( - window.history.state, - "", - window.location.pathname + stripLandingParams(window.location.search) + window.location.hash, - ); + const left = stripLandingParams(search, hash); + window.history.replaceState(window.history.state, "", path + left.search + left.hash); } if (landing?.type === "new-message") { handOffNewMessage(); } else if (landing?.type === "inline") { - if (landing.subject || landing.body) handOffDraft({ subject: landing.subject, body: landing.body }); - if (landing.filesLost) toast.error(tLaunch("share_files_lost")); + void (async () => { + const { subject, body } = landing; + if ((subject || body) && !(await askAboutShare({ subject, body, files: [] }))) return; + if (subject || body) handOffDraft({ subject, body }); + if (landing.filesLost) toast.error(tLaunch("share_files_lost")); + if (landing.cut) toast.info(tLaunch("share_cut")); + })(); } else if (landing?.type === "stored") { - void adoptStoredHandoff(landing.id).then((found) => { - if (!found) toast.error(tLaunch("share_gone")); - }); + void (async () => { + const shared = await peekStoredHandoff(landing.id, landing.key); + if (!shared) { + await discardStoredHandoff(landing.id); + toast.error(tLaunch("share_gone")); + return; + } + if (!(await askAboutShare(shared))) { + await discardStoredHandoff(landing.id); + return; + } + if (!(await adoptStoredHandoff(landing.id, landing.key))) toast.error(tLaunch("share_gone")); + })(); } void restoreStoredHandoffs(); + return stopLossNotes; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // Leaving the mail page ends an open composer, and its autosave waits 2 + // seconds: what was just typed is saved first. If it can't be, stay. A file + // keeps waiting and opens once the user goes to that page. + const leaveFor = useCallback(async (path: "/" | "/calendar" | "/contacts") => { + const result = await flushComposers(FLUSH_BEFORE_LEAVING_MS); + if (result.failed > 0 || result.timedOut) { + toast.error(tLaunch("save_first_failed")); + return; + } + router.push(path); + }, [router, tLaunch]); + // Takes a handoff to the page that handles it, once each: on arrival, or // when the sign-in it had to wait for is done. useEffect(() => { @@ -136,13 +197,13 @@ export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandle const target = HANDOFF_TARGET[handoff.kind]; if (!target || sentToTarget.current.has(handoff.id)) continue; sentToTarget.current.add(handoff.id); - if (matchSurface(pathname)?.surface !== target.surface) router.push(target.path); + if (matchSurface(pathname)?.surface !== target.surface) void leaveFor(target.path as "/" | "/calendar" | "/contacts"); return; } }; goToTarget(); return subscribeHandoff("any", goToTarget); - }, [pathname, router]); + }, [pathname, leaveFor]); useEffect(() => { if (pathname.startsWith("/protocol/")) return; @@ -177,7 +238,7 @@ export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandle if (!launch) { const shortcut = shortcutFromTargetUrl(launchParams.targetURL, window.location.origin); if (shortcut === "new-message") handOffNewMessage(); - else if (shortcut && matchSurface(pathname)?.surface !== shortcut) router.push(`/${shortcut}`); + else if (shortcut && matchSurface(pathname)?.surface !== shortcut) void leaveFor(`/${shortcut}`); return; } @@ -213,12 +274,23 @@ export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandle notifyPendingWebcal(); if (pathname !== "/calendar") router.push("/calendar"); }); - }, [pathname, router, tLaunch]); + }, [pathname, router, tLaunch, leaveFor]); + const offeredFiles = offer?.files ?? []; return ( <> {children} + + {offer.subject &&
{tLaunch("share_confirm_subject")}
{offer.subject}
} + {offer.body &&
{tLaunch("share_confirm_text")}
{offer.body.slice(0, OFFER_TEXT_LENGTH)}
} + {offeredFiles.length > 0 &&
{tLaunch("share_confirm_files", { count: offeredFiles.length })}
{offeredFiles.slice(0, OFFER_FILE_COUNT).join(", ")}{offeredFiles.length > OFFER_FILE_COUNT ? ", …" : ""}
} + + )} + /> ); } diff --git a/integration/tests/23-do-pwa-launch.spec.ts b/integration/tests/23-do-pwa-launch.spec.ts index dd898c0e9..6d2330b72 100644 --- a/integration/tests/23-do-pwa-launch.spec.ts +++ b/integration/tests/23-do-pwa-launch.spec.ts @@ -1,6 +1,7 @@ import { test, expect, type Browser, type BrowserContext, type Page } from '@playwright/test'; import { accountSwitcher, neutralizeDevOverlay } from './helpers/app'; -import { ENT, ENT_PASSWORD, adminContext, contextOptions, getPolicy, putPolicy, requireEntEnv, shot } from './helpers/ent'; +import { WEBMAIL_URL } from './helpers/config'; +import { ENT, ENT_PASSWORD, adminContext, contextOptions, getPolicy, jmapAs, putPolicy, requireEntEnv, shot } from './helpers/ent'; /** * The installed app's launches through the real UI: shortcuts, files opened @@ -149,6 +150,45 @@ async function workerReady(page: Page): Promise { expect(active).toBe(true); } +/** The question a share has to pass, wherever it came from. */ +const startMessage = (page: Page) => page.getByRole('button', { name: 'Start message', exact: true }); +const dontStart = (page: Page) => page.getByRole('button', { name: "Don't start", exact: true }); + +const HOSTILE = 'http://hostile.example'; + +/** A page on another origin that POSTs to the share target as soon as it loads. Nobody clicks. */ +function hostilePage(opts: { subject: string; fileName?: string }): string { + return `hostile`; +} + +async function hostileTab(context: BrowserContext, opts: Parameters[0]): Promise { + await context.route(`${HOSTILE}/**`, (route) => route.fulfill({ contentType: 'text/html', body: hostilePage(opts) })); + const tab = await context.newPage(); + await tab.goto(`${HOSTILE}/`); + return tab; +} + +/** Every message on the server with this subject. */ +async function draftsWithSubject(subject: string): Promise { + const client = await jmapAs(ENT.alice); + const res = await client.request([['Email/query', { accountId: client.accountId, filter: { subject } }, 'q']]); + return res.methodResponses[0][1].ids as string[]; +} + +async function deleteDrafts(ids: string[]): Promise { + if (ids.length === 0) return; + const client = await jmapAs(ENT.alice); + await client.request([['Email/set', { accountId: client.accountId, destroy: ids }, 'd']]); +} + const storedShareEntries = (page: Page) => page.evaluate(async (name) => { if (!(await caches.has(name))) return 0; return (await (await caches.open(name)).keys()).length; @@ -197,6 +237,7 @@ test.describe('share target', () => { { name: `shared-${RUN}.txt`, type: 'text/plain', body: 'shared bytes' }, ]); await page.waitForURL((url) => url.pathname.endsWith('/') || !url.pathname.includes('protocol')); + await startMessage(page).click({ timeout: 60_000 }); await signInIfAsked(page, ENT.alice); await expect(composer(page)).toBeVisible({ timeout: 30_000 }); @@ -205,7 +246,7 @@ test.describe('share target', () => { await expect(composer(page).getByText(`shared-${RUN}.txt`)).toBeVisible(); // It reached the app through the worker's store, not the server's fallback. await expect(page.getByText("The shared files couldn't be kept")).toHaveCount(0); - await expect.poll(() => new URL(page.url()).search).toBe(''); + await expect.poll(() => new URL(page.url()).hash).toBe(''); await expect.poll(() => storedShareEntries(page)).toBe(0); await shot(page, 'pwa-launch-share'); await page.context().close(); @@ -218,8 +259,9 @@ test.describe('share target', () => { await workerReady(page); await postShare(page, { url: 'https://example.org/waited' }, [{ name: `waited-${RUN}.txt`, type: 'text/plain', body: 'x' }]); + await startMessage(page).click({ timeout: 60_000 }); await page.locator('#username').waitFor({ state: 'visible', timeout: 60_000 }); - await expect.poll(() => storedShareEntries(page)).toBe(2); + await expect.poll(() => storedShareEntries(page)).toBe(3); // What SSO does: away and back, in the same tab. await page.reload(); await signIn(page, ENT.alice); @@ -234,6 +276,7 @@ test.describe('share target', () => { const page = await signedIn(browser, { serviceWorkers: 'block' }); await postShare(page, { url: 'https://example.org/no-worker' }, [{ name: 'lost.txt', type: 'text/plain', body: 'x' }]); await page.waitForLoadState('domcontentloaded'); + await startMessage(page).click({ timeout: 60_000 }); await signInIfAsked(page, ENT.alice); await expect(composer(page)).toBeVisible({ timeout: 30_000 }); @@ -252,10 +295,181 @@ test.describe('share target', () => { }); expect(res.status()).toBe(303); const location = new URL(res.headers().location, 'http://x.invalid'); - expect(location.searchParams.get('share')).toBe('unavailable'); - expect(location.searchParams.get('share_url')).toBe('https://example.org/direct'); + // In the fragment: shared text stays out of access logs. + expect(location.search).toBe(''); + const fragment = new URLSearchParams(location.hash.slice(1)); + expect(fragment.get('share')).toBe('unavailable'); + expect(fragment.get('share_url')).toBe('https://example.org/direct'); await context.close(); }); + + test('long non-Latin text fits the address the fallback sends on', async ({ browser }) => { + const context = await browser.newContext(contextOptions()); + const res = await context.request.post('/protocol/share', { + multipart: { text: 'あ'.repeat(3000) }, + maxRedirects: 0, + }); + expect(res.status()).toBe(303); + const location = res.headers().location; + expect(location.length).toBeLessThan(4000); + expect((await context.request.get(location)).ok()).toBe(true); + await context.close(); + }); +}); + +test.describe('a share nobody asked for', () => { + test('a POST from another site leaves no draft on the server and asks first', async ({ browser }) => { + const signedInPage = await signedIn(browser); + await workerReady(signedInPage); + const subject = `hostile-${RUN}-a`; + const tab = await hostileTab(signedInPage.context(), { subject, fileName: `invoice-${RUN}.exe` }); + await tab.waitForURL((url) => url.href.startsWith(WEBMAIL_URL)); + + // The question comes before anything else, also before a sign-in. + await expect(startMessage(tab)).toBeVisible({ timeout: 30_000 }).catch(() => {}); + if (await startMessage(tab).isVisible()) { + await expect(tab.getByTestId('share-confirm-details')).toContainText(`invoice-${RUN}.exe`); + } else { + await signInIfAsked(tab, ENT.alice); + } + await tab.waitForTimeout(6000); + const drafts = await draftsWithSubject(subject); + await deleteDrafts(drafts); + expect(drafts).toEqual([]); + await expect(composer(tab)).toHaveCount(0); + await expect(startMessage(tab)).toBeVisible(); + await shot(tab, 'pwa-launch-share-confirm'); + + // Refused: what it stored goes too. + await dontStart(tab).click(); + await expect.poll(() => storedShareEntries(tab)).toBe(0); + await expect(composer(tab)).toHaveCount(0); + await signedInPage.context().close(); + }); + + test('agreed, it is only the starting state: no draft until the first real edit', async ({ browser }) => { + const signedInPage = await signedIn(browser); + await workerReady(signedInPage); + const subject = `hostile-${RUN}-b`; + const tab = await hostileTab(signedInPage.context(), { subject, fileName: `kept-${RUN}.txt` }); + await tab.waitForURL((url) => url.href.startsWith(WEBMAIL_URL)); + await startMessage(tab).click({ timeout: 60_000 }); + await signInIfAsked(tab, ENT.alice); + await expect(composer(tab).getByText(`kept-${RUN}.txt`)).toBeVisible({ timeout: 30_000 }); + await expect(composer(tab).getByPlaceholder(/subject/i)).toHaveValue(subject); + + await tab.waitForTimeout(6000); + expect(await draftsWithSubject(subject)).toEqual([]); + + // Leaving doesn't save it either. + await tab.evaluate(() => { window.dispatchEvent(new Event('beforeunload')); }); + await tab.waitForTimeout(3000); + expect(await draftsWithSubject(subject)).toEqual([]); + + // The user's own edit: from here it is a draft like any other. + await composer(tab).getByPlaceholder(/subject/i).fill(`${subject} edited`); + await expect(composer(tab).getByPlaceholder(/subject/i)).toHaveValue(`${subject} edited`); + await expect.poll(() => draftsWithSubject(subject), { timeout: 20_000 }).toHaveLength(1); + await deleteDrafts(await draftsWithSubject(subject)); + await signedInPage.context().close(); + }); + + test('a hidden frame stores nothing', async ({ browser }) => { + const page = await (await launchContext(browser)).newPage(); + await page.goto('/'); + await page.locator('#username').waitFor({ state: 'visible', timeout: 60_000 }); + await workerReady(page); + // From the app's own origin: another site's frame doesn't reach this worker + // at all in Chromium (partitioned storage), so only this shows the check. + await page.evaluate(() => { + const frame = document.createElement('iframe'); + frame.name = 'hidden-share'; + frame.style.display = 'none'; + document.body.append(frame); + const form = document.createElement('form'); + form.method = 'POST'; + form.enctype = 'multipart/form-data'; + form.action = '/protocol/share'; + form.target = 'hidden-share'; + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = 'title'; + input.value = 'frame'; + form.append(input); + document.body.append(form); + form.submit(); + }); + await page.waitForTimeout(3000); + expect(await storedShareEntries(page)).toBe(0); + await page.context().close(); + }); + + test('text in an address anyone can link to asks first too', async ({ browser }) => { + const page = await signedIn(browser); + await page.goto(`/#share=unavailable&share_title=${encodeURIComponent(`linked-${RUN}`)}&share_text=hello`); + await expect(startMessage(page)).toBeVisible({ timeout: 30_000 }); + await expect(composer(page)).toHaveCount(0); + await dontStart(page).click(); + await expect(composer(page)).toHaveCount(0); + await page.context().close(); + }); +}); + +test.describe('a handoff while a composer is open', () => { + test('one set aside by reading a message is not replaced by the next', async ({ browser }) => { + const page = await signedIn(browser); + await page.locator('[data-testid="email-list-item"]').first().waitFor({ timeout: 30_000 }); + const subject = `setaside${RUN}`; + await launch(page, { target: '/?compose=1' }); + await expect(composer(page)).toBeVisible({ timeout: 30_000 }); + await composer(page).getByPlaceholder(/subject/i).fill(subject); + await expect(composer(page).getByPlaceholder(/subject/i)).toHaveValue(subject); + // Reading a message suspends the composer before its autosave ran. + await page.locator('[data-testid="email-list-item"]').first().click(); + await expect(composer(page)).toHaveCount(0); + await page.waitForTimeout(2100); + await launch(page, { target: '/?compose=1' }); + await expect(page.getByText('Close this message to start the next one.').first()).toBeVisible({ timeout: 10_000 }); + await expect(composer(page)).toHaveCount(0); + await expect(page.getByText(subject).first()).toBeVisible(); + await page.context().close(); + }); + + test('two at once: the second waits until the first composer closes', async ({ browser }) => { + const page = await signedIn(browser); + // Let the page settle after the sign-in: the composer shouldn't race the first mail load. + await page.waitForTimeout(3000); + await launch(page, { target: '/?compose=1' }); + await expect(composer(page)).toBeVisible({ timeout: 30_000 }); + const subject = `kept-open-${RUN}`; + await composer(page).getByPlaceholder(/subject/i).fill(subject); + await expect(composer(page).getByPlaceholder(/subject/i)).toHaveValue(subject); + + await page.waitForTimeout(2100); + await launch(page, { target: '/?compose=1' }); + await expect(page.getByText('Close this message to start the next one.').first()).toBeVisible({ timeout: 10_000 }); + await expect(composer(page).getByPlaceholder(/subject/i)).toHaveValue(subject); + + await page.locator('[data-testid="composer-close"]').first().click(); + await expect(composer(page).getByPlaceholder(/subject/i)).toHaveValue('', { timeout: 15_000 }); + await deleteDrafts(await draftsWithSubject(subject)); + await page.context().close(); + }); + + test('a .vcf launch saves what was just typed before leaving the composer', async ({ browser }) => { + const page = await signedIn(browser); + await launch(page, { target: '/?compose=1' }); + await expect(composer(page)).toBeVisible({ timeout: 30_000 }); + const subject = `typedfirst${RUN}`; + await composer(page).getByPlaceholder(/subject/i).fill(subject); + // Well inside the autosave's 2 seconds. + await launch(page, { target: '/contacts', files: [VCF] }); + await page.waitForURL(/\/contacts/); + await expect(page.getByText(`Launched Person ${RUN}`).first()).toBeVisible({ timeout: 30_000 }); + await expect.poll(() => draftsWithSubject(subject), { timeout: 10_000 }).toHaveLength(1); + await deleteDrafts(await draftsWithSubject(subject)); + await page.context().close(); + }); }); test.describe('file launches', () => { @@ -365,7 +579,7 @@ test.describe('file launches', () => { await page.goto('/'); await page.locator('#username').waitFor({ state: 'visible', timeout: 60_000 }); await launch(page, { target: '/calendar', files: [ICS] }); - await expect.poll(() => storedShareEntries(page)).toBe(2); + await expect.poll(() => storedShareEntries(page)).toBe(3); await page.reload(); await signIn(page, ENT.alice); await page.waitForURL(/\/calendar/); @@ -432,6 +646,7 @@ test.describe('in the Pro shell', () => { await workerReady(page); await postShare(page, { url: 'https://example.org/pro' }, [{ name: `pro-${RUN}.txt`, type: 'text/plain', body: 'x' }]); + await startMessage(page).click({ timeout: 60_000 }); await signInIfAsked(page, ENT.alice); await page.waitForURL(/\/pro/); await expect(page.getByText(`pro-${RUN}.txt`).first()).toBeVisible({ timeout: 30_000 }); diff --git a/lib/__tests__/launch-handoff.test.ts b/lib/__tests__/launch-handoff.test.ts index 5b21f7abe..9dafa8f5d 100644 --- a/lib/__tests__/launch-handoff.test.ts +++ b/lib/__tests__/launch-handoff.test.ts @@ -3,21 +3,27 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { FakeCaches, loadWorker } from './sw-harness'; import { HANDOFF_CACHE, + HANDOFF_MAX_BYTES, + HANDOFF_MAX_ENTRIES, HANDOFF_TTL_MS, adoptStoredHandoff, clearHandoffs, + discardStoredHandoff, + expireHandoffs, handOffDraft, handOffFiles, handOffLaunchFiles, handOffNewMessage, kindForFile, parseLaunchLanding, + peekStoredHandoff, pendingHandoffs, restoreStoredHandoffs, shareDraft, shortcutFromTargetUrl, stripLandingParams, subscribeHandoff, + subscribeHandoffLost, takeHandoff, } from '../launch-handoff'; @@ -25,7 +31,9 @@ import { const file = (name: string, type = '', body = 'x') => new File([body], name, { type }); const handle = (f: File) => ({ kind: 'file', getFile: async () => f }); -const settle = () => new Promise((r) => setTimeout(r, 0)); +// Sealing runs on Web Crypto's thread pool. +const settle = () => new Promise((r) => setTimeout(r, 60)); +const whenStored = () => vi.waitFor(() => expect(sessionStorage.getItem('bulwark:launch-handoffs')).not.toBeNull(), { timeout: 5000 }); function memoryStorage() { const backing = new Map(); @@ -163,7 +171,7 @@ describe('across a page load', () => { it('brings back a file that waited for a sign-in, with its name and type', async () => { handOffFiles([file('a.ics', 'text/calendar', 'BEGIN:VCALENDAR')], 'calendar'); - await settle(); + await whenStored(); await newPage(); expect(takeHandoff('calendar')).toBeNull(); @@ -176,9 +184,9 @@ describe('across a page load', () => { it('removes the stored copy once it is taken', async () => { handOffFiles([file('a.ics')], 'calendar'); - await settle(); + await whenStored(); const store = await caches.open(HANDOFF_CACHE); - expect((await store.keys()).length).toBe(2); + expect((await store.keys()).length).toBe(3); takeHandoff('calendar'); await settle(); @@ -195,29 +203,104 @@ describe('across a page load', () => { it("leaves another window's handoff alone", async () => { handOffFiles([file('a.ics')], 'calendar'); - await settle(); + await whenStored(); await newPage(); // Another window: its own sessionStorage, the same cache. vi.stubGlobal('sessionStorage', memoryStorage()); await restoreStoredHandoffs(); expect(takeHandoff('calendar')).toBeNull(); - expect((await (await caches.open(HANDOFF_CACHE)).keys()).length).toBe(2); + expect((await (await caches.open(HANDOFF_CACHE)).keys()).length).toBe(3); + }); + + it('keeps no readable bytes, name or text at rest', async () => { + handOffDraft({ subject: 'Secret subject', body: 'Secret text', files: [file('secret-name.txt', 'text/plain', 'secret bytes')] }); + await whenStored(); + const store = await caches.open(HANDOFF_CACHE); + const keys = await store.keys(); + expect(keys.length).toBeGreaterThanOrEqual(3); + for (const key of keys) { + const stored = await (await store.match(key))!.text(); + for (const secret of ['Secret subject', 'Secret text', 'secret-name', 'secret bytes']) expect(stored).not.toContain(secret); + } + }); + + it('cannot read it once the tab with the key is gone', async () => { + handOffFiles([file('a.ics')], 'calendar'); + await whenStored(); + await newPage(); + vi.stubGlobal('sessionStorage', memoryStorage()); + const store = await caches.open(HANDOFF_CACHE); + const metaKey = (await store.keys()).find((key) => key.url.endsWith('/meta'))!.url; + const id = metaKey.split('/').slice(-2)[0]; + expect(await adoptStoredHandoff(id)).toBe(false); + expect(await adoptStoredHandoff(id, 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')).toBe(false); + }); + + it('says so when a stored handoff of this tab is gone after the reload', async () => { + const losses: string[] = []; + const off = subscribeHandoffLost((loss) => losses.push(loss)); + handOffFiles([file('a.ics')], 'calendar'); + await whenStored(); + await newPage(); + await caches.delete(HANDOFF_CACHE); + await restoreStoredHandoffs(); + expect(losses).toEqual(['expired']); + expect(sessionStorage.getItem('bulwark:launch-handoffs')).toBeNull(); + off(); + }); + + it('says so when a stored file is missing, and delivers the rest', async () => { + const losses: string[] = []; + const off = subscribeHandoffLost((loss) => losses.push(loss)); + handOffDraft({ subject: 'S', files: [file('one.txt'), file('two.txt')] }); + await whenStored(); + await newPage(); + const store = await caches.open(HANDOFF_CACHE); + const first = (await store.keys()).find((key) => key.url.endsWith('/0'))!; + await store.delete(first); + await restoreStoredHandoffs(); + expect(losses).toEqual(['missing']); + expect(takeHandoff('compose')?.files.map((f) => f.name)).toEqual(['two.txt']); + off(); + }); + + it('keeps no more than the limit, oldest out first', async () => { + const created: string[] = []; + for (let n = 0; n < HANDOFF_MAX_ENTRIES + 2; n += 1) { + const name = `f${n}.ics`; + handOffFiles([file(name)], 'calendar'); + created.push(name); + await settle(); + await settle(); + } + const store = await caches.open(HANDOFF_CACHE); + const metas = (await store.keys()).filter((key) => key.url.endsWith('/meta')); + expect(metas.length).toBeLessThanOrEqual(HANDOFF_MAX_ENTRIES); + }); + + it('does not store what is over the byte limit, and keeps it in memory', async () => { + const big = file('big.ics'); + Object.defineProperty(big, 'size', { value: HANDOFF_MAX_BYTES + 1 }); + handOffFiles([big], 'calendar'); + await settle(); + expect(await caches.has(HANDOFF_CACHE)).toBe(false); + expect(takeHandoff('calendar')?.files).toEqual([big]); }); it('sweeps what nobody took, and keeps what is recent', async () => { vi.useFakeTimers({ toFake: ['Date'] }); handOffFiles([file('old.ics')], 'calendar'); - await settle(); + await whenStored(); await newPage(); vi.stubGlobal('sessionStorage', memoryStorage()); vi.advanceTimersByTime(HANDOFF_TTL_MS + 1); handOffFiles([file('new.vcf')], 'contacts'); - await settle(); + await whenStored(); await restoreStoredHandoffs(); const store = await caches.open(HANDOFF_CACHE); const urls = (await store.keys()).map((key) => key.url); - expect(urls).toHaveLength(2); + expect(urls).toHaveLength(3); const meta = await store.match(urls.find((url) => url.endsWith('/meta'))!); expect(((await meta!.json()) as { kind: string }).kind).toBe('contacts'); }); @@ -245,10 +328,47 @@ describe('across a page load', () => { }); }); +describe('while the page stays open', () => { + it('drops what waited too long on a timer, and says so', async () => { + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }); + const losses: string[] = []; + const off = subscribeHandoffLost((loss) => losses.push(loss)); + handOffFiles([file('a.eml')], 'eml'); + await vi.advanceTimersByTimeAsync(HANDOFF_TTL_MS + 2000); + expect(pendingHandoffs()).toEqual([]); + expect(losses).toEqual(['expired']); + off(); + }); + + it('says so when a taker finds only an expired handoff', () => { + vi.useFakeTimers({ toFake: ['Date'] }); + const losses: string[] = []; + const off = subscribeHandoffLost((loss) => losses.push(loss)); + handOffFiles([file('a.eml')], 'eml'); + vi.advanceTimersByTime(HANDOFF_TTL_MS + 1); + expect(takeHandoff('eml')).toBeNull(); + expect(losses).toEqual(['expired']); + off(); + }); + + it('keeps what is still in time', async () => { + handOffFiles([file('a.eml')], 'eml'); + await expireHandoffs(); + expect(pendingHandoffs()).toHaveLength(1); + }); + + it('leaves the second of two drafts queued until it is taken', () => { + handOffDraft({ subject: 'first' }); + handOffDraft({ subject: 'second' }); + expect(takeHandoff('compose')?.subject).toBe('first'); + expect(pendingHandoffs().map((h) => h.subject)).toEqual(['second']); + }); +}); + describe('sign-out', () => { it('forgets what is waiting, in memory and stored', async () => { handOffFiles([file('a.ics')], 'calendar'); - await settle(); + await whenStored(); clearHandoffs(); await settle(); expect(pendingHandoffs()).toEqual([]); @@ -266,12 +386,18 @@ describe('what the service worker stored', () => { form.set('text', 'Look at this'); form.set('url', 'https://example.org/page'); form.append('files', file('photo.png', 'image/png', 'bytes')); - const answer = await worker.request({ url: 'https://mail.example.com/protocol/share', method: 'POST', mode: 'navigate', formData: async () => form } as never); - const landing = parseLaunchLanding(new URL(answer!.headers.get('location')!).search); + const answer = await worker.request({ url: 'https://mail.example.com/protocol/share', method: 'POST', mode: 'navigate', destination: 'document', formData: async () => form } as never); + const location = new URL(answer!.headers.get('location')!); + const landing = parseLaunchLanding(location.search, location.hash); expect(landing?.type).toBe('stored'); - const { id } = landing as { id: string }; + const { id, key } = landing as { id: string; key: string }; - expect(await adoptStoredHandoff(id)).toBe(true); + // Without the key from the address it can't be read; asking shows what it is. + expect(await peekStoredHandoff(id, '')).toBeNull(); + expect(await peekStoredHandoff(id, key)).toEqual({ subject: 'A link', body: 'Look at this\n\nhttps://example.org/page', files: ['photo.png'] }); + expect(pendingHandoffs()).toEqual([]); + + expect(await adoptStoredHandoff(id, key)).toBe(true); const handoff = takeHandoff('compose'); expect(handoff).toMatchObject({ subject: 'A link', body: 'Look at this\n\nhttps://example.org/page' }); expect(handoff?.files[0].name).toBe('photo.png'); @@ -283,13 +409,37 @@ describe('what the service worker stored', () => { }); it('is gone when the id names nothing', async () => { - expect(await adoptStoredHandoff('11111111-2222')).toBe(false); + expect(await adoptStoredHandoff('11111111-2222', 'AAAA')).toBe(false); + expect(await peekStoredHandoff('11111111-2222', 'AAAA')).toBeNull(); + }); + + it('goes when the user refuses it', async () => { + const worker = loadWorker({ buildId: 'a', caches, clients: [] }); + const form = new FormData(); + form.append('files', file('photo.png')); + const answer = await worker.request({ url: 'https://mail.example.com/protocol/share', method: 'POST', mode: 'navigate', destination: 'document', formData: async () => form } as never); + const location = new URL(answer!.headers.get('location')!); + const { id, key } = parseLaunchLanding(location.search, location.hash) as { id: string; key: string }; + await discardStoredHandoff(id); + expect((await (await caches.open(HANDOFF_CACHE)).keys()).length).toBe(0); + expect(await peekStoredHandoff(id, key)).toBeNull(); + }); + + it('cleans control characters out of names and text, as for a mailto: link', async () => { + const worker = loadWorker({ buildId: 'a', caches, clients: [] }); + const form = new FormData(); + form.set('title', 'Hi\r\nBcc: evil@example.org'); + form.append('files', file('a\u202Egnp.exe')); + const answer = await worker.request({ url: 'https://mail.example.com/protocol/share', method: 'POST', mode: 'navigate', destination: 'document', formData: async () => form } as never); + const location = new URL(answer!.headers.get('location')!); + const { id, key } = parseLaunchLanding(location.search, location.hash) as { id: string; key: string }; + expect(await peekStoredHandoff(id, key)).toMatchObject({ subject: 'HiBcc: evil@example.org', files: ['agnp.exe'] }); }); it('is gone when it is too old', async () => { const store = await caches.open(HANDOFF_CACHE); - await store.put('/__bulwark/handoff/old/meta', new Response(JSON.stringify({ kind: 'compose', createdAt: Date.now() - HANDOFF_TTL_MS - 1, files: [] }))); - expect(await adoptStoredHandoff('old')).toBe(false); + await store.put('/__bulwark/handoff/old/meta', new Response(JSON.stringify({ kind: 'compose', createdAt: Date.now() - HANDOFF_TTL_MS - 1, bytes: 0 }))); + expect(await adoptStoredHandoff('old', 'AAAA')).toBe(false); expect((await store.keys()).length).toBe(0); }); }); @@ -306,25 +456,55 @@ describe('text of a share', () => { it('copes with only a link', () => { expect(shareDraft({ url: 'https://a.example' })).toEqual({ subject: '', body: 'https://a.example' }); }); + + it('strips control characters from the subject, so no header can be planted', () => { + expect(shareDraft({ title: 'Hi\r\nBcc: evil@example.org' }).subject).toBe('HiBcc: evil@example.org'); + }); + + it('keeps line breaks in the body and drops other control characters', () => { + expect(shareDraft({ text: 'one\r\ntwo\u0000\u0007' }).body).toBe('one\ntwo'); + }); + + it('applies the limits of a mailto: link', () => { + const draft = shareDraft({ title: 'x'.repeat(2000), text: 'y'.repeat(100_000), url: 'https://a.example' }); + expect(draft.subject).toHaveLength(998); + expect(draft.body).toHaveLength(64 * 1024); + }); + + it('applies them to a draft handed in directly as well', () => { + handOffDraft({ subject: 'a\r\nb', body: 'y'.repeat(100_000) }); + const handoff = takeHandoff('compose'); + expect(handoff?.subject).toBe('ab'); + expect(handoff?.body).toHaveLength(64 * 1024); + }); }); describe('the address a launch lands on', () => { it.each([ - ['?compose=1', { type: 'new-message' }], - ['?share=abc-123', { type: 'stored', id: 'abc-123' }], - ['?share=unavailable&share_title=T&share_text=B&share_files=1', { type: 'inline', subject: 'T', body: 'B', filesLost: true }], - ['?share=unavailable&share_url=https%3A%2F%2Fa.example', { type: 'inline', subject: '', body: 'https://a.example', filesLost: false }], - ['?share=..%2F..%2Fetc', null], - ['?compose=0', null], - ['?openLatestUnread=1', null], - ['', null], - ])('%s', (search, expected) => { - expect(parseLaunchLanding(search)).toEqual(expected); + ['?compose=1', '', { type: 'new-message' }], + ['', '#share=abc-123&key=k1', { type: 'stored', id: 'abc-123', key: 'k1' }], + ['', '#share=unavailable&share_title=T&share_text=B&share_files=1', { type: 'inline', subject: 'T', body: 'B', filesLost: true, cut: false }], + ['', '#share=unavailable&share_url=https%3A%2F%2Fa.example&share_cut=1', { type: 'inline', subject: '', body: 'https://a.example', filesLost: false, cut: true }], + ['', '#share=..%2F..%2Fetc', null], + // Shared text stays out of the query, which reaches logs. + ['?share=unavailable&share_text=x', '', null], + ['?compose=0', '', null], + ['?openLatestUnread=1', '#x', null], + ['', '', null], + ])('%s %s', (search, hash, expected) => { + expect(parseLaunchLanding(search, hash)).toEqual(expected); + }); + + it('cleans text that came in the address', () => { + const landing = parseLaunchLanding('', '#share=unavailable&share_title=Hi%0D%0ABcc%3A%20evil'); + expect(landing).toMatchObject({ subject: 'HiBcc: evil' }); }); it('leaves only what is not about the launch', () => { - expect(stripLandingParams('?compose=1')).toBe(''); - expect(stripLandingParams('?share=unavailable&share_text=x&slot=2')).toBe('?slot=2'); + expect(stripLandingParams('?compose=1', '')).toEqual({ search: '', hash: '' }); + expect(stripLandingParams('?slot=2', '#share=unavailable&share_text=x')).toEqual({ search: '?slot=2', hash: '' }); + expect(stripLandingParams('?compose=1&slot=2', '#section')).toEqual({ search: '?slot=2', hash: '#section' }); + expect(stripLandingParams('', '#share=a&key=b')).toEqual({ search: '', hash: '' }); }); }); diff --git a/lib/__tests__/manifest-launch.test.ts b/lib/__tests__/manifest-launch.test.ts index 124f67b55..c4c2794f4 100644 --- a/lib/__tests__/manifest-launch.test.ts +++ b/lib/__tests__/manifest-launch.test.ts @@ -160,7 +160,9 @@ describe('share route without a service worker', () => { expect(res.status).toBe(303); const landing = new URL(res.headers.get('location')!, 'https://x.example'); expect(landing.pathname).toBe('/'); - expect(Object.fromEntries(landing.searchParams)).toEqual({ share: 'unavailable', share_title: 'T', share_text: 'B', share_url: 'https://example.org/' }); + // In the fragment: it never reaches an access log. + expect(landing.search).toBe(''); + expect(Object.fromEntries(new URLSearchParams(landing.hash.slice(1)))).toEqual({ share: 'unavailable', share_title: 'T', share_text: 'B', share_url: 'https://example.org/' }); }); it('says files were lost', async () => { @@ -169,7 +171,7 @@ describe('share route without a service worker', () => { form.set('text', 'B'); form.append('files', new File(['x'], 'a.png')); const res = await POST(post(form, '500')); - expect(new URL(res.headers.get('location')!, 'https://x.example').searchParams.get('share_files')).toBe('1'); + expect(res.headers.get('location')).toContain('share_files=1'); }); it('does not read a large upload into memory', async () => { @@ -180,15 +182,34 @@ describe('share route without a service worker', () => { const request = { headers: new Headers({ 'content-length': String(50 * 1024 * 1024) }), formData: spy } as unknown as Request; const res = await POST(request); expect(spy).not.toHaveBeenCalled(); - expect(new URL(res.headers.get('location')!, 'https://x.example').searchParams.get('share_files')).toBe('1'); + expect(res.headers.get('location')).toContain('share_files=1'); }); - it('keeps the address short', async () => { + it.each([ + ['Latin', 'x'], + ['Japanese, 9 encoded bytes each', 'あ'], + ['emoji, whole characters only', '😀'], + ])('keeps the address short and says it cut: %s', async (_name, character) => { const { POST } = await route(); const form = new FormData(); - form.set('text', 'x'.repeat(100_000)); + form.set('text', character.repeat(3000)); + form.set('title', character.repeat(3000)); + form.set('url', character.repeat(3000)); const res = await POST(post(form, '100000')); - expect(res.headers.get('location')!.length).toBeLessThan(4200); + const location = res.headers.get('location')!; + expect(location.length).toBeLessThan(4000); + const fragment = new URLSearchParams(new URL(location, 'https://x.example').hash.slice(1)); + expect(fragment.get('share_cut')).toBe('1'); + // Decodes cleanly: no half a character at the end. + expect(fragment.get('share_text')!.endsWith(character)).toBe(true); + }); + + it('does not say it cut what fits', async () => { + const { POST } = await route(); + const form = new FormData(); + form.set('text', 'あ'.repeat(100)); + const res = await POST(post(form, '1000')); + expect(res.headers.get('location')).not.toContain('share_cut'); }); it('honours the subpath', async () => { diff --git a/lib/__tests__/sw-harness.ts b/lib/__tests__/sw-harness.ts index 3f3479d40..f74964783 100644 --- a/lib/__tests__/sw-harness.ts +++ b/lib/__tests__/sw-harness.ts @@ -39,7 +39,7 @@ export function loadWorker(opts: { buildId: string; caches: FakeCaches; clients: clients: { claim: async () => {}, matchAll: async () => opts.clients }, registration: { showNotification: async () => {}, getNotifications: async () => [] }, }; - const context = vm.createContext({ self, caches: opts.caches, fetch: opts.fetch ?? (async () => { throw new TypeError('offline'); }), URL, URLSearchParams, Request, Response, Headers, Date, console, AbortController, setTimeout, clearTimeout, crypto: globalThis.crypto }); + const context = vm.createContext({ self, caches: opts.caches, fetch: opts.fetch ?? (async () => { throw new TypeError('offline'); }), URL, URLSearchParams, Request, Response, Headers, Date, console, AbortController, setTimeout, clearTimeout, crypto: globalThis.crypto, TextEncoder, btoa }); vm.runInContext(SW_SOURCE, context); async function dispatch(type: string, event: Record = {}) { const waits: Promise[] = []; diff --git a/lib/__tests__/sw-share-target.test.ts b/lib/__tests__/sw-share-target.test.ts index 799517e37..57509a55f 100644 --- a/lib/__tests__/sw-share-target.test.ts +++ b/lib/__tests__/sw-share-target.test.ts @@ -3,13 +3,17 @@ import { describe, expect, it, vi } from 'vitest'; import { FakeCaches, loadWorker } from './sw-harness'; // The manifest's share_target POST, in the service worker, and a mailto: -// launch while offline. Before: a POST went to the server when offline mode was off. +// launch while offline. Any site can POST here, signed in or not, so what the +// worker keeps is limited and sealed. Before: a POST went to the server when +// offline mode was off, and files sat readable in the cache with no limit. const ORIGIN = 'https://mail.example.com'; +const CACHE = 'bulwark-launch-handoff-v1'; +const TTL = 15 * 60 * 1000; const settle = () => new Promise((r) => setTimeout(r, 0)); function shareRequest(form: FormData, overrides: Record = {}) { - return { url: `${ORIGIN}/protocol/share`, method: 'POST', mode: 'navigate', formData: async () => form, ...overrides } as never; + return { url: `${ORIGIN}/protocol/share`, method: 'POST', mode: 'navigate', destination: 'document', formData: async () => form, ...overrides } as never; } function sharedForm(): FormData { @@ -17,15 +21,31 @@ function sharedForm(): FormData { form.set('title', 'A link'); form.set('text', 'Look'); form.set('url', 'https://example.org/'); - form.append('files', new File(['bytes'], 'photo.png', { type: 'image/png' })); + form.append('files', new File(['secret bytes'], 'photo.png', { type: 'image/png' })); return form; } +const landingOf = (answer: Response | null) => { + const url = new URL(answer!.headers.get('location')!); + return { url, fragment: new URLSearchParams(url.hash.slice(1)) }; +}; + async function shellOn(caches: FakeCaches, on: boolean) { const cache = await caches.open('bulwark-sw-state-v1'); await cache.put('/__bulwark/offline-shell', new Response(JSON.stringify({ on }))); } +async function plantEntry(caches: FakeCaches, id: string, createdAt: number, bytes = 0) { + const store = await caches.open(CACHE); + await store.put(`/__bulwark/handoff/${id}/meta`, new Response(JSON.stringify({ kind: 'compose', createdAt, bytes }))); + await store.put(`/__bulwark/handoff/${id}/sealed`, new Response('x')); +} + +async function entryIds(caches: FakeCaches): Promise { + const store = await caches.open(CACHE); + return (await store.keys()).filter((key) => key.url.endsWith('/meta')).map((key) => key.url.split('/').slice(-2)[0]); +} + function htmlPage(body: string) { const res = new Response(body, { status: 200, headers: { 'Content-Type': 'text/html' } }); Object.defineProperty(res, 'type', { value: 'basic' }); @@ -33,7 +53,7 @@ function htmlPage(body: string) { } describe('share target', () => { - it.each([[false], [true]])('takes the POST with offline mode on=%s, keeps the files and goes to the app', async (on) => { + it.each([[false], [true]])('takes the POST with offline mode on=%s, keeps the files sealed and goes to the app', async (on) => { const caches = new FakeCaches(); await shellOn(caches, on); const fetchMock = vi.fn(); @@ -44,15 +64,23 @@ describe('share target', () => { expect(fetchMock).not.toHaveBeenCalled(); expect(answer?.status).toBe(303); - const landing = new URL(answer!.headers.get('location')!); - expect(`${landing.origin}${landing.pathname}`).toBe(`${ORIGIN}/`); - const id = landing.searchParams.get('share')!; + const { url, fragment } = landingOf(answer); + expect(`${url.origin}${url.pathname}`).toBe(`${ORIGIN}/`); + // Id and key in the fragment: neither ever reaches the server. + expect(url.search).toBe(''); + const id = fragment.get('share')!; expect(id).toMatch(/^[\w-]+$/); + expect(fragment.get('key')).toMatch(/^[\w-]{43}$/); - const store = await caches.open('bulwark-launch-handoff-v1'); + const store = await caches.open(CACHE); const meta = (await (await store.match(`/__bulwark/handoff/${id}/meta`))!.json()) as Record; - expect(meta).toMatchObject({ kind: 'compose', title: 'A link', text: 'Look', url: 'https://example.org/', files: [{ name: 'photo.png', type: 'image/png' }] }); - expect(await (await store.match(`/__bulwark/handoff/${id}/0`))!.text()).toBe('bytes'); + expect(meta).toMatchObject({ kind: 'compose', bytes: 12 }); + // Only what a sweep needs is readable. + expect(Object.keys(meta).sort()).toEqual(['bytes', 'createdAt', 'kind']); + for (const part of ['meta', 'sealed', '0']) { + const text = await (await store.match(`/__bulwark/handoff/${id}/${part}`))!.text(); + for (const secret of ['secret bytes', 'photo.png', 'A link', 'example.org']) expect(text).not.toContain(secret); + } }); it('answers while the worker has only just started and has not read its state', async () => { @@ -65,7 +93,7 @@ describe('share target', () => { const worker = loadWorker({ buildId: 'a', caches: new FakeCaches(), clients: [], location: `${ORIGIN}/webmail/sw.js?v=a` }); expect(await worker.request(shareRequest(sharedForm()))).toBeNull(); const answer = await worker.request(shareRequest(sharedForm(), { url: `${ORIGIN}/webmail/protocol/share` })); - expect(answer!.headers.get('location')).toContain(`${ORIGIN}/webmail/?share=`); + expect(answer!.headers.get('location')).toContain(`${ORIGIN}/webmail/#share=`); }); it('skips empty file fields', async () => { @@ -75,37 +103,54 @@ describe('share target', () => { form.set('url', 'https://example.org/'); form.append('files', new File([], '')); const answer = await worker.request(shareRequest(form)); - const id = new URL(answer!.headers.get('location')!).searchParams.get('share')!; - const store = await caches.open('bulwark-launch-handoff-v1'); - const meta = (await (await store.match(`/__bulwark/handoff/${id}/meta`))!.json()) as { files: unknown[] }; - expect(meta.files).toEqual([]); + const id = landingOf(answer).fragment.get('share')!; + const store = await caches.open(CACHE); + const meta = (await (await store.match(`/__bulwark/handoff/${id}/meta`))!.json()) as { bytes: number }; + expect(meta.bytes).toBe(0); + expect(await store.match(`/__bulwark/handoff/${id}/0`)).toBeUndefined(); }); it('keeps the text and says the files are lost when there is no room', async () => { const caches = new FakeCaches(); const original = caches.open.bind(caches); - const store = await original('bulwark-launch-handoff-v1'); - vi.spyOn(caches, 'open').mockImplementation(async (name: string) => (name === 'bulwark-launch-handoff-v1' + const store = await original(CACHE); + vi.spyOn(caches, 'open').mockImplementation(async (name: string) => (name === CACHE ? { ...store, put: async () => { throw new Error('QuotaExceededError'); } } : original(name))); const worker = loadWorker({ buildId: 'a', caches, clients: [] }); - const answer = await worker.request(shareRequest(sharedForm())); + const { fragment } = landingOf(await worker.request(shareRequest(sharedForm()))); - const landing = new URL(answer!.headers.get('location')!).searchParams; - expect(landing.get('share')).toBe('unavailable'); - expect(landing.get('share_files')).toBe('1'); - expect(landing.get('share_title')).toBe('A link'); - expect(landing.get('share_url')).toBe('https://example.org/'); + expect(fragment.get('share')).toBe('unavailable'); + expect(fragment.get('share_files')).toBe('1'); + expect(fragment.get('share_title')).toBe('A link'); + expect(fragment.get('share_url')).toBe('https://example.org/'); expect((await store.keys()).length).toBe(0); }); + it('keeps that address short too, and says it cut', async () => { + const caches = new FakeCaches(); + const original = caches.open.bind(caches); + const store = await original(CACHE); + vi.spyOn(caches, 'open').mockImplementation(async (name: string) => (name === CACHE + ? { ...store, put: async () => { throw new Error('QuotaExceededError'); } } + : original(name))); + const worker = loadWorker({ buildId: 'a', caches, clients: [] }); + const form = new FormData(); + form.set('text', 'あ'.repeat(3000)); + + const answer = await worker.request(shareRequest(form)); + + expect(answer!.headers.get('location')!.length).toBeLessThan(4000); + expect(landingOf(answer).fragment.get('share_cut')).toBe('1'); + }); + it('says so when the form cannot be read', async () => { const worker = loadWorker({ buildId: 'a', caches: new FakeCaches(), clients: [] }); const answer = await worker.request(shareRequest(new FormData(), { formData: async () => { throw new TypeError('bad body'); } })); - const landing = new URL(answer!.headers.get('location')!).searchParams; - expect(landing.get('share')).toBe('unavailable'); - expect(landing.get('share_files')).toBe('1'); + const { fragment } = landingOf(answer); + expect(fragment.get('share')).toBe('unavailable'); + expect(fragment.get('share_files')).toBe('1'); }); it('leaves other requests alone', async () => { @@ -117,8 +162,94 @@ describe('share target', () => { expect(await worker.request(shareRequest(sharedForm(), { mode: 'cors' }))).toBeNull(); expect(await worker.request(shareRequest(sharedForm(), { url: `${ORIGIN}/api/share` }))).toBeNull(); expect(await worker.request(shareRequest(sharedForm(), { url: 'https://other.example/protocol/share' }))).toBeNull(); - expect(await worker.request({ url: `${ORIGIN}/protocol/share`, mode: 'navigate' })).toBeNull(); - expect(await caches.has('bulwark-launch-handoff-v1')).toBe(false); + expect(await worker.request({ url: `${ORIGIN}/protocol/share`, mode: 'navigate', destination: 'document' } as never)).toBeNull(); + expect(await caches.has(CACHE)).toBe(false); + }); + + it('leaves a POST into a hidden frame to the network: it stores nothing', async () => { + const caches = new FakeCaches(); + await shellOn(caches, false); + const worker = loadWorker({ buildId: 'a', caches, clients: [] }); + await settle(); + expect(await worker.request(shareRequest(sharedForm(), { destination: 'iframe' }))).toBeNull(); + expect(await worker.request(shareRequest(sharedForm(), { destination: 'embed' }))).toBeNull(); + expect(await caches.has(CACHE)).toBe(false); + }); +}); + +describe('limits on what the worker keeps', () => { + it('drops the oldest entries over the entry limit, on every share', async () => { + const caches = new FakeCaches(); + const now = Date.now(); + for (let n = 0; n < 5; n += 1) await plantEntry(caches, `old-${n}`, now - 1000 * (5 - n)); + const worker = loadWorker({ buildId: 'a', caches, clients: [] }); + + const answer = await worker.request(shareRequest(sharedForm())); + + const ids = await entryIds(caches); + expect(ids).toHaveLength(5); + expect(ids).not.toContain('old-0'); + expect(ids).toContain('old-1'); + expect(ids).toContain(landingOf(answer).fragment.get('share')); + // Everything of the dropped one goes, not only its list. + expect(await (await caches.open(CACHE)).match('/__bulwark/handoff/old-0/sealed')).toBeUndefined(); + }); + + it('drops the oldest entries over the byte limit', async () => { + const caches = new FakeCaches(); + const now = Date.now(); + await plantEntry(caches, 'big', now - 2000, 100 * 1024 * 1024 - 5); + await plantEntry(caches, 'small', now - 1000, 2); + const worker = loadWorker({ buildId: 'a', caches, clients: [] }); + + await worker.request(shareRequest(sharedForm())); + + const ids = await entryIds(caches); + expect(ids).not.toContain('big'); + expect(ids).toContain('small'); + }); + + it('stores nothing from a share over the byte limit, says the files are lost, and keeps the rest', async () => { + const caches = new FakeCaches(); + await plantEntry(caches, 'kept', Date.now() - 1000, 10); + const worker = loadWorker({ buildId: 'a', caches, clients: [] }); + const big = new File(['x'], 'huge.bin'); + Object.defineProperty(big, 'size', { value: 100 * 1024 * 1024 + 1 }); + const fields = new Map([['title', 'T']]); + const form = { get: (name: string) => fields.get(name) ?? null, getAll: () => [big] } as unknown as FormData; + + const { fragment } = landingOf(await worker.request(shareRequest(form))); + + expect(fragment.get('share')).toBe('unavailable'); + expect(fragment.get('share_files')).toBe('1'); + expect(await entryIds(caches)).toEqual(['kept']); + }); + + it('sweeps what is too old on every share', async () => { + const caches = new FakeCaches(); + await plantEntry(caches, 'stale', Date.now() - TTL - 1); + await plantEntry(caches, 'fresh', Date.now() - 1000); + const worker = loadWorker({ buildId: 'a', caches, clients: [] }); + await worker.request(shareRequest(sharedForm())); + const ids = await entryIds(caches); + expect(ids).not.toContain('stale'); + expect(ids).toContain('fresh'); + }); + + it('sweeps what is too old when it activates', async () => { + const caches = new FakeCaches(); + await plantEntry(caches, 'stale', Date.now() - TTL - 1); + await plantEntry(caches, 'fresh', Date.now() - 1000); + const worker = loadWorker({ buildId: 'a', caches, clients: [] }); + await worker.dispatch('activate'); + expect(await entryIds(caches)).toEqual(['fresh']); + }); + + it('creates no cache when it activates with nothing kept', async () => { + const caches = new FakeCaches(); + const worker = loadWorker({ buildId: 'a', caches, clients: [] }); + await worker.dispatch('activate'); + expect(await caches.has(CACHE)).toBe(false); }); }); diff --git a/lib/launch-handoff.ts b/lib/launch-handoff.ts index a072cf0ac..8f5be39ec 100644 --- a/lib/launch-handoff.ts +++ b/lib/launch-handoff.ts @@ -14,12 +14,24 @@ * sessionStorage, which can't hold a File. So a handoff lives in this module's * memory, and everything except a .eml also goes into the Cache API: signing * in with SSO, or after a session ended, leaves the page, and the file has to - * still be there afterwards. sessionStorage only holds which stored handoffs - * belong to this tab, so a second window doesn't take them. What is stored - * goes when it is taken, when it is too old, or at sign-out. + * still be there afterwards. + * + * What is stored is sealed with a key of its own, and the key stays in this + * tab's sessionStorage, so a second window can't take it. The worker passes + * its key in the landing address's fragment. What is stored goes when it is + * taken, after HANDOFF_TTL_MS, when newer handoffs need the room, at sign-out + * and when offline mode wipes. + * + * Anyone can POST to the share target, or link to a landing address, so what + * arrives that way is untrusted: the provider asks the user before it gets + * here (peekStoredHandoff, then adoptStoredHandoff or discardStoredHandoff). + * Chromium's link capturing can bring a cross-site form POST into an installed + * app's window, so that goes for app windows too. Only a bridge skips it. */ +import { stripBidiControls } from '@/lib/bidi'; import { matchSurface } from '@/lib/deep-links'; +import { cleanMailtoBody, cleanMailtoSubject } from '@/lib/protocol-handlers/mailto'; export type HandoffKind = 'compose' | 'eml' | 'calendar' | 'contacts'; @@ -33,21 +45,28 @@ export interface Handoff { createdAt: number; } -/** The service worker writes this cache too, in the same layout. */ +/** The service worker writes this cache too, in the same layout and with the same limits. */ export const HANDOFF_CACHE = 'bulwark-launch-handoff-v1'; -const STORE_PREFIX = '/__bulwark/handoff/'; -const TAB_IDS_KEY = 'bulwark:launch-handoffs'; export const HANDOFF_TTL_MS = 15 * 60 * 1000; +export const HANDOFF_MAX_ENTRIES = 5; +export const HANDOFF_MAX_BYTES = 100 * 1024 * 1024; +const STORE_PREFIX = '/__bulwark/handoff/'; +const TAB_KEYS_KEY = 'bulwark:launch-handoffs'; /** A launch can arrive twice: as launchQueue's target and as the page's own address. */ const NEW_MESSAGE_REPEAT_MS = 2000; type Listener = (kind: HandoffKind) => void; +export type HandoffLoss = 'expired' | 'missing'; const queue: Handoff[] = []; const stored = new Map>(); const kindListeners = new Map>(); const anyListeners = new Set(); +const lossListeners = new Set<(loss: HandoffLoss) => void>(); let lastNewMessageAt = 0; +let expiryTimer: ReturnType | null = null; +/** Bumped by clearHandoffs: a store still under way when it ran takes itself back. */ +let clearCount = 0; function newId(): string { return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' @@ -59,41 +78,137 @@ function cacheApi(): CacheStorage | null { return typeof caches !== 'undefined' && caches && typeof caches.open === 'function' ? caches : null; } -function readTabIds(): string[] { +function readTabKeys(): Record { try { - const parsed: unknown = JSON.parse(sessionStorage.getItem(TAB_IDS_KEY) ?? '[]'); - return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : []; + const parsed: unknown = JSON.parse(sessionStorage.getItem(TAB_KEYS_KEY) ?? '{}'); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record : {}; } catch { - return []; + return {}; } } -function writeTabIds(ids: string[]): void { +function writeTabKeys(keys: Record): void { try { - if (ids.length > 0) sessionStorage.setItem(TAB_IDS_KEY, JSON.stringify(ids)); - else sessionStorage.removeItem(TAB_IDS_KEY); + if (Object.keys(keys).length > 0) sessionStorage.setItem(TAB_KEYS_KEY, JSON.stringify(keys)); + else sessionStorage.removeItem(TAB_KEYS_KEY); } catch { // Hardened or private modes: the handoff stays in memory only. } } +function setTabKey(id: string, key: string | null): void { + const keys = readTabKeys(); + if (key) keys[id] = key; + else delete keys[id]; + writeTabKeys(keys); +} + +function toBase64Url(bytes: Uint8Array): string { + return btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +function fromBase64Url(text: string): Uint8Array { + const binary = atob(text.replace(/-/g, '+').replace(/_/g, '/')); + const bytes = new Uint8Array(new ArrayBuffer(binary.length)); + for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); + return bytes; +} + +/** A 12-byte IV, then the AES-GCM ciphertext. */ +async function seal(key: CryptoKey, data: BufferSource): Promise> { + const iv = crypto.getRandomValues(new Uint8Array(12)); + const body = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, data)); + const sealed = new Uint8Array(new ArrayBuffer(iv.length + body.length)); + sealed.set(iv); + sealed.set(body, iv.length); + return sealed; +} + +async function unseal(key: CryptoKey, sealed: ArrayBuffer): Promise { + return crypto.subtle.decrypt({ name: 'AES-GCM', iv: sealed.slice(0, 12) }, key, sealed.slice(12)); +} + +function canSeal(): boolean { + return typeof crypto !== 'undefined' && !!crypto.subtle; +} + +function entryPrefix(id: string): string { + return `${STORE_PREFIX}${id}/`; +} + +function pathOf(key: { url: string }): string { + return new URL(key.url, 'https://x.invalid').pathname; +} + +async function deleteEntry(cache: Cache, prefix: string): Promise { + const keys = await cache.keys(); + const own = keys.filter((key) => pathOf(key).startsWith(prefix)); + // The list last: the sweep goes by it. + await Promise.all(own.filter((key) => !pathOf(key).endsWith('/meta')).map((key) => cache.delete(key))); + await cache.delete(`${prefix}meta`); +} + +/** + * Drops what is too old and, when `incoming` wants in, the oldest entries that + * are in the way. Returns when the oldest remaining entry was stored. + */ +async function sweep(cache: Cache, incoming: { bytes: number } | null): Promise { + const entries: Array<{ prefix: string; createdAt: number; bytes: number }> = []; + for (const key of await cache.keys()) { + const path = pathOf(key); + if (!path.endsWith('/meta')) continue; + const response = await cache.match(key); + const meta = response ? ((await response.json().catch(() => null)) as { createdAt?: unknown; bytes?: unknown } | null) : null; + entries.push({ + prefix: path.slice(0, -'meta'.length), + createdAt: typeof meta?.createdAt === 'number' ? meta.createdAt : 0, + bytes: typeof meta?.bytes === 'number' ? meta.bytes : 0, + }); + } + entries.sort((a, b) => a.createdAt - b.createdAt); + const now = Date.now(); + let total = entries.reduce((sum, entry) => sum + entry.bytes, 0); + while (entries.length > 0) { + const tooOld = now - entries[0].createdAt > HANDOFF_TTL_MS; + const inTheWay = incoming !== null + && (entries.length >= HANDOFF_MAX_ENTRIES || total + incoming.bytes > HANDOFF_MAX_BYTES); + if (!tooOld && !inTheWay) break; + const [oldest] = entries.splice(0, 1); + total -= oldest.bytes; + await deleteEntry(cache, oldest.prefix); + } + return entries[0]?.createdAt ?? null; +} + async function writeStored(handoff: Handoff): Promise { const api = cacheApi(); - if (!api) return; - const prefix = STORE_PREFIX + handoff.id + '/'; + if (!api || !canSeal()) return; + const bytes = handoff.files.reduce((sum, file) => sum + file.size, 0); + if (bytes > HANDOFF_MAX_BYTES) return; + const prefix = entryPrefix(handoff.id); + const startedAt = clearCount; try { const cache = await api.open(HANDOFF_CACHE); + await sweep(cache, { bytes }); + const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); + const rawKey = new Uint8Array(await crypto.subtle.exportKey('raw', key)); // The list first: the sweep goes by it, so no file can stay behind unlisted. - await cache.put(prefix + 'meta', new Response(JSON.stringify({ - kind: handoff.kind, - createdAt: handoff.createdAt, + await cache.put(`${prefix}meta`, new Response(JSON.stringify({ kind: handoff.kind, createdAt: handoff.createdAt, bytes }))); + const contents = JSON.stringify({ title: handoff.subject ?? '', text: handoff.body ?? '', url: '', files: handoff.files.map((file) => ({ name: file.name, type: file.type })), - }))); - await Promise.all(handoff.files.map((file, index) => cache.put(prefix + index, new Response(file)))); - writeTabIds([...readTabIds().filter((id) => id !== handoff.id), handoff.id]); + }); + await cache.put(`${prefix}sealed`, new Response(await seal(key, new TextEncoder().encode(contents)))); + for (const [index, file] of handoff.files.entries()) { + await cache.put(`${prefix}${index}`, new Response(await seal(key, await file.arrayBuffer()))); + } + if (startedAt !== clearCount) { + await deleteEntry(cache, prefix); + return; + } + setTabKey(handoff.id, toBase64Url(rawKey)); } catch { // Quota, or no Cache API here: the handoff stays in memory only. await deleteStored(handoff.id); @@ -101,18 +216,12 @@ async function writeStored(handoff: Handoff): Promise { } async function deleteStored(id: string): Promise { - writeTabIds(readTabIds().filter((other) => other !== id)); + setTabKey(id, null); const api = cacheApi(); if (!api) return; try { if (!(await api.has(HANDOFF_CACHE))) return; - const cache = await api.open(HANDOFF_CACHE); - const prefix = STORE_PREFIX + id + '/'; - const keys = await cache.keys(); - const own = keys.filter((key) => new URL(key.url, 'https://x.invalid').pathname.startsWith(prefix)); - // The list last, for the sweep. - await Promise.all(own.filter((key) => !key.url.endsWith('/meta')).map((key) => cache.delete(key))); - await cache.delete(prefix + 'meta'); + await deleteEntry(await api.open(HANDOFF_CACHE), entryPrefix(id)); } catch { // The sweep gets it. } @@ -125,8 +234,53 @@ function notify(kind: HandoffKind): void { for (const listener of [...(kindListeners.get(kind) ?? [])]) listener(kind); } +function reportLoss(loss: HandoffLoss): void { + for (const listener of [...lossListeners]) listener(loss); +} + +function dropFromQueue(index: number): Handoff { + const [handoff] = queue.splice(index, 1); + void (stored.get(handoff.id) ?? Promise.resolve()).then(() => deleteStored(handoff.id)); + stored.delete(handoff.id); + return handoff; +} + +function armExpiry(oldestStored: number | null): void { + if (expiryTimer !== null) clearTimeout(expiryTimer); + expiryTimer = null; + const times = [...queue.map((handoff) => handoff.createdAt), ...(oldestStored === null ? [] : [oldestStored])]; + if (times.length === 0) return; + const delay = Math.max(0, Math.min(...times) + HANDOFF_TTL_MS - Date.now()) + 1000; + expiryTimer = setTimeout(() => { void expireHandoffs(); }, delay); +} + +/** What waited too long goes, in memory and stored. Also runs on a timer while this page is open. */ +export async function expireHandoffs(): Promise { + const now = Date.now(); + let lost = false; + for (let index = queue.length - 1; index >= 0; index -= 1) { + if (now - queue[index].createdAt <= HANDOFF_TTL_MS) continue; + dropFromQueue(index); + lost = true; + } + if (lost) reportLoss('expired'); + await sweepStoredHandoffs(); +} + +async function sweepStoredHandoffs(): Promise { + let oldest: number | null = null; + const api = cacheApi(); + try { + if (api && (await api.has(HANDOFF_CACHE))) oldest = await sweep(await api.open(HANDOFF_CACHE), null); + } catch { + // Tried again at the next page load. + } + armExpiry(oldest); +} + function enqueue(handoff: Handoff, persist: boolean): void { queue.push(handoff); + armExpiry(null); if (persist) stored.set(handoff.id, writeStored(handoff)); notify(handoff.kind); } @@ -152,14 +306,16 @@ export function handOffFiles(files: File[], kind: HandoffKind): void { enqueue({ id: newId(), kind, files, createdAt: Date.now() }, kind !== 'eml'); } -/** Opens a new draft with this text and these files. */ +/** Opens a new draft with this text and these files. Trusted: what came from outside was asked about first. */ export function handOffDraft(draft: { subject?: string; body?: string; files?: File[] }): void { + const subject = cleanMailtoSubject(draft.subject ?? ''); + const body = cleanMailtoBody(draft.body ?? ''); enqueue({ id: newId(), kind: 'compose', files: draft.files ?? [], - ...(draft.subject ? { subject: draft.subject } : {}), - ...(draft.body ? { body: draft.body } : {}), + ...(subject ? { subject } : {}), + ...(body ? { body } : {}), createdAt: Date.now(), }, true); } @@ -203,15 +359,17 @@ export async function handOffLaunchFiles( /** The oldest handoff of this kind; it is gone from here after this. */ export function takeHandoff(kind: HandoffKind): Handoff | null { const now = Date.now(); - for (let index = 0; index < queue.length; index += 1) { + let lost = false; + let found: Handoff | null = null; + for (let index = 0; index < queue.length && !found; index += 1) { if (queue[index].kind !== kind) continue; - const [handoff] = queue.splice(index, 1); + const handoff = dropFromQueue(index); index -= 1; - void (stored.get(handoff.id) ?? Promise.resolve()).then(() => deleteStored(handoff.id)); - stored.delete(handoff.id); - if (now - handoff.createdAt <= HANDOFF_TTL_MS) return handoff; + if (now - handoff.createdAt <= HANDOFF_TTL_MS) found = handoff; + else lost = true; } - return null; + if (lost) reportLoss('expired'); + return found; } /** What is waiting, oldest first. Nothing is taken. */ @@ -231,88 +389,134 @@ export function subscribeHandoff(kind: HandoffKind | 'any', listener: Listener): return () => { set.delete(listener); }; } -/** Subject and body of a draft from what the OS shared. */ -export function shareDraft(shared: { title?: string; text?: string; url?: string }): { subject: string; body: string } { - const text = (shared.text ?? '').trim(); - const url = (shared.url ?? '').trim(); - // Android often puts the link in the text as well. - const body = url && !text.includes(url) ? [text, url].filter(Boolean).join('\n\n') : text; - return { subject: (shared.title ?? '').trim(), body }; +/** Hears about a handoff that went without arriving, so the user can be told. */ +export function subscribeHandoffLost(listener: (loss: HandoffLoss) => void): () => void { + lossListeners.add(listener); + return () => { lossListeners.delete(listener); }; } -/** Brings a stored handoff into this tab. False when it is gone. */ -export async function adoptStoredHandoff(id: string): Promise { - if (queue.some((handoff) => handoff.id === id)) return true; +/** Subject and body of a draft from what the OS shared, under the same rules as a mailto: link. */ +export function shareDraft(shared: { title?: string; text?: string; url?: string }): { subject: string; body: string } { + const text = cleanMailtoBody(shared.text ?? '').trim(); + const url = cleanMailtoSubject(shared.url ?? '').trim(); + // Android often puts the link in the text as well. + const body = url && !text.includes(url) ? [text, url].filter(Boolean).join('\n\n') : text; + return { subject: cleanMailtoSubject(shared.title ?? '').trim(), body: cleanMailtoBody(body) }; +} + +/** A name from outside, as it may be shown and attached. */ +function cleanFileName(name: string): string { + // eslint-disable-next-line no-control-regex + return stripBidiControls(name.replace(/[\u0000-\u001F\u007F]/g, '')).slice(0, 255) || 'file'; +} + +interface StoredContents { + meta: { kind: HandoffKind; createdAt: number }; + subject: string; + body: string; + files: Array<{ name: string; type: string }>; + key: CryptoKey; + cache: Cache; +} + +async function openStored(id: string, encodedKey: string): Promise { const api = cacheApi(); - if (!api) return false; - const prefix = STORE_PREFIX + id + '/'; + if (!api || !canSeal() || !encodedKey) return null; + const prefix = entryPrefix(id); try { - if (!(await api.has(HANDOFF_CACHE))) return false; + if (!(await api.has(HANDOFF_CACHE))) return null; const cache = await api.open(HANDOFF_CACHE); - const metaResponse = await cache.match(prefix + 'meta'); - if (!metaResponse) return false; - const meta = (await metaResponse.json()) as { - kind: HandoffKind; createdAt: number; title?: string; text?: string; url?: string; - files?: Array<{ name: string; type?: string }>; - }; + const metaResponse = await cache.match(`${prefix}meta`); + if (!metaResponse) return null; + const meta = (await metaResponse.json()) as StoredContents['meta']; if (Date.now() - meta.createdAt > HANDOFF_TTL_MS) { await deleteStored(id); - return false; + return null; } - const files: File[] = []; - for (const [index, entry] of (meta.files ?? []).entries()) { - const response = await cache.match(prefix + index); - if (!response) continue; - files.push(new File([await response.blob()], entry.name, { type: entry.type ?? '' })); - } - const { subject, body } = shareDraft(meta); - // A second await above: another caller may have got here first. - if (queue.some((handoff) => handoff.id === id)) return true; - if (!readTabIds().includes(id)) writeTabIds([...readTabIds(), id]); - enqueue({ - id, - kind: meta.kind, - files, - ...(subject ? { subject } : {}), - ...(body ? { body } : {}), - createdAt: meta.createdAt, - }, false); - return true; + const sealedResponse = await cache.match(`${prefix}sealed`); + if (!sealedResponse) return null; + const key = await crypto.subtle.importKey('raw', fromBase64Url(encodedKey), 'AES-GCM', false, ['decrypt']); + const contents = JSON.parse(new TextDecoder().decode(await unseal(key, await sealedResponse.arrayBuffer()))) as { + title?: string; text?: string; url?: string; files?: Array<{ name: string; type?: string }>; + }; + return { + meta, + ...shareDraft(contents), + files: (contents.files ?? []).map((file) => ({ name: cleanFileName(file.name), type: file.type ?? '' })), + key, + cache, + }; } catch { - return false; + return null; } } +/** What a stored handoff holds, to ask the user about. Null when it is gone or can't be read. */ +export async function peekStoredHandoff(id: string, encodedKey: string): Promise<{ subject: string; body: string; files: string[] } | null> { + const contents = await openStored(id, encodedKey); + return contents && { subject: contents.subject, body: contents.body, files: contents.files.map((file) => file.name) }; +} + +/** The user refused it. */ +export function discardStoredHandoff(id: string): Promise { + return deleteStored(id); +} + +/** Brings a stored handoff into this tab. False when it is gone. Without `encodedKey`: this tab's own key. */ +export async function adoptStoredHandoff(id: string, encodedKey?: string): Promise { + if (queue.some((handoff) => handoff.id === id)) return true; + const key = encodedKey ?? readTabKeys()[id] ?? ''; + const contents = await openStored(id, key); + if (!contents) return false; + const prefix = entryPrefix(id); + const files: File[] = []; + let missing = false; + for (const [index, entry] of contents.files.entries()) { + try { + const response = await contents.cache.match(`${prefix}${index}`); + if (!response) throw new Error('gone'); + const bytes = await unseal(contents.key, await response.arrayBuffer()); + files.push(new File([bytes], entry.name, { type: entry.type })); + } catch { + missing = true; + } + } + // The awaits above: another caller may have got here first. + if (queue.some((handoff) => handoff.id === id)) return true; + setTabKey(id, key); + enqueue({ + id, + kind: contents.meta.kind, + files, + ...(contents.subject ? { subject: contents.subject } : {}), + ...(contents.body ? { body: contents.body } : {}), + createdAt: contents.meta.createdAt, + }, false); + if (missing) reportLoss('missing'); + return true; +} + /** After a page load: this tab's stored handoffs come back, what is too old goes. */ export async function restoreStoredHandoffs(): Promise { - await Promise.all(readTabIds().map(async (id) => { - if (!(await adoptStoredHandoff(id))) writeTabIds(readTabIds().filter((other) => other !== id)); + let lost = false; + await Promise.all(Object.keys(readTabKeys()).map(async (id) => { + if (await adoptStoredHandoff(id)) return; + setTabKey(id, null); + lost = true; })); - const api = cacheApi(); - if (!api) return; - try { - if (!(await api.has(HANDOFF_CACHE))) return; - const cache = await api.open(HANDOFF_CACHE); - for (const key of await cache.keys()) { - const match = /\/__bulwark\/handoff\/([^/]+)\/meta$/.exec(key.url); - if (!match) continue; - const response = await cache.match(key); - const meta = response ? ((await response.json()) as { createdAt?: number }) : null; - if (!meta || typeof meta.createdAt !== 'number' || Date.now() - meta.createdAt > HANDOFF_TTL_MS) { - await deleteStored(match[1]); - } - } - } catch { - // Tried again at the next page load. - } + if (lost) reportLoss('expired'); + await sweepStoredHandoffs(); } -/** Sign-out: nothing that was on its way into an account stays. */ +/** Sign-out and offline wipes: nothing that was on its way into an account stays. */ export function clearHandoffs(): void { + clearCount += 1; queue.length = 0; stored.clear(); - writeTabIds([]); + writeTabKeys({}); lastNewMessageAt = 0; + if (expiryTimer !== null) clearTimeout(expiryTimer); + expiryTimer = null; const api = cacheApi(); if (api) void api.delete(HANDOFF_CACHE).catch(() => {}); } @@ -320,16 +524,16 @@ export function clearHandoffs(): void { export type LaunchLanding = | { type: 'new-message' } /** The service worker stored what was shared. */ - | { type: 'stored'; id: string } + | { type: 'stored'; id: string; key: string } /** It couldn't be stored, or no worker ran: the text came along in the address. */ - | { type: 'inline'; subject: string; body: string; filesLost: boolean }; + | { type: 'inline'; subject: string; body: string; filesLost: boolean; cut: boolean }; -const LANDING_PARAMS = ['compose', 'share', 'share_title', 'share_text', 'share_url', 'share_files']; +const LANDING_HASH_PARAMS = ['share', 'key', 'share_title', 'share_text', 'share_url', 'share_files', 'share_cut']; /** What the page's own address asks for, once. See stripLandingParams. */ -export function parseLaunchLanding(search: string): LaunchLanding | null { - const params = new URLSearchParams(search); - if (params.get('compose') === '1') return { type: 'new-message' }; +export function parseLaunchLanding(search: string, hash: string): LaunchLanding | null { + if (new URLSearchParams(search).get('compose') === '1') return { type: 'new-message' }; + const params = new URLSearchParams(hash.replace(/^#/, '')); const share = params.get('share'); if (!share) return null; if (share === 'unavailable') { @@ -341,17 +545,25 @@ export function parseLaunchLanding(search: string): LaunchLanding | null { url: params.get('share_url') ?? '', }), filesLost: params.get('share_files') === '1', + cut: params.get('share_cut') === '1', }; } - return /^[\w-]{1,64}$/.test(share) ? { type: 'stored', id: share } : null; + return /^[\w-]{1,64}$/.test(share) ? { type: 'stored', id: share, key: params.get('key') ?? '' } : null; } -/** The query without what parseLaunchLanding read, so a reload doesn't do it again. */ -export function stripLandingParams(search: string): string { - const params = new URLSearchParams(search); - for (const name of LANDING_PARAMS) params.delete(name); - const rest = params.toString(); - return rest ? `?${rest}` : ''; +/** The address without what parseLaunchLanding read, so a reload doesn't do it again. */ +export function stripLandingParams(search: string, hash: string): { search: string; hash: string } { + const query = new URLSearchParams(search); + query.delete('compose'); + const rest = query.toString(); + let leftHash = hash; + const fragment = new URLSearchParams(hash.replace(/^#/, '')); + if (fragment.has('share')) { + for (const name of LANDING_HASH_PARAMS) fragment.delete(name); + const left = fragment.toString(); + leftHash = left ? `#${left}` : ''; + } + return { search: rest ? `?${rest}` : '', hash: leftHash }; } /** diff --git a/lib/offline/__tests__/handoff-wipes.test.ts b/lib/offline/__tests__/handoff-wipes.test.ts new file mode 100644 index 000000000..84c58ba08 --- /dev/null +++ b/lib/offline/__tests__/handoff-wipes.test.ts @@ -0,0 +1,64 @@ +import 'fake-indexeddb/auto'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useAccountStore } from '@/stores/account-store'; +import { usePolicyStore } from '@/stores/policy-store'; +import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; +import { handOffFiles, pendingHandoffs } from '@/lib/launch-handoff'; +import { disableOffline, enforcePolicy, wipeEverything } from '../offline-mode'; +import { forgetIndexes } from '../cache'; +import { forgetDeviceKey } from '../crypto'; +import { allowOfflineDbCreation, deleteOfflineDb } from '../db'; +import { useOfflineStore, writeEnabled } from '../state'; + +// Offline mode's wipes take what was on its way into an account along. + +function waiting() { + handOffFiles([new File(['x'], 'a.ics')], 'calendar'); + expect(pendingHandoffs()).toHaveLength(1); +} + +beforeEach(async () => { + forgetIndexes(); + forgetDeviceKey(); + await deleteOfflineDb(); + allowOfflineDbCreation(); + localStorage.clear(); + useOfflineStore.setState({ enabled: [], outbox: [], pendingChanges: 0, browserOffline: false, downServers: [], startedOffline: false }); + usePolicyStore.setState({ loaded: true, subject: null, subjectPending: false, policy: { ...usePolicyStore.getState().policy, organisation: DEFAULT_ORGANISATION_POLICY } } as never); + useAccountStore.setState({ accounts: [{ id: 'acc', label: 'alice', serverUrl: 'https://mail.example.org', username: 'alice@example.org' } as never] }); +}); + +afterEach(async () => { + await wipeEverything(); +}); + +describe('offline wipes and handoffs', () => { + it('wipeEverything clears them', async () => { + waiting(); + await wipeEverything(); + expect(pendingHandoffs()).toHaveLength(0); + }); + + it('disableOffline clears them', async () => { + writeEnabled(['acc']); + waiting(); + await disableOffline('acc'); + expect(pendingHandoffs()).toHaveLength(0); + }); + + it('enforcePolicy clears them when the organisation forbids offline mode', async () => { + writeEnabled(['acc']); + waiting(); + usePolicyStore.setState({ policy: { ...usePolicyStore.getState().policy, organisation: { ...DEFAULT_ORGANISATION_POLICY, devices: { ...DEFAULT_ORGANISATION_POLICY.devices, noOffline: true } } } }); + enforcePolicy(); + await vi.waitFor(() => expect(useOfflineStore.getState().enabled).toEqual([])); + expect(pendingHandoffs()).toHaveLength(0); + }); + + it('enforcePolicy leaves them while offline mode is allowed', () => { + writeEnabled(['acc']); + waiting(); + enforcePolicy(); + expect(pendingHandoffs()).toHaveLength(1); + }); +}); diff --git a/lib/offline/offline-mode.ts b/lib/offline/offline-mode.ts index 0152db852..edc755b96 100644 --- a/lib/offline/offline-mode.ts +++ b/lib/offline/offline-mode.ts @@ -66,6 +66,7 @@ import { type OutboxSummary, type PolicyFingerprint, } from './queue'; +import { clearHandoffs } from '@/lib/launch-handoff'; import { isOfflineNow, OFFLINE_ACCOUNTS_KEY, readEnabledFrom, serverKey, setServerDown, useOfflineStore, writeEnabled, type OutboxRow } from './state'; import { flushQueue, settleStaleSends } from './flush'; import { syncAccount } from './sync'; @@ -696,6 +697,7 @@ export async function enableOffline(account: string): Promise { /** Switches offline mode off for an account (the user confirmed losing unsent mail) and removes everything it kept here. */ export async function disableOffline(account: string): Promise { confirmLoss(account); + clearHandoffs(); writeEnabled(useOfflineStore.getState().enabled.filter((id) => id !== account)); await wipeAccount(account); } @@ -779,6 +781,7 @@ export async function wipeEverything(): Promise { await deleteOfflineDb(); await tellServiceWorker(false); await deleteShellCaches(); + clearHandoffs(); confirmed.clear(); named.clear(); useOfflineStore.setState({ outbox: [], pendingChanges: 0, usageBytes: 0, syncing: [], lastSyncedAt: {} }); @@ -801,6 +804,7 @@ export function enforcePolicy(): void { for (const account of useOfflineStore.getState().enabled) { if (!policyDecided(account)) continue; if (!offlineAllowed(account)) { + clearHandoffs(); void wipeAccount(account); notify({ kind: 'forbidden' }); } diff --git a/lib/protocol-handlers/mailto.ts b/lib/protocol-handlers/mailto.ts index 8cb217dd8..e58385708 100644 --- a/lib/protocol-handlers/mailto.ts +++ b/lib/protocol-handlers/mailto.ts @@ -39,6 +39,16 @@ function stripBodyControlChars(value: string): string { .replace(CONTROL_CHARS_EXCEPT_LINE_BREAKS, ""); } +/** A subject from outside the app: no control characters, no longer than a mail header allows. */ +export function cleanMailtoSubject(value: string): string { + return stripControlChars(value).slice(0, MAX_SUBJECT_LENGTH); +} + +/** A body from outside the app: line breaks kept, other control characters gone, limited in length. */ +export function cleanMailtoBody(value: string): string { + return stripBodyControlChars(value).slice(0, MAX_BODY_LENGTH); +} + function splitRecipients(value: string): string[] { // Quote/angle-aware split so a `"Doe, John" ` display name with // an embedded comma stays a single recipient instead of being torn in two. @@ -125,8 +135,8 @@ export function parseMailto(raw: string): ParsedMailto | null { to, cc, bcc, - subject: stripControlChars(getQueryValue(searchParams, "subject")).slice(0, MAX_SUBJECT_LENGTH), - body: stripBodyControlChars(getQueryValue(searchParams, "body")).slice(0, MAX_BODY_LENGTH), + subject: cleanMailtoSubject(getQueryValue(searchParams, "subject")), + body: cleanMailtoBody(getQueryValue(searchParams, "body")), }; } diff --git a/locales/ar/common.json b/locales/ar/common.json index 8bc955352..7a9984275 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -258,7 +258,18 @@ "share_gone": "ما شاركته لم يعد موجودًا. يرجى مشاركته مرة أخرى.", "share_files_lost": "تعذر الاحتفاظ بالملفات المشتركة. أضفها إلى الرسالة مرة أخرى.", "ics_needs_server": "يحتاج استيراد ملف تقويم إلى اتصال بالخادم. يبدأ الاستيراد عند عودتك إلى الاتصال بالإنترنت.", - "contacts_need_server": "يحتاج استيراد جهات الاتصال إلى اتصال بالخادم. حاول مرة أخرى عند عودتك إلى الاتصال بالإنترنت." + "contacts_need_server": "يحتاج استيراد جهات الاتصال إلى اتصال بالخادم. حاول مرة أخرى عند عودتك إلى الاتصال بالإنترنت.", + "share_confirm_title": "هل تريد بدء رسالة جديدة؟", + "share_confirm_message": "يريد موقع أو تطبيق آخر بدء رسالة. لن يُرفع أو يُحفظ أي شيء إلا إذا وافقت.", + "share_confirm_subject": "الموضوع:", + "share_confirm_text": "النص:", + "share_confirm_files": "{count, plural, one {ملف:} other {ملفات:}}", + "share_confirm_accept": "بدء الرسالة", + "share_confirm_decline": "عدم البدء", + "share_cut": "كان النص المشترك طويلًا جدًا وتم اختصاره.", + "handoff_expired": "انتظر شيء فتحته أو شاركته مدة طويلة ولم يعد موجودًا. يرجى فتحه أو مشاركته مرة أخرى.", + "compose_waiting": "أغلق هذه الرسالة لبدء الرسالة التالية.", + "save_first_failed": "تعذر حفظ رسالتك، لذلك لم يُفتح الملف. احفظ الرسالة ثم افتح التقويم أو جهات الاتصال." }, "sidebar_apps": { "modal_title": "تطبيقات الشريط الجانبي", diff --git a/locales/ca/common.json b/locales/ca/common.json index 57dfafc11..8febad5bf 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -258,7 +258,18 @@ "share_gone": "El que heu compartit ja no hi és. Torneu a compartir-ho.", "share_files_lost": "No s'han pogut conservar els fitxers compartits. Torneu-los a afegir al missatge.", "ics_needs_server": "Per importar un fitxer de calendari cal connexió amb el servidor. La importació començarà quan torneu a estar en línia.", - "contacts_need_server": "Per importar contactes cal connexió amb el servidor. Torneu-ho a provar quan torneu a estar en línia." + "contacts_need_server": "Per importar contactes cal connexió amb el servidor. Torneu-ho a provar quan torneu a estar en línia.", + "share_confirm_title": "Voleu començar un missatge nou?", + "share_confirm_message": "Un altre lloc o aplicació vol començar un missatge. No es puja ni es desa res si no hi esteu d'acord.", + "share_confirm_subject": "Assumpte:", + "share_confirm_text": "Text:", + "share_confirm_files": "{count, plural, one {Fitxer:} other {Fitxers:}}", + "share_confirm_accept": "Comença el missatge", + "share_confirm_decline": "No el comencis", + "share_cut": "El text compartit era massa llarg i s'ha escurçat.", + "handoff_expired": "Una cosa que heu obert o compartit ha esperat massa i ja no hi és. Torneu-la a obrir o compartir.", + "compose_waiting": "Tanqueu aquest missatge per començar el següent.", + "save_first_failed": "No s'ha pogut desar el missatge, per això no s'ha obert el fitxer. Deseu el missatge i obriu el calendari o els contactes." }, "sidebar_apps": { "modal_title": "Aplicacions de la barra lateral", diff --git a/locales/cs/common.json b/locales/cs/common.json index b564e4a67..f908c764d 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -258,7 +258,18 @@ "share_gone": "To, co jste sdíleli, už tu není. Sdílejte to prosím znovu.", "share_files_lost": "Sdílené soubory se nepodařilo uchovat. Přidejte je do zprávy znovu.", "ics_needs_server": "Import souboru kalendáře vyžaduje připojení k serveru. Import začne, až budete znovu online.", - "contacts_need_server": "Import kontaktů vyžaduje připojení k serveru. Zkuste to znovu, až budete online." + "contacts_need_server": "Import kontaktů vyžaduje připojení k serveru. Zkuste to znovu, až budete online.", + "share_confirm_title": "Začít novou zprávu?", + "share_confirm_message": "Jiný web nebo aplikace chce začít zprávu. Bez vašeho souhlasu se nic nenahraje ani neuloží.", + "share_confirm_subject": "Předmět:", + "share_confirm_text": "Text:", + "share_confirm_files": "{count, plural, one {Soubor:} few {Soubory:} many {Souborů:} other {Soubory:}}", + "share_confirm_accept": "Začít zprávu", + "share_confirm_decline": "Nezačínat", + "share_cut": "Sdílený text byl příliš dlouhý a byl zkrácen.", + "handoff_expired": "To, co jste otevřeli nebo sdíleli, čekalo příliš dlouho a už tu není. Otevřete to nebo sdílejte znovu.", + "compose_waiting": "Zavřete tuto zprávu, abyste mohli začít další.", + "save_first_failed": "Zprávu se nepodařilo uložit, proto se soubor neotevřel. Uložte zprávu a pak otevřete kalendář nebo kontakty." }, "sidebar_apps": { "modal_title": "Aplikace postranního panelu", diff --git a/locales/da/common.json b/locales/da/common.json index 9935f289d..2bfa0300b 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -258,7 +258,18 @@ "share_gone": "Det, du delte, er her ikke længere. Del det venligst igen.", "share_files_lost": "De delte filer kunne ikke gemmes. Tilføj dem til beskeden igen.", "ics_needs_server": "Import af en kalenderfil kræver forbindelse til serveren. Importen starter, når du er online igen.", - "contacts_need_server": "Import af kontakter kræver forbindelse til serveren. Prøv igen, når du er online igen." + "contacts_need_server": "Import af kontakter kræver forbindelse til serveren. Prøv igen, når du er online igen.", + "share_confirm_title": "Vil du starte en ny besked?", + "share_confirm_message": "Et andet websted eller en anden app vil starte en besked. Intet uploades eller gemmes, medmindre du accepterer.", + "share_confirm_subject": "Emne:", + "share_confirm_text": "Tekst:", + "share_confirm_files": "{count, plural, one {Fil:} other {Filer:}}", + "share_confirm_accept": "Start besked", + "share_confirm_decline": "Start ikke", + "share_cut": "Den delte tekst var for lang og er blevet forkortet.", + "handoff_expired": "Noget, du åbnede eller delte, ventede for længe og er væk. Åbn eller del det igen.", + "compose_waiting": "Luk denne besked for at starte den næste.", + "save_first_failed": "Din besked kunne ikke gemmes, så filen er ikke åbnet. Gem beskeden, og åbn derefter kalenderen eller kontakterne." }, "sidebar_apps": { "modal_title": "Sidepanel-apps", diff --git a/locales/de/common.json b/locales/de/common.json index 0466fccf5..5ddd25b9f 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -258,7 +258,18 @@ "share_gone": "Das Geteilte ist nicht mehr vorhanden. Bitte teilen Sie es noch einmal.", "share_files_lost": "Die geteilten Dateien konnten nicht aufbewahrt werden. Fügen Sie sie der Nachricht bitte noch einmal hinzu.", "ics_needs_server": "Zum Importieren einer Kalenderdatei wird eine Verbindung zum Server gebraucht. Der Import beginnt, sobald Sie wieder online sind.", - "contacts_need_server": "Zum Importieren von Kontakten wird eine Verbindung zum Server gebraucht. Versuchen Sie es noch einmal, wenn Sie wieder online sind." + "contacts_need_server": "Zum Importieren von Kontakten wird eine Verbindung zum Server gebraucht. Versuchen Sie es noch einmal, wenn Sie wieder online sind.", + "share_confirm_title": "Neue Nachricht beginnen?", + "share_confirm_message": "Eine andere Website oder App möchte eine Nachricht beginnen. Es wird nichts hochgeladen oder gespeichert, wenn Sie nicht zustimmen.", + "share_confirm_subject": "Betreff:", + "share_confirm_text": "Text:", + "share_confirm_files": "{count, plural, one {Datei:} other {Dateien:}}", + "share_confirm_accept": "Nachricht beginnen", + "share_confirm_decline": "Nicht beginnen", + "share_cut": "Der geteilte Text war zu lang und wurde gekürzt.", + "handoff_expired": "Etwas, das Sie geöffnet oder geteilt haben, hat zu lange gewartet und ist nicht mehr da. Bitte öffnen oder teilen Sie es noch einmal.", + "compose_waiting": "Schließen Sie diese Nachricht, um die nächste zu beginnen.", + "save_first_failed": "Ihre Nachricht konnte nicht gespeichert werden, deshalb wurde die Datei nicht geöffnet. Speichern Sie die Nachricht und öffnen Sie dann den Kalender oder die Kontakte." }, "sidebar_apps": { "modal_title": "Sidebar-Apps", diff --git a/locales/en/common.json b/locales/en/common.json index b87b0ec93..5f487c8de 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -258,7 +258,18 @@ "share_gone": "What you shared is no longer here. Please share it again.", "share_files_lost": "The shared files couldn't be kept. Add them to the message again.", "ics_needs_server": "Importing a calendar file needs a connection to the server. The import starts when you're back online.", - "contacts_need_server": "Importing contacts needs a connection to the server. Try again when you're back online." + "contacts_need_server": "Importing contacts needs a connection to the server. Try again when you're back online.", + "share_confirm_title": "Start a new message?", + "share_confirm_message": "Another site or app wants to start a message. Nothing is uploaded or saved unless you agree.", + "share_confirm_subject": "Subject:", + "share_confirm_text": "Text:", + "share_confirm_files": "{count, plural, one {File:} other {Files:}}", + "share_confirm_accept": "Start message", + "share_confirm_decline": "Don't start", + "share_cut": "The shared text was too long and has been shortened.", + "handoff_expired": "Something you opened or shared waited too long and is gone. Please open or share it again.", + "compose_waiting": "Close this message to start the next one.", + "save_first_failed": "Your message couldn't be saved, so the file hasn't been opened. Save the message, then open the calendar or contacts." }, "sidebar_apps": { "modal_title": "Sidebar apps", diff --git a/locales/es/common.json b/locales/es/common.json index 6a5f1f06f..3ace97f0b 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -258,7 +258,18 @@ "share_gone": "Lo que compartiste ya no está aquí. Compártelo de nuevo.", "share_files_lost": "No se pudieron conservar los archivos compartidos. Añádelos de nuevo al mensaje.", "ics_needs_server": "Importar un archivo de calendario requiere conexión con el servidor. La importación empezará cuando vuelvas a estar en línea.", - "contacts_need_server": "Importar contactos requiere conexión con el servidor. Inténtalo de nuevo cuando vuelvas a estar en línea." + "contacts_need_server": "Importar contactos requiere conexión con el servidor. Inténtalo de nuevo cuando vuelvas a estar en línea.", + "share_confirm_title": "¿Empezar un mensaje nuevo?", + "share_confirm_message": "Otro sitio o aplicación quiere empezar un mensaje. No se sube ni se guarda nada si no aceptas.", + "share_confirm_subject": "Asunto:", + "share_confirm_text": "Texto:", + "share_confirm_files": "{count, plural, one {Archivo:} other {Archivos:}}", + "share_confirm_accept": "Empezar mensaje", + "share_confirm_decline": "No empezar", + "share_cut": "El texto compartido era demasiado largo y se ha acortado.", + "handoff_expired": "Algo que abriste o compartiste esperó demasiado y ya no está. Ábrelo o compártelo de nuevo.", + "compose_waiting": "Cierra este mensaje para empezar el siguiente.", + "save_first_failed": "No se pudo guardar tu mensaje, así que el archivo no se ha abierto. Guarda el mensaje y luego abre el calendario o los contactos." }, "sidebar_apps": { "modal_title": "Aplicaciones de la barra lateral", diff --git a/locales/et/common.json b/locales/et/common.json index 4353289be..b08747e89 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -258,7 +258,18 @@ "share_gone": "See, mida jagasid, pole enam siin. Jaga seda palun uuesti.", "share_files_lost": "Jagatud faile ei saanud alles hoida. Lisa need kirjale uuesti.", "ics_needs_server": "Kalendrifaili importimiseks on vaja ühendust serveriga. Import algab, kui oled jälle võrgus.", - "contacts_need_server": "Kontaktide importimiseks on vaja ühendust serveriga. Proovi uuesti, kui oled jälle võrgus." + "contacts_need_server": "Kontaktide importimiseks on vaja ühendust serveriga. Proovi uuesti, kui oled jälle võrgus.", + "share_confirm_title": "Kas alustada uut kirja?", + "share_confirm_message": "Teine sait või rakendus soovib kirja alustada. Ilma sinu nõusolekuta ei laadita ega salvestata midagi.", + "share_confirm_subject": "Teema:", + "share_confirm_text": "Tekst:", + "share_confirm_files": "{count, plural, one {Fail:} other {Failid:}}", + "share_confirm_accept": "Alusta kirja", + "share_confirm_decline": "Ära alusta", + "share_cut": "Jagatud tekst oli liiga pikk ja seda lühendati.", + "handoff_expired": "Midagi, mille avasid või jagasid, ootas liiga kaua ja seda pole enam. Ava või jaga see uuesti.", + "compose_waiting": "Järgmise alustamiseks sulge see kiri.", + "save_first_failed": "Kirja ei saanud salvestada, seega faili ei avatud. Salvesta kiri ja ava siis kalender või kontaktid." }, "sidebar_apps": { "modal_title": "Külgriba rakendused", diff --git a/locales/fa/common.json b/locales/fa/common.json index 9b939d48b..e620575ee 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -258,7 +258,18 @@ "share_gone": "چیزی که هم‌رسانی کردید دیگر اینجا نیست. لطفاً دوباره آن را هم‌رسانی کنید.", "share_files_lost": "فایل‌های هم‌رسانی‌شده نگه داشته نشدند. دوباره آن‌ها را به پیام اضافه کنید.", "ics_needs_server": "وارد کردن فایل تقویم به اتصال به سرور نیاز دارد. وقتی دوباره آنلاین شوید، وارد کردن شروع می‌شود.", - "contacts_need_server": "وارد کردن مخاطبان به اتصال به سرور نیاز دارد. وقتی دوباره آنلاین شدید، دوباره امتحان کنید." + "contacts_need_server": "وارد کردن مخاطبان به اتصال به سرور نیاز دارد. وقتی دوباره آنلاین شدید، دوباره امتحان کنید.", + "share_confirm_title": "پیام جدیدی شروع شود؟", + "share_confirm_message": "سایت یا برنامه دیگری می‌خواهد پیامی را شروع کند. تا موافقت نکنید چیزی بارگذاری یا ذخیره نمی‌شود.", + "share_confirm_subject": "موضوع:", + "share_confirm_text": "متن:", + "share_confirm_files": "{count, plural, one {فایل:} other {فایل‌ها:}}", + "share_confirm_accept": "شروع پیام", + "share_confirm_decline": "شروع نشود", + "share_cut": "متن هم‌رسانی‌شده خیلی طولانی بود و کوتاه شد.", + "handoff_expired": "چیزی که باز یا هم‌رسانی کردید بیش از حد منتظر ماند و دیگر نیست. لطفاً دوباره آن را باز یا هم‌رسانی کنید.", + "compose_waiting": "برای شروع پیام بعدی این پیام را ببندید.", + "save_first_failed": "پیام شما ذخیره نشد، بنابراین فایل باز نشد. پیام را ذخیره کنید و سپس تقویم یا مخاطبان را باز کنید." }, "sidebar_apps": { "modal_title": "برنامه‌های نوار کناری", diff --git a/locales/fr/common.json b/locales/fr/common.json index 22b8087bf..b44c16906 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -258,7 +258,18 @@ "share_gone": "Ce que vous avez partagé n'est plus disponible. Partagez-le à nouveau.", "share_files_lost": "Les fichiers partagés n'ont pas pu être conservés. Ajoutez-les de nouveau au message.", "ics_needs_server": "L'importation d'un fichier de calendrier nécessite une connexion au serveur. Elle démarrera dès que vous serez de nouveau en ligne.", - "contacts_need_server": "L'importation de contacts nécessite une connexion au serveur. Réessayez quand vous serez de nouveau en ligne." + "contacts_need_server": "L'importation de contacts nécessite une connexion au serveur. Réessayez quand vous serez de nouveau en ligne.", + "share_confirm_title": "Commencer un nouveau message ?", + "share_confirm_message": "Un autre site ou une autre application veut commencer un message. Rien n'est envoyé ni enregistré sans votre accord.", + "share_confirm_subject": "Objet :", + "share_confirm_text": "Texte :", + "share_confirm_files": "{count, plural, one {Fichier :} other {Fichiers :}}", + "share_confirm_accept": "Commencer le message", + "share_confirm_decline": "Ne pas commencer", + "share_cut": "Le texte partagé était trop long et a été raccourci.", + "handoff_expired": "Un élément que vous avez ouvert ou partagé a attendu trop longtemps et n'est plus disponible. Ouvrez-le ou partagez-le à nouveau.", + "compose_waiting": "Fermez ce message pour commencer le suivant.", + "save_first_failed": "Votre message n'a pas pu être enregistré, le fichier n'a donc pas été ouvert. Enregistrez le message, puis ouvrez le calendrier ou les contacts." }, "sidebar_apps": { "modal_title": "Applications de la barre latérale", diff --git a/locales/he/common.json b/locales/he/common.json index 95af29824..1d5253370 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -5281,7 +5281,18 @@ "share_gone": "מה ששיתפתם כבר לא נמצא כאן. שתפו אותו שוב.", "share_files_lost": "לא ניתן היה לשמור את הקבצים ששותפו. הוסיפו אותם שוב להודעה.", "ics_needs_server": "ייבוא קובץ לוח שנה דורש חיבור לשרת. הייבוא יתחיל כשתחזרו להיות מחוברים.", - "contacts_need_server": "ייבוא אנשי קשר דורש חיבור לשרת. נסו שוב כשתחזרו להיות מחוברים." + "contacts_need_server": "ייבוא אנשי קשר דורש חיבור לשרת. נסו שוב כשתחזרו להיות מחוברים.", + "share_confirm_title": "להתחיל הודעה חדשה?", + "share_confirm_message": "אתר או אפליקציה אחרים רוצים להתחיל הודעה. שום דבר לא יועלה או יישמר בלי הסכמתכם.", + "share_confirm_subject": "נושא:", + "share_confirm_text": "טקסט:", + "share_confirm_files": "{count, plural, one {קובץ:} other {קבצים:}}", + "share_confirm_accept": "התחלת הודעה", + "share_confirm_decline": "לא להתחיל", + "share_cut": "הטקסט ששותף היה ארוך מדי וקוצר.", + "handoff_expired": "משהו שפתחתם או שיתפתם המתין יותר מדי זמן וכבר אינו כאן. פתחו או שתפו אותו שוב.", + "compose_waiting": "סגרו הודעה זו כדי להתחיל את הבאה.", + "save_first_failed": "לא ניתן היה לשמור את ההודעה, לכן הקובץ לא נפתח. שמרו את ההודעה ואז פתחו את לוח השנה או את אנשי הקשר." }, "mailbox_context_menu": { "mark_folder_read": "סמן תיקייה כקרויה", diff --git a/locales/hu/common.json b/locales/hu/common.json index 0564585b8..3dc525033 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -258,7 +258,18 @@ "share_gone": "Amit megosztott, már nincs itt. Kérjük, ossza meg újra.", "share_files_lost": "A megosztott fájlokat nem sikerült megőrizni. Adja hozzá őket újra az üzenethez.", "ics_needs_server": "A naptárfájl importálásához kapcsolat kell a szerverrel. Az importálás akkor indul el, amikor újra online lesz.", - "contacts_need_server": "A névjegyek importálásához kapcsolat kell a szerverrel. Próbálja újra, amikor ismét online lesz." + "contacts_need_server": "A névjegyek importálásához kapcsolat kell a szerverrel. Próbálja újra, amikor ismét online lesz.", + "share_confirm_title": "Új üzenetet kezd?", + "share_confirm_message": "Egy másik webhely vagy alkalmazás üzenetet szeretne kezdeni. Az Ön beleegyezése nélkül semmi sem kerül feltöltésre vagy mentésre.", + "share_confirm_subject": "Tárgy:", + "share_confirm_text": "Szöveg:", + "share_confirm_files": "{count, plural, one {Fájl:} other {Fájlok:}}", + "share_confirm_accept": "Üzenet kezdése", + "share_confirm_decline": "Ne kezdje el", + "share_cut": "A megosztott szöveg túl hosszú volt, ezért rövidítettük.", + "handoff_expired": "Valami, amit megnyitott vagy megosztott, túl sokáig várakozott, és már nincs meg. Kérjük, nyissa meg vagy ossza meg újra.", + "compose_waiting": "Zárja be ezt az üzenetet a következő kezdéséhez.", + "save_first_failed": "Az üzenetet nem sikerült menteni, ezért a fájl nem nyílt meg. Mentse az üzenetet, majd nyissa meg a naptárat vagy a névjegyeket." }, "sidebar_apps": { "modal_title": "Oldalsáv alkalmazások", diff --git a/locales/it/common.json b/locales/it/common.json index a82800289..fdbaa241f 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -258,7 +258,18 @@ "share_gone": "Ciò che hai condiviso non è più disponibile. Condividilo di nuovo.", "share_files_lost": "Non è stato possibile conservare i file condivisi. Aggiungili di nuovo al messaggio.", "ics_needs_server": "Per importare un file di calendario serve una connessione al server. L'importazione inizierà quando tornerai online.", - "contacts_need_server": "Per importare i contatti serve una connessione al server. Riprova quando tornerai online." + "contacts_need_server": "Per importare i contatti serve una connessione al server. Riprova quando tornerai online.", + "share_confirm_title": "Iniziare un nuovo messaggio?", + "share_confirm_message": "Un altro sito o app vuole iniziare un messaggio. Non viene caricato o salvato nulla senza il tuo consenso.", + "share_confirm_subject": "Oggetto:", + "share_confirm_text": "Testo:", + "share_confirm_files": "{count, plural, one {File:} other {File:}}", + "share_confirm_accept": "Inizia messaggio", + "share_confirm_decline": "Non iniziare", + "share_cut": "Il testo condiviso era troppo lungo ed è stato accorciato.", + "handoff_expired": "Qualcosa che hai aperto o condiviso ha atteso troppo a lungo e non c'è più. Aprilo o condividilo di nuovo.", + "compose_waiting": "Chiudi questo messaggio per iniziare il successivo.", + "save_first_failed": "Non è stato possibile salvare il messaggio, quindi il file non è stato aperto. Salva il messaggio, poi apri il calendario o i contatti." }, "sidebar_apps": { "modal_title": "App della barra laterale", diff --git a/locales/ja/common.json b/locales/ja/common.json index 0e567d3ca..0ac0ff619 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -258,7 +258,18 @@ "share_gone": "共有した内容はもうありません。もう一度共有してください。", "share_files_lost": "共有したファイルを保持できませんでした。メッセージにもう一度追加してください。", "ics_needs_server": "カレンダーファイルのインポートにはサーバーへの接続が必要です。オンラインに戻るとインポートが始まります。", - "contacts_need_server": "連絡先のインポートにはサーバーへの接続が必要です。オンラインに戻ってからもう一度お試しください。" + "contacts_need_server": "連絡先のインポートにはサーバーへの接続が必要です。オンラインに戻ってからもう一度お試しください。", + "share_confirm_title": "新しいメッセージを開始しますか?", + "share_confirm_message": "別のサイトまたはアプリがメッセージを開始しようとしています。同意しない限り、何もアップロードも保存もされません。", + "share_confirm_subject": "件名:", + "share_confirm_text": "本文:", + "share_confirm_files": "{count, plural, other {ファイル:}}", + "share_confirm_accept": "メッセージを開始", + "share_confirm_decline": "開始しない", + "share_cut": "共有されたテキストが長すぎるため短縮されました。", + "handoff_expired": "開いた、または共有した内容は待機時間が長すぎたためなくなりました。もう一度開くか共有してください。", + "compose_waiting": "次のメッセージを開始するには、このメッセージを閉じてください。", + "save_first_failed": "メッセージを保存できなかったため、ファイルを開いていません。メッセージを保存してから、カレンダーまたは連絡先を開いてください。" }, "sidebar_apps": { "modal_title": "サイドバーアプリ", diff --git a/locales/ko/common.json b/locales/ko/common.json index 51cfc0fed..181b0b691 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -258,7 +258,18 @@ "share_gone": "공유한 내용이 더 이상 없습니다. 다시 공유해 주세요.", "share_files_lost": "공유한 파일을 보관할 수 없습니다. 메시지에 다시 추가해 주세요.", "ics_needs_server": "캘린더 파일을 가져오려면 서버에 연결되어 있어야 합니다. 다시 온라인 상태가 되면 가져오기가 시작됩니다.", - "contacts_need_server": "연락처를 가져오려면 서버에 연결되어 있어야 합니다. 다시 온라인 상태가 되면 다시 시도해 주세요." + "contacts_need_server": "연락처를 가져오려면 서버에 연결되어 있어야 합니다. 다시 온라인 상태가 되면 다시 시도해 주세요.", + "share_confirm_title": "새 메시지를 시작할까요?", + "share_confirm_message": "다른 사이트나 앱에서 메시지를 시작하려고 합니다. 동의하지 않으면 아무것도 업로드하거나 저장하지 않습니다.", + "share_confirm_subject": "제목:", + "share_confirm_text": "내용:", + "share_confirm_files": "{count, plural, other {파일:}}", + "share_confirm_accept": "메시지 시작", + "share_confirm_decline": "시작 안 함", + "share_cut": "공유한 텍스트가 너무 길어서 줄였습니다.", + "handoff_expired": "열었거나 공유한 항목이 너무 오래 대기하여 사라졌습니다. 다시 열거나 공유해 주세요.", + "compose_waiting": "다음 메시지를 시작하려면 이 메시지를 닫으세요.", + "save_first_failed": "메시지를 저장할 수 없어 파일을 열지 않았습니다. 메시지를 저장한 다음 캘린더나 연락처를 여세요." }, "sidebar_apps": { "modal_title": "사이드바 앱", diff --git a/locales/lv/common.json b/locales/lv/common.json index 60fbbb89b..ac37493b7 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -258,7 +258,18 @@ "share_gone": "Tas, ko kopīgojāt, šeit vairs nav. Lūdzu, kopīgojiet to vēlreiz.", "share_files_lost": "Kopīgotos failus neizdevās saglabāt. Pievienojiet tos ziņai vēlreiz.", "ics_needs_server": "Kalendāra faila importēšanai vajadzīgs savienojums ar serveri. Importēšana sāksies, kad atkal būsiet tiešsaistē.", - "contacts_need_server": "Kontaktu importēšanai vajadzīgs savienojums ar serveri. Mēģiniet vēlreiz, kad atkal būsiet tiešsaistē." + "contacts_need_server": "Kontaktu importēšanai vajadzīgs savienojums ar serveri. Mēģiniet vēlreiz, kad atkal būsiet tiešsaistē.", + "share_confirm_title": "Vai sākt jaunu ziņu?", + "share_confirm_message": "Cita vietne vai lietotne vēlas sākt ziņu. Bez jūsu piekrišanas nekas netiek augšupielādēts vai saglabāts.", + "share_confirm_subject": "Tēma:", + "share_confirm_text": "Teksts:", + "share_confirm_files": "{count, plural, zero {Faili:} one {Fails:} other {Faili:}}", + "share_confirm_accept": "Sākt ziņu", + "share_confirm_decline": "Nesākt", + "share_cut": "Kopīgotais teksts bija pārāk garš un tika saīsināts.", + "handoff_expired": "Tas, ko atvērāt vai kopīgojāt, gaidīja pārāk ilgi un vairs nav pieejams. Atveriet vai kopīgojiet to vēlreiz.", + "compose_waiting": "Aizveriet šo ziņu, lai sāktu nākamo.", + "save_first_failed": "Ziņu neizdevās saglabāt, tāpēc fails netika atvērts. Saglabājiet ziņu un pēc tam atveriet kalendāru vai kontaktus." }, "sidebar_apps": { "modal_title": "Sānu joslas lietotnes", diff --git a/locales/mn/common.json b/locales/mn/common.json index 649c9e7ac..767ccf332 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -258,7 +258,18 @@ "share_gone": "Таны хуваалцсан зүйл энд байхгүй болсон. Дахин хуваалцана уу.", "share_files_lost": "Хуваалцсан файлуудыг хадгалж чадсангүй. Зурваст дахин нэмнэ үү.", "ics_needs_server": "Хуанлийн файл импортлоход сервертэй холболт хэрэгтэй. Та дахин онлайн болоход импорт эхэлнэ.", - "contacts_need_server": "Харилцагчдыг импортлоход сервертэй холболт хэрэгтэй. Дахин онлайн болсон үедээ оролдоно уу." + "contacts_need_server": "Харилцагчдыг импортлоход сервертэй холболт хэрэгтэй. Дахин онлайн болсон үедээ оролдоно уу.", + "share_confirm_title": "Шинэ зурвас эхлүүлэх үү?", + "share_confirm_message": "Өөр сайт эсвэл апп зурвас эхлүүлэхийг хүсэж байна. Та зөвшөөрөхгүй бол юу ч байршуулахгүй, хадгалахгүй.", + "share_confirm_subject": "Гарчиг:", + "share_confirm_text": "Текст:", + "share_confirm_files": "{count, plural, one {Файл:} other {Файлууд:}}", + "share_confirm_accept": "Зурвас эхлүүлэх", + "share_confirm_decline": "Эхлүүлэхгүй", + "share_cut": "Хуваалцсан текст хэт урт байсан тул богиносгосон.", + "handoff_expired": "Таны нээсэн эсвэл хуваалцсан зүйл хэт удаан хүлээсэн тул байхгүй болсон. Дахин нээх эсвэл хуваалцана уу.", + "compose_waiting": "Дараагийнхыг эхлүүлэхийн тулд энэ зурвасыг хаана уу.", + "save_first_failed": "Зурвасыг хадгалж чадаагүй тул файлыг нээгээгүй. Зурвасаа хадгалаад хуанли эсвэл харилцагчдыг нээнэ үү." }, "sidebar_apps": { "modal_title": "Sidebar програмууд", diff --git a/locales/nb/common.json b/locales/nb/common.json index 0ddf770f4..df53a9495 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -258,7 +258,18 @@ "share_gone": "Det du delte, er ikke her lenger. Del det på nytt.", "share_files_lost": "De delte filene kunne ikke tas vare på. Legg dem til i meldingen på nytt.", "ics_needs_server": "Import av en kalenderfil krever tilkobling til serveren. Importen starter når du er på nett igjen.", - "contacts_need_server": "Import av kontakter krever tilkobling til serveren. Prøv igjen når du er på nett igjen." + "contacts_need_server": "Import av kontakter krever tilkobling til serveren. Prøv igjen når du er på nett igjen.", + "share_confirm_title": "Vil du starte en ny melding?", + "share_confirm_message": "Et annet nettsted eller en annen app vil starte en melding. Ingenting lastes opp eller lagres uten at du godtar det.", + "share_confirm_subject": "Emne:", + "share_confirm_text": "Tekst:", + "share_confirm_files": "{count, plural, one {Fil:} other {Filer:}}", + "share_confirm_accept": "Start melding", + "share_confirm_decline": "Ikke start", + "share_cut": "Den delte teksten var for lang og er forkortet.", + "handoff_expired": "Noe du åpnet eller delte, ventet for lenge og er borte. Åpne eller del det på nytt.", + "compose_waiting": "Lukk denne meldingen for å starte den neste.", + "save_first_failed": "Meldingen kunne ikke lagres, så filen er ikke åpnet. Lagre meldingen, og åpne deretter kalenderen eller kontaktene." }, "sidebar_apps": { "modal_title": "Apper i sidefeltet", diff --git a/locales/nl/common.json b/locales/nl/common.json index ab2c81582..3e3035b24 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -258,7 +258,18 @@ "share_gone": "Wat je hebt gedeeld, is er niet meer. Deel het opnieuw.", "share_files_lost": "De gedeelde bestanden konden niet worden bewaard. Voeg ze opnieuw aan het bericht toe.", "ics_needs_server": "Voor het importeren van een agendabestand is een verbinding met de server nodig. Het importeren begint zodra je weer online bent.", - "contacts_need_server": "Voor het importeren van contacten is een verbinding met de server nodig. Probeer het opnieuw als je weer online bent." + "contacts_need_server": "Voor het importeren van contacten is een verbinding met de server nodig. Probeer het opnieuw als je weer online bent.", + "share_confirm_title": "Een nieuw bericht beginnen?", + "share_confirm_message": "Een andere site of app wil een bericht beginnen. Er wordt niets geüpload of opgeslagen zonder je toestemming.", + "share_confirm_subject": "Onderwerp:", + "share_confirm_text": "Tekst:", + "share_confirm_files": "{count, plural, one {Bestand:} other {Bestanden:}}", + "share_confirm_accept": "Bericht beginnen", + "share_confirm_decline": "Niet beginnen", + "share_cut": "De gedeelde tekst was te lang en is ingekort.", + "handoff_expired": "Iets wat je hebt geopend of gedeeld, heeft te lang gewacht en is weg. Open of deel het opnieuw.", + "compose_waiting": "Sluit dit bericht om het volgende te beginnen.", + "save_first_failed": "Je bericht kon niet worden opgeslagen, dus het bestand is niet geopend. Sla het bericht op en open daarna de agenda of de contacten." }, "sidebar_apps": { "modal_title": "Zijbalk-apps", diff --git a/locales/pl/common.json b/locales/pl/common.json index 1fd914ac1..3a647b062 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -258,7 +258,18 @@ "share_gone": "To, co udostępniono, już tu nie istnieje. Udostępnij to ponownie.", "share_files_lost": "Nie udało się zachować udostępnionych plików. Dodaj je ponownie do wiadomości.", "ics_needs_server": "Import pliku kalendarza wymaga połączenia z serwerem. Import zacznie się, gdy znów będziesz online.", - "contacts_need_server": "Import kontaktów wymaga połączenia z serwerem. Spróbuj ponownie, gdy znów będziesz online." + "contacts_need_server": "Import kontaktów wymaga połączenia z serwerem. Spróbuj ponownie, gdy znów będziesz online.", + "share_confirm_title": "Rozpocząć nową wiadomość?", + "share_confirm_message": "Inna witryna lub aplikacja chce rozpocząć wiadomość. Bez Twojej zgody nic nie zostanie wysłane ani zapisane.", + "share_confirm_subject": "Temat:", + "share_confirm_text": "Tekst:", + "share_confirm_files": "{count, plural, one {Plik:} few {Pliki:} many {Pliki:} other {Pliki:}}", + "share_confirm_accept": "Rozpocznij wiadomość", + "share_confirm_decline": "Nie rozpoczynaj", + "share_cut": "Udostępniony tekst był za długi i został skrócony.", + "handoff_expired": "To, co otwarto lub udostępniono, czekało zbyt długo i już go nie ma. Otwórz lub udostępnij to ponownie.", + "compose_waiting": "Zamknij tę wiadomość, aby rozpocząć następną.", + "save_first_failed": "Nie udało się zapisać wiadomości, więc plik nie został otwarty. Zapisz wiadomość, a potem otwórz kalendarz lub kontakty." }, "sidebar_apps": { "modal_title": "Aplikacje paska bocznego", diff --git a/locales/pt/common.json b/locales/pt/common.json index aca70eda7..9468bc5df 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -258,7 +258,18 @@ "share_gone": "O que partilhou já não está aqui. Partilhe novamente.", "share_files_lost": "Não foi possível guardar os ficheiros partilhados. Adicione-os novamente à mensagem.", "ics_needs_server": "Importar um ficheiro de calendário precisa de uma ligação ao servidor. A importação começa quando voltar a estar online.", - "contacts_need_server": "Importar contactos precisa de uma ligação ao servidor. Tente novamente quando voltar a estar online." + "contacts_need_server": "Importar contactos precisa de uma ligação ao servidor. Tente novamente quando voltar a estar online.", + "share_confirm_title": "Começar uma nova mensagem?", + "share_confirm_message": "Outro site ou aplicação quer começar uma mensagem. Nada é carregado ou guardado sem o seu acordo.", + "share_confirm_subject": "Assunto:", + "share_confirm_text": "Texto:", + "share_confirm_files": "{count, plural, one {Ficheiro:} other {Ficheiros:}}", + "share_confirm_accept": "Começar mensagem", + "share_confirm_decline": "Não começar", + "share_cut": "O texto partilhado era demasiado longo e foi encurtado.", + "handoff_expired": "Algo que abriu ou partilhou esperou demasiado tempo e já não está disponível. Abra ou partilhe novamente.", + "compose_waiting": "Feche esta mensagem para começar a seguinte.", + "save_first_failed": "Não foi possível guardar a mensagem, por isso o ficheiro não foi aberto. Guarde a mensagem e depois abra o calendário ou os contactos." }, "sidebar_apps": { "modal_title": "Apps da barra lateral", diff --git a/locales/ro/common.json b/locales/ro/common.json index 31a66a763..dd59bc843 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -258,7 +258,18 @@ "share_gone": "Ce ați partajat nu mai este aici. Partajați din nou.", "share_files_lost": "Fișierele partajate nu au putut fi păstrate. Adăugați-le din nou la mesaj.", "ics_needs_server": "Importul unui fișier de calendar are nevoie de conexiune la server. Importul începe când reveniți online.", - "contacts_need_server": "Importul contactelor are nevoie de conexiune la server. Încercați din nou când reveniți online." + "contacts_need_server": "Importul contactelor are nevoie de conexiune la server. Încercați din nou când reveniți online.", + "share_confirm_title": "Începeți un mesaj nou?", + "share_confirm_message": "Alt site sau altă aplicație vrea să înceapă un mesaj. Nu se încarcă și nu se salvează nimic fără acordul dvs.", + "share_confirm_subject": "Subiect:", + "share_confirm_text": "Text:", + "share_confirm_files": "{count, plural, one {Fișier:} few {Fișiere:} other {Fișiere:}}", + "share_confirm_accept": "Începeți mesajul", + "share_confirm_decline": "Nu începeți", + "share_cut": "Textul partajat era prea lung și a fost scurtat.", + "handoff_expired": "Ceva ce ați deschis sau partajat a așteptat prea mult și nu mai este disponibil. Deschideți sau partajați din nou.", + "compose_waiting": "Închideți acest mesaj pentru a-l începe pe următorul.", + "save_first_failed": "Mesajul nu a putut fi salvat, așa că fișierul nu a fost deschis. Salvați mesajul, apoi deschideți calendarul sau contactele." }, "sidebar_apps": { "modal_title": "Aplicații din bara laterală", diff --git a/locales/ru/common.json b/locales/ru/common.json index 8790a5cf5..4e4aa57a3 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -258,7 +258,18 @@ "share_gone": "То, чем вы поделились, больше недоступно. Поделитесь ещё раз.", "share_files_lost": "Не удалось сохранить полученные файлы. Добавьте их в письмо ещё раз.", "ics_needs_server": "Для импорта файла календаря нужно подключение к серверу. Импорт начнётся, когда вы снова будете в сети.", - "contacts_need_server": "Для импорта контактов нужно подключение к серверу. Попробуйте ещё раз, когда снова будете в сети." + "contacts_need_server": "Для импорта контактов нужно подключение к серверу. Попробуйте ещё раз, когда снова будете в сети.", + "share_confirm_title": "Начать новое письмо?", + "share_confirm_message": "Другой сайт или приложение хочет начать письмо. Без вашего согласия ничего не будет загружено или сохранено.", + "share_confirm_subject": "Тема:", + "share_confirm_text": "Текст:", + "share_confirm_files": "{count, plural, one {Файл:} few {Файлы:} many {Файлы:} other {Файлы:}}", + "share_confirm_accept": "Начать письмо", + "share_confirm_decline": "Не начинать", + "share_cut": "Полученный текст был слишком длинным и был сокращён.", + "handoff_expired": "То, что вы открыли или чем поделились, ждало слишком долго и больше недоступно. Откройте или поделитесь ещё раз.", + "compose_waiting": "Закройте это письмо, чтобы начать следующее.", + "save_first_failed": "Не удалось сохранить письмо, поэтому файл не открыт. Сохраните письмо, затем откройте календарь или контакты." }, "sidebar_apps": { "modal_title": "Приложения боковой панели", diff --git a/locales/sk/common.json b/locales/sk/common.json index c186d6754..80ce1012d 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -258,7 +258,18 @@ "share_gone": "To, čo ste zdieľali, tu už nie je. Zdieľajte to prosím znova.", "share_files_lost": "Zdieľané súbory sa nepodarilo uchovať. Pridajte ich do správy znova.", "ics_needs_server": "Import súboru kalendára vyžaduje pripojenie k serveru. Import sa začne, keď budete znova online.", - "contacts_need_server": "Import kontaktov vyžaduje pripojenie k serveru. Skúste to znova, keď budete online." + "contacts_need_server": "Import kontaktov vyžaduje pripojenie k serveru. Skúste to znova, keď budete online.", + "share_confirm_title": "Začať novú správu?", + "share_confirm_message": "Iný web alebo aplikácia chce začať správu. Bez vášho súhlasu sa nič nenahrá ani neuloží.", + "share_confirm_subject": "Predmet:", + "share_confirm_text": "Text:", + "share_confirm_files": "{count, plural, one {Súbor:} few {Súbory:} many {Súborov:} other {Súbory:}}", + "share_confirm_accept": "Začať správu", + "share_confirm_decline": "Nezačínať", + "share_cut": "Zdieľaný text bol príliš dlhý a bol skrátený.", + "handoff_expired": "To, čo ste otvorili alebo zdieľali, čakalo príliš dlho a už tu nie je. Otvorte to alebo zdieľajte znova.", + "compose_waiting": "Zatvorte túto správu, aby ste mohli začať ďalšiu.", + "save_first_failed": "Správu sa nepodarilo uložiť, preto sa súbor neotvoril. Uložte správu a potom otvorte kalendár alebo kontakty." }, "sidebar_apps": { "modal_title": "Aplikácie postranného panela", diff --git a/locales/th/common.json b/locales/th/common.json index fadbbffd1..8ccc9804c 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -2391,7 +2391,18 @@ "share_gone": "สิ่งที่คุณแชร์ไม่อยู่ที่นี่แล้ว โปรดแชร์อีกครั้ง", "share_files_lost": "เก็บไฟล์ที่แชร์ไว้ไม่ได้ โปรดเพิ่มไฟล์ลงในข้อความอีกครั้ง", "ics_needs_server": "การนำเข้าไฟล์ปฏิทินต้องเชื่อมต่อกับเซิร์ฟเวอร์ การนำเข้าจะเริ่มเมื่อคุณกลับมาออนไลน์", - "contacts_need_server": "การนำเข้ารายชื่อติดต่อต้องเชื่อมต่อกับเซิร์ฟเวอร์ โปรดลองอีกครั้งเมื่อคุณกลับมาออนไลน์" + "contacts_need_server": "การนำเข้ารายชื่อติดต่อต้องเชื่อมต่อกับเซิร์ฟเวอร์ โปรดลองอีกครั้งเมื่อคุณกลับมาออนไลน์", + "share_confirm_title": "เริ่มข้อความใหม่หรือไม่", + "share_confirm_message": "เว็บไซต์หรือแอปอื่นต้องการเริ่มข้อความ จะไม่มีการอัปโหลดหรือบันทึกสิ่งใดหากคุณไม่ยินยอม", + "share_confirm_subject": "หัวเรื่อง:", + "share_confirm_text": "ข้อความ:", + "share_confirm_files": "{count, plural, other {ไฟล์:}}", + "share_confirm_accept": "เริ่มข้อความ", + "share_confirm_decline": "ไม่เริ่ม", + "share_cut": "ข้อความที่แชร์ยาวเกินไปจึงถูกตัดให้สั้นลง", + "handoff_expired": "สิ่งที่คุณเปิดหรือแชร์รอนานเกินไปและไม่อยู่แล้ว โปรดเปิดหรือแชร์อีกครั้ง", + "compose_waiting": "ปิดข้อความนี้เพื่อเริ่มข้อความถัดไป", + "save_first_failed": "บันทึกข้อความไม่ได้ จึงยังไม่เปิดไฟล์ โปรดบันทึกข้อความแล้วเปิดปฏิทินหรือรายชื่อติดต่อ" }, "sidebar_apps": { "modal_title": "แอปพลิเคชันแถบด้านข้าง", diff --git a/locales/tr/common.json b/locales/tr/common.json index ff89c598e..f9a47385b 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -258,7 +258,18 @@ "share_gone": "Paylaştığınız içerik artık burada değil. Lütfen yeniden paylaşın.", "share_files_lost": "Paylaşılan dosyalar saklanamadı. Onları iletiye yeniden ekleyin.", "ics_needs_server": "Takvim dosyasını içe aktarmak için sunucu bağlantısı gerekir. İçe aktarma, yeniden çevrimiçi olduğunuzda başlar.", - "contacts_need_server": "Kişileri içe aktarmak için sunucu bağlantısı gerekir. Yeniden çevrimiçi olduğunuzda tekrar deneyin." + "contacts_need_server": "Kişileri içe aktarmak için sunucu bağlantısı gerekir. Yeniden çevrimiçi olduğunuzda tekrar deneyin.", + "share_confirm_title": "Yeni bir ileti başlatılsın mı?", + "share_confirm_message": "Başka bir site veya uygulama ileti başlatmak istiyor. Siz kabul etmedikçe hiçbir şey yüklenmez veya kaydedilmez.", + "share_confirm_subject": "Konu:", + "share_confirm_text": "Metin:", + "share_confirm_files": "{count, plural, one {Dosya:} other {Dosyalar:}}", + "share_confirm_accept": "İletiyi başlat", + "share_confirm_decline": "Başlatma", + "share_cut": "Paylaşılan metin çok uzundu ve kısaltıldı.", + "handoff_expired": "Açtığınız veya paylaştığınız bir şey çok uzun süre bekledi ve artık yok. Lütfen yeniden açın veya paylaşın.", + "compose_waiting": "Sonrakini başlatmak için bu iletiyi kapatın.", + "save_first_failed": "İletiniz kaydedilemedi, bu yüzden dosya açılmadı. İletiyi kaydedin, ardından takvimi veya kişileri açın." }, "sidebar_apps": { "modal_title": "Kenar çubuğu uygulamaları", diff --git a/locales/uk/common.json b/locales/uk/common.json index 8d6f02fc1..02f7fd724 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -258,7 +258,18 @@ "share_gone": "Те, чим ви поділилися, більше недоступне. Поділіться ще раз.", "share_files_lost": "Не вдалося зберегти отримані файли. Додайте їх до листа ще раз.", "ics_needs_server": "Для імпорту файлу календаря потрібне підключення до сервера. Імпорт почнеться, коли ви знову будете в мережі.", - "contacts_need_server": "Для імпорту контактів потрібне підключення до сервера. Спробуйте ще раз, коли знову будете в мережі." + "contacts_need_server": "Для імпорту контактів потрібне підключення до сервера. Спробуйте ще раз, коли знову будете в мережі.", + "share_confirm_title": "Почати новий лист?", + "share_confirm_message": "Інший сайт або застосунок хоче почати лист. Без вашої згоди нічого не буде завантажено чи збережено.", + "share_confirm_subject": "Тема:", + "share_confirm_text": "Текст:", + "share_confirm_files": "{count, plural, one {Файл:} few {Файли:} many {Файли:} other {Файли:}}", + "share_confirm_accept": "Почати лист", + "share_confirm_decline": "Не починати", + "share_cut": "Отриманий текст був задовгий, тому його скорочено.", + "handoff_expired": "Те, що ви відкрили або чим поділилися, чекало надто довго і більше недоступне. Відкрийте або поділіться ще раз.", + "compose_waiting": "Закрийте цей лист, щоб почати наступний.", + "save_first_failed": "Не вдалося зберегти лист, тому файл не відкрито. Збережіть лист, а потім відкрийте календар або контакти." }, "sidebar_apps": { "modal_title": "Програми бічної панелі", diff --git a/locales/vi/common.json b/locales/vi/common.json index f8f43adae..c0fc52f4e 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -258,7 +258,18 @@ "share_gone": "Nội dung bạn đã chia sẻ không còn ở đây. Vui lòng chia sẻ lại.", "share_files_lost": "Không thể giữ các tệp đã chia sẻ. Hãy thêm lại chúng vào thư.", "ics_needs_server": "Nhập tệp lịch cần kết nối với máy chủ. Quá trình nhập sẽ bắt đầu khi bạn trực tuyến trở lại.", - "contacts_need_server": "Nhập danh bạ cần kết nối với máy chủ. Hãy thử lại khi bạn trực tuyến trở lại." + "contacts_need_server": "Nhập danh bạ cần kết nối với máy chủ. Hãy thử lại khi bạn trực tuyến trở lại.", + "share_confirm_title": "Bắt đầu thư mới?", + "share_confirm_message": "Một trang web hoặc ứng dụng khác muốn bắt đầu một thư. Sẽ không có gì được tải lên hoặc lưu nếu bạn không đồng ý.", + "share_confirm_subject": "Chủ đề:", + "share_confirm_text": "Nội dung:", + "share_confirm_files": "{count, plural, other {Tệp:}}", + "share_confirm_accept": "Bắt đầu thư", + "share_confirm_decline": "Không bắt đầu", + "share_cut": "Văn bản được chia sẻ quá dài và đã được rút ngắn.", + "handoff_expired": "Nội dung bạn đã mở hoặc chia sẻ đã chờ quá lâu và không còn nữa. Vui lòng mở hoặc chia sẻ lại.", + "compose_waiting": "Đóng thư này để bắt đầu thư tiếp theo.", + "save_first_failed": "Không thể lưu thư của bạn nên tệp chưa được mở. Hãy lưu thư, rồi mở lịch hoặc danh bạ." }, "sidebar_apps": { "modal_title": "Ứng dụng thanh bên", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 391355f2e..c80f7d0cd 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -258,7 +258,18 @@ "share_gone": "您分享的內容已不在此處。請重新分享。", "share_files_lost": "無法保留分享的檔案。請將它們重新加入郵件。", "ics_needs_server": "匯入行事曆檔案需要連線到伺服器。您恢復連線後將開始匯入。", - "contacts_need_server": "匯入聯絡人需要連線到伺服器。請在恢復連線後再試一次。" + "contacts_need_server": "匯入聯絡人需要連線到伺服器。請在恢復連線後再試一次。", + "share_confirm_title": "要開始撰寫新郵件嗎?", + "share_confirm_message": "另一個網站或應用程式想要開始撰寫郵件。除非您同意,否則不會上傳或儲存任何內容。", + "share_confirm_subject": "主旨:", + "share_confirm_text": "內文:", + "share_confirm_files": "{count, plural, other {檔案:}}", + "share_confirm_accept": "開始撰寫郵件", + "share_confirm_decline": "不要開始", + "share_cut": "分享的文字太長,已被縮短。", + "handoff_expired": "您開啟或分享的內容等待太久,已不存在。請重新開啟或分享。", + "compose_waiting": "請關閉此郵件以開始下一封。", + "save_first_failed": "無法儲存您的郵件,因此未開啟檔案。請儲存郵件,然後開啟行事曆或聯絡人。" }, "sidebar_apps": { "modal_title": "側邊欄應用程式", diff --git a/locales/zh/common.json b/locales/zh/common.json index 2fabc2fe3..a2713c9e2 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -258,7 +258,18 @@ "share_gone": "您分享的内容已不在此处。请重新分享。", "share_files_lost": "无法保留分享的文件。请将它们重新添加到邮件中。", "ics_needs_server": "导入日历文件需要连接到服务器。您恢复联网后将开始导入。", - "contacts_need_server": "导入联系人需要连接到服务器。请在恢复联网后重试。" + "contacts_need_server": "导入联系人需要连接到服务器。请在恢复联网后重试。", + "share_confirm_title": "要开始写新邮件吗?", + "share_confirm_message": "另一个网站或应用想要开始写一封邮件。除非您同意,否则不会上传或保存任何内容。", + "share_confirm_subject": "主题:", + "share_confirm_text": "正文:", + "share_confirm_files": "{count, plural, other {文件:}}", + "share_confirm_accept": "开始写邮件", + "share_confirm_decline": "不开始", + "share_cut": "分享的文本太长,已被缩短。", + "handoff_expired": "您打开或分享的内容等待时间过长,已不存在。请重新打开或分享。", + "compose_waiting": "请关闭此邮件以开始下一封。", + "save_first_failed": "无法保存您的邮件,因此未打开文件。请保存邮件,然后打开日历或联系人。" }, "sidebar_apps": { "modal_title": "侧边栏应用", diff --git a/public/sw.js b/public/sw.js index 6a313d86e..84f0e41c1 100644 --- a/public/sw.js +++ b/public/sw.js @@ -62,7 +62,7 @@ self.addEventListener("install", (event) => { }); self.addEventListener("activate", (event) => { - event.waitUntil(Promise.all([self.clients.claim(), deleteStaleShellCaches()])); + event.waitUntil(Promise.all([self.clients.claim(), deleteStaleShellCaches(), sweepHandoffsOnActivate()])); }); // ---- Offline mode (#523): the app shell ---- @@ -315,18 +315,92 @@ async function deleteStaleShellCaches() { // // The manifest's share_target POSTs here. Files can't travel through a // redirect, so they wait in the Cache API until the page takes them (same -// layout as lib/launch-handoff.ts, which also removes them). Without a worker -// the POST reaches app/(main)/protocol/share/route.ts. +// layout and limits as lib/launch-handoff.ts, which also removes them). Any +// site can POST here, signed in or not, and the worker can't tell who did: the +// page asks the user. So what is kept is limited, and sealed with a key that +// only the landing address's fragment knows. A browser without share_target +// gets the same question. Without a worker the POST reaches +// app/(main)/protocol/share/route.ts. const HANDOFF_CACHE = "bulwark-launch-handoff-v1"; +const HANDOFF_TTL_MS = 15 * 60 * 1000; +const HANDOFF_MAX_ENTRIES = 5; +const HANDOFF_MAX_BYTES = 100 * 1024 * 1024; function isShareTarget(request) { - if (request.method !== "POST" || request.mode !== "navigate") return false; + // A document only: a hidden frame doesn't get to store anything. + if (request.method !== "POST" || request.mode !== "navigate" || request.destination !== "document") return false; const url = new URL(request.url); return url.origin === self.location.origin && url.pathname.replace(/\/+$/, "") === BASE_PATH + "/protocol/share"; } +/** Drops what is too old and, when `incomingBytes` wants in, the oldest entries in the way. */ +async function sweepHandoffs(cache, incomingBytes) { + const keys = await cache.keys(); + const pathOf = (key) => new URL(key.url, self.location.origin).pathname; + const entries = []; + for (const key of keys) { + const path = pathOf(key); + if (!path.endsWith("/meta")) continue; + const response = await cache.match(key); + const meta = response ? await response.json().catch(() => null) : null; + entries.push({ prefix: path.slice(0, -"meta".length), createdAt: (meta && meta.createdAt) || 0, bytes: (meta && meta.bytes) || 0 }); + } + entries.sort((a, b) => a.createdAt - b.createdAt); + let total = entries.reduce((sum, entry) => sum + entry.bytes, 0); + while (entries.length > 0) { + const tooOld = Date.now() - entries[0].createdAt > HANDOFF_TTL_MS; + const inTheWay = incomingBytes !== null && (entries.length >= HANDOFF_MAX_ENTRIES || total + incomingBytes > HANDOFF_MAX_BYTES); + if (!tooOld && !inTheWay) break; + const oldest = entries.shift(); + total -= oldest.bytes; + const own = keys.filter((key) => pathOf(key).startsWith(oldest.prefix)); + // The list last: this sweep goes by it. + await Promise.all(own.filter((key) => !pathOf(key).endsWith("/meta")).map((key) => cache.delete(key))); + await cache.delete(oldest.prefix + "meta"); + } +} + +async function sweepHandoffsOnActivate() { + try { + if (typeof caches === "undefined" || !(await caches.has(HANDOFF_CACHE))) return; + await sweepHandoffs(await caches.open(HANDOFF_CACHE), null); + } catch (_) { + // Tried again at the next share. + } +} + +/** A 12-byte IV, then the AES-GCM ciphertext. */ +async function sealHandoff(key, data) { + const iv = crypto.getRandomValues(new Uint8Array(12)); + const body = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, data)); + const sealed = new Uint8Array(iv.length + body.length); + sealed.set(iv); + sealed.set(body, iv.length); + return sealed; +} + +/** As much of `value` as takes up to `budget` bytes once encoded, as in the server's fallback. */ +function fitEncoded(value, budget, flags) { + let used = 0; + let out = ""; + for (const character of value) { + try { + used += encodeURIComponent(character).length; + } catch (_) { + continue; + } + if (used > budget) { + flags.cut = true; + return out; + } + out += character; + } + return out; +} + async function receiveShare(request) { - const land = (params) => Response.redirect(self.location.origin + BASE_PATH + "/?" + new URLSearchParams(params), 303); + // In the fragment: it stays out of access logs, and the key out of the server's sight. + const land = (params) => Response.redirect(self.location.origin + BASE_PATH + "/#" + new URLSearchParams(params), 303); let form; try { form = await request.formData(); @@ -341,22 +415,37 @@ async function receiveShare(request) { const files = form.getAll("files").filter((file) => typeof file !== "string" && file.size > 0); const id = crypto.randomUUID(); const prefix = "/__bulwark/handoff/" + id + "/"; + const bytes = files.reduce((sum, file) => sum + file.size, 0); let cache = null; try { + if (bytes > HANDOFF_MAX_BYTES) throw new Error("too large"); cache = await caches.open(HANDOFF_CACHE); - const list = files.map((file) => ({ name: file.name, type: file.type })); - await cache.put(prefix + "meta", new Response(JSON.stringify({ kind: "compose", createdAt: Date.now(), ...shared, files: list }))); - await Promise.all(files.map((file, index) => cache.put(prefix + index, new Response(file)))); - return land({ share: id }); + await sweepHandoffs(cache, bytes); + const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt"]); + const rawKey = new Uint8Array(await crypto.subtle.exportKey("raw", key)); + const contents = JSON.stringify({ ...shared, files: files.map((file) => ({ name: file.name, type: file.type })) }); + // The list first: the sweep goes by it. + await cache.put(prefix + "meta", new Response(JSON.stringify({ kind: "compose", createdAt: Date.now(), bytes }))); + await cache.put(prefix + "sealed", new Response(await sealHandoff(key, new TextEncoder().encode(contents)))); + for (let index = 0; index < files.length; index += 1) { + await cache.put(prefix + index, new Response(await sealHandoff(key, await files[index].arrayBuffer()))); + } + const encodedKey = btoa(String.fromCharCode(...rawKey)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + return land({ share: id, key: encodedKey }); } catch (_) { // No room for the files. The text can still come along in the address. - if (cache) await Promise.all([...files.map((_file, index) => cache.delete(prefix + index)), cache.delete(prefix + "meta")]).catch(() => {}); + if (cache) await Promise.all([...files.map((_file, index) => cache.delete(prefix + index)), cache.delete(prefix + "sealed"), cache.delete(prefix + "meta")]).catch(() => {}); + const flags = { cut: false }; + const title = fitEncoded(shared.title, 400, flags); + const text = fitEncoded(shared.text, 1800, flags); + const link = fitEncoded(shared.url, 1000, flags); return land({ share: "unavailable", - share_title: shared.title.slice(0, 300), - share_text: shared.text.slice(0, 4000), - share_url: shared.url.slice(0, 2000), + share_title: title, + share_text: text, + share_url: link, ...(files.length > 0 ? { share_files: "1" } : {}), + ...(flags.cut ? { share_cut: "1" } : {}), }); } }