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 @@
-
-
-
-