image-drop-upload
v3.0.0
Published
Embeddable image drop zone: a dependency-free widget with previews and a framework-free Node upload handler
Maintainers
Readme
image-drop-upload
Зона перетягування зображень із попереднім переглядом: фронтенд-віджет без залежностей і серверний обробник завантаження без фреймворка.
Киньте на неї файли або виберіть їх звичним способом — кожен з'явиться мініатюрою з розміром і кнопкою, щоб прибрати його назад. Зображення заповнюються по черзі, у міру декодування, тож велика вибірка показує першу мініатюру одразу, а не сітку порожніх квадратів. Без серверної адреси віджет лишається звичайним полем форми: файли їдуть разом із формою, і бекенд не бачить різниці. Дайте адресу — і він надсилає їх сам, зі смугою поступу та робочою кнопкою скасування.
Про що він дбає:
- Файл — це те, що кажуть його байти. Ім'я й тип, які повідомляє браузер, — це
здогад операційної системи за розширенням; перейменувати виконуваний файл на
.pngдостатньо, щоб обдурити обидва. Обидві половини читають натомість перші байти. Перевірка на клієнті існує, щоб пояснити причину користувачеві; вирішує серверна, бо все, що доступне через HTTP, можна надіслати curl-ом узагалі без браузера. - Сервер відхиляє імена, а не виправляє їх. Вихід за межі теки в будь-якому написанні, нульові байти, зарезервовані імена Windows, крапки в кінці — ім'я, яке доводиться переписувати, щоб зробити безпечним, зазвичай належить спробі зламу.
- Нічого недописаного не лишається. Кожне завантаження йде під тимчасовим іменем і перейменовується, лише коли файл цілий, а обірваний запит прибирається за собою, замість лишати сміття на диску.
- Форма з чужого сайту не може надіслати в цю адресу.
multipart/form-dataне проходить preflight, тож без перевірки Origin будь-яка сторінка будь-якого сайту могла б завантажити файл під сесійною кукою користувача.
Три більші можливості є тоді, коли вони потрібні, і відсутні, коли ні. Кожна — це один блок опцій; не вказали його — цей код не виконується взагалі:
- Стиснення перед надсиланням — вписати в рамку, перекодувати з обраною якістю або прибрати метадані, не чіпаючи жодного пікселя. Усе на сторінці, тож зекономлені байти взагалі нікуди не їдуть.
- Завантаження під сеансом — тека на відвідувача й власник у кожній відповіді, за тим, чим ваш застосунок уже розрізняє людей, зокрема й неавторизованих.
- Перевірка на шкідливий вміст — пошук за хешем у VirusTotal або власний сканер. Із сервера йде лише хеш, файл — ні.
- Повторна спроба після невдачі — автоматично або кнопкою, і лише для тих невдач, що про зв'язок, а не про файл.
Розмір і будова: 16 КБ JS і 2 КБ CSS у gzip на сторінці, нуль runtime-залежностей.
Серверна половина — одна функція (req, res), яка монтується в Express, AdonisJS,
Fastify, Nest або голий node:http, і має єдину залежність — парсер multipart. Із нею
йдуть п'ять мов: англійська (типова), українська, іспанська, німецька й французька, —
а також світла й темна теми, де кольори, шрифти й розміри задаються під час підключення.
🇬🇧 This page in English — головна версія документації.

Встановлення
npm install image-drop-uploadДля сторінки це все: у віджета немає залежностей, і нічого серверного разом із ним не ставиться. busboy потрібен лише тоді, коли ви берете й Node-обробник, що йде в комплекті:
npm install busboyАбо склонуйте репозиторій і запустіть демо:
git clone https://github.com/Insider515/drag-and-drop-preview-images-module.git
cd drag-and-drop-preview-images-module
npm install
npm start # збирає демо й піднімає його на http://127.0.0.1:5173Швидкий старт
Як поле форми — без жодних змін на сервері
Не задавайте endpoint — і віджет лишиться тим, що замінив: <input type="file"> із
попереднім переглядом. Файли йдуть разом із формою, і ваш наявний бекенд не помітить
різниці.
<form action="/contact" method="post" enctype="multipart/form-data">
<div id="images"></div>
<button type="submit">Send</button>
</form>import { DropPreview } from 'image-drop-upload';
import 'image-drop-upload/style.css';
new DropPreview('#images', { name: 'images[]' });Самостійне завантаження
const drop = new DropPreview('#images', {
endpoint: '/api/upload',
autoUpload: true,
});
drop.on('uploaded', ({ answer }) => console.log(answer.uploaded));// server
import express from 'express';
import { createUploadHandler } from 'image-drop-upload/server';
const app = express();
app.use('/api/upload', createUploadHandler({ root: './uploads' }));
app.listen(3000);Серверній половині потрібен ще один пакет:
npm install busboyЗ вашим власним бекендом
Віджету не потрібен сервер із цього пакета, і в більшості сторінок його й не буде: бекенд
часто на PHP, Python, Go або це вже наявна точка входу. Наведіть endpoint на неї — більше
нічого не змінюється, і busboy у цьому разі не потрібен узагалі. Саме тому він не
ставиться разом із пакетом.
Від вашої точки входу потрібно небагато:
| | |
|---|---|
| Приймати | POST із multipart/form-data, файли в полі images[] — перейменовується опцією віджета name |
| На успіх | будь-який 2xx. Усі файли з цього запиту тоді показуються як надіслані |
| На відмову | не-2xx, за бажанням із JSON: { "code": "TOO_LARGE", "error": "…" } на весь запит або { "failures": [{ "name": "a.png", "code": "TOO_LARGE", "error": "…" }] }, щоб позначити окремі файли, а решту лишити |
filesPerRequest за замовчуванням дорівнює 1, тож кожен файл іде окремим запитом, і обрив
зв'язку продовжується з того файлу, на якому спинився, а не з початку черги.
Що вміє
| | | | --- | --- | | Перегляд | Мініатюри з самих файлів, з іменем і розміром | | Вибіркове видалення | У кожної плитки своя кнопка; «Видалити все» очищає чергу | | Перевпорядкування | Перетягуванням, утриманням на сенсорному екрані або Alt+стрілкою | | Перетягування | У зону, з підсвіткою, що не блимає, коли курсор перетинає вкладені елементи | | Вставка | Ctrl+V скриншота у віджет або на всій сторінці | | Камера | Кнопка «Зробити фото» на телефоні, без відмови від галереї | | Розпізнавання | Тип читається з перших байтів файлу, ніколи з розширення | | Обмеження | На файл, на чергу, на кількість і на декодовані пікселі | | Завантаження | Поступ черги й кожного файлу, скасування — або нічого, якщо лишити полем форми | | Мови | П'ять готових; своя — це об'єкт зі словником | | Теми | Світла й темна, за системою або примусово; кожен колір — CSS-змінна | | Два на сторінці | Нічого не реєструється глобально, екземпляри не заважають один одному | | Стиснення | Змінити розмір, перекодувати або прибрати метадані без втрат — на сторінці, до надсилання | | Сеанси | Тека на відвідувача і хто що залив у відповіді | | Перевірка на шкідливе | Пошук за хешем, тож сам файл сервера не покидає | | Повторна спроба | Обірваний зв'язок надсилається знову, відхилений файл — ні | | Сховище | Локальний диск або S3 і все, що говорить його мовою — без SDK |
Звідки може взятися зображення
Чотири шляхи, і два останні ввімкнені за замовчуванням:
| | | |---|---| | Кнопка | Відкриває вибір файлів | | Перетягування | У зону, з робочого столу або з іншого вікна | | Вставка | Ctrl+V скриншота або зображення, скопійованого зі сторінки | | Камера | Кнопка «Зробити фото» на телефоні |
Вставка
new DropPreview('#images', {
paste: true, // типово
});true слухає сам віджет, тож вставка потрапляє в той, з яким людина щойно працювала. Це
єдине, що може бути правильним, коли на сторінці їх двоє, — і саме тому модуль так і не
реєструє нічого глобально.
На практиці: клацніть по зоні один раз, далі Ctrl+V. Для сторінки з одним віджетом, де Ctrl+V має працювати без попереднього клацання:
paste: 'document',Це слухає всю сторінку — але не зазирає через плече: вставка в <input>, <textarea> чи
будь-що з contenteditable належить тому полю й лишається йому. Вставка, що не несе
файлів, теж лишається — звичайний текст вставляється туди, куди й цілився.
Вставлені зображення проходять усі ті самі перевірки. Скриншот, який насправді текстовий файл, відхиляється так само.
paste: false не чіпляє нічого.
Камера
new DropPreview('#images', {
camera: 'auto', // типово
capture: 'environment', // задня камера; 'user' — фронтальна
});Поруч із зоною з'являється кнопка Зробити фото, яка відкриває камеру одразу, а не вибір файлів.
'auto' означає «там, де вказівник грубий» — телефон або планшет. На комп'ютері ця
кнопка відкривала б той самий діалог, що й сусідня, тобто була б шумом. true — показувати
скрізь, false — ніде. Браузер, у якого це не можна спитати, вважається комп'ютером.
Це окремий input, і це не дрібниця реалізації. Браузер, який шанує capture,
ігнорує multiple, бо камера повертає одне фото. Поставити capture на головний input
означало б відмовитись від вибору кількох зображень із галереї — а це те, чим є більшість
завантажень. Тому камера дістає власний input, а головний лишається недоторканим:
головний input multiple, accept: усі формати, які знає розпізнавач
input камери одне фото, accept: image/*, capture: environmentКнопка стоїть в окремому рядку, а не разом із «Завантажити» й «Видалити все», бо той рядок ховається, коли черга порожня, — а саме тоді хтось і хоче зробити фото.
Порядок у черзі
Увімкнено за замовчуванням. Інакше порядок, у якому файли підуть нагору, — це випадковість того, як їх відсортував системний діалог, а для галереї чи набору фотографій товару цей порядок і є суттю.
new DropPreview('#images', {
reorder: true, // типово
});Три способи пересунути плитку:
| | | |---|---| | Миша | Тягніть за будь-яке місце плитки | | Палець | Затримайте на ручці в кутку, потім тягніть | | Клавіатура | Наведіть фокус на плитку і натисніть Alt зі стрілкою |
Чому пальцю потрібна ручка. Доки перетягування не почалось, браузер читає рух дотику як
гортання і скасовує вказівник тієї ж миті, коли це вирішить. Єдиний спосіб цьому завадити —
touch-action: none, а поставити його на всю плитку означало б, що екран, повний
мініатюр, стане неможливо прогорнути. Тому його бере один маленький куток, а решта плитки
лишається придатною для гортання. Миші нема від чого гортати, тож вона починає звідусіль.
Alt зі стрілкою, а не сама стрілка. Від стрілки на елементі з фокусом очікують, що вона
пересуне фокус, а не те, що під ним. Плитки несуть aria-keyshortcuts, і фокус іде за
плиткою, яку пересунули, — інакше наступне натискання посуне вже інший файл, і людина
загубить, де вона.
З коду
drop.move(fileId, 2); // поставити цей файл третімІндекс — це куди файл потрапить, рахуючи в черзі такою, якою вона стане. Рахувати в
старій черзі — це те, що дає помилку на одиницю, коли файл рухається вперед. Метод повертає
false, коли робити було нічого, а індекс за межами лягає на найближчий край.
drop.on('reorder', ({ id, from, to, files }) => save(files.map((f) => f.name)));Прихований input переписується з кожним переміщенням, тож форма, надіслана звичайним способом, несе саме той порядок, що на екрані. Сторінка, яка показує один порядок, а надсилає інший, гірша за відсутність перевпорядкування взагалі.
reorder: false прибирає ручки, обробку перетягування й обробку клавіш. move() працює
далі — для застосунку, який керує порядком із власного інтерфейсу.
Опції віджета
new DropPreview(target, {
endpoint: null, // POST here; null keeps it a plain form field
name: 'images[]', // form field name
fields: null, // extra form fields sent with an upload
headers: null, // object or function — for authorisation tokens
credentials: 'same-origin',
accept: [], // MIME types; empty means every image format known
allowSvg: false, // SVG is XML that can carry script; opt-in
limits: null, // see below
compress: null, // стискати перед надсиланням; див. нижче
retry: null, // надіслати невдале завантаження ще раз; див. нижче
filesPerRequest: 1, // файлів в одному запиті; 0 — уся черга разом
autoUpload: false, // upload as soon as files are chosen
showUploadButton: true,
showClearButton: true,
reorder: true, // тягніть плитку або Alt+стрілка на плитці з фокусом
paste: true, // Ctrl+V; 'document' слухає всю сторінку
camera: 'auto', // кнопка «Зробити фото», де вказівник грубий
capture: 'environment', // яку камеру відкривати
locale: null, // 'en' | 'uk' | 'es' | 'de' | 'fr' | your dictionary
theme: null, // colours, fonts, metrics
colorScheme: 'auto', // 'auto' | 'light' | 'dark'
});target — елемент або CSS-селектор.
Обмеження
new DropPreview('#images', {
limits: {
maxFileSize: 10 * 1024 * 1024, // bytes per file
maxFiles: 20, // files in the queue
maxTotalSize: 100 * 1024 * 1024, // bytes for the whole queue
maxPixels: 50 * 1024 * 1024, // pixels a preview may decode
},
});maxPixels — не про охайність. PNG на 30 КБ може оголосити 40000×40000 і коштувати
кілька гігабайтів після декодування — вкладка помре ще до будь-якого завантаження.
Розміри відомі лише після того, як браузер розібрав заголовок, тому перевірка
відбувається під час побудови перегляду, і файл поза межею відкидається з поясненням.
Методи й події
await drop.add(fileList); // same checks as picking them by hand
drop.remove(id);
drop.clear();
await drop.upload();
drop.cancel();
drop.destroy();
drop.files; // the queue, as plain objects
drop.totalBytes;
drop.busy;
drop.on('change', ({ files }) => {});
drop.on('rejected', ({ rejected }) => {}); // [{ file, code, detail }]
drop.on('uploaded', ({ answer, files }) => {});
drop.on('error', ({ error, code, message }) => {});on() повертає функцію відписки.
Відмова несе code, а не речення, — на ньому можна розгалужуватись. Щоб показати її
людині, drop.describeError(code, detail) перетворює код на текст активною мовою.
Мова
new DropPreview('#images', { locale: 'de' });Приймається 'en', 'uk', 'es', 'de', 'fr' або повний тег, чия основа збігається
з однією з них: 'de-AT' дасть німецьку. Нерозпізнане значення дає англійську, а не
помилку.
Множина рахується за правилом мови — українська має три форми, решта дві — а одиниці
розміру стають місцевими (1.5 Ko у французькій). На корені віджета проставляється
атрибут lang.
Своя мова
Неповний словник — нормально: усе, чого в ньому немає, береться з англійської.
import { DropPreview, PLURAL_RULES } from 'image-drop-upload';
new DropPreview('#images', {
locale: {
id: 'sv',
name: 'Svenska',
tag: 'sv',
plural: PLURAL_RULES.default,
strings: {
'drop.button': 'Välj filer',
'drop.hint': 'eller dra dem hit',
'count.files': { one: '{n} fil', other: '{n} filer' },
},
},
});Готові словники — звичайні експорти (import { en, uk } from '…'), а в репозиторії
лежать у src/locales/ — по одному простому об'єкту на мову. Тест стежить, щоб набір
ключів у всіх п'яти збігався, і відхиляє переклад, у якому з'явився підстановник, якого
немає в англійській.
Оформлення
Нічого не передали — віджет виглядає так, як у демонстрації.
new DropPreview('#images', {
colorScheme: 'auto', // 'auto' follows the system; 'light'/'dark' force it
theme: {
font: "'Inter', system-ui, sans-serif",
fontSize: '15px',
radius: '2px',
tileSize: '140px',
gap: '16px',
dropHeight: '160px',
colors: { accent: '#7c3aed', accentHover: '#6d28d9' },
light: { bg: '#fffdf7', text: '#2b2415' },
dark: { bg: '#0b1020', accent: '#a78bfa' },
},
});colors діє в обох схемах, light і dark його уточнюють. Оформите лише світлу — темна
лишиться з вбудованими кольорами, і світлий фон уночі не з'явиться.
Кольори: bg, bgSubtle, bgSunken, border, borderStrong, text, textMuted,
accent, accentHover, accentSoft, accentContrast, danger, dangerHover,
success, warning, shadow, overlay.
Шрифти й розміри: font, fontSize, radius, radiusLarge, tileSize, gap,
dropHeight.
Невідомий ключ кидає помилку, а не ігнорується. Ключ, що починається з --, проходить
наскрізь — для змінної, якої в переліку ще немає. Значення перевіряються: те, що могло б
закрити правило й відкрити своє (;, }, url(, коментарі), відхиляється — це важить,
якщо кольори у вас приходять від ваших користувачів.
Тема діє на один віджет, тож два на сторінці можуть виглядати по-різному.
Куди можна змонтувати сервер
createUploadHandler() повертає звичайну функцію (req, res) над власними об'єктами
Node. basePath — префікс, який треба відрізати, коли хост не переписує req.url сам:
Express переписує, Adonis і node:http — ні.
Їй потрібен busboy, який ставиться командою npm install busboy. У пакеті він
оголошений необов'язковою peer-залежністю, а не звичайною, щоб сторінка, яка бере віджет
до чужого бекенда, не встановлювала серверний розбірник, який ніколи не завантажиться.
Імпорт /server не потребує нічого, як і UploadService окремо; потребує його саме
createUploadHandler() — і без нього відмовляється створюватись, називаючи цю команду.
Відмова саме при старті — навмисно: доки її не було, без busboy додаток запускався,
виглядав справним і відповідав на перше справжнє завантаження звичайною 500.
// Express
app.use('/api/upload', createUploadHandler({ root: './uploads' }))
// AdonisJS
const files = createUploadHandler({ root: app.makePath('uploads'), basePath: '/api/upload' })
router.any('/api/upload/*', ({ request, response }) => files(request.request, response.response))
// Fastify
fastify.all('/api/upload/*', (req, reply) => files(req.raw, reply.raw))
// bare node:http
http.createServer(files).listen(3000)Опції обробника
createUploadHandler({
root: './uploads', // required; the one directory files may land in
basePath: '', // prefix to strip from the URL
field: 'images[]', // поле форми, яке читати — типове для віджета
accept: [], // MIME types; empty means every image format known
allowSvg: false,
onConflict: 'rename', // 'rename' | 'refuse' | 'overwrite'
rename: null, // (name, { type }) => string — choose the stored name
limits: {
maxFileSize: 10 * 1024 * 1024,
maxFiles: 20, // на запит, а не на людину; див. perClient
maxRequestSize: 100 * 1024 * 1024,
maxPixels: 50 * 1024 * 1024, // пікселів, які може заявити зображення; 0 вимикає
maxBytesPerSecond: 0, // байтів на секунду на завантаження; 0 — без обмеження
perClient: null, // скільки один клієнт може завантажити через запити
minFreeSpace: 64 * 1024 * 1024,
},
maxConcurrent: 8, // uploads in flight; beyond that, 503
allowedOrigins: undefined, // same-origin only by default; false disables the check
authorize: (req, ctx) => true,
onWarning: (message, detail) => {},
});Що повертається
{
"uploaded": [{ "name": "photo.png", "original": "photo.png", "size": 1024, "type": "image/png" }],
"failures": [{ "name": "evil.png", "code": "NOT_AN_IMAGE", "error": "…", "params": null }],
"fields": { "album": "holiday" }
}Один поганий файл не валить пакет. Клієнту повідомляють, які не пройшли і чому, а решта лишається.
Тільки частина, що зберігає
UploadService робить роботу з файловою системою і нічого не знає про HTTP — для
фреймворка, який розбирає multipart сам:
import { UploadService } from 'image-drop-upload/server';
const service = new UploadService({ root: './uploads' });
const stored = await service.store(filename, readableStream);Стиснення зображень перед надсиланням
Вимкнено за замовчуванням. Вмикається блоком compress на віджеті:
new DropPreview('#images', {
compress: {
maxWidth: 128,
maxHeight: 128,
quality: 'auto',
},
});Приберіть блок — і файли стають у чергу такими, якими прийшли. Усе це відбувається на сторінці через canvas, який у браузері вже є, тож до збірки нічого не додається, а зекономлені байти взагалі нікуди не їдуть.
Що можна чесно обіцяти, а що ні
Саме тут збірка без залежностей упирається в межу, тож прочитайте це перед вибором налаштувань.
| | Чи є тут | |
|---|---|---|
| Зміна розміру | так | Це вміє canvas |
| Стиснення з втратами | так | toBlob приймає якість |
| Видалення метаданих | так, і точно | Зображення копіюється байт у байт; прибирається лише EXIF, XMP, IPTC, коментарі й позначки часу |
| Стиснення без втрат | ні | Стиснути JPEG, не чіпаючи пікселів, можна лише кодеком, а кодек — це залежність |
Тому quality: 'lossless' тут означає лише метадані. Це не менший перекодований файл,
і модуль не вдаватиме інакше: на цьому шляху він взагалі не торкається canvas, бо canvas
декодує в пікселі й кодує знову, а для JPEG це щоразу трохи втрачає — навіть за якості 1.
Самі лише метадані варті більшого, ніж здається. Фотографія з телефона зазвичай несе кілобайт налаштувань камери й GPS, а ще вбудовану мініатюру на десятки кілобайтів. Усе невидиме, усе завантажується, а дещо — де знято — ніхто публікувати не збирався.
quality: 'auto' — це 0.85
П'ятнадцять відсотків від верху шкали, і це те, що «не більше ніж на 15%» може чесно означати, коли порівнювати немає з чим. Це регулятор кодувальника, а не виміряна різниця для ока — виміряти її тут нічим, бо для цього потрібен кодек.
Хочете інакше — ставте число: quality: 0.6.
На PNG це не впливає. У PNG немає регулятора якості; браузер ігнорує аргумент. Щоб зменшити PNG, змініть розмір або формат:
compress: { maxWidth: 1600, format: 'image/webp' } // зазвичай велика економіяНалаштування
| Налаштування | Типове | Що робить |
|---|---|---|
| maxWidth, maxHeight | — | Рамка, у яку вписати. Досить і чогось одного |
| fit | 'contain' | 'contain' вписує в рамку; 'cover' заповнює її, виходячи за один бік |
| quality | 'auto' | 'auto' (0.85), число або 'lossless' (лише метадані) |
| format | 'auto' | 'auto' лишає формат; або image/jpeg, image/png, image/webp |
| stripMetadata | true | Прибрати EXIF, XMP, IPTC, коментарі |
| skipIfLarger | true | Лишити оригінал, якщо новий файл насправді не менший |
Зображення, яке вже вміщається в рамку, лишається свого розміру — збільшення додає байтів і вигадує деталі, яких не було.
'cover' — той, що може вийти більшим за рамку, і саме так і задумано: він масштабує, доки
коротша сторона не заповнить рамку, а довша при цьому звисає. Фото 1000×4000 у рамці 800×600
стане 800×3200 — нічого не обрізається, бо віджет, який мовчки відрізав би людям половину
їхньої ж фотографії, був би гіршим за той, що віддає високу картинку. Якщо результат мусить
вміститися, це обіцяє 'contain'.
Три речі, яких воно не робитиме
Не надішле вам більший файл. PNG просто з оптимізатора зазвичай менший за будь-що, що
canvas зробить із тих самих пікселів, тож результат вимірюється і відкидається, якщо
програв. Вимикається через skipIfLarger: false, якщо вам важливіший єдиний формат.
Не покладе ваші фотографії набік. Камери телефонів записують, як їх тримали, в тег EXIF, і браузери повертають зображення за ним. Прибрати цей тег — покласти кожне портретне фото набік. Тому на шляху «лише метадані» EXIF із орієнтацією, відмінною від «прямо», лишається; на шляху з перекодуванням його прибирають безпечно, бо canvas уже вписав поворот у пікселі.
Не втратить файл через невдале стиснення. Кожна невдача — canvas кинув виняток,
браузер не вміє формат, зображення не декодувалося — закінчується надсиланням оригіналу
та подією warning, яку можна слухати:
drop.on('warning', ({ code, error }) => console.warn(code, error));Чого це коштує
Стиснення йде після того, як файл пройшов ліміти, по одному зображенню за раз, і
використовує те саме декодоване зображення, яке вже зробив попередній перегляд — нічого не
декодується двічі, а декодована копія звільняється одразу після використання. Зверніть
увагу на порядок: файл, більший за maxFileSize, відхиляється до будь-якої спроби
його зменшити. Підніміть ліміт, якщо хочете приймати великі оригінали й стискати їх.
Черга повідомляє обидва розміри, тож економію можна показати:
drop.on('change', ({ files }) => {
for (const f of files) console.log(f.name, f.originalSize, '->', f.size);
});Повторна спроба після невдачі
Вимкнено за замовчуванням: одна невдача — і по тому, а причина лишається на плитці.
Вмикається блоком retry, і тоді невдале завантаження надсилається ще раз саме:
new DropPreview('#images', {
endpoint: '/upload',
retry: { attempts: 3 },
});Є ще кнопка й метод — вони працюють незалежно від того, є блок чи ні.
Повторюється невдача, а не файл
Невдале завантаження буває двох різних видів, і ставитись до них однаково — це зробити кнопку повтору або марною, або дратівливою:
| | |
|---|---|
| Варто повторити | NETWORK, INTERNAL, BUSY, SCAN_FAILED, NO_SPACE і будь-який HTTP_ERROR зі статусом 5xx |
| Варто спробувати ще, але не самому | QUOTA |
| Ні | TOO_LARGE, NOT_AN_IMAGE, TYPE_NOT_ALLOWED, INFECTED, INVALID_NAME, DENIED, будь-який 4xx — і ABORTED, бо це було рішення самої людини |
Обрив зв'язку — про мить. Завеликий файл — про файл: надіслати його знову означає дістати ту саму відмову, лише повільніше, і сховати причину під індикатором. Тому повторюється лише перше, а друге лишається з поясненням на плитці.
У змішаній партії це помітно. Два файли впали: один тому, що шлюз відповів 502, другий
тому, що це текстовий файл з іменем .png — на дріт повертається лише перший.
QUOTA — третій випадок, і саме через нього поряд з isRetryable є ширший worthTryingAgain.
Вичерпаний бюджет клієнта поповнюється, коли мине його вікно — за замовчуванням хвилина, — а
цей годинник із боку браузера не видно, тож повтор через 1 і 2 секунди лише спалив би спроби.
Тому сам собою він не повторюється, але кнопка Повторити для нього з'являється: почекати
хвилину й натиснути — це те, що справді працює. Коли ж він вважався остаточним, кнопка
ховалась, і в людини не лишалось іншого шляху, ніж очистити чергу й обрати ті самі файли вдруге.
Налаштування
| Налаштування | Типове | Що робить |
|---|---|---|
| attempts | 3 | Скільки спроб усього, разом із першою |
| delay | 1000 | Мілісекунд до другої спроби |
| backoff | 2 | На скільки множиться кожне очікування |
| maxDelay | 30000 | Найдовше, яким може бути одне очікування |
З типовими значеннями паузи такі: 1 с, 2 с, 4 с, 8 с… Кожна довша за попередню навмисно: сервер, у якого погана хвилина, лишається таким якийсь час, а натовп браузерів, що гатить у нього з однаковим інтервалом, робить із однієї поганої хвилини кілька.
Поки триває очікування, рядок стану про це каже — «Не вдалося — пробуємо ще раз (2 з 3)» мовою віджета, — і «Скасувати» обриває очікування одразу. Скасування, яке нібито ігнорують упродовж 30-секундної паузи, було б гірше за відсутність повтору взагалі.
Масове завантаження переживає обрив
Файли їдуть по одному на запит, тож те, що долетіло, лишається долетілим. Покладіть п'ятдесят фотографій в один запит — і зв'язок, обірваний на сорок дев'ятій, забере всі п'ятдесят, а ті, що сервер уже записав, лишаться в нього на диску, тож повторне надсилання партії залишить дублікати кожної з них. Заміряно, доки це не було типовою поведінкою: десять файлів, обрив після шостого, повтор партії — п'ятнадцять файлів на сервері, п'ять із них дублікати.
Тепер той самий обрив коштує одного файлу, який був у польоті:
12 у черзі, зв'язок обірвано після 7-го
плитки: 7 done, 1 error, 4 ready
на сервері: 7 файлів
натиснуто Retry
на сервері: 12 файлів, без дублікатівФайл зі статусом done не надсилається повторно ніколи — ні кнопкою «Завантажити», ні
«Повторити», ні чимось іще. Саме тому друга спроба дешева.
| Налаштування | Типове | Що робить |
|---|---|---|
| filesPerRequest | 1 | Файлів в одному запиті. Збільште, щоб проміняти надійність на меншу кількість звернень; 0 кладе всю чергу в один запит, як було раніше |
Обрив зв'язку зупиняє обхід. Решта файлів упала б так само, один за одним, і дивитись, як сорок штук роблять це повільно, не допомагає нікому. А от відмова в одному файлі не зупиняє: те, що третій не є зображенням, нічого не каже про четвертий.
Смуга поступу міряє всю чергу, а не запит у польоті — інакше вона відкочувалась би на кожному файлі, — а рядок стану рахує файли, а не запити: «Завантажую 3 з 20».
Дві смуги, і що означає кожна
Смуга під зоною міряє всю чергу. Тонка смуга внизу мініатюри міряє цей файл і з'являється лише тоді, коли він справді передається — смуга на нулі під кожною плиткою це меблі, а не інформація.
[███████░░░] черга: чотири файли, один готовий
┌──────┐
│ │
│ ███░ │ цей файл: 60% пішло
└──────┘За одного файлу на запит — а це типово — число на плитці точне. Коли в одному запиті їх кілька, байти розподіляються в тому порядку, в якому надсилаються, тобто в порядку частин multipart-тіла; це найближче до правди, що доступне без підказки браузера, на якій частині він зараз.
Файл, що долетів, показано повним і таким лишається. Файл, що впав, повертається до нуля — зокрема й той, який сервер відхилив усередині загалом успішного запиту: те, що пішло, не повернеться, а наступна спроба починає його заново, тож напівзаповнена смуга була б неправдою.
Число кожного файлу є і в черзі:
drop.on('change', ({ files }) => {
for (const f of files) console.log(f.name, Math.round(f.progress * 100) + '%');
});Кнопка і виклик руками
Кнопка Повторити з'являється поруч із «Завантажити», щойно щось впало з причини, яку варто повторити, і зникає, коли повторювати нічого. Кнопка, яка є завжди, але зазвичай марна, привчає на неї не зважати.
Те саме з коду:
if (drop.retryable) await drop.retry();retry() ставить у чергу лише те, що варто повторити, і надсилає; коли повторювати
нічого — повертає null і не робить жодного запиту.
Як за цим стежити
drop.on('retry', ({ attempt, of, delay, code }) =>
console.log(`спроба ${attempt} з ${of} через ${delay} мс, після ${code}`));Подія error спрацьовує один раз, після останньої спроби, а не після кожної.
Чого воно не робить
Продовження відбувається між файлами, а не всередині одного. Фотографія на 10 МБ, що обірвалась на 90%, починається спочатку — але лише вона, а не двадцять сусідніх. Щоб продовжувати всередині файлу, сервер має тримати часткові завантаження й домовитись про протокол для них, а це вже більша річ, ніж цей пакет.
Сеанси: хто що залив
Вимкнено за замовчуванням. Точка не знає нічого про відвідувачів, усі файли падають в одну теку, і хто що поклав — ніде не записано.
Вмикається блоком sessions. Ваш застосунок уже вміє відрізняти одного відвідувача від
іншого — за логіном або за кукою, яку носить неавторизований гість, — і це єдине, що
ви даєте:
createUploadHandler({
root: './uploads',
sessions: {
identify: (req) => req.session?.id ?? null,
},
});Тепер кожен сеанс дістає власну підтеку, а у відповіді сказано яку:
uploads/
1f3c…a9/photo.png ← Анна
7b2e…04/photo.png ← Борис, те саме ім'я, без зіткнення{ "owner": "1f3c…a9",
"uploaded": [{ "name": "photo.png", "original": "photo.png", "size": 51234,
"type": "image/png", "owner": "1f3c…a9" }] }Приберіть блок — і все повертається до однієї спільної теки. Більше нічого не змінюється.
Три налаштування
| Налаштування | Типове | Що робить |
|---|---|---|
| identify(req) | — | Повертає id. Синхронно або асинхронно. Єдина обов'язкова частина |
| scope | 'directory' | 'directory' — тека на сеанс; 'label' — одна спільна тека, лише позначка власника |
| required | true | Немає сеансу → 403 NO_SESSION. false — пускати такі запити в спільну теку |
'label' — коли файли лежать пласко, а власника ви пишете у свою базу:
sessions: { identify: (req) => req.session.id, scope: 'label' }Тоді id взагалі не потрапляє у файлову систему, тож може бути будь-яким — user/42+ok
підійде.
Що модуль перевіряє і чого не робитиме за вас
За scope: 'directory' id стає іменем теки, а приходить він зазвичай із куки — тобто від
клієнта. Тому його перевіряють так само суворо, як ім'я файлу, і він має бути вже
придатним одиничним сегментом:
| Хук повернув | Що буде |
|---|---|
| "1f3ca9" | uploads/1f3ca9/ |
| "../escape", "..", "/etc", "a/b" | 400 INVALID_SESSION |
| "CON", "x\0y", 300 символів | 400 INVALID_SESSION |
| null, "" | 403 NO_SESSION, або спільна тека за required: false |
| хук кинув виняток | 500, причина йде в onWarning, а не клієнтові |
Відхиляється, а не виправляється. /etc не перетворюється тихцем на etc, а a/b
на b — саме так два різні сеанси опинилися б в одній теці. Якщо ваші id у base64 або
інакше не схожі на імена тек, зробіть із них хеш самі:
identify: (req) => createHash('sha256').update(req.session.id).digest('hex').slice(0, 32),Сеанс, який не вдалося встановити, зупиняє запит до читання тіла, тож відвідувач, якому нема куди класти файли, не встигне надіслати мегабайти на ваш диск.
На сторінці
Налаштовувати нічого: віджет шле запит із кукою, це вже робить credentials: 'same-origin'.
Два випадки варті окремого слова:
// Точка на іншому домені, а сеанс живе в куці.
new DropPreview('#images', { endpoint: 'https://api.example.com/upload', credentials: 'include' });
// Сеанс їде токеном, а не кукою.
new DropPreview('#images', { endpoint: '/upload', headers: () => ({ Authorization: `Bearer ${token()}` }) });headers приймає функцію, щоб токен читався в мить завантаження, а не в мить створення
віджета — це важливо для токена, який оновлюється.
Іменування файлів за сеансом
rename тепер знає, для якого сеансу називає файл:
rename: (name, meta) => `${meta.identity}-${Date.now()}-${name}`,meta.identity дорівнює null, коли сеанси вимкнені або коли required: false пропустив
анонімного відвідувача.
Перевірка завантажень на шкідливий вміст
Вимкнено за замовчуванням. Вмикається блоком scan із вашим власним ключем:
createUploadHandler({
root: './uploads',
scan: {
service: 'virustotal',
apiKey: process.env.VT_API_KEY,
},
});Приберіть блок — і нічого не перевіряється. Залежностей не додається в жодному разі:
запит іде через fetch і SHA-256, які в Node уже є.
Прочитайте це, перш ніж покладатися
Надсилається лише хеш. Файл — ні. Перевірка — це пошук SHA-256 файлу, тож із вашого сервера не йде нічого, з чого можна відновити фотографію клієнта.
Саме цей вибір визначає можливості, і обмеження тут — головне:
Воно впізнає шкідливе, про яке хтось уже повідомив. Воно не досліджує файл. Зразок, зроблений сьогодні вранці, йому невідомий.
Це варте того — відомі зразки і є більшістю того, що реально приходить, — і це не
заміна сканеру, який читає байти. Потрібен такий — передайте check і зверніться до
ClamAV через сокет або до того, що вже працює у вашій організації. Усе нижче стосується
і вашого сканера.
Де саме це відбувається
Після того, як байти лягли на диск під тимчасовим випадковим іменем, і до того, як файл дістав справжнє. Відхилений файл ніколи не існував під іменем, яке щось могло б віддати, а його тимчасова копія видаляється. Файл, відхилений раніше з іншої причини — не той тип, завеликий — до сканера взагалі не потрапляє й не коштує запиту.
Налаштування
| Налаштування | Типове | Що робить |
|---|---|---|
| service | — | 'virustotal', єдиний вбудований |
| apiKey | — | Обов'язковий для цієї служби |
| check(file, signal) | — | Ваш власний сканер; отримує { sha256, name, type, size } |
| onUnknown | 'accept' | Що робити з файлом, якого база ніколи не бачила |
| onError | 'reject' | Що робити, коли сама перевірка не вдалася |
| timeoutMs | 5000 | Скільки може тривати одна перевірка |
onUnknown типово accept, бо майже нічого з того, що завантажує звичайна людина,
ніколи не потрапляло до баз шкідливого ПЗ. 'reject' відхиляв би майже кожне справжнє
фото.
onError типово reject — із протилежної причини. Якщо ви попросили перевірку, а
вона не відбулася, прийняти файл усе одно означає вважати себе захищеним, не будучи ним, —
і на екрані ніщо про це не скаже. Ставте 'accept', якщо волієте, щоб завантаження
працювали крізь аварію, розуміючи, чим платите.
Аварія, яку ви справді зустрінете
Безкоштовний тариф VirusTotal дозволяє чотири запити на хвилину. Понад це він
відповідає 429, а з типовим onError це означає відмову SCAN_FAILED, доки хвилина не
мине. На публічній формі це черга роздратованих людей, тож перш ніж вмикати всерйоз:
подивіться ліміт свого тарифу, тримайте maxFiles помірним або сховайте перевірку за
власну чергу через check.
Що бачить відвідувач
| Код | HTTP | Повідомлення мовою віджета |
|---|---|---|
| INFECTED | 422 | Файл позначено як шкідливий |
| NOT_SCREENED | 422 | Файл не вдалося перевірити (лише за onUnknown: 'reject') |
| SCAN_FAILED | 503 | Перевірка недоступна, спробуйте ще раз |
Усі три перекладені п'ятьма мовами. Чому перевірка не вдалася — хибний ключ, ліміт
запитів, тайм-аут — іде в onWarning, а не клієнтові: ключ API чи назва внутрішньої
служби не для стороннього ока.
Власний сканер
scan: {
check: async ({ sha256, name, type, size }, signal) => {
const response = await fetch('http://scanner.internal/lookup', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ sha256 }),
signal, // шануйте його, інакше timeoutMs не врятує
});
const { infected } = await response.json();
return { verdict: infected ? 'malicious' : 'clean' };
},
}Повертайте { verdict } зі значенням 'clean', 'malicious' або 'unknown' і за
бажання detail, який поїде клієнтові як params помилки. Будь-що інше вважається
збоєм і обробляється за onError — сканер, що відповів нісенітницею, ніколи не буде
прочитаний як «чисто».
Скільки один клієнт може завантажити
maxFiles і maxRequestSize — це межі одного запиту, і нічим іншим вони ніколи не
були. Цього перестало вистачати тієї миті, коли віджет почав слати по одному файлу на запит,
щоб обрив не коштував цілої партії: за одного файлу на запит межа у двадцять файлів на запит
не обмежує нічого, що хтось назвав би двадцятьма файлами.
Заміряно, доки цього не було, — межі три файли й 20 КБ, десять запитів по одному файлу на 8 КБ:
усе одним запитом: 5 файлів → прийнято 2, TOO_MANY і TOTAL_TOO_LARGE ✓
по одному на запит: 10 файлів → прийнято 10, 80 КБ крізь межу в 20 КБ ✗perClient рахує через запити:
createUploadHandler({
root: './uploads',
limits: {
perClient: { files: 50, bytes: 200 * 1024 * 1024, windowMs: 60_000 },
},
});Ті самі десять запитів тепер спиняються після двох, витративши 16 КБ із бюджету в 20 КБ.
Ключем є те, чого клієнт не може змінити. Сеанс, коли налаштовано sessions, інакше —
адреса з'єднання. Поле форми не годиться: їх пише той, хто завантажує.
Відмова стається до читання тіла. Content-Length — це твердження самого клієнта, тож
воно йде лише на ранню відповідь: якщо применшити, втратиться тільки вона, бо бюджет
обмежує файл ще раз під час читання байтів. Партія, у якої бюджет скінчився посеред шляху,
зберігає те, що долетіло, а решту позначає QUOTA. Пакет, з якого не долетіло нічого, дістає
429 і Retry-After — однаково, чи бюджет скінчився до читання тіла, чи вже під час нього:
та сама відмова не повинна приходити двома різними статусами.
Місце займається тоді, коли файл ось-ось буде збережено, а не перевіряється з наступним записом. Перевірити спершу, а записати потім — це гонка, і не теоретична: вісім запитів, надісланих одночасно, усі пройшли межу в три, бо кожен дивився до того, як хоч один щось записав. Файл, який потім не склався, повертає своє місце, а не витрачає бюджет того, кому відмовили.
У пам'яті цього процесу. За двох екземплярів кожен рахує своє, тож справжня стеля — це число, помножене на їхню кількість. Спільне сховище — робота для бази, якої в цьому пакеті немає і не буде. Клієнтів, чиє вікно минуло, забувають у міру користування лічильником, тож мапа лишається обмеженою без таймера, який тримав би процес живим.
Обмеження швидкості завантаження
Вимкнено за замовчуванням. Задайте швидкість — і сервер читатиме саме так повільно:
createUploadHandler({
root: './uploads',
limits: { maxBytesPerSecond: 512 * 1024 }, // 0 — без обмеження
});Заміряно на живій точці: 256 КБ при 256 КБ/с зайняли 1004 мс, при 128 КБ/с — 2002 мс, файли на диску байт у байт ті самі.
Це має бути сервер, і це не вибір
Браузер не дає JavaScript жодного важеля над тим, як швидко він надсилає тіло запиту.
Ні в XMLHttpRequest, ні в його об'єкті upload, ні у fetch. Перевірено, а не припущено:
XMLHttpRequest.prototype — жодного члена про rate, throttle чи speed
XMLHttpRequestUpload — так самоЄдиний механізм, який це дозволив би, — ReadableStream як тіло запиту, який годують
повільно. Він вимагає HTTP/2 і є в Chrome та Edge, але не в Safari чи Firefox; на звичайній
точці HTTP/1.1 він просто падає — саме це й сталося на демо-сервері.
Тому обмеження живе там, де його справді можна втримати. Повільне читання заповнює вікно TCP, і відправник мусить чекати — це доходить до браузера, нічого в нього не питаючи.
Що воно захищає, а що ні
Воно захищає ваш сервер: його канал, його диск і решту людей, що ним користуються.
Воно не робить сторінку відвідувача чуйнішою. Його браузер і далі віддає байти на повній швидкості в буфери операційної системи; він просто не може позбутися їх швидше. Якщо мета — «щоб завантаження не з'їдало весь канал відвідувача», то це не воно, і в браузері такого немає взагалі.
Воно на завантаження, а не на сервер. Двоє одночасних дістають по повній швидкості
кожен: заміряно — два завантаження по 256 КБ при ліміті 256 КБ/с завершились разом за
1037 мс, тобто сумарно близько 494 КБ/с. За maxConcurrent у польоті стеля дорівнює цьому
числу, помноженому на них, — обирайте його з урахуванням цього множення.
Воно приблизне. Пауза міряється відносно всього передавання, а не останнього шматка, тож перебір надолужується далі, а не накопичується. Через справжній сокет це лягає в межах відсотка; якщо годувати величезними шматками з пам'яті — може вийти відсотків на десять більше, бо пауза не забирає назад шматок, який уже в дорозі.
Куди потрапляють файли
На локальний диск у теку root, якщо не сказати інакше. Так робили всі версії до цієї, і
так потрібно більшості.
Для чогось іншого — S3 або контейнер, чий диск зникає разом із ним, — передайте backend:
import { createUploadHandler, createS3Storage } from 'image-drop-upload/server';
createUploadHandler({
root: './uploads', // усе одно потрібна: див. нижче
storage: createS3Storage({
bucket: 'photos',
region: 'eu-central-1',
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
}),
});Без SDK
Запит — це PUT із заголовком Signature Version 4, порахованим через node:crypto. Цей
алгоритм опублікований, а не закритий, і виписати його — саме те, що не дає модулю, чия
суть у відсутності залежностей, обзавестися їхнім деревом заради одного HTTP-запиту.
Перевірено еталонними векторами самої AWS — тим самим ключем, регіоном і моментом, що в їхній
документації, — тож арифметика звірена з автором, а не з моїм її прочитанням.
endpoint наводить той самий код на будь-що S3-сумісне: Cloudflare R2, MinIO, DigitalOcean
Spaces, Backblaze B2.
createS3Storage({
bucket: 'photos',
region: 'auto',
endpoint: 'https://<account>.r2.cloudflarestorage.com',
accessKeyId: …, secretAccessKey: …,
prefix: 'incoming', // тека в кошику
acl: 'public-read', // не вказали — вирішує кошик
publicUrl: (key) => `https://cdn.example/${key}`, // якщо попереду CDN
conditionalWrites: true, // так за замовчуванням; див. нижче
});root усе одно потрібна, і це навмисно
Файл спершу лягає в тимчасовий локальний файл, там читається й перевіряється, і лише тоді йде далі. Усе, що пакет відхиляє — тип за байтами, заявлені розміри, обсяг, перевірку на шкідливий вміст — можна вирішити, лише подивившись на файл цілком. Нічого з цього не можна спитати про байти, які вже полетіли в кошик, а файл, відхилений уже після того, як він там опинився, — це файл, який комусь доведеться йти й видаляти.
Тож диск використовується як робоче місце, а не як сховище: тимчасова копія зникає незалежно
від того, взяв backend файл чи відмовив. Місця в кожен момент треба приблизно
maxFileSize × maxConcurrent.
Власний backend
Два методи, і обов'язковий лише перший:
const storage = {
async put(name, filePath, { type, size, sha256, overwrite }) {
// filePath — локальний тимчасовий файл, уже перевірений. Поверніть, куди він ліг.
// Якщо не `overwrite` — зайняте ім'я відхиліть, кинувши
// UploadError(409, 'EXISTS', …), і буде спробуване наступне.
return { key: name, url: `https://files.example/${name}` };
},
async exists(name) {
return false; // необов'язковий: береже від надсилання тіла, яке відхилять
},
};Ім'я займає сам запис, а не питання перед ним. exists(), а потім put() — це два
питання з проміжком посередині, і в цей проміжок встигає хтось інший: виміряно, чотири
завантаження одного імені, надіслані разом, лишили в кошику один об'єкт, а всім чотирьом
сказали, що файл збережено. Тому S3-бекенд пише з If-None-Match: *, і служба відповідає 412,
коли ключ зайнятий, — та сама гарантія, яку на диску дає O_EXCL. exists() лишається
дешевим поглядом наперед, щоб не слати тіло, яке відхилять, а у власному бекенді його можна
не робити взагалі.
conditionalWrites: false вимикає це для служби, яка не приймає такого заголовка. Це єдиний
спосіб втратити захист, і ціна — та сама гонка вище.
onConflict працює так само, як на диску: rename додає суфікс, refuse відповідає 409,
overwrite не питає — і з overwrite жодної умови не надсилається, бо він означає те, що
означає. З sessions і scope: 'directory' id сеансу стає текою в кошику так само, як на
диску; з scope: 'label' пласко в обох.
Що бачить клієнт
{ "name": "photo.png", "original": "photo.png", "size": 51234, "type": "image/png",
"key": "incoming/photo.png", "path": "https://cdn.example/incoming/photo.png",
"etag": "\"9b2c…\"" }path — це URL: той, що дав publicUrl, або власний URL кошика. key — де файл лежить у
кошику. Локальний root не повідомляє жодного з трьох: у відповіді був би шлях на вашій
файловій системі, а це нікого, крім вас, не стосується.
Нічого про нутрощі вашого кошика до того, хто завантажує, не доходить: відмову служби
записано з причиною через onWarning, а клієнтові сказано просто «файл не вдалося
зберегти».
Безпека
Наскрізний принцип: вирішують власні байти файлу, і сервер вирішує ще раз.
Розпізнавання. File.type браузер бере з відповідності розширень в операційній
системі, тож перейменувати payload.exe на photo.png достатньо, щоб він назвався
image/png. Обидві сторони натомість читають перші байти. Клієнтська копія існує, щоб
пояснити людині причину; сервера — щоб мати вагу, бо все, що досяжне по HTTP, можна
надіслати curl і взагалі без браузера.
SVG — лише за запитом. Це єдиний формат зображення, який є XML і може нести скрипт. Решта приймається за замовчуванням; цей треба попросити окремо.
Імена. Відхиляти, а не лагодити. Вихід за межі в будь-якому написанні, нульові байти, символи, щодо яких файлові системи не згодні, кінцеві крапки й пробіли, зарезервовані імена Windows — усе це відхиляється, а не переписується: ім'я, яке треба переписати, щоб воно стало безпечним, зазвичай належить спробі. Шлях із завантаження теки вкорочується до останнього сегмента ще до всього цього.
Утримання в межах. Куди потрапить файл, перевіряється двічі: лексично й проти
розв'язаного кореня. Жодної з перевірок окремо не досить — друга ловить символьне
посилання, підкладене в теку завантажень і скероване на /etc.
Запис. Кожен файл іде під тимчасовим іменем і перейменовується лише після успіху, тож
обірване завантаження не лишає обрізаного файлу під справжнім іменем. Остаточне ім'я
займається через O_EXCL, тож «чи вільне» і «зайняти» — одна операція; інакше два запити
з однаковим іменем пройшли б перевірку обидва, і один файл зник би.
Міждоменні запити. multipart/form-data — простий запит: preflight для нього не
робиться, тож звичайна <form> на будь-якому сайті могла б відправити сюди файли під
сесійною cookie користувача. Перевірка Origin це закриває. Запит зовсім без браузерних
заголовків — curl, виклик між серверами — пропускається.
Обмеження рахуються за фактично отриманими байтами, а не за Content-Length — це
твердження відправника, а в chunked-запиті його немає взагалі.
Заявлений розмір. PNG на 30 КБ може сказати, що він 40000×40000: тридцять кілобайтів на
дроті й шість гігабайтів, щойно щось його декодує. Браузер такі відхиляє, але браузер — не
те, чим користується зловмисник: curl надсилає такий файл повз перевірку, яка не виконується.
Тому сервер сам читає розміри із заголовка, нічого не декодуючи, і відхиляє файл ще під час
передавання. Стелю задає limits.maxPixels, типово 50 мегапікселів; 0 вимикає перевірку.
TIFF тримає каталог після пікселів, а JPEG із великою вбудованою мініатюрою може відсунути свій заголовок кадру за межі того, що тримається в пам'яті. Обидва вимірюються з готового тимчасового файлу, до того як він дістане справжнє ім'я. Формат, розмір якого прочитати не вдалося, пропускається — щоб відсутній парсер не перетворився на зламану точку прийому.
У DOM. Нічого не збирається конкатенацією рядків в innerHTML; у помічника, яким
користується віджет, такої лазівки немає. Файл на ім'я <img src=x onerror=alert(1)>.png
малюється як цей текст і більше ніяк.
Чого він не робить
- Не автентифікує, а частоту обмежує лише стелею одночасних завантажень — обмеження на клієнта ставиться перед ним.
- Не перевіряє на шкідливий вміст, доки ви це не ввімкнете, а ввімкнути можна пошук за хешем: він упізнає зразки, про які вже хтось повідомив, і не досліджує файл. «Це справжнє зображення» — не те саме, що «це безпечне зображення».
- Не обробляє зображення, доки ви це не ввімкнете. Без блоку
compressнічого не перекодовується, не масштабується і не очищається, а EXIF — включно з координатами GPS — зберігається як прийшов. - Не досліджує самі пікселі: розміри читаються із заголовка — саме з того, чому повірив би декодер, — але ніщо тут не декодує зображення, щоб переконатися, що воно таке і є.
server/standalone.js— сервер для розробки без жодної автентифікації. Він не входить до npm-пакета.
Розробка
npm install
npm run dev # API + Vite з гарячим перезавантаженням -> http://localhost:5173
npm test # 584 тести, і ще 10 зі справжнім S3-сервером (нижче)
npm run build # бібліотека -> dist/
npm run build:demo # демо-сторінка -> demo-dist/
npm start # зібрати демо і віддати без ViteПараметри сервера розробки: --port, --host, --root, --svg, --vite.
Перевірка S3 на справжньому сервері
Решта тестів S3 підсовує бекенду підроблений fetch і дивиться, що той збирався надіслати.
Це доводить, що запит саме такий, як задумано, але не доводить, що сервер із ним погодиться,
— а підпис запиту якраз із тих речей, які виглядають правильно, доки з того боку не прилетить
403. Тому вісім тестів працюють зі справжнім сервером і пропускаються, коли його немає:
docker run -d --name minio -p 9000:9000 \
-e MINIO_ROOT_USER=user -e MINIO_ROOT_PASSWORD=password123 \
quay.io/minio/minio server /data
docker run --rm --network host --entrypoint /bin/sh quay.io/minio/mc \
-c "mc alias set local http://127.0.0.1:9000 user password123 && mc mb -p local/uploads"
S3_ENDPOINT=http://127.0.0.1:9000 S3_BUCKET=uploads \
S3_KEY=user S3_SECRET=password123 npm testПідійде будь-що, що говорить мовою S3; CI ганяє їх по MinIO на кожен пуш. Об'єкти лягають під префікс, унікальний для прогону, тож прогони не заважають один одному.
Що можна покрутити в демо
Сторінка демо — не частина пакета; вона існує, щоб кожну опцію можна було ввімкнути й побачити, а не прочитати про неї:
| Кнопка | Що показує |
|---|---|
| Language, Theme, Custom palette | Мова, схема, власні кольори — усе без перезавантаження |
| SVG | Як виглядає відмова, коли формат не дозволено |
| Compress | off, lossless (лише метадані), auto (0.85), 0.6, 0.3 |
| Resize | off, 1920, 1024, 512, 128×128 px |
| Malware check | Вмикає перевірку на сервері |
| Auto retry | Три спроби зі зростаючою паузою замість однієї |
| Reorder | Перетягування плиток або вимкнено |
| Paste | Усередині віджета, на всій сторінці або вимкнено |
| Camera button | Примусово показує кнопку, щоб її було видно на комп'ютері |
| Break the next upload | Наступне завантаження дістане 503 один раз, щоб побачити повтор |
| Upload history | Прибирає панель історії — вона належить сторінці, не віджету |
Історія показує обидва розміри, тож економію видно одразу:
change: 1 file(s), 6.8 KB — was 783.1 KB, saved 776.4 KB (99%)
photo.jpg: 783.1 KB -> 6.8 KBПеревірка на шкідливі файли в демо — симуляція. Щоб демо не вимагало акаунта й ключа
API (і щоб нічиї фотографії не їхали до третьої сторони), вона позначає як шкідливий
будь-який файл, в імені якого є virus. Перейменуйте фото на photo-virus.jpg, увімкніть
перевірку, натисніть «Завантажити» — і побачите відмову. Справжня — це
scan: { service: 'virustotal', apiKey }.
Структура
src/ the widget
drop-preview.js the class: queue, previews, upload
core/
files.js magic-number identification
validate.js limits and the accept/refuse decision
uploader.js XHR upload with progress and cancellation
i18n.js dictionary lookup, plural rules, fallback
theme.js host colours, fonts and metrics
format.js byte formatting
locales/ en, uk, es, de, fr — one plain object each
ui/dom.js element building with no innerHTML escape hatch
server/ the back end
handler.js the routes and the multipart reading
upload-service.js storing: sniffing, limits, temp file, atomic rename
safe-name.js names and containment
sniff.js the same identification, server side
http.js the small router it runs on; no framework
dimensions.js які розміри заявляє зображення, із заголовка
sign-v4.js підпис запитів до S3, лише через node:crypto
storage/s3.js backend для S3; більше нічому не потрібен
types/ hand-written .d.ts, checked by `npm run typecheck`
demo/ the demonstration page
test/ testsОбмеження
- Тільки зображення. Таблиця розпізнавання знає десять форматів; решта відхиляється свідомо.
- Перегляд — це
background-imageна div, тож анімований GIF або WebP анімується в плитці так, як вирішить браузер; кадри не витягуються. - Стиснення робить canvas браузера, тож стиснення без втрат тут немає: стиснути JPEG, не чіпаючи пікселів, можна лише кодеком, а кодек — це залежність. Видалення метаданих точне; усе інше перекодовує.
- Розміри читаються із заголовка, а не вимірюються: файл, що заявляє скромний розмір, а містить щось інше, буде збережено. Перевірка зупиняє протилежний і значно корисніший випадок — малий файл, що заявляє величезний.
- Черга тримається в прихованому input через
DataTransfer; його підтримують усі сучасні браузери, але запасного шляху немає — без нього файли не поїдуть зі звичайною формою. - HEIC і AVIF розпізнаються й завантажуються, але браузер, який їх не декодує, покаже порожню плитку; перетворення на сервері немає.
- Найменше, що можна надіслати повторно, — файл.
filesPerRequestдорівнює 1, тож обрив зв'язку продовжується з того файлу, на якому спинився, а не з початку черги, — але всередині файлу розбиття немає, тож один дуже великий файл на поганому зв'язку — це досі «усе або нічого». - Віджет використовує container queries і
:has()— потрібен браузер 2023 року або новіший. - Node 20+.
Ліцензія
MIT © Mykhailo Kravtsov.
