Kodax

Pagination و Filtering — REST APIs

الترقيم والفلترة (Pagination & Filtering)

لا تُرجع 100,000 سجل في طلب واحد: بطيء للخادم والشبكة والعميل. الترقيم والفلترة ضروريان — لكنهما أيضاً من أكثر الأماكن عرضة للثغرات.

الترقيم بالإزاحة (Offset)

GET /products?page=3&limit=20   ←   skip = (3 − 1) × 20 = 40
{ "data": [], "pagination": { "page": 3, "limit": 20, "total": 487, "pages": 25, "hasNext": true } }

- بسيط ويدعم القفز لأي صفحة.

- عيبه: بطيء في الصفحات البعيدة جداً، وإذا أُضيفت سجلات أثناء التصفح تتكرر أو تُفقد عناصر.

الترقيم بالمؤشر (Cursor)

GET /posts?limit=20&after=2026-09-30T10:00:00Z_8f3a

"أعطني 20 بعد هذا العنصر" (حسب حقل مرتب وفريد). ثابت وسريع مع البيانات المتغيرة — مثالي للتمرير اللانهائي في الخلاصات. لا يسمح بالقفز لصفحة 17.

الحدود دائماً

function parsePagination(query) {
  const page = Math.max(1, Number.parseInt(query.page, 10) || 1);
  const limit = Math.min(100, Math.max(1, Number.parseInt(query.limit, 10) || 20));
  return { page, limit, skip: (page - 1) * limit };
}
console.log(parsePagination({ page: "2", limit: "1000000" }));   // limit يُقص إلى 100

بدون حد أقصى، limit=1000000 طلب واحد يُسقط الخادم.

⚠️ الفلترة: لا تمرر req.query لقاعدة البيانات

Express يحلل ?price[gte]=10 إلى { price: { gte: "10" } } — وكذلك ?password[$ne]=x إلى { password: { $ne: "x" } }. إذا مررت الكائن إلى Model.find(req.query) أو حوّلته بحيلة "أضف $ للعوامل"، يستطيع المهاجم:

- حقن عوامل MongoDB ($ne، $regex، $where) — NoSQL Injection.

- الفلترة على حقول لا يجب كشفها (passwordHash، resetToken) لاستنتاج قيمها.

الحل: قوائم بيضاء للحقول القابلة للفلترة، وللعوامل، وللترتيب، مع تحويل الأنواع:

const FILTERABLE = { price: "number", category: "string", inStock: "boolean" };
const OPERATORS = { gte: "$gte", lte: "$lte", gt: "$gt", lt: "$lt" };

function buildFilter(query) {
  const filter = {};
  for (const [field, type] of Object.entries(FILTERABLE)) {
    const raw = query[field];
    if (raw === undefined) continue;
    const cast = (v) => (type === "number" ? Number(v) : type === "boolean" ? v === "true" : String(v));
    if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
      for (const [op, value] of Object.entries(raw)) {
        if (!(op in OPERATORS) || typeof value !== "string") continue;   // تجاهل $ne وغيره
        const v = cast(value);
        if (type === "number" && Number.isNaN(v)) continue;
        (filter[field] ??= {})[OPERATORS[op]] = v;
      }
    } else if (typeof raw === "string") {
      filter[field] = cast(raw);
    }
  }
  return filter;
}
console.log(buildFilter({ price: { gte: "10", $ne: "x" }, password: { $ne: "" }, category: "books" }));

ومع Mongoose فعّل mongoose.set("sanitizeFilter", true) كطبقة حماية إضافية، وحدد select للحقول المُعادة.

الترتيب واختيار الحقول

نفس المبدأ: sort و fields من قائمة بيضاء فقط — ترتيب على حقل غير مفهرس قد يكون مكلفاً جداً، وطلب fields=passwordHash يجب ألا ينجح.

تمرين

أضف إلى المثال الكامل فلترة createdAt[gte] بتاريخ (حوّله بـ new Date وتحقق من صلاحيته)، ثم جرّب إرسال ?category[$regex]=.* وتأكد أنه يُتجاهل.

// الترقيم والفلترة والترتيب بقوائم بيضاء — يعمل على مصفوفة في الذاكرة
// (نفس منطق بناء الفلتر يُمرر لـ MongoDB أو يُحوَّل لـ SQL معاملي)

const products = Array.from({ length: 57 }, (_, i) => ({
  id: i + 1,
  name: `منتج ${i + 1}`,
  price: 5 + ((i * 37) % 200),
  category: ["كتب", "إلكترونيات", "ملابس"][i % 3],
  inStock: i % 4 !== 0,
  passwordHash: "لا يجب كشفه أبداً",   // حقل حساس للتوضيح
}));

const FILTERABLE = { price: "number", category: "string", inStock: "boolean" };
const OPERATORS = { gte: (a, b) => a >= b, lte: (a, b) => a <= b, gt: (a, b) => a > b, lt: (a, b) => a < b };
const SORTABLE = new Set(["price", "name", "id"]);
const SELECTABLE = new Set(["id", "name", "price", "category", "inStock"]);

const cast = (type, v) => (type === "number" ? Number(v) : type === "boolean" ? v === "true" : String(v));

function buildPredicates(query) {
  const preds = [];
  for (const [field, type] of Object.entries(FILTERABLE)) {
    const raw = query[field];
    if (raw === undefined) continue;
    if (raw && typeof raw === "object" && !Array.isArray(raw)) {
      for (const [op, value] of Object.entries(raw)) {
        if (!OPERATORS[op] || typeof value !== "string") continue;   // يتجاهل $ne و $regex...
        const v = cast(type, value);
        if (type === "number" && Number.isNaN(v)) continue;
        preds.push((p) => OPERATORS[op](p[field], v));
      }
    } else if (typeof raw === "string") {
      const v = cast(type, raw);
      preds.push((p) => p[field] === v);
    }
  }
  return preds;
}

function listProducts(query) {
  const page = Math.max(1, Number.parseInt(query.page, 10) || 1);
  const limit = Math.min(50, Math.max(1, Number.parseInt(query.limit, 10) || 10));

  let rows = products.filter((p) => buildPredicates(query).every((fn) => fn(p)));

  if (typeof query.sort === "string") {
    const desc = query.sort.startsWith("-");
    const field = query.sort.replace(/^-/, "");
    if (SORTABLE.has(field)) rows.sort((a, b) => (a[field] > b[field] ? 1 : -1) * (desc ? -1 : 1));
  }

  const fields = typeof query.fields === "string" ? query.fields.split(",").filter((f) => SELECTABLE.has(f)) : [...SELECTABLE];
  const total = rows.length;
  const data = rows.slice((page - 1) * limit, page * limit).map((p) => Object.fromEntries(fields.map((f) => [f, p[f]])));

  return { data, pagination: { page, limit, total, pages: Math.ceil(total / limit), hasNext: page * limit < total } };
}

// كما يحللها Express: ?price[gte]=100&category=كتب&sort=-price&fields=name,price&page=1&limit=3
console.log(JSON.stringify(listProducts({ price: { gte: "100" }, category: "كتب", sort: "-price", fields: "name,price", page: "1", limit: "3" }), null, 1));

// محاولات خبيثة: عوامل غير مسموحة، حقل حساس، limit ضخم
const attack = listProducts({ price: { $ne: "0" }, passwordHash: { $ne: "" }, fields: "passwordHash", limit: "1000000" });
console.log("حقول مُعادة:", Object.keys(attack.data[0]), "| limit المطبق:", attack.pagination.limit);
اختبر فهمك