openvibers/openvibe.deals Commits
Deals: per-address limits on pages, sitemaps and feeds; search is bounded (#25)
Changes
@@@ -16,6 +16,10 @@limit_req_zone $binary_remote_addr zone=ovdeals_api:10m rate=10r/s;limit_req_zone $binary_remote_addr zone=ovdeals_auth:10m rate=10r/m;limit_req_zone $binary_remote_addr zone=ovdeals_write:10m rate=5r/s;++## Public reads: pages and the index artifacts (sitemaps, llms-full.txt, feeds). The artifacts build a++## view per indexable offer (up to 50,000), so they get their own, far slower budget.++limit_req_zone $binary_remote_addr zone=ovdeals_pages:10m rate=10r/s;++limit_req_zone $binary_remote_addr zone=ovdeals_index:10m rate=30r/m;server {listen 80;@@@ -86,8 +90,9 @@ server {proxy_pass http://127.0.0.1:4840;}-- # ── OpenVibe.Events deliveries come from the host (127.0.0.1:4840/internal/events), never from the internet ──-- location /internal/ { return 404; }++ # ── OpenVibe.Events deliveries come from the host (127.0.0.1:4840/internal/events), never from the internet.++ # Match case-insensitively: Express routes are case-insensitive, so /Internal/events would reach the app. ──++ location ~* ^/internal(/|$) { return 404; }# ── API ─────────────────────────────────────────────────location /api/ {@@@ -95,11 +100,21 @@ server {proxy_pass http://127.0.0.1:4840;}-- # ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:4840/metrics) ──-- location = /metrics { return 404; }++ # ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:4840/metrics). Case-insensitive for++ # the same reason as /internal/: Express would match /Metrics. ──++ location ~* ^/metrics(/|$) { return 404; }++++ # ── Index artifacts: the app builds a view per indexable offer (up to 50,000), so they read slowly ──++ # No proxy_set_header here: the server level already sets them, and setting any one of them inside a++ # location would drop all the inherited ones.++ location ~* ^/(sitemap[^/]*\.xml|sitemaps/|llms-full\.txt|feed\.) {++ limit_req zone=ovdeals_index burst=10 nodelay;++ proxy_pass http://127.0.0.1:4840;++ }# ── Pages, feeds, sitemaps, robots.txt, llms.txt, static assets ──location / {++ limit_req zone=ovdeals_pages burst=40 nodelay;proxy_pass http://127.0.0.1:4840;proxy_set_header X-Real-IP $remote_addr;proxy_set_header X-Forwarded-For $remote_addr;@@@ -10,6 +10,11 @@*/const { tokens } = require('./values');++/** Search bounds: a search runs a LIKE scan per word, so a caller cannot grow the work without limit. */++const SEARCH_QUERY_MAX = 100; // characters kept from the raw query++const SEARCH_WORDS_MAX = 5; // words matched (every word must match, so extras only narrow); extras ignored++const SEARCH_RESULTS_MAX = 100; // rows per query, whatever the caller asks++function createListings({ store }) {const { db } = store;const LATEST = 'h.id = (SELECT MAX(id) FROM deal_hotness_snapshots WHERE offer_id = o.id)';@@@ -31,14 +36,16 @@ function createListings({ store }) {};async function search(query, { limit = 20, offset = 0, includeExpired = false } = {}) {-- const words = [...new Set(tokens(query))].slice(0, 6);++ const words = [...new Set(tokens(String(query || '').slice(0, SEARCH_QUERY_MAX)))].slice(0, SEARCH_WORDS_MAX);if (!words.length) return { total: 0, rows: [] };++ const take = Math.min(Math.max(1, Math.trunc(limit) || 20), SEARCH_RESULTS_MAX);++ const skip = Math.max(0, Math.trunc(offset) || 0);const where = words.map((_, i) => `(' ' || lower(o.title) || ' ' || lower(COALESCE(o.description, '')) || ' ') LIKE @w${i}`).join(' AND ');const params = Object.fromEntries(words.map((w, i) => [`w${i}`, `%${w.replace(/[%_\\]/g, (c) => `\\${c}`)}%`]));const status = includeExpired ? "o.status <> 'disabled'" : "o.status = 'active'";const base = `FROM deal_offers o WHERE o.merged_into IS NULL AND ${status} AND ${where.replace(/LIKE (@w\d+)/g, "LIKE $1 ESCAPE '\\'")}`;const total = (await db.prepare(`SELECT COUNT(*) AS n ${base}`).get(params)).n;-- const rows = await db.prepare(`SELECT o.* ${base} ORDER BY o.created_at DESC LIMIT @limit OFFSET @offset`).all({ ...params, limit, offset });++ const rows = await db.prepare(`SELECT o.* ${base} ORDER BY o.created_at DESC LIMIT @limit OFFSET @offset`).all({ ...params, limit: take, offset: skip });return { total, rows, words };}@@@ -26,7 +26,8 @@** Never limited: /api/health, /api/ready, /release.json, /metrics, sign-in, the signed Events* deliveries at /internal/events (Sources wake-ups: Events pushes at its own pace, and a 429 would only-- * make it retry and fall behind), and the pages and feeds people read.++ * make it retry and fall behind), and the pages and feeds people read — except /search, which runs a++ * LIKE scan per word and so takes the read defaults like the API.*/const { createActorLimiter, createValkeyLimitStore, defaultActor } = require('openvibe-sdk/limits');@@@ -298,8 +298,8 @@ function createPages(ctx) {// ── search ──────────────────────────────────────────────-- router.get('/search', wrap(async (req, res) => {-- const q = String(req.query.q || '').slice(0, 200);++ router.get('/search', actorLimits.reads('deals.search'), wrap(async (req, res) => {++ const q = String(req.query.q || '').slice(0, 100);const page = pageNumber(req);const r = q ? await listings.search(q, { limit: PER_PAGE, offset: (page - 1) * PER_PAGE }) : { total: 0, rows: [] };const pager = q ? ssr.paginate({ page, perPage: PER_PAGE, total: r.total, href: (p) => `/search?q=${encodeURIComponent(q)}&page=${p}` }) : null;@@@ -34,6 +34,14 @@ const { actor, serviceItself } = require('../server/http/actor-limits');assert.strictEqual((await t.get('/api/v1/offers', { as: ben })).status, 200, 'another person still passes');});++ await check('/search takes the read defaults too: 3 a minute per person, then 429 rate_limited', async () => {++ const cat = t.network.addUser('cat');++ for (let i = 0; i < 3; i++) assert.strictEqual((await t.get('/search?q=drone', { as: cat })).status, 200);++ const r = await t.get('/search?q=drone', { as: cat });++ assert.strictEqual(r.status, 429, r.text);++ assert.ok(r.json().detail.includes('deals.search'), r.json().detail);++ });++await check('a service naming the person counts against that person', async () => {const r = await t.get('/api/v1/watches', { as: svc, headers: { 'X-OV-Subject': ann.subject } });assert.deepStrictEqual([r.status, r.json().code], [429, 'rate_limited']);@@@ -99,6 +99,22 @@ const { boot, check, done, robotsOf, jsonLd, HOUR } = require('./helpers/boot');assert.strictEqual((await t.get('/p/nope')).status, 404);});++ await check('search caps its query: 100 characters and 5 words, extra words ignored', async () => {++ // Five words that all match the deal (title "Mini drone with camera", description "Folding **mini** drone.").++ const five = await t.get(`/search?q=${encodeURIComponent('mini drone with camera folding')}`);++ assert.strictEqual(five.status, 200);++ assert.match(five.text, /Mini drone with camera/);++ // A sixth word that matches nothing is ignored rather than required, so the deal still matches.++ const six = await t.get(`/search?q=${encodeURIComponent('mini drone with camera folding zzznomatch')}`);++ assert.strictEqual(six.status, 200);++ assert.match(six.text, /Mini drone with camera/, 'the 6th word is ignored');++ assert.doesNotMatch(six.text, /No active deals match/);++ // The raw query is truncated to 100 characters before it is echoed or searched: the search box++ // holds exactly 100 characters (the quote right after the 100th proves no more survive there).++ const capped = await t.get(`/search?q=${'a'.repeat(150)}`);++ assert.ok(capped.text.includes(`value="${'a'.repeat(100)}"`), 'the echoed query is capped at 100 characters');++ });++await check('the submitter marks it expired: badge, noindex (expired), deals.offer.expired, out of lists and sitemaps', async () => {const other = await t.get(`/d/${deal.slug}/expire`, { as: bob, form: {} });assert.strictEqual(other.status, 403, 'only the submitter or a moderator');
@@ -16,6 +16,10 @@
@@ -16,6 +16,10 @@
limit_req_zone $binary_remote_addr zone=ovdeals_api:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=ovdeals_api:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=ovdeals_auth:10m rate=10r/m;
limit_req_zone $binary_remote_addr zone=ovdeals_auth:10m rate=10r/m;
limit_req_zone $binary_remote_addr zone=ovdeals_write:10m rate=5r/s;
limit_req_zone $binary_remote_addr zone=ovdeals_write:10m rate=5r/s;
+## Public reads: pages and the index artifacts (sitemaps, llms-full.txt, feeds). The artifacts build a
+## view per indexable offer (up to 50,000), so they get their own, far slower budget.
+limit_req_zone $binary_remote_addr zone=ovdeals_pages:10m rate=10r/s;
+limit_req_zone $binary_remote_addr zone=ovdeals_index:10m rate=30r/m;
server {
server {
listen 80;
listen 80;
@@ -86,8 +90,9 @@ server {
@@ -86,8 +90,9 @@ server {
proxy_pass http://127.0.0.1:4840;
proxy_pass http://127.0.0.1:4840;
}
}
- # ── OpenVibe.Events deliveries come from the host (127.0.0.1:4840/internal/events), never from the internet ──
- location /internal/ { return 404; }
+ # ── OpenVibe.Events deliveries come from the host (127.0.0.1:4840/internal/events), never from the internet.
+ # Match case-insensitively: Express routes are case-insensitive, so /Internal/events would reach the app. ──
+ location ~* ^/internal(/|$) { return 404; }
# ── API ─────────────────────────────────────────────────
# ── API ─────────────────────────────────────────────────
location /api/ {
location /api/ {
@@ -95,11 +100,21 @@ server {
@@ -95,11 +100,21 @@ server {
proxy_pass http://127.0.0.1:4840;
proxy_pass http://127.0.0.1:4840;
}
}
- # ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:4840/metrics) ──
- location = /metrics { return 404; }
+ # ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:4840/metrics). Case-insensitive for
+ # the same reason as /internal/: Express would match /Metrics. ──
+ location ~* ^/metrics(/|$) { return 404; }
+
+ # ── Index artifacts: the app builds a view per indexable offer (up to 50,000), so they read slowly ──
+ # No proxy_set_header here: the server level already sets them, and setting any one of them inside a
+ # location would drop all the inherited ones.
+ location ~* ^/(sitemap[^/]*\.xml|sitemaps/|llms-full\.txt|feed\.) {
+ limit_req zone=ovdeals_index burst=10 nodelay;
+ proxy_pass http://127.0.0.1:4840;
+ }
# ── Pages, feeds, sitemaps, robots.txt, llms.txt, static assets ──
# ── Pages, feeds, sitemaps, robots.txt, llms.txt, static assets ──
location / {
location / {
+ limit_req zone=ovdeals_pages burst=40 nodelay;
proxy_pass http://127.0.0.1:4840;
proxy_pass http://127.0.0.1:4840;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
@@ -10,6 +10,11 @@
@@ -10,6 +10,11 @@
*/
*/
const { tokens } = require('./values');
const { tokens } = require('./values');
+/** Search bounds: a search runs a LIKE scan per word, so a caller cannot grow the work without limit. */
+const SEARCH_QUERY_MAX = 100; // characters kept from the raw query
+const SEARCH_WORDS_MAX = 5; // words matched (every word must match, so extras only narrow); extras ignored
+const SEARCH_RESULTS_MAX = 100; // rows per query, whatever the caller asks
+
function createListings({ store }) {
function createListings({ store }) {
const { db } = store;
const { db } = store;
const LATEST = 'h.id = (SELECT MAX(id) FROM deal_hotness_snapshots WHERE offer_id = o.id)';
const LATEST = 'h.id = (SELECT MAX(id) FROM deal_hotness_snapshots WHERE offer_id = o.id)';
@@ -31,14 +36,16 @@ function createListings({ store }) {
@@ -31,14 +36,16 @@ function createListings({ store }) {
};
};
async function search(query, { limit = 20, offset = 0, includeExpired = false } = {}) {
async function search(query, { limit = 20, offset = 0, includeExpired = false } = {}) {
- const words = [...new Set(tokens(query))].slice(0, 6);
+ const words = [...new Set(tokens(String(query || '').slice(0, SEARCH_QUERY_MAX)))].slice(0, SEARCH_WORDS_MAX);
if (!words.length) return { total: 0, rows: [] };
if (!words.length) return { total: 0, rows: [] };
+ const take = Math.min(Math.max(1, Math.trunc(limit) || 20), SEARCH_RESULTS_MAX);
+ const skip = Math.max(0, Math.trunc(offset) || 0);
const where = words.map((_, i) => `(' ' || lower(o.title) || ' ' || lower(COALESCE(o.description, '')) || ' ') LIKE @w${i}`).join(' AND ');
const where = words.map((_, i) => `(' ' || lower(o.title) || ' ' || lower(COALESCE(o.description, '')) || ' ') LIKE @w${i}`).join(' AND ');
const params = Object.fromEntries(words.map((w, i) => [`w${i}`, `%${w.replace(/[%_\\]/g, (c) => `\\${c}`)}%`]));
const params = Object.fromEntries(words.map((w, i) => [`w${i}`, `%${w.replace(/[%_\\]/g, (c) => `\\${c}`)}%`]));
const status = includeExpired ? "o.status <> 'disabled'" : "o.status = 'active'";
const status = includeExpired ? "o.status <> 'disabled'" : "o.status = 'active'";
const base = `FROM deal_offers o WHERE o.merged_into IS NULL AND ${status} AND ${where.replace(/LIKE (@w\d+)/g, "LIKE $1 ESCAPE '\\'")}`;
const base = `FROM deal_offers o WHERE o.merged_into IS NULL AND ${status} AND ${where.replace(/LIKE (@w\d+)/g, "LIKE $1 ESCAPE '\\'")}`;
const total = (await db.prepare(`SELECT COUNT(*) AS n ${base}`).get(params)).n;
const total = (await db.prepare(`SELECT COUNT(*) AS n ${base}`).get(params)).n;
- const rows = await db.prepare(`SELECT o.* ${base} ORDER BY o.created_at DESC LIMIT @limit OFFSET @offset`).all({ ...params, limit, offset });
+ const rows = await db.prepare(`SELECT o.* ${base} ORDER BY o.created_at DESC LIMIT @limit OFFSET @offset`).all({ ...params, limit: take, offset: skip });
return { total, rows, words };
return { total, rows, words };
}
}
@@ -26,7 +26,8 @@
@@ -26,7 +26,8 @@
*
*
* Never limited: /api/health, /api/ready, /release.json, /metrics, sign-in, the signed Events
* Never limited: /api/health, /api/ready, /release.json, /metrics, sign-in, the signed Events
* deliveries at /internal/events (Sources wake-ups: Events pushes at its own pace, and a 429 would only
* deliveries at /internal/events (Sources wake-ups: Events pushes at its own pace, and a 429 would only
- * make it retry and fall behind), and the pages and feeds people read.
+ * make it retry and fall behind), and the pages and feeds people read — except /search, which runs a
+ * LIKE scan per word and so takes the read defaults like the API.
*/
*/
const { createActorLimiter, createValkeyLimitStore, defaultActor } = require('openvibe-sdk/limits');
const { createActorLimiter, createValkeyLimitStore, defaultActor } = require('openvibe-sdk/limits');
@@ -298,8 +298,8 @@ function createPages(ctx) {
@@ -298,8 +298,8 @@ function createPages(ctx) {
// ── search ──────────────────────────────────────────────
// ── search ──────────────────────────────────────────────
- router.get('/search', wrap(async (req, res) => {
- const q = String(req.query.q || '').slice(0, 200);
+ router.get('/search', actorLimits.reads('deals.search'), wrap(async (req, res) => {
+ const q = String(req.query.q || '').slice(0, 100);
const page = pageNumber(req);
const page = pageNumber(req);
const r = q ? await listings.search(q, { limit: PER_PAGE, offset: (page - 1) * PER_PAGE }) : { total: 0, rows: [] };
const r = q ? await listings.search(q, { limit: PER_PAGE, offset: (page - 1) * PER_PAGE }) : { total: 0, rows: [] };
const pager = q ? ssr.paginate({ page, perPage: PER_PAGE, total: r.total, href: (p) => `/search?q=${encodeURIComponent(q)}&page=${p}` }) : null;
const pager = q ? ssr.paginate({ page, perPage: PER_PAGE, total: r.total, href: (p) => `/search?q=${encodeURIComponent(q)}&page=${p}` }) : null;
@@ -34,6 +34,14 @@ const { actor, serviceItself } = require('../server/http/actor-limits');
@@ -34,6 +34,14 @@ const { actor, serviceItself } = require('../server/http/actor-limits');
assert.strictEqual((await t.get('/api/v1/offers', { as: ben })).status, 200, 'another person still passes');
assert.strictEqual((await t.get('/api/v1/offers', { as: ben })).status, 200, 'another person still passes');
});
});
+ await check('/search takes the read defaults too: 3 a minute per person, then 429 rate_limited', async () => {
+ const cat = t.network.addUser('cat');
+ for (let i = 0; i < 3; i++) assert.strictEqual((await t.get('/search?q=drone', { as: cat })).status, 200);
+ const r = await t.get('/search?q=drone', { as: cat });
+ assert.strictEqual(r.status, 429, r.text);
+ assert.ok(r.json().detail.includes('deals.search'), r.json().detail);
+ });
+
await check('a service naming the person counts against that person', async () => {
await check('a service naming the person counts against that person', async () => {
const r = await t.get('/api/v1/watches', { as: svc, headers: { 'X-OV-Subject': ann.subject } });
const r = await t.get('/api/v1/watches', { as: svc, headers: { 'X-OV-Subject': ann.subject } });
assert.deepStrictEqual([r.status, r.json().code], [429, 'rate_limited']);
assert.deepStrictEqual([r.status, r.json().code], [429, 'rate_limited']);
@@ -99,6 +99,22 @@ const { boot, check, done, robotsOf, jsonLd, HOUR } = require('./helpers/boot');
@@ -99,6 +99,22 @@ const { boot, check, done, robotsOf, jsonLd, HOUR } = require('./helpers/boot');
assert.strictEqual((await t.get('/p/nope')).status, 404);
assert.strictEqual((await t.get('/p/nope')).status, 404);
});
});
+ await check('search caps its query: 100 characters and 5 words, extra words ignored', async () => {
+ // Five words that all match the deal (title "Mini drone with camera", description "Folding **mini** drone.").
+ const five = await t.get(`/search?q=${encodeURIComponent('mini drone with camera folding')}`);
+ assert.strictEqual(five.status, 200);
+ assert.match(five.text, /Mini drone with camera/);
+ // A sixth word that matches nothing is ignored rather than required, so the deal still matches.
+ const six = await t.get(`/search?q=${encodeURIComponent('mini drone with camera folding zzznomatch')}`);
+ assert.strictEqual(six.status, 200);
+ assert.match(six.text, /Mini drone with camera/, 'the 6th word is ignored');
+ assert.doesNotMatch(six.text, /No active deals match/);
+ // The raw query is truncated to 100 characters before it is echoed or searched: the search box
+ // holds exactly 100 characters (the quote right after the 100th proves no more survive there).
+ const capped = await t.get(`/search?q=${'a'.repeat(150)}`);
+ assert.ok(capped.text.includes(`value="${'a'.repeat(100)}"`), 'the echoed query is capped at 100 characters');
+ });
+
await check('the submitter marks it expired: badge, noindex (expired), deals.offer.expired, out of lists and sitemaps', async () => {
await check('the submitter marks it expired: badge, noindex (expired), deals.offer.expired, out of lists and sitemaps', async () => {
const other = await t.get(`/d/${deal.slug}/expire`, { as: bob, form: {} });
const other = await t.get(`/d/${deal.slug}/expire`, { as: bob, form: {} });
assert.strictEqual(other.status, 403, 'only the submitter or a moderator');
assert.strictEqual(other.status, 403, 'only the submitter or a moderator');