1// Photo/video attachments on the site's two forms that mail the maintainer: the 2// landing #contact form (script.js â api/support) and step 2 of the /goodbye 3// survey (â api/uninstall). The files travel as base64 inside the JSON body, so 4// Vercel's 4.5 MB request cap bounds them: â¤5 files and 3 MB of media in all. 5// Stills are recompressed to a â¤1600px JPEG (screenshots are the common case); 6// GIFs and videos pass through untouched. api/_attachments.js holds the 7// endpoints to the same numbers, and its test imports these. The extension's 8// Support modal keeps its own copy â it is a separate build. 9 10export const MAX_ATTACH_FILES = 5; 11export const MAX_ATTACH_TOTAL_BYTES = 3 * 1024 * 1024; 12export const ATTACH_TYPES = new Set([ 13 'image/png', 'image/jpeg', 'image/webp', 'image/gif', 14 'video/mp4', 'video/webm', 'video/quicktime', 15]); 16const IMAGE_MAX_DIM = 1600; 17 18const HTML_ESCAPES = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; 19const escapeHtml = (str) => String(str).replace(/[&<>"']/g, (ch) => HTML_ESCAPES[ch]); 20 21const formatSize = (bytes) => (bytes >= 1024 * 1024 22 ? `${(bytes / (1024 * 1024)).toFixed(1)} MB` 23 : `${Math.max(1, Math.round(bytes / 1024))} KB`); 24 25const ICON_IMAGE = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>'; 26const ICON_VIDEO = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="6" width="14" height="12" rx="2"/><path d="M16 10l6-3v10l-6-3z"/></svg>';
27const ICON_REMOVE = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>'; 28 29const isStill = (file) => file.type.startsWith('image/') && file.type !== 'image/gif'; 30 31const compressImage = async (file) => { 32 try { 33 const bitmap = await createImageBitmap(file); 34 const scale = Math.min(1, IMAGE_MAX_DIM / Math.max(bitmap.width, bitmap.height)); 35 const canvas = document.createElement('canvas'); 36 canvas.width = Math.max(1, Math.round(bitmap.width * scale)); 37 canvas.height = Math.max(1, Math.round(bitmap.height * scale)); 38 canvas.getContext('2d').drawImage(bitmap, 0, 0, canvas.width, canvas.height); 39 bitmap.close(); 40 const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.85)); 41 return blob && blob.size < file.size ? blob : file; 42 } catch { 43 return file; 44 } 45}; 46 47const blobToBase64 = (blob) => new Promise((resolve, reject) => { 48 const reader = new FileReader(); 49 reader.onload = () => resolve(String(reader.result).split(',')[1] || ''); 50 reader.onerror = () => reject(reader.error); 51 reader.readAsDataURL(blob); 52}); 53 54// â { filename, contentType, content (base64), bytes }, or null when the type is 55// outside the allowlist or the file can't be read. 56const prepareAttachment = async (file) => { 57 const blob = isStill(file) ? await compressImage(file) : file; 58 if (!ATTACH_TYPES.has(blob.type)) return null; 59 try { 60 const content = await blobToBase64(blob); 61 if (!content) return null; 62 const baseName = file.name || 'attachment'; 63 const filename = blob === file ? baseName : baseName.replace(/\.[^.]+$/, '') + '.jpg'; 64 return { filename, contentType: blob.type, content, bytes: blob.size }; 65 } catch { 66 return null; 67 } 68}; 69 70const carriesFiles = (event) => Array.from(event.dataTransfer?.types || []).includes('Files'); 71 72// Wires one attach block â `root` holds a hidden `.attach-input` file input, an 73// `.attach-btn` and an `.attach-list` â to its form, `target`: files come from 74// the picker, from a paste anywhere in the form (a screenshot sitting in the 75// clipboard) or from a drop onto it. `onReject()` fires when a file didn't make 76// it in (wrong type, over the count or the total); `onChange()` after every 77// change, and around the wait while files are prepared. 78// â { files() â [{filename, contentType, content}] for the POST body, 79// busy() â true while files are still being prepared, clear() } 80export const wireAttachments = ({ root, target, onReject, onChange = () => {}, removeLabel = 'Remove' }) => { 81 const input = root.querySelector('.attach-input'); 82 const list = root.querySelector('.attach-list'); 83 let attachments = []; 84 let preparing = 0; 85 86 const render = () => { 87 list.hidden = attachments.length === 0; 88 list.innerHTML = attachments.map((attachment, index) => ` 89 <div class="attach-chip"> 90 ${attachment.contentType.startsWith('video/') ? ICON_VIDEO : ICON_IMAGE} 91 <span class="attach-chip-name">${escapeHtml(attachment.filename)}</span> 92 <span class="attach-chip-size">${formatSize(attachment.bytes)}</span> 93 <button type="button" class="attach-chip-remove" data-index="${index}" aria-label="${escapeHtml(`${removeLabel} ${attachment.filename}`)}">${ICON_REMOVE}</button> 94 </div> 95 `).join(''); 96 onChange(); 97 }; 98 99 const add = async (files) => { 100 if (!files.length) return; 101 preparing += 1; 102 onChange(); 103 let rejected = false; 104 for (const file of files) { 105 const room = () => MAX_ATTACH_TOTAL_BYTES - attachments.reduce((sum, attachment) => sum + attachment.bytes, 0); 106 // Only a still can shrink on the way in, so anything else already over 107 // the room left is turned away unread â a long recording would otherwise 108 // be copied into memory in full just to learn it doesn't fit. 109 const prepared = attachments.length < MAX_ATTACH_FILES && (isStill(file) || file.size <= room()) 110 ? await prepareAttachment(file) 111 : null; 112 if (prepared && attachments.length < MAX_ATTACH_FILES && prepared.bytes <= room()) { 113 attachments = [...attachments, prepared]; 114 } else { 115 rejected = true; 116 } 117 } 118 preparing -= 1; 119 render(); 120 if (rejected) onReject(); 121 }; 122 123 list.addEventListener('click', (event) => { 124 const remove = event.target.closest('.attach-chip-remove'); 125 if (!remove) return; 126 attachments = attachments.filter((_, index) => index !== Number(remove.dataset.index)); 127 render(); 128 }); 129 root.querySelector('.attach-btn').addEventListener('click', () => input.click()); 130 input.addEventListener('change', () => { 131 const files = Array.from(input.files); 132 input.value = ''; 133 add(files); 134 }); 135 136 // Clipboard files only â pasting text still types it. 137 target.addEventListener('paste', (event) => { 138 const files = Array.from(event.clipboardData?.files || []); 139 if (!files.length) return; 140 event.preventDefault(); 141 add(files); 142 }); 143 144 target.addEventListener('dragover', (event) => { 145 if (!carriesFiles(event)) return; 146 event.preventDefault(); 147 target.classList.add('attach-drop'); 148 }); 149 target.addEventListener('dragleave', (event) => { 150 if (!target.contains(event.relatedTarget)) target.classList.remove('attach-drop'); 151 }); 152 target.addEventListener('drop', (event) => { 153 if (!carriesFiles(event)) return; 154 event.preventDefault(); 155 target.classList.remove('attach-drop'); 156 add(Array.from(event.dataTransfer.files)); 157 }); 158 // A file dropped just past the form would open in the tab and take the 159 // half-filled form with it â so everywhere else, a file drop is refused. 160 window.addEventListener('dragover', (event) => { 161 if (!carriesFiles(event) || target.contains(event.target)) return; 162 event.preventDefault(); 163 event.dataTransfer.dropEffect = 'none'; 164 }); 165 window.addEventListener('drop', (event) => { 166 if (carriesFiles(event) && !target.contains(event.target)) event.preventDefault(); 167 }); 168 169 return { 170 files: () => attachments.map(({ filename, contentType, content }) => ({ filename, contentType, content })), 171 busy: () => preparing > 0, 172 clear: () => { 173 attachments = []; 174 render(); 175 }, 176 }; 177};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.