fix: keep pictures in messages and the Files image preview from being saved under No downloads, and say the mobile app's notifications aren't covered yet

This commit is contained in:
Linus Rath
2026-11-01 09:47:28 +01:00
parent 6e931a731c
commit 9f8d134e85
8 changed files with 132 additions and 8 deletions
@@ -45,7 +45,7 @@ export const DEVICE_FIELDS: DeviceField[] = [
{
key: 'pushWithoutContent',
label: 'Push without sender and subject',
description: 'Notifications say only “New mail”. The server leaves sender, subject and text out of what it sends to the browser and the push service, also for browsers that still run an old Bulwark.',
description: 'Browser notifications say only “New mail”. The server leaves sender, subject and text out of what it sends to the browser and the push service, also for browsers that still run an old Bulwark. The Bulwark mobile app isn’t covered yet: its notifications still show sender and subject.',
enforced: 'server',
},
{
@@ -0,0 +1,70 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';
import { ImagePreviewModal } from '@/components/files/image-preview-modal';
import { ReaderAttachments, type ReaderAttachmentItem } from '@/components/email/reader-attachments';
import { blockMediaSaving } from '@/lib/device-frame';
import { DEFAULT_POLICY } from '@/lib/admin/types';
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
import { useAuthStore } from '@/stores/auth-store';
import { usePolicyStore } from '@/stores/policy-store';
// "No downloads" for pictures: no "Save image as" or dragging out in the
// message frames, the attachment thumbnails and the Files image preview,
// which also gets the watermark (Organisation > Devices).
const initialPolicy = usePolicyStore.getState();
const initialAuth = useAuthStore.getState();
afterEach(() => {
usePolicyStore.setState(initialPolicy, true);
useAuthStore.setState(initialAuth, true);
});
function setDevices(devices: Record<string, boolean>) {
const policy = { ...DEFAULT_POLICY, organisation: { ...DEFAULT_ORGANISATION_POLICY, devices: { ...DEFAULT_ORGANISATION_POLICY.devices, ...devices } } };
usePolicyStore.setState({ policy, rawPolicy: policy, subject: null, loaded: true });
useAuthStore.setState({ activeAccountId: 'acc-a', username: 'alice@example.org', getAllConnectedClients: (() => new Map()) as never });
}
/** Dispatches a cancelable event and says whether something refused it. */
function refused(target: Element, type: 'contextmenu' | 'dragstart'): boolean {
const event = new Event(type, { bubbles: true, cancelable: true });
target.dispatchEvent(event);
return event.defaultPrevented;
}
describe('pictures under "No downloads"', () => {
it('a message frame refuses the context menu and dragging on pictures only, while the policy is on', () => {
const doc = document.implementation.createHTMLDocument('mail');
doc.body.innerHTML = '<p>Text</p><img src="blob:x" alt="inline">';
let on = true;
blockMediaSaving(doc, () => on);
expect(refused(doc.querySelector('img')!, 'contextmenu')).toBe(true);
expect(refused(doc.querySelector('img')!, 'dragstart')).toBe(true);
expect(refused(doc.querySelector('p')!, 'contextmenu')).toBe(false);
on = false;
expect(refused(doc.querySelector('img')!, 'contextmenu')).toBe(false);
});
it('attachment thumbnails can’t be saved', () => {
const png: ReaderAttachmentItem = { id: 'p', name: 'chart.png', type: 'image/png', size: 10, blobId: 'b' };
render(<ReaderAttachments attachments={[png]} thumbUrls={{ p: 'blob:thumb' }} opensPreview={() => true} onOpen={() => {}} onDownload={() => {}} onDownloadAll={() => {}} downloadingAll={false} downloadsOff />);
const img = document.querySelector('[data-testid="reader-attachments"] img')!;
expect(fireEvent.contextMenu(img)).toBe(false);
});
it('the Files image preview refuses saving and carries the watermark', async () => {
setDevices({ noDownloads: true, watermark: true });
render(<ImagePreviewModal name="photo.png" onClose={() => {}} getImageUrl={async () => 'blob:photo'} />);
const img = await screen.findByAltText('photo.png');
expect(fireEvent.contextMenu(img)).toBe(false);
expect(screen.getByTestId('watermark').textContent).toContain('alice@example.org');
});
it('leaves the Files image preview alone without the policy', async () => {
setDevices({});
render(<ImagePreviewModal name="photo.png" onClose={() => {}} getImageUrl={async () => 'blob:photo'} />);
const img = await screen.findByAltText('photo.png');
expect(fireEvent.contextMenu(img)).toBe(true);
expect(screen.queryByTestId('watermark')).toBeNull();
});
});
+5 -2
View File
@@ -153,6 +153,7 @@ import { downloadAllowed, saveBlobAs } from "@/lib/download-guard";
import { useDevicePolicy } from "@/hooks/use-device-policy";
import { Watermark, useWatermarkText } from "@/components/device/watermark";
import { watermarkForPrint } from "@/lib/watermark";
import { NO_SAVE_MEDIA_CSS, blockMediaSaving } from "@/lib/device-frame";
import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { useWopiStatus, canWopiOpen } from "@/hooks/use-wopi-status";
import { parseTnef, isTnefAttachment } from "@/lib/tnef";
@@ -3256,8 +3257,8 @@ export function EmailViewer({
pre { white-space: pre-wrap; word-wrap: break-word; }
${wordHtmlCSS}
${darkModeCSS}
</style>${devices.noPrint ? '<style>@media print{html,body{display:none!important}}</style>' : ''}</head><body dir="auto">${resolveColorSchemeMedia(effectiveEmailContent.html, recolorDark)}<style>html,body{height:auto!important;min-height:0!important;max-height:none!important}body{overflow-y:hidden!important}</style>${reading.frameCss ? `<style>${reading.frameCss}</style>` : ''}</body></html>`;
}, [effectiveEmailContent.html, effectiveEmailContent.isHtml, effectiveEmailContent.hasStyleTag, effectiveEmailContent.externalBlocked, recolorDark, emailHasNativeDarkMode, messageSpacing, bodyLanguage, reading.frameKey, reading.frameCss, appFontSources, devices.noPrint]);
</style>${devices.noPrint ? '<style>@media print{html,body{display:none!important}}</style>' : ''}${devices.noDownloads ? `<style>${NO_SAVE_MEDIA_CSS}</style>` : ''}</head><body dir="auto">${resolveColorSchemeMedia(effectiveEmailContent.html, recolorDark)}<style>html,body{height:auto!important;min-height:0!important;max-height:none!important}body{overflow-y:hidden!important}</style>${reading.frameCss ? `<style>${reading.frameCss}</style>` : ''}</body></html>`;
}, [effectiveEmailContent.html, effectiveEmailContent.isHtml, effectiveEmailContent.hasStyleTag, effectiveEmailContent.externalBlocked, recolorDark, emailHasNativeDarkMode, messageSpacing, bodyLanguage, reading.frameKey, reading.frameCss, appFontSources, devices.noPrint, devices.noDownloads]);
// Unblocking external content is handled by rebuilding the iframe srcDoc:
// toggling allowExternalContent (both "Load images" and "Trust sender" set
@@ -3556,6 +3557,8 @@ export function EmailViewer({
// A press in the body closes a mention's card. The page never sees
// it: the focus is already inside the iframe, so there is no blur.
doc.addEventListener('mousedown', () => setBodyMention(null), true);
// "No downloads": the message's own pictures can't be saved or dragged out either.
blockMediaSaving(doc, () => downloadsOffRef.current);
// Selected text, for the side panel, the onTextSelectionChange hook
// and the right-click menu plugins may add entries to.
watchBodySelection(doc, iframe, {
+2 -1
View File
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
import { IconButton } from "@/components/ui/icon-button";
import { getAttachmentDisplayName } from "@/lib/attachment-name";
import { cn, formatFileSize } from "@/lib/utils";
import { noMediaSavingProps } from "@/lib/device-frame";
import { attachmentIcon } from "./attachment-chips";
export interface ReaderAttachmentItem {
@@ -237,7 +238,7 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
const draw = layout === "cards" ? card : tile;
return (
<section aria-label={t("attachments")} className={cn("flex flex-col gap-3", className)} data-testid="reader-attachments" data-layout={layout}>
<section aria-label={t("attachments")} className={cn("flex flex-col gap-3", className)} data-testid="reader-attachments" data-layout={layout} {...noMediaSavingProps(downloadsOff)}>
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2 text-sm text-muted-foreground">
<Paperclip className="h-4 w-4 shrink-0" aria-hidden="true" />
@@ -74,6 +74,7 @@ import { turnOffLinks } from "@/lib/phishing-links";
import { usePhishingMarked } from "@/hooks/use-phishing-report";
import { useDevicePolicy } from "@/hooks/use-device-policy";
import { Watermark, useWatermarkText } from "@/components/device/watermark";
import { NO_SAVE_MEDIA_CSS, blockMediaSaving } from "@/lib/device-frame";
export type ConversationAttachmentHandler = (
blobId: string, name: string, type: string | undefined, source: EmailBlobContext,
@@ -557,6 +558,8 @@ export function ConversationEmailBody({
const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction);
// The device policies of the message's own account (Organisation > Devices).
const devices = useDevicePolicy(email);
const downloadsOffRef = useRef(devices.noDownloads);
downloadsOffRef.current = devices.noDownloads;
const watermark = useWatermarkText(email);
const wopiStatus = useWopiStatus(true);
const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments);
@@ -832,8 +835,8 @@ export function ConversationEmailBody({
td, th { word-break: break-word; padding: 0.5rem; }
pre { white-space: pre-wrap; word-wrap: break-word; }
${isDark ? emailDarkModeCss() : ''}
</style>${devices.noPrint ? '<style>@media print{html,body{display:none!important}}</style>' : ''}</head><body dir="auto">${emailContent.html}${reading.frameCss ? `<style>${reading.frameCss}</style>` : ''}</body></html>`;
}, [emailContent.isHtml, emailContent.html, allowExternal, isDark, bodyLanguage, reading.frameKey, reading.frameCss, appFontSources, devices.noPrint]);
</style>${devices.noPrint ? '<style>@media print{html,body{display:none!important}}</style>' : ''}${devices.noDownloads ? `<style>${NO_SAVE_MEDIA_CSS}</style>` : ''}</head><body dir="auto">${emailContent.html}${reading.frameCss ? `<style>${reading.frameCss}</style>` : ''}</body></html>`;
}, [emailContent.isHtml, emailContent.html, allowExternal, isDark, bodyLanguage, reading.frameKey, reading.frameCss, appFontSources, devices.noPrint, devices.noDownloads]);
useLayoutEffect(() => { setHtmlBodyEl(null); }, [emailIframeSrcDoc]);
// One object per body: React writes innerHTML again whenever this object
// changes, dropping whatever was mounted in the body.
@@ -899,6 +902,8 @@ export function ConversationEmailBody({
void resolveExternalLink({ href, target: link.getAttribute('target') ?? undefined, emailId: email.id })
.then((target) => { if (target) window.open(target, '_blank', 'noopener,noreferrer'); });
}, true);
// "No downloads": the message's own pictures can't be saved or dragged out either.
blockMediaSaving(doc, () => downloadsOffRef.current);
// Selected text: the onTextSelectionChange hook and the right-click
// menu plugins may add entries to.
watchBodySelection(doc, iframe, {
+2 -1
View File
@@ -13,6 +13,7 @@ import { EmlPreview, type EmlAttachment, type ParsedEml } from "@/components/fil
import { FilePreviewPluginSection } from "@/components/files/file-preview-plugin-section";
import { canOpenUrlInNewWindow } from "@/lib/standalone-display";
import { saveBlobAs } from "@/lib/download-guard";
import { noMediaSavingProps } from "@/lib/device-frame";
import { useDevicePolicy, useStrictestDevices } from "@/hooks/use-device-policy";
import { Watermark, useWatermarkText } from "@/components/device/watermark";
import { stripBidiControls } from "@/lib/bidi";
@@ -383,7 +384,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi
)}
{!loading && !error && fileType === "image" && objectUrl && (
<div className="absolute inset-0" onContextMenu={downloadsOff ? (e) => e.preventDefault() : undefined}>
<div className="absolute inset-0" {...noMediaSavingProps(downloadsOff)}>
<ZoomableImage src={objectUrl} alt={name} onBackgroundClick={onClose} />
</div>
)}
+8 -1
View File
@@ -8,6 +8,9 @@ import { ZoomableImage } from "@/components/files/zoomable-image";
import { FilePreviewPluginSection } from "@/components/files/file-preview-plugin-section";
import type { FileResourceView } from "@/lib/plugin-types";
import { useCloseOnBack } from "@/hooks/use-close-on-back";
import { useStrictestDevices } from "@/hooks/use-device-policy";
import { Watermark, useWatermarkText } from "@/components/device/watermark";
import { noMediaSavingProps } from "@/lib/device-frame";
interface ImagePreviewModalProps {
name: string;
@@ -23,6 +26,9 @@ interface ImagePreviewModalProps {
export function ImagePreviewModal({ name, onClose, onDownload, getImageUrl, file, neighbours }: ImagePreviewModalProps) {
const t = useTranslations("files");
// The Files app follows the strictest signed-in account (Organisation > Devices).
const downloadsOff = useStrictestDevices().noDownloads;
const watermark = useWatermarkText(undefined, { strictest: true });
const [imageUrl, setImageUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
@@ -58,7 +64,7 @@ export function ImagePreviewModal({ name, onClose, onDownload, getImageUrl, file
return (
<PreviewShell name={name} onClose={onClose} onDownload={onDownload ? () => void onDownload(name) : undefined} neighbours={neighbours}>
<div className="relative flex-1 min-h-0">
<div className="relative flex-1 min-h-0" {...noMediaSavingProps(downloadsOff)}>
{loading && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<Spinner label={t("loading")} className="w-8 h-8 text-white/80" />
@@ -72,6 +78,7 @@ export function ImagePreviewModal({ name, onClose, onDownload, getImageUrl, file
{imageUrl && !error && (
<ZoomableImage src={imageUrl} alt={name} onBackgroundClick={onClose} />
)}
{imageUrl && !error && <Watermark text={watermark} />}
</div>
<FilePreviewPluginSection file={file} />
</PreviewShell>
+37
View File
@@ -0,0 +1,37 @@
/**
* "No downloads" inside the reader's own documents (the same-origin message
* frames) and over previews: a right-click on a picture offers no "Save
* image as", and pictures can't be dragged out to the desktop. Pictures
* of the message itself (inline cid parts, remote images once loaded) are
* otherwise as easy to save as an attachment.
*/
/** Styles for a frame's document: pictures can't be dragged or long-pressed out. */
export const NO_SAVE_MEDIA_CSS = 'img,picture,video,svg{-webkit-user-drag:none;user-drag:none;-webkit-touch-callout:none}';
const MEDIA = 'img,picture,video,svg,canvas';
/** Refuses the context menu and dragging on pictures while `isOn()` says so. */
export function blockMediaSaving(target: Document | HTMLElement, isOn: () => boolean): () => void {
const block = (event: Event) => {
if (!isOn()) return;
const element = event.target as Element | null;
if (element && typeof element.closest === 'function' && element.closest(MEDIA)) event.preventDefault();
};
target.addEventListener('contextmenu', block, true);
target.addEventListener('dragstart', block, true);
return () => {
target.removeEventListener('contextmenu', block, true);
target.removeEventListener('dragstart', block, true);
};
}
/** React props for a container whose pictures must not be saved. */
export function noMediaSavingProps(on: boolean) {
if (!on) return {};
const block = (event: { target: EventTarget; preventDefault: () => void }) => {
const element = event.target as Element | null;
if (element && typeof element.closest === 'function' && element.closest(MEDIA)) event.preventDefault();
};
return { onContextMenuCapture: block, onDragStartCapture: block };
}