Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/test.yml-template
Original file line number Diff line number Diff line change
@@ -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
31 changes: 27 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.2",
"axios": "^1.7.2",
"eslint": "^8.57.0",
"eslint-plugin-jest": "^28.6.0",
Expand All @@ -30,5 +30,8 @@
},
"mateAcademy": {
"projectType": "javascript"
},
"dependencies": {
"busboy": "^1.6.0"
}
}
1 change: 1 addition & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Implement a page with HTML `form` that allows to:
- original file: `file.txt`
- compression type: `gzip`
- compressed file: `file.txt.gz`


To pass the task you also need to implement a server that:
- use Streams
Expand Down
160 changes: 155 additions & 5 deletions src/createServer.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,160 @@
'use strict';

const http = require('http');
const path = require('path');
const zlib = require('node:zlib');
const { Readable } = require('stream');

const extMap = { gzip: 'gzip', deflate: 'deflate', br: 'br' };

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incorrect extension mapping — the extMap currently maps each type to the algorithm name ('gzip', 'deflate', 'br') so the produced filenames will be file.txt.gzip or file.txt.deflate which violates the requirement that the compressed file name must append these exact extensions: gzip -> .gz, deflate -> .dfl, br -> .br (description). Update the map to use the required extensions, for example:

const extMap = { gzip: 'gz', deflate: 'dfl', br: 'br' };

This change is required so that a request with gzip returns file.txt.gz as specified in the task.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical: extMap is incorrect. The task requires these output extensions: gzip -> .gz, deflate -> .dfl, br -> .br. Currently the map will produce filenames like file.txt.gzip and file.txt.deflate. Update the map to use the required extensions, for example:

const extMap = { gzip: 'gz', deflate: 'dfl', br: 'br' };

This change is required so the response filename matches the task specification.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The extension map uses long names ('gzip', 'deflate', 'br') instead of the required short extensions. The spec requires mapping: gzip → .gz, deflate → .dfl, br → .br. Update this to e.g. const extMap = { gzip: 'gz', deflate: 'dfl', br: 'br' }; so the response filename matches the checklist. This is critical because tests check the exact filename extension.


function createServer() {
/* Write your code here */
// Return instance of http.Server class
return http.createServer(async (req, res) => {
if (req.url === '/' && req.method === 'GET') {
res.statusCode = 200;
res.end('OK');

return;
Comment on lines +12 to +16

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GET / currently returns the string OK. The task requires serving a page with an HTML form (there is an index.html in the project). Serve that HTML (or its contents) on GET / with Content-Type: text/html so visiting / shows the required form that posts to /compress. Right now consumers don't receive the form expected by the spec.

}

if (req.url !== '/compress') {
res.statusCode = 404;
res.end('Not Found');

return;
}

if (req.method !== 'POST') {
res.statusCode = 400;
res.end('Incorrect form method');

return;
}

const ct = req.headers['content-type'];

if (!ct || !ct.includes('multipart/form-data')) {
res.statusCode = 400;
res.end('Expected multipart/form-data');

return;
}

const boundary = ct.split('boundary=')[1]?.replace(/(^"|"$)/g, '');

if (!boundary) {
res.statusCode = 400;
res.end('Missing boundary');

return;
}

const chunks = [];

for await (const chunk of req) {
chunks.push(chunk);
}

const body = Buffer.concat(chunks);

const boundaryBuf = Buffer.from(`--${boundary}`);
let start = 0;
const parts = [];

while (true) {
const idx = body.indexOf(boundaryBuf, start);

if (idx === -1) {
break;
}

const nextIdx = body.indexOf(boundaryBuf, idx + boundaryBuf.length);
const end = nextIdx !== -1 ? nextIdx : body.length;

parts.push(body.slice(idx + boundaryBuf.length, end));
start = end;
}

let fileBuffer = null;
let filename = null;
let type = null;

for (const part of parts) {
let p = part;

if (p.slice(0, 2).toString() === '\r\n') {
p = p.slice(2);
}

const delimiter = Buffer.from('\r\n\r\n');
const idx = p.indexOf(delimiter);

if (idx === -1) {
continue;
}

const headersBuf = p.slice(0, idx);
const bodyBuf = p.slice(idx + delimiter.length);
const headersStr = headersBuf.toString('utf8').trim();

if (headersStr.includes('name="compressionType"')) {
type = bodyBuf.toString('utf8').trim().toLowerCase();

if (!['gzip', 'deflate', 'br'].includes(type)) {
res.statusCode = 400;
res.end('Unsupported compression type');

return;
}
}

if (headersStr.includes('name="file"')) {
const match = headersStr.match(/filename="(.+?)"/);

filename = match ? match[1] : 'file';

let endIdx = bodyBuf.length;

if (bodyBuf.slice(-2).toString() === '\r\n') {
endIdx -= 2;
}
fileBuffer = bodyBuf.slice(0, endIdx);
}
}

if (!fileBuffer || !type) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Functional correctness: the code currently treats the presence of fileBuffer as enough to accept the file, but it does not verify the uploaded file contains data. The requirement says the file field must be present and contain data. Replace the check at the validation step with something that ensures non-empty content, e.g.:

if (!fileBuffer || !fileBuffer.length || !type) {
  // respond 400
}

This ensures zero-length uploads are rejected with 400 as required.

res.statusCode = 400;
res.end('Missing file or compressionType');

return;
}
Comment on lines +124 to +129

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The validation if (!fileBuffer || !type) does not detect zero-byte file uploads because an empty Buffer is truthy. The requirements mandate rejecting missing or empty uploads with HTTP 400. Change the check to ensure the buffer has length, e.g. if (!fileBuffer || fileBuffer.length === 0 || !type) { ... }. This is a critical validation fix.


const compressor =
type === 'gzip'
? zlib.createGzip()
: type === 'deflate'
? zlib.createDeflate()
: zlib.createBrotliCompress();

const compressedFileName = `${path.basename(filename)}.${extMap[type]}`;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When building the compressed filename you use path.basename(filename) and extMap[type]. After fixing extMap ensure the constructed name yields originalName.<ext> with the short extensions (.gz, .dfl, .br). Tests expect that exact naming scheme. Confirm extMap[type] returns the short extension before concatenation.


res.statusCode = 200;
res.setHeader('Content-Type', 'application/octet-stream');

res.setHeader(
'Content-Disposition',
`attachment; filename=${compressedFileName}`,
Comment on lines +143 to +145

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please quote the filename in Content-Disposition to safely handle names with spaces or special characters. Change:

'Content-Disposition',
`attachment; filename=${compressedFileName}`,

to

'Content-Disposition',
`attachment; filename="${compressedFileName}"`,

This prevents clients from mis-parsing the header and ensures the correct filename is presented to the user.

Comment on lines +143 to +145

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Content-Disposition header sets the filename without quotes: attachment; filename=${compressedFileName}. Filenames containing spaces or special characters must be quoted. Change to attachment; filename="${compressedFileName}" (or use proper RFC5987 encoding if needed). Tests typically expect the filename to be quoted. This is required to meet the spec about delivering a downloadable file with correct filename.

);

const stream = Readable.from([fileBuffer]);

stream
.pipe(compressor)
.pipe(res)
.on('error', () => {
res.statusCode = 500;
res.end('Compression error');
});
Comment on lines +150 to +156

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Error handling for stream piping is fragile. Instead of chaining .pipe(...).pipe(res).on('error', ...), prefer stream.pipeline(readableStream, compressor, res, (err) => { ... }) from the stream module to propagate errors across streams reliably and avoid leaking partial responses. This is a recommended robustness improvement (not strictly required), but helps prevent unhandled stream errors. Example: const { pipeline } = require('stream'); pipeline(stream, compressor, res, err => { if (err) { res.statusCode = 500; res.end('Compression error'); } });.

});
}

module.exports = {
createServer,
};
module.exports = { createServer };
34 changes: 34 additions & 0 deletions src/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<main>
<h1>File Compression</h1>

<form action="/compress" method="post" enctype="multipart/form-data">
<label for="file">Select a file: </label>
<input type="file" name="file" id="file" required>

<br>
<br>

<label for="type">Select a compression type: </label>
<select name="compressionType" id="type" required>
<option value="gzip">gzip</option>
<option value="deflate">deflate</option>
<option value="br">br</option>
</select>

<br>
<br>

<button type="submit">Submit</button>
</form>
</main>

</body>
</html>