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

File storage — object storage, presigned URL আর image pipeline

File Storage — Object Storage, Presigned Uploads, CORS and an Image Pipeline

Taught in Bangla, this lesson shows why local disk fails with two servers or a redeploy, the object storage vocabulary of buckets, keys and metadata, how a presigned URL is signed by building one by hand, direct browser uploads with presigned POST or PUT and CORS, confirming uploads with PostgreSQL records, an image pipeline in a background job with sharp and srcset, private files versus public assets, lifecycle rules, cost and lock-in.

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

  • একাধিক server-এ local disk কেন ভাঙে, আর object storage-এর bucket, key আর metadata
  • নিজের মেশিনে একটি S3-সামঞ্জস্যপূর্ণ server চালিয়ে AWS SDK v3 দিয়ে কাজ, আর হাতে SigV4 সই
  • Edge browser থেকে presigned POST আর PUT, bucket-এর CORS, আর server-এ নিশ্চিত করে PostgreSQL-এ লেখা
  • Background job-এ ছবির variant, srcset আর next/image-এর loader
  • Private ফাইলের স্বল্পমেয়াদি URL, public ছবির CDN cache, মোছা, lifecycle, খরচ আর lock-in

১ · Local disk কেন যথেষ্ট নয় — দুটি server আর একটি deploy

পর্ব ৩০-এ “বাজারঘর” পণ্যের ছবি নিত multer দিয়ে, magic bytes যাচাই করত, sharp দিয়ে নতুন ছবি বানিয়ে রাখত server-এর storage/products/ ফোল্ডারে। একটি server থাকা পর্যন্ত এটি ভালো চলে। এবার দোকান বড় হলো। ঈদের আগে ভিড় সামলাতে আপনি দ্বিতীয় একটি server চালু করলেন, সামনে বসালেন একটি load balancer, যা অনুরোধ পালা করে দুই server-এ পাঠায়।

রহিমা আপা ছবি তুললেন। অনুরোধটি গেল server A-তে, ছবি বসল A-এর disk-এ। এক মুহূর্ত পরে ক্রেতার browser ছবিটি চাইল, আর load balancer অনুরোধটি পাঠাল server B-তে। B-এর disk-এ এই ছবি নেই, তাই উত্তর 404. একই পাতা আবার খুললে হয়তো A-তে যাবে, ছবি দেখা যাবে। এমন “কখনো আছে, কখনো নেই” বাগ খুঁজে পাওয়া সবচেয়ে কঠিনগুলোর একটি।

পাড়ার দুটি শাখার মুদি দোকান ভাবুন। দুই শাখায় আলাদা হিসাবের খাতা, আর প্রতিটি খাতা শুধু সেই শাখার তাকে থাকে। এক শাখায় জমা রাখা বাকির কাগজ অন্য শাখায় খুঁজলে পাওয়া যাবে না। সমাধান হলো সব কাগজ একটি কেন্দ্রীয় গুদামে রাখা, যেখান থেকে দুই শাখাই আনতে পারে। Object storage সেই কেন্দ্রীয় গুদাম।

দ্বিতীয় সমস্যা deploy. আধুনিক deploy প্রায়ই পুরোনো server বা container ফেলে দিয়ে নতুন একটি চালু করে। Container-এর ভেতরের লেখা ফাইল সেই container-এর সাথেই মুছে যায়, যদি না আলাদা volume থাকে। অনেক PaaS আর serverless পরিবেশে disk সাময়িক, বা আদৌ লেখা যায় না। তৃতীয় সমস্যা backup আর আকার: একটি disk ভরে গেলে বড় disk কিনে ফাইল সরাতে হয়, আর সেই disk নষ্ট হলে সব ছবি একসাথে যায়।

প্রশ্নServer-এর diskObject storage
দুটি server একই ফাইল দেখে?না, NFS-এর মতো ভাগ করা disk না বসালেহ্যাঁ, HTTP API দিয়ে সবাই একই bucket দেখে
নতুন deploy-এর পরে ফাইল থাকে?Volume না থাকলে নাহ্যাঁ, app থেকে আলাদা জায়গা
বড় ফাইল app-কে আটকায়?হ্যাঁ, সব byte app দিয়ে যায়Presigned URL-এ না
CDN-এর পেছনে বসানোনিজে কনফিগ করতে হয়সাধারণ ব্যবস্থা
ফাইলের ভেতরে অল্প বদলসহজ (append, seek)পুরো object আবার লিখতে হয়
খরচের ধরনDisk কেনা বা ভাড়াGB-মাস, অনুরোধ আর বাইরে যাওয়া ডেটা

শেষ দুটি সারি খেয়াল করুন। Object storage database নয়, আর সাধারণ ফাইল সিস্টেমও নয়। একটি object লেখা হয় পুরোটা একসাথে, পড়া হয় পুরোটা বা একটি byte-range. ছবি, PDF, ভিডিও, backup-এর মতো “একবার লিখি, বহুবার পড়ি” ধরনের ডেটার জন্য এটি দারুণ। কিন্তু প্রতি সেকেন্ডে বদলানো log ফাইল বা database-এর ডেটা এখানে রাখা ভুল। ভেতরের নকশা, যেমন ডেটা কয়েক জায়গায় কপি রাখা আর erasure coding, System Design কোর্সের object storage পর্বে আছে; এখানে আমরা app লেখকের চোখে দেখব।

২ · Object storage-এর ভাষা — bucket, key, object, metadata

চারটি শব্দ জানলেই S3-ধাঁচের সব API পড়া যায়।

Bucket

সবচেয়ে বড় পাত্র। অনুমতি, CORS আর lifecycle নিয়ম সাধারণত bucket ধরে বসে। আমাদের দুটি: bazarghar-private আর bazarghar-media.

গুদামের একটি ঘর

Key

Bucket-এর ভেতরে object-এর পুরো নাম। / থাকতে পারে, কিন্তু সেটি শুধু একটি অক্ষর।

মালের গায়ের লেবেল

Object

Byte-এর একটি দলা, যা পুরোটা লেখা হয়। একই key-তে আবার লিখলে পুরোনোটি বদলে যায়।

বস্তাটি নিজে

Metadata

Content-Type, Cache-Control, আর নিজের x-amz-meta-* মান। ফেরত দেওয়ার সময় header হয়ে যায়।

চালানের কাগজ

নিজের মেশিনে একটি S3-সামঞ্জস্যপূর্ণ server

শেখার জন্য cloud account লাগে না। পরিকল্পনা ছিল MinIO চালানো, কারণ বহু বছর এটিই ছিল নিজে চালানোর জনপ্রিয় S3-সামঞ্জস্যপূর্ণ server. কিন্তু লেখার সময়ে (নভেম্বর ২০২৬) তার ডাউনলোড ঠিকানা থেকে binary চাইলে উত্তর এলো 410 Gone, সাথে বার্তা: open-source MinIO Server আর Client প্রকল্প archive করা হয়েছে, আর রক্ষণাবেক্ষণ হয় না। প্রকল্পের নিজের ঘোষণা আর GitHub পাতা অনুযায়ী এটি ধাপে ধাপে ঘটেছে: ২০২৫ সালে community সংস্করণের তৈরি binary দেওয়া বন্ধ হয় আর প্রকল্পটি maintenance mode-এ যায়, তারপর ২০২৬ সালে GitHub repository archive, অর্থাৎ শুধু পড়া যায়। কোম্পানিটি তার বাণিজ্যিক পণ্য চালিয়ে যাচ্ছে বলে জানা যায়, আর কিছু community fork-ও আছে; তবে এদের ভবিষ্যৎ আমরা যাচাই করিনি। নিজে চালানো সফটওয়্যারের এই ঝুঁকিটিও শেখার বিষয়, আমরা অংশ ৯-এ ফিরব।

তাই আমরা নিলাম SeaweedFS 4.52, একটি open-source বিতরণকৃত storage, যার একটি S3 gateway আছে। GitHub release-এর Windows zip নামিয়ে SHA-256 মিলিয়ে নেওয়া হয়েছিল, release পাতার digest-এর সাথে হুবহু এক, আর প্রকল্পের দেওয়া MD5 ফাইলও মিলেছে। সব port 127.0.0.1-এ, telemetry বন্ধ। SDK হলো @aws-sdk/client-s3 3.1161.0, যা লেখার সময়ে npm-এর latest. পুরোনো aws-sdk (v2)-কে npm নিজেই বলছে end-of-support, তাই নতুন কোডে v3.

npm install @aws-sdk/client-s3@3.1161.0 @aws-sdk/s3-presigned-post@3.1161.0 \
  @aws-sdk/s3-request-presigner@3.1161.0 sharp@0.35.5 express@5.3.0 pg@8.23.1

weed server -dir=./swdata -ip=127.0.0.1 -master.telemetry=false \
  -filer -s3 -s3.port=47904 -s3.config=./s3.json
import { S3Client } from '@aws-sdk/client-s3';

export const s3 = new S3Client({
  endpoint: 'http://127.0.0.1:47904',     // নিজের store; AWS-এ এটি লাগে না
  region: 'us-east-1',
  forcePathStyle: true,                   // http://host/bucket/key আকারে
  requestChecksumCalculation: 'WHEN_REQUIRED',   // কেন, অংশ ৩-এ
  credentials: {
    accessKeyId: process.env.S3_KEY,      // .env-এ, কখনো client-এ নয়
    secretAccessKey: process.env.S3_SECRET,
  },
});

আমাদের .env-এ ছিল একেবারে নকল মান, যেমন BAZARGHAREXAMPLEKEY আর example-secret-not-real-…. Secret কোথায় রাখবেন, সেটি পর্ব ৭৮-এর বিষয়। এবার চারটি object লিখে তালিকা চাইলাম। সত্যিকারের run:

flat keys: ["incoming/x.jpg","products/a2/one.webp","products/a2/two.webp","products/d4/three.webp"]
prefix products/ delimiter / → {"CommonPrefixes":["products/a2/","products/d4/"],"Contents":[]}
head: {"ContentType":"image/webp","ContentLength":25,"ETag":"\"94e6cdeedd044e4fd51d866ec62e232d\"",
       "Metadata":{"uploaded-by":"user-17"}}
after delete: NoSuchKey 404
delete again status: 204

প্রথম লাইনে কোনো “folder” নেই, আছে চারটি লম্বা নাম, বর্ণানুক্রমে। দ্বিতীয় লাইনে আমরা Prefix: 'products/' আর Delimiter: '/' দিলাম, আর store নামগুলোকে পরের / পর্যন্ত কেটে দলে ভাগ করল: products/a2/ আর products/d4/. File explorer-এর folder দেখানোর এটাই কৌশল। এর মানে, “folder rename” বলে কোনো সস্তা কাজ নেই। products/-কে catalog/ করতে হলে প্রতিটি object কপি করে নতুন key-তে লিখে পুরোনোটি মুছতে হয়।

শেষ দুই লাইন দেখায়, মুছে ফেলা object পড়তে গেলে 404 NoSuchKey, কিন্তু একই key আবার মুছলেও 204. মোছা idempotent, যা retry-তে সুবিধা দেয়। ETag এখানে ছোট object-এর MD5-এর মতো দেখায়, কিন্তু বড় ফাইল টুকরো করে পাঠালে তা আর MD5 থাকে না; তাই ETag-কে hash ধরে নেবেন না।

৩ · Presigned URL-এর ভেতরে — সই হাতে বানানো

পর্ব ৩০-এ presigned URL ছিল মেলার সিলমারা স্লিপ: server গোপন চাবি দিয়ে সই করে, store সই আর সময় মেলায়, চাবি কখনো browser-এ যায় না। এবার সেই সিল কীভাবে তৈরি হয়, তা হাতে করে দেখি। AWS তার documentation-এ query-string সইয়ের একটি উদাহরণ দেয়: নকল চাবি AKIAIOSFODNN7EXAMPLE, একটি নির্দিষ্ট তারিখ, আর তার প্রত্যাশিত সই। নিচের কোড শুধু node:crypto দিয়ে সেই সই আবার বানায়। এখানে কোনো আসল চাবি নেই, কোনো নেটওয়ার্কও নয়।

// AWS-এর docs-এর উদাহরণ মান (নকল চাবি), হাতে SigV4 presigned GET
const { createHash, createHmac } = require('node:crypto');
const sha = (s) => createHash('sha256').update(s).digest('hex');
const hmac = (k, s) => createHmac('sha256', k).update(s).digest();

const ACCESS = 'AKIAIOSFODNN7EXAMPLE';
const SECRET = 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY';
const host = 'examplebucket.s3.amazonaws.com', key = '/test.txt';
const date = '20130524', amzDate = '20130524T000000Z', region = 'us-east-1';
const scope = `${date}/${region}/s3/aws4_request`;

const q = [['X-Amz-Algorithm', 'AWS4-HMAC-SHA256'],
  ['X-Amz-Credential', `${ACCESS}/${scope}`], ['X-Amz-Date', amzDate],
  ['X-Amz-Expires', '86400'], ['X-Amz-SignedHeaders', 'host']]
  .map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&');

// ধাপ ১: canonical request — কী সই হচ্ছে, তার হুবহু লেখা
const canonical = ['GET', key, q, `host:${host}\n`, 'host', 'UNSIGNED-PAYLOAD'].join('\n');
// ধাপ ২: string to sign
const toSign = ['AWS4-HMAC-SHA256', amzDate, scope, sha(canonical)].join('\n');
// ধাপ ৩: চাবি থেকে চার ধাপে signing key
let k = hmac('AWS4' + SECRET, date);
for (const part of [region, 's3', 'aws4_request']) k = hmac(k, part);
// ধাপ ৪: সই
const sig = createHmac('sha256', k).update(toSign).digest('hex');

console.log('canonical hash:', sha(canonical).slice(0, 16) + '…');
console.log('signature:', sig);
const docs = 'aeeed9bbccd4d02ee5c0109b86d86835f995330da4c265957d157751f604d404';
console.log('matches AWS docs example:', sig === docs);
const c2 = canonical.replace('/test.txt', '/test.txT');       // key-এ এক অক্ষর বদল
const bad = createHmac('sha256', k).update(toSign.replace(sha(canonical), sha(c2))).digest('hex');
console.log('key test.txT → same signature?', sig === bad);
console.log('URL ends: …&X-Amz-Signature=' + sig.slice(0, 12) + '…');

আউটপুটের তৃতীয় লাইন matches AWS docs example: true: আমাদের হাতের সই aeeed9bb…f604d404, docs-এর মানের সাথে হুবহু এক। চতুর্থ লাইন false: key-এর একটি অক্ষর বদলালেই canonical request-এর hash বদলায়, তাই সইও। Store ঠিক এই হিসাবটি নিজে করে, আর মিল না পেলে উত্তর দেয় SignatureDoesNotMatch. খেয়াল করুন, গোপন চাবিটি URL-এ কোথাও নেই; আছে শুধু তার HMAC-এর ফল, যেখান থেকে চাবি উল্টো বের করা যায় না।

চারটি ধাপ মনে রাখুন। Canonical request হলো method, path, query, সই করা header আর payload-এর একটি নির্দিষ্ট রূপ। তার SHA-256 নিয়ে বানানো হয় string to sign. গোপন চাবি থেকে তারিখ, region আর service দিয়ে পরপর HMAC করে আসে signing key, যা শুধু সেই দিন আর সেই region-এ চলে। শেষে সেই key দিয়ে string-এর HMAC, যা signature. যা কিছু canonical request-এ ঢোকে, তা-ই সুরক্ষিত; যা ঢোকে না, পথে বদলালেও store টের পায় না।

SDK-এর একটি ফাঁদ: খালি body-র checksum

শেষ কথাটির বাস্তব উদাহরণ পেলাম প্রথম run-এই। SDK দিয়ে PUT-এর presigned URL বানিয়ে browser থেকে পাঠালাম, আর store ফেরাল 400 BadDigest. URL-এর query খুলে দেখা গেল কারণ। সত্যিকারের run, SDK 3.1161.0:

default       X-Amz-Algorithm X-Amz-Content-Sha256 X-Amz-Credential X-Amz-Date X-Amz-Expires
              X-Amz-Signature X-Amz-SignedHeaders x-amz-checksum-crc32 x-amz-sdk-checksum-algorithm x-id
              signed: content-length;content-type;host   crc32: AAAAAA==
WHEN_REQUIRED X-Amz-Algorithm X-Amz-Content-Sha256 X-Amz-Credential X-Amz-Date X-Amz-Expires
              X-Amz-Signature X-Amz-SignedHeaders x-id
              signed: content-length;content-type;host   crc32: null

Default অবস্থায় SDK URL-এ একটি checksum বসিয়েছে: AAAAAA==, যা আসলে শূন্য byte-এর CRC32. URL বানানোর সময় SDK-এর কাছে ফাইল ছিল না, তাই সে খালি body-র হিসাব লিখেছে। Browser ৮,৩১,০৩৬ byte পাঠাল, store মেলাল, মিলল না। requestChecksumCalculation: 'WHEN_REQUIRED' দেওয়ার পরে এই দুটি প্যারামিটার আর এলো না, আর upload চলল। AWS-এর নিজের S3 এই প্যারামিটার কীভাবে সামলায়, তা আমরা পরীক্ষা করিনি; কিন্তু শিক্ষা একটাই: presigned URL-এর প্রতিটি query প্যারামিটার পড়ুন, আর নিজের store-এ সত্যিকারের upload চালিয়ে দেখুন।

৪ · Browser থেকে সরাসরি store-এ — presigned POST, PUT আর CORS

পুরো ব্যবস্থাটি আগে এক ছবিতে দেখে নিন। তারপর প্রতিটি তীর আলাদা করে চালাব।

“বাজারঘর”-এর ফাইলের পথ

The app signs and records; bytes go straight to the store; a worker makes variants; a CDN serves them

Browser, app, PostgreSQL, দুটি bucket, worker আর CDN-এর মধ্যে ছয়টি ধাপ Browser (Edge) App (Express) সই আর যাচাই PostgreSQL uploads, jobs private bucket incoming/, invoices/ Worker sharp: WebP, AVIF media bucket products/<hash>/ CDN ১ বছরের cache ১২৩ ৪৫৬ ১ presign চাওয়া ২ ফাইল সরাসরি store-এ ৩ complete: যাচাই, সারি, job ৪ worker job নেয় ৫ variant লেখা ৬ CDN থেকে ছবি
ছবি ৬৪.১ — App ফাইলের byte ছোঁয় না; সে শুধু সই করে, যাচাই করে আর হিসাব রাখে।

POST না PUT

Browser থেকে সরাসরি পাঠানোর দুটি পথ। Presigned PUT হলো একটি URL, যেখানে ফাইলটি body হয়ে যায়। Presigned POST হলো একটি HTML form-এর অনুমতি: URL-এর সাথে কয়েকটি field, যার একটি policy. Policy-তে লেখা থাকে শর্ত, যেমন আকার কত থেকে কত। PUT-এ আকারের সীমা দেওয়ার মানসম্মত উপায় নেই; বড়জোর Content-Length সইয়ে ঢুকিয়ে একটি নির্দিষ্ট আকার বেঁধে দেওয়া যায়। তাই browser upload-এ POST policy সাধারণত বেশি কড়া। আমাদের server POST-এর policy বানায় এভাবে:

app.post('/api/uploads/presign', requireLogin, async (req, res) => {
  const { type, size } = req.body ?? {};
  if (!TYPES[type]) return res.status(415).json({ error: 'TYPE_NOT_ALLOWED' });
  if (!Number.isInteger(size) || size < 1 || size > MAX) {
    return res.status(413).json({ error: 'TOO_LARGE', max: MAX });
  }
  const id = randomUUID();
  const key = `incoming/${id}.${TYPES[type]}`;       // key আমরা ঠিক করি
  await pool.query(`insert into uploads (id, owner_id, key, declared_type, declared_size)
                    values ($1, $2, $3, $4, $5)`, [id, req.user.id, key, type, size]);
  const post = await createPresignedPost(s3, {
    Bucket: PRIVATE, Key: key, Expires: 300,
    Fields: { 'Content-Type': type },
    Conditions: [['content-length-range', 1, MAX], ['eq', '$Content-Type', type]],
  });
  res.json({ id, key, ...post });                    // { url, fields }
});

Policy আসলে Base64 করা JSON. খুলে দেখলে (সত্যিকারের run, একটি নমুনা key দিয়ে বানানো):

{ "expiration": "2026-11-02T18:24:53Z",
  "conditions": [
    ["content-length-range", 1, 5242880],
    ["eq", "$Content-Type", "image/jpeg"],
    {"Content-Type": "image/jpeg"}, {"bucket": "bazarghar-private"},
    {"X-Amz-Algorithm": "AWS4-HMAC-SHA256"},
    {"X-Amz-Credential": "BAZARGHAREXAMPLEKEY/20261102/us-east-1/s3/aws4_request"},
    {"X-Amz-Date": "20261102T181953Z"}, {"key": "incoming/3f9c1b7e.jpg"} ] }

এই পুরো লেখাটিই সই হয়। Browser কোনো শর্ত বদলালে সই আর মেলে না। এবার Edge 156-এ, Playwright দিয়ে চালানো সত্যিকারের upload. পাতাটি ছিল আমাদের app-এর http://127.0.0.1:47910 থেকে, আর ছবি একটি কৃত্রিম ৩০০০×২২৫০ JPEG, ৮,৩১,০৩৬ byte.

  1. Browserilish.jpg
  2. Apppresign
  3. Storeincoming/
  4. Appcomplete
Browser → Appশুধু বর্ণনা, ফাইল নয়
POST /api/uploads/presign
{"type":"image/jpeg","size":831036,"mode":"post"}
PostgreSQLuploads-এ নতুন সারি
id      f945ed61-…
key     incoming/f945ed61-….jpg
status  pending   declared_size 831036
App → Browserurl + ৮টি field
url: http://127.0.0.1:47904/bazarghar-private
fields: Content-Type, bucket, X-Amz-Algorithm,
  X-Amz-Credential, X-Amz-Date, key, Policy,
  X-Amz-Signature
Browser → StoreFormData, ফাইল সবার শেষে
POST /bazarghar-private   (multipart/form-data)
… 8 fields … file=ilish.jpg (831036 bytes)
← 204
App: completestore-কে জিজ্ঞেস করা
HeadObject  → size 831036, type image/jpeg
Range 0-15  → magic ffd8ffdb
begin; status='uploaded'; insert job; commit
← 202 {"status":"uploaded"}
আবার completeদ্বিতীয় ডাক
← 200 {"status":"uploaded","again":true}
নতুন job নেই, নতুন সারি নেই
  1. ধাপ ১ — অনুমতি চাওয়া। ব্যবহারকারী ilish.jpg বাছলেন। Browser app-কে শুধু জানাল ধরন image/jpeg আর আকার ৮,৩১,০৩৬ byte. ফাইলের একটি byte-ও app-এ যায়নি।
  2. ধাপ ২ — যাচাই আর সারি। App login দেখল, ধরন allowlist-এ আছে, আকার ৫২,৪২,৮৮০-এর নিচে। নিজে UUID দিয়ে key বানাল, আর PostgreSQL-এ pending সারি লিখল। এই সারিই পরে মালিকহীন ফাইল ধরতে সাহায্য করবে।
  3. ধাপ ৩ — সই। createPresignedPost দিল একটি URL আর ৮টি field. Policy-র মেয়াদ ৩০০ সেকেন্ড, আকার ১ থেকে ৫২,৪২,৮৮০ byte, ধরন ঠিক image/jpeg.
  4. ধাপ ৪ — সরাসরি পাঠানো। Browser FormData-তে আগে field, শেষে ফাইল রেখে store-এ POST করল। Store সই, মেয়াদ আর প্রতিটি শর্ত মিলিয়ে উত্তর দিল 204. App-এর memory, সংযোগ বা bandwidth এতে খরচ হয়নি।
  5. ধাপ ৫ — নিশ্চিত করা। Browser app-কে বলল “শেষ”. App বিশ্বাস না করে store-কে জিজ্ঞেস করল: আকার ৮,৩১,০৩৬, ঘোষণার সাথে মিল। প্রথম ১৬ byte-এ ffd8ffdb, অর্থাৎ JPEG. একটি transaction-এ সারি uploaded আর একটি job, তারপর উত্তর 202.
  6. ধাপ ৬ — দ্বিতীয় ডাক। নেটওয়ার্কের কারণে browser আবার “শেষ” পাঠালে app দেখে সারি আর pending নয়, তাই নতুন কিছু না করে আগের অবস্থা ফেরত দেয়: 200, again: true. Retry নিরাপদ।

এবার একই পাতা থেকে ইচ্ছা করে কয়েকটি মিথ্যা বলা হলো। প্রতিটি সারি Edge-এর সত্যিকারের ফল:

কী করা হলোকে আটকালউত্তর
সৎভাবে ৬ MiB ঘোষণাApp, presign-এর সময়413 TOO_LARGE
১ MB বলে ৬ MiB পাঠানো, POSTStore, policy-র আকার শর্ত400 EntityTooLarge
১ MB বলে ৬ MiB পাঠানো, PUTStore, সই করা content-length403 SignatureDoesNotMatch
Form-এ ধরন বদলে text/html, POSTStore, policy-র eq403 AccessDenied, “Policy Condition failed”
Header-এ ধরন বদলে text/html, PUTStore, সই করা content-type403 SignatureDoesNotMatch
.jpg নামে PHP লেখা, POSTকেউ না: store নিল (204)complete-এ 422, magic 3c3f7068

শেষ সারিটি সবচেয়ে জরুরি। সই প্রমাণ করে অনুমতি, ফাইলের ভেতর নয়। Store “ধরন image/jpeg” লেবেলটি মিলিয়েছে, কারণ লেবেল browser নিজেই পাঠিয়েছিল। ভেতরের byte <?ph, যা ধরা পড়েছে কেবল complete-এর magic bytes যাচাইয়ে। তাই incoming/ কখনো public নয়, আর যাচাইয়ের আগে কোনো ফাইল ব্যবহারকারীর সামনে যায় না।

Bucket-এর CORS — আর CORS কী নয়

পাতাটি চলছে 127.0.0.1:47910 origin-এ, আর store 127.0.0.1:47904-এ। Port আলাদা মানে origin আলাদা, তাই browser same-origin নীতি মেনে store-এর অনুমতি চায়। পর্ব ৫০-এ app-এর CORS দেখেছেন; এখানে নিয়মটি বসে bucket-এ:

await s3.send(new PutBucketCorsCommand({ Bucket: PRIVATE, CORSConfiguration: { CORSRules: [{
  AllowedOrigins: ['https://bazarghar.example'],   // ডেমোতে http://127.0.0.1:47910
  AllowedMethods: ['PUT', 'POST'],
  AllowedHeaders: ['content-type'],
  ExposeHeaders: ['ETag'],
  MaxAgeSeconds: 600,
}] } }));

PUT-এ content-type: image/jpeg header থাকায় browser আগে একটি preflight OPTIONS পাঠায়। curl দিয়ে দুই origin থেকে সেটি নকল করলাম, সত্যিকারের run:

== Origin http://127.0.0.1:47910
HTTP/1.1 200 OK
Access-Control-Allow-Headers: content-type
Access-Control-Allow-Methods: PUT, POST
Access-Control-Allow-Origin: http://127.0.0.1:47910
Access-Control-Expose-Headers: ETag
Access-Control-Max-Age: 600
Vary: Origin

== Origin http://127.0.0.1:47912
HTTP/1.1 403 Forbidden
<Error><Code>AccessDenied</Code><Message>Access Denied.</Message>…

তারপর একটি অন্য origin (47912) থেকে Edge-এ দুটি বৈধ সই করা upload চালালাম, একটি PUT আর একটি POST. দুটিতেই JavaScript পেল TypeError: Failed to fetch, আর console-এ লেখা “blocked by CORS policy”. কিন্তু পরে store-এ খোঁজ নিয়ে দেখা গেল:

incoming/cors-put.txt  NotFound
incoming/cors-post.txt EXISTS 10

PUT-টি preflight-এই আটকে গিয়েছিল, তাই পাঠানোই হয়নি। কিন্তু POST একটি “simple request” (form-এর মতো multipart/form-data, বাড়তি header নেই), তাই browser preflight ছাড়াই পাঠিয়েছে। Store সই মিলিয়ে ফাইল রেখে দিয়েছে; browser শুধু উত্তরটি JavaScript-কে পড়তে দেয়নি। CORS নিরাপত্তার দেয়াল নয়, browser-এর পড়ার অনুমতি। কে লিখতে পারবে, তা ঠিক করে সই, তার মেয়াদ আর policy-র শর্ত। এজন্যই presign route-এ login, আর ছোট মেয়াদ।

৫ · Server-এ নিশ্চিত করা আর PostgreSQL-এ হিসাব

ফাইল এখন দুই জায়গায় বাস করে: byte store-এ, হিসাব database-এ। দুটি আলাদা ব্যবস্থা একসাথে পাল্টায় না, তাই ক্রম আর অবস্থা আগে থেকে ঠিক করতে হয়। “বাজারঘর”-এর uploads সারির জীবন:

pending → uploaded → ready
পাশের পথ: rejected আর abandoned
app.post('/api/uploads/:id/complete', requireLogin, async (req, res) => {
  const { rows: [u] } = await pool.query(
    'select * from uploads where id = $1 and owner_id = $2', [req.params.id, req.user.id]);
  if (!u) return res.status(404).json({ error: 'NOT_FOUND' });
  if (u.status !== 'pending') return res.json({ id: u.id, status: u.status, again: true });

  let head;
  try { head = await s3.send(new HeadObjectCommand({ Bucket: PRIVATE, Key: u.key })); }
  catch (e) { return res.status(409).json({ error: 'NOT_UPLOADED_YET' }); }
  const first = await s3.send(new GetObjectCommand({ Bucket: PRIVATE, Key: u.key, Range: 'bytes=0-15' }));
  const hex = Buffer.from(await first.Body.transformToByteArray()).toString('hex');
  const ok = head.ContentLength === u.declared_size && hex.startsWith(MAGIC[u.declared_type]);

  const db = await pool.connect();
  try {
    await db.query('begin');
    await db.query(`update uploads set status = $2, completed_at = now() where id = $1`,
      [u.id, ok ? 'uploaded' : 'rejected']);
    await db.query(`insert into jobs (kind, payload) values ($1, $2)`,
      ok ? ['image.variants', { uploadId: u.id }] : ['upload.delete', { key: u.key }]);
    await db.query('commit');
  } catch (e) { await db.query('rollback'); throw e; } finally { db.release(); }
  res.status(ok ? 202 : 422).json({ id: u.id, status: ok ? 'uploaded' : 'rejected' });
});

চারটি সিদ্ধান্ত লুকিয়ে আছে এই কোডে।

  • মালিকানা: owner_id = $2. অন্যের upload id জানলেও কেউ সেটি “complete” করতে পারবে না। এটি পর্ব ৪৮-এর ownership যাচাই।
  • Client-কে নয়, store-কে বিশ্বাস: আকার আসে HeadObject থেকে, ধরন আসে প্রথম ১৬ byte থেকে। পুরো ফাইল নামানো হয়নি, শুধু একটি byte-range.
  • সারি আর job এক transaction-এ: “uploaded” লেখা হলো অথচ job হারাল, এমন অর্ধেক অবস্থা আর সম্ভব নয়। এটি পর্ব ৪৩-এর outbox ধারণা: job-এর সারিটিই outbox, যা worker পরে পড়বে।
  • Idempotent: দ্বিতীয় ডাকে একই ফল, যা পর্ব ৩৩-এর idempotency-র সরল রূপ।

Client-এর “complete” ছাড়া আর কোনো পথ আছে? অনেক object storage নতুন object এলে একটি event পাঠাতে পারে, যেমন একটি queue বা webhook-এ। তখন browser-এর ডাকের ওপর নির্ভর করতে হয় না, ব্যবহারকারী tab বন্ধ করলেও কাজ এগোয়। কিন্তু event-এর ব্যবস্থা প্রতিটি provider-এ আলাদা, আর আমাদের পরীক্ষার store-এ আমরা এটি চালাইনি। Client-এর ডাক আর পরে orphan sweep, এই সরল জুটি সব S3-সামঞ্জস্যপূর্ণ store-এ চলে।

Key কে বানায়: random না content-hash

incoming/-এর key random UUID, কারণ তখনো আমরা ফাইলটি দেখিনি। কিন্তু চূড়ান্ত ছবির key worker বানায় ফাইলের content hash থেকে। একই byte মানে একই নাম, বদলানো byte মানে নতুন নাম। নিচের কোড চালিয়ে দেখুন:

// Content-addressed key: ফাইলের byte থেকে নাম
const { createHash } = require('node:crypto');

function keyFor(bytes, ext) {
  const h = createHash('sha256').update(bytes).digest('hex');
  // প্রথম ২ অক্ষরে ভাগ: একই prefix-এ লাখো key জমে না
  return { hash: h, key: `products/${h.slice(0, 2)}/${h.slice(0, 32)}.${ext}` };
}

// তিনটি "ছবি": a আর c-এর byte হুবহু এক, নাম আলাদা
const files = [
  { name: 'ilish.jpg', bytes: Buffer.from('JPEG…ilish-pixels…v1') },
  { name: 'ilish-new.jpg', bytes: Buffer.from('JPEG…ilish-pixels…v2') },
  { name: 'IMG_0042.jpg', bytes: Buffer.from('JPEG…ilish-pixels…v1') },
];

const store = new Map();          // key → bytes, object store-এর মডেল
for (const f of files) {
  const { key } = keyFor(f.bytes, 'jpg');
  const fresh = !store.has(key);
  if (fresh) store.set(key, f.bytes);
  console.log(f.name.padEnd(14), key, fresh ? 'PUT' : 'skip (already stored)');
}
console.log('objects stored:', store.size, 'of', files.length, 'uploads');

// এক byte বদলালে hash পুরো বদলে যায়
const a = keyFor(Buffer.from('JPEG…ilish-pixels…v1'), 'jpg').hash;
const b = keyFor(Buffer.from('JPEG…ilish-pixels…v1 '), 'jpg').hash;
let same = 0;
for (let i = 0; i < 64; i++) if (a[i] === b[i]) same++;
console.log('hex chars equal after 1-byte change:', same, 'of 64');

আউটপুটে ilish.jpg পেল products/a2/a2f35604dbd6a74727ba4ff3cb9d86f5.jpg, আর IMG_0042.jpg একই key পেয়ে skip (already stored). তিনটি upload, কিন্তু objects stored: 2. শেষ লাইনে একটি বাড়তি space-এর পরে ৬৪টি hex অক্ষরের মাত্র ৬টি মিলেছে, যা কাকতালীয়। নাম বদলায় বলে পুরোনো নাম কখনো নতুন ছবি দেখায় না; তাই অংশ ৭-এ এক বছরের immutable cache নিরাপদ। a2/-এর মতো ভাগ পুরোনো অভ্যাস; AWS-এর docs প্রতিটি prefix-এ সেকেন্ডে কয়েক হাজার অনুরোধের কথা বলে, আর অন্য store-এ নিয়ম আলাদা, তাই নিজের provider-এর docs দেখুন।

আমাদের সত্যিকারের run-এ এই সুবিধা নিজে থেকেই দেখা দিল। একই ছবি POST আর PUT দুই পথে দুবার upload হয়েছিল। দুটি job-ই পেল একই hash cd3bd1229e378312, আর দ্বিতীয়টি একই key-তে একই byte লিখল। Storage দ্বিগুণ হয়নি।

৬ · Image pipeline — background job-এ sharp, srcset আর next/image

ছবি ছোট করা CPU-র কাজ, AVIF-এ আরও বেশি। এটি অনুরোধের ভেতরে করলে ব্যবহারকারী অপেক্ষা করেন, আর একসাথে অনেক upload এলে server ধীর হয়। তাই কাজটি যায় একটি background worker-এ। Queue, retry আর cron-এর পুরো নকশা পর্ব ৬২-এর বিষয়, আর তত্ত্ব System Design কোর্সের jobs পর্বে; এখানে আমরা PostgreSQL-এর একটি jobs টেবিলকেই সরল queue হিসেবে নিলাম।

const WIDTHS = [320, 480, 640, 960, 1280];
const FORMATS = { webp: { quality: 75 }, avif: { quality: 50 } };

async function claim() {                       // একটি job ধরা, অন্য worker-কে বাদ দিয়ে
  const { rows } = await pool.query(`
    update jobs set status = 'running', attempts = attempts + 1
    where id = (select id from jobs where status = 'queued'
                order by id for update skip locked limit 1)
    returning *`);
  return rows[0];
}

// variants(job)-এর ভেতরে:
const img = sharp(input, { limitInputPixels: 24_000_000 }).autoOrient();
for (const w of WIDTHS) for (const [fmt, opt] of Object.entries(FORMATS)) {
  const data = await img.clone().resize({ width: w, withoutEnlargement: true })[fmt](opt).toBuffer();
  await s3.send(new PutObjectCommand({ Bucket: MEDIA, Key: `products/${hash}/${w}.${fmt}`,
    Body: data, ContentType: `image/${fmt}`, CacheControl: 'public, max-age=31536000, immutable' }));
}

for update skip locked হলো ছোট কিন্তু জরুরি কৌশল: দুটি worker একসাথে চললে একজন যে সারি ধরেছে, অন্যজন সেটি এড়িয়ে পরেরটি নেয়, ফলে একই job দুবার চলে না। ব্যর্থ হলে attempts বাড়ে, তিনবারের পরে job “dead”. WebP আর AVIF দুটোই বানানো হলো, কারণ AVIF প্রায়ই ছোট, কিন্তু encode ধীর। নিচের stepper-এ worker-এর সত্যিকারের run, sharp 0.35.5 (libvips 8.18.7):

Worker-এর হিসাব (ইনপুট 3000×2250 JPEG, 831,036 byte)
এখনকার মাপ
WebP q75
AVIF q50
মোট লেখা
অবস্থা
  1. ধাপ ১ — Job ধরা। Worker skip locked দিয়ে job 6 নিল। incoming/f945ed61-….jpg নামাল: JPEG, ৩০০০×২২৫০, ৮,৩১,০৩৬ byte. SHA-256-এর প্রথম ১৬ অক্ষর cd3bd1229e378312 হলো নতুন key-এর ভিত্তি।
  2. ধাপ ২ — ৩২০ প্রস্থ। WebP ৩,৮১৮ byte (২২ ms), AVIF ৩,৯৫৮ byte (৭০ ms). এত ছোট মাপে AVIF একটু বড়ই হলো। মোট ৭,৭৭৬ byte.
  3. ধাপ ৩ — ৪৮০ প্রস্থ। WebP ৬,১৩৪, AVIF ৬,৩৯৮ byte. মোট দাঁড়াল ২০,৩০৮ byte.
  4. ধাপ ৪ — ৬৪০ প্রস্থ। WebP ৮,৫১৪, AVIF ৮,৫৪১ byte, প্রায় সমান। পণ্যের তালিকায় সবচেয়ে বেশি এই মাপটিই যাবে (নিচের srcset-এর হিসাব দেখুন)। মোট ৩৭,৩৬৩ byte.
  5. ধাপ ৫ — ৯৬০ প্রস্থ। এবার AVIF ১২,৬৫২, WebP ১৩,৫২০ byte; বড় মাপে AVIF এগিয়ে গেল। মোট ৬৩,৫৩৫ byte.
  6. ধাপ ৬ — ১২৮০ আর শেষ। WebP ১৮,৮৮৮, AVIF ১৬,৮৯৫ (১৮৩ ms). দশটি ফাইল মিলে ৯৯,৩১৮ byte, আসল ছবির প্রায় ১২%. সারি ready, আর incoming/-এর আসলটি মুছে ফেলা হলো। পুরো run-এ তিনটি job লেগেছে ১,৭১৫ ms, এক মেশিনে একবারের মাপ।

সংখ্যাগুলো একটি কৃত্রিম ছবির, যাতে gradient, কয়েকটি রঙিন আকার আর হালকা noise আছে। আসল ছবিতে অনুপাত আলাদা হবে; WebP না AVIF ভালো, সে সিদ্ধান্ত নিজের পণ্যের ছবিতে মেপে নিন। আসল ছবি রাখবেন কি না, তা-ও নীতির প্রশ্ন: রাখলে পরে নতুন মাপ বানানো যায়, না রাখলে storage আর ঝুঁকি কম। আমাদের worker মুছে দেয়।

কোন পর্দা কোন ফাইল নেবে — srcset আর sizes

পাঁচটি প্রস্থ বানানো অর্থহীন, যদি browser জানতে না পারে কোনটি নেবে। srcset বলে কোন ফাইল কত প্রস্থের, আর sizes বলে পাতায় ছবিটি কত CSS pixel জায়গা নেবে। Browser তারপর পর্দার device pixel ratio দিয়ে গুণ করে দরকারি pixel বের করে। পণ্যের তালিকায় আমাদের নকশা: ফোনে দুই কলাম, ট্যাবে তিন, বড় পর্দায় ৩০০px কার্ড।

// পণ্যের ছবির srcset পরিকল্পনা: কোন পর্দা কোন ফাইল নেবে
const WIDTHS = [320, 480, 640, 960, 1280];          // worker যে মাপগুলো বানায়
const id = 'a2f35604';
const srcset = (fmt) => WIDTHS.map((w) => `/img/${id}-${w}.${fmt} ${w}w`).join(', ');
const sizes = '(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 300px';

// sizes-এর নিয়ম: প্রথম যে শর্ত মেলে, সেই CSS প্রস্থ
function slotWidth(vw) {
  if (vw <= 640) return vw * 0.5;
  if (vw <= 1024) return vw / 3;
  return 300;
}
// browser সাধারণত দরকারি pixel-এর সমান বা বড় সবচেয়ে ছোট মাপটি নেয়
const pick = (need) => WIDTHS.find((w) => w >= need) ?? WIDTHS.at(-1);

const screens = [
  { name: 'budget phone', vw: 360, dpr: 2 },
  { name: 'big phone', vw: 412, dpr: 3 },
  { name: 'tablet', vw: 800, dpr: 2 },
  { name: 'laptop', vw: 1366, dpr: 1 },
  { name: 'hi-dpi desktop', vw: 1920, dpr: 2 },
];
for (const s of screens) {
  const css = slotWidth(s.vw), need = Math.ceil(css * s.dpr);
  console.log(`${s.name.padEnd(15)} slot ${String(Math.round(css)).padStart(3)}px x${s.dpr} need ${String(need).padStart(4)} → ${pick(need)}w`);
}
console.log('srcset (avif):', srcset('avif').slice(0, 60) + '…');
console.log('sizes:', sizes);
console.log('files per product:', WIDTHS.length, 'widths x 2 formats =', WIDTHS.length * 2);

আউটপুটের পাঁচ লাইন: budget phone-এ জায়গা ১৮০px, ×২ মানে ৩৬০ pixel লাগে, তাই 480w. big phone ৬১৮ → 640w, tablet ৫৩৪ → 640w, laptop ৩০০ → 320w, hi-dpi desktop ৬০০ → 640w. তালিকার কার্ডে ৯৬০ আর ১২৮০ কখনো লাগেনি; সেগুলো পণ্যের বিস্তারিত পাতার বড় ছবির জন্য। sizes না দিলে browser ধরে নেয় ছবি পুরো পর্দাজুড়ে (100vw), আর ৮০০px ট্যাবে ×২ = ১৬০০ চেয়ে সবচেয়ে বড় ফাইলটিই নামাত। Browser-এর আসল বাছাই এই মডেলের চেয়ে বেশি নমনীয়, যেমন cache-এ বড় ফাইল থাকলে সেটিই নিতে পারে।

প্লেইন HTML-এ একাধিক ফরম্যাট দিতে <picture> লাগে: আগে AVIF-এর <source>, তারপর WebP, শেষে <img>. Browser প্রথম যে ধরন বোঝে, সেটি নেয়। অলস লোডিং আর জায়গা ধরে রাখার মতো বিষয় পর্ব ২৩-এ আর HTML & CSS কোর্সে আছে।

next/image আর নিজের loader

Next.js-এর next/image সাধারণত নিজেই ছবি ছোট করে (built-in optimizer). কিন্তু আমাদের worker আগেই মাপগুলো বানিয়ে রেখেছে, আর CDN থেকে সরাসরি দেওয়াই সস্তা। তাই loaderFile দিয়ে Next.js-কে জানাই যে সে শুধু URL বানাবে। Next.js 16.4.4-এর docs অনুযায়ী loader পায় { src, width, quality } আর একটি URL ফেরত দেয়:

export default {
  images: {
    loader: 'custom',
    loaderFile: './image-loader.js',
    imageSizes: [320, 480],          // deviceSizes-এর সবচেয়ে ছোটটির চেয়ে ছোট
    deviceSizes: [640, 960, 1280],   // worker ঠিক এই মাপগুলোই বানায়
  },
};
'use client';
const CDN = 'https://img.bazarghar.example';
const MADE = [320, 480, 640, 960, 1280];

export default function bazargharLoader({ src, width }) {
  const w = MADE.find((m) => m >= width) ?? MADE.at(-1);   // অজানা মাপ এলে কাছের বড়টি
  return `${CDN}/${src}-${w}.webp`;
}
import Image from 'next/image';

export default function Page() {
  return (
    <Image src="products/a2/a2f35604" alt="ইলিশ মাছ, ১ কেজি" width={1280} height={960}
      sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 300px" />
  );
}

next build চালিয়ে তৈরি HTML থেকে <img>-টি বের করলাম। সত্যিকারের run, Next.js 16.4.4, দেখার সুবিধার জন্য attribute আলাদা লাইনে:

<img alt="ইলিশ মাছ, ১ কেজি" loading="lazy" width="1280" height="960"
  decoding="async" data-nimg="1" style="color:transparent"
  sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 300px"
  srcSet="https://img.bazarghar.example/products/a2/a2f35604-320.webp 320w,
          https://img.bazarghar.example/products/a2/a2f35604-480.webp 480w,
          https://img.bazarghar.example/products/a2/a2f35604-640.webp 640w,
          https://img.bazarghar.example/products/a2/a2f35604-960.webp 960w,
          https://img.bazarghar.example/products/a2/a2f35604-1280.webp 1280w"
  src="https://img.bazarghar.example/products/a2/a2f35604-1280.webp"/>

imageSizes আর deviceSizes মিলিয়ে ঠিক আমাদের পাঁচটি প্রস্থই এসেছে, কারণ sizes দেওয়া থাকলে Next.js দুই তালিকা জুড়ে পূর্ণ srcset বানায়। Default তালিকা রাখলে loader 750 বা 828-এর মতো মাপ চাইত, যা worker বানায়নি; সেজন্যই loader-এ “কাছের বড়টি” নেওয়ার নিরাপত্তা। এই loader শুধু WebP দেয়। AVIF-ও চাইলে Next.js-এর getImageProps দিয়ে <picture> বানানো যায়, অথবা CDN Accept header দেখে ফরম্যাট বাছতে পারে। Remote ছবিতে Next.js-এর নিজের optimizer ব্যবহার করলে remotePatterns-এ ঠিকানা allowlist করতে হয়, আর Next.js 16 থেকে qualities-এর default [75]; বিস্তারিত পর্ব ৫৮-এর Open Graph ছবির আলোচনার পাশে রাখুন।

৭ · Private ফাইল বনাম public asset

সব ফাইল এক রকম নয়। পণ্যের ছবি সবার জন্য, বারবার পড়া হয়, কখনো বদলায় না (বদলালে নতুন key). চালানের PDF শুধু একজন ক্রেতার, মাঝেমধ্যে পড়া হয়, আর ফাঁস হলে নাম, ঠিকানা, ফোন নম্বর বেরিয়ে যায়। তাই দুটির জন্য দুটি bucket আর দুটি নিয়ম।

বিষয়পণ্যের ছবি (public)চালানের PDF (private)
Bucketbazarghar-media, শুধু products/*-এ পড়ার অনুমতিbazarghar-private, কোনো public অনুমতি নেই
KeyContent-hash: products/cd3b…/640.avifঅনুমানযোগ্য হলেও ক্ষতি নেই, অনুমতি সই-এ
দেওয়ার পথCDNApp যাচাই করে ৬০ সেকেন্ডের GET URL
Cache-Controlpublic, max-age=31536000, immutableApp-এর redirect-এ no-store
বদলনতুন key, পুরোনোটি থাকে বা পরে মোছেএকই key-তে নতুন সংস্করণ চলতে পারে

Public ছবি anonymous ভাবে পড়লাম, আর private bucket-এর চালান সই ছাড়া চাইলাম। সত্যিকারের run:

anon GET public products/cd3bd1229e378312/640.avif 200 content-type: image/avif | content-length: 8541
  | cache-control: public, max-age=31536000, immutable | etag: "ca2055d5bbfc05ea508c2550b166175b"
anon GET private invoice 403

Worker যে CacheControl লিখেছিল, store সেটিই header হিসেবে দিচ্ছে। CDN এই header দেখে এক বছর রাখবে, আর browser-ও আবার জিজ্ঞেস করবে না। এটি নিরাপদ শুধু এজন্য যে key-তে hash আছে; ছবি বদলালে key বদলায়, পুরোনো URL কখনো নতুন ছবি দেখায় না, তাই cache “মুছে ফেলার” দরকারই পড়ে না। CDN-এর ভেতরের কাজ System Design-এর CDN পর্বে, আর cache header-এর বিস্তারিত পর্ব ৮৪-এ।

একই key-তে নতুন ছবি, কিন্তু এক বছরের cache: এটিই সবচেয়ে সাধারণ ভুল। products/17.jpg নামে বারবার লিখলে CDN আর ব্যবহারকারীর browser পুরোনো ছবিই দেখাতে থাকবে, আর আপনার হাতে প্রতিটি browser-এর cache মোছার কোনো উপায় নেই। হয় নাম বদলান (hash বা version), নয়তো cache ছোট রাখুন।

চালান ডাউনলোড — স্বল্পমেয়াদি GET URL

app.get('/api/invoices/:id/download', requireLogin, async (req, res) => {
  const { rows: [inv] } = await pool.query('select * from invoices where id = $1', [req.params.id]);
  if (!inv || inv.owner_id !== req.user.id) return res.status(404).json({ error: 'NOT_FOUND' });
  const url = await getSignedUrl(s3, new GetObjectCommand({
    Bucket: PRIVATE, Key: inv.key,
    ResponseContentDisposition: `attachment; filename="${inv.id}.pdf"`,
    ResponseContentType: 'application/pdf',
  }), { expiresIn: 60 });
  res.set('Cache-Control', 'no-store').redirect(302, url);
});
  1. Browseruser 17
  2. Appমালিক?
  3. 30260 s URL
  4. StorePDF
Browser → Applogin করা অবস্থায়
GET /api/invoices/INV-1042/download
Appinvoices সারি
INV-1042  owner_id 17  key invoices/2026/11/INV-1042.pdf
user 17 → এগিয়ে যায়   user 18 → 404
App → Browserসত্যিকারের run
302  Cache-Control: no-store
Location: /bazarghar-private/invoices/2026/11/
          INV-1042.pdf?…&X-Amz-Expires=60&…
Store → Browserসই মিলেছে
200
content-type: application/pdf
content-disposition: attachment; filename="INV-1042.pdf"
পরে একই URLttl 2 s, ৩ সেকেন্ড পরে
403  AccessDenied — Request has expired
URL-এ কারসাজিINV-1042 → INV-1043
403  SignatureDoesNotMatch
সই ছাড়া সরাসরি → 403
  1. ধাপ ১ — অনুরোধ। ক্রেতা (user 17) “চালান ডাউনলোড” চাপলেন। অনুরোধ যায় app-এ, store-এ নয়, কারণ store জানে না কে কোন চালানের মালিক।
  2. ধাপ ২ — মালিকানা। App PostgreSQL থেকে INV-1042-এর সারি পড়ল: মালিক 17, মিলেছে। User 18 একই অনুরোধ করলে পান 404, 403 নয়, যাতে অন্যের চালান আছে কি না সেটুকুও ফাঁস না হয়।
  3. ধাপ ৩ — ছোট মেয়াদের সই। App ৬০ সেকেন্ডের GET URL বানাল, সাথে attachment আর application/pdf-এর নির্দেশ। উত্তর 302, আর no-store, যাতে কোনো proxy এই redirect জমিয়ে না রাখে।
  4. ধাপ ৪ — Store থেকে ফাইল। Browser redirect মেনে store-এ গেল। সই আর সময় মিলল: 200, content-disposition: attachment; filename="INV-1042.pdf". Byte গেল store থেকে সরাসরি, app দিয়ে নয়।
  5. ধাপ ৫ — মেয়াদ শেষ। URL কেউ copy করে রাখল। পরীক্ষার জন্য ২ সেকেন্ডের URL বানিয়ে ৩ সেকেন্ড পরে খোলা হলো: 403, “Request has expired”. Chat-এ ফাঁস হওয়া লিংকের ক্ষতি তাই সময়ে বাঁধা।
  6. ধাপ ৬ — কারসাজি। URL-এ INV-1042 বদলে INV-1043 করলে SignatureDoesNotMatch. সই ছাড়া সরাসরি key চাইলে 403. অন্যের চালান পাওয়ার একমাত্র পথ app-এর মালিকানা যাচাই।

মেয়াদের হিসাব store করে X-Amz-Date আর X-Amz-Expires থেকে। নিচের ছোট মডেলটি নকল ঘড়িতে সেই হিসাব দেখায়, সাথে ঘড়ি ভুলের একটি ফাঁদ:

// Presigned URL কখন অচল হয়: নকল ঘড়িতে store-এর হিসাব
const url = new URL('https://store.example.test/bazarghar-private/invoices/INV-1042.pdf' +
  '?X-Amz-Date=20261103T101500Z&X-Amz-Expires=300&X-Amz-Signature=0000example');

function parseAmzDate(s) {          // 20261103T101500Z → ms
  const [, y, mo, d, h, mi, se] = /^(\d{4})(\d\d)(\d\d)T(\d\d)(\d\d)(\d\d)Z$/.exec(s);
  return Date.UTC(+y, mo - 1, +d, +h, +mi, +se);
}

function check(u, nowMs) {
  const signedAt = parseAmzDate(u.searchParams.get('X-Amz-Date'));
  const expires = Number(u.searchParams.get('X-Amz-Expires'));
  if (!(expires >= 1 && expires <= 604800)) return 'REJECT bad expiry';   // ৭ দিন সর্বোচ্চ
  const left = Math.round((signedAt + expires * 1000 - nowMs) / 1000);
  return left > 0 ? `OK, ${left}s left` : `403 expired ${-left}s ago`;
}

const t0 = parseAmzDate('20261103T101500Z');
const clock = [0, 60, 299, 300, 301, 3600];          // সই-এর পর কত সেকেন্ড
for (const s of clock) console.log(`t+${String(s).padStart(4)}s`, check(url, t0 + s * 1000));

// app server-এর ঘড়ি ৪ মিনিট পিছিয়ে: সই-এর সময়ই ভুল লেখা হলো
const skewed = new URL(url);
skewed.searchParams.set('X-Amz-Date', '20261103T101100Z');
console.log('app clock -4 min, t+0s:', check(skewed, t0));
skewed.searchParams.set('X-Amz-Expires', '2592000');           // ৩০ দিন চাওয়া
console.log('asked 30 days:', check(skewed, t0));

প্রথম ছয় লাইন: t+ 0s OK, 300s left থেকে t+ 299s OK, 1s left, তারপর t+ 300s 403 expired 0s ago. সীমানার ঠিক সেকেন্ডে কী হবে, তা এই মডেলের সিদ্ধান্ত; আসল store-এ এক সেকেন্ড এদিক-ওদিক হতে পারে। সপ্তম লাইন app clock -4 min, t+0s: OK, 60s left: App server-এর ঘড়ি ৪ মিনিট পিছিয়ে থাকলে ৫ মিনিটের URL ব্যবহারকারীর হাতে পৌঁছেই মাত্র ১ মিনিট বাঁচে। শেষ লাইন REJECT bad expiry: SigV4-এ presigned URL-এর মেয়াদ সর্বোচ্চ ৭ দিন (৬,০৪,৮০০ সেকেন্ড). আমাদের SDK run-এ ৩০ দিন চাইতেই error এলো: “Signature version 4 presigned URLs must have an expiration date less than one week in the future”.

সত্যিকারের store-এ ঘড়ির পরীক্ষাও করলাম। SDK-কে নকল সই-সময় দিয়ে (signingDate) ৩০০ সেকেন্ডের URL: ৩০ মিনিট আগের সময়ে সই করা URL পেল 403 Request has expired, আর ২০ মিনিট ভবিষ্যতের সময়ে সই করা URL আমাদের SeaweedFS-এ 200 পেল। ভবিষ্যতের তারিখ নিয়ে সব store একই আচরণ করে কি না, আমরা যাচাই করিনি। শিক্ষা হলো: server-এ NTP দিয়ে ঘড়ি মিলিয়ে রাখুন, যা clock skew-এর পুরোনো সমস্যা।

৮ · মোছা, মালিকহীন ফাইল আর lifecycle নিয়ম

সরাসরি upload-এ মালিকহীন ফাইল জন্মানোর পথ আরও বেশি। Presign হলো কিন্তু ফাইল আসেনি; ফাইল এলো কিন্তু “complete” আসেনি, কারণ ব্যবহারকারী tab বন্ধ করেছেন; app crash করল সারি লেখার আগে। আমাদের run-এর পরে PostgreSQL-এ চারটি pending সারি ছিল, যাদের ফাইল store আগেই ফিরিয়েছিল। তার সাথে আমরা ইচ্ছা করে একটি upload করে complete ডাকলাম না, আর একটি object রাখলাম যার কোনো সারিই নেই। তারপর pending সারিগুলোকে ২৫ ঘণ্টা পুরোনো করে দিলাম, ঘড়ি এগিয়ে নেওয়ার বদলে। পাহারাদার script আগে শুধু দেখে, তারপর মোছে। সত্যিকারের run:

$ node sweep.mjs
would mark only      incoming/14ab0bf8-0a4d-4064-b4c2-d3d6f817e078.jpg
would mark only      incoming/14d1f372-74ba-42ae-ba74-c7ce9a5a4212.jpg
would mark only      incoming/449c7ccd-8ed2-4d75-87eb-74bb3ae02669.jpg
would mark only      incoming/494dc256-e05e-4ff1-9832-2f674784621e.jpg
would delete object + mark incoming/34145076-d34f-4a3c-b112-fcb06f1254a6.jpg
keep (young) incoming/stray-after-crash.jpg 0.00h
{ dryRun: true, stalePending: 5, objectFound: 1, neverArrived: 4,
  unknownYoung: 1, unknownOld: 0 }
$ node sweep.mjs --delete
… একই তালিকা, "did" দিয়ে …
[ { status: 'abandoned', n: 5 }, { status: 'ready', n: 2 }, { status: 'rejected', n: 1 } ]

পাহারাদার দুই দিক থেকে খোঁজে। Database থেকে: ২৪ ঘণ্টার পুরোনো pending সারি, যার জন্য store-কে জিজ্ঞেস করে ফাইল আছে কি না। Store থেকে: incoming/-এর এমন object, যার কোনো সারি নেই। stray-after-crash.jpg সারিহীন, কিন্তু মাত্র লেখা, তাই বেঁচে গেল; সারি তৈরির আগের মুহূর্তের চলমান upload মুছে ফেলার ঝুঁকি এই ছাড়ের সময় ঠেকায়। এই কাজটি নিয়মিত চালানো একটি cron job, যার নকশা পর্ব ৬২-এ।

Lifecycle নিয়ম — store নিজেই পরিষ্কার করুক

যেখানে পারা যায়, পরিষ্কারের কাজ store-কে দিন। আমরা private bucket-এ দুটি নিয়ম বসিয়ে ফেরত পড়লাম। সত্যিকারের run:

lifecycle: [{"Status":"Enabled","Expiration":{"Days":1},"ID":"incoming-1-day","Filter":{"Prefix":"incoming/"}},
 {"Status":"Enabled","ID":"abort-multipart","Filter":{"Prefix":""},"AbortIncompleteMultipartUpload":{"DaysAfterInitiation":2}}]

প্রথমটি incoming/-এর যেকোনো object এক দিন পরে মোছে। Worker সফল ছবিকে অন্য bucket-এ সরিয়ে নেয়, তাই incoming/-এ যা পড়ে থাকে তা সংজ্ঞা অনুযায়ীই আবর্জনা। দ্বিতীয়টি বড় ফাইলের অসম্পূর্ণ S3 multipart upload-এর টুকরো মোছে, যা তালিকায় সাধারণত দেখা যায় না কিন্তু জায়গা নেয়। একটি সতর্কতা: নিয়মটি store গ্রহণ করেছে আর ফেরত দিয়েছে, কিন্তু এক দিন অপেক্ষা করে মুছে যাওয়া আমরা নিজে দেখিনি। প্রতিটি provider-এ lifecycle-এর কোন অংশ সত্যিই চলে, আর কত দেরিতে চলে, তা তাদের docs-এ দেখে নিন; database-এর পাহারাদার তাই থাকছেই।

মোছার ক্রম পর্ব ৩০-এর মতোই: আগে database সারি, পরে object. ভাঙলে বাড়তি object থাকে, যা পাহারাদার ধরবে; ভাঙা লিংক থাকে না। পণ্য মুছলে তার দশটি variant-ও মোছার job হবে। Versioning চালু থাকা bucket-এ “মোছা” মানে একটি delete marker বসানো, পুরোনো সংস্করণ থেকে যায়; তখন lifecycle-এ পুরোনো সংস্করণ মোছার নিয়মও লাগে। ব্যবহারকারী তার অ্যাকাউন্ট মুছতে বললে backup আর পুরোনো সংস্করণেও কী থাকছে, তা গোপনীয়তার নীতিতে লিখে রাখুন।

Virus বা malware scan: PDF বা ব্যবহারকারীর পাঠানো document রাখলে অনেক দল worker-এ একটি malware scan যোগ করে, যেমন open-source ClamAV-এর মতো কোনো scanner দিয়ে, আর পরিষ্কার না হওয়া পর্যন্ত ফাইল incoming/-এ আটকে রাখে। কিছু provider-এর নিজস্ব ব্যবস্থাও থাকতে পারে। মনে রাখুন, scanner শুধু জানা চিহ্ন ধরে, নতুন বা বিশেষভাবে বানানো ফাইল এড়িয়ে যেতে পারে। তাই এটি একটি বাড়তি স্তর, magic bytes, নতুন করে encode, attachment আর আলাদা origin-এর বিকল্প নয়। তত্ত্ব আছে Cybersecurity কোর্সের malware পর্বে; এই পর্বে আমরা কোনো scanner চালাইনি।

৯ · খরচ, provider আর lock-in এড়ানো

Object storage-এর বিল সাধারণত তিন ভাগে: রাখা (GB-মাস), অনুরোধ (প্রতি হাজার PUT বা GET) আর বাইরে যাওয়া ডেটা, যাকে বলে egress. অনেকে শুধু প্রথমটি দেখেন। একটি পূর্ণ হিসাব করি। এখানে সব দাম অনুমান, কোনো provider-এর আসল দাম নয়: রাখা ২ টাকা প্রতি GB-মাস, egress ১০ টাকা প্রতি GB. অনুরোধের খরচ বাদ।

অনুমানহিসাবফল
৫,০০০ পণ্য, প্রতিটিতে দশ variant (৯৯,৩১৮ byte) + আসল ছবি (৮,৩১,০৩৬ byte)৫,০০০ × ৯,৩০,৩৫৪≈ ৪.৬৫ GB → মাসে ≈ ৯.৩ টাকা
দিনে ২ লাখ কার্ড-দেখা, প্রতিটিতে ৬৪০ AVIF (৮,৫৪১ byte), ৩০ দিন৬০,০০,০০০ × ৮,৫৪১≈ ৫১.২ GB
দিনে ২০ হাজার বিস্তারিত পাতা, ১২৮০ AVIF (১৬,৮৯৫ byte), ৩০ দিন৬,০০,০০০ × ১৬,৮৯৫≈ ১০.১ GB
মোট egress৫১.২ + ১০.১≈ ৬১.৪ GB → মাসে ≈ ৬১৪ টাকা
ভুল নকশা: কার্ডে আসল ৮,৩১,০৩৬ byte-এর ছবি৬০,০০,০০০ × ৮,৩১,০৩৬≈ ৪,৯৮৬ GB → ≈ ৪৯,৮৬২ টাকা

তিনটি শিক্ষা। প্রথমত, এই অনুমানে egress রাখার খরচের প্রায় ৬৬ গুণ। দ্বিতীয়ত, ঠিক মাপের ছবি দেওয়াই সবচেয়ে বড় সঞ্চয়: একই দর্শকে ৬১ GB বনাম প্রায় ৫ TB. তৃতীয়ত, CDN-এর cache hit rate ৯৫% হলে store থেকে বাইরে যায় মাত্র ৫%, প্রায় ৩.১ GB; বাকিটা CDN-এর বিলে, যার দাম আলাদা চুক্তির। আসল সংখ্যার জন্য provider-এর বর্তমান দামের পাতা দেখে নিজের হিসাব বসান; দাম আর বিনামূল্যের সীমা প্রায়ই বদলায়।

S3-সামঞ্জস্যপূর্ণ provider আর lock-in

S3 API এখন কার্যত একটি সাধারণ ভাষা। Amazon S3 নিজে (২০০৬ সালে চালু), Cloudflare R2, Backblaze B2-এর S3-সামঞ্জস্যপূর্ণ API, বড় cloud-গুলোর নিজস্ব object storage, আর নিজে চালানোর সফটওয়্যার, যেমন আমাদের SeaweedFS, সবাই কমবেশি একই SDK মেনে নেয়। কয়েকটি provider egress-এ দাম না নেওয়ার কথা ঘোষণা করেছে, যেমন লেখার সময়ে R2; শর্তগুলো তাদের নিজস্ব পাতায় পড়ে নিন। বাংলাদেশের কিছু ডেটা সেন্টার আর cloud সেবাও object storage দেওয়ার কথা বলে; দেশের ভেতরে latency কম হতে পারে, আর কিছু প্রতিষ্ঠানের ডেটা দেশে রাখার নিয়মও থাকতে পারে। কেনার আগে তাদের docs-এ যাচাই করুন কোন S3 অপারেশন সত্যিই চলে: presigned POST, bucket CORS, lifecycle, versioning. আমরা এখানে কোনো provider সুপারিশ করছি না।

  • মানসম্মত API-তে থাকুন: SDK-এর সাধারণ অপারেশন, endpoint config দিয়ে বদলানো যায় এমন। Provider-এর বিশেষ feature ব্যবহার করলে জেনে-বুঝে, এক জায়গায় আবদ্ধ রাখুন।
  • এক জায়গায় storage-এর কোড: storage.putVariant(), storage.signInvoice()-এর মতো ছোট module, যাতে বদলাতে হলে একটি ফাইল বদলায়। পর্ব ২৯-এর service স্তরের ধারণা।
  • Key আর URL আপনার: Database-এ রাখুন key, পুরো provider URL নয়। CDN-এর domain নিজের রাখুন, যেমন img.bazarghar.example, যাতে পেছনের store বদলালেও HTML-এর লিংক ভাঙে না।
  • সরে যাওয়ার খরচ মাপুন: ৫ TB সরাতে পুরোনো provider-এর egress লাগে। আগেই হিসাব করে রাখলে চুক্তির সময় দর কষাকষি সহজ।
  • নিজে চালানোর ঝুঁকি: MinIO-র archive হওয়া দেখায়, open-source প্রকল্পেরও দিক বদলায়। নিজে চালালে update, backup আর বিকল্পের পরিকল্পনা আপনার দায়িত্ব। চালানোর দিক Cloud & DevOps কোর্সে.

আরও গভীরে: AWS ২০২০ সালের ডিসেম্বরে ঘোষণা করে যে S3-এ লেখার পরপরই পড়া এখন শক্তিশালী ধারাবাহিক (strong read-after-write consistency); তার আগে কিছু ক্ষেত্রে সদ্য লেখা object তালিকায় দেরিতে আসত। প্রতিটি S3-সামঞ্জস্যপূর্ণ store-এর ধারাবাহিকতার নিশ্চয়তা আলাদা, তাই তাদের docs দেখুন। SigV4-এর signing key তারিখ, region আর service দিয়ে আলাদা করা, যাতে একটি দিনের বা region-এর key ফাঁস হলেও মূল চাবি নিরাপদ থাকে। আর বড় ফাইলের multipart upload-এ ETag প্রায়ই টুকরোগুলোর hash থেকে বানানো হয়, তাই পূর্ণ ফাইল যাচাইয়ে আলাদা checksum লাগে।

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

  • Upload কেন app server দিয়ে না পাঠিয়ে presigned URL-এ? App-এর memory, সংযোগ আর bandwidth বাঁচে, আর একাধিক server-এ ফাইল ভাগের সমস্যা থাকে না; বিনিময়ে যাচাই পরে করতে হয়।
  • Presigned PUT আর POST-এর পার্থক্য? POST-এর policy-তে আকারের পরিসর আর অন্য শর্ত বসানো যায়; PUT-এ সাধারণত শুধু সই করা header.
  • Bucket CORS কি নিরাপত্তা দেয়? না, এটি browser-কে উত্তর পড়ার অনুমতি দেয়; লেখার অনুমতি আসে সই থেকে।
  • এক বছরের cache কখন নিরাপদ? যখন ফাইলের নাম তার বিষয়বস্তুর সাথে বদলায়, যেমন content-hash key.
  • Object storage-এর বিলে প্রায়ই কোনটি বড়? Egress, বিশেষ করে বড় বা ভুল মাপের ছবি দিলে।

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

S3-এর কোড ইন্টারনেটে বিপুল, আর তার বড় অংশ পুরোনো SDK v2-এর। তাই AI দ্রুত কোড দেয়, কিন্তু প্রায়ই পুরোনো API, খোলা bucket আর লম্বা মেয়াদ নিয়ে।

AI প্রায়ই যা লেখেসমস্যাযাচাইয়ের উপায়
require('aws-sdk'), s3.getSignedUrl('putObject', …)SDK v2, npm-এ end-of-supportnpm view aws-sdk deprecated
ACL: 'public-read' সব upload-এযাচাইয়ের আগেই সবার জন্য খোলাসই ছাড়া incoming/-এর key খুলে দেখুন
expiresIn: 604800৭ দিনের চাবি, ফাঁস হলে দীর্ঘ ক্ষতিকাজের জন্য কত সেকেন্ড লাগে, মেপে বসান
Presign-এর কোড client component-এSecret browser bundle-এBuild করা JS-এ key খুঁজুন
AllowedOrigins: ['*']সব সাইট উত্তর পড়তে পারেঅন্য origin থেকে preflight পাঠান
Client-এর “done” পেয়েই সারি readyআকার, ধরন বা আদৌ ফাইল আছে কি না, অজানাফাইল না পাঠিয়ে complete ডাকুন
Checksum বা অজানা option-এর কথা নেইBrowser upload-এ BadDigestনিজের store-এ সত্যিকারের upload চালান

AI-কে পুরো ব্যবস্থার শর্ত আর ব্যর্থ হওয়ার পরীক্ষা একসাথে দিন:

আমি Express 5 আর Node 24-এ পণ্যের ছবির সরাসরি upload বানাচ্ছি, S3-সামঞ্জস্যপূর্ণ store-এ। শুধু @aws-sdk/client-s3 v3 আর @aws-sdk/s3-presigned-post ব্যবহার করবে; aws-sdk v2 নয়। শুরুতে আমাকে npm-এ বর্তমান version যাচাই করতে বলো, নিজে অনুমান করো না। শর্ত: ১. presign route-এ login, ধরন allowlist (jpeg, png, webp), আকার সর্বোচ্চ 5 MiB, key server বানাবে, মেয়াদ 300 সেকেন্ড. ২. presigned POST, policy-তে content-length-range আর Content-Type eq. ৩. complete route: মালিকানা যাচাই, HeadObject দিয়ে আকার, প্রথম 16 byte দিয়ে magic bytes, PostgreSQL-এ একই transaction-এ সারি আর job, দ্বিতীয় ডাকে একই ফল. ৪. bucket CORS শুধু আমার origin-এর জন্য. ৫. কোনো secret client-এ যাবে না, ACL public-read নয়. নতুন package বা option যোগ করলে docs-এর কোন অংশে আছে বলো। শেষে পরীক্ষার তালিকা দাও: বড় ফাইল, ভুল ধরন, অন্য origin, মেয়াদ শেষ, complete দুবার — প্রতিটিতে কোন status আশা করব।

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

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

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

প্র ০১একটি কোম্পানির API কীভাবে গোটা শিল্পের “মানদণ্ড” হয়ে গেল? S3-সামঞ্জস্য মানে কি পুরো মিল?

Amazon S3 চালু হয় ২০০৬ সালে, প্রথম দিকের cloud সেবাগুলোর একটি হিসেবে। এর API ছিল সরল: HTTP-র PUT, GET, DELETE আর একটি bucket-key নাম। কোনো আন্তর্জাতিক মানসংস্থা এটি মানদণ্ড ঘোষণা করেনি। তবু বছরের পর বছর লাখো অ্যাপ, backup টুল আর library এই API-তে লেখা হলো। নতুন storage যে-ই বানাক, গ্রাহক টানতে হলে তাকে বলতে হলো “আপনার পুরোনো কোড এখানেও চলবে”. এভাবে একটি পণ্যের API কার্যত (de facto) মানদণ্ড হয়ে দাঁড়াল, যেমনটি অনেক সময় প্রযুক্তিতে ঘটে।

কিন্তু “সামঞ্জস্যপূর্ণ” শব্দটি একটি পরিসর, নিশ্চয়তা নয়। মূল অপারেশন, যেমন PutObject, GetObject, ListObjectsV2 আর presigned URL, প্রায় সবখানে চলে। কিন্তু POST policy, bucket CORS, lifecycle-এর সব ধরনের নিয়ম, versioning, object lock, event notification, এসব জায়গায় পার্থক্য পাওয়া যায়। আমাদের নিজের run-এই একটি সূক্ষ্ম পার্থক্য ধরা পড়ল: SDK-এর default checksum আচরণ আমাদের store-এ browser upload ভেঙে দিচ্ছিল। আবার AWS নিজেও API বদলায়; SDK v3-এর নতুন checksum ব্যবস্থাও এমন একটি বদল, যার পরে কিছু তৃতীয় পক্ষের store-এ সমস্যার খবর পাওয়া গিয়েছিল।

এখান থেকে দুটি ব্যবহারিক নিয়ম আসে। প্রথমত, provider বাছাইয়ের সময় তাদের docs-এ “কোন অপারেশন সমর্থিত” তালিকাটি পড়ুন, বিজ্ঞাপনের শিরোনাম নয়। দ্বিতীয়ত, CI-তে একটি ছোট integration test রাখুন যা আসল বা local store-এ upload, presign, CORS preflight আর delete চালায়। SDK update বা provider বদলালে সেই test-ই প্রথম জানাবে। নিজে চালানো open-source store-এর ক্ষেত্রেও একই সতর্কতা, বরং বেশি: প্রকল্পের দিক বদলাতে পারে, যেমন লেখার সময়ে MinIO-র open-source server-এর ডাউনলোড পাতা জানাচ্ছে যে প্রকল্পটি archive করা।

মূল উপলব্ধি: জনপ্রিয় API মানে কোড সরানো সম্ভব, সহজ নয়। আপনি যে অংশটুকু ব্যবহার করেন, সেটির মিল নিজে পরীক্ষা করে নিশ্চিত হন।

প্র ০২Presigned URL কি আসলে একটি “bearer token”? লিংক ফাঁস হলে কী হয়, আর কীভাবে ক্ষতি ছোট রাখবেন?

হ্যাঁ। যার হাতে URL, সে-ই কাজটি করতে পারে, পরিচয় দেখানোর দরকার নেই। এটি পর্ব ৪৬-এর bearer token-এর মতোই। পার্থক্য হলো এর ক্ষমতা খুব সরু: একটি key, একটি method, কয়েকটি শর্ত, আর সীমিত সময়। আমাদের run-এ key-এর একটি অক্ষর বদলালেই SignatureDoesNotMatch, আর মেয়াদ পেরোলে Request has expired.

ফাঁসের পথগুলো অপ্রত্যাশিত জায়গায় থাকে। App-এর access log পুরো URL লিখে রাখতে পারে, সাথে query-র সই। ব্যবহারকারী লিংকটি chat-এ পাঠাতে পারেন। Browser-এর history-তে থাকে। কোনো পাতা থেকে বাইরের লিংকে গেলে Referer header-এ URL যেতে পারে, যদিও আধুনিক browser-এর default নীতি সাধারণত শুধু origin পাঠায়। আর একবার দেওয়া URL “ফেরত নেওয়ার” সহজ উপায় সাধারণত নেই; সই করা চাবিটি বাতিল বা বদলানোই বড় পদক্ষেপ, যা সেই চাবির সব URL একসাথে অচল করে।

তাই ক্ষতি ছোট রাখার কৌশল কয়েক স্তরে। মেয়াদ কাজের মাপে: চালান ডাউনলোডে ৬০ সেকেন্ড যথেষ্ট, কারণ app সাথে সাথে redirect করে। App-এর redirect-এ no-store, আর log-এ query string না রাখা। সই করার জন্য আলাদা, কম ক্ষমতার চাবি, যা শুধু নির্দিষ্ট bucket-এ কাজ করে; ফাঁস হলে সেটি ঘুরিয়ে দেওয়া সহজ। আর প্রতিবার ডাউনলোডের আগে app-এ মালিকানা যাচাই, যাতে URL শুধু সেই মুহূর্তের জন্য তৈরি হয়। কিছু CDN নিজস্ব signed URL বা signed cookie দেয়, যেখানে নিয়ম আরও নমনীয় হতে পারে; সেগুলো provider-ভেদে আলাদা।

মূল উপলব্ধি: সই করা লিংক একটি ছোট, মেয়াদি চাবি। প্রশ্নটি “ফাঁস হবে কি না” নয়, “ফাঁস হলে কতটুকু আর কতক্ষণ”, আর উত্তরটি নকশা থেকেই ঠিক করতে হয়।

প্র ০৩ছবির সব মাপ আগেই বানিয়ে রাখা ভালো, নাকি অনুরোধ এলে তখন বানানো? Next.js-এর optimizer কোন পথ নেয়?

দুটি পথই প্রচলিত। আগেভাগে বানানো (eager), যেমন আমাদের worker: upload-এর পরে একবার পাঁচ মাপ × দুই ফরম্যাট, তারপর CDN থেকে স্থির ফাইল। সুবিধা হলো পরিবেশনের সময় কোনো CPU খরচ নেই, আর ফলাফল আগেই দেখা যায়। অসুবিধা হলো নতুন মাপ লাগলে, যেমন নতুন নকশায় ৭২০px, সব পুরোনো পণ্যের জন্য আবার job চালাতে হয়, আর যে মাপ কেউ কখনো চায় না তারও জায়গা লাগে। আমাদের srcset-এর হিসাবেই দেখা গেল, তালিকার কার্ডে ৯৬০ আর ১২৮০ কখনো লাগেনি।

চাহিদামতো বানানো (on-demand): URL-এ প্রস্থ আর মান লেখা থাকে, প্রথম অনুরোধে একটি service ছবি বানায়, তারপর cache করে। Next.js-এর built-in image optimizer এই ধরনের, আর আলাদা image proxy-ও আছে। সুবিধা নমনীয়তা। ঝুঁকিও আছে: কেউ যদি হাজার রকম প্রস্থ আর মান চেয়ে অনুরোধ পাঠায়, প্রতিটি নতুন cache entry আর CPU খরচ, যা একধরনের DoS. এজন্যই Next.js প্রস্থকে deviceSizes আর imageSizes-এর তালিকায় বেঁধে রাখে, remote ছবির উৎস remotePatterns-এ allowlist করতে বলে, আর Next.js 16-এর docs অনুযায়ী qualities-এর default শুধু [75], এই কারণ দেখিয়ে যে সীমাহীন অনুমতি অপব্যবহার হতে পারে।

মাঝামাঝি পথও সাধারণ: সবচেয়ে বেশি লাগা দুই-তিনটি মাপ আগে বানানো, বাকিগুলো চাহিদামতো। সিদ্ধান্তের উপাদান হলো পণ্যের সংখ্যা, নকশা কত ঘন ঘন বদলায়, CDN-এর hit rate আর আপনার কাছে কোন পরিকাঠামো আছে। Shared hosting বা static export-এ Next.js-এর optimizer চলে না, তখন eager বা বাইরের service-ই ভরসা। আর যে পথই নিন, প্রস্থের একটি সীমিত তালিকা রাখুন, যা আমাদের loader আর config দুই জায়গায় মেলানো।

মূল উপলব্ধি: “আগে বানাও” CPU-কে সময়ের আগে সরায়, “চাহিদামতো” জায়গা আর পরিশ্রম বাঁচায় কিন্তু সীমা চায়। দুটিতেই তালিকাভুক্ত মাপই নিরাপত্তা।

প্র ০৪ফাইল store-এ, হিসাব database-এ। দুটিকে একটি transaction-এ বাঁধা যায় না কেন, আর তাহলে নির্ভরযোগ্যতা আসে কোথা থেকে?

PostgreSQL-এর transaction কাজ করে তার নিজের ডেটার ওপর। Object store একটি আলাদা ব্যবস্থা, HTTP দিয়ে কথা বলে, নিজের নিয়মে লেখে। দুই ব্যবস্থাকে একসাথে commit বা rollback করার পুরোনো কৌশল আছে, যেমন two-phase commit, কিন্তু S3-ধাঁচের API তাতে অংশ নেয় না, আর এমন সমন্বয় জটিল ও ধীর। তাই ধরে নিতে হয়, মাঝখানে যেকোনো জায়গায় ভাঙতে পারে, আর নকশাকে সেই অনুযায়ী সহনশীল করতে হয়।

আমাদের নকশা চারটি কৌশলে এটি করে। প্রথমত, অবস্থার একটি স্পষ্ট যন্ত্র: pending, uploaded, ready, rejected, abandoned. যেকোনো মুহূর্তে সারি দেখে বলা যায় কী বাকি। দ্বিতীয়ত, database-এর ভেতরে যা একসাথে হওয়া দরকার, তা এক transaction-এ: সারির অবস্থা আর job. এটিই পর্ব ৪৩-এর outbox ধারণা, যেখানে বাইরের কাজটি পরে একটি worker করে। তৃতীয়ত, প্রতিটি ধাপ idempotent: complete দুবার এলে একই ফল, একই ছবি দুবার প্রক্রিয়া হলে content-hash key-তে একই byte, delete দুবার হলেও 204. ফলে retry নিরাপদ।

চতুর্থত, শেষে মিলিয়ে দেখা (reconciliation): পাহারাদার database থেকে পুরোনো pending খোঁজে আর store থেকে সারিহীন object খোঁজে। এর সাথে lifecycle নিয়ম incoming/ পরিষ্কার রাখে। এই ধরনটিকে বলা হয় eventual consistency: মুহূর্তে মুহূর্তে দুই জায়গা না মিললেও, নিয়মিত কাজে শেষ পর্যন্ত মিলে যায়। ক্রমের নিয়মও গুরুত্বপূর্ণ: তৈরিতে আগে byte, পরে সারির চূড়ান্ত অবস্থা; মোছায় আগে সারি, পরে byte. ভাঙলে সবসময় “বাড়তি ফাইল” থাকে, “ভাঙা লিংক” নয়, আর বাড়তি ফাইল খুঁজে মোছা সহজ।

মূল উপলব্ধি: দুটি ব্যবস্থাকে এক করে বাঁধার বদলে ব্যর্থতাকে নিরাপদ দিকে ঠেলে দিন, প্রতিটি ধাপ পুনরাবৃত্তিযোগ্য রাখুন, আর নিয়মিত মিলিয়ে দেখুন।

প্র ০৫বাংলাদেশের একটি ছোট ই-কমার্স storage বাছাইয়ে কোন প্রশ্নগুলো করা উচিত? দাম ছাড়া আর কী দেখা দরকার?

প্রথম প্রশ্ন ডেটার ধরন আর পরিমাণ। “বাজারঘর”-এর হিসাবে ৫,০০০ পণ্যে মাত্র কয়েক GB, কিন্তু মাসিক egress তার চেয়ে অনেক বেশি। তাই “GB প্রতি রাখার দাম” তুলনা প্রায় অর্থহীন; দেখতে হবে egress, অনুরোধের দাম, আর CDN-এর সাথে বাইরে যাওয়ার খরচ কীভাবে হিসাব হয়। কিছু provider egress বিনামূল্যে দেওয়ার কথা বলে, কিছু CDN-এর সাথে বিশেষ শর্ত রাখে। এসব শর্ত বদলায়, তাই নিজের অনুমিত ট্রাফিক বসিয়ে বর্তমান দামের পাতা থেকে হিসাব করুন, আর ধারাবাহিক বিলের ওপর চোখ রাখুন।

দ্বিতীয় প্রশ্ন ব্যবহারকারী কোথায়। দর্শকের বেশিরভাগ বাংলাদেশে হলে দূরের region থেকে প্রথম byte আসতে দেরি হতে পারে; CDN এটি অনেকটা লুকায়, কিন্তু upload সরাসরি store-এ যায়, তাই upload-এর গতিতে store-এর অবস্থান বোঝা যায়। দেশের কিছু ডেটা সেন্টার বা cloud সেবা object storage দেওয়ার কথা বলে; latency, স্থানীয় মুদ্রায় বিল আর দেশে ডেটা রাখার প্রয়োজন থাকলে সেগুলো বিবেচনার যোগ্য। কিন্তু তাদের S3 সামঞ্জস্যের পরিধি, SLA, backup আর সমস্যা হলে সহায়তা কেমন, তা লিখিতভাবে জেনে নিন।

তৃতীয় প্রশ্ন সরে যাওয়ার পথ। আজ যা সস্তা, কাল তা না-ও থাকতে পারে, বা প্রতিষ্ঠান নিজেই নীতি বদলাতে পারে। তাই নকশাতেই বাইরে যাওয়ার দরজা রাখুন: মানসম্মত SDK অপারেশন, endpoint config-এ, database-এ শুধু key, নিজের CDN domain, আর storage-এর কোড এক module-এ। নিয়মিত একটি ছোট অংশ অন্য store-এ কপি করে দেখুন, যা backup-ও বটে। চতুর্থত, আইনি আর গোপনীয়তার দিক: চালানে ব্যক্তিগত তথ্য থাকে, তাই কে দেখতে পারে, কতদিন রাখা হয়, মুছতে বললে backup-এ কী হয়, এসবের নীতি লিখে রাখুন।

মূল উপলব্ধি: Storage বাছাই একটি দাম-তালিকা নয়, একটি চুক্তি: খরচের ধরন, দূরত্ব, সামঞ্জস্য, সহায়তা আর বেরিয়ে আসার পথ, সব মিলিয়ে সিদ্ধান্ত।

অনুশীলন

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

  1. হিসাব: একটি দোকানে ২০,০০০ পণ্য। প্রতিটির দশ variant মিলে ১,০০,০০০ byte, আসল ছবি রাখা হয় না। প্রতি মাসে ১ কোটি কার্ড-দেখা, প্রতিটিতে ১০,০০০ byte. অনুমিত দাম: রাখা ২ টাকা/GB-মাস, egress ১০ টাকা/GB, ১ GB = ১০৯ byte. (ক) মাসিক রাখার খরচ? (খ) CDN ছাড়া store-এর egress খরচ? (গ) CDN-এর hit rate ৯০% হলে store-এর egress খরচ?

    (ক) ২০,০০০ × ১,০০,০০০ = ২০০ কোটি byte = ২ GB → ২ × ২ = ৪ টাকা।

    (খ) ১ কোটি × ১০,০০০ = ১০,০০০ কোটি byte = ১০০ GB → ১০০ × ১০ = ১,০০০ টাকা।

    (গ) ৯০% CDN থেকে, তাই store থেকে যায় ১০%: ১০ GB → ১০০ টাকা। তবে CDN-এর নিজের বিল আলাদা, তা এই হিসাবের বাইরে। রাখার খরচ এখানে egress-এর তুলনায় নগণ্য, যা অংশ ৯-এর মূল কথা।

  2. কমান্ড: আপনার নিজের local S3-সামঞ্জস্যপূর্ণ store-এ bucket CORS বসানোর পরে curl দিয়ে একটি অনুমোদিত আর একটি অননুমোদিত origin থেকে PUT-এর preflight পাঠান। কোন header দেখে বুঝবেন অনুমতি মিলেছে?

    curl -i -X OPTIONS "http://127.0.0.1:47904/bucket/incoming/x.jpg" -H "Origin: http://127.0.0.1:47910" -H "Access-Control-Request-Method: PUT" -H "Access-Control-Request-Headers: content-type". অনুমোদিত origin-এ উত্তরে আসবে Access-Control-Allow-Origin ঠিক সেই origin, Access-Control-Allow-Methods-এ PUT, আর Allow-Headers-এ content-type. আমাদের run-এ অননুমোদিত origin পেয়েছিল 403 আর কোনো Allow-Origin নেই। মনে রাখবেন, POST form-এর মতো simple request-এ browser preflight পাঠায়ই না, তাই এই পরীক্ষা সেখানে কিছু প্রমাণ করে না।

  3. নকশা সমালোচনা: একটি দল এই নকশা দিল: “Browser নিজে products/17.jpg key বেছে নেয়। Server ৭ দিনের presigned PUT দেয়, bucket-এ public-read. Upload শেষে browser একটি ready flag পাঠায়, server সরাসরি পণ্যের পাতায় ছবিটি দেখায়। CDN-এ cache এক বছর।” অন্তত পাঁচটি সমস্যা বের করুন।
    • Client key বাছে: অন্য পণ্যের key-তে লিখে ছবি বদলে দেওয়া যায়; key server বানাবে।
    • ৭ দিনের মেয়াদ: ফাঁস হলে সপ্তাহজুড়ে যে কেউ লিখতে পারে; কয়েক মিনিট যথেষ্ট।
    • PUT, আকারের শর্ত নেই: বিশাল ফাইল পাঠানো যায়; POST policy-র content-length-range নিন।
    • যাচাইয়ের আগেই public: .jpg নামে যেকোনো কিছু সবার সামনে; incoming/ private রাখুন।
    • Browser-এর ready বিশ্বাস: HeadObject, magic bytes, মালিকানা কিছুই দেখা হয়নি।
    • একই key + এক বছরের cache: ছবি বদলালে পুরোনোটাই দেখাবে; content-hash key নিন।
    • Variant নেই: ফোনে পুরো আকারের ছবি, egress আর গতি দুটোরই ক্ষতি।
  4. ফল অনুমান: একটি presigned POST-এর policy: ["content-length-range", 1, 5242880] আর ["eq", "$Content-Type", "image/jpeg"], মেয়াদ ৩০০ সেকেন্ড। তিনটি চেষ্টা: (ক) ৮,৩১,০৩৬ byte JPEG, ২০ সেকেন্ড পরে; (খ) একই ফাইল, form-এ Content-Type বদলে image/png; (গ) ৬ MiB ফাইল, ১০ সেকেন্ড পরে। প্রতিটিতে store কী ফেরত দেবে, আর (ক)-এর পরে uploads সারির অবস্থা কী হবে?

    (ক) সব শর্ত মেলে, মেয়াদের ভেতরে: 204 (আমাদের run-এর মতো). কিন্তু সারি তখনো pending; complete ডাকার পরেই uploaded, আর worker শেষ করলে ready. (খ) eq শর্ত ভাঙে: আমাদের store-এ 403 AccessDenied, “Invalid according to Policy: Policy Condition failed”. (গ) ৬২,৯১,৪৫৬ > ৫২,৪২,৮৮০: 400 EntityTooLarge. (খ) আর (গ)-এর সারি pending থেকে যায়, যা পাহারাদার ২৪ ঘণ্টা পরে abandoned করবে। ভিন্ন store-এ error code-এর লেখা আলাদা হতে পারে, কিন্তু প্রত্যাখ্যান হবেই।

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

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

ক্রমBrowser থেকে object storage-এ একটি পণ্যের ছবি পৌঁছে CDN-এ দেখানো পর্যন্ত ধাপগুলো সাজান (▲▼ চেপে সরান)

  1. Browser ধরন আর আকার জানিয়ে server-এর কাছে অনুমতি চায়
  2. Server যাচাই করে, key বানায়, pending সারি লেখে আর presigned POST দেয়
  3. Browser ফাইলটি সরাসরি store-এর private অংশে পাঠায়
  4. Server store থেকে আকার আর magic bytes মিলিয়ে সারি ও job এক transaction-এ লেখে
  5. Worker variant বানিয়ে content-hash key-তে media bucket-এ রাখে
  6. CDN দীর্ঘ cache header-সহ variant ব্যবহারকারীকে দেয়

একটি S3-সামঞ্জস্যপূর্ণ bucket-এ চারটি key আছে: products/a2/1.webp, products/a2/2.webp, products/d4/3.webp, incoming/x.jpg. Prefix: 'products/' আর Delimiter: '/' দিয়ে ListObjectsV2 করলে কী আসে?

  • চারটি key-ই আলাদা করে
  • শুধু incoming/ একটি CommonPrefix হিসেবে
  • CommonPrefixes: products/a2/ আর products/d4/; কোনো সরাসরি object নয়
  • Error, কারণ products নামে folder তৈরি করা হয়নি

Browser থেকে সরাসরি upload-এ আকারের ঊর্ধ্বসীমা store-কে দিয়ে প্রয়োগ করাতে চাইলে সবচেয়ে উপযুক্ত পদ্ধতি কোনটি?

  • Presign-এর আগে browser-এর বলা আকার দেখা
  • Presigned POST-এর policy-তে content-length-range শর্ত
  • Bucket-এর CORS-এ MaxAgeSeconds ছোট রাখা
  • Lifecycle নিয়মে এক দিন পরে মুছে ফেলা

Bucket-এর CORS-এ শুধু https://shop.example অনুমোদিত। অন্য origin-এর একটি পাতা বৈধ presigned POST দিয়ে multipart/form-data form পাঠাল, কোনো বাড়তি header নেই। সাধারণত কী ঘটে?

  • Preflight-এ আটকে যায়, অনুরোধ store পর্যন্ত পৌঁছায় না
  • Store CORS দেখে ফাইল প্রত্যাখ্যান করে
  • সই অবৈধ হয়ে যায়, কারণ origin সইয়ের অংশ
  • ফাইল store-এ জমা হয়, কিন্তু পাতার JavaScript উত্তর পড়তে পারে না

পণ্যের ছবি products/cd3b…/640.avif-এর মতো content-hash key-তে রাখা হয়। কোন Cache-Control সবচেয়ে উপযুক্ত, আর কেন?

  • public, max-age=31536000, immutable, কারণ ছবি বদলালে key বদলায়
  • no-store, যাতে সবসময় নতুন ছবি আসে
  • private, max-age=60, যাতে CDN জমা না রাখে
  • no-cache, যাতে প্রতিবার যাচাই হয়

একটি <img>-এ sizes="(max-width: 640px) 50vw, 300px", আর srcset-এ 320w, 480w, 640w, 960w. 360px চওড়া, device pixel ratio ২-এর ফোনে browser সাধারণত কোন ফাইল নেবে?

  • 320w
  • 960w
  • 480w
  • 640w

একটি চালানের PDF private bucket-এ। ক্রেতাকে ডাউনলোড দেওয়ার সবচেয়ে নিরাপদ পথ কোনটি?

  • Bucket-এ public read দিয়ে চালানের key ইমেইলে পাঠানো
  • App-এ মালিকানা যাচাই করে ৬০ সেকেন্ডের presigned GET-এ redirect
  • একবার ৭ দিনের presigned GET বানিয়ে ক্রেতার পাতায় স্থায়ীভাবে দেখানো
  • Browser-কে access key দিয়ে নিজে সই করতে দেওয়া

AWS SDK v3 দিয়ে বানানো presigned PUT URL-এ x-amz-checksum-crc32=AAAAAA== আছে, আর browser থেকে upload BadDigest দিচ্ছে। সম্ভাব্য কারণ আর প্রতিকার কোনটি?

  • URL-এর মেয়াদ শেষ; মেয়াদ বাড়াতে হবে
  • CORS ভুল; AllowedOrigins-এ * বসাতে হবে
  • Content-Type ভুল; header বাদ দিতে হবে
  • SDK খালি body-র checksum বসিয়েছে; client-এ requestChecksumCalculation: 'WHEN_REQUIRED'

শব্দকোষ

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

মনে রাখুন

  1. একাধিক server আর বারবার deploy-এ local disk ভাঙে; object storage হলো bucket আর key-এর ভাণ্ডার, আসল folder নেই।
  2. Presigned POST-এ key, মেয়াদ, আকার আর ধরন সই করা policy-তে বাঁধা; ফাইল app দিয়ে যায় না। CORS শুধু browser-এর পড়ার অনুমতি।
  3. “শেষ” শুনে বিশ্বাস নয়: HeadObject, magic bytes, মালিকানা, আর সারি ও job এক transaction-এ; প্রতিটি ধাপ idempotent.
  4. Worker-এ variant, content-hash key, এক বছরের immutable cache; srcset, sizes আর next/image loader একই প্রস্থের তালিকা মানে।
  5. Private ফাইলে ছোট মেয়াদের GET; মালিকহীন ফাইলের পাহারাদার আর lifecycle; খরচে egress দেখুন, আর lock-in এড়াতে key ও domain নিজের রাখুন।

পরের পর্বে: ফাইলের পরে টাকা। পর্ব ৬৫-এ payment gateway-র পুরো flow: checkout, redirect, webhook-এ নিশ্চিত করা, আর একই পেমেন্ট দুবার না ধরার idempotency.

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

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