mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 16:15:39 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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, {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user