Files
picpeak/frontend/src/hooks/useImageProtection.ts
T
Paul Nothaft 967224c030 fix: remove the image-fragmentation surface
Closes #1300.

Fragmentation was configurable, stored per event, served to the gallery
client, and consumed by nothing. It was not unbuilt scaffolding — both
halves exist and are individually coherent — but they were never
connected, and they disagree: the server cut a fixed 3x3 grid while the
client reassembled a 4x4 one, so wiring them together as they stood
would have produced scrambled images rather than protection.

Removed rather than finished, because finishing it buys nothing. The
client fetches the whole image and then redraws it in pieces on a
canvas, so the full original has already crossed the wire before any
"protection" is applied — that is obfuscation, not a control. The
per-fragment canvas work also lands on mobile, which is the memory
profile under investigation in #1287.

Goes: secureImageService.fragmentImageBuffer and its branch, the
?fragment=N delivery path and handleFragmentedImage in secureImages,
the fragmented-JSON response in protectedImages, fragmentation_level in
the gallery payload, the default_fragmentation_level setting, the PUT
validator, the ProtectedImage fragment renderer, and the operator
control with its strings in all eight locales.

No migration. `events.fragmentation_level` and the app_settings row stay
— dropping a column is irreversible and the stored values are harmless
once nothing reads them. If they should go, that is a deliberate data
decision and its own migration.

`fragmentGrid` on AuthenticatedImage and the layouts is deliberately
untouched: #1299 already removes it as part of the inert prop surface,
and doing it here would only collide.
2026-09-05 12:11:40 +02:00

299 lines
10 KiB
TypeScript

import { useEffect, useRef, useCallback } from 'react';
type VendorStyle = CSSStyleDeclaration & {
webkitUserSelect?: string;
webkitTouchCallout?: string;
webkitUserDrag?: string;
MozAppearance?: string;
webkitAppearance?: string;
};
export type ProtectionLevel = 'basic' | 'standard' | 'enhanced' | 'maximum';
interface UseImageProtectionOptions {
enabled: boolean;
onAttemptedDownload?: () => void;
onProtectionViolation?: (violationType: string) => void;
protectionLevel?: ProtectionLevel;
useCanvasRendering?: boolean;
overlayProtection?: boolean;
blockKeyboardShortcuts?: boolean;
detectPrintScreen?: boolean;
watermarkText?: string;
}
export const useImageProtection = (options: UseImageProtectionOptions) => {
const elementRef = useRef<HTMLImageElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const overlayRef = useRef<HTMLDivElement | null>(null);
const printScreenDetectorRef = useRef<HTMLCanvasElement | null>(null);
const printScreenIntervalRef = useRef<NodeJS.Timeout | null>(null);
const reportViolation = useCallback((violationType: string) => {
options.onAttemptedDownload?.();
options.onProtectionViolation?.(violationType);
}, [options]);
// Enhanced print screen detection
const detectPrintScreen = useCallback(() => {
if (!options.detectPrintScreen || options.protectionLevel === 'basic') return;
try {
if (!printScreenDetectorRef.current) {
printScreenDetectorRef.current = document.createElement('canvas');
printScreenDetectorRef.current.width = 1;
printScreenDetectorRef.current.height = 1;
printScreenDetectorRef.current.style.position = 'absolute';
printScreenDetectorRef.current.style.left = '-9999px';
printScreenDetectorRef.current.style.top = '-9999px';
document.body.appendChild(printScreenDetectorRef.current);
}
const canvas = printScreenDetectorRef.current;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Fill with a specific pattern
ctx.fillStyle = '#FFFFFF';
ctx.fillRect(0, 0, 1, 1);
// Try to read the pixel data
try {
const imageData = ctx.getImageData(0, 0, 1, 1);
const data = imageData.data;
// Check if data was modified (some screenshot tools modify canvas data)
if (data[0] !== 255 || data[1] !== 255 || data[2] !== 255) {
reportViolation('print_screen_detected');
}
} catch (e) {
// Canvas data access blocked - possible screenshot attempt
reportViolation('canvas_access_blocked');
}
} catch (error) {
// Silently handle detection errors
}
}, [options.detectPrintScreen, options.protectionLevel, reportViolation]);
// Enhanced keyboard shortcut detection
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (!options.blockKeyboardShortcuts || options.protectionLevel === 'basic') return;
const isBlocked =
// Developer tools
e.key === 'F12' ||
(e.ctrlKey && e.shiftKey && (e.key === 'I' || e.key === 'J' || e.key === 'C')) ||
// View source
(e.ctrlKey && e.key === 'u') ||
(e.ctrlKey && e.key === 'U') ||
// Save page/image
(e.ctrlKey && e.key === 's') ||
(e.ctrlKey && e.key === 'S') ||
// Print
(e.ctrlKey && e.key === 'p') ||
(e.ctrlKey && e.key === 'P') ||
// Print Screen
e.key === 'PrintScreen' ||
// Select all
(e.ctrlKey && e.key === 'a') ||
(e.ctrlKey && e.key === 'A') ||
// Copy
(e.ctrlKey && e.key === 'c') ||
(e.ctrlKey && e.key === 'C') ||
// Enhanced protection: additional shortcuts
(options.protectionLevel === 'enhanced' || options.protectionLevel === 'maximum') && (
// Find
(e.ctrlKey && e.key === 'f') ||
(e.ctrlKey && e.key === 'F') ||
// Zoom
(e.ctrlKey && (e.key === '+' || e.key === '-' || e.key === '0')) ||
// Function keys that might trigger actions
['F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', 'F11'].includes(e.key)
) ||
// Maximum protection: block almost everything
options.protectionLevel === 'maximum' && (
e.ctrlKey || e.altKey || e.metaKey ||
['Insert', 'Delete', 'Home', 'End', 'PageUp', 'PageDown'].includes(e.key)
);
if (isBlocked) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
reportViolation(`keyboard_shortcut_${e.key}_${e.ctrlKey ? 'ctrl_' : ''}${e.shiftKey ? 'shift_' : ''}${e.altKey ? 'alt_' : ''}`);
return false;
}
}, [options.blockKeyboardShortcuts, options.protectionLevel, reportViolation]);
useEffect(() => {
if (!options.enabled || !elementRef.current) return;
const element = elementRef.current;
const protectionLevel = options.protectionLevel || 'standard';
// Basic protection events
const handleContextMenu = (e: MouseEvent) => {
e.preventDefault();
reportViolation('context_menu');
return false;
};
const handleDragStart = (e: DragEvent) => {
e.preventDefault();
reportViolation('drag_start');
return false;
};
const handleSelectStart = (e: Event) => {
e.preventDefault();
reportViolation('text_selection');
return false;
};
// Enhanced visibility change detection
const handleVisibilityChange = () => {
if (protectionLevel === 'maximum' && document.hidden) {
// Page became hidden - might be screenshot attempt
setTimeout(() => {
if (!document.hidden) {
reportViolation('suspicious_visibility_change');
}
}, 100);
}
};
// Detect copy attempts through clipboard API
const handleCopy = (e: ClipboardEvent) => {
if (protectionLevel !== 'basic') {
e.preventDefault();
e.stopPropagation();
reportViolation('clipboard_copy');
return false;
}
};
// Detect paste attempts (might be used to extract data)
const handlePaste = (e: ClipboardEvent) => {
if (protectionLevel === 'enhanced' || protectionLevel === 'maximum') {
e.preventDefault();
reportViolation('clipboard_paste');
return false;
}
};
// Add basic event listeners
element.addEventListener('contextmenu', handleContextMenu);
element.addEventListener('dragstart', handleDragStart);
element.addEventListener('selectstart', handleSelectStart);
// Add enhanced event listeners
if (protectionLevel !== 'basic') {
document.addEventListener('keydown', handleKeyDown, true);
document.addEventListener('visibilitychange', handleVisibilityChange);
document.addEventListener('copy', handleCopy, true);
document.addEventListener('paste', handlePaste, true);
// Start print screen detection
if (options.detectPrintScreen && (protectionLevel === 'enhanced' || protectionLevel === 'maximum')) {
const interval = protectionLevel === 'maximum' ? 50 : 100;
printScreenIntervalRef.current = setInterval(detectPrintScreen, interval);
}
}
// CSS protection
const elementStyle = element.style as VendorStyle;
elementStyle.userSelect = 'none';
elementStyle.webkitUserSelect = 'none';
elementStyle.webkitTouchCallout = 'none';
elementStyle.pointerEvents = 'auto';
elementStyle.webkitUserDrag = 'none';
element.draggable = false;
// Enhanced CSS protection
if (protectionLevel !== 'basic') {
elementStyle.outline = 'none';
elementStyle.webkitAppearance = 'none';
elementStyle.MozAppearance = 'none';
// Disable text selection on parent elements
let parent = element.parentElement;
while (parent) {
const parentStyle = parent.style as VendorStyle;
parentStyle.userSelect = 'none';
parentStyle.webkitUserSelect = 'none';
parent = parent.parentElement;
}
}
// Create overlay protection
if (options.overlayProtection && protectionLevel !== 'basic') {
const overlay = document.createElement('div');
overlay.style.cssText = `
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: transparent;
z-index: 1;
pointer-events: none;
`;
// Position relative container
const container = element.parentElement;
if (container) {
container.style.position = 'relative';
container.appendChild(overlay);
overlayRef.current = overlay;
}
}
// Cleanup function
return () => {
element.removeEventListener('contextmenu', handleContextMenu);
element.removeEventListener('dragstart', handleDragStart);
element.removeEventListener('selectstart', handleSelectStart);
if (protectionLevel !== 'basic') {
document.removeEventListener('keydown', handleKeyDown, true);
document.removeEventListener('visibilitychange', handleVisibilityChange);
document.removeEventListener('copy', handleCopy, true);
document.removeEventListener('paste', handlePaste, true);
}
// Clear print screen detection interval
if (printScreenIntervalRef.current) {
clearInterval(printScreenIntervalRef.current);
}
// Remove print screen detector canvas
if (printScreenDetectorRef.current && printScreenDetectorRef.current.parentElement) {
printScreenDetectorRef.current.parentElement.removeChild(printScreenDetectorRef.current);
}
// Remove overlay
if (overlayRef.current && overlayRef.current.parentElement) {
overlayRef.current.parentElement.removeChild(overlayRef.current);
}
};
}, [
options.enabled,
options.onAttemptedDownload,
options.onProtectionViolation,
options.protectionLevel,
options.overlayProtection,
options.blockKeyboardShortcuts,
options.detectPrintScreen,
reportViolation,
handleKeyDown,
detectPrintScreen
]);
return {
elementRef,
canvasRef,
overlayRef
};
};