967224c030
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.
299 lines
10 KiB
TypeScript
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
|
|
};
|
|
};
|