Merge remote-tracking branch 'origin/main' into codex/1110-usage-coverage
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "picpeak-frontend",
|
||||
"private": true,
|
||||
"version": "3.123.0-beta.0",
|
||||
"version": "3.124.1-beta.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -11,25 +11,12 @@ import {
|
||||
interface AuthenticatedImageProps extends Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'onLoad'> {
|
||||
src: string;
|
||||
fallbackSrc?: string;
|
||||
useWatermark?: boolean;
|
||||
isGallery?: boolean;
|
||||
protectFromDownload?: boolean;
|
||||
slug?: string;
|
||||
photoId?: number;
|
||||
requiresToken?: boolean;
|
||||
secureUrlTemplate?: string;
|
||||
downloadUrlTemplate?: string;
|
||||
onProtectionViolation?: (violationType: string) => void;
|
||||
watermarkText?: string;
|
||||
overlayProtection?: boolean;
|
||||
fragmentGrid?: boolean;
|
||||
scrambleFragments?: boolean;
|
||||
useCanvasRendering?: boolean;
|
||||
blockKeyboardShortcuts?: boolean;
|
||||
detectPrintScreen?: boolean;
|
||||
detectDevTools?: boolean;
|
||||
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
||||
useEnhancedProtection?: boolean;
|
||||
/** Fired when the canvas branch blocks a context-menu attempt. The only
|
||||
* protection callback this component actually implements (#1297). */
|
||||
onProtectionViolation?: (violationType: string) => void;
|
||||
onLoad?: () => void;
|
||||
/**
|
||||
* Priority in the shared fetch queue (#1287). NOT the native `fetchPriority`
|
||||
@@ -43,51 +30,44 @@ interface AuthenticatedImageProps extends Omit<React.ImgHTMLAttributes<HTMLImage
|
||||
queuePriority?: 'high' | 'prefetch' | 'normal';
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches an image with the gallery's bearer token and renders it.
|
||||
*
|
||||
* IMAGE PROTECTION IS NOT IMPLEMENTED HERE (#1297). This component used to
|
||||
* accept the whole protection prop surface — protectFromDownload,
|
||||
* watermarkText, fragmentGrid, blockKeyboardShortcuts, detectPrintScreen,
|
||||
* detectDevTools, protectionLevel and the rest — and discard every one of
|
||||
* them in a `void unusedProps` block. Callers computed them from the event's
|
||||
* protection level and passed them in good faith, so raising that level
|
||||
* produced canvas rendering (via the layouts' own OR on
|
||||
* `protectionLevel === 'maximum'`) and nothing else it implies.
|
||||
*
|
||||
* They are removed rather than implemented, so the interface states what the
|
||||
* component actually does. The implementation those props describe already
|
||||
* exists in `ProtectedImage` — which is exported and currently rendered
|
||||
* nowhere. Wiring that in is a deliberate product decision about what
|
||||
* protection level should mean, not a silent side effect of a cleanup.
|
||||
*
|
||||
* Two props survive because they are real:
|
||||
* useCanvasRendering draws to a canvas instead of an <img>
|
||||
* onProtectionViolation fires from the canvas context-menu handler below
|
||||
*
|
||||
* `useWatermark` was removed too. #1297 did not list it — it sat outside the
|
||||
* `unusedProps` block — but it was equally inert: declared, defaulted, never
|
||||
* read.
|
||||
*/
|
||||
export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
src,
|
||||
fallbackSrc,
|
||||
alt,
|
||||
useWatermark = false,
|
||||
isGallery = false,
|
||||
protectFromDownload,
|
||||
slug,
|
||||
photoId,
|
||||
requiresToken,
|
||||
secureUrlTemplate,
|
||||
downloadUrlTemplate,
|
||||
onProtectionViolation,
|
||||
watermarkText,
|
||||
overlayProtection,
|
||||
fragmentGrid,
|
||||
scrambleFragments,
|
||||
useCanvasRendering,
|
||||
blockKeyboardShortcuts,
|
||||
detectPrintScreen,
|
||||
detectDevTools,
|
||||
protectionLevel,
|
||||
useEnhancedProtection,
|
||||
onProtectionViolation,
|
||||
onLoad,
|
||||
queuePriority = 'normal',
|
||||
...props
|
||||
}) => {
|
||||
const unusedProps = {
|
||||
protectFromDownload,
|
||||
photoId,
|
||||
requiresToken,
|
||||
secureUrlTemplate,
|
||||
downloadUrlTemplate,
|
||||
onProtectionViolation,
|
||||
watermarkText,
|
||||
overlayProtection,
|
||||
fragmentGrid,
|
||||
scrambleFragments,
|
||||
blockKeyboardShortcuts,
|
||||
detectPrintScreen,
|
||||
detectDevTools,
|
||||
protectionLevel,
|
||||
useEnhancedProtection
|
||||
};
|
||||
void unusedProps;
|
||||
|
||||
const [imageSrc, setImageSrc] = useState<string>('');
|
||||
const [error, setError] = useState(false);
|
||||
@@ -98,14 +78,16 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
const imageRef = useRef<HTMLImageElement | null>(null);
|
||||
|
||||
// Draw image to canvas when canvas rendering is enabled
|
||||
// Returns whether the pixels actually made it onto the canvas, so the
|
||||
// caller knows if the source image is still needed (#1287).
|
||||
const drawToCanvas = useCallback(() => {
|
||||
if (!useCanvasRendering || !canvasRef.current || !imageRef.current) return;
|
||||
if (!useCanvasRendering || !canvasRef.current || !imageRef.current) return false;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const img = imageRef.current;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!ctx || !img.complete || img.naturalWidth === 0) return;
|
||||
if (!ctx || !img.complete || img.naturalWidth === 0) return false;
|
||||
|
||||
// Set canvas dimensions to match image
|
||||
canvas.width = img.naturalWidth;
|
||||
@@ -115,6 +97,7 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
setCanvasReady(true);
|
||||
return true;
|
||||
}, [useCanvasRendering]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -271,7 +254,26 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
|
||||
img.onload = () => {
|
||||
imageRef.current = img;
|
||||
drawToCanvas();
|
||||
const drawn = drawToCanvas();
|
||||
// Once drawImage has copied the pixels into the canvas the source
|
||||
// decode is dead weight, so drop it here rather than at unmount. The
|
||||
// grid is not virtualised — a 546-photo event mounts 546 of these and
|
||||
// none of them unmount while the gallery is open — so a cleanup-only
|
||||
// release never actually runs for the case it was meant to fix
|
||||
// (#1287). Nothing redraws from `imageRef` afterwards: drawToCanvas
|
||||
// has this one caller.
|
||||
if (drawn) {
|
||||
// Handlers off BEFORE the src goes. Measured in Chromium and WebKit:
|
||||
// neither fires `error` when the attribute is removed after a
|
||||
// successful load, so this is not fixing an observed bug — but if any
|
||||
// engine ever did, `onerror` would set canvasFailed, swap the canvas
|
||||
// for a plain <img>, and decode the image a second time, which is the
|
||||
// exact opposite of what this release is for. The ordering is free.
|
||||
img.onload = null;
|
||||
img.onerror = null;
|
||||
imageRef.current = null;
|
||||
img.removeAttribute('src');
|
||||
}
|
||||
onLoad?.();
|
||||
};
|
||||
|
||||
@@ -286,6 +288,17 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
return () => {
|
||||
img.onload = null;
|
||||
img.onerror = null;
|
||||
// Fallback release for the paths the onload handler above cannot
|
||||
// cover: the draw failed, or the source changed / the component
|
||||
// unmounted before onload ever fired. `imageRef` is what drawToCanvas
|
||||
// reads and it was never cleared, so a detached Image — and the decode
|
||||
// behind it — stayed pinned by a live JS reference. A decoded <img> in
|
||||
// the document is evictable under memory pressure; one held by a ref
|
||||
// is not.
|
||||
if (imageRef.current === img) {
|
||||
imageRef.current = null;
|
||||
}
|
||||
img.removeAttribute('src');
|
||||
};
|
||||
}, [imageSrc, useCanvasRendering, drawToCanvas, onLoad]);
|
||||
|
||||
|
||||
@@ -12,9 +12,6 @@ interface ProtectedImageProps extends React.CanvasHTMLAttributes<HTMLCanvasEleme
|
||||
alt: string;
|
||||
protectionLevel?: ProtectionLevel;
|
||||
watermarkText?: string;
|
||||
fragmentGrid?: boolean;
|
||||
gridSize?: number;
|
||||
scrambleFragments?: boolean;
|
||||
invisibleWatermark?: boolean;
|
||||
onProtectionViolation?: (violationType: string) => void;
|
||||
fallbackSrc?: string;
|
||||
@@ -26,9 +23,6 @@ export const ProtectedImage: React.FC<ProtectedImageProps> = ({
|
||||
alt,
|
||||
protectionLevel = 'standard',
|
||||
watermarkText,
|
||||
fragmentGrid = false,
|
||||
gridSize = 4,
|
||||
scrambleFragments = false,
|
||||
invisibleWatermark = false,
|
||||
onProtectionViolation,
|
||||
fallbackSrc,
|
||||
@@ -120,52 +114,6 @@ export const ProtectedImage: React.FC<ProtectedImageProps> = ({
|
||||
ctx.shadowOffsetY = 0;
|
||||
}, []);
|
||||
|
||||
// Fragment and scramble image for maximum protection
|
||||
const renderFragmentedImage = useCallback((
|
||||
ctx: CanvasRenderingContext2D,
|
||||
img: HTMLImageElement,
|
||||
width: number,
|
||||
height: number
|
||||
) => {
|
||||
const fragmentWidth = width / gridSize;
|
||||
const fragmentHeight = height / gridSize;
|
||||
const fragments: Array<{ x: number; y: number; destX: number; destY: number }> = [];
|
||||
|
||||
// Create fragment map
|
||||
for (let row = 0; row < gridSize; row++) {
|
||||
for (let col = 0; col < gridSize; col++) {
|
||||
fragments.push({
|
||||
x: col * fragmentWidth,
|
||||
y: row * fragmentHeight,
|
||||
destX: col * fragmentWidth,
|
||||
destY: row * fragmentHeight,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Scramble fragments if requested
|
||||
if (scrambleFragments) {
|
||||
for (let i = fragments.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
const temp = fragments[i].destX;
|
||||
const tempY = fragments[i].destY;
|
||||
fragments[i].destX = fragments[j].destX;
|
||||
fragments[i].destY = fragments[j].destY;
|
||||
fragments[j].destX = temp;
|
||||
fragments[j].destY = tempY;
|
||||
}
|
||||
}
|
||||
|
||||
// Draw fragments
|
||||
fragments.forEach(fragment => {
|
||||
ctx.drawImage(
|
||||
img,
|
||||
fragment.x, fragment.y, fragmentWidth, fragmentHeight,
|
||||
fragment.destX, fragment.destY, fragmentWidth, fragmentHeight
|
||||
);
|
||||
});
|
||||
}, [gridSize, scrambleFragments]);
|
||||
|
||||
// Main canvas rendering function - wrapped in useCallback to prevent infinite re-renders
|
||||
const renderToCanvas = useCallback(() => {
|
||||
if (!canvasRef.current || !imageRef.current) {
|
||||
@@ -199,14 +147,9 @@ export const ProtectedImage: React.FC<ProtectedImageProps> = ({
|
||||
ctx.globalCompositeOperation = 'source-over'; // Reset composite operation
|
||||
|
||||
try {
|
||||
if (fragmentGrid && (protectionLevel === 'enhanced' || protectionLevel === 'maximum')) {
|
||||
// Render fragmented image
|
||||
renderFragmentedImage(ctx, img, canvas.width, canvas.height);
|
||||
} else {
|
||||
// Render normal image - ensure image is valid before drawing
|
||||
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
// Ensure image is valid before drawing
|
||||
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
// Apply watermarks
|
||||
@@ -242,7 +185,7 @@ export const ProtectedImage: React.FC<ProtectedImageProps> = ({
|
||||
reportViolation('canvas_rendering_error');
|
||||
setError(true);
|
||||
}
|
||||
}, [fragmentGrid, protectionLevel, renderFragmentedImage, watermarkText, invisibleWatermark, applyInvisibleWatermark, applyVisibleWatermark, reportViolation]);
|
||||
}, [protectionLevel, watermarkText, invisibleWatermark, applyInvisibleWatermark, applyVisibleWatermark, reportViolation]);
|
||||
|
||||
// Set up protection event listeners
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Canvas-mode memory release (#1287).
|
||||
*
|
||||
* In canvas mode the component keeps a detached `Image` in `imageRef` so
|
||||
* `drawToCanvas` can read it. The effect cleanup nulled `onload`/`onerror`
|
||||
* but never cleared that ref, so the Image — and the decoded bitmap behind
|
||||
* it — stayed pinned by a live JS reference for the component's lifetime.
|
||||
*
|
||||
* That is not academic at gallery scale. The photo grid is NOT virtualised:
|
||||
* a 546-photo event mounts 546 of these and none ever unmount, so nothing was
|
||||
* ever released. A decoded <img> in the document is evictable under memory
|
||||
* pressure; one held by a ref is not.
|
||||
*/
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
vi.mock('../../../utils/galleryAuthStorage', () => ({
|
||||
getActiveGallerySlug: () => 'demo',
|
||||
getGalleryToken: () => 'token',
|
||||
inferGallerySlugFromLocation: () => 'demo',
|
||||
resolveSlugFromRequestUrl: () => 'demo',
|
||||
}));
|
||||
vi.mock('../../../utils/url', () => ({ buildResourceUrl: (u: string) => `http://localhost${u}` }));
|
||||
|
||||
import { AuthenticatedImage } from '../AuthenticatedImage';
|
||||
|
||||
/** Every Image the component constructs, so the test can inspect them. */
|
||||
const created: HTMLImageElement[] = [];
|
||||
let createObjectURL: ReturnType<typeof vi.fn>;
|
||||
let revokeObjectURL: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
created.length = 0;
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({
|
||||
ok: true,
|
||||
blob: async () => new Blob(['x'], { type: 'image/png' }),
|
||||
})));
|
||||
// Patch the two methods rather than replacing URL — spreading the
|
||||
// constructor loses its prototype and breaks every `new URL(...)`.
|
||||
// Unique per call: the canvas effect keys off `imageSrc`, so a constant
|
||||
// URL would make a src change look like no change at all.
|
||||
let n = 0;
|
||||
createObjectURL = vi.fn(() => `blob:mock-url-${++n}`);
|
||||
revokeObjectURL = vi.fn();
|
||||
URL.createObjectURL = createObjectURL as unknown as typeof URL.createObjectURL;
|
||||
URL.revokeObjectURL = revokeObjectURL as unknown as typeof URL.revokeObjectURL;
|
||||
|
||||
const RealImage = globalThis.Image;
|
||||
vi.stubGlobal('Image', class extends RealImage {
|
||||
constructor() {
|
||||
super();
|
||||
created.push(this as unknown as HTMLImageElement);
|
||||
// jsdom leaves these at 0/false for a blob: src, which makes
|
||||
// drawToCanvas bail before it draws. Present a decoded image so the
|
||||
// draw path is reachable.
|
||||
Object.defineProperty(this, 'complete', { get: () => true });
|
||||
Object.defineProperty(this, 'naturalWidth', { get: () => 10 });
|
||||
Object.defineProperty(this, 'naturalHeight', { get: () => 10 });
|
||||
// jsdom never fires load for a blob: src, so drive it manually.
|
||||
setTimeout(() => this.onload?.(new Event('load')), 0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe('AuthenticatedImage canvas mode', () => {
|
||||
it('releases the decoded image as soon as it is drawn, without waiting for unmount', async () => {
|
||||
// The case this whole change exists for. Every other test here asserts
|
||||
// release on unmount or src change — neither of which happens to a grid
|
||||
// tile, because the grid is not virtualised and the tiles stay mounted
|
||||
// for as long as the gallery is open. Once drawImage has copied the
|
||||
// pixels the source decode is dead weight and must go immediately.
|
||||
const ctx = { drawImage: vi.fn() };
|
||||
const getContext = vi
|
||||
.spyOn(HTMLCanvasElement.prototype, 'getContext')
|
||||
.mockReturnValue(ctx as unknown as CanvasRenderingContext2D);
|
||||
|
||||
render(
|
||||
<AuthenticatedImage src="/api/gallery/demo/thumbnail/1" alt="t" useCanvasRendering />
|
||||
);
|
||||
|
||||
await waitFor(() => expect(created.length).toBeGreaterThan(0));
|
||||
const img = created[0];
|
||||
|
||||
await waitFor(() => expect(ctx.drawImage).toHaveBeenCalled());
|
||||
// Still mounted, still the same src — and already released.
|
||||
await waitFor(() => expect(img.getAttribute('src')).toBeNull());
|
||||
|
||||
getContext.mockRestore();
|
||||
});
|
||||
|
||||
it('releases the decoded image on unmount', async () => {
|
||||
const { unmount } = render(
|
||||
<AuthenticatedImage src="/api/gallery/demo/thumbnail/1" alt="t" useCanvasRendering />
|
||||
);
|
||||
|
||||
await waitFor(() => expect(created.length).toBeGreaterThan(0));
|
||||
const img = created[0];
|
||||
|
||||
unmount();
|
||||
|
||||
// The src is dropped so the browser can reclaim the decode without
|
||||
// waiting for GC, and the handlers are detached.
|
||||
expect(img.getAttribute('src')).toBeNull();
|
||||
expect(img.onload).toBeNull();
|
||||
expect(img.onerror).toBeNull();
|
||||
});
|
||||
|
||||
it('revokes the blob URL on unmount', async () => {
|
||||
const { unmount } = render(
|
||||
<AuthenticatedImage src="/api/gallery/demo/thumbnail/1" alt="t" useCanvasRendering />
|
||||
);
|
||||
|
||||
await waitFor(() => expect(created.length).toBeGreaterThan(0));
|
||||
unmount();
|
||||
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock-url-1');
|
||||
});
|
||||
|
||||
it('releases the previous image when the src changes', async () => {
|
||||
// A recycled tile (a layout reusing a component instance for a different
|
||||
// photo) must not accumulate one pinned decode per photo it has shown.
|
||||
const { rerender } = render(
|
||||
<AuthenticatedImage src="/api/gallery/demo/thumbnail/1" alt="t" useCanvasRendering />
|
||||
);
|
||||
await waitFor(() => expect(created.length).toBe(1));
|
||||
const first = created[0];
|
||||
|
||||
rerender(<AuthenticatedImage src="/api/gallery/demo/thumbnail/2" alt="t" useCanvasRendering />);
|
||||
await waitFor(() => expect(created.length).toBe(2));
|
||||
|
||||
expect(first.getAttribute('src')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -128,25 +128,6 @@ describe('ProtectedImage', () => {
|
||||
expect(mockContext.fillText).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles fragment grid rendering', async () => {
|
||||
render(
|
||||
<ProtectedImage
|
||||
{...defaultProps}
|
||||
fragmentGrid={true}
|
||||
gridSize={4}
|
||||
protectionLevel="enhanced"
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const canvas = screen.getByRole('img', { name: 'Test image' });
|
||||
expect(canvas).toHaveStyle({ opacity: '1' });
|
||||
});
|
||||
|
||||
// Verify multiple drawImage calls for fragments
|
||||
expect(mockContext.drawImage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('blocks interactions in maximum protection mode', async () => {
|
||||
const onViolation = vi.fn();
|
||||
|
||||
@@ -230,25 +211,6 @@ describe('ProtectedImage', () => {
|
||||
expect(mockContext.putImageData).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('scrambles fragments when enabled', async () => {
|
||||
render(
|
||||
<ProtectedImage
|
||||
{...defaultProps}
|
||||
fragmentGrid={true}
|
||||
scrambleFragments={true}
|
||||
protectionLevel="maximum"
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const canvas = screen.getByRole('img', { name: 'Test image' });
|
||||
expect(canvas).toHaveStyle({ opacity: '1' });
|
||||
});
|
||||
|
||||
// Fragment scrambling should result in multiple drawImage calls
|
||||
expect(mockContext.drawImage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('adds random noise in maximum protection', async () => {
|
||||
render(
|
||||
<ProtectedImage
|
||||
|
||||
@@ -41,9 +41,7 @@ export const GalleryFolderTiles: React.FC<GalleryFolderTilesProps> = ({
|
||||
compact = false,
|
||||
slug,
|
||||
protectionLevel,
|
||||
useEnhancedProtection,
|
||||
useCanvasRendering,
|
||||
allowDownloads = true,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -96,12 +94,6 @@ export const GalleryFolderTiles: React.FC<GalleryFolderTilesProps> = ({
|
||||
className="w-full h-full object-cover group-hover:scale-[1.02] transition-transform"
|
||||
isGallery
|
||||
slug={slug}
|
||||
photoId={coverPhoto.id}
|
||||
requiresToken={coverPhoto.requires_token}
|
||||
secureUrlTemplate={coverPhoto.secure_url_template}
|
||||
protectFromDownload={!allowDownloads || useEnhancedProtection}
|
||||
protectionLevel={protectionLevel}
|
||||
useEnhancedProtection={useEnhancedProtection}
|
||||
// Same rule as every other gallery image path: maximum
|
||||
// protection implies canvas rendering even when the separate
|
||||
// toggle is off (its default), otherwise a cover silently
|
||||
|
||||
@@ -43,9 +43,7 @@ export const HeroHeader: React.FC<HeroHeaderProps> = ({
|
||||
heroLogoSize = 'medium',
|
||||
heroLogoPosition = 'top',
|
||||
dividerStyle = 'wave',
|
||||
allowDownloads = true,
|
||||
protectionLevel = 'standard',
|
||||
useEnhancedProtection = false,
|
||||
useCanvasRendering = false,
|
||||
onScrollToContent,
|
||||
heroImageAnchor = 'center'
|
||||
@@ -144,10 +142,6 @@ export const HeroHeader: React.FC<HeroHeaderProps> = ({
|
||||
style={{ objectPosition: heroImageAnchor }}
|
||||
isGallery={true}
|
||||
slug={slug}
|
||||
photoId={heroPhoto.id}
|
||||
protectFromDownload={!allowDownloads || useEnhancedProtection}
|
||||
protectionLevel={protectionLevel}
|
||||
useEnhancedProtection={useEnhancedProtection}
|
||||
useCanvasRendering={useCanvasRendering || protectionLevel === 'maximum'}
|
||||
/>
|
||||
|
||||
|
||||
@@ -144,9 +144,6 @@ export const PeopleSheet: React.FC<PeopleSheetProps> = ({
|
||||
alt=""
|
||||
isGallery
|
||||
slug={slug}
|
||||
photoId={photo.id}
|
||||
requiresToken={photo.requires_token}
|
||||
secureUrlTemplate={photo.secure_url_template}
|
||||
// Crop to the face, exactly as the strip does. Without
|
||||
// this a group photo shows whoever is centred — often
|
||||
// not the person being labelled, and identical for two
|
||||
|
||||
@@ -86,9 +86,6 @@ const PersonAvatar: React.FC<PersonAvatarProps> = ({
|
||||
alt=""
|
||||
isGallery
|
||||
slug={slug}
|
||||
photoId={photo.id}
|
||||
requiresToken={photo.requires_token}
|
||||
secureUrlTemplate={photo.secure_url_template}
|
||||
style={cropStyle || { width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -244,7 +244,6 @@ const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
|
||||
onDownload,
|
||||
allowDownloads = true,
|
||||
protectionLevel = 'standard',
|
||||
useEnhancedProtection = false,
|
||||
useCanvasRendering = false,
|
||||
slug,
|
||||
feedbackEnabled = false
|
||||
@@ -269,18 +268,7 @@ const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
|
||||
loading="lazy"
|
||||
isGallery={true}
|
||||
slug={slug}
|
||||
photoId={photo.id}
|
||||
requiresToken={photo.requires_token}
|
||||
secureUrlTemplate={photo.secure_url_template}
|
||||
protectFromDownload={!allowDownloads || useEnhancedProtection}
|
||||
protectionLevel={protectionLevel}
|
||||
useEnhancedProtection={useEnhancedProtection}
|
||||
useCanvasRendering={useCanvasRendering || protectionLevel === 'maximum'}
|
||||
fragmentGrid={protectionLevel === 'enhanced' || protectionLevel === 'maximum'}
|
||||
blockKeyboardShortcuts={useEnhancedProtection}
|
||||
detectPrintScreen={useEnhancedProtection}
|
||||
detectDevTools={protectionLevel === 'maximum'}
|
||||
watermarkText={useEnhancedProtection ? 'Protected' : undefined}
|
||||
onProtectionViolation={(violationType) => {
|
||||
// Track analytics
|
||||
if (typeof window !== 'undefined' && (window as any).umami) {
|
||||
|
||||
@@ -1159,9 +1159,6 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
draggable={false}
|
||||
isGallery={true}
|
||||
slug={slug}
|
||||
photoId={photo.id}
|
||||
requiresToken={photo.requires_token}
|
||||
secureUrlTemplate={photo.secure_url_template}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -1189,21 +1186,9 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
transition: isDragging ? 'none' : 'transform 0.2s',
|
||||
}}
|
||||
draggable={false}
|
||||
useWatermark={useEnhancedProtection}
|
||||
watermarkText={useEnhancedProtection ? `${photo.filename} - Protected` : undefined}
|
||||
isGallery={true}
|
||||
slug={slug}
|
||||
photoId={photo.id}
|
||||
requiresToken={photo.requires_token}
|
||||
secureUrlTemplate={photo.secure_url_template}
|
||||
protectFromDownload={!allowDownloads || useEnhancedProtection}
|
||||
protectionLevel={protectionLevel}
|
||||
useEnhancedProtection={useEnhancedProtection}
|
||||
useCanvasRendering={useCanvasRendering || protectionLevel === 'maximum'}
|
||||
fragmentGrid={protectionLevel === 'enhanced' || protectionLevel === 'maximum'}
|
||||
blockKeyboardShortcuts={useEnhancedProtection}
|
||||
detectPrintScreen={useEnhancedProtection}
|
||||
detectDevTools={protectionLevel === 'enhanced' || protectionLevel === 'maximum'}
|
||||
onProtectionViolation={(violationType) => {
|
||||
console.warn(`Protection violation in lightbox for photo ${photo.id}: ${violationType}`);
|
||||
|
||||
|
||||
@@ -89,7 +89,6 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
alt={currentPhoto.filename}
|
||||
className="w-full h-full object-contain"
|
||||
isGallery={true}
|
||||
protectFromDownload={!allowDownloads}
|
||||
/>
|
||||
|
||||
{/* Colour labels for the photo in view (#1189). Bottom-left because it
|
||||
@@ -267,7 +266,6 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
isGallery={true}
|
||||
protectFromDownload={!allowDownloads}
|
||||
/>
|
||||
{/* The strip is the only place this layout shows more than one
|
||||
photo at a time, so it is the only place a label can
|
||||
|
||||
@@ -68,9 +68,7 @@ const PhotoCard: React.FC<PhotoCardProps> = ({
|
||||
isSelectionMode,
|
||||
isLiked,
|
||||
slug,
|
||||
allowDownloads = true,
|
||||
protectionLevel = 'standard',
|
||||
useEnhancedProtection = false,
|
||||
useCanvasRendering = false,
|
||||
feedbackEnabled = false,
|
||||
allowLikes = false,
|
||||
@@ -124,12 +122,6 @@ const PhotoCard: React.FC<PhotoCardProps> = ({
|
||||
loading="lazy"
|
||||
isGallery={true}
|
||||
slug={slug}
|
||||
photoId={photo.id}
|
||||
requiresToken={photo.requires_token}
|
||||
secureUrlTemplate={photo.secure_url_template}
|
||||
protectFromDownload={!allowDownloads || useEnhancedProtection}
|
||||
protectionLevel={protectionLevel}
|
||||
useEnhancedProtection={useEnhancedProtection}
|
||||
useCanvasRendering={useCanvasRendering || protectionLevel === 'maximum'}
|
||||
/>
|
||||
|
||||
|
||||
@@ -48,7 +48,6 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
||||
allowDownloads = true,
|
||||
slug,
|
||||
protectionLevel = 'standard',
|
||||
useEnhancedProtection = false,
|
||||
useCanvasRendering = false,
|
||||
feedbackEnabled = false,
|
||||
feedbackOptions,
|
||||
@@ -84,6 +83,24 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
||||
onToggleSelect={onToggleSelect}
|
||||
className={`photo-card relative group cursor-pointer aspect-square ${animationClass}`}
|
||||
lazy
|
||||
/*
|
||||
* Pre-load band (#1287). Grid was the only lazy layout passing no
|
||||
* `inViewRootMargin`, so PhotoCard ran the observer at the
|
||||
* IntersectionObserver default of 0px with threshold 0.1 — a tile could
|
||||
* not begin loading until a tenth of it was already on screen. The
|
||||
* gallery owner's description of the symptom is that exact shape:
|
||||
* spinning the wheel outran loading by ~50 images, then it caught up.
|
||||
*
|
||||
* Viewport-relative rather than a fixed 100px like Justified: a phone
|
||||
* and a 4K desktop scroll past very different amounts of grid per
|
||||
* gesture, and a band tuned to one is wrong for the other.
|
||||
*
|
||||
* `%`, not `vh` — rootMargin only accepts px and percentages, and an
|
||||
* IntersectionObserver constructed with a vh value throws. A percentage
|
||||
* resolves against the root's own box, so 100% is one viewport height
|
||||
* of lead in each direction, which is what vh would have meant.
|
||||
*/
|
||||
inViewRootMargin="100% 0px"
|
||||
fadeInWhenVisible={animationType === 'fade'}
|
||||
skeletonClassName="skeleton aspect-square w-full rounded-lg"
|
||||
imageProps={{
|
||||
@@ -93,18 +110,7 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
||||
loading: 'lazy',
|
||||
isGallery: true,
|
||||
slug,
|
||||
photoId: photo.id,
|
||||
requiresToken: photo.requires_token,
|
||||
secureUrlTemplate: photo.secure_url_template,
|
||||
protectFromDownload: !allowDownloads || useEnhancedProtection,
|
||||
protectionLevel,
|
||||
useEnhancedProtection,
|
||||
useCanvasRendering: useCanvasRendering || protectionLevel === 'maximum',
|
||||
fragmentGrid: protectionLevel === 'enhanced' || protectionLevel === 'maximum',
|
||||
blockKeyboardShortcuts: useEnhancedProtection,
|
||||
detectPrintScreen: useEnhancedProtection,
|
||||
detectDevTools: protectionLevel === 'maximum',
|
||||
watermarkText: useEnhancedProtection ? 'Protected' : undefined,
|
||||
onProtectionViolation: (violationType: string) => {
|
||||
console.warn(`Protection violation on grid photo ${photo.id}: ${violationType}`);
|
||||
},
|
||||
|
||||
@@ -70,7 +70,6 @@ const JustifiedPhoto: React.FC<JustifiedPhotoProps> = ({
|
||||
allowDownloads = true,
|
||||
slug,
|
||||
protectionLevel = 'standard',
|
||||
useEnhancedProtection = false,
|
||||
useCanvasRendering = false,
|
||||
feedbackEnabled = false,
|
||||
feedbackOptions,
|
||||
@@ -134,18 +133,7 @@ const JustifiedPhoto: React.FC<JustifiedPhotoProps> = ({
|
||||
loading: 'lazy',
|
||||
isGallery: true,
|
||||
slug,
|
||||
photoId: photo.id,
|
||||
requiresToken: photo.requires_token,
|
||||
secureUrlTemplate: photo.secure_url_template,
|
||||
protectFromDownload: !allowDownloads || useEnhancedProtection,
|
||||
protectionLevel,
|
||||
useEnhancedProtection,
|
||||
useCanvasRendering: useCanvasRendering || protectionLevel === 'maximum',
|
||||
fragmentGrid: protectionLevel === 'enhanced' || protectionLevel === 'maximum',
|
||||
blockKeyboardShortcuts: useEnhancedProtection,
|
||||
detectPrintScreen: useEnhancedProtection,
|
||||
detectDevTools: protectionLevel === 'maximum',
|
||||
watermarkText: useEnhancedProtection ? 'Protected' : undefined,
|
||||
onProtectionViolation: (violationType: string) => {
|
||||
console.warn(`Protection violation on justified photo ${photo.id}: ${violationType}`);
|
||||
},
|
||||
@@ -411,10 +399,6 @@ export const JustifiedGalleryLayout: React.FC<JustifiedGalleryLayoutProps> = ({
|
||||
className="w-full h-full object-cover"
|
||||
isGallery={true}
|
||||
slug={slug}
|
||||
photoId={heroPhoto.id}
|
||||
protectFromDownload={!allowDownloads || useEnhancedProtection}
|
||||
protectionLevel={protectionLevel}
|
||||
useEnhancedProtection={useEnhancedProtection}
|
||||
useCanvasRendering={useCanvasRendering || protectionLevel === 'maximum'}
|
||||
/>
|
||||
|
||||
|
||||
@@ -120,7 +120,6 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
|
||||
className: 'w-full h-full object-cover rounded-lg',
|
||||
loading: 'lazy',
|
||||
isGallery: true,
|
||||
protectFromDownload: !allowDownloads,
|
||||
}}
|
||||
overlayBaseClassName="absolute inset-0 bg-black/40 transition-opacity duration-200 rounded-lg flex items-center justify-center gap-2"
|
||||
allowDownloads={allowDownloads}
|
||||
@@ -376,7 +375,6 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
className: 'w-full h-full object-cover rounded-lg transition-transform duration-300 group-hover:scale-[1.02]',
|
||||
loading: 'lazy',
|
||||
isGallery: true,
|
||||
protectFromDownload: !allowDownloads,
|
||||
}}
|
||||
overlayBaseClassName="absolute inset-0 bg-black/40 transition-opacity duration-200 rounded-lg flex items-center justify-center gap-2"
|
||||
actionVariant="dark"
|
||||
@@ -434,7 +432,6 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
className: 'w-full h-full object-cover rounded-lg transition-transform duration-300 group-hover:scale-[1.02]',
|
||||
loading: 'lazy',
|
||||
isGallery: true,
|
||||
protectFromDownload: !allowDownloads,
|
||||
}}
|
||||
overlayBaseClassName="absolute inset-0 bg-black/40 transition-opacity duration-200 rounded-lg flex items-center justify-center gap-2"
|
||||
actionVariant="dark"
|
||||
@@ -501,7 +498,6 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
className: 'w-full h-full object-cover transition-transform duration-300 group-hover:scale-105',
|
||||
loading: 'lazy',
|
||||
isGallery: true,
|
||||
protectFromDownload: !allowDownloads,
|
||||
}}
|
||||
overlayBaseClassName="absolute inset-0 bg-black/40 transition-opacity duration-200 flex items-center justify-center gap-2"
|
||||
actionVariant="dark"
|
||||
|
||||
@@ -83,7 +83,6 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
|
||||
className: 'w-full h-full object-cover transition-transform duration-300 group-hover:scale-105',
|
||||
loading: 'lazy',
|
||||
isGallery: true,
|
||||
protectFromDownload: !allowDownloads,
|
||||
}}
|
||||
overlayBaseClassName="absolute inset-0 bg-black/40 transition-opacity duration-200 flex items-center justify-center gap-2"
|
||||
allowDownloads={allowDownloads}
|
||||
|
||||
@@ -130,7 +130,6 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
className: 'w-full h-full object-cover rounded-lg',
|
||||
loading: 'lazy',
|
||||
isGallery: true,
|
||||
protectFromDownload: !allowDownloads,
|
||||
}}
|
||||
overlayBaseClassName="absolute inset-0 bg-black/40 transition-opacity duration-200 rounded-lg flex items-center justify-center gap-2"
|
||||
allowDownloads={allowDownloads}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Grid's lazy-loading pre-load band (#1287).
|
||||
*
|
||||
* Grid was the only layout passing `lazy` without an `inViewRootMargin`, so
|
||||
* PhotoCard ran its observer at the IntersectionObserver default of `0px`
|
||||
* with `threshold: 0.1` — a tile could not begin loading until a tenth of it
|
||||
* was already on screen. The gallery owner described exactly that: spinning
|
||||
* the scroll wheel outran loading by ~50 images before it caught up.
|
||||
*
|
||||
* The unit matters as much as the value. `rootMargin` accepts only px and
|
||||
* percentages; an IntersectionObserver constructed with a `vh` value throws
|
||||
* SyntaxError, which would have broken every Grid gallery outright. Verified
|
||||
* in Chrome:
|
||||
*
|
||||
* '100% 0px' → accepted
|
||||
* '100px 0px' → accepted
|
||||
* '100vh 0px' → SyntaxError: rootMargin must be specified in pixels or percent
|
||||
*
|
||||
* jsdom has no IntersectionObserver, so this asserts against the source
|
||||
* rather than constructing one.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
|
||||
const layouts = resolve(__dirname, '..');
|
||||
const read = (f: string) => readFileSync(resolve(layouts, f), 'utf8');
|
||||
|
||||
/** Only px and % are legal rootMargin units. */
|
||||
const LEGAL_ROOT_MARGIN = /^(-?\d+(px|%)|0)(\s+(-?\d+(px|%)|0)){0,3}$/;
|
||||
|
||||
describe('grid lazy pre-load band', () => {
|
||||
it('Grid passes an inViewRootMargin', () => {
|
||||
expect(read('GridGalleryLayout.tsx')).toMatch(/inViewRootMargin=/);
|
||||
});
|
||||
|
||||
it('every inViewRootMargin in every layout uses a legal unit', () => {
|
||||
// A vh value throws at IntersectionObserver construction and takes the
|
||||
// whole gallery down with it, so this guards the unit, not just presence.
|
||||
for (const file of ['GridGalleryLayout.tsx', 'JustifiedGalleryLayout.tsx']) {
|
||||
const src = read(file);
|
||||
for (const [, value] of src.matchAll(/inViewRootMargin="([^"]+)"/g)) {
|
||||
expect(value, `${file}: "${value}"`).toMatch(LEGAL_ROOT_MARGIN);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('every layout that lazy-renders also declares a pre-load band', () => {
|
||||
// The defect was Grid being lazy with no margin. Any future layout that
|
||||
// opts into `lazy` and forgets the margin reintroduces it.
|
||||
for (const file of ['GridGalleryLayout.tsx', 'JustifiedGalleryLayout.tsx']) {
|
||||
const src = read(file);
|
||||
const isLazy = /^\s*lazy\s*$/m.test(src) || /\slazy=\{?true/.test(src);
|
||||
if (!isLazy) continue;
|
||||
expect(src, `${file} is lazy but declares no inViewRootMargin`)
|
||||
.toMatch(/inViewRootMargin=/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -22,9 +22,7 @@ export const StoryHero: React.FC<StoryHeroProps> = ({
|
||||
stats,
|
||||
photo,
|
||||
slug,
|
||||
allowDownloads = true,
|
||||
protectionLevel = 'standard',
|
||||
useEnhancedProtection = false,
|
||||
useCanvasRendering = false
|
||||
}) => {
|
||||
const formattedDate = date
|
||||
@@ -51,12 +49,6 @@ export const StoryHero: React.FC<StoryHeroProps> = ({
|
||||
className="w-full h-full object-cover"
|
||||
isGallery={true}
|
||||
slug={slug}
|
||||
photoId={photo.id}
|
||||
requiresToken={photo.requires_token}
|
||||
secureUrlTemplate={photo.secure_url_template}
|
||||
protectFromDownload={!allowDownloads || useEnhancedProtection}
|
||||
protectionLevel={protectionLevel}
|
||||
useEnhancedProtection={useEnhancedProtection}
|
||||
useCanvasRendering={useCanvasRendering || protectionLevel === 'maximum'}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -28,9 +28,7 @@ export const StoryPhotoCard: React.FC<StoryPhotoCardProps> = ({
|
||||
onToggleFavorite,
|
||||
onClick,
|
||||
slug,
|
||||
allowDownloads = true,
|
||||
protectionLevel = 'standard',
|
||||
useEnhancedProtection = false,
|
||||
useCanvasRendering = false,
|
||||
featured = false,
|
||||
galleryId: _galleryId
|
||||
@@ -105,12 +103,6 @@ export const StoryPhotoCard: React.FC<StoryPhotoCardProps> = ({
|
||||
}`}
|
||||
isGallery={true}
|
||||
slug={slug}
|
||||
photoId={photo.id}
|
||||
requiresToken={photo.requires_token}
|
||||
secureUrlTemplate={photo.secure_url_template}
|
||||
protectFromDownload={!allowDownloads || useEnhancedProtection}
|
||||
protectionLevel={protectionLevel}
|
||||
useEnhancedProtection={useEnhancedProtection}
|
||||
useCanvasRendering={useCanvasRendering || protectionLevel === 'maximum'}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -15,7 +15,6 @@ interface ImageSecuritySettings {
|
||||
max_image_requests_per_hour: number;
|
||||
suspicious_activity_threshold: number;
|
||||
enable_canvas_rendering: boolean;
|
||||
default_fragmentation_level: number;
|
||||
security_monitoring_enabled: boolean;
|
||||
block_suspicious_ips: boolean;
|
||||
log_security_events_to_db: boolean;
|
||||
@@ -31,7 +30,6 @@ const defaultSettings: ImageSecuritySettings = {
|
||||
max_image_requests_per_hour: 500,
|
||||
suspicious_activity_threshold: 10,
|
||||
enable_canvas_rendering: false,
|
||||
default_fragmentation_level: 3,
|
||||
security_monitoring_enabled: true,
|
||||
block_suspicious_ips: true,
|
||||
log_security_events_to_db: true,
|
||||
@@ -161,21 +159,6 @@ export const ImageSecurityTab: React.FC = () => {
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">{t('settings.imageSecurity.imageQualityHelp', '1-100, higher = better quality')}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('settings.imageSecurity.fragmentationLevel', 'Fragmentation Level')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="10"
|
||||
value={settings.default_fragmentation_level}
|
||||
onChange={(e) => handleChange('default_fragmentation_level', parseInt(e.target.value) || 3)}
|
||||
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">{t('settings.imageSecurity.fragmentationLevelHelp', '1-10, higher = more protection')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 pt-2">
|
||||
|
||||
@@ -20,7 +20,6 @@ interface UseImageProtectionOptions {
|
||||
blockKeyboardShortcuts?: boolean;
|
||||
detectPrintScreen?: boolean;
|
||||
watermarkText?: string;
|
||||
fragmentGrid?: boolean;
|
||||
}
|
||||
|
||||
export const useImageProtection = (options: UseImageProtectionOptions) => {
|
||||
|
||||
@@ -1929,7 +1929,6 @@
|
||||
"defaultProtectionHelp": "Diese Einstellungen gelten für alle neuen Veranstaltungen. Einzelne Veranstaltungen können diese Standardwerte überschreiben.",
|
||||
"protectionLevel": "Standard-Schutzstufe",
|
||||
"imageQuality": "Standard-Bildqualität",
|
||||
"fragmentationLevel": "Fragmentierungsstufe",
|
||||
"enableDevtools": "DevTools-Erkennung standardmäßig aktivieren",
|
||||
"enableCanvas": "Canvas-Rendering standardmäßig aktivieren (erweiterter Schutz)",
|
||||
"rateLimiting": "Ratenbegrenzung",
|
||||
@@ -1946,7 +1945,6 @@
|
||||
"infoTitle": "Über Bildschutz",
|
||||
"infoText": "Diese Schutzfunktionen helfen, gelegentliches Herunterladen und Kopieren zu verhindern, können aber nicht alle Methoden blockieren. Entschlossene Benutzer finden möglicherweise trotzdem Wege, Bilder zu erfassen. Erwägen Sie die Verwendung von Wasserzeichen und rechtlichen Vereinbarungen für umfassenden Schutz.",
|
||||
"imageQualityHelp": "1–100, höher = bessere Qualität",
|
||||
"fragmentationLevelHelp": "1–10, höher = mehr Schutz",
|
||||
"suspiciousActivityThresholdHelp": "Verstöße, bevor als verdächtig markiert wird",
|
||||
"autoBlockThresholdHelp": "Verstöße, bevor die IP automatisch gesperrt wird"
|
||||
},
|
||||
@@ -7019,7 +7017,7 @@
|
||||
"showCss": "Eigenes CSS (optional)",
|
||||
"hideCss": "Eigenes CSS ausblenden",
|
||||
"cssHelp": "Viele E-Mail-Programme entfernen einen <style>-Block — halten Sie wichtige Gestaltung in Inline-Attributen. Externe Bilder und @import werden entfernt.",
|
||||
"rateHelp": "Sendungen werden gestreckt, damit Ihr Mailanbieter nicht drosselt. Prüfen Sie das Stundenlimit Ihres Anbieters, bevor Sie diesen Wert erhöhen.",
|
||||
"rateHelp": "Sendungen werden zeitlich verteilt, damit Ihr Mailanbieter Sie nicht drosselt und ein plötzlicher Schwall nicht wie Spam aussieht. Prüfen Sie das Stundenlimit Ihres Anbieters, bevor Sie diesen Wert erhöhen.",
|
||||
"recipientCount": "{{count}} Empfänger",
|
||||
"skippedOptOut": "{{count}} übersprungen (abgemeldet)",
|
||||
"saveToRefresh": "Speichern, um diese Zahl zu aktualisieren.",
|
||||
@@ -7050,6 +7048,12 @@
|
||||
"testFailed": "Test-E-Mail konnte nicht gesendet werden.",
|
||||
"queueFailed": "Kampagne konnte nicht eingereiht werden.",
|
||||
"cancelFailed": "Kampagne konnte nicht abgebrochen werden.",
|
||||
"previewEmpty": "Aktualisieren Sie die Vorschau, um die E-Mail so zu sehen, wie ein Kunde sie erhält."
|
||||
"previewEmpty": "Aktualisieren Sie die Vorschau, um die E-Mail so zu sehen, wie ein Kunde sie erhält.",
|
||||
"largeSend": {
|
||||
"title": "Großer Versand — prüfen Sie zuerst Ihre Versandreputation",
|
||||
"body": "{{count}} Personen auf einmal von einer Domain anzuschreiben, die sonst nur Transaktionsmails versendet, lässt Spamfilter aufmerksam werden. Anbieter können den gesamten Versand drosseln, als Spam einstufen oder blockieren — und ein schlechter Lauf beeinträchtigt auch die Zustellung Ihrer Galerie-E-Mails.",
|
||||
"advice": "Stellen Sie sicher, dass SPF, DKIM und DMARC für Ihre Versanddomain eingerichtet sind, senden Sie sich zuerst einen Test, und teilen Sie eine erste Kampagne nach Möglichkeit in mehrere kleinere Sendungen auf.",
|
||||
"duration": "Bei {{rate}}/Minute dauert dies etwa {{minutes}} Minuten. Die Versand-Warteschlange wird geteilt: Während der Kampagne können andere E-Mails — Galerie-Einladungen, Passwort-Zurücksetzungen — dahinter verzögert werden."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1492,7 +1492,6 @@
|
||||
"defaultProtectionHelp": "These settings apply to all new events. Individual events can override these defaults.",
|
||||
"protectionLevel": "Default Protection Level",
|
||||
"imageQuality": "Default Image Quality",
|
||||
"fragmentationLevel": "Fragmentation Level",
|
||||
"enableDevtools": "Enable DevTools detection by default",
|
||||
"enableCanvas": "Enable canvas rendering by default (advanced protection)",
|
||||
"rateLimiting": "Rate Limiting",
|
||||
@@ -1509,7 +1508,6 @@
|
||||
"infoTitle": "About Image Protection",
|
||||
"infoText": "These protection features help prevent casual downloading and copying but cannot block all methods. Determined users may still find ways to capture images. Consider using watermarks and legal agreements for comprehensive protection.",
|
||||
"imageQualityHelp": "1-100, higher = better quality",
|
||||
"fragmentationLevelHelp": "1-10, higher = more protection",
|
||||
"suspiciousActivityThresholdHelp": "Violations before flagging as suspicious",
|
||||
"autoBlockThresholdHelp": "Violations before auto-blocking IP"
|
||||
},
|
||||
@@ -7018,7 +7016,7 @@
|
||||
"showCss": "Custom CSS (optional)",
|
||||
"hideCss": "Hide custom CSS",
|
||||
"cssHelp": "Many email clients drop a <style> block — keep the important styling on inline attributes. Remote images and @import are stripped.",
|
||||
"rateHelp": "Sends are spread out so your mail provider does not rate-limit you. Check your provider's hourly cap before raising this.",
|
||||
"rateHelp": "Sends are spread out so your mail provider does not rate-limit you, and so a sudden burst does not look like spam. Check your provider's hourly cap before raising this.",
|
||||
"recipientCount": "{{count}} recipients",
|
||||
"skippedOptOut": "{{count}} skipped (opted out)",
|
||||
"saveToRefresh": "Save to refresh this count.",
|
||||
@@ -7049,6 +7047,12 @@
|
||||
"testFailed": "Could not send the test email.",
|
||||
"queueFailed": "Could not queue the campaign.",
|
||||
"cancelFailed": "Could not cancel the campaign.",
|
||||
"previewEmpty": "Refresh the preview to see the email as a customer will."
|
||||
"previewEmpty": "Refresh the preview to see the email as a customer will.",
|
||||
"largeSend": {
|
||||
"title": "Large send — check your sending reputation first",
|
||||
"body": "Mailing {{count}} people at once from a domain that usually sends only transactional email is what makes spam filters take notice. Providers may throttle, junk or block the whole batch, and a bad run damages delivery of your gallery emails too.",
|
||||
"advice": "Confirm SPF, DKIM and DMARC are set up for your sending domain, send yourself a test first, and consider splitting a first campaign across several smaller sends.",
|
||||
"duration": "At {{rate}}/minute this takes about {{minutes}} minutes. The send queue is shared, so while it runs other email — gallery invitations, password resets — can be delayed behind it."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -941,7 +941,6 @@
|
||||
"defaultProtectionHelp": "Estos ajustes se aplican a todos los nuevos eventos. Los eventos individuales pueden anular estos valores por defecto.",
|
||||
"protectionLevel": "Nivel de protección por defecto",
|
||||
"imageQuality": "Calidad de imagen por defecto",
|
||||
"fragmentationLevel": "Nivel de fragmentación",
|
||||
"enableDevtools": "Habilitar detección de DevTools por defecto",
|
||||
"enableCanvas": "Habilitar renderizado con canvas por defecto (protección avanzada)",
|
||||
"rateLimiting": "Limitación de velocidad",
|
||||
|
||||
@@ -911,7 +911,6 @@
|
||||
"defaultProtectionHelp": "Ces paramètres s'appliquent à tous les nouveaux événements. Les événements individuels peuvent remplacer ces valeurs par défaut.",
|
||||
"protectionLevel": "Niveau de protection par défaut",
|
||||
"imageQuality": "Qualité d'image par défaut",
|
||||
"fragmentationLevel": "Niveau de fragmentation",
|
||||
"enableDevtools": "Activer la détection des outils DevTools par défaut",
|
||||
"enableCanvas": "Activer le rendu canvas par défaut (protection avancée)",
|
||||
"rateLimiting": "Limitation de débit",
|
||||
|
||||
@@ -907,7 +907,6 @@
|
||||
"defaultProtectionHelp": "Deze instellingen zijn van toepassing op alle nieuwe evenementen. Individuele evenementen kunnen deze standaardwaarden overschrijven.",
|
||||
"protectionLevel": "Standaard beveiligingsniveau",
|
||||
"imageQuality": "Standaard beeldkwaliteit",
|
||||
"fragmentationLevel": "Fragmentatieniveau",
|
||||
"enableDevtools": "Standaard DevTools-detectie inschakelen",
|
||||
"enableCanvas": "Standaard canvas-rendering inschakelen (geavanceerde beveiliging)",
|
||||
"rateLimiting": "Snelheidsbeperking",
|
||||
|
||||
@@ -924,7 +924,6 @@
|
||||
"defaultProtectionHelp": "Aplicado a novos eventos por padrão. Podem ser substituídas individualmente.",
|
||||
"protectionLevel": "Nível de Proteção Padrão",
|
||||
"imageQuality": "Qualidade de Imagem Padrão",
|
||||
"fragmentationLevel": "Nível de Fragmentação",
|
||||
"enableDevtools": "Ativar detecção de DevTools por padrão",
|
||||
"enableCanvas": "Ativar renderização em Canvas por padrão",
|
||||
"rateLimiting": "Limite de Requisições",
|
||||
|
||||
@@ -930,7 +930,6 @@
|
||||
"defaultProtectionHelp": "Эти настройки применяются ко всем новым событиям. Отдельные события могут их переопределить.",
|
||||
"protectionLevel": "Уровень защиты по умолчанию",
|
||||
"imageQuality": "Качество изображений по умолчанию",
|
||||
"fragmentationLevel": "Уровень фрагментации",
|
||||
"enableDevtools": "Включить обнаружение DevTools по умолчанию",
|
||||
"enableCanvas": "Включить рендеринг Canvas по умолчанию (расширенная защита)",
|
||||
"rateLimiting": "Ограничение скорости",
|
||||
|
||||
@@ -911,7 +911,6 @@
|
||||
"defaultProtectionHelp": "Te nastavitve veljajo za vse nove dogodke. Posamezni dogodki lahko te privzete nastavitve prepišejo.",
|
||||
"protectionLevel": "Privzeta raven zaščite",
|
||||
"imageQuality": "Privzeta kakovost slike",
|
||||
"fragmentationLevel": "Raven fragmentacije",
|
||||
"enableDevtools": "Privzeto omogoči zaznavanje DevTools",
|
||||
"enableCanvas": "Privzeto omogoči izris prek canvas (napredna zaščita)",
|
||||
"rateLimiting": "Omejevanje hitrosti",
|
||||
|
||||
@@ -16,7 +16,7 @@ import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Save, Send, TestTube2, Users, Eye, ArrowLeft } from 'lucide-react';
|
||||
import { Save, Send, TestTube2, Users, Eye, ArrowLeft, AlertTriangle } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import { Button, Card, Input, Loading, useConfirm } from '../../../components/common';
|
||||
@@ -27,6 +27,25 @@ import {
|
||||
import { customerAdminService } from '../../../services/customerAdmin.service';
|
||||
import { usePermissions } from '../../../contexts/PermissionsContext';
|
||||
|
||||
/**
|
||||
* Recipient count above which the composer warns about deliverability.
|
||||
*
|
||||
* Not a provider limit — the queue's own pacing handles rate. This is about
|
||||
* reputation: what trips spam filtering is a domain that normally sends a
|
||||
* trickle of transactional mail suddenly emitting hundreds of near-identical
|
||||
* messages. 50 is deliberately conservative, because the operators who most
|
||||
* need the warning are the ones sending their first campaign.
|
||||
*/
|
||||
const LARGE_SEND_THRESHOLD = 50;
|
||||
|
||||
/**
|
||||
* Queue throughput ceiling, mirroring newsletterService.clampRate. The server
|
||||
* clamps the stored rate to this, so the estimate has to clamp identically or
|
||||
* it would promise a speed the queue cannot deliver.
|
||||
*/
|
||||
const MIN_RATE_PER_MINUTE = 1;
|
||||
const MAX_RATE_PER_MINUTE = 10;
|
||||
|
||||
/** Variables the server substitutes per recipient. */
|
||||
const VARIABLES = [
|
||||
'customer_name', 'first_name', 'last_name', 'salutation',
|
||||
@@ -170,6 +189,21 @@ export const NewsletterComposerPage: React.FC = () => {
|
||||
// A campaign with no subject, no body or nobody to send to must not be
|
||||
// sendable — the button is the last place to catch that before 2 000
|
||||
// people get a blank email.
|
||||
// The rate the send will actually use: queueing persists the draft first,
|
||||
// so an edited rate is the one that takes effect. `estimatedMinutes` from
|
||||
// the resolution is computed from the SAVED rate, so pairing the two showed
|
||||
// a contradiction after any unsaved edit — 120 recipients switched from
|
||||
// 10/min to 1/min still claimed 12 minutes instead of 120. Recomputed here
|
||||
// with the server's own formula (adminNewsletters.js: ceil(count / rate)).
|
||||
const effectiveRate = Math.min(
|
||||
MAX_RATE_PER_MINUTE,
|
||||
Math.max(MIN_RATE_PER_MINUTE, Number(draft?.sendRatePerMinute) || MAX_RATE_PER_MINUTE)
|
||||
);
|
||||
const estimatedMinutes = Math.max(
|
||||
1,
|
||||
Math.ceil((resolution?.recipientCount ?? 0) / effectiveRate)
|
||||
);
|
||||
|
||||
const canQueue = useMemo(() => Boolean(
|
||||
draft
|
||||
&& draft.status === 'draft'
|
||||
@@ -378,7 +412,9 @@ export const NewsletterComposerPage: React.FC = () => {
|
||||
/>
|
||||
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('newsletters.rateHelp',
|
||||
'Sends are spread out so your mail provider does not rate-limit you. Check your provider\'s hourly cap before raising this.')}
|
||||
'Sends are spread out so your mail provider does not rate-limit you, and so a '
|
||||
+ 'sudden burst does not look like spam. Check your provider\'s hourly cap '
|
||||
+ 'before raising this.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -404,6 +440,44 @@ export const NewsletterComposerPage: React.FC = () => {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{(resolution?.recipientCount ?? 0) >= LARGE_SEND_THRESHOLD && (
|
||||
<div
|
||||
data-testid="large-send-warning"
|
||||
className="rounded-md border border-amber-300 dark:border-amber-700/60 bg-amber-50 dark:bg-amber-900/20 p-3"
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-600 dark:text-amber-500 shrink-0 mt-0.5" />
|
||||
<div className="text-xs text-amber-900 dark:text-amber-200 space-y-1">
|
||||
<p className="font-medium">
|
||||
{t('newsletters.largeSend.title',
|
||||
'Large send — check your sending reputation first')}
|
||||
</p>
|
||||
<p>
|
||||
{t('newsletters.largeSend.body',
|
||||
'Mailing {{count}} people at once from a domain that usually sends '
|
||||
+ 'only transactional email is what makes spam filters take notice. '
|
||||
+ 'Providers may throttle, junk or block the whole batch, and a bad '
|
||||
+ 'run damages delivery of your gallery emails too.',
|
||||
{ count: resolution?.recipientCount ?? 0 })}
|
||||
</p>
|
||||
<p>
|
||||
{t('newsletters.largeSend.advice',
|
||||
'Confirm SPF, DKIM and DMARC are set up for your sending domain, '
|
||||
+ 'send yourself a test first, and consider splitting a first '
|
||||
+ 'campaign across several smaller sends.')}
|
||||
</p>
|
||||
<p>
|
||||
{t('newsletters.largeSend.duration',
|
||||
'At {{rate}}/minute this takes about {{minutes}} minutes. The send '
|
||||
+ 'queue is shared, so while it runs other email — gallery '
|
||||
+ 'invitations, password resets — can be delayed behind it.',
|
||||
{ rate: effectiveRate, minutes: estimatedMinutes })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={queueCampaign}
|
||||
disabled={!canQueue}
|
||||
|
||||
@@ -151,6 +151,52 @@ describe('newsletter composer', () => {
|
||||
expect(summary).toHaveTextContent('3 skipped (opted out)');
|
||||
});
|
||||
|
||||
it('warns about deliverability once the send is large', async () => {
|
||||
// Spam filtering reacts to a domain's volume, not to the queue's pacing,
|
||||
// so the throttle alone is not something to reassure the operator with.
|
||||
resolution = { ...resolution, recipientCount: 120, estimatedMinutes: 12 };
|
||||
renderComposer();
|
||||
await screen.findByTestId('recipient-summary');
|
||||
|
||||
const warning = await screen.findByTestId('large-send-warning');
|
||||
expect(warning).toHaveTextContent(/sending reputation/i);
|
||||
expect(warning).toHaveTextContent(/spam filters/i);
|
||||
expect(warning).toHaveTextContent(/SPF, DKIM and DMARC/i);
|
||||
// The operator is told what it costs: duration, and what may wait behind
|
||||
// it. 120 recipients at the fixture's 20/min clamps to the queue's real
|
||||
// ceiling of 10/min, so the honest estimate is 12 minutes.
|
||||
expect(warning).toHaveTextContent(/12 minutes/);
|
||||
expect(warning).toHaveTextContent(/can be delayed behind it/i);
|
||||
});
|
||||
|
||||
it('recomputes the duration when the rate is edited, before saving', async () => {
|
||||
// The estimate the server returns is computed from the SAVED rate, while
|
||||
// the input shows the edited one. Pairing them meant the warning could
|
||||
// claim 12 minutes for a send that would actually take 120.
|
||||
resolution = { ...resolution, recipientCount: 120, estimatedMinutes: 12 };
|
||||
renderComposer();
|
||||
await screen.findByTestId('recipient-summary');
|
||||
await screen.findByTestId('large-send-warning');
|
||||
|
||||
const rate = screen.getByLabelText(/Send rate/i);
|
||||
await userEvent.clear(rate);
|
||||
await userEvent.type(rate, '1');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('large-send-warning')).toHaveTextContent(/At 1\/minute/);
|
||||
expect(screen.getByTestId('large-send-warning')).toHaveTextContent(/120 minutes/);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not warn on a send small enough not to matter', async () => {
|
||||
resolution = { ...resolution, recipientCount: 12 };
|
||||
renderComposer();
|
||||
await screen.findByTestId('recipient-summary');
|
||||
await waitFor(() => expect(resolveSpy).toHaveBeenCalled());
|
||||
|
||||
expect(screen.queryByTestId('large-send-warning')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the preview in a sandboxed iframe with no allow-scripts', async () => {
|
||||
renderComposer();
|
||||
await screen.findByTestId('recipient-summary');
|
||||
|
||||
@@ -290,7 +290,6 @@ export interface GalleryData {
|
||||
image_quality?: number;
|
||||
use_canvas_rendering?: boolean;
|
||||
enable_devtools_protection?: boolean;
|
||||
fragmentation_level?: number;
|
||||
overlay_protection?: boolean;
|
||||
// Hero logo customization fields
|
||||
hero_logo_visible?: boolean | null;
|
||||
|
||||
@@ -49,7 +49,6 @@ export interface ImageProtectionOptions {
|
||||
blockKeyboardShortcuts?: boolean;
|
||||
detectPrintScreen?: boolean;
|
||||
watermarkText?: string;
|
||||
fragmentGrid?: boolean;
|
||||
}
|
||||
|
||||
export interface ProtectedImageProps {
|
||||
@@ -57,9 +56,6 @@ export interface ProtectedImageProps {
|
||||
alt: string;
|
||||
protectionLevel?: ProtectionLevel;
|
||||
watermarkText?: string;
|
||||
fragmentGrid?: boolean;
|
||||
gridSize?: number;
|
||||
scrambleFragments?: boolean;
|
||||
invisibleWatermark?: boolean;
|
||||
onProtectionViolation?: (violationType: ViolationType) => void;
|
||||
fallbackSrc?: string;
|
||||
@@ -83,13 +79,6 @@ export interface WatermarkConfig {
|
||||
rotation: number;
|
||||
}
|
||||
|
||||
export interface FragmentConfig {
|
||||
enabled: boolean;
|
||||
gridSize: number;
|
||||
scramble: boolean;
|
||||
randomSeed?: number;
|
||||
}
|
||||
|
||||
export interface SteganographyConfig {
|
||||
enabled: boolean;
|
||||
message: string;
|
||||
@@ -121,7 +110,6 @@ export interface CanvasProtectionContext {
|
||||
originalImageData: ImageData;
|
||||
protectedImageData: ImageData;
|
||||
watermarkApplied: boolean;
|
||||
fragmentsScrambled: boolean;
|
||||
}
|
||||
|
||||
export interface PrintScreenDetectionState {
|
||||
@@ -181,7 +169,6 @@ export interface ProtectionConfig {
|
||||
rendering: {
|
||||
canvas: {
|
||||
enabled: boolean;
|
||||
fragmentGrid: FragmentConfig;
|
||||
watermark: WatermarkConfig;
|
||||
steganography: SteganographyConfig;
|
||||
noiseInjection: boolean;
|
||||
@@ -231,9 +218,7 @@ export type ProtectionProps = {
|
||||
|
||||
export type CanvasProtectionProps = ProtectionProps & {
|
||||
useCanvasRendering?: boolean;
|
||||
fragmentGrid?: boolean;
|
||||
watermarkText?: string;
|
||||
scrambleFragments?: boolean;
|
||||
invisibleWatermark?: boolean;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user