সরাসরি পর্বে যান
পর্ব ৩০ · ৯৩-এর মধ্যে · মডিউল ৪

File upload — multipart, stream আর সীমা

File Uploads — multipart/form-data, Streams, Limits and Safe Storage in Express 5

Taught in Bangla, this lesson shows how a file travels as multipart/form-data, sending it with FormData, multer in Express 5, streams, pipeline and backpressure with measured memory for memory versus disk storage, size, count and early rejection limits, checking type by magic bytes because the client can lie, random names outside the web root with no execution, resizing and re-encoding images with sharp and cleaning orphans.

এই পর্বে যা শিখবেন

  • multipart/form-data-র আসল byte পড়া, আর একটি ছোট parser হাতে লেখা
  • FormData আর XMLHttpRequest দিয়ে progress দেখানো
  • Express 5-এ multer আর তার নিচের busboy ব্যবহার, memory বনাম disk-এর মাপা তুলনা
  • Stream, pipeline আর backpressure; আকার ও সংখ্যার সীমা
  • Magic bytes, নিরাপদ নাম, web root-এর বাইরে রাখা, sharp, presigned URL আর মালিকহীন ফাইল পরিষ্কার

১ · একটি ফাইল তারের ওপর দিয়ে কীভাবে যায়

“বাজারঘর”-এর দোকানি রহিমা আপা ইলিশ মাছের একটি ছবি তুলে নতুন পণ্যে যোগ করতে চান। এতক্ষণ আমরা JSON পাঠিয়েছি। JSON হলো লেখা, আর ছবি হলো এলোমেলো byte. একই অনুরোধে পণ্যের নাম (লেখা) আর ছবি (byte) দুটোই যেতে হবে। এই কাজের জন্যই আছে multipart/form-data.

ডাকঘরে একটি বড় পার্সেল ভাবুন। ভেতরে কয়েকটি আলাদা প্যাকেট, প্রতিটির গায়ে একটি লেবেল: “নাম”, “ছবি”. দুটি প্যাকেটের মাঝে একটি নির্দিষ্ট দাগ টানা আছে, যাতে খোলার সময় বোঝা যায় কোথায় একটি শেষ আর কোথায় পরেরটি শুরু। সেই দাগটিই boundary, প্রতিটি প্যাকেট একটি part, আর লেবেল হলো Content-Disposition.

এবার আসল জিনিসটি দেখি। আমরা Edge browser-এ একটি form থেকে ১×১ pixel-এর ৯০ byte PNG পাঠালাম, আর সামনে একটি কাঁচা node:http server বসিয়ে যা এলো হুবহু ধরে রাখলাম। শিরোনামে গেল Content-Type:

Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryQG5LhSqiMKuBnFqf
Content-Length: 394

আর body-র ৩৯৪ byte-এর শুরু আর মাঝের অংশ, বাঁয়ে hex, ডানে যা পড়া যায় (সত্যিকারের run, Edge 156):

0000 2d 2d 2d 2d 2d 2d 57 65 62 4b 69 74 46 6f 72 6d  ------WebKitForm
0010 42 6f 75 6e 64 61 72 79 51 47 35 4c 68 53 71 69  BoundaryQG5LhSqi
0020 4d 4b 75 42 6e 46 71 66 0d 0a 43 6f 6e 74 65 6e  MKuBnFqf..Conten
0030 74 2d 44 69 73 70 6f 73 69 74 69 6f 6e 3a 20 66  t-Disposition: f
0040 6f 72 6d 2d 64 61 74 61 3b 20 6e 61 6d 65 3d 22  orm-data; name="
0050 74 69 74 6c 65 22 0d 0a 0d 0a e0 a6 87 e0 a6 b2  title"..........
0060 e0 a6 bf e0 a6 b6 20 e0 a6 ae e0 a6 be e0 a6 9b  ...... .........
…
00b0 20 66 6f 72 6d 2d 64 61 74 61 3b 20 6e 61 6d 65   form-data; name
00c0 3d 22 70 68 6f 74 6f 22 3b 20 66 69 6c 65 6e 61  ="photo"; filena
00d0 6d 65 3d 22 e0 a6 aa e0 a6 a3 e0 a7 8d e0 a6 af  me="............
00e0 2e 70 6e 67 22 0d 0a 43 6f 6e 74 65 6e 74 2d 54  .png"..Content-T
00f0 79 70 65 3a 20 69 6d 61 67 65 2f 70 6e 67 0d 0a  ype: image/png..
0100 0d 0a 89 50 4e 47 0d 0a 1a 0a 00 00 00 0d 49 48  ...PNG........IH
…
0150 00 00 00 00 49 45 4e 44 ae 42 60 82 0d 0a 2d 2d  ....IEND.B`...--
…
0180 75 42 6e 46 71 66 2d 2d 0d 0a                    uBnFqf--..

চারটি জিনিস খেয়াল করুন। প্রতিটি part শুরু হয় -- আর boundary দিয়ে। Header শেষ হয় একটি ফাঁকা লাইনে (0d 0a 0d 0a). e0 a6 87 হলো “ই” অক্ষরের UTF-8 byte, অর্থাৎ শিরোনাম “ইলিশ মাছ” আর ফাইলের নাম “পণ্য.png” দুটোই কাঁচা UTF-8-এ গেছে। আর শেষ boundary-র পরে আরও দুটি হাইফেন (--) মানে পার্সেল শেষ।

ফাইলের byte-এ আছে 0d 0a, যা দেখতে লাইন ভাঙার মতো। তাই multipart ভাঙতে হয় byte হিসেবে, লেখা হিসেবে নয়। নিচের ছোট parser চালিয়ে দেখুন। এটি শেখার মডেল, production-এ নিজে parser লিখবেন না।

// একটি ছোট multipart body হাতে বানাই, তারপর হাতে ভাঙি
const b = '----bazarghar';
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const body = Buffer.concat([
  Buffer.from(`--${b}\r\nContent-Disposition: form-data; name="title"\r\n\r\n`),
  Buffer.from('Ilish mach'),
  Buffer.from(`\r\n--${b}\r\nContent-Disposition: form-data; name="photo"; filename="fish.png"\r\n`),
  Buffer.from('Content-Type: image/png\r\n\r\n'),
  png,
  Buffer.from(`\r\n--${b}--\r\n`),
]);

function parse(buf, boundary) {
  const mark = Buffer.from(`--${boundary}`);
  const parts = [];
  let at = buf.indexOf(mark);
  while (at !== -1) {
    const start = at + mark.length;
    if (buf.subarray(start, start + 2).toString() === '--') break;   // শেষ boundary
    const next = buf.indexOf(mark, start);
    const headEnd = buf.indexOf('\r\n\r\n', start);
    const head = buf.subarray(start + 2, headEnd).toString();
    const data = buf.subarray(headEnd + 4, next - 2);    // শেষের \r\n বাদ
    const name = /name="([^"]*)"/.exec(head)[1];
    const file = /filename="([^"]*)"/.exec(head)?.[1];
    parts.push({ name, file, bytes: data.length, data });
    at = next;
  }
  return parts;
}
console.log('body bytes:', body.length);
for (const p of parse(body, b)) {
  console.log(p.name, p.file ?? '(text)', p.bytes, 'bytes', p.file ? p.data.toString('hex') : p.data.toString());
}
console.log('as text:', Buffer.from(png.toString('utf8')).length, 'bytes, real:', png.length);

আউটপুট: body bytes: 217, তারপর title (text) 10 bytes Ilish mach আর photo fish.png 8 bytes 89504e470d0a1a0a. শেষ লাইন as text: 10 bytes, real: 8 সবচেয়ে জরুরি: ৮ byte-কে UTF-8 লেখা ধরে ফিরিয়ে আনলে ১০ byte হয়ে গেছে, কারণ 0x89 বৈধ UTF-8 নয়। তাই ফাইল Buffer-এ রাখতে হয়, string-এ নয়।

২ · Browser থেকে পাঠানো — FormData আর progress

Client-এর কাজ সহজ, কিন্তু দুটি ফাঁদ আছে। পর্ব ১২-এ যে FormData দেখেছেন, সেটিই এখানে ফাইলও বহন করে। ধীর মোবাইল নেটওয়ার্কে ব্যবহারকারী progress bar দেখতে চান। fetch()-এ upload-এর অগ্রগতির মানসম্মত event নেই (লেখার সময়ে), তাই এই একটি কাজে পুরোনো XMLHttpRequest এখনও সবচেয়ে সরল পথ।

function uploadPhoto(productId, title, file, onProgress) {
  return new Promise((resolve, reject) => {
    const fd = new FormData();
    fd.append('title', title);
    fd.append('photo', file);

    const xhr = new XMLHttpRequest();
    xhr.upload.addEventListener('progress', (e) => {
      if (e.lengthComputable) onProgress(Math.round(e.loaded / e.total * 100));
    });
    xhr.addEventListener('load', () => {
      if (xhr.status < 300) resolve(JSON.parse(xhr.responseText));
      else reject(new Error(xhr.responseText));
    });
    xhr.addEventListener('error', () => reject(new Error('network')));

    xhr.open('POST', `/api/products/${productId}/image`);
    xhr.send(fd);          // Content-Type নিজে বসাবেন না
  });
}

এই একই ধরনের কোড headless Edge 156-এ চালিয়ে upload গতি ১ MiB/s-এ বেঁধে রাখা হয়েছিল (DevTools protocol), ১৪,০৫,৯১০ byte-এর ছবিতে। সত্যিকারের run, ছোট করে দেখানো:

8%   114688/1406208
15%  212992/1406208
…
93%  1310720/1406208
100% 1406208/1406208
upload load
response 200

total ১৪,০৬,২০৮, অর্থাৎ ফাইলের চেয়ে মাত্র ২৯৮ byte বেশি। Multipart-এর বাড়তি খরচ এখানে মাত্র ০.০২%. একই ছবি JSON-এর ভেতরে Base64 করে পাঠালে হতো ১৮,৭৪,৫৪৮ byte, অর্থাৎ ৩৩% বেশি।

ফাঁদ ১, শেষ লাইনের মন্তব্যটি: Content-Type: multipart/form-data হাতে লিখলে boundary হারিয়ে যায়। Browser নিজে boundary বানিয়ে শিরোনামে বসায়। আমাদের server-এ হাতে শিরোনাম দিয়ে পাঠিয়ে আসল উত্তর পেলাম: {"error":"BAD_MULTIPART","message":"Multipart: Boundary not found"}.

ফাঁদ ২, upload load মানেই সফল নয়: এটি শুধু বলে byte পৌঁছেছে। Server তখনও ফাইল যাচাই করছে। তাই UI-তে ১০০%-এর পরে “প্রক্রিয়া চলছে” দেখান, আর সাফল্য ঘোষণা করুন response পেয়ে।

৩ · Express 5-এ multer — চেকপোস্টের সারি

Express-এর express.json() multipart পড়ে না। এই কাজের প্রচলিত package হলো multer, যার ভেতরে কাজ করে busboy নামের parser. লেখার সময়ে সংস্করণ: Express 5.3.0, multer 2.5.0, busboy 1.6.0, sharp 0.35.5, file-type 22.3.0, Node 24.15. npm-এ multer 3-এর একটি alpha আছে; আমরা stable latest নিয়েছি। Multer 1.x-কে npm নিজেই deprecated বলছে, কারণ তার জানা দুর্বলতাগুলো 2.x-এ সারানো।

npm install express@5.3.0 multer@2.5.0 sharp@0.35.5 file-type@22.3.0

“বাজারঘর”-এর upload পথ: একের পর এক চেকপোস্ট

Cheap checks first; bytes reach permanent storage only at the end

Upload-এর পথে যাচাইয়ের ধাপগুলো Browser: FormData Reverse proxy: আকার সীমা Login আর অনুমতি Content-Length যাচাই multer → tmp ফোল্ডার Magic bytes যাচাই sharp: নতুন করে encode Storage (web root-এর বাইরে) + database-এ সারি 4xx tmp মুছে
ছবি ৩০.১ — সস্তা যাচাই আগে। যে ধাপে ব্যর্থ, সেখানেই থেমে উত্তর ফেরে, স্থায়ী জায়গায় কিছু পৌঁছায় না।

এবার সেই পথের কোড। পর্ব ২৯-এ যেমন দেখেছেন, বড় অ্যাপে এই কাজ service স্তরে যায়; এখানে এক ফাইলে রাখা হয়েছে, যাতে পুরো পথ এক নজরে দেখা যায়। অংশ ৫-এর আগে কেবল গঠনটুকু দেখুন। পর্ব ২৬-এর middleware-এর ক্রমটিই এখানে আসল নিরাপত্তা: login-এর আগে multer বসালে অচেনা যে কেউ আপনার disk ভরতে পারত।

const MAX_BYTES = 5 * 1024 * 1024;             // 5 MiB

const upload = multer({
  storage: multer.diskStorage({
    destination: TMP,                          // storage/tmp
    filename: (req, file, cb) => cb(null, randomUUID()),
  }),
  limits: { fileSize: MAX_BYTES, files: 1, fields: 5, parts: 6 },
  defParamCharset: 'utf8',                     // বাংলা ফাইলের নামের জন্য
});

app.post('/api/products/:id/image',
  requireAdmin,                                // সবার আগে
  rejectBigEarly,                              // Content-Length দেখে ফেরত
  upload.single('photo'),                      // তারপর byte পড়া
  async (req, res) => {
    if (!req.file) return res.status(400).json({ error: 'NO_FILE' });
    const tmp = req.file.path;
    try {
      const type = await fileTypeFromFile(tmp);
      if (!type || !ALLOWED.has(type.mime)) {
        return res.status(415).json({ error: 'NOT_AN_IMAGE', detected: type?.mime ?? 'unknown' });
      }
      // sharp দিয়ে 800 আর 200 আকারে .webp বানানো (অংশ ৮)
      res.status(201).json({ /* … */ });
    } finally {
      await rm(tmp, { force: true });          // সফল বা ব্যর্থ, tmp মুছবেই
    }
  });

Express 5-এ async handler-এর rejected promise নিজে থেকেই error middleware-এ যায়, যা পর্ব ২৮-এর central handler ধরবে। upload.single('photo')-কে route-এ বসানো হয়েছে, পুরো অ্যাপে নয়। Multer-এর docs-ও বলে, global middleware হিসেবে বসাবেন না। আর JSON API-র অন্য route-এ ফাইল ঢোকার পথই থাকবে না।

নিচের stepper-এ রহিমা আপার আসল ছবিটি পুরো পথ পার হচ্ছে। সংখ্যাগুলো সত্যিকারের run-এর। ছবিটি কৃত্রিমভাবে বানানো ৪০০০×৩০০০ JPEG.

  1. BrowserFormData
  2. LoginrequireAdmin
  3. আকারContent-Length
  4. multertmp/uuid
  5. ধরনfile-type
  6. sharp.webp
  1. ধাপ ১ — পাঠানো। রহিমা আপা phone-photo.jpg বেছে নিলেন, আকার ১৪,০৫,৯১০ byte. Browser FormData থেকে body বানাল, boundary বসাল, আর POST করল।
  2. ধাপ ২ — Login. Body-র একটি byte-ও পড়ার আগে requireAdmin দেখল অনুরোধটি কার। Login না থাকলে আমাদের run-এ উত্তর ছিল 401 LOGIN_REQUIRED. এখানে তিনি অনুমতিপ্রাপ্ত, তাই এগিয়ে গেল।
  3. ধাপ ৩ — আগাম আকার যাচাই। শিরোনামের Content-Length ১৪,০৬,২২৬, আর সীমা ৫ MiB + ৬৪ KiB = ৫৩,০৮,৪১৬ byte. বাড়তি ৬৪ KiB multipart-এর শিরোনাম আর boundary-র জন্য। সীমার নিচে, তাই পথ খোলা।
  4. ধাপ ৪ — Multer. Busboy byte-এর stream থেকে part আলাদা করল। title গেল req.body-তে, যা পর্ব ২৭-এর মতো schema দিয়ে যাচাই করতে হবে, ফাইলের byte সরাসরি stream হয়ে storage/tmp/<uuid>-এ গেল। পুরো ফাইল memory-তে বসেনি। req.file.size = ১৪,০৫,৯১০.
  5. ধাপ ৫ — ধরন। fileTypeFromFile ফাইলের শুরুর byte পড়ে বলল image/jpeg, যা অনুমোদিত তালিকায় আছে। Client কী বলেছিল, এখানে তা গোনা হয়নি।
  6. ধাপ ৬ — নতুন ছবি। sharp বানাল ৮০০×৬০০ (১,৫৭,৬৯৬ byte) আর ২০০×২০০ (১৬,৬৯৮ byte). finally tmp ফাইল মুছল (tmp left = 0), উত্তর গেল 201, মোট সময় ০.১১ সেকেন্ড, নিজের মেশিনে একবারের run.

Memory না disk — multer-এর দুই storage

memoryStorage

পুরো ফাইল req.file.buffer-এ। সহজ, tmp মোছার ঝামেলা নেই। ছোট ফাইল, কম সংখ্যা আর কড়া সীমায় চলে। সীমা ছাড়া এটি বিপজ্জনক।

খরচ: ফাইলের আকারের সমানুপাতিক

diskStorage

Byte stream হয়ে ফাইলে যায়, req.file.path পাওয়া যায়। Memory প্রায় স্থির থাকে, তবে tmp ফাইল আপনাকে মুছতে হয়, আর সীমা না থাকলে disk ভরে।

খরচ: disk আর পরিষ্কারের দায়িত্ব

busboy সরাসরি

নিচের স্তর: প্রতিটি part একটি stream. যেমন সরাসরি object storage-এ stream করতে চাইলে। সব error আর সীমা আপনাকে হাতে সামলাতে হয়।

খরচ: আপনার নিজের কোড

৪ · Stream, pipeline আর backpressure — মেপে দেখা

পুকুর থেকে বালতি ভরে উঠোনের ড্রামে পানি আনার কথা ভাবুন। পুরো পুকুর একসাথে তোলার চেষ্টা (buffering) অসম্ভব। এক বালতি করে আনলে (stream) হাতে থাকে শুধু এক বালতি। আর ড্রামের মুখ সরু হলে যিনি ঢালছেন তিনি বলেন “একটু থামুন”, যাতে উঠোন ভেসে না যায়। এই “থামুন” বলাটাই backpressure.

Node-এর stream টুকরো (chunk) ধরে ধরে ডেটা নেয়। JavaScript কোর্সের Node পর্বে stream-এর ভিত্তি আছে। এখানে দরকার দুটি জিনিস। Writable stream-এর write() false ফেরালে লেখা থামাতে হয়, আর drain event-এ আবার শুরু করতে হয়। সেই সীমাটির নাম highWaterMark. pipeline() এই থামা-চলা নিজে সামলায়, আর কোনো ধাপে error হলে সব stream বন্ধ করে। পুরোনো .pipe() error-এ বাকি stream বন্ধ করে না, তাই নতুন কোডে pipeline নিন।

const { Readable, Writable, Transform } = require('node:stream');
const { pipeline } = require('node:stream/promises');

const CHUNK = 64 * 1024, CHUNKS = 200;       // মোট 12.5 MiB
const slowDisk = () => new Writable({ highWaterMark: 256 * 1024,
  write(chunk, enc, done) { setImmediate(done); } });   // ধীর ডিস্কের ভান

async function withPipeline() {
  let bytes = 0, peak = 0;
  const disk = slowDisk();
  const counter = new Transform({ transform(chunk, enc, cb) {
    bytes += chunk.length;
    peak = Math.max(peak, disk.writableLength);
    cb(null, chunk);
  } });
  const source = (function* () {
    for (let i = 0; i < CHUNKS; i++) yield Buffer.alloc(CHUNK);
  })();
  await pipeline(Readable.from(source), counter, disk);
  console.log('pipeline : bytes', bytes, 'peak queue', peak);
}

async function ignoreBackpressure() {
  const disk = slowDisk();
  let peak = 0;
  for (let i = 0; i < CHUNKS; i++) {
    disk.write(Buffer.alloc(CHUNK));          // false এলেও থামছে না
    peak = Math.max(peak, disk.writableLength);
  }
  disk.end();
  await new Promise((ok) => disk.on('finish', ok));
  console.log('no pause : bytes', CHUNK * CHUNKS, 'peak queue', peak);
}

withPipeline().then(ignoreBackpressure);

আউটপুট দুই লাইন: pipeline : bytes 13107200 peak queue 262144 আর no pause : bytes 13107200 peak queue 13107200. একই ১২.৫ MiB, একই ধীর “disk”. pipeline-এ সারি কখনো ২,৬২,১৪৪ byte (২৫৬ KiB, অর্থাৎ highWaterMark) ছাড়ায়নি। write()-এর false উপেক্ষা করলে পুরো ১২.৫ MiB memory-তে জমেছে। ফাইল যত বড়, জমাও তত বড়।

এবার আসল Express server, প্রতি mode-এর জন্য নতুন process. curl দিয়ে ফাইল পাঠিয়ে ৫ ms পরপর process.memoryUsage() মাপা হয়েছে। সত্যিকারের run, Node 24.15, Windows, নিজের মেশিনে:

Mode ও ফাইলশুরুর RSSচূড়ার RSSচূড়ার arrayBuffers
memoryStorage, ৫০ MiB × ১52 MiB156 MiB100 MiB
memoryStorage, ২০০ MiB × ১63 MiB468 MiB400 MiB
memoryStorage, ৫০ MiB × ৪ একসাথে52 MiB346 MiB286 MiB
diskStorage, ৫০ MiB × ১52 MiB83 MiB29 MiB
diskStorage, ২০০ MiB × ১63 MiB127 MiB60 MiB
diskStorage, ৫০ MiB × ৪ একসাথে52 MiB118 MiB62 MiB
সরাসরি busboy + pipeline, ২০০ MiB × ১63 MiB126 MiB58 MiB

এখানে একটি অবাক করা সংখ্যা আছে: ২০০ MiB ফাইলে memoryStorage-এর চূড়া ৪০০, ফাইলের দ্বিগুণ। Multer-এর সোর্স খুললেই কারণ পাওয়া যায়: chunks.push(chunk), তারপর Buffer.concat(chunks). জোড়া লাগানোর মুহূর্তে টুকরোগুলো আর নতুন একটানা buffer দুটোই memory-তে থাকে। Disk mode-এর ৬০ MiB-ও জমানো ফাইল নয়; সম্ভবত সেগুলো ব্যবহার শেষ হওয়া chunk, যা garbage collector এখনো সরায়নি। এটি আমাদের ব্যাখ্যা, আলাদা করে মাপা হয়নি। এই সংখ্যাগুলো একটি মেশিনের একটি run, সাধারণ ফল নয়। তবে ধরনটি কোড থেকেই আসে।

সরাসরি busboy-র কোড ছোট, আর এটিই multer-এর ভেতরের চেহারা:

app.post('/up', (req, res, next) => {
  const bb = Busboy({ headers: req.headers, limits: { fileSize: MAX } });
  let info;
  bb.on('file', (name, file) => {
    const to = path.join(TMP, randomUUID());
    info = pipeline(file, createWriteStream(to))
      .then(() => ({ size: statSync(to).size, truncated: file.truncated }));
  });
  bb.on('close', () => info.then((x) => res.json(x)).catch(next));
  req.pipe(bb);
});

৫ · সীমা — আকার, সংখ্যা আর আগাম প্রত্যাখ্যান

সীমা না থাকলে upload route হলো খোলা ট্রাকের ঢোকার পথ। এক অনুরোধেই disk ভরে যেতে পারে, যা DoS-এর সবচেয়ে সহজ রূপ। Multer-এর fileSize-এর default হলো Infinity, মানে সীমা না বসালে কোনো সীমাই নেই।

সীমাকী আটকায়defaultআমাদের মানভাঙলে error
fileSizeএকটি ফাইলের byteInfinity5 MiBLIMIT_FILE_SIZE
filesফাইলের সংখ্যাInfinity1LIMIT_FILE_COUNT
fieldsলেখার ঘরের সংখ্যাInfinity5LIMIT_FIELD_COUNT
partsমোট partInfinity6LIMIT_PART_COUNT
fieldSizeএকটি লেখার ঘরের byte1 MBdefaultLIMIT_FIELD_VALUE

Multer ব্যবহার না করলেও ধারণাটি একই। নিচের Transform stream গুনে গুনে সীমা পার হলেই error দেয়, আর pipeline সব থামায়:

const { Readable, Writable, Transform } = require('node:stream');
const { pipeline } = require('node:stream/promises');

const MiB = 1024 * 1024;
const LIMIT = 5 * MiB;
let produced = 0, stored = 0;

async function* client() {                     // 50 MiB পাঠাতে চায়
  try {
    for (let i = 0; i < 50; i++) { produced += MiB; yield Buffer.alloc(MiB); }
  } finally {
    console.log('source closed after', produced / MiB, 'MiB');
  }
}

function sizeLimit(max) {
  let seen = 0;
  return new Transform({ transform(chunk, enc, cb) {
    seen += chunk.length;
    if (seen <= max) return cb(null, chunk);
    const e = new Error('file too large');
    e.code = 'LIMIT_FILE_SIZE';
    cb(e);                                     // pipeline সব stream বন্ধ করবে
  } });
}

const disk = new Writable({ write(chunk, enc, done) { stored += chunk.length; done(); } });

pipeline(Readable.from(client()), sizeLimit(LIMIT), disk)
  .then(() => console.log('saved'))
  .catch((err) => {
    console.log('stopped:', err.code);
    console.log('stored', stored / MiB, 'MiB; delete the partial file now');
  });

আউটপুট তিন লাইন: source closed after 7 MiB, stopped: LIMIT_FILE_SIZE আর stored 5 MiB; delete the partial file now. ৫০ MiB-র উৎস ৭ MiB-তেই বন্ধ হয়েছে। ৫-এর বদলে ৭ কেন? সীমা ভাঙে ষষ্ঠ MiB-তে, আর stream-এর ভেতরের buffer আরও এক টুকরো আগেই টেনে রেখেছিল। “ডিস্কে” থেকে গেছে ৫ MiB-র অসম্পূর্ণ ফাইল, যা মুছতে হবে।

এবার সত্যিকারের multer-এ ৫০ MiB ফাইল পাঠাই, সীমা ৫ MiB. একটি জিনিস আমরা ধরে নিয়েছিলাম: সীমা পেরোলেই server পড়া থামাবে। মেপে দেখা গেল ঘটনা অন্য।

Server-এর হিসাব (৫০ MiB ফাইল, সীমা ৫ MiB)
server পড়েছে
tmp ফাইল
অবস্থা
  1. ধাপ ১ — অনুরোধ এলো। এই route-এ আগাম যাচাই নেই, multer সরাসরি শুরু করল। Body মোট ৫,২৪,২৯,০১৪ byte.
  2. ধাপ ২ — Stream চলছে। Busboy থেকে byte tmp ফাইলে যাচ্ছে। Memory প্রায় স্থির।
  3. ধাপ ৩ — সীমায় পৌঁছানো। Multer busboy-কে সীমার চেয়ে ১ বেশি বলে, যাতে ঠিক ৫ MiB-র ফাইল পাস করে। পরের byte এলেই busboy জানাবে।
  4. ধাপ ৪ — সীমা ভাঙল। Busboy-র limit event এলো, multer LIMIT_FILE_SIZE error তৈরি করল।
  5. ধাপ ৫ — পরিষ্কার। Multer অসম্পূর্ণ tmp ফাইল নিজেই মুছল। আমাদের run-এ tmpFiles = 0.
  6. ধাপ ৬ — চমক। Multer সংযোগ কাটে না। Client-এর পাঠানো শেষ না হওয়া পর্যন্ত বাকি byte পড়ে ফেলে দেয় (সোর্সে একে drain বলা হয়েছে), যাতে client লেখা শেষের আগে সংযোগ ভেঙে না যায়। Disk বাঁচল, কিন্তু network আর সময় খরচ হলো।
  7. ধাপ ৭ — উত্তর। সব পড়ার পর error handler চলল, client পেল 413 {"error":"LIMIT_FILE_SIZE"}. seenByServer = ৫,২৪,২৯,০১৪, ৫ MiB নয়।

তাই Content-Length দেখে আগেই ফেরত দেওয়া কাজের। একই ফাইলে তিন route-এর সত্যিকারের ফল, curl থেকে:

$ curl -F photo=@big50.bin .../multer    # শুধু multer-এর সীমা
{"error":"LIMIT_FILE_SIZE"}
[413] uploaded=52429014 t=0.059s
server: seenByServer 52429014, tmpFiles 0

$ curl -F photo=@big50.bin .../early     # আগে Content-Length যাচাই
{"error":"TOO_LARGE"}
[413] uploaded=917504 t=0.002s
server: seenByServer 0, tmpFiles 0

$ curl -F photo=@big50.bin .../busboy    # busboy, truncated যাচাই ছাড়া
{"size":5242880,"truncated":true}
[200] uploaded=52429014 t=0.044s
server: tmpFiles 1

দ্বিতীয়টিতে server কিছুই পড়েনি; curl-এর ৯,১৭,৫০৪ byte শুধু সংযোগের buffer পর্যন্ত গিয়েছিল। Connection: close বসিয়ে সংযোগ বন্ধ করা হয়েছিল। ধীর নেটওয়ার্কে এই পার্থক্য সেকেন্ড নয়, মিনিটের। Localhost-এ তা চোখে পড়ে না, তবে seenByServer সত্যিটা বলে।

তৃতীয়টি সবচেয়ে বিপজ্জনক। সরাসরি busboy-তে সীমা পেরোলে error আসে না। Stream চুপচাপ থামে, উত্তর যায় 200, আর disk-এ থাকে কাটা ফাইল। file.truncated নিজে যাচাই করতে হয়। আর Content-Length client-এর দাবি মাত্র; সেটি মিথ্যা বা অনুপস্থিত হতে পারে (chunked body). তাই আগাম যাচাই হলো সুবিধা, আর fileSize হলো আসল বেড়া। দুটোই রাখুন।

সবচেয়ে সস্তা বেড়া আরও সামনে: পর্ব ০২-এর reverse proxy. যেমন nginx-এর client_max_body_size অ্যাপে পৌঁছানোর আগেই বড় অনুরোধ ফেরায়। Proxy-র সীমা অ্যাপের চেয়ে একটু বেশি রাখুন, যাতে সুন্দর JSON error অ্যাপ থেকেই যায়। এই ধরনের কনফিগ পর্ব ৮২-এ।

৬ · ধরন যাচাই — client মিথ্যা বলতে পারে

req.file.mimetype আর ফাইলের extension, দুটোই client-এর লেখা। যে কেউ photo.png নামে যেকোনো কিছু পাঠাতে পারে। তাই একটি fileFilter-এ শুধু mimetype দেখা যাচাই নয়, কেবল ভদ্র client-কে দ্রুত ফেরত পাঠানোর উপায়। আসল উত্তর থাকে ফাইলের ভেতরে। বেশিরভাগ বাইনারি ফরম্যাট শুরুতেই নিজের পরিচয়-byte লেখে, যাকে বলে magic bytes.

পার্সেলের গায়ে লেখা “শুকনা মাছ”. ডাকঘরের কর্মী লেবেল বিশ্বাস না করে মুখটি খুলে গন্ধ নেন। Extension হলো লেবেল, আর magic bytes হলো গন্ধ।

// ফাইলের প্রথম কয়েকটি byte দেখে ধরন চেনা
const SIGNATURES = [
  { mime: 'image/png', hex: '89504e470d0a1a0a' },
  { mime: 'image/jpeg', hex: 'ffd8ff' },
  { mime: 'image/gif', hex: '47494638' },
  { mime: 'application/pdf', hex: '25504446' },
];

function sniff(buf) {
  const head = buf.subarray(0, 12).toString('hex');
  if (head.startsWith('52494646') && head.slice(16, 24) === '57454250') return 'image/webp';
  return SIGNATURES.find((s) => head.startsWith(s.hex))?.mime ?? 'unknown';
}

const hex = (s) => Buffer.from(s, 'hex');
const uploads = [
  { name: 'ilish.png', claims: 'image/png', data: hex('89504e470d0a1a0a0000000d49484452') },
  { name: 'dal.jpg', claims: 'image/jpeg', data: hex('ffd8ffe000104a4649460001') },
  { name: 'shell.png', claims: 'image/png', data: Buffer.from('<?php echo 1; ?>') },
  { name: 'page.jpg', claims: 'image/jpeg', data: Buffer.from('<html><script>') },
  { name: 'cv.pdf.jpg', claims: 'image/jpeg', data: Buffer.from('%PDF-1.7 ...') },
  { name: 'photo.txt', claims: 'text/plain', data: hex('52494646aabb000057454250') },
];

const ALLOWED = new Set(['image/png', 'image/jpeg', 'image/webp']);
for (const u of uploads) {
  const real = sniff(u.data);
  const ok = ALLOWED.has(real);
  console.log(`${u.name.padEnd(11)} claims ${u.claims.padEnd(11)} real ${real.padEnd(16)} ${ok ? 'ACCEPT' : 'REJECT'}`);
}

আউটপুটের ছয় লাইনে: ilish.png আর dal.jpg ACCEPT. shell.png-এর real unknown, page.jpg-এর real unknown, cv.pdf.jpg-এর real application/pdf, তিনটিই REJECT. আর photo.txt নিজেকে text/plain বললেও byte বলছে image/webp, তাই ACCEPT. নাম আর দাবি কোনো ফলই বদলায়নি।

এই কয়েক লাইনের হাতের কোড শেখার জন্য। Production-এ file-type-এর মতো package ভালো, কারণ সে আরও গভীরে দেখে। আমাদের Express server-এ সত্যিকারের run:

$ curl -H 'x-demo-role: admin' .../image -F 'photo=@liar.png;type=image/png'
{"error":"NOT_AN_IMAGE","detected":"unknown"}                  # [415] PHP লেখা, .png নামে
$ ... -F 'photo=@liar.svg;type=image/svg+xml'
{"error":"NOT_AN_IMAGE","detected":"unknown"}                  # [415] script-সহ SVG
$ ... -F 'photo=@sig-only.png;type=image/png'
{"error":"NOT_AN_IMAGE","detected":"unknown"}                  # [415] শুধু ৮ byte PNG সই
$ ... -F 'photo=@real-png-named.txt;type=text/plain'
{"productId":"17","clientName":"real-png-named.txt",
 "clientMime":"text/plain","detected":"image/png", ...}        # [201]
$ ... -F 'photo=@polyglot.png;type=image/png'
{"productId":"17",...,"detected":"image/png","receivedBytes":106,...}   # [201]

শেষ লাইনটি জরুরি। polyglot.png হলো একটি বৈধ PNG, যার শেষে <?php … ?> লেখা জোড়া। এমন দ্বিমুখী ফাইলকে বলে polyglot file. Magic bytes ঠিক, তাই পাস। Magic bytes শুধু শুরুটা দেখে, ফাইলের বাকি অংশ নয়। তাই পরের ধাপে আমরা আসল ফাইল রাখি না, sharp দিয়ে নতুন ছবি বানাই। আমাদের run-এ ইনপুটে <?php ছিল (true), আউটপুট .webp-তে নেই (false).

SVG কেন তালিকায় নেই: SVG হলো XML লেখা, যার ভেতরে <script> থাকতে পারে। একই origin থেকে সরাসরি খুললে সেটি XSS-এর পথ হতে পারে। দোকানের পণ্যের ছবিতে SVG দরকার নেই, তাই সরল সমাধান: না নেওয়া। Allowlist লিখুন (“শুধু এই তিনটি”), blocklist নয়। আরও দেখুন Cybersecurity কোর্সের file handling পর্ব.

৭ · নিরাপদ নাম, web root-এর বাইরে, কখনো execute নয়

ফাইলের নাম-ও client-এর লেখা। হাতে multipart বানিয়ে multer-কে কয়েকটি নাম পাঠালাম। সত্যিকারের run, multer 2.5.0:

option            client sent            originalname
default           "পণ্য.png"             "পণà§à¦¯.png"
defParamCharset   "পণ্য.png"             "পণ্য.png"
default           "../../evil.php"       "evil.php"
default           "..\\..\\evil.php"     "evil.php"
preservePath      "../../evil.php"       "../../evil.php"
default           "photo.php.jpg"        "photo.php.jpg"

তিনটি শিক্ষা। প্রথমত, multer-এর default latin1, তাই বাংলা নাম ভেঙে যায়; defParamCharset: 'utf8' লাগে। দ্বিতীয়ত, default অবস্থায় ../ কেটে ফেলা হয়, কিন্তু preservePath চালু করলে পুরো পথ ফেরে। এই path traversal ঠেকানোর একমাত্র পরিষ্কার উপায়: client-এর নাম দিয়ে disk-এর পথ কখনো না বানানো। তৃতীয়ত, photo.php.jpg সব বাধা পার হয়ে যায়।

তাই disk-এর নাম আমরা নিজে দিই: randomUUID() আর আমাদের ঠিক করা extension, যেমন 942c9ccd-…-800.webp. Client-এর নামটি দরকার হলে database-এর একটি কলামে থাকে, শুধু দেখানোর জন্য, আর UI-তে দেখানোর সময় escape হয়। Random নামের আরেকটি লাভ: একই নামে পাঠালে আগের ফাইল ঢেকে যায় না, আর ক্রমিক নম্বরের মতো পরের ফাইল অনুমান করা যায় না।

ফোল্ডারকী থাকেসরাসরি URL-এ পাওয়া যায়?
public/আপনার build করা CSS, JS, লোগোহ্যাঁ, express.static দিয়ে
storage/tmp/যাচাই না হওয়া আসল uploadকখনো না
storage/products/sharp-এর বানানো ছবিনা, শুধু /media/:file route দিয়ে

Web root হলো সেই ফোল্ডার, যার ভেতরের ফাইল server সরাসরি URL-এ দেয়। Upload সেখানে রাখলে যাচাই ছাড়াই ফাইলটি পৃথিবীর সামনে খোলা, তাই storage/ ফোল্ডার public/-এর বাইরে।

কখনো execute নয়, বিশেষ করে PHP shared hosting-এ। Node নিজে থেকে কোনো ফাইল চালায় না, কিন্তু একই সাইটে Apache আর PHP থাকলে ঘটনা আলাদা। public_html/uploads/-এ shell.php পৌঁছে গেলে, আর কেউ URL-টি খুললে, সার্ভার সেটি ফাইল হিসেবে পাঠায় না, কোড হিসেবে চালায়। তখন একটি upload ফর্মই পুরো সাইটের দখল দিয়ে দিতে পারে। কিছু পুরোনো Apache কনফিগে photo.php.jpg-ও PHP হিসেবে চলতে পারে। প্রতিরোধ: Upload public_html-এর বাইরে রাখুন। ভেতরে রাখতেই হলে, সেই ফোল্ডারে script চালানো বন্ধ করুন। Hosting-ভেদে .htaccess-এর নিয়ম আলাদা, তাই নিজের সার্ভারে পরীক্ষা করে দেখুন। ফোল্ডারের অনুমতিতে execute বন্ধ রাখুন, যা Linux permission-এর পর্বে আছে।

৮ · sharp দিয়ে ছবি ছোট করা, আর ফাইল ফেরত দেওয়া

ফোনের ছবি বিশাল, অথচ পণ্যের তালিকায় লাগে ২০০ pixel. sharp হলো libvips-এর ওপর বানানো Node package, যা দ্রুত ছবি কমায়। img.clone() দিয়ে একই ইনপুট থেকে দুটি আকার:

const img = sharp(tmp, { limitInputPixels: 24_000_000 }).autoOrient();
const big = await img.clone()
  .resize({ width: 800, withoutEnlargement: true })
  .webp({ quality: 80 })
  .toFile(path.join(PRODUCTS, `${id}-800.webp`));
const thumb = await img.clone()
  .resize({ width: 200, height: 200, fit: 'cover' })
  .webp({ quality: 75 })
  .toFile(path.join(PRODUCTS, `${id}-200.webp`));
# সত্যিকারের run, sharp 0.35.5, libvips 8.18.7
input jpeg 4000x3000 1405910 bytes; exif 344
800 webp q80 800x600 157696
800 jpeg q80 800x600 144297
800 avif q50 800x600  80497
200 webp q75 200x200  16698
exif after null
keepMetadata exif 344 158066
bomb 12000 12000 144000000 pixels; decoded ~ 432000000 bytes
limit 24M: Input image exceeds pixel limit
default limit: passed, output 1200

১৪,০৫,৯১০ byte থেকে ১,৫৭,৬৯৬ byte, প্রায় ৯ গুণ ছোট। ছবিটি কৃত্রিম noise থেকে বানানো, তাই ফরম্যাটের তুলনা এটি থেকে টানবেন না; আসল ছবিতে ফল আলাদা হবে। আরও দুটি সংখ্যা দেখুন।

  • EXIF: ফোনের ছবিতে থাকতে পারে GPS অবস্থান। আমাদের ইনপুটে একটি কাল্পনিক GPS-সহ ৩৪৪ byte EXIF ছিল। আউটপুটে null, কারণ sharp নিজে না বললে metadata রাখে না। দোকানির বাড়ির ঠিকানা ফাঁস হওয়া থেকে এটি বাঁচায়। .keepMetadata() দিলে তা ফিরে আসে (৩৪৪ byte), তাই সেটি ভেবে ব্যবহার করুন। .autoOrient() রাখা হয়েছে, কারণ EXIF মুছে গেলে ফোনের ঘোরানো ছবি উল্টো দেখাতে পারে।
  • Decompression bomb: huge-pixels.png মাত্র ৪,৪৬,৪১০ byte, সাদা রঙের ১২০০০×১২০০০ ছবি। ৫ MiB-র সীমায় পাস, magic bytes-এও পাস। কিন্তু ১২০০০ × ১২০০০ × ৩ = ৪৩,২০,০০,০০০ byte, প্রায় ৪১২ MiB হলো খোলা অবস্থার আকার। Sharp-এর default সীমা ২৬,৮৪,০২,৬৮৯ pixel, তাই এটি default-এ পাস করে গেছে (default limit: passed). আমাদের ২৪ megapixel সীমায় আটকেছে, আর Express দিয়েছে 422 TOO_MANY_PIXELS.

ধারণক্ষমতার হিসাব

ধরুন (অনুমান) দিনে ২,০০০ ছবি আসে। প্রতিটিতে আমাদের দুই আকার মিলে ১,৫৭,৬৯৬ + ১৬,৬৯৮ = ১,৭৪,৩৯৪ byte. দিনে ৩৪,৮৭,৮৮,০০০ byte, প্রায় ৩৩৩ MiB; ৩০ দিনে প্রায় ৯.৭৫ GiB. আসল ফাইলও রাখলে দিনে ২,৮১,১৮,২০,০০০ byte, ৩০ দিনে প্রায় ৭৮.৬ GiB. আট গুণ জায়গার সিদ্ধান্ত এটি। আসল ছবি রাখলে পরে নতুন আকার বানানো যায়; না রাখলে সুরক্ষা আর খরচ সহজ।

ফাইল ফেরত দেওয়া — সঠিক header

রাখা যতটা জরুরি, ফেরত দেওয়াও ততটাই। Content-Type ঠিক না থাকলে browser অনুমান করতে পারে; একে বলে content sniffing. আর ডাউনলোড হবে না পাতায় খুলবে, তা ঠিক করে Content-Disposition.

const SAFE_NAME = /^[0-9a-f-]{36}-(800|200)\.webp$/;

app.get('/media/:file', (req, res) => {
  const name = req.params.file;
  if (!SAFE_NAME.test(name)) return res.sendStatus(404);
  res.set('X-Content-Type-Options', 'nosniff');
  res.sendFile(name, { root: PRODUCTS, maxAge: '365d', immutable: true });
});

app.get('/invoices/:id/download', (req, res) => {
  // এখানে মালিকানা যাচাই বসবে (পর্ব ৪৮)
  res.set('X-Content-Type-Options', 'nosniff');
  res.download(invoicePath, 'চালান-১২৩.pdf');
});
$ curl -I .../media/942c9ccd-...-800.webp
HTTP/1.1 200 OK
X-Content-Type-Options: nosniff
Accept-Ranges: bytes
Cache-Control: public, max-age=31536000, immutable
Content-Type: image/webp
Content-Length: 157696

$ curl -I .../invoices/123/download
HTTP/1.1 200 OK
X-Content-Type-Options: nosniff
Content-Disposition: attachment; filename="?????-???.pdf"; filename*=UTF-8''%E0%A6%9A%E0%A6%BE%E0%A6%B2%E0%A6%BE%E0%A6%A8-%E0%A7%A7%E0%A7%A8%E0%A7%A9.pdf
Content-Type: application/pdf

$ curl -o /dev/null -w '%{http_code}' --path-as-is .../media/..%2f..%2fapp.mjs
404

res.download নিজেই দুটি নাম দিয়েছে: পুরোনো ASCII filename আর UTF-8 filename*. attachment মানে পাতায় খোলা নয়, ডাউনলোড; ব্যবহারকারীর পাঠানো অচেনা ধরনের ফাইল সবসময় এভাবে দিন। immutable নিরাপদ, কারণ random নাম কখনো অন্য ছবিকে বোঝায় না; ছবি বদলালে নামও বদলায়। nosniff browser-কে অনুমান থেকে বিরত রাখে। Regex-টি allowlist: নামের গঠনই না মিললে disk পর্যন্ত যাওয়া হয় না।

৯ · মালিকহীন ফাইল আর object storage

ফাইল আর database দুটি আলাদা জায়গা। একসাথে কখনো পাল্টায় না, তাই মাঝখানে কিছু ভাঙলে মালিকহীন ফাইল জন্মায়। আমাদের server-এ তিনটি পথ পরীক্ষা করলাম:

# ৪ MB ঘোষণা করে ১ MiB পাঠিয়ে client সংযোগ কেটে দিল
tmp after client abort: 0          # multer নিজে মুছেছে
# আধা পথে server process-টি হঠাৎ বন্ধ করা হলো
tmp after crash: 7b30c006-b7bf-425a-8642-8d1148390f5f count=1

Process মরে গেলে finally বা multer কারো চলার সুযোগ নেই, তাই ফাইলটি থেকে যায়। তৃতীয় পথ: ছবি লেখা শেষ, কিন্তু database-এ সারি ঢোকার আগেই কিছু ভাঙল। পণ্য মুছলেও ছবি থেকে যেতে পারে। সমাধান একটি পাহারাদার কাজ, যা নিয়মিত চলবে (পর্ব ৬২-এর background job). নিচের প্রথম run-এ কিছু মোছে না, কেবল দেখায়:

const known = await knownFiles();          // database-এ যে নামগুলো আছে
for (const name of await readdir(DIR)) {
  const full = path.join(DIR, name);
  const s = await stat(full);
  if (known.has(name)) { kept++; continue; }
  if (now - s.mtimeMs < GRACE_MS) { young++; continue; }   // 24 ঘণ্টা
  orphan++; freed += s.size;
  console.log(del ? 'delete' : 'would delete', name, s.size);
  if (del) await rm(full);
}
$ node sweep.mjs
would delete bbb-200.webp 16802
would delete bbb-800.webp 158210
{ kept: 2, young: 1, orphan: 2, freed: 175012 }
$ node sweep.mjs --delete
delete bbb-200.webp 16802
delete bbb-800.webp 158210
{ kept: 2, young: 1, orphan: 2, freed: 175012 }

young ফাইলটি মালিকহীন, কিন্তু বয়স ২৪ ঘণ্টার কম, তাই বেঁচে গেছে। এই ছাড়ের সময় না থাকলে পাহারাদার চলমান upload-এর ফাইলও মুছে ফেলত, কারণ তার database সারি এখনো তৈরি হয়নি। ক্রমের নিয়ম: আগে ফাইল, পরে database সারি। মোছার সময় উল্টো: আগে সারি, পরে ফাইল। ভাঙলে তখন বাড়তি ফাইল থাকে, ভাঙা লিংক নয়, আর বাড়তি ফাইল পাহারাদার ধরে ফেলবে।

Object storage আর presigned URL

একটি server-এর disk-এ ফাইল রাখলে দুটি সমস্যা। Server দুটি হলে একটির ফাইল অন্যটি পায় না। আর সব byte অ্যাপের মধ্য দিয়ে গেলে বড় ফাইলে সংযোগ আটকে থাকে। বড় অ্যাপ তাই ফাইল রাখে object storage-এ, S3-ধাঁচের API-তে, যা অনেক সেবা ও নিজে চালানোর সফটওয়্যারে পাওয়া যায়। আর ব্রাউজারকে দেয় একটি presigned URL: সই করা, সময়-বাঁধা, একটি নির্দিষ্ট কাজের টিকিট। সেবাগুলোর তুলনা এখানে নয়; পুরো নকশা পর্ব ৬৪-এ, চালানোর দিক পর্ব ৮২ আর Cloud & DevOps কোর্সে, ভেতরের নকশা System Design কোর্সে.

মেলার গেটে সরকারি অফিস একটি সিলমারা স্লিপ দিল: “এই একটি স্টলে, ৫ মিনিটের মধ্যে, একটি মাল রাখা যাবে।” গেটের প্রহরী অফিসে ফোন করে না, শুধু সিল আর সময় দেখে। আপনাকে অফিসের চাবি দিতে হলো না, আর আপনার মাল অফিসের ভেতর দিয়েও গেল না।

সই বানাতে নেটওয়ার্ক লাগে না, শুধু গোপন চাবি। সম্পূর্ণ নকল key আর কাল্পনিক ঠিকানায় @aws-sdk/client-s3 3.1161.0 চালিয়ে এই URL পেলাম (কোথাও পাঠানো হয়নি, সত্যিকারের কোনো সেবা জড়িত নয়):

https://storage.example.test/bazarghar-media/incoming/3f9c1b7e.jpg?
  X-Amz-Algorithm=AWS4-HMAC-SHA256
  &X-Amz-Content-Sha256=UNSIGNED-PAYLOAD
  &X-Amz-Credential=EXAMPLEKEY%2F20261101%2Fap-south-1%2Fs3%2Faws4_request
  &X-Amz-Date=20261101T163742Z
  &X-Amz-Expires=300
  &X-Amz-Signature=0f75a0ba791cae8b0cbf7509d280ea549a15171aea2d858ee68106d8e4609376
  &X-Amz-SignedHeaders=content-type%3Bhost
  &x-id=PutObject

X-Amz-Expires=300 মানে ৫ মিনিট। একটি ফাঁদ ধরা পড়ল: শুধু ContentType দিলে প্রথম run-এ SignedHeaders=host এসেছিল, অর্থাৎ ধরনটি সইয়ে ঢোকেনি। signableHeaders: new Set(['content-type']) দেওয়ার পরই ওপরের ফল এলো। আর UNSIGNED-PAYLOAD মনে করিয়ে দেয়, সই ফাইলের ভেতরের byte যাচাই করে না।

  1. Browserছবি বাছাই
  2. APIসই করে
  3. Object storeincoming/
  4. Workerযাচাই
  1. ধাপ ১ — অনুমতি চাওয়া। Browser ফাইল পাঠায় না। আমাদের API-কে শুধু বলে: “image/jpeg, ১৪,০৫,৯১০ byte, পণ্য ১৭”.
  2. ধাপ ২ — সই। API login, পণ্যের মালিকানা, ধরন আর আকার যাচাই করে। Key নিজে ঠিক করে: incoming/3f9c1b7e.jpg. গোপন চাবি দিয়ে সই করে, ৩০০ সেকেন্ডের URL ফেরত দেয়। চাবি কখনো browser-এ যায় না।
  3. ধাপ ৩ — সরাসরি PUT. Browser ফাইলটি পাঠায় object store-এ। আমাদের Express server-এ এই ১৪ লাখ byte-এর এক byte-ও আসে না, memory বা সংযোগও আটকায় না।
  4. ধাপ ৪ — গুদামের পরীক্ষা। Store সই আর সময় মেলায়। ৩০০ সেকেন্ড পেরোলে বা key বদলালে প্রত্যাখ্যান। মিললে ফাইল বসে incoming/-এ, যা ব্যবহারকারীদের জন্য খোলা নয়।
  5. ধাপ ৫ — যাচাই এখনো বাকি। Browser API-কে জানায় “শেষ”. একটি worker ফাইলটি পড়ে, magic bytes দেখে, sharp দিয়ে নতুন ছবি বানায়। অংশ ৬-এর সব নিয়ম এখানেও খাটে, শুধু জায়গা বদলেছে। Client-এর দাবি করা আকারও এখানে মিলিয়ে নেওয়া হয়।
  6. ধাপ ৬ — প্রকাশ। নতুন ছবি যায় আলাদা স্থায়ী জায়গায়, আর database-এ সারি বসে। incoming/-এর পুরোনো ফাইল সেখানকার lifecycle নিয়মে নিজে থেকে মুছে যায়, যা মালিকহীন ফাইল ঠেকায়।

এই stepper ধারণা বোঝানোর জন্য; এখানে আসল object store-এ upload চালানো হয়নি, কেবল ওপরের URL-টি বানানো হয়েছে। ট্রেড-অফ আছে: সরাসরি upload-এ অ্যাপ হালকা হয়, তবে যাচাই দেরিতে হয়, CORS কনফিগ লাগে, আর নকশা জটিল হয়। ছোট দোকানে একটি server-এর disk দিয়েই শুরু করা ঠিক সিদ্ধান্ত হতে পারে, যদি backup থাকে।

আরও গভীরে: multipart/form-data-র বর্তমান সংজ্ঞা RFC 7578, আর পুরোনো MIME ধারণা থেকে এসেছে। Busboy parser ডেটা না জমিয়ে চলতে চলতে boundary খোঁজে, কারণ boundary দুটি chunk-এর মাঝে ভাগ হয়ে আসতে পারে; এজন্যই আমাদের হাতের parser-এর মতো indexOf পুরো body-তে চালানো বড় ফাইলে চলে না। Node-এ HTTP/1.1 body-ও একটি Readable stream, তাই TCP-র flow control পর্যন্ত backpressure পৌঁছায়: disk ধীর হলে client-ও ধীরে পাঠাতে বাধ্য হয়।

পরীক্ষা ও ইন্টারভিউতে:

  • Multipart কেন, Base64-in-JSON নয়? Base64-এ প্রায় ৩৩% বাড়ে আর পুরো লেখা parse করতে হয়; multipart-এ byte অবিকৃত, stream করা যায়।
  • Backpressure কী? ধীর গ্রাহক দ্রুত উৎসকে থামতে বলা; write() false দিলে drain পর্যন্ত অপেক্ষা।
  • Extension বা MIME দেখে কেন যাচাই নয়? দুটোই client লেখে; byte দেখুন, আর সম্ভব হলে নতুন করে encode করুন।
  • Upload কোথায় রাখবেন? Web root-এর বাইরে, random নামে, execute বন্ধ রেখে, অথবা আলাদা object storage-এ।
  • Presigned URL-এ server কী হারায়? আগাম byte যাচাই; তাই পরে worker দিয়ে যাচাই করে তবে প্রকাশ করতে হয়।

১০ · AI-এর যুগে file upload

Upload কোড AI খুব দ্রুত লেখে, আর সেই কোড সহজ পরীক্ষায় চলেও। সমস্যা হলো, ছবি পাঠালে চলা মানেই অন্য কিছু পাঠালে আটকানো নয়। আর ফাঁকগুলো সবই ভুল ইনপুটেই ধরা পড়ে।

AI প্রায়ই যা লেখেসমস্যাযাচাইয়ের উপায়
multer@1.4.x বা কোনো পুরোনো সংস্করণnpm-এ deprecated, জানা দুর্বলতাnpm view multer version, npm audit
dest: 'public/uploads'Web root-এর ভেতরে, যাচাই ছাড়া খোলাUpload করে সরাসরি URL খুলুন
limits নেইDefault Infinity৫০ MiB পাঠিয়ে দেখুন
file.originalname দিয়ে disk-এর নামOverwrite, অদ্ভুত নাম, বাংলা ভাঙাএকই নামে দুবার, বাংলা নামে একবার
file.mimetype.startsWith('image/')Client-এর দাবি বিশ্বাস;type=image/png দিয়ে লেখা ফাইল পাঠান
Content-Type হাতে বসানোBoundary হারায়Network tab-এ শিরোনাম পড়ুন
Login-এর আগে multerঅচেনা কেউ disk ভরতে পারেLogin ছাড়া পাঠিয়ে tmp গুনুন
Key-সহ presign কোড browser-এগোপন চাবি ফাঁসBuild করা JS-এ key খুঁজুন

AI-কে “upload বানিয়ে দাও” না বলে কঠিন শর্ত আর ব্যর্থ হওয়ার পরীক্ষা চান:

আমি Express 5 আর Node 24-এ একটি পণ্যের ছবি upload route বানাচ্ছি। প্রথমে npm-এ multer-এর বর্তমান stable version কত, তা আমাকে যাচাই করতে বলো; নিজে অনুমান করো না। শর্ত: ১. disk storage, ফোল্ডার public-এর বাইরে, নাম randomUUID. ২. fileSize 5 MiB, files 1, fields 5, parts 6. ৩. login middleware multer-এর আগে. ৪. ধরন যাচাই byte দেখে, অনুমোদিত: jpeg, png, webp. Client-এর mimetype ব্যবহার করবে না. ৫. sharp দিয়ে নতুন webp, limitInputPixels 24 million, finally-তে tmp মোছা. ৬. MulterError-এর জন্য সঠিক status. নতুন package যোগ করলে কেন লাগবে বলো। শেষে curl কমান্ড দাও: বড় ফাইল, .png নামে লেখা ফাইল, ভুল field name, login ছাড়া — প্রতিটিতে কোন status আশা করব।

AI-এর লেখা upload কোডের চেকলিস্ট

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন, তারপর উত্তর খুলুন।

প্র ০১ওয়েবে ফাইল পাঠানোর জন্য আলাদা একটি ফরম্যাট কেন বানাতে হলো? আজকের JSON API-তে Base64 দিয়ে কাজ চালালে ক্ষতি কী?

শুরুর দিকের HTML form শুধু লেখা পাঠাত, application/x-www-form-urlencoded আকারে। ১৯৯৫ সালে RFC 1867 (Nebel ও Masinter) form-ভিত্তিক file upload প্রস্তাব করে। নতুন কিছু উদ্ভাবন না করে এটি ধার নেয় ইমেইলের MIME থেকে, যেখানে একটি বার্তায় লেখা আর সংযুক্তি boundary দিয়ে আলাদা থাকত। পরে আসে RFC 2388 (১৯৯৮), আর বর্তমান সংজ্ঞা RFC 7578 (২০১৫). চিঠির সংযুক্তির ধারণা থেকেই এসেছে আজকের প্রায় সব ছবি আপলোড।

Urlencoded-এ প্রতিটি অ-ASCII byte হয় তিন অক্ষর (%E0), তাই বাইনারি ফাইল প্রায় তিন গুণ পর্যন্ত ফুলে যেতে পারে। Base64 ভালো, কিন্তু তবু প্রতি ৩ byte-এ ৪ অক্ষর। আমাদের ১৪,০৫,৯১০ byte-এর ছবি হতো ১৮,৭৪,৫৪৮ byte, যেখানে multipart যোগ করেছে মাত্র ২৯৮ byte.

আকারের চেয়েও বড় সমস্যা হলো মডেল। JSON parser সাধারণত পুরো লেখা memory-তে নিয়ে তবে object বানায়, তাই পুরো ফাইল এক টুকরো string হিসেবে বসে, আর stream করার সুবিধা চলে যায়। express.json()-এর default body সীমাও (লেখার সময়ে 100 KB) এই কারণেই ছোট।

তবু Base64 সব জায়গায় ভুল নয়। খুব ছোট আইকন, বা যে API শুধু JSON নিতে পারে, সেখানে সরলতার দাম কিছু byte-এর চেয়ে বেশি। ছোট ডেটায় পার্থক্য নগণ্য। আবার gzip চালু থাকলেও ছবির Base64 খুব একটা কমে না, কারণ ছবি আগেই সংকুচিত। আর log-এ ভুলে পুরো body ছাপা হলে বিশাল লেখা জমে, যা ব্যস্ত সার্ভারে আরেক ঝামেলা।

মূল উপলব্ধি: পুরোনো ফরম্যাট টিকে আছে কারণ সে একটি আসল সমস্যা সমাধান করে: লেখা আর byte মিশিয়ে, না বদলে, ধারাবাহিকভাবে পাঠানো। একটি ফরম্যাট বাছাই মানে শুধু আকার নয়, ডেটা কীভাবে পড়া হবে সেটিও বেছে নেওয়া।

প্র ০২সীমা পার হলেও multer সংযোগ না কেটে বাকি body পড়ে কেন? এটি কি ভুল নকশা, নাকি ইচ্ছাকৃত?

অনেকে ধরে নেন, সীমা ভাঙলেই পড়া বন্ধ। আমাদের মাপ দেখিয়েছে উল্টো: ৫ MiB-র সীমায় ৫০ MiB পুরোটা পড়া হয়েছে। Multer-এর সোর্সের মন্তব্য কারণটি বলে: error-এর উত্তর দেওয়ার আগে body drain করা হয়, যাতে client লিখতে থাকা অবস্থায় server সংযোগ বন্ধ করলে client-এ EPIPE ধরনের ভাঙন না হয়।

বিষয়টি HTTP/1.1-এর একটি আসল সমস্যা। Client body পাঠাচ্ছে, মাঝখানে server উত্তর দিয়ে socket বন্ধ করল। Client তখনও লিখতে থাকে, আর TCP reset এলে অনেক client উত্তরটি পড়ার আগেই “connection reset” দেখায়। ফলে “ফাইল খুব বড়” এই সুন্দর বার্তাটি ব্যবহারকারী পান না, পান ঝাপসা network error. HTTP/1.1-এ Expect: 100-continue এই সমস্যা কমানোর জন্যই: Client আগে শিরোনাম পাঠিয়ে অনুমতি চায়। সব client এটি ব্যবহার করে না, আর browser-এর আচরণের ওপর ভরসা করা যায় না।

ট্রেড-অফ তাহলে এমন: Drain করলে পরিষ্কার error, কিন্তু আক্রমণ হলে bandwidth খরচ। কেটে দিলে সম্পদ বাঁচে, কিন্তু error ঝাপসা হতে পারে। সমাধান স্তরে স্তরে। Reverse proxy-তে বড় সীমা, যা আপত্তিকর অনুরোধ অ্যাপে আসতেই দেয় না। Content-Length দেখে আগাম উত্তর। Node-এর timeout আর সংযোগের সংখ্যার সীমা। আর login, যাতে অচেনা কেউ শুরুই করতে না পারে। আমাদের run-এ আগাম উত্তরটি ঠিকমতো 413 দিয়েছে, তবে সব client আর নেটওয়ার্কে তা একই রকম না-ও হতে পারে; নিজের পথে পরীক্ষা করে দেখা দরকার। Browser-এর আচরণ আলাদা হতে পারে, কারণ body পাঠানো শেষ না করে সে হয়তো উত্তর দেখাবে না। তাই client দিকেও file.size দেখে আগেই বার্তা দেখান; এটি নিরাপত্তা নয়, ভালো অভিজ্ঞতা।

মূল উপলব্ধি: এটি ভুল নয়, একটি সিদ্ধান্ত। Library-র default-কে অনুমান না করে মাপুন; seenByServer-এর মতো একটি সংখ্যা অনেক ভুল ধারণা ভাঙে।

প্র ০৩Browser ফাইলের ধরন “অনুমান” করতে শিখল কেন, আর nosniff কেন লাগল?

নিয়ম অনুযায়ী Content-Type-ই ফাইলের ধরন বলে। কিন্তু শুরুর দিকের অনেক সার্ভার ভুল ধরন পাঠাত, যেমন সব অচেনা ফাইলকে text/plain. পাতা ভেঙে দেখালে ব্যবহারকারী দোষ দিতেন browser-কে। তাই browser-গুলো ভেতরের byte দেখে ধরন অনুমান করতে শুরু করে। একে বলে content sniffing. ব্যবহারকারীর সুবিধার জন্য নেওয়া সিদ্ধান্তটি পরে নিরাপত্তার সমস্যা হয়ে দাঁড়ায়।

একটি ছবি-আপলোড সাইটের কথা ভাবুন। কেউ এমন ফাইল দিল, যার ভেতরের লেখা দেখতে HTML-এর মতো, অথচ নাম .jpg. Browser অনুমানে সেটিকে HTML ধরে চালালে, script সেই সাইটের origin-এই চলে। এভাবে আপলোড করা “ছবি” XSS-এর পথ হয়ে যায়। ব্যবহারকারীর দেওয়া ফাইল সেই কারণেই সংবেদনশীল।

Microsoft তাদের Internet Explorer 8-এ X-Content-Type-Options: nosniff শিরোনাম আনে (২০০৮ সালের দিকে), আর পরে অন্য browser-ও এটি গ্রহণ করে। WHATWG আজ sniffing-এর আচরণকে একটি মানদণ্ডে লিখে রেখেছে, যাতে অনুমানটি অন্তত সবখানে একই হয়। আজকের browser আগের চেয়ে অনেক সাবধান, তবু সার্ভার থেকে স্পষ্ট ধরন বলা আর nosniff পাঠানো সহজ, খরচহীন একটি অভ্যাস, যা পুরোনো ভুল থেকে পাওয়া শিক্ষা।

আরেকটি বিখ্যাত উদাহরণ হলো GIFAR, ২০০৮ সালের দিকে আলোচিত: একই ফাইল বৈধ GIF আবার বৈধ Java archive. এজন্যই বড় সাইটগুলো ব্যবহারকারীর ফাইল একটি আলাদা ডোমেইন থেকে দেয়, যাতে ফাইলে কিছু চললেও মূল সাইটের cookie নাগালে না থাকে। আপনার অ্যাপেও সম্ভব হলে এই পৃথক origin কৌশল ভাবুন। nosniff, Content-Disposition: attachment আর আলাদা origin একে অপরের বিকল্প নয়, পরিপূরক।

মূল উপলব্ধি: “সাহায্যকারী” অনুমান যে সিস্টেমেই থাকে, সেখানেই অচেনা ডেটা নির্দেশ হয়ে উঠতে পারে। স্পষ্ট ধরন বলুন, আর অনুমান বন্ধ করুন।

প্র ০৪sharp দিয়ে নতুন করে encode করলেই কি ছবি পুরোপুরি নিরাপদ? ছবি প্রক্রিয়াকরণ নিজেই কি ঝুঁকি?

নতুন encode অনেক কিছু মুছে দেয়। আমাদের polyglot-এর শেষে জোড়া লেখা, EXIF-এর GPS, সবই গেছে, কারণ আউটপুট ফাইলটি pixel থেকে নতুন করে লেখা। কিন্তু একটি নতুন ঝুঁকি যোগ হয়: এখন আপনার server অচেনা byte decode করছে। ছবির decoder জটিল C বা C++ কোড, আর অচেনা ফাইল ঠিক সেই জটিল কোডে যাচ্ছে।

ইতিহাসে এর উদাহরণ আছে। ২০১৬ সালে ImageMagick-এ “ImageTragick” (CVE-2016-3714) ধরা পড়ে, যেখানে বিশেষভাবে তৈরি ছবি প্রক্রিয়াকরণের সময় সার্ভারে command চলার পথ খুলে দিত, আর অনেক সাইট আপলোড করা ছবি ঠিক এভাবেই ImageMagick-এ পাঠাত। ২০২৩ সালে libwebp-এর একটি heap overflow (CVE-2023-4863) সামনে আসে, যা browser থেকে শুরু করে বহু অ্যাপে ছড়ানো library-তে ছিল। Sharp-এর পূর্বনির্মিত binary-তে এই ধরনের library ভেতরে বাঁধা থাকে।

রক্ষার উপায় স্তরে স্তরে। Package নিয়মিত হালনাগাদ করুন, কারণ sharp আপডেট মানে ভেতরের decoder-ও আপডেট। Decoder-এর সংখ্যা কমান: শুধু JPEG, PNG আর WebP নিন, যাতে অদ্ভুত ফরম্যাট decode পর্যন্ত না যায়। limitInputPixels বসান, আর sharp-এর docs-এর পরামর্শমতো অচেনা ইনপুটে failOn-এর default 'warning' রাখুন। ঝুঁকি বেশি হলে ছবির কাজ আলাদা process বা container-এ, কম অনুমতিতে চালান, যাতে ভাঙলেও মূল অ্যাপ ও database-এর চাবি নাগালে না থাকে। পর্ব ৬২-এর background job এই আলাদা করার স্বাভাবিক জায়গা।

আরও একটি খরচ আছে: ছবি প্রক্রিয়াকরণ CPU খায়। অনেকে একসাথে আপলোড করলে event loop বন্ধ না হলেও (libvips নিজের thread-এ কাজ করে) CPU ভরে যেতে পারে।

মূল উপলব্ধি: নিরাপত্তায় “সমাধান” বলে কিছু নেই, শুধু ঝুঁকি বদল। Encode করে আপনি polyglot-এর ঝুঁকি কমান আর decoder-এর ঝুঁকি নেন; তাই সেই ঝুঁকিকেও ছোট রাখার ব্যবস্থা করুন।

প্র ০৫“বাজারঘর”-এ বিক্রেতারা ১ GB ভিডিও তুলতে চান, আর নেটওয়ার্ক প্রায়ই কেটে যায়। এই পর্বের পথ কি চলবে? নকশা কেমন হবে?

না। এই পর্বের নকশা ৫ MiB ছবির জন্য। ধরুন গতি ৫ Mbps. ৫ MiB যেতে লাগে ৮.৪ সেকেন্ড, আর ১ GiB যেতে প্রায় ১,৭১৮ সেকেন্ড, মানে ২৮ মিনিটের বেশি। এত সময়ে লোডশেডিং, মোবাইল টাওয়ার বদল বা Wi-Fi থেকে ডেটায় যাওয়া, যেকোনোটিতে সংযোগ কাটবে। একটি POST ব্যর্থ মানে আবার শূন্য থেকে শুরু। ৯৫%-এ কেটে গেলেও।

সমাধান resumable upload. ফাইল ছোট টুকরোয় ভাগ হয়, প্রতিটি আলাদা অনুরোধে যায়, আর server মনে রাখে কতটুকু পেয়েছে। সংযোগ ফিরলে client জিজ্ঞেস করে “কোথায় ছিলাম?”, আর সেখান থেকে চলে। S3-ধাঁচের storage-এ এর জন্য আছে multipart upload. নাম মিললেও এটি multipart/form-data নয়, আলাদা জিনিস। খোলা protocol হিসেবে আছে tus, আর IETF-এ HTTP-র resumable upload নিয়েও কাজ হয়েছে; সর্বশেষ অবস্থা নিজে দেখে নিন।

নকশাটি হবে এমন: Client API থেকে একটি upload সেশন নেয়। প্রতিটি টুকরোর জন্য presigned URL পায়, সরাসরি object store-এ পাঠায়, প্রতিটির hash মেলায়। ব্যর্থ টুকরো কয়েকবার retry হয়, পর্ব ১৯-এর exponential backoff দিয়ে। শেষে সেশন সম্পূর্ণ হয়। অসম্পূর্ণ টুকরো কিছুদিন পরে lifecycle নিয়মে মুছে যায়, নইলে সেগুলোই বড় মালিকহীন ফাইল। Worker তখন ভিডিও পরীক্ষা ও রূপান্তর করে, যা ছবির চেয়ে বহুগুণ ভারী, আর এজন্য আলাদা server-ও লাগতে পারে।

একটি সমালোচনাও আছে: সবাই কি এটি বানাবে? ছোট দোকানের জন্য নিজে ভিডিও চালানো হয়তো ভুল সিদ্ধান্ত। ভিডিওর জন্য বিশেষায়িত কোনো সেবা নেওয়া, বা ভিডিওর দৈর্ঘ্য ও আকার সীমিত রাখাই যুক্তিসঙ্গত হতে পারে। প্রতিটি পথের দাম, নির্ভরতা আর কাজের চাপ তুলনা করে বাছাই করুন।

মূল উপলব্ধি: একই “upload” শব্দের পেছনে আকারভেদে সম্পূর্ণ ভিন্ন নকশা। প্রথমে সংখ্যা হিসাব করুন: আকার, গতি, ব্যর্থতার সম্ভাবনা। তারপর নকশা।

অনুশীলন

হিসাব, কমান্ড, সমালোচনা আর আউটপুট অনুমান, চার রকম কাজ।

  1. হিসাব: একটি server-এ memoryStorage চলছে, সীমা ৫ MiB. একসাথে ১০০ জন ৫ MiB ফাইল পাঠালেন। (ক) ফাইলের জন্য চূড়ার memory কত হতে পারে? ধরে নিন জোড়া লাগানোর সময় প্রতিটি ফাইল দ্বিগুণ জায়গা নেয়, আর সবাই একই মুহূর্তে। (খ) ৫ Mbps গতিতে একটি ফাইল যেতে কত সেকেন্ড? (গ) ফাইলটি Base64-in-JSON হলে কত byte?

    (ক) ১০০ × ৫ MiB × ২ = ১,০০০ MiB, প্রায় ১ GiB. Disk stream-এ এই সংখ্যাটি অনেক কম, আর ফাইলের আকারের সমানুপাতিক নয়। সবার ঠিক একই মুহূর্তে চূড়া না হলে বাস্তব সংখ্যা কম হবে, যেমন আমাদের ৪ × ৫০ MiB-র run-এ ৪০০-এর বদলে ২৮৬ MiB ছিল।

    (খ) ৫ × ১০,৪৮,৫৭৬ × ৮ = ৪,১৯,৪৩,০৪০ bit. ৪,১৯,৪৩,০৪০ ÷ ৫০,০০,০০০ ≈ ৮.৩৯ সেকেন্ড। তাই ধীর client অনেকক্ষণ সংযোগ ধরে রাখে।

    (গ) ৫২,৪২,৮৮০ ÷ ৩ ওপরের দিকে গোল করলে ১৭,৪৭,৬২৭, × ৪ = ৬৯,৯০,৫০৮ byte, যা fileSize নয়, JSON body সীমায় আটকাবে।

  2. কমান্ড: নিজের কম্পিউটারের একটি PNG আর একটি JPEG-এর প্রথম ৮ byte দেখুন। তারপর একটি .txt-এর নাম বদলে .png করে একই কাজ করুন। শেষে Linux বা macOS-এ file কমান্ড চালিয়ে দেখুন সে কী বলে।

    Node দিয়ে সব OS-এ চলে: node -e "console.log(require('fs').readFileSync(process.argv[1]).subarray(0,8).toString('hex'))" a.png. PNG-এ পাবেন 89504e470d0a1a0a, JPEG-এ শুরু ffd8ff. নাম বদলানো ফাইলের byte বদলায়নি, লেখার byte-ই দেখাবে। file কমান্ডও এই একই ধারণায় কাজ করে, নামে নয়, ভেতরের byte-এ। সেজন্য extension বদলালে তার উত্তর বদলায় না।

  3. নকশা সমালোচনা: নিচের কোডে অন্তত ছয়টি সমস্যা বের করুন: app.use(multer({ dest: 'public/uploads/' }).any()); তারপর app.post('/avatar', (req, res) => { if (req.files[0].mimetype.startsWith('image/')) { fs.renameSync(req.files[0].path, 'public/uploads/' + req.files[0].originalname); res.send('ok'); } });
    • Global .any(): অ্যাপের যেকোনো route-এ ফাইল ঢুকতে পারে, login-এর আগেই।
    • limits নেই: fileSize Infinity, disk ভরে যেতে পারে।
    • Web root-এর ভেতরে: যাচাইয়ের আগেই ফাইল URL-এ খোলা।
    • mimetype বিশ্বাস: Client-এর দাবি; byte দেখা হয়নি, SVG-ও image/ দিয়ে শুরু।
    • originalname দিয়ে পথ: Overwrite, বিপজ্জনক extension; .php নামে থাকলে shared hosting-এ চলতে পারে।
    • ব্যর্থতার পথ নেই: ছবি না হলে উত্তরই যায় না আর ফাইল মোছে না; req.files[0] না থাকলে error. renameSync event loop আটকায়।
  4. আউটপুট অনুমান: একটি Transform সীমা ১০ MiB-তে error দেয়, পরীক্ষা seen > max. উৎস ৪ MiB-র ৫টি টুকরো দেয়, আর Writable প্রতিটি পাওয়া byte গোনে। pipeline শেষে stored কত MiB হবে? error কি হবে?

    আউটপুট (আমরা চালিয়ে দেখেছি): stopped: LIMIT_FILE_SIZE, stored 8 MiB. ১ম টুকরোয় ৪, ২য়তে ৮, দুটিই পাস। ৩য়তে ১২ > ১০, error. ফলে ১০ MiB নয়, ৮ MiB জমে, কারণ পরীক্ষা হয় টুকরো ধরে, আর এই কোড ভাঙা টুকরোটি জমায় না। অসম্পূর্ণ ফাইলটি এরপর মুছতে হবে। Busboy-র মতো parser টুকরো কেটে ঠিক সীমা পর্যন্ত দিতে পারে, তাই সংখ্যা আলাদা হতে পারে।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমএকটি নিরাপদ ছবি upload route-এর ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. Login আর অনুমতি যাচাই
  2. Content-Length দেখে বড় অনুরোধ ফেরানো
  3. Multer দিয়ে আকারের সীমাসহ tmp ফোল্ডারে stream
  4. Magic bytes দেখে ধরন যাচাই
  5. sharp দিয়ে নতুন ছবি বানিয়ে web root-এর বাইরে রাখা
  6. Database-এ সারি লিখে উত্তর পাঠানো আর tmp মোছা

multipart/form-data-তে এক part থেকে আরেক part আলাদা করে কী?

  • Content-Length শিরোনাম
  • Content-Type-এ ঘোষিত boundary
  • প্রতিটি লাইন ভাঙা
  • ফাইলের magic bytes

Browser-এ FormData দিয়ে পাঠানোর সময় কোডে হাতে Content-Type: multipart/form-data বসালে কী হয়?

  • গতি বাড়ে, কারণ browser-কে অনুমান করতে হয় না
  • শুধু ফাইলের নাম হারায়
  • Boundary হারিয়ে যায়, server parse করতে পারে না
  • কোনো পার্থক্য হয় না

Node stream-এ writable.write(chunk) false ফেরালে ঠিক পদক্ষেপ কোনটি?

  • Chunk-টি হারিয়ে গেছে, তাই আবার পাঠানো
  • যেমন চলছিল তেমন লেখা চালিয়ে যাওয়া
  • Stream-টি বন্ধ করে error দেওয়া
  • লেখা থামিয়ে drain event-এর জন্য অপেক্ষা করা

Client Content-Type: image/png সহ photo.png পাঠাল। ফাইলের প্রথম byte 3c 3f 70 68 70, অর্থাৎ <?php. Server কী করা উচিত?

  • Byte না মেলায় প্রত্যাখ্যান করা, যেমন 415 দিয়ে
  • নাম আর MIME ঠিক আছে, তাই গ্রহণ করা
  • নাম বদলে .jpg করে রাখা
  • public ফোল্ডারে রেখে পরে দেখা

Multer-এ limits.fileSize ৫ MiB, আর আগে কোনো যাচাই নেই। Client ৫০ MiB পাঠালে সাধারণত কী ঘটে?

  • ৫ MiB-তেই সংযোগ কেটে যায়, বাকি byte আসেই না
  • অসম্পূর্ণ ফাইল মুছে যায়, বাকি body পড়ে ফেলা হয়, তারপর error handler চলে
  • ৫ MiB-র কাটা ফাইল রেখে 200 দেয়
  • পুরো ৫০ MiB disk-এ রেখে পরে সতর্ক করে

Presigned URL সম্পর্কে কোন কথাটি ঠিক?

  • URL-এর ভেতরে গোপন চাবিটি থাকে
  • Presigned হলে ফাইলের ধরন আর যাচাই করতে হয় না
  • এটি সই করা, সময়-বাঁধা অনুমতি; ফাইল অ্যাপের server দিয়ে না গিয়ে সরাসরি store-এ যায়
  • একবার পেলে URL চিরকাল চলে

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন, অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. Multipart হলো boundary দিয়ে ভাগ করা byte. Content-Type হাতে বসাবেন না, progress-এর জন্য XMLHttpRequest.
  2. ফাইল stream করুন, memory-তে জমাবেন না; pipeline backpressure আর error সামলায়।
  3. সীমা সব স্তরে: proxy, Content-Length, fileSize, files, pixel. আর সরাসরি busboy-তে truncated দেখুন।
  4. নাম, MIME, extension বিশ্বাস নয়: magic bytes দেখুন, নতুন করে encode করুন, random নাম দিন, web root-এর বাইরে রাখুন, execute বন্ধ রাখুন।
  5. ফেরত দিন nosniff আর ঠিক Content-Disposition দিয়ে; মালিকহীন ফাইল পরিষ্কার করুন; বড় হলে object storage আর presigned URL.

পরের পর্বে: Upload এখন চলছে। কিন্তু production-এ কিছু ভাঙলে আপনি জানবেন কীভাবে? পর্ব ৩১-এ structured log, request id, config আর health check.

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান