Загрузка файлов в Node.js

На первый взгляд кажется: “Что может быть сложного в загрузке файла на сервер”? Но, оказывается, не всё так просто. Мы настолько привыкли к готовым встроенным в языки иструментам, что перестали понимать как они работают. Давайте немного погрузимся в тему и разберём, как же загрузить файл на сервер и подготовить его к дальнейшей обработке на примере Node.js.

Если что-то делается, то это делается с какой-то целью. Так и в моём случае. У меня была цель написать “менеджер загрузок” в облачное хранилище. Т.е. такое промежуточное звено, к которому по HTTP прилетает файл, оно проверяет этот файл (на допустимый размер, тип) и после отгружает в облачное хранилище. Цель была поставлена и я начал изучать предметную область.

Вопрос первый: Content-Type

Как оказалось, файлы отправить на сервер можно далеко не одним способом. Как минимум есть два.

Передача файла из HTML-формы: Content-Type: multipart/form-data

Первый способ и наверное самый распространнённый — это передача файла из HTML-формы. Если особо не мудрить с JavaScript на клиенте, то файл, прикреплённый через поле <input type="file">, отправится на сервер именно таким способом. Представим, что у нас есть следующая форма на странице:

1
2
3
4
5
6
<form action="" method="post" enctype="multipart/form-data">
<input type="text" name="field1">
<input type="file" name="my_file">
<input type="text" name="field2">
<button type="submit">Send</button>
</form>

Если отправить файл из такой формы, то HTTP-запрос будет выглядеть как-то так:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
POST / HTTP/1.1
Host: localhost:8089
Content-Length: 316066
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryj1yY9PZhcv5EFcOc
Accept-Encoding: gzip, deflate, br
------WebKitFormBoundaryhTSfiKpL4LvMkLXx
Content-Disposition: form-data; name="field1"
Test 1
------WebKitFormBoundaryhTSfiKpL4LvMkLXx
Content-Disposition: form-data; name="my_file"; filename="Screenshot from 2017-01-08 02-23-17.png"
Content-Type: image/png
PNG
IHDRpx#6sBIT|dtEXtSoftwaregnome-screenshot> IDATxwx\?I.{`0 - d,iolvMe)8;$1pElf-Kf3fF=a+u}^TU"I&lA0t~*?DHii ##uu$d::AZGZG2HHH !5HkHcI0`9GY0HkPvOPU[F+;::AZGZG2HHHii ##uu$d.ii#EZXlw+V,KrSOT4,6i|
e87epF(XS\\HWWGbXHg4!##uu$d::AZGZG2HHHiiR0R5|x5{]}1|heiY>Tb^C E J9Z17)J]wqkxs;(J[F;;:R"#HH-:R"#HH-:R"#HH-.iAZCjQ*A;>Q
...
...
...
------WebKitFormBoundaryj1yY9PZhcv5EFcOc
Content-Disposition: form-data; name="field2"
Test 2
------WebKitFormBoundaryj1yY9PZhcv5EFcOc--

На том месте, где у меня многоточия, на самом деле, очень много всего. Если кому интересно узнать, как на самом деле передаются файлы по сети, можно перехватить уходящие запросы со своего компьютера с помощью программы Wireshark.

В общем, при таком способе сам файл передаётся в перемешку с полями из формы. Но это не всегда нужно. Бывает, что нужно отправить только лишь файл и ничего более. Это чаще применимо к RESTful API или каким-нибудь динамическим пользовательским интерфейсам, в которых загрузка файлов происходит до отправки формы, т.е. отдельно от данных в форме. Поэтому давайте рассмотрим отправку другим способом.

Передача голых файлов: Content-Type: image/png

Проще всего показать пересылку таких файлов на примере утилиты cUrl.

Я заранее подготовился и поднял у себя сервер на 3000 порту, принимающий и сохраняющий файлы. Попробуем отправить файл. Предварительно запустим Wireshark. Выполним команду находясь в папке с нужным нам файлом:

1
$ curl -iv http://localhost:3000 -T "./Screenshot from 2017-01-08 02-23-17.png"

Посмотрим что говорит Wireshark:

Содержимое запроса в Wireshark

Видим, что таким методом отправляется только наш файл и ничего более. В большинстве случаев именно это и нужно, чтобы сделать качественный, красивый и удобный интерфейс. Так же можно заметить, что используется метод PUT. Никто, конечно, не мешает поменять руками на POST, но обычно при таком типе передачи используется именно PUT.

Я нашёл хорошую обзорную статью со сравнениями этих двух вышеописанных способов. В статье есть конкретные примеры. Например, говорится, что YouTube для загрузки видео использует второй способ. Если интересно можно почитать: https://philsturgeon.uk/api/2016/01/04/http-rest-api-file-uploads/.

Вопрос второй: проверка файла на стороне сервера

В моём случае идеально подходил метод БЕЗ использования multipart/form-data. Т.е. я принял решения грузить “голые” файлы. По ТЗ требовалось проверять, что файл является картинкой, и что его размер не превышает 10 мегабайт. Первая мысль приходящая в голову: проверять HTTP-заголовки Content-Length (содержит информацию о длине тела запроса) и Content-Type (содержит информацию о типе пересылаемых данных). Отчасти эта мысль верна: да проверить эти заголовки не помешает, но полностью доверять им нельзя. Любой недробожелательно настроенный может взять и подменить, например, Content-Length. Указать что он отсылает 5 байт, а на деле отослать гигабайты. То же самое и с Content-Type: нельзя доверять этому заголовку, необходимо проверить само содержимое файла.

Давайте сформируем полный список того, что нужно проверить на сервере, прежде чем вернуть пользователю ответ с кодом 201 Created (файл загружен):

  1. Если Content-Length установлен, то он должен быть меньше установленного лимита. Если это не так, то вернуть код 413 Request Entity Too Large.
  2. Если Content-Type установлен, то он должен быть одним из разрешённых. Если это не так, то вернуть код 422 Unprocessable Entity.
  3. Расширение загружаемого файла, должно быть одним из разрешённых. Если это не так, то вернуть код 422 Unprocessable Entity.
  4. Фактический размер файла должен быть меньше установленного лимита.
  5. Файл должен быть полностью и без ошибок загружен на сервер. Если это не так, то вернуть код 500 Internal Server Error.

На первый взгляд всё просто. Но есть один нюанс: всё было бы просто, если бы использовалась передача с Content-Type: multipart/form-data. Для такого типа загрузки файлов написано бесчисленное множество библиотек для Node.js, а во многих языках забота о загрузке файлов на сервер таким способом и вовсе снята с программиста. Например, в PHP мы просто можем использовать массив $_FILES. Т.е. язык гарантирует, что файл уже есть на сервере и остаётся только с ним работать. В Node.js такого поведения можно добиться с помощью следующих библиотек:

  • Multer — отличное решение если вы используете фреймворк Express.
  • Busboy — более низкоуровневое решение, чем Multer. Но функционала в нём не меньше. Отлично можно приспособить к любому используемому фреймворку.
  • Formidable

Но всё не так радужно, если мы не используем multipart/form-data. В этом случае во многих языках нам самим придётся позаботиться о сохранение файла куда-нибудь на сервер (например в /tmp). Рассмотрим как это можно сделать на Node.js.

Загрузка файла — это обычный запрос к веб-серверу, просто тело у этого запроса огромное. В Node.js для организации веб-сервера можно использовать стандартную библиотеку http. Все HTTP-запросы в ней представлены в виде объектов. Но объектов не простых, а имплементирующих интерфейс stream.Readable. Т.е. все запросы (далее буду говорит request) являются потоками. Я не буду останавливаться на том, что такое потоки. Вы всегда можете просто почитать об этом в интернете. Но дам совет для неискушённого читателя — воспринимайте слово “поток” буквально.

Из всего выше сказанного следует, что нам придётся работать с потоками. Это означает, что к нам будут поступать данные маленькими кусочками — чанками (chunk). Мы должны эти кусочки сохранять.

Сразу скажу: поток это такой объект, который порождает события (для искушённых — имплементирует EventEmitter). Нам нужно слушать эти события и как-то на них реагировать. Событие поступления нового чанка называется в Node.js data. Т.е. нам как минимум нужно написать обработчик события data.

К сожалению, люди на просторах интернета чаще предлагают одно в корне неверное решение. (Я конечно нагнетаю. На самом деле это решение жизнеспособно, но влечёт за собой ряд проблем.)

Давайте посмотрим, как делать НЕ НАДО!!! Привожу пример обработчика события data. В данном примере request — это объект запроса.

1
2
3
4
5
6
7
8
9
10
11
12
13
function(request, respond) {
var body = '';
var filePath = __dirname + '/public/data.txt';
request.on('data', function(data) {
body += data;
});
request.on('end', function (){
fs.appendFile(filePath, body, function() {
respond.end();
});
});
}

Что же здесь не так? А то, что пришедшие чанки сохраняются в переменную, которая, в свою очередь, хранится в оперативной памяти. Это порождает потенциальную уязвимости. Да и вообще: зачем нам лишний раз занимать ОЗУ, когда можно этого не делать?

Поэтому привожу полный листинг кода веб-сервера, загружающего файл к себе и производящего все нужные проверки:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
const http = require('http');
const tmp = require('tmp');
const typeIs = require('type-is');
const util = require("util");
const path = require('path');
const config = {
// 10 mb
maxFileSize: 10485760,
allowedMimeTypes: ["image/gif", "image/jpeg", "image/pjpeg", "image/png", "image/webp"],
allowedExtensions: ['png', 'gif', 'jpeg', 'jpg', 'webp']
};
// Функция для подсчёта байт в потоке пришедших к текщему моменту времени. Будучи "присоединённой" к потоку, следит за новыми чанками. При появлении кидает событие "progress" и передёт пришедший чанк далее (вниз по потоку).
function StreamLength(){return this instanceof StreamLength?(Transform.call(this),void(this.bytes=0)):new StreamLength}
var Transform=require("stream").Transform;
util.inherits(StreamLength,Transform);
StreamLength.prototype._transform=function(a,b,c){this.bytes+=a.length;this.push(a);this.emit("progress");c()};
http.createServer(function(request, response) {
request.on('error', function(err) {
console.error(err);
response.statusCode = 400;
response.end();
});
response.on('error', function(err) {
console.error(err);
});
var found;
if ((found = request.url.match(/^\/([^\/]+?)\/?$/i)) !== null && request.method === 'PUT') {
let filename = found[1];
let contentLength = request.headers['content-length'];
let contentType = request.headers['content-type'];
if ((typeof config.maxFileSize !== 'undefined') && !isNaN(+config.maxFileSize) && !isNaN(+contentLength) && +contentLength > +config.maxFileSize) {
response.statusCode = 413;
response.end();
} else if ((typeof contentType !== 'undefined') && (typeof config.allowedMimeTypes !== 'undefined') && !typeIs.is(contentType, config.allowedMimeTypes)) {
response.statusCode = 422;
response.end();
} else if ((typeof config.allowedExtensions !== 'undefined') && !config.allowedExtensions.includes(path.extname(filename).toLowerCase().replace('.', ''))) {
res.statusCode = 422;
res.end();
} else {
let internalErrorResponse = () => {
response.statusCode = 500;
response.end();
};
tmp.file(function _tempFileCreated(err, path, fd, cleanupCallback) {
if (err) internalErrorResponse();
else {
let outStream = fs.createWriteStream(null, {fd});
let aborted = false;
let abortWithError = function(uploadError) {
if (!aborted) {
aborted = true;
if (uploadError.code !== 'EEXIST') {
cleanupCallback(internalErrorResponse);
} else {
internalErrorResponse();
}
}
};
outStream.on('finish', function () {
// к этому моменту файл полность загружен путь до файла лежит в переменной "path". Остаётся только проверить тип файла и в случае несоответствия удалить его.
// тут какие-то ваши операции. Наример, если вы работаете с картинками, то тут самое время пережать их в нужные вам размеры.
// после всех операций удаляем временный файл
cleanupCallback();
// возвращаем пользователю ответ
response.statusCode = 201;
response.end();
});
outStream.on('error', function(err) {
abortWithError(err);
});
let counter = new StreamLength();
counter.on('progress', function(){
if (((!isNaN(+contentLength) && counter.bytes > +contentLength) || (counter.bytes > +config.maxFileSize)) && !aborted) {
aborted = true;
cleanupCallback(function () {
res.statusCode = 413;
res.end();
});
}
});
request.on('abort', function () {
cleanupCallback();
});
request.on('aborted', function () {
cleanupCallback();
});
request.pipe(counter).pipe(outStream);
}
});
}
} else {
response.statusCode = 404;
response.end();
}
}).listen(process.env.PORT || 3000);

Это всё. Теперь загрузка файлов не будет забивать нам оперативку. Можно запустить сервер и проверить, выполнив в консоле:

1
$ curl -iv http://localhost:3000 -T ./Screenshot\ from\ 2017-01-08\ 03-04-23.png

Если у вас что-то не работает, удостоверьтесь, что используете свежую версию Node.js. Данный код точно работает на Node.js v7.4.0.

Что нам понадобилось из сторонних библиотек:

  1. type-is — для проверки Content-Type.
  2. node-tmp — для создания временного файла на диске для сохранения потока.

Что я не осветил в данной статье: Transfer-Encoding: chunked. Это уже на самостоятельное прочтение. Но стоит заметить, что данных сниппет будет работать и с этим заголовком.

Поделиться Комментарии