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);اختبر فهمك