PageSourceSearch

https://gptprotools.com/attachments.js

js gptprotools.com collected 2026-10-02 01:30:51 UTC 8,157 bytes, 177 lines download raw bytes

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 = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
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.