diff --git a/.github/workflows/test.yml-template b/.github/workflows/test.yml-template new file mode 100644 index 0000000..bb13dfc --- /dev/null +++ b/.github/workflows/test.yml-template @@ -0,0 +1,23 @@ +name: Test + +on: + pull_request: + branches: [ master ] + +jobs: + build: + + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [20.x] + + steps: + - uses: actions/checkout@v2 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + - run: npm install + - run: npm test diff --git a/package-lock.json b/package-lock.json index d0b3b95..3650660 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", diff --git a/package.json b/package.json index 1d03d64..8e6392d 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", diff --git a/src/createServer.js b/src/createServer.js index 1cf1dda..db4b9ba 100644 --- a/src/createServer.js +++ b/src/createServer.js @@ -1,8 +1,233 @@ 'use strict'; +const http = require('http'); +const zlib = require('zlib'); +const path = require('path'); +const fs = require('fs'); + +const COMPRESSION_MAP = { + gzip: { fn: () => zlib.createGzip(), ext: '.gz' }, + deflate: { fn: () => zlib.createDeflate(), ext: '.dfl' }, + br: { fn: () => zlib.createBrotliCompress(), ext: '.br' }, +}; + +/** + * Parse a multipart/form-data body. + * Returns { fields: { name: value }, + * files: { name: { filename, data: Buffer } } } + */ +function parseMultipart(buffer, boundary) { + const fields = {}; + const files = {}; + + const boundaryBuf = Buffer.from('--' + boundary); + // const CRLF = Buffer.from('\r\n'); + const CRLFCRLF = Buffer.from('\r\n\r\n'); + + let offset = 0; + + while (offset < buffer.length) { + // Find next boundary + const boundaryIdx = indexOf(buffer, boundaryBuf, offset); + + if (boundaryIdx === -1) { + break; + } + + offset = boundaryIdx + boundaryBuf.length; + + // Check for final boundary (--) + if (buffer[offset] === 0x2d && buffer[offset + 1] === 0x2d) { + break; + } + + // Skip CRLF after boundary + if (buffer[offset] === 0x0d && buffer[offset + 1] === 0x0a) { + offset += 2; + } + + // Find header/body separator + const headerEnd = indexOf(buffer, CRLFCRLF, offset); + + if (headerEnd === -1) { + break; + } + + const headerStr = buffer.slice(offset, headerEnd).toString('utf8'); + + offset = headerEnd + 4; // skip \r\n\r\n + + // Find end of this part (next boundary) + const nextBoundary = indexOf(buffer, boundaryBuf, offset); + + if (nextBoundary === -1) { + break; + } + + // eslint-disable-next-line max-len + // Part body ends 2 bytes before the next boundary + // line (strip trailing \r\n) + const bodyEnd = nextBoundary - 2; + const body = buffer.slice(offset, bodyEnd); + + // Parse Content-Disposition + const dispMatch = headerStr.match( + /Content-Disposition:[^\r\n]*[^a-z]name="([^"]+)"/i, + ); + + if (!dispMatch) { + offset = nextBoundary; + continue; + } + + const name = dispMatch[1]; + + const fileMatch = headerStr.match(/filename="([^"]*)"/i); + + if (fileMatch) { + files[name] = { filename: fileMatch[1], data: body }; + } else { + fields[name] = body.toString('utf8'); + } + + offset = nextBoundary; + } + + return { fields, files }; +} + +/** indexOf for Buffers */ +function indexOf(haystack, needle, start = 0) { + for (let i = start; i <= haystack.length - needle.length; i++) { + let found = true; + + for (let j = 0; j < needle.length; j++) { + if (haystack[i + j] !== needle[j]) { + found = false; + break; + } + } + + if (found) { + return i; + } + } + + return -1; +} + function createServer() { - /* Write your code here */ - // Return instance of http.Server class + return http.createServer((req, res) => { + const url = new URL(req.url, `http://${req.headers.host}`); + const pathname = url.pathname; + + // Serve the HTML page + if (req.method === 'GET' && pathname === '/') { + const htmlPath = path.join(__dirname, 'index.html'); + const stream = fs.createReadStream(htmlPath); + + res.writeHead(200, { 'Content-Type': 'text/html' }); + stream.pipe(res); + + return; + } + + // Handle GET /compress → 400 + if (req.method === 'GET' && pathname === '/compress') { + res.writeHead(400, { 'Content-Type': 'text/plain' }); + res.end('Bad Request: GET is not allowed on /compress. Use POST.'); + + return; + } + + // Handle POST /compress + if (req.method === 'POST' && pathname === '/compress') { + const contentType = req.headers['content-type'] || ''; + const boundaryMatch = contentType.match(/boundary=(.+)$/i); + + if (!contentType.startsWith('multipart/form-data') || !boundaryMatch) { + res.writeHead(400, { 'Content-Type': 'text/plain' }); + res.end('Bad Request: Expected multipart/form-data.'); + + return; + } + + const boundary = boundaryMatch[1]; + const chunks = []; + + req.on('data', (chunk) => chunks.push(chunk)); + + req.on('end', () => { + const buffer = Buffer.concat(chunks); + let parsed; + + try { + parsed = parseMultipart(buffer, boundary); + } catch (err) { + res.writeHead(400, { 'Content-Type': 'text/plain' }); + res.end('Bad Request: Failed to parse form data.'); + + return; + } + + const { fields, files } = parsed; + const compressionType = fields['compressionType']; + const uploadedFile = files['file']; + + // Validate: file present + if (!uploadedFile || !uploadedFile.filename) { + res.writeHead(400, { 'Content-Type': 'text/plain' }); + res.end('Bad Request: No file provided.'); + + return; + } + + // Validate: compression type supported + if (!compressionType || !COMPRESSION_MAP[compressionType]) { + res.writeHead(400, { 'Content-Type': 'text/plain' }); + + res.end( + `Bad Request: Unsupported compression type "${compressionType}". Use gzip, deflate, or br.`, + ); + + return; + } + + const { fn: createCompressor, ext } = COMPRESSION_MAP[compressionType]; + const outputFilename = uploadedFile.filename + ext; + + res.writeHead(200, { + 'Content-Type': 'application/octet-stream', + 'Content-Disposition': `attachment; filename=${outputFilename}`, + }); + + // Stream: Buffer → compressor → response + const { Readable } = require('stream'); + const readable = Readable.from(uploadedFile.data); + const compressor = createCompressor(); + + readable.pipe(compressor).pipe(res); + + compressor.on('error', () => { + if (!res.headersSent) { + res.writeHead(500, { 'Content-Type': 'text/plain' }); + res.end('Internal Server Error'); + } + }); + }); + + req.on('error', () => { + res.writeHead(400, { 'Content-Type': 'text/plain' }); + res.end('Bad Request: Error reading request body.'); + }); + + return; + } + + // 404 for everything else + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('Not Found'); + }); } module.exports = { diff --git a/src/index.html b/src/index.html new file mode 100644 index 0000000..9cb9b0a --- /dev/null +++ b/src/index.html @@ -0,0 +1,130 @@ + + + + + + File Compression + + + +
+

🗜️ File Compressor

+
+
+ + +
+
+ + +
+ +
+

+
+ + + + diff --git a/src/index.js b/src/index.js index 842ef95..9ac6b7d 100644 --- a/src/index.js +++ b/src/index.js @@ -9,3 +9,12 @@ createServer().listen(5700, () => { console.log('Server started! 🚀'); console.log('Available at http://localhost:5700'); }); + +/* const createServer = require('./createServer'); + +const PORT = process.env.PORT || 3000; +const server = createServer(); + +server.listen(PORT, () => { + console.log(`Compression server running at http://localhost:${PORT}`); +}); */