diff --git a/package-lock.json b/package-lock.json index d0b3b95..46b74df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "devDependencies": { "@faker-js/faker": "^8.4.1", "@mate-academy/eslint-config": "latest", - "@mate-academy/scripts": "^1.8.6", + "@mate-academy/scripts": "^2.1.3", "axios": "^1.7.2", "eslint": "^8.57.0", "eslint-plugin-jest": "^28.6.0", @@ -1487,10 +1487,11 @@ } }, "node_modules/@mate-academy/scripts": { - "version": "1.8.6", - "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.8.6.tgz", - "integrity": "sha512-b4om/whj4G9emyi84ORE3FRZzCRwRIesr8tJHXa8EvJdOaAPDpzcJ8A0sFfMsWH9NUOVmOwkBtOXDu5eZZ00Ig==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-2.1.3.tgz", + "integrity": "sha512-a07wHTj/1QUK2Aac5zHad+sGw4rIvcNl5lJmJpAD7OxeSbnCdyI6RXUHwXhjF5MaVo9YHrJ0xVahyERS2IIyBQ==", "dev": true, + "license": "MIT", "dependencies": { "@octokit/rest": "^17.11.2", "@types/get-port": "^4.2.0", @@ -2739,6 +2740,17 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/call-bind": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", @@ -8040,6 +8052,14 @@ "node": ">=8" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", diff --git a/package.json b/package.json index 1d03d64..9f3cce9 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "devDependencies": { "@faker-js/faker": "^8.4.1", "@mate-academy/eslint-config": "latest", - "@mate-academy/scripts": "^1.8.6", + "@mate-academy/scripts": "^2.1.3", "axios": "^1.7.2", "eslint": "^8.57.0", "eslint-plugin-jest": "^28.6.0", @@ -30,5 +30,8 @@ }, "mateAcademy": { "projectType": "javascript" + }, + "dependencies": { + "busboy": "^1.6.0" } } diff --git a/src/createServer.js b/src/createServer.js index 1cf1dda..1864da8 100644 --- a/src/createServer.js +++ b/src/createServer.js @@ -1,10 +1,213 @@ +/* eslint-disable no-console */ 'use strict'; -function createServer() { - /* Write your code here */ - // Return instance of http.Server class +const http = require('http'); +const zlib = require('zlib'); + +function parseMultipart(req, callback) { + const contentType = req.headers['content-type'] || ''; + const boundaryMatch = contentType.match(/boundary=(.+)$/); + + if (!boundaryMatch) { + return callback(new Error('No boundary'), null); + } + + const boundary = boundaryMatch[1]; + const chunks = []; + + req.on('data', (chunk) => chunks.push(chunk)); + + req.on('end', () => { + const buffer = Buffer.concat(chunks); + const result = { fields: {}, file: null }; + + // Розбиваємо на частини по boundary + const delimiter = Buffer.from(`\r\n--${boundary}`); + const parts = splitBuffer(buffer, delimiter); + + for (const part of parts) { + // Заголовки та тіло частини розділені \r\n\r\n + const headerEnd = indexOfSequence(part, Buffer.from('\r\n\r\n')); + + if (headerEnd === -1) { + continue; + } + + const headerStr = part.slice(0, headerEnd).toString(); + // Тіло — все після \r\n\r\n + const body = part.slice(headerEnd + 4); + + // Ім'я поля з Content-Disposition + const nameMatch = headerStr.match(/name="([^"]+)"/); + + if (!nameMatch) { + continue; + } + + const fieldName = nameMatch[1]; + const filenameMatch = headerStr.match(/filename="([^"]+)"/); + + if (filenameMatch) { + // Це файлове поле + result.file = { + filename: filenameMatch[1], + data: body, + }; + } else { + // Звичайне текстове поле + result.fields[fieldName] = body.toString(); + } + } + + callback(null, result); + }); + + req.on('error', (err) => callback(err, null)); +} + +// Розбиває Buffer на масив Buffer-ів по розділювачу +function splitBuffer(buf, delimiter) { + const parts = []; + let start = 0; + let pos = 0; + + while (pos <= buf.length - delimiter.length) { + if (buf.slice(pos, pos + delimiter.length).equals(delimiter)) { + parts.push(buf.slice(start, pos)); + pos += delimiter.length; + start = pos; + } else { + pos++; + } + } + + parts.push(buf.slice(start)); + + return parts; } -module.exports = { - createServer, +// Шукає позицію послідовності байт у Buffer +function indexOfSequence(buf, seq) { + for (let i = 0; i <= buf.length - seq.length; i++) { + if (buf.slice(i, i + seq.length).equals(seq)) { + return i; + } + } + + return -1; +} + +const COMPRESSION_MAP = { + gzip: { create: () => zlib.createGzip(), ext: 'gz' }, + deflate: { create: () => zlib.createDeflate(), ext: 'dfl' }, + br: { create: () => zlib.createBrotliCompress(), ext: 'br' }, }; + +function createServer() { + const server = http.createServer((req, res) => { + const { method, url } = req; + + // GET / → повертаємо HTML форму + if (method === 'GET' && url === '/') { + res.writeHead(200, { 'Content-Type': 'text/html' }); + + res.end(` + + + +
+ + + +
+ + + `); + + return; + } + + // GET /compress → 400 + if (method === 'GET' && url === '/compress') { + res.writeHead(400); + res.end(); + + return; + } + + // Будь-який запит на невідомий шлях → 404 + if (url !== '/compress') { + res.writeHead(404); + res.end(); + + return; + } + + // POST /compress → основна логіка + if (method === 'POST' && url === '/compress') { + parseMultipart(req, (err, parsed) => { + if (err || !parsed) { + res.writeHead(400); + + return res.end(); + } + + const { file, fields } = parsed; + const compressionType = (fields.compressionType || '').trim(); + + // Крок 4: Валідація — файл і тип стиснення обов'язкові + if (!file) { + res.writeHead(400); + + return res.end(); + } + + if (!compressionType) { + res.writeHead(400); + + return res.end(); + } + + if (!COMPRESSION_MAP[compressionType]) { + res.writeHead(400); + + return res.end(); + } + + // Крок 5: Формуємо ім'я вихідного файлу + const { create, ext } = COMPRESSION_MAP[compressionType]; + const outputFilename = `${file.filename}.${ext}`; + + res.writeHead(200, { + 'Content-Disposition': `attachment; filename=${outputFilename}`, + }); + + // Крок 6: Стискаємо через Stream + // file.data — це Buffer; перетворюємо на Readable stream + const { Readable } = require('stream'); + const readable = new Readable(); + + readable.push(file.data); + readable.push(null); + + const compressor = create(); + + readable.pipe(compressor).pipe(res); + }); + + return; + } + + // Fallback + res.writeHead(404); + res.end(); + }); + + return server; +} + +module.exports = { createServer };