diff --git a/index.js b/index.js index d269788..636f62a 100644 --- a/index.js +++ b/index.js @@ -4,28 +4,60 @@ // when someone begins editing and when someone finishes. const db = require('ep_etherpad-lite/node/db/DB').db; const async = require('ep_etherpad-lite/node_modules/async'); +const log4js = require('ep_etherpad-lite/node_modules/log4js'); +const logger = log4js.getLogger('ep_search'); // Settings -- EDIT THESE IN settings.json not here.. // var pluginSettings = settings.ep_search; // var checkFrequency = pluginSettings.checkFrequency || 60000; // 10 seconds +const SEARCH_RATE_LIMIT_WINDOW_MS = 60 * 1000; +const SEARCH_MAX_REQUESTS_PER_WINDOW = 10; +const ipRequestCounts = {}; + +const checkRateLimit = (ip) => { + const now = Date.now(); + if (!ipRequestCounts[ip] || now - ipRequestCounts[ip].windowStart > SEARCH_RATE_LIMIT_WINDOW_MS) { + ipRequestCounts[ip] = {windowStart: now, count: 0}; + } + ipRequestCounts[ip].count++; + return ipRequestCounts[ip].count > SEARCH_MAX_REQUESTS_PER_WINDOW; +}; + +const ipCleanup = setInterval(() => { + const now = Date.now(); + for (const [ip, data] of Object.entries(ipRequestCounts)) { + if (now - data.windowStart > SEARCH_RATE_LIMIT_WINDOW_MS * 2) delete ipRequestCounts[ip]; + } +}, 5 * 60 * 1000); +ipCleanup.unref(); exports.registerRoute = (hookName, args, cb) => { args.app.get('/search', (req, res) => { - const searchString = req.query.query; - const result = {}; + const clientIp = req.ip || req.connection?.remoteAddress || 'unknown'; + if (checkRateLimit(clientIp)) { + logger.warn(`Search rate limit exceeded for ${clientIp}`); + return res.status(429).json({error: 'Rate limit exceeded'}); + } + + const searchString = (req.query.query || '').toLowerCase(); + const result = []; + + if (!searchString) { + return res.json(result); + } db.findKeys('pad:*', '*:*:*', (err, pads) => { // get all pads async.forEachSeries(pads, (pad, callback) => { db.get(pad, (err, padData) => { // get the pad contents - const padText = padData.atext.text || ''; + const padText = padData?.atext?.text || ''; // does searchString exist in aText? - if (padText.toLowerCase().indexOf(searchString.toLowerCase()) !== -1) { - result.pad = pad; + if (padText.toLowerCase().indexOf(searchString) !== -1) { + result.push(pad); } callback(); }); }, (err) => { - res.send(JSON.stringify(result)); + res.json(result); }); }); }); diff --git a/locales/en.json b/locales/en.json new file mode 100644 index 0000000..8111651 --- /dev/null +++ b/locales/en.json @@ -0,0 +1,11 @@ +{ + "ep_search.heading": "Search pads", + "ep_search.help": "Find pads containing the text you enter.", + "ep_search.label": "Search query", + "ep_search.placeholder": "Search all pads", + "ep_search.submit": "Search", + "ep_search.searching": "Searching...", + "ep_search.results": "Matching pads: {{count}}", + "ep_search.noResults": "No matching pads found.", + "ep_search.error": "Search failed. Please try again." +} diff --git a/static/css/search.css b/static/css/search.css new file mode 100644 index 0000000..ff27fb3 --- /dev/null +++ b/static/css/search.css @@ -0,0 +1,79 @@ +.ep-search-panel { + margin: 1rem auto; + max-width: 52rem; + padding: 1rem; + border: 1px solid #d7d7d7; + border-radius: 8px; + background: #fff; + box-shadow: 0 1px 3px rgb(0 0 0 / 8%); +} + +.ep-search-panel__header { + margin-bottom: 0.75rem; +} + +.ep-search-panel__header h2 { + margin: 0 0 0.25rem; + font-size: 1.1rem; +} + +.ep-search-panel__header p, +.ep-search-panel__status { + margin: 0; + color: #555; +} + +.ep-search-panel__form { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.ep-search-panel__controls { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 0.5rem; +} + +.ep-search-panel__controls input { + width: 100%; + padding: 0.55rem 0.7rem; + border: 1px solid #bdbdbd; + border-radius: 6px; + box-sizing: border-box; +} + +.ep-search-panel__controls button { + align-self: flex-start; + padding: 0.55rem 1rem; + border: 1px solid #1863a1; + border-radius: 6px; + background: #1f7ac9; + color: #fff; + cursor: pointer; +} + +.ep-search-panel__controls button[type="submit"] { + position: static !important; + left: auto !important; + top: auto !important; +} + +.ep-search-panel__controls button:disabled { + opacity: 0.65; + cursor: wait; +} + +.ep-search-panel__results { + margin: 0.75rem 0 0; + padding-left: 1.25rem; +} + +.ep-search-panel__results li + li { + margin-top: 0.35rem; +} + +.ep-search-panel__results a { + overflow-wrap: anywhere; +} diff --git a/static/tests/frontend-new/specs/smoke.spec.ts b/static/tests/frontend-new/specs/smoke.spec.ts index 927551a..5ab2194 100644 --- a/static/tests/frontend-new/specs/smoke.spec.ts +++ b/static/tests/frontend-new/specs/smoke.spec.ts @@ -1,13 +1,33 @@ import {expect, test} from '@playwright/test'; -import {getPadBody, goToNewPad} from 'ep_etherpad-lite/tests/frontend-new/helper/padHelper'; test.beforeEach(async ({page}) => { - await goToNewPad(page); + await page.goto('http://localhost:9001/'); }); test.describe('ep_search', () => { - test('pad loads with plugin installed', async ({page}) => { - const padBody = await getPadBody(page); - await expect(padBody).toBeVisible(); + test('index page renders the search panel', async ({page}) => { + await expect(page.locator('#ep-search-panel')).toBeVisible(); + await expect(page.locator('#ep-search-input')).toBeVisible(); + }); + + test('renders search results as a proper list of links', async ({page}) => { + await page.route('**/search/**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(['pad:first', 'pad:second']), + }); + }); + + await expect(page.locator('#ep-search-input')).toBeVisible(); + await page.locator('#ep-search-input').fill('needle'); + await page.locator('#ep-search-submit').click(); + + const items = page.locator('#ep-search-results li'); + await expect(items).toHaveCount(2); + await expect(items.nth(0).locator('a')).toHaveAttribute('href', '/p/first'); + await expect(items.nth(0).locator('a')).toHaveText('first'); + await expect(items.nth(1).locator('a')).toHaveAttribute('href', '/p/second'); + await expect(page.locator('#ep-search-status')).toContainText('Matching pads: 2'); }); }); diff --git a/templates/search.html b/templates/search.html index 30901ac..5ac0665 100644 --- a/templates/search.html +++ b/templates/search.html @@ -1,29 +1,103 @@ - -
- -
- - -
-
- - diff --git a/test/search.test.js b/test/search.test.js new file mode 100644 index 0000000..059e2dd --- /dev/null +++ b/test/search.test.js @@ -0,0 +1,127 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const Module = require('node:module'); +const path = require('node:path'); + +const indexPath = path.join(__dirname, '..', 'index.js'); +const NO_OP = () => {}; + +const loadPlugin = (pads) => { + let findKeysCalls = 0; + const fakeDb = { + findKeys: (key, pattern, callback) => { + findKeysCalls++; + callback(null, Object.keys(pads)); + }, + get: (pad, callback) => callback(null, pads[pad]), + }; + const fakeAsync = { + forEachSeries: (items, iterator, done) => { + let itemIndex = 0; + const next = (err) => { + if (err != null || itemIndex >= items.length) return done(err); + iterator(items[itemIndex++], next); + }; + next(); + }, + }; + + const originalLoad = Module._load; + Module._load = function (request, parent, isMain) { + if (request === 'ep_etherpad-lite/node/db/DB') return {db: fakeDb}; + if (request === 'ep_etherpad-lite/node_modules/async') return fakeAsync; + if (request === 'ep_etherpad-lite/node_modules/log4js') { + return {getLogger: () => ({warn: NO_OP})}; + } + return originalLoad.call(this, request, parent, isMain); + }; + + delete require.cache[indexPath]; + const plugin = require(indexPath); + Module._load = originalLoad; + + return { + findKeysCalls: () => findKeysCalls, + plugin, + }; +}; + +const runSearch = async (plugin, query, ip = '127.0.0.1') => { + let handler; + plugin.registerRoute(null, { + app: { + get: (route, routeHandler) => { + assert.equal(route, '/search'); + handler = routeHandler; + }, + }, + }, NO_OP); + + return new Promise((resolve) => { + const response = { + statusCode: 200, + status(code) { + this.statusCode = code; + return this; + }, + json(body) { + resolve({status: this.statusCode, body}); + }, + }; + handler({query: {query}, ip}, response); + }); +}; + +const expectSearchBody = async (plugin, query, ip) => { + const {status, body} = await runSearch(plugin, query, ip); + assert.equal(status, 200); + return body; +}; + +const searchReturnsEveryMatchingPad = async () => { + const {plugin} = loadPlugin({ + 'pad:first': {atext: {text: 'needle in the first pad'}}, + 'pad:second': {atext: {text: 'Needle in the second pad'}}, + 'pad:third': {atext: {text: 'does not match'}}, + }); + + assert.deepEqual( + await expectSearchBody(plugin, 'needle', 'matching-ip'), + ['pad:first', 'pad:second'], + ); +}; + +const emptySearchesShortCircuit = async () => { + const {findKeysCalls, plugin} = loadPlugin({ + 'pad:first': {atext: {text: 'needle in the first pad'}}, + }); + + assert.deepEqual(await expectSearchBody(plugin, '', 'empty-ip'), []); + assert.equal(findKeysCalls(), 0); +}; + +const rateLimitingReturns429 = async () => { + const {plugin} = loadPlugin({ + 'pad:first': {atext: {text: 'needle in the first pad'}}, + }); + + for (let i = 0; i < 10; i++) { + const {status} = await runSearch(plugin, 'needle', 'rate-limited-ip'); + assert.equal(status, 200); + } + + const {status, body} = await runSearch(plugin, 'needle', 'rate-limited-ip'); + assert.equal(status, 429); + assert.deepEqual(body, {error: 'Rate limit exceeded'}); +}; + +(async () => { + await searchReturnsEveryMatchingPad(); + await emptySearchesShortCircuit(); + await rateLimitingReturns429(); + console.log('search tests passed'); +})().catch((err) => { + console.error(err); + process.exitCode = 1; +});