Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 38 additions & 6 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
Expand Down
11 changes: 11 additions & 0 deletions locales/en.json
Original file line number Diff line number Diff line change
@@ -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."
}
79 changes: 79 additions & 0 deletions static/css/search.css
Original file line number Diff line number Diff line change
@@ -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;
}
30 changes: 25 additions & 5 deletions static/tests/frontend-new/specs/smoke.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
126 changes: 100 additions & 26 deletions templates/search.html
Original file line number Diff line number Diff line change
@@ -1,29 +1,103 @@
<script src="/static/js/jquery.js"></script>
<br/>
<label for="searchInput"><h2>Search:</h2></label>
<form id="searchForm">
<input id="searchInput" type="text">
<input type="submit">
</form>
<div id="searchResults"></div>

<script type="text/javascript">
$('#searchForm').keyup(function(){
$("#searchResults").html("");
doSearch();
});

function doSearch(){
var searchString = $("#searchInput").val();
$("#searchResults").html("");
$.getJSON('/search/?query='+searchString, function(data) {
// console.log(data);
$.each(data, function(index, value){
// console.log(value);
value = value.replace("pad:", "");
value = encodeURI(value);
$("#searchResults").append("<a href='/p/"+value+"'>"+value+"</a>");
<link rel="stylesheet" type="text/css" href="/static/plugins/ep_search/static/css/search.css">

<section id="ep-search-panel" class="ep-search-panel">
<div class="ep-search-panel__header">
<h2 data-l10n-id="ep_search.heading">Search pads</h2>
<p data-l10n-id="ep_search.help">Find pads containing the text you enter.</p>
</div>

<form id="ep-search-form" class="ep-search-panel__form" role="search">
<label for="ep-search-input" data-l10n-id="ep_search.label">Search query</label>
<div class="ep-search-panel__controls">
<input id="ep-search-input" type="search" autocomplete="off"
data-l10n-id="ep_search.placeholder" data-l10n-attr="placeholder"
placeholder="Search all pads">
<button id="ep-search-submit" type="submit" data-l10n-id="ep_search.submit">Search</button>
</div>
</form>

<p id="ep-search-status" class="ep-search-panel__status" aria-live="polite"></p>
<ul id="ep-search-results" class="ep-search-panel__results"></ul>
</section>

<script>
(() => {
if (window.__epSearchInitialized) return;
window.__epSearchInitialized = true;

const form = document.getElementById('ep-search-form');
const input = document.getElementById('ep-search-input');
const submit = document.getElementById('ep-search-submit');
const status = document.getElementById('ep-search-status');
const results = document.getElementById('ep-search-results');
if (!form || !input || !submit || !status || !results) return;

const t = (key, values = {}) => {
if (window.html10n && typeof window.html10n.get === 'function') {
return window.html10n.get(key, values);
}
return key;
};

const clearResults = () => {
results.replaceChildren();
};

const setStatus = (message) => {
status.textContent = message || '';
status.hidden = !message;
};

const renderResults = (pads) => {
clearResults();
pads.forEach((padKey) => {
const padId = String(padKey).replace(/^pad:/, '');
const item = document.createElement('li');
const link = document.createElement('a');
link.href = `/p/${encodeURIComponent(padId)}`;
link.textContent = padId;
item.append(link);
results.append(item);
});
};

let activeRequest = 0;

const runSearch = async () => {
const query = input.value.trim();
const requestId = ++activeRequest;
if (!query) {
clearResults();
setStatus('');
submit.disabled = false;
return;
}

setStatus(t('ep_search.searching'));
submit.disabled = true;
try {
const params = new URLSearchParams({query});
const response = await fetch(`/search/?${params.toString()}`);
if (!response.ok) throw new Error(`Search failed: ${response.status}`);
const pads = await response.json();
if (requestId !== activeRequest) return;
renderResults(pads);
setStatus(pads.length > 0
? t('ep_search.results', {count: pads.length})
: t('ep_search.noResults'));
} catch (err) {
if (requestId !== activeRequest) return;
clearResults();
setStatus(t('ep_search.error'));
} finally {
if (requestId === activeRequest) submit.disabled = false;
}
};

form.addEventListener('submit', (event) => {
event.preventDefault();
void runSearch();
});
}

})();
</script>
Loading
Loading