bookmark-file-manager
v1.0.1
Published
Embeddable file manager: a dependency-free front-end widget and a framework-free Node handler over a real filesystem
Maintainers
Keywords
Readme
Bookmark_file-manager
Вбудовуваний файловий менеджер: фронтенд-віджет без залежностей і Express-роутер поверх реальної файлової системи. Усі вісім операцій панелі — робочі.
Опубліковано в npm — bookmark-file-manager:
npm install bookmark-file-manager- Віджет — звичайний JS-клас, монтується в будь-який DOM-елемент. Не тягне за собою Bootstrap, jQuery, шрифти іконок і взагалі нічого: нуль runtime-залежностей. Збірка — 98 КБ JS + 7 КБ CSS у gzip; з них ~26 КБ — чотири неанглійські мови (лише з англійською вийшло б 73 КБ).
- П’ять мов із коробки — англійська (типова), українська, іспанська, німецька, французька. Своя мова — це об’єкт зі словником.
- Бекенд — одна функція
(req, res)без фреймворка: монтується в Express, AdonisJS, Fastify, Nest або голийnode:http. Власного сервера не піднімає і глобального стану не тримає. - Працює в React / Vue / Angular / Svelte / без фреймворка — це просто DOM.

🇬🇧 This page in English — головна версія документації.
Встановлення
npm install bookmark-file-managerЖодного фреймворка ставити не треба — ні на клієнті, ні на сервері.
Швидкий старт
Сервер
Одна функція, без фреймворка. Вона має вигляд (req, res) — саме те, що приймає
Express як мідлвару і що будь-який Node-фреймворк може віддати зі своїх сирих
об'єктів.
import express from 'express';
import { createFileManagerRouter } from 'bookmark-file-manager/server';
const app = express();
app.use('/api/files', createFileManagerRouter({
root: './storage', // єдиний каталог, який видно клієнту
}));
app.listen(3000);Express тут — ваш вибір, а не вимога: те саме в Adonis, Fastify, Nest або
голому node:http.
Клієнт
import { FileManager } from 'bookmark-file-manager';
import 'bookmark-file-manager/style.css';
const manager = new FileManager('#file-manager', {
endpoint: '/api/files',
});<div id="file-manager" style="height: 600px"></div>Контейнеру потрібна висота — віджет займає її цілком.
Що вміє
Вісім основних операцій. За замовчуванням їхніх кнопок на панелі немає — операції
викликаються з контекстного меню та гарячими клавішами. Повернути кнопки:
toolbarActions: true (див. нижче).
| Кнопка | Що робить |
| --- | --- |
| Створити теку | Діалог імені, перевірка зайнятості до надсилання на сервер |
| Створити файл | Те саме; розширення визначає значок |
| Перемістити | Вибір теки призначення в дереві; теку не можна перемістити всередину себе |
| Копіювати | Рекурсивно, з автоперейменуванням у разі конфлікту |
| Перейменувати | Рівно для одного вибраного елемента, F2 |
| Видалити | З підтвердженням і переліком того, що видаляється, Delete |
| Завантажити на пристрій | Один файл — потоком; тека або мультивибір — ZIP на льоту |
| Завантажити | Мультизавантаження з поступом і скасуванням; drag & drop |
Крім цього:
- Дерево тек із лінивим підвантаженням рівнів
- Мініатюри зображень просто в плитках і перегляд зображень та PDF поверх віджета, зі стрілками по файлах теки (докладніше)
- Властивості файлу та зміна прав доступу з контекстного меню (докладніше)
- Пакування й розпакування 13 форматів архівів (докладніше)
- Пошук по всьому дереву: маски, регулярні вирази, розширення, лише файли або лише теки, за вмістом (докладніше)
- Термінал із командами файлового менеджера — ls, cd, cat, find, grep, mkdir, rm та іншими (докладніше)
- Перегляд і редагування таблиць: csv, xlsx, xls, ods (докладніше)
- Перегляд і редагування документів: docx, odt, doc; PDF — посторінково (докладніше)
- Редактор коду з підсвіткою та нумерацією рядків (докладніше)
- Два режими: плитка й таблиця із сортуванням за іменем / розміром / датою
- Клікабельні хлібні крихти, плитка
.., кнопки «вгору» і «скопіювати шлях» - Подвійний клік по порожньому місцю смуги крихт перетворює її на адресний рядок — шлях можна вписати вручну (докладніше)
- Копіювання віртуального шляху в буфер: із меню та кнопкою біля крихт
- Виділення: клік,
Ctrl/Cmd+клік,Shift+клік діапазоном,Ctrl+A - Drag & drop: перетягування між теками, деревом і крихтами; завантаження файлів з ОС
- Контекстне меню, клавіатурна навігація (стрілки,
Enter,Backspace,Home/End) - Фільтр за іменем у поточній теці
- Попередній перегляд у новій вкладці для тексту, зображень, PDF, аудіо та відео
- Рядок стану: кількість, обсяг, виділення, поточний шлях
- П’ять мов: англійська (типова), українська, іспанська, німецька, французька; своя мова — це об’єкт зі словником (докладніше)
- Світла й темна теми; кольори, шрифти й розміри задаються під час підключення (докладніше)
- Адаптація під ширину контейнера,
prefers-reduced-motion
Опції віджета
new FileManager(target, {
endpoint: '/api/files', // база REST API
provider: null, // власний провайдер замість HTTP (див. нижче)
locale: null, // 'en' | 'uk' | 'es' | 'de' | 'fr' | свій словник;
// null — англійська
theme: null, // кольори, шрифти й розміри; null — вбудована схема
rootLabel: null, // підпис кореня в дереві та крихтах; null — за мовою
initialPath: '/',
view: 'grid', // 'grid' | 'list'
iconBasePath: null, // тека зі значками за розширенням; null — мальовані
permissions: null, // { create, upload, move, copy, rename, remove,
// download, chmod }
customizeThumbnail: null, // (entry) => URL | '<svg…>' | null
preview: true, // перегляд зображень і PDF поверх віджета
thumbnails: true, // плитки малюються із самих файлів
sheets: true, // csv/xlsx/xls/ods відкриваються в редакторі таблиць
code: true, // текстові та вихідні файли — у редакторі коду
documents: true, // docx/odt/doc — у редакторі документів,
// pdf — у посторінковому (з контекстного меню)
search: true, // рекурсивний пошук: лупа, Enter у фільтрі, Ctrl+Shift+F
terminal: true, // панель термінала внизу; кнопка і Ctrl+` ховають її
showTree: true,
showToolbar: true, // сама верхня панель (пошук, оновити, вигляд)
toolbarActions: false, // кнопки дій на ній: false | true | ['upload', …]
showStatusBar: true,
showSearch: true,
confirmDelete: true,
headers: null, // об’єкт або функція — для токенів авторизації
credentials: 'same-origin',
});target — елемент або CSS-селектор.
Кнопки на панелі
За замовчуванням верхня панель несе лише пошук, «Оновити» та перемикач вигляду. Кнопок дій на ній немає:
new FileManager('#host'); // без кнопок
new FileManager('#host', { toolbarActions: true }); // усі
new FileManager('#host', { toolbarActions: ['upload', 'delete'] });
new FileManager('#host', { toolbarActions: TOOLBAR_PRIMARY_ACTIONS }); // лише вісім основнихКнопки — іконки, підпис у підказці при наведенні. Це не косметика: із двадцяти однієї операції підписи розгорнули б панель на три рядки і не лишили місця для списку файлів. Іконками вони вміщуються в один рядок (виміряно: 47 px заввишки при ширині 1280).
| Група | Ідентифікатори |
| --- | --- |
| Створення | newFolder, newFile, upload |
| Основні | move, copy, rename, delete, download |
| Відкриття | open, preview, sheet, code, document |
| Архіви | compress, extract, archiveContents |
| Права | executable, permissions |
| Інше | copyPath, properties, selectAll |
Повний перелік експортується як TOOLBAR_ACTIONS, вісім основних — як
TOOLBAR_PRIMARY_ACTIONS. Масив фільтрує цей перелік, але не змінює порядок:
будь-який набір читається зліва направо однаково, а роздільники ставляться лише
між непорожніми групами. Невідомий ідентифікатор відкидається з попередженням у
консоль — друкарська помилка, через яку кнопка мовчки не з’явилася, варта
втраченого вечора.
Навіщо на панелі все контекстне меню
На сенсорному пристрої правого кліку немає, і контекстне меню відкрити нічим. Без цих кнопок половина менеджера на планшеті просто недосяжна. Тому панель несе ті самі операції, що й меню.
Дві різні поведінки, навмисно розділені:
- Залежні від типу — «Розпакувати», «Відкрити таблицю», «Перегляд»,
«Зробити виконуваним» — з’являються лише тоді, коли доречні. Кнопка
розпакування на
.txtне «тимчасово вимкнена», вона просто не є частиною відповіді. - Залежні від кількості — «Перейменувати», «Властивості», «Видалити» — лишаються на місці й тьмяніють. Кнопка, що зникає й з’являється від зміни виділення, — рухома ціль для пальця.
Приховування кнопок прибирає ряд кнопок, а не операції. Усе лишається доступним:
| Операція | Звідки викликається без кнопки |
| --- | --- |
| Створити теку / файл, Завантажити | контекстне меню на порожньому місці |
| Перемістити, Копіювати, Перейменувати, Завантажити на пристрій, Видалити | контекстне меню на елементі |
| Перейменувати | F2 |
| Видалити | Delete |
| Завантажити | перетягування файлів у вікно |
| будь-яка | manager.createFolder(), manager.upload(), … |
showToolbar: false як і раніше прибирає панель цілком. Коли кнопок дій немає,
на панелі з’являється клас fsfm-toolbar-bare — зачіпка, якщо її потрібно перестилізувати
або сховати.
Мова
Типово віджет говорить англійською. Щоб узяти іншу — передайте locale:
new FileManager('#host', { locale: 'de' });Приймається 'en', 'uk', 'es', 'de', 'fr' або повний тег, чия основа збігається
з однією з них: 'de-AT' дасть німецьку. Нерозпізнане значення дає англійську, а не
помилку — друкарська помилка в конфізі не повинна класти файловий менеджер.
Мова змінює не лише слова: дати форматуються за тегом мови, одиниці розміру стають
місцевими (1.5 Ko замість 1.5 KB), а множина рахується за правилом мови — українська
має три форми (1 тека, 2 теки, 5 тек), решта дві. На корені віджета проставляється
атрибут lang.
Мова обирається під час монтування. Щоб перемкнути її на льоту — перемонтуйте віджет:
manager.destroy();
manager = new FileManager('#host', { ...options, locale: 'fr' });Своя мова
Якщо потрібної мови немає — передайте свій словник. Він може бути неповним: усе, чого в ньому немає, береться з англійської, тож перекласти десяток рядків і лишити решту — робочий варіант.
import { FileManager, PLURAL_RULES } from 'bookmark-file-manager';
new FileManager('#host', {
locale: {
id: 'sv',
name: 'Svenska',
tag: 'sv', // для дат і атрибута lang
plural: PLURAL_RULES.default, // або .slavic для трьох форм
strings: {
'action.newFolder': 'Ny mapp',
'common.save': 'Spara',
'count.files': { one: '{n} fil', other: '{n} filer' },
},
},
});За основу зручно взяти готовий словник:
import { en, uk } from 'bookmark-file-manager';
console.log(Object.keys(en.strings).length); // скільки всього рядків
console.log(en.strings['action.newFolder']); // 'New folder'Готові словники доступні як звичайні експорти пакета (import { en, uk, es, de, fr }),
а в репозиторії лежать у src/locales/ — по одному файлу на мову, звичайні об’єкти,
редагуються руками. Тест стежить, щоб набір ключів у всіх п’яти збігався, щоб множинні
форми були множинними скрізь і щоб у перекладі не з’явився підстановник, якого немає
в англійській.
Усі п’ять мов лежать у збірці, щоб locale: 'de' працювало без другого імпорту й без
асинхронного кроку. Це коштує ~26 КБ gzip: збірка лише з англійською важить 73 КБ,
з усіма п’ятьма — 98 КБ.
Повідомлення сервера
Сервер відповідає англійською і не знає, якою мовою говорить конкретний віджет. Тому
разом із текстом він надсилає стабільний code і значення, які підставив:
{ "error": "Not found: /reports", "code": "NOT_FOUND", "params": { "path": "/reports" } }Віджет бере свій рядок за цим кодом (srv.NOT_FOUND) і будує речення сам. Якщо для коду
перекладу немає — показується англійський текст сервера: це гірше, ніж переклад, але
краще, ніж порожньо. Коди з кількома різними повідомленнями (INVALID_NAME,
INVALID_PATH) навмисно не перекладаються — вгадувати, яке саме сталося, було б гірше.
Оформлення: кольори, шрифти, розміри
Нічого не передали — віджет виглядає так, як у демонстрації. Це і є типова схема.
Щоб змінити:
new FileManager('#host', {
theme: {
// шрифти й розміри — по одному значенню, від схеми не залежать
font: "'Inter', system-ui, sans-serif",
fontSize: '15px',
codeFont: "'JetBrains Mono', monospace",
codeSize: '13px',
radius: '2px',
sidebarWidth: '280px',
tileWidth: '140px',
rowHeight: '44px',
// кольори — або одразу для обох схем…
colors: { accent: '#7c3aed', accentHover: '#6d28d9', accentSoft: '#ede9fe' },
// …або окремо для світлої та темної
light: { bg: '#fffdf7', text: '#2b2415' },
dark: { bg: '#0b1020', accent: '#a78bfa' },
},
});colors діє в обох схемах, light і dark його уточнюють. Якщо оформити лише
light, у темній схемі решта кольорів лишиться вбудованою — світлий фон уночі не
з'явиться.
Які ключі бувають
Шрифти й розміри: font, fontSize, codeFont, codeSize, codeLineHeight,
radius, radiusLarge, sidebarWidth, tileWidth, rowHeight.
Кольори (у colors, light або dark): bg, bgSubtle, bgSunken, border,
borderStrong, text, textMuted, textInverse, accent, accentHover,
accentSoft, accentContrast, danger, dangerHover, success, warning,
terminalBg, shadow, shadowLarge.
Підсвітка коду — вкладеним об'єктом tokens: comment, string, number,
keyword, type, builtin, tag, attr, property, variable, operator,
punctuation, heading, link, meta.
theme: { dark: { tokens: { string: '#9ece6a', keyword: '#bb9af7' } } }Списки доступні й з коду — METRIC_PROPERTIES, COLOR_PROPERTIES, TOKEN_PROPERTIES.
Дрібниці, про які варто знати
Значення — це звичайний CSS, тож rgb(124 58 237 / 90%), hsl(...), calc(...)
і навіть var(--ваша-змінна) працюють.
Невідомий ключ кидає помилку, а не мовчки ігнорується: acent замість accent не
має коштувати вам вечора. Якщо потрібної змінної в списках немає, передайте її як є —
ключ, що починається з --, проходить наскрізь:
theme: { '--fsfm-tile-width': '150px' }Значення перевіряються: те, що могло б закрити правило й відкрити своє (;, },
url(, коментарі), відхиляється з помилкою. Це має значення, якщо кольори у вас
приходять від ваших власних користувачів.
Тема діє на один віджет — два менеджери на сторінці можуть виглядати по-різному.
Якщо зручніше просто CSS
Тема — це лише зручний спосіб виставити ті самі CSS-змінні. Ніщо не заважає зробити це стилями:
.fsfm { --fsfm-accent: #7c3aed; --fsfm-font-size: 15px; }
.fsfm.fsfm-dark { --fsfm-accent: #a78bfa; }Права на операції
new FileManager('#host', {
permissions: { remove: false, move: false },
});Кнопки заборонених операцій прибираються з панелі та контекстного меню, а не показуються вічно неактивними. Пропущені ключі вважаються дозволеними.
Це лише UI. Сервер оголошує свій набір у /config, і діє перетин —
клієнт не може дозволити те, що заборонив сервер. Права на сервері задаються тією самою
опцією в роутера і перевіряються на кожному маршруті:
createFileManagerRouter({ root: './storage', permissions: { remove: false } });Поточний стан доступний як manager.permissions і manager.can('remove').
Власні значки
customizeThumbnail викликається для кожного елемента. Поверніть URL зображення, рядок із
<svg> або null, щоб використати значок за замовчуванням:
new FileManager('#host', {
customizeThumbnail: (entry) => {
if (entry.isDirectory) return null;
if (entry.name.endsWith('.psd')) return '/icons/photoshop.svg';
return null;
},
});Якщо зображення за URL не завантажиться, підставиться вбудований значок. Виняток усередині зворотного виклику перехоплюється і не ламає малювання списку.
Методи
await manager.ready; // перший лістинг намальовано
manager.navigate('/Documents');
manager.navigateUp();
manager.refresh();
manager.open(entry);
manager.preview(); // перегляд поточного виділення
manager.preview(entry); // або конкретного файлу
manager.showProperties(); // властивості виділеного
manager.editPermissions(); // редактор прав
manager.setExecutable(true); // +x на виділення
manager.compress(); // запакувати виділення (діалог формату)
manager.extract(entry); // розпакувати архів
manager.showArchiveContents(entry); // подивитися, що всередині
manager.openSheet(entry); // відкрити таблицю в редакторі
manager.openCode(entry); // відкрити файл у редакторі коду
manager.copyPath(); // шлях(и) виділеного в буфер
manager.copyPath('/Documents'); // або конкретний шлях
manager.setView('list');
manager.getSelection(); // масив вибраних елементів
manager.currentPath;
manager.readOnly;
manager.permissions; // чинний набір прав
manager.can('remove');
manager.createFolder(); // ті самі дії, що й кнопки
manager.createFile();
manager.moveSelection();
manager.copySelection();
manager.renameSelection();
manager.deleteSelection();
manager.downloadSelection();
manager.upload();
manager.destroy(); // знімає все, включно з обробникамиПодії
manager.on('ready', ({ path, readOnly }) => {});
manager.on('navigate', ({ path, listing }) => {});
manager.on('selectionchange', ({ selection }) => {});
manager.on('open', ({ entry }) => {});
manager.on('created', ({ entry }) => {});
manager.on('renamed', ({ from, to }) => {});
manager.on('moved', ({ entries, destination }) => {});
manager.on('copied', ({ entries, destination }) => {});
manager.on('deleted', ({ paths }) => {});
manager.on('uploaded', ({ entries, failures, destination }) => {});
manager.on('download', ({ entries, asArchive }) => {});
manager.on('viewchange', ({ view }) => {});
manager.on('preview', ({ entry }) => {});
manager.on('chmod', ({ entries, executable }) => {});
manager.on('archived', ({ entries, archive, format }) => {});
manager.on('extracted', ({ entry, result }) => {});
manager.on('codeopen', ({ entry, document }) => {});
manager.on('codesaved', ({ entry, result }) => {});
manager.on('sheetopen', ({ entry, workbook }) => {});
manager.on('sheetsaved', ({ entry, result }) => {});
manager.on('pathcopied', ({ paths, text }) => {});
manager.on('error', ({ error, message }) => {});on() повертає функцію відписки.
Куди його можна змонтувати
createFileManagerHandler() повертає звичайну функцію (req, res) над сирими
об'єктами Node. Жодної залежності від фреймворка в ній немає — тому монтується всюди,
де до цих об'єктів можна дотягтися.
basePath — це префікс, який треба відрізати від URL. Express переписує req.url сам,
тож там його вказувати не треба; Adonis, Fastify і node:http не переписують.
AdonisJS
// start/routes.ts
import app from '@adonisjs/core/services/app'
import router from '@adonisjs/core/services/router'
import { createFileManagerHandler } from 'bookmark-file-manager/server'
const files = createFileManagerHandler({
root: app.makePath('public'), // ваша папка
basePath: '/api/files',
})
router.any('/api/files/*', ({ request, response }) =>
files(request.request, response.response))Fastify
const files = createFileManagerHandler({ root: './storage', basePath: '/api/files' })
fastify.all('/api/files/*', (req, reply) => files(req.raw, reply.raw))NestJS
const files = createFileManagerHandler({ root: './storage', basePath: '/api/files' })
@All('api/files/*')
handle(@Req() req: Request, @Res() res: Response) {
return files(req, res)
}Голий node:http
const files = createFileManagerHandler({ root: './storage' })
http.createServer(files).listen(3000)Express
app.use('/api/files', createFileManagerRouter({ root: './storage' }))createFileManagerRouter — та сама функція під іменем, до якого звикли користувачі
Express. Нічого міняти не треба.
Чого ви позбавляєтесь, обходячи Express
Нічого: перевірка Origin (CSRF), nosniff, CSP на завантаженнях, ліміти тіла й черга
на обробку зображень — усе це всередині обробника, а не в Express. Єдине, що дає саме
Express, — маршрутизація, а її тут і так немає: усі 27 маршрутів мають фіксовані шляхи.
Якщо хочете реєструвати маршрути самі:
files.paths() // [{ method: 'GET', path: '/list' }, …]
files.fsOps // сам рушій, якщо REST вам не потрібен взагаліОпції роутера
createFileManagerRouter({
root: './storage', // обов’язково; створюється, якщо немає
readOnly: false, // відхиляти всі операції, що змінюють
// Точкові заборони. Пропущене — дозволено. readOnly: true гасить усі записи
// і не може бути перебитий цим об’єктом.
// chmod за замовчуванням ЗАБОРОНЕНО; archive і extract успадковують значення create.
permissions: { create: true, upload: true, move: true, copy: true,
rename: true, remove: true, download: true, chmod: false,
archive: true, extract: true, edit: true },
// Ліміти. Кожен — стеля, а не підказка.
maxUploadSize: 100 * 1024 * 1024, // байтів на один файл
maxUploadFiles: 50, // файлів в одному запиті
maxRequestSize: 1024 * 1024 * 1024, // байтів на весь запит завантаження
maxConcurrentUploads: 8, // одночасних завантажень; далі 503
maxBatchPaths: 1000, // елементів у move/copy/delete/download/chmod
maxSizeWalkEntries: 200000, // стеля обходу під час підрахунку розміру теки
// Архіви. Без зовнішніх утиліт лишаються тільки zip, tar, tar.gz і gz.
archiveTools: true,
archiveLimits: {
maxEntries: 100000, // записів в архіві
maxTotalBytes: 5 * 1024 * 1024 * 1024, // розпакований обсяг цілком
maxEntrySize: 2 * 1024 * 1024 * 1024, // один запис
toolTimeout: 10 * 60 * 1000, // скільки може працювати утиліта
},
minFreeSpace: 64 * 1024 * 1024, // відмовляти в завантаженні нижче цього порога
maxListEntries: 50000, // стеля видачі /list; 0 — без стелі
maxTreeChildren: 2000, // підкаталогів на вузол дерева
concurrency: 32, // паралельних stat/readdir на запит
// Вільне місце — це диск сервера, а не файли користувача.
// За замовчуванням /config його не показує.
exposeUsage: false,
// Мініатюри. Розміри — білий список: довільний ?size= перетворив би
// ендпоінт на спосіб зайняти процесор рендерами, яких ніхто не перепитає.
thumbnails: true,
thumbnailSizes: [64, 128, 256],
maxRawThumbnailSize: 512 * 1024, // стеля для віддачі файлу як є
thumbnailPixelLimit: 50 * 1024 * 1024, // захист від «архівних бомб» у пікселях
maxConcurrentThumbnails: 4, // одночасних рендерів зображень
maxQueuedThumbnails: 64, // скільки чекають у черзі; далі 503
// Запити, що змінюють дані, приймаються лише з того самого origin. Можна задати
// список, функцію або false (тільки якщо у вас власний захист від CSRF).
allowedOrigins: ['https://dashboard.example'],
// Викликається перед кожною операцією. false -> 403.
// context = { route, paths, destination, name }
authorize: (req, action, context) =>
context.paths.every((p) => p.startsWith(`/users/${req.user.id}`)),
onWarning: (message, detail) => logger.warn(message, detail),
});authorize
Хук отримує третім аргументом те, чого стосується операція, а не лише її маршрут:
| Поле | Значення |
| --- | --- |
| route | маршрут усередині роутера, наприклад /delete |
| paths | віртуальні шляхи, які операція читає або пише |
| destination | каталог призначення для move/copy/create/upload, інакше null |
| name | нове ім’я для create/rename, інакше null |
| pending | true на першому з двох викликів для /upload |
Для /upload хук викликається двічі: до розбору тіла (pending: true,
destination ще невідомий) і повторно — з остаточним каталогом призначення,
перш ніж буде записано хоч один байт. Так зроблено тому, що каталог може
приїхати не лише в ?path=, а й полем форми; перевірка самого лише
req.query.path перевіряла б значення, якого завантаження не зобов’язане дотримуватися.
Роутер не тримає глобального стану — можна змонтувати кілька коренів:
app.use('/api/public', createFileManagerRouter({ root: './public' }));
app.use('/api/private', createFileManagerRouter({ root: './private', readOnly: true }));REST API
| Метод | Маршрут | Призначення |
| --- | --- | --- |
| GET | /config | Ліміти й режим (вільне місце — лише за exposeUsage) |
| GET | /list?path= | Вміст теки |
| GET | /tree?path=&depth= | Лише каталоги, для дерева |
| GET | /stat?path= | Один елемент |
| GET | /thumbnail?path=&size= | Мініатюра зображення; ETag/If-None-Match |
| GET | /render?path=&width= | Показувана версія tiff/heic; ETag/If-None-Match |
| GET | /properties?path=&size= | Повні метадані; size=1 рахує розмір теки |
| GET | /archive/list?path= | Вміст архіву без розпакування |
| GET | /sheet?path= | Таблиця як книга (аркуші й комірки) |
| GET | /document?path= | Документ: блоки тексту або сторінки PDF |
| GET | /search?path=&query=&mode=&type=&scope=&extensions=&caseSensitive= | Пошук по дереву |
| GET | /text?path= | Файл як текст, із кодуванням і переносами рядків |
| GET | /download?paths=… | Файл потоком або ZIP; Range, ETag/If-None-Match |
| POST | /directory | { path, name } |
| POST | /file | { path, name, content } |
| POST | /rename | { path, name } |
| POST | /move | { paths[], destination, overwrite? } |
| POST | /copy | { paths[], destination, overwrite? } |
| POST | /delete | { paths[] } |
| POST | /chmod | { paths[], mode \| executable, recursive? } |
| POST | /archive | { paths[], format, destination?, name? } |
| POST | /extract | { path, destination? } |
| POST | /sheet/save | { path, workbook, format? } |
| POST | /document/save | { path, document, format? } |
| POST | /document/pages | { path, plan[], sources?[], target? } — сторінки PDF |
| POST | /text/save | { path, text, encoding?, bom?, newline? } |
| POST | /upload?path= | multipart/form-data |
Усі шляхи — віртуальні, відносно root (/Documents/файл.txt). Абсолютні шляхи
сервера у відповіді не потрапляють.
Шлях під час адресації перевіряється на дві речі: нульовий байт і .. (зокрема
записаний через зворотний слеш — у Windows він роздільник). Усе інше, що
допускає файлова система, адресується як є: звіт?.txt, note:2026.md,
а\б.txt — звичайні імена в Linux і macOS, і менеджер зобов’язаний їх показувати.
Імена, які клієнт просить створити, перевіряються суворіше: / \ : * ? " < > |
та керівні символи відхиляються (INVALID_NAME). Це правило переносимості —
створений тут файл має пережити копіювання на Windows. Читати таке ім’я
можна, створювати — ні.
Помилки повертаються як { error, code } з осмисленим HTTP-статусом:
INVALID_NAME, INVALID_PATH, OUTSIDE_ROOT, NOT_FOUND, EXISTS, READ_ONLY,
PERMISSION_DENIED, INTO_SELF, TOO_LARGE, NO_SPACE, CROSS_ORIGIN, TOO_MANY,
BUSY, INVALID_MODE, SPECIAL_BITS_REFUSED, CHMOD_UNSUPPORTED, NOT_AN_ARCHIVE,
FORMAT_UNAVAILABLE, FORMAT_READ_ONLY, FORMAT_SINGLE_FILE, ARCHIVE_TOO_LARGE,
ARCHIVE_ENTRY_TOO_LARGE, ARCHIVE_TOO_MANY, ARCHIVE_UNREADABLE, ARCHIVE_TIMEOUT,
NOT_VIEWABLE, NO_RENDER, NOT_A_SHEET, NOT_A_WORKBOOK, SINGLE_SHEET_FORMAT,
TOO_MANY_ROWS, TOO_MANY_COLUMNS, TOO_MANY_CELLS, TOO_MANY_SHEETS, NOT_TEXT,
TEXT_TOO_LARGE, INVALID_TEXT, NOT_A_DOCUMENT, INVALID_DOCUMENT, INVALID_PLAN,
TOO_MANY_BLOCKS, BLOCK_TOO_LONG, DOCUMENT_TOO_LONG, DOCUMENT_TOO_LARGE,
NOT_A_DOC, DOC_TOO_OLD, DOC_ENCRYPTED, NOT_A_PDF, PDF_ENCRYPTED,
PDF_NO_PAGES, PDF_NO_PAGES_SELECTED, PDF_BAD_PAGE, TOO_MANY_PAGES,
EMPTY_QUERY, INVALID_REGEX, QUERY_TOO_LONG, TOO_MANY_EXTENSIONS,
CONTENT_NEEDS_FILES, SEARCH_FAILED.
/config повертає { readOnly, permissions, maxUploadSize, maxUploadFiles,
maxRequestSize, thumbnails, thumbnailSizes, chmodSupported, archiveFormats, imageView,
sheetFormats, documentFormats, search, usage }. readOnly — похідна величина: він істинний рівно тоді,
коли заборонено всі операції запису, тому не може розійтися з permissions.
usage дорівнює null, доки роутер не запущено з exposeUsage: true.
/list може повернути truncated: true і total, якщо в каталозі більше ніж
maxListEntries елементів; віджет у цьому разі показує попередження, а не
видає частину за ціле.
Перегляд і мініатюри
Дві незалежні речі, обидві увімкнено за замовчуванням.
Мініатюри в плитках (thumbnails). Плитка малюється із самого файлу. Окремий
маршрут /thumbnail, а не /download, із простої причини: тека світлин,
намальована з оригіналів, коштувала б браузеру сотні мегабайтів заради квадратиків
128×128. Замір на тестовому файлі: оригінал 168 КБ → мініатюра 356 байтів.
| Формат | Як виходить мініатюра |
| --- | --- |
| jpg, jpeg, png, webp, gif, avif, tif, tiff, heic, heif | декодується і перетискається у WebP (потрібен sharp; для heic — як у переглядачі) |
| ico, bmp | віддається як є — libvips їх не читає, а браузер малює |
| svg | віддається як є — растеризувати вектор заради 128 px безглуздо |
| pdf | мініатюри немає, лишається мальований значок |
sharp — необов’язкова peer-залежність. Без неї маршрут працює далі:
файли, менші за maxRawThumbnailSize, віддаються як є, решта отримує 415, і
плитка відкочується до мальованого значка. Ставиться звичайним npm install sharp.
Чому в PDF немає мініатюри: растеризація першої сторінки потребує рушія (poppler,
mupdf, pdfium), а готові збірки sharp зібрані без нього. Тягнути його заради плитки —
непропорційно; PDF показується цілком у переглядачі.
Переглядач (preview). Подвійний клік або Space відкриває файл поверх віджета,
а не в новій вкладці: користувач лишається в теці, а стрілки ← → гортають її
файли. Esc закриває. PDF іде в <iframe>, де його малює штатний переглядач
браузера зі своїм гортанням, масштабом і пошуком.
Зображень підтримано 13 форматів, але не всі однаково:
| Формат | Як показується |
| --- | --- |
| jpeg, jpg, png, gif, webp, avif, bmp, ico, svg | напряму браузером, файл віддається байт у байт |
| tiff, tif, heic, heif | сервер перетворює у WebP через /render |
Поділ перевірено, а не взято з документації: кожен формат завантажувався в Chrome із
data: URL — перелічені в першому рядку повідомили свій розмір, tiff і heic
не відобразилися. Safari показує HEIC сам, але покладатися на це означало б зробити
переглядач працездатним в одному браузері й таким, що мовчки ламається в решті.
Формати з першого рядка не перепаковуються: перекодовувати JPEG лише щоб
на нього подивитися — означає втратити якість задарма. Перетворення вмикається
рівно там, де без нього не обійтися, і результат обмежений за шириною
(1024/1600/2048/2560), щоб стомегапіксельний скан не приїжджав цілком.
У підписі при цьому вказується розмір оригіналу, а не показаного зображення.
Що потрібно для tiff і heic
tiff потребує sharp. heic — складніше, і це варто знати заздалегідь: штатні збірки
sharp містять libheif без HEVC-декодера (HEVC обтяжений патентами, на відміну від
AV1, тому AVIF працює, а HEIC ні). Тому можливість перевіряється на старті
реальним декодуванням, а не оголошується: libheif розбирає контейнер HEIC успішно
і падає лише на запиті пікселів, тож проба за заголовками повідомляла б про
підтримку, якої немає.
Якщо sharp не впорався, шукається зовнішній конвертер — heif-convert (з libheif),
magick, convert або sips (macOS). Він теж перевіряється спробою конвертації,
а не наявністю файлу. Що вийшло, видно в /config.imageView.heif: 'sharp',
'tool' або false.
Якщо жодного шляху немає, формат просто не потрапляє до /config.imageView.render, і
переглядач чесно пише, що показати не може, пропонуючи завантажити, — замість значка
зламаного зображення без пояснень.
Те, що не показується в переглядачі, як і раніше відкривається в новій вкладці.
preview: false повертає попередню поведінку цілком.
manager.on('preview', ({ entry }) => analytics.track('preview', entry.path));
manager.preview(); // поточне виділення
manager.preview(someEntry); // конкретний файл; false, якщо нічого показуватиІзоляція того, що показується
Перевірено, а не припущено — випадки різні:
- Розмітка зі скриптом (наприклад
.svg) зупиняється заголовком відповідіContent-Security-Policy: default-src 'none'; sandbox. Ворожий SVG, вставлений в<iframe>з того самого origin, не виконує свого скрипта, аcontentDocumentчитається якnull. - PDF — виняток. Chrome віддає фрейм власному переглядачеві, і той документ
лишається same-origin:
contentDocumentдоступний. Але лежить у ньому не файл, а порожня оболонкаpdf_embedderсамого Chrome; PDF малюється розширенням у вкладеному фреймі. Скрипт усередині PDF виконується в пісочниці браузера і до DOM сторінки не дістає. Ізоляція тут браузерна, а не від цього заголовка.
Атрибут sandbox на <iframe> навмисно не ставиться. Виглядає як зайва
осторога, але порожній sandbox відбирає у переглядача Chrome потрібні йому права,
і замість документа виводиться «Chrome заблокував цю сторінку». Переглядач, який
нічого не показує, безпечнішим не робить.
Архіви
Підтримуються 13 форматів. Пункти «Створити архів…», «Розпакувати» і «Показати вміст…» — у контекстному меню.
| Формат | Читання | Запис | Чим робиться |
| --- | :---: | :---: | --- |
| zip | ✅ | ✅ | власна реалізація (writer зі Zip64 + reader) |
| tar | ✅ | ✅ | власна реалізація |
| tar.gz, tgz | ✅ | ✅ | власний tar + zlib |
| gz | ✅ | ✅ | zlib |
| tar.bz2, tbz2 | ✅ | ✅ | власний tar + bzip2 |
| bz2 | ✅ | ✅ | bzip2 |
| tar.xz, txz | ✅ | ✅ | власний tar + xz |
| xz | ✅ | ✅ | xz |
| tar.zst | ✅ | ✅ | власний tar + zlib (Node ≥ 22.15) або zstd |
| zst | ✅ | ✅ | те саме |
| 7z | ✅ | ✅ | власний tar + bsdtar (libarchive) |
| rar | ✅ | ❌ | bsdtar / unrar |
Чому RAR лише на читання
Не через брак часу. Компресор RAR пропрієтарний, ліцензія на референсні
вихідні коди прямо забороняє використовувати їх для створення сумісного архіватора, і
вільної реалізації не існує. Перевірено, а не припущено: 7zz a -trar
відповідає E_NOTIMPL, у libarchive формату на запис немає взагалі. Усе, що заявляло б
створення RAR, обманювало б.
Спроба запакувати в rar повертає FORMAT_READ_ONLY із цим поясненням, а віджет
такого варіанта просто не пропонує.
Що доступно на конкретній машині
zip, tar, tar.gz і gz працюють завжди — вони цілком на власному коді та zlib.
Решті потрібні bzip2, xz, zstd або bsdtar. Роутер шукає їх один раз на
старті й публікує результат у /config.archiveFormats; віджет малює рівно те, що
там написано, і ніколи не пропонує формату, який тут не спрацює.
archiveTools: false вимикає підпроцеси зовсім — лишаються чотири формати на
zlib.
Як це влаштовано
Усе, включно з форматами, якими опікується підпроцес, проходить через власний шар tar. Підпроцес ніколи не отримує імен з архіву:
bz2,xz,zstзапускаються як-dc, потік stdin → stdout. Імені файлу вони не бачать узагалі.7zіrarперетворюються на tar-потік силамиbsdtar, а читає його власний tar-reader.- Під час пакування в
7zвласний tar-writer віддає потікbsdtar, якому передано лише шлях призначення — обраний цим кодом.
Сенс у тому, що рішення «що і під яким іменем опиниться на диску» ухвалюється в одному місці, яке можна прочитати, і однаково для всіх форматів. Ніщо не запускається через оболонку: лише масиви argv, тому ім’я файлу не може бути розібране як опція чи команда.
Захист під час розпакування
Архів — це недовірений ввід, записи якого є іменами файлів. Правило ухвалено таке: відхиляти, а не лагодити. Ім’я, яке треба переписати, щоб стало безпечним, належить або зламаному, або ворожому архіву, і тиха заміна приховує обидва випадки.
Відхиляються: .. у будь-якому написанні (включно з ..\ із Windows-архівів), абсолютні
шляхи, шляхи з літерою диска та UNC, нульові байти, зарезервовані імена, задовгі
сегменти й задовга вкладеність.
Символічні та жорсткі посилання не розпаковуються взагалі. Це друга половина тієї
самої проблеми: запис-симлінк на /etc, а слідом файл, який пишеться «крізь» нього,
виходить із каталогу, усі імена в якому перевірку пройшли.
Обмежено кількість записів, сумарний розпакований обсяг і розмір одного запису, причому і за оголошеним розміром (до запису першого байта), і за фактично прочитаними байтами — архів може збрехати про перше.
Усе, що відхилено, повертається клієнту в skipped і показується користувачеві:
мовчазне часткове розпакування — це саме те, за якого спроба обходу лишається
непоміченою.
Куди розпаковується
Ніколи прямо в поточну теку — так неохайний архів розсипає сорок файлів по чужих документах. Але й зайвої обгортки не з’являється: якщо всередині рівно одна тека верхнього рівня (а саме так пише цей код і більшість інших), вона й стає результатом, інакше створюється тека за іменем архіву. Рішення ухвалюється після розпакування до тимчасового каталогу — інакше для tar-потоку форму не вдалося б дізнатися заздалегідь.
Поодинокі gz, bz2, xz, zst — виняток: усередині один файл з очевидним іменем,
загортати його в теку немає навіщо. Запакувати в них можна лише один звичайний файл;
для теки або кількох елементів віджет пропонує відповідний tar.*.
Редактор коду
Подвійний клік по текстовому файлу відкриває редактор поверх віджета: нумерація
рядків, підсвітка синтаксису, Tab для відступу, Enter зі збереженням відступу,
Ctrl/Cmd+S для збереження, Esc для виходу. У підвалі — номер рядка й
колонки, у шапці — мова, кодування, вид перенесення рядків і розмір.
Підтримано всі запитані розширення:
| Мова | Розширення |
| --- | --- |
| TypeScript | ts tsx mts cts d.ts d.mts d.cts |
| JavaScript | js mjs cjs jsx |
| Стилі | css scss sass less styl |
| Розмітка | html htm xml svg |
| Дані | json yaml yml |
| Інше | md sh py php txt і файли без розширення (Makefile, .env, …) |
yml додано поряд із yaml: це те саме під частішим написанням, і без
нього більшість справжніх конфігів провалювалася б у простий текст.
Як це зроблено
Звичайна <textarea> з підсвіченою копією під нею. Вибір свідомий: так
безкоштовно дістаються штатні скасування й повтор, введення через IME, мобільна
клавіатура, доступність і виділення, що поводиться як у будь-якому іншому текстовому
полі. Усе це довелося б переписувати — і гірше — на contenteditable.
Рядки не переносяться. Інакше один логічний рядок займає кілька екранних, і колонці номерів довелося б вимірювати висоту кожного — джерело розсинхрону, який до кінця не лікується. Без перенесення рядок дорівнює ряду, і нумерація правильна за побудовою.
Підсвітка — токенізатор, а не парсер: правила застосовуються по черзі, перемагає
перше, що збіглося. Тому недописаний код (а редактор найчастіше показує
саме його) підсвічується розумно, а не розсипається. Ціну названо чесно:
інтерполяція всередині шаблонного рядка пофарбована як частина рядка; / як «regex
чи ділення» вирішується евристикою за попереднім символом; усередині <script> і
<style> мова не перемикається.
Підсвітка вимикається на файлах, більших за 512 КБ — редагування працює далі, а в шапці написано, чому зник колір.
Що зберігається разом із текстом
Кодування, BOM і перенесення рядків визначаються під час відкриття і повертаються на місце під час збереження. Відкрити CRLF-файл на Linux-сервері та записати його як LF означає переписати кожен рядок: правка в один символ перетворюється на diff, який неможливо переглянути.
Однобайтові кодування (windows-1251 та інші) розпізнаються, але Node вміє їх
лише читати. Такий файл зберігається в UTF-8 з попередженням, а не мовчки.
Двійковий файл відкрити не можна: наявність нульового байта — ознака, за якою
ухвалюється рішення. Без неї декодер відкотився б до windows-1252, який
відображає будь-який байт, і зображення «відкрилося» б мегабайтами сміття.
Запис іде до тимчасового файлу й перейменовується на місце. Право — той самий edit,
що й у таблиць: у режимі лише для читання редактор не пропонується.
Таблиці
Чотири формати, усі на читання і на запис, без жодної залежності:
| Формат | Як влаштований | Що дає |
| --- | --- | --- |
| csv (tsv, txt) | звичайний текст | роздільник, перенесення рядка, кодування і BOM визначаються та зберігаються |
| xlsx (xlsm) | ZIP з XML | аркуші, значення, дати, формули |
| ods | ZIP з XML | те саме |
| xls | BIFF8 усередині контейнера OLE2 | те саме |
ZIP-читач і писач у проєкту вже були, тому xlsx і ods потребували лише
розбору XML. Єдине, що довелося будувати з нуля, — контейнер OLE2 і записи
BIFF8 для xls.
Перевірено не на собі: файли, записані цим кодом, відкриваються openpyxl (xlsx), odfpy (ods) і xlrd (xls); файли, створені ними, читаються тут. І те, й інше закріплено тестами на реальних файлах.
Редактор
Подвійний клік по таблиці відкриває сітку поверх віджета: вкладки аркушів, заголовки
стовпців, редагування на місці, стрілки для переміщення, Enter/Tab для
підтвердження, Delete для очищення. Числа вирівнюються праворуч. Кнопки «Рядок» і
«Стовпець» розширюють область введення.
Тип комірки визначає сервер, а не браузер: клієнт надсилає те, що набрав
користувач, і "99.9" стає числом уже на сервері. Клієнт, який оголосив
type: "number" з рядком усередині, нічого цим не досягне.
Чого редактор не робить
Він працює зі значеннями. Під час збереження не переносяться оформлення, ширини стовпців, діаграми, зведені таблиці, умовне форматування й макроси. Це написано просто в шапці редактора, бо найгірше, що може зробити редактор, — мовчки викинути те, чого не зрозумів.
Формули не обчислюються. У файлі поряд із формулою зберігається її останній результат;
він і показується. Для xlsx та ods текст формули зберігається, для xls — ні:
BIFF8 зберігає вираз як потік RPN-токенів, і зворотний компілятор тут ні до чого.
CSV зберігає один аркуш. Спроба зберегти в нього книгу з двох аркушів відхиляється
(SINGLE_SHEET_FORMAT), а не відкидає другий мовчки.
Кодування: windows-1251, koi8-r та інші однобайтові розпізнаються під час читання,
але Node вміє їх лише декодувати. Такий файл зберігається в UTF-8, і про це
повідомляється у warnings, а не відбувається нишком.
Збереження
Запис іде до тимчасового файлу в тому самому каталозі й перейменовується на місце —
перерване збереження не лишить пів-таблиці під справжнім іменем. Права: операцію
закрито ключем edit, який за замовчуванням успадковує значення create, тому
менеджер у режимі лише для читання редактора не пропонує.
Термінал
Панель унизу віджета: запрошення з поточним шляхом, введення команд, вивід. Увімкнена
за замовчуванням; кнопка з іконкою термінала у правій частині панелі інструментів і
Ctrl+`` ховають і показують її. Панель тягнеться за нижній край — висота
добирається під задачу.
Це не системна оболонка. Жодна команда не запускає процес: кожна
звертається до того самого REST API, що й кнопки. З цього випливає все інше —
немає пісочниці, з якої можна вийти, бо немає й оболонки; вихід за
межі кореня неможливий із тієї самої причини, що й у списку; а менеджер,
відкритий без права remove, не виконає rm, і скаже про це до надсилання
запиту.
Команди
| Команда | Що робить |
| --- | --- |
| help [команда] | список команд або довідка про одну |
| pwd | поточна тека |
| ls [-l] [шлях] | вміст; -l — права, розмір, дата |
| cd [шлях] | перейти (список і дерево йдуть слідом) |
| cat <файл> | текст файлу |
| stat <шлях> | властивості |
| du [шлях] | сумарний розмір теки |
| tree [шлях] [глибина] | дерево тек |
| find <маска> [шлях] | знайти за іменем в усіх вкладених теках |
| grep <текст> [шлях] | знайти текст усередині файлів |
| mkdir <ім’я...> | створити теку |
| touch <ім’я...> | створити порожній файл |
| rm <шлях...> | видалити |
| cp <звідки...> <куди> | копіювати до теки |
| mv <звідки...> <куди\|ім’я> | перемістити або перейменувати |
| chmod <права> <шлях...> | змінити права: chmod 755 build.sh |
| open <шлях> | відкрити в переглядачі або редакторі віджета |
| download <шлях...> | завантажити |
| clear | очистити екран |
find і grep — це той самий пошук, що в діалозі (вище), із тими самими
межами й тим самим захистом.
Введення
Tabдоповнює: ім’я команди в першій позиції, інакше ім’я файлу або теки; за кількох варіантів дописує спільну частину й показує перелік.↑/↓— історія команд,Ctrl+L— очистити,Ctrl+C— облишити рядок.- Лапки та зворотний слеш працюють як в оболонці:
rm "Мій звіт.txt"іrm Мій\ звіт.txtвидаляють один файл, а не два неіснуючих. Доповнення заTabсаме розставляє лапки, якщо в імені є пробіл.
Панель пам’ятає, що робили мишею
Вона замінила журнал подій і зберегла цю роботу: створення, перейменування, переміщення, видалення, завантаження та помилки друкуються тут само приглушеним кольором. Історія сесії читається як одне ціле, незалежно від того, чим виконували операцію.
cd веде і список, і дерево; навпаки — перехід мишею рухає запрошення.
Це одне місце, до якого можна потрапити двома способами.
Що доступно з коду
const manager = new FileManager('#host', { terminal: true });
manager.toggleTerminal(false); // сховати
manager.toggleTerminal(); // перемкнути, поверне новий стан
manager.termNote('власний рядок'); // надрукувати приглушено
manager.terminal.run('ls -l'); // виконати команду
manager.on('terminaltoggle', ({ visible }) => …);Мова команд лежить у src/core/terminal-commands.js і не торкається DOM — її
можна використовувати окремо, і вона вкрита тестами: розбір аргументів та арифметика
шляхів ламаються в командному рядку найчастіше, і перевіряти їх у браузері немає навіщо.
Адресний рядок
Подвійний клік по порожньому місцю смуги з хлібними крихтами перетворює її на
поле введення з поточним шляхом. Enter — перейти, Escape або клік повз —
повернути крихти. Подвійний клік по самій крихті або по кнопці лишається тим, чим
був: це два кліки по елементу керування.
Шлях розуміється так само, як у терміналі — тим самим резолвером:
| Введення | Куди |
| --- | --- |
| /Проєкти/веб | абсолютний шлях |
| src | відносно поточної теки |
| ../Архів | на рівень вище і вниз |
| ~ | до кореня |
| /Проєкти/веб/style.css | до теки файлу, з виділенням файлу |
Шлях перевіряється до переходу. Це не дрібниця: невдала навігація відкочується до кореня, і друкарська помилка викидала б користувача з теки, у якій він працював. Натомість неіснуючий шлях показується помилкою просто в рядку, поле лишається відкритим, і помилку можна виправити на місці.
З коду — manager.editPath(); подія pathedit повідомляє, куди перейшли.
Пошук
Дві різні речі, і їх навмисно розділено.
Фільтр у панелі звужує теку, яка вже на екрані: відповідає миттєво, бо дані вже тут, і нічого не вантажить. Це те, що було й лишилося.
Пошук обходить дерево на сервері. Відкривається кнопкою з лупою поряд із
фільтром, клавішею Enter у самому фільтрі (те, що набрано, переноситься в
запит) або Ctrl+Shift+F.
Що вміє
| Можливість | Як |
| --- | --- |
| По всіх вкладених теках | обхід від поточної теки вниз |
| Ігнорувати регістр | за замовчуванням; галочка «Враховувати регістр» вимикає |
| За масками | режим «Маска»: * ? [а-я] {js,ts}; ** переходить через слеш |
| Регулярні вирази | режим «Регулярний вираз», синтаксис JavaScript |
| Лише файли / лише теки | список «Шукати» |
| Із зазначенням розширення | поле «Розширення»: js, ts, md — працює і без запиту |
| За вмістом файлів | галочка «Шукати всередині файлів»; показує рядки з номерами |
Маска описує ім’я цілком: *.js знайде main.js, але не main.js.map, а
main без зірочок — лише файл з іменем рівно main. Якщо в масці є слеш,
вона звіряється зі шляхом відносно місця старту: **/src/*.js.
Розширення — це фільтр за іменем, а не за вмістом, тому .TXT потрапляє
під txt (регістр розширень теж ігнорується).
Запит можна не вказувати зовсім, якщо задано розширення: «усі .js нижче цієї
теки» — повноцінний пошук. Без запиту і без розширень пошук відхиляється
(EMPTY_QUERY): інакше це був би звичайний список, що вдає пошук.
Пошук за вмістом
Читаються лише текстові файли. Двійкові пропускаються за тією самою ознакою, що й у редакторі коду, — нульовим байтом: без цього кожен JPEG у дереві декодувався б у мегабайти мохибейку і шукався б у ньому. Кодування визначається тим самим кодом, що й у CSV та редактора.
Файли, більші за 2 МБ, не читаються, на файл повертається до 5 рядків, довгий рядок
обрізається до вікна навколо збігу. Усе це налаштовується через searchLimits.
Право доступу окреме: пошук за вмістом потребує download. Бачити, що
файл існує, і читати, що в ньому написано, — різні речі, і пошук, який
це змішує, став би способом читати файли по рядку. Якщо права немає,
/config.search.content дорівнює false, і віджет не пропонує галочки.
Чому пошук виконується в окремому потоці
Пошук — єдине місце, де користувач передає серверу програму:
регулярний вираз. Рушій регулярних виразів у JavaScript працює з
поверненнями, тому шаблон на кшталт ^(a+)+$ на рядку з чотирьохсот a не
«працює довше» — він не закінчується ніколи. Перевіряти годинник між файлами
марно: увесь запит стоїть усередині одного виклику exec, і перервати його
зсередини нічим.
Виміряно: той самий шаблон на тому самому рядку у звичайному процесі через вісім секунд усе ще рахував, і процес довелося вбити. Тому обхід живе в робочому потоці, який можна завершити; збіги передаються назовні порціями, тож зупинений пошук усе одно повертає знайдене. Основний цикл подій при цьому не блокується, і сервер продовжує відповідати.
Звідси ж обмеження maxConcurrent (за замовчуванням 4): кожен пошук — це
потік, і без межі десяток запитів поспіль став би способом покласти сервер.
Понад межу повертається 429 BUSY.
Межі та чесність відповіді
createFileManagerRouter({
root: './files',
searchLimits: {
maxResults: 500, // збігів у відповіді
maxEntries: 200000, // переглянуто записів
maxDepth: 32, // рівнів вкладеності
timeout: 10000, // мс на обхід
maxContentBytes: 2 * 1024 * 1024,
maxMatchesPerFile: 5,
maxConcurrent: 4, // одночасних пошуків
},
});Відповідь завжди повідомляє, чи повна вона: truncated — уперлися в межу,
timedOut — обхід зупинено за часом. Віджет пише про це в рядку
результатів, а не показує частину як ціле.
Символічні посилання обходяться за тим самим правилом, що й у списку: лише поки ведуть усередину кореня. Посилання на предка всередині кореня законне й обробляється — пройдені каталоги запам’ятовуються за реальним шляхом, інакше обхід не скінчився б.
Результати
Кожен збіг показує, де він лежить, — інакше перелік імен марний, адже весь сенс у тому, що файл міститься не тут. Клік переходить до потрібної теки й виділяє файл, подвійний — відкриває його.
const result = await manager.provider.search('/', {
query: 'функція',
mode: 'substring',
scope: 'content',
extensions: 'js,ts',
});
// result.matches[0] → { path, parent, name, isDirectory, size, modified,
// matchedIn: 'content', lines: [{ line, column, text }] }Документи
Чотири формати, теж без жодної залежності:
| Формат | Як влаштований | Що доступно |
| --- | --- | --- |
| docx | ZIP з XML (WordprocessingML) | читання і запис тексту з накресленням |
| odt | ZIP з XML (OpenDocument) | те саме |
| doc | двійкові записи всередині контейнера OLE2 | те саме, але із застереженням (нижче) |
| pdf | граф пронумерованих об’єктів | читання тексту і операції зі сторінками |
ZIP-читач, писач і контейнер OLE2 у проєкту вже були — від таблиць. Наново
довелося побудувати розбір .doc (таблиця шматків і сторінки властивостей) та розбір PDF.
Перевірено не на собі: файли, записані цим кодом, відкриваються python-docx (docx), odfpy (odt), textutil (doc) і pypdf (pdf); файли, створені ними, читаються тут. І те, й інше закріплено тестами.
Редактор тексту
Подвійний клік по docx, odt або doc відкриває редактор поверх віджета: кожен
абзац — окремий рядок зі списком типу (абзац, заголовок 1–6, пункт списку) і
полем введення. Жирний, курсив і підкреслення — кнопками або Ctrl+B/I/U,
Ctrl+S зберігає.
Що зберігається під час правки
Головна властивість: правка одного абзацу не чіпає решти документа. Кожен блок пам’ятає розмітку, з якої прочитаний, і під час збереження блок, якого ніхто не торкався, записується назад дослівно; наново збирається лише змінений. Тому таблиці, зображення, колонтитули, стилі й нумерація лишаються на місці, хоча редактор їх не показує. Втратити оформлення можна лише всередині того абзацу, який ви змінили.
Те, чого редактор показати не може, видно в ньому як замкнений блок «[таблиця]» —
щоб було зрозуміло, що там щось є і що воно збережеться. Суто службові
елементи (sectPr, закладки) не показуються зовсім, але зберігаються так само.
Вихідна розмітка не йде в браузер. Клієнт отримує лише текст, накреслення і номер блока; під час збереження сервер сам перечитує файл і зіставляє блоки за цими номерами. Розмітка, надіслана клієнтом, ігнорується — інакше будь-який запит міг би дописати в документ довільний XML.
Виняток — .doc. Він перезбирається під час збереження цілком, бо
відновлювати двійкові структури Word частинами не можна. Зображення та врізки під час
запису в .doc не збережуться, маркери списків стануть відступом; про це сказано в
шапці редактора і повертається у warnings. Для повного збереження — .docx.
Чому PDF не редагується як текст
Сторінка PDF не містить абзаців. Вона містить оператори на кшталт
BT /F1 12 Tf 72 700 Td (Привіт) Tj ET — «поставити ці гліфи в цю точку». Слів,
рядків і переносів у файлі немає: вони виникають із координат. До того ж шрифт зазвичай
вшито з урізаним набором гліфів — лише тими, що вже використані, — тож літери,
якої в документі не було, узяти немає звідки.
Тому «редагування тексту PDF» неминуче означає або перемальовування сторінки
зображенням, або псування верстки. Тут цього немає. Текст видобувається і
показується (вкладка «Текст», окремо за сторінками), і textLayer повідомляє,
чи є він узагалі: у скана його немає, і редактор скаже про це, а не покаже порожнечу.
Операції зі сторінками PDF
Видалення, перестановка, поворот, розділення та об’єднання — це один і той самий запис: план перелічує потрібні сторінки, у потрібному порядку, із зазначенням файлу і повороту.
await manager.provider.saveDocumentPages('/договір.pdf', [
{ page: 2 }, // третя сторінка джерела
{ page: 0, rotate: 90 }, // перша, повернута
{ source: 1, page: 0 }, // перша сторінка приєднаного файлу
], { sources: ['/додаток.pdf'], target: '/підсумок.pdf' });Без target файл перезаписується на місці, з ним — пишеться новий (що і є
«розділити» та «зберегти як»). Приєднувані файли проходять ту саму перевірку
меж кореня, що й усе інше: об’єднати з файлом поза коренем не можна.
Сторінки копіюються разом з усім, на що посилаються, — шрифтами, дескрипторами, вшитими файлами шрифтів. Скопіювати сторінку без цього означає отримати файл, у якому текст невидимий.
Що читається, а що відхиляється
Читач PDF не довіряє таблиці перехресних посилань у кінці файлу: об’єкти знаходяться скануванням. Таблиця — швидкий шлях, але вона ж першою виявляється невірною у файлі, зібраному скриптом або обрізаному під час завантаження. Один прохід по байтах відкриває файли, які «правильні» парсери відкидають; тест на файл із зіпсованою таблицею це закріплює.
Відхиляється, а не читається наполовину: PDF із шифруванням (PDF_ENCRYPTED),
.doc у форматі Word 6.0/95 (DOC_TOO_OLD) і захищений паролем .doc
(DOC_ENCRYPTED). Половина документа, видана за цілий, гірша за чесну відмову.
Збереження
Як і в таблиць: запис до тимчасового файлу поряд і перейменування на місце.
Операцію закрито ключем edit; у режимі лише для читання ні редактор, ні
операції зі сторінками не пропонуються.
Права доступу та властивості
Властивості
Alt+Enter або пункт «Властивості» в контекстному меню — працюють завжди, окремого
дозволу не потребують. Показується ім’я, тип, розмір (і скільки зайнято на диску —
для розріджених файлів це різні числа), дати створення / зміни / доступу,
права у вигляді rwxr-xr-x (755), власник, кількість жорстких посилань, а для символічного
посилання — його ціль віртуальним шляхом (або null, якщо воно веде за межі кореня).
Для теки розмір вмісту не рахується сам: поряд стоїть кнопка «Порахувати».
Обхід дерева — це окремий запит, і здебільшого число нікому не потрібне.
Обхід обмежено maxSizeWalkEntries; якщо вперся в стелю, відповідь чесно позначається
totalSizePartial: true, а не видає неповну суму за повну.
Зміна прав
Потребує дозволу chmod, і це єдиний дозвіл, вимкнений за
замовчуванням:
createFileManagerRouter({ root: './storage', permissions: { chmod: true } });Причина в тому, що всі інші операції — очевидна робота файлового менеджера, і дозволити їх за замовчуванням нікому нічого не коштує. Встановлення біта виконання — не той випадок: воно перетворює каталог завантажень на місце, звідки завантажене можна запустити. Якби право вмикалося за замовчуванням, встановлення, яке просто оновило пакет, отримало б цю можливість без чийогось рішення. Явне ввімкнення — єдине замовчування, яке нікого не здивує.
У контекстному меню з’являються два пункти:
- «Зробити виконуваним» / «Зняти виконуваність» — лише для файлів. Для каталогу
біт
xозначає «можна увійти», а не «можна запустити», і перемикати це одним кліком із меню було б обманом очікувань. - «Права доступу…» — сітка читання/запис/виконання за трьома класами плюс поле
вісімкового значення. Обидва подання — той самий набір бітів і тримаються в
синхроні: хто знає, що таке
644, набирає його; хто не знає — ставить галочки. Для теки є прапорець «застосувати до всього вмісту».
chmod +x слідує бітам читання, а не виставляє всі три:
| Було | Стане | Чому |
| --- | --- | --- |
| 644 | 755 | звичайний випадок |
| 640 | 750 | група та інші не отримують нічого понад те, що мали |
| 600 | 700 | |
Так «зробити виконуваним» ніколи не розширює коло тих, хто може дістатися файлу,
— лише те, що вони можуть із ним зробити. Рекурсивний режим читає біти кожного запису
окремо, тому дерево з файлів 644 і каталогів 755 зберігає цю відмінність,
а не схлопується в одне значення.
Що відхиляється
- Біти setuid, setgid і sticky — завжди, без опції для ввімкнення. У файлового менеджера, доступного по HTTP, немає законної причини роздавати setuid, а ціна помилки тут не «невірні права», а примітив локального підвищення привілеїв у каталозі, куди той самий менеджер приймає завантаження.
- Число замість рядка.
modeприймається лише рядком вісімкових цифр ("755").mode: 755людина читає як вісімкове, а JSON — як десяткове0o1363; різницю помічають уже після того, як файл став доступним на запис усім. - Символічні посилання під час рекурсивного обходу пропускаються, а не обходяться:
fs.chmod()іде за посиланням, і крок усередину нього змінив би права того, на що воно вказує, — єдиного в дереві, чию ціль обхід не втримав усередині кореня. - Кореневий каталог —
ROOT_IMMUTABLE.
У Windows POSIX-бітів немає (fs.chmod там перемикає лише атрибут «лише читання»),
тому chmodSupported у /config дорівнює false, право примусово вимикається, а
віджет прибирає елементи керування зовсім — замість того щоб обіцяти те, чого платформа
не зробить.
Безпека
Вихід за корінь
- Шлях перевіряється двічі — лексично і через
realpath. Друге ловить симлінк усередині кореня, що веде назовні; такі посилання не показуються і не відкриваються. Однієї перевірки недостатньо ні в той, ні в інший бік. ..у шляху відхиляється, а не розв’язується: клієнт, який його надіслав, або зламаний, або промацує — мовчазна нормалізація приховує обидва випадки.- Копіювання та переміщення підпорядковуються тому самому правилу.
fs.cp({dereference: true})тут не годиться: він слухняно йде за посиланням за межі кореня і кладе зовнішній файл усередину як справжній, після чого менеджер його показує і віддає. Власний обхід перевіряє кожен запис черезlstat, іде за посиланням лише поки воно лишається всередині кореня, а решту пропускає й повідомляє про це черезonWarning. - Імена файлів під час завантаження вкорочуються до останнього сегмента, тож
../../evil.txtзапишеться якevil.txt.
Запити
- Запити, що змінюють дані, з чужого origin відхиляються (
CROSS_ORIGIN). JSON-маршрути й так прикриті CORS —application/jsonне є simple request і отримує preflight. А ось/upload— є: звичайна<form enctype="multipart/form-data">на будь-якому сайті могла б записати файли у сховище під сесійною cookie користувача. Запит узагалі безOriginіSec-Fetch-Siteпропускається: це не браузерна форма, а curl чи виклик server-to-server. authorizeвикликається до звернення до диска і отримує конкретні шляхи (див. вище).
Завантаження
- Завантаження йде до тимчасового файлу й перейменовується лише після успіху — обірване
або таке, що перевищило ліміт, завантаження не лишає обрізаного файлу під справжнім іменем.
Тимчасові файли не потрапляють у видачу
/list. - Ім’я призначення займається атомарно (
openзwx). Перевірка «чи вільне ім’я» і його зайняття — одна операція, інакше два одночасні завантаження одного імені обрали б його обидва і одне затерло б інше. - Обмежено розмір файлу, розмір усього запиту, кількість файлів і кількість одночасних
завантажень; нижче
minFreeSpaceзавантаження відхиляється, щоб не забити диск до нуля і не покласти разом із ним хост-застосунок.
Видача назовні
- Завантаження на пристрій віддається з
X-Content-Type-Options: nosniff,Content-Security-Policy: default-src 'none'; sandboxіContent-Disposition: attachment, крім явного?inline=1для завідомо безпечних типів. Завантажений.htmlабо.svgне можна використати проти хост-застосунку. - Назовні йдуть лише повідомлення
FsError— вони написані для користувача і не називають нічого, крім віртуального шляху. Будь-яка інша помилка ФС замінюється загальним формулюванням і пишеться вonWarning: сире повідомлення node містить абсолютний шлях, на якому воно сталося. - Вільне місце — це характеристика диска сервера, а не файлів користувача, тому
/configвіддає його лише заexposeUsage: true.
Автентифікації роутер не робить — це справа хост-застосунку. Ставте свій middleware
перед ним і/або використовуйте authorize.
Чого роутер не робить
- Не автентифікує і не обмежує частоту запитів — і те, й інше ставиться перед
