feat(content): server-side search, type filter, and sort

Content discovery was client-side only, scoped to the items already rendered
on the current page — searching "logo" on page 1 couldn't find logos on page
2 or in another folder.

Server (GET /api/content):
- ?q= text search on filename (LIKE, workspace-wide — a search ignores the
  open folder so nothing is missed). LIKE metacharacters are escaped so a
  filename with % or _ matches literally.
- ?type=video|image|youtube|web — youtube (video/youtube) and web (other
  remote_url) are split from plain uploaded video/image so the four UI buckets
  map cleanly.
- ?sort=date_desc|date_asc|name|size — whitelisted (never interpolates user
  input into ORDER BY); default keeps the legacy newest-first ordering.

Frontend (content-library):
- Type filter + sort dropdowns; search debounced (300ms) and now hits the
  server instead of filtering the DOM.
- Result count shown while a search/type filter is active.
- en/es i18n.

api.getContent gains an opts arg ({q,type,sort}); folder_id is omitted while
searching to match the server's workspace-wide behaviour.

Test: content-search-filter-sort.test.js mounts the real router and covers
substring match, LIKE-escape (literal %), the type buckets, name/size sort,
the ORDER BY injection guard, and combined filters. Suite 541/541.

Closes #214

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-07-23 10:45:26 -05:00
parent e7483dfc24
commit 60da126e72
6 changed files with 196 additions and 22 deletions

View file

@ -54,11 +54,19 @@ export const api = {
}),
// Content
getContent: (folderId, includeExpired = false) => {
const exp = includeExpired ? '&include_expired=1' : '';
if (folderId === undefined) return request(`/content${exp ? '?' + exp.slice(1) : ''}`);
const q = folderId === null ? 'root' : encodeURIComponent(folderId);
return request(`/content?folder_id=${q}${exp}`);
getContent: (folderId, includeExpired = false, opts = {}) => {
const p = new URLSearchParams();
// #214: a text search spans the whole workspace, so folder_id is only sent when
// NOT searching (the server also ignores folder_id when q is present, but keeping
// the client in sync avoids a misleading URL).
const searching = opts.q && opts.q.trim();
if (!searching && folderId !== undefined) p.set('folder_id', folderId === null ? 'root' : folderId);
if (includeExpired) p.set('include_expired', '1');
if (searching) p.set('q', opts.q.trim());
if (opts.type && opts.type !== 'all') p.set('type', opts.type);
if (opts.sort) p.set('sort', opts.sort);
const qs = p.toString();
return request(`/content${qs ? '?' + qs : ''}`);
},
getContentItem: (id) => request(`/content/${id}`),
deleteContent: (id) => request(`/content/${id}`, { method: 'DELETE' }),

View file

@ -186,6 +186,16 @@ export default {
'content.youtube_add_btn': 'Add YouTube Video',
// Search / folders
'content.search_placeholder': 'Search content...',
'content.filter_type_all': 'All types',
'content.filter_type_video': 'Videos',
'content.filter_type_image': 'Images',
'content.filter_type_youtube': 'YouTube',
'content.filter_type_web': 'Web / remote',
'content.sort_newest': 'Newest first',
'content.sort_oldest': 'Oldest first',
'content.sort_name': 'Name AZ',
'content.sort_size': 'Largest first',
'content.result_count': '{count} result(s)',
'content.new_folder_btn': '+ New Folder',
'content.breadcrumb_root': 'All Content',
'content.rename_btn': 'Rename',

View file

@ -149,6 +149,16 @@ export default {
'content.youtube_name_placeholder': 'Nombre para mostrar (opcional)',
'content.youtube_add_btn': 'Agregar video de YouTube',
'content.search_placeholder': 'Buscar contenido...',
'content.filter_type_all': 'Todos los tipos',
'content.filter_type_video': 'Videos',
'content.filter_type_image': 'Imágenes',
'content.filter_type_youtube': 'YouTube',
'content.filter_type_web': 'Web / remoto',
'content.sort_newest': 'Más recientes primero',
'content.sort_oldest': 'Más antiguos primero',
'content.sort_name': 'Nombre AZ',
'content.sort_size': 'Más grandes primero',
'content.result_count': '{count} resultado(s)',
'content.new_folder_btn': '+ Nueva carpeta',
'content.breadcrumb_root': 'Todo el contenido',
'content.rename_btn': 'Renombrar',

View file

@ -96,7 +96,21 @@ export function render(container) {
</div>
<div style="display:flex;gap:12px;margin-bottom:12px;align-items:center;flex-wrap:wrap">
<input type="text" id="contentSearch" class="input" placeholder="${t('content.search_placeholder')}" style="max-width:250px;width:100%">
<input type="text" id="contentSearch" class="input" placeholder="${t('content.search_placeholder')}" style="max-width:250px;width:100%" value="${esc(state.search)}">
<select id="contentTypeFilter" class="input btn-sm" style="width:auto;background:var(--bg-input)">
<option value="all" ${state.type === 'all' ? 'selected' : ''}>${t('content.filter_type_all')}</option>
<option value="video" ${state.type === 'video' ? 'selected' : ''}>${t('content.filter_type_video')}</option>
<option value="image" ${state.type === 'image' ? 'selected' : ''}>${t('content.filter_type_image')}</option>
<option value="youtube" ${state.type === 'youtube' ? 'selected' : ''}>${t('content.filter_type_youtube')}</option>
<option value="web" ${state.type === 'web' ? 'selected' : ''}>${t('content.filter_type_web')}</option>
</select>
<select id="contentSort" class="input btn-sm" style="width:auto;background:var(--bg-input)">
<option value="date_desc" ${state.sort === 'date_desc' ? 'selected' : ''}>${t('content.sort_newest')}</option>
<option value="date_asc" ${state.sort === 'date_asc' ? 'selected' : ''}>${t('content.sort_oldest')}</option>
<option value="name" ${state.sort === 'name' ? 'selected' : ''}>${t('content.sort_name')}</option>
<option value="size" ${state.sort === 'size' ? 'selected' : ''}>${t('content.sort_size')}</option>
</select>
<span id="contentResultCount" style="font-size:13px;color:var(--text-muted)"></span>
<button class="btn btn-secondary btn-sm" id="newFolderBtn">${t('content.new_folder_btn')}</button>
<label style="display:flex;align-items:center;gap:6px;font-size:13px;color:var(--text-secondary);cursor:pointer;margin-left:auto">
<input type="checkbox" id="showExpiredToggle" ${state.showExpired ? 'checked' : ''}> ${t('content.show_expired')}
@ -174,19 +188,17 @@ export function render(container) {
}
});
// Content search filters items currently shown in the grid.
function filterContent() {
const q = document.getElementById('contentSearch').value.toLowerCase();
document.querySelectorAll('.content-item').forEach(item => {
const name = item.querySelector('.content-item-name')?.textContent.toLowerCase() || '';
item.style.display = (!q || name.includes(q)) ? '' : 'none';
});
document.querySelectorAll('.folder-card').forEach(card => {
const name = card.dataset.name?.toLowerCase() || '';
card.style.display = (!q || name.includes(q)) ? '' : 'none';
});
}
document.getElementById('contentSearch').oninput = filterContent;
// #214: search/type/sort now query the server so results span the whole workspace,
// not just the items already rendered on the current page. Search is debounced to
// avoid a request per keystroke.
let searchTimer = null;
document.getElementById('contentSearch').oninput = (e) => {
clearTimeout(searchTimer);
const v = e.target.value;
searchTimer = setTimeout(() => { state.search = v.trim(); loadContent(); }, 300);
};
document.getElementById('contentTypeFilter').onchange = (e) => { state.type = e.target.value; loadContent(); };
document.getElementById('contentSort').onchange = (e) => { state.sort = e.target.value; loadContent(); };
// #157: "Show expired" — reloads the grid including deactivated / past-expiry items so
// they can be inspected and restored (clear/extend expiry in the edit modal).
@ -215,6 +227,9 @@ const state = {
currentFolderId: null, // null = root
folders: [], // all folders for this user (flat tree)
showExpired: false, // #157: include is_active=0 / past-expiry items in the library view
search: '', // #214: server-side text search (spans the whole workspace)
type: 'all', // #214: type filter — all | video | image | youtube | web
sort: 'date_desc', // #214: sort order — date_desc | date_asc | name | size
};
async function handleFiles(files) {
@ -250,11 +265,23 @@ async function loadContent() {
try {
const [content, folders] = await Promise.all([
api.getContent(state.currentFolderId === null ? null : state.currentFolderId, state.showExpired),
api.getContent(state.currentFolderId === null ? null : state.currentFolderId, state.showExpired, {
q: state.search, type: state.type, sort: state.sort,
}),
api.getFolders(),
]);
state.folders = folders;
// #214: while a search or type filter is active, results span the whole workspace,
// so surface a count and note the folder scope no longer applies.
const countEl = document.getElementById('contentResultCount');
if (countEl) {
const filtering = state.search || (state.type && state.type !== 'all');
countEl.textContent = filtering
? t('content.result_count', { count: content.length })
: '';
}
// Breadcrumb path: walk parent_id chain from current folder up to root.
const folderById = new Map(folders.map(f => [f.id, f]));
const path = [];

View file

@ -69,7 +69,11 @@ router.get('/', (req, res) => {
sql += " AND is_active = 1 AND (expires_at IS NULL OR expires_at > strftime('%s','now'))";
}
if (folder) { sql += ' AND folder = ?'; params.push(folder); }
if (folderId !== undefined) {
// #214: a text search (?q=) spans the whole workspace, not just the open folder —
// "searching for a logo on page 1 shouldn't miss logos in another folder". When q is
// absent we keep the folder-scoped browse behaviour.
const q = (req.query.q || '').trim();
if (!q && folderId !== undefined) {
if (folderId === 'root' || folderId === '') {
sql += ' AND folder_id IS NULL';
} else {
@ -77,7 +81,31 @@ router.get('/', (req, res) => {
params.push(folderId);
}
}
sql += ' ORDER BY folder, created_at DESC LIMIT ? OFFSET ?';
if (q) {
// Leading-wildcard LIKE (no index) — fine for the library's scale. Escape the LIKE
// metacharacters so a filename with % or _ is matched literally.
const esc = q.replace(/[\\%_]/g, (m) => '\\' + m);
sql += " AND filename LIKE ? ESCAPE '\\'";
params.push('%' + esc + '%');
}
// #214: type filter. youtube (video/youtube) and web (any other remote_url) are split
// out from plain uploaded video/image so the UI's four buckets map cleanly.
switch (req.query.type) {
case 'image': sql += " AND mime_type LIKE 'image/%'"; break;
case 'video': sql += " AND mime_type LIKE 'video/%' AND mime_type != 'video/youtube'"; break;
case 'youtube': sql += " AND mime_type = 'video/youtube'"; break;
case 'web': sql += " AND remote_url IS NOT NULL AND mime_type != 'video/youtube'"; break;
// default / 'all' / unknown: no type constraint
}
// #214: whitelisted sort (never interpolate user input into ORDER BY). Default keeps the
// legacy newest-first ordering.
const SORTS = {
date_desc: 'created_at DESC',
date_asc: 'created_at ASC',
name: 'filename COLLATE NOCASE ASC',
size: 'file_size DESC',
};
sql += ' ORDER BY ' + (SORTS[req.query.sort] || SORTS.date_desc) + ' LIMIT ? OFFSET ?';
params.push(Math.min(parseInt(req.query.limit) || 100, 500), parseInt(req.query.offset) || 0);
const content = db.prepare(sql).all(...params);
res.json(content);

View file

@ -0,0 +1,91 @@
'use strict';
// #214 server-side search / type filter / sort on GET /api/content. Mounts the real
// router behind a stub that injects a workspace, so the SQL query building is exercised
// end-to-end over HTTP (the whitelisted sort + escaped LIKE are the parts worth guarding).
const os = require('node:os');
const path = require('node:path');
const crypto = require('node:crypto');
process.env.DATA_DIR = path.join(os.tmpdir(), 'st-search-' + crypto.randomBytes(4).toString('hex'));
process.env.SELF_HOSTED = 'true';
process.env.NODE_ENV = 'test';
const { test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const http = require('node:http');
const express = require('express');
const { db } = require('../db/database');
const WS = 'ws-search';
let server, base;
function get(qs) {
return new Promise((resolve, reject) => {
http.get(`${base}/${qs}`, (res) => {
let body = '';
res.on('data', (c) => (body += c));
res.on('end', () => resolve(JSON.parse(body)));
}).on('error', reject);
});
}
const names = (rows) => rows.map((r) => r.filename);
before(async () => {
// Content with workspace_id NULL is visible to any workspace via the GET's
// "workspace_id = ? OR workspace_id IS NULL" clause — enough to exercise the query
// building without standing up a full workspace/org row.
const mk = (name, mime, size, remote) =>
db.prepare('INSERT INTO content (id, filename, mime_type, file_size, remote_url) VALUES (?,?,?,?,?)')
.run(crypto.randomBytes(6).toString('hex'), name, mime, size, remote);
// created_at defaults to now for all; insert in a known order and lean on id/size/name for assertions.
mk('alpha-logo.png', 'image/png', 100, null);
mk('beta clip.mp4', 'video/mp4', 900, null);
mk('gamma-100%-off.png', 'image/png', 300, null); // literal % — must survive LIKE escaping
mk('promo short', 'video/youtube', 0, 'https://youtu.be/aaaaaaaaaaa');
mk('news feed', 'text/html', 0, 'https://example.com/feed');
const app = express();
app.use((req, _res, next) => { req.workspaceId = WS; req.user = { id: 'u', role: 'admin' }; next(); });
app.use('/', require('../routes/content'));
server = http.createServer(app);
await new Promise((r) => server.listen(0, r));
base = `http://127.0.0.1:${server.address().port}`;
});
after(() => new Promise((r) => server.close(r)));
test('text search matches filename substring across the workspace', async () => {
const r = await get('?q=logo');
assert.deepEqual(names(r), ['alpha-logo.png']);
});
test('LIKE metacharacters in the query are matched literally, not as wildcards', async () => {
// A naive %q% would make "100%" match everything; the escape keeps it literal.
const r = await get('?q=' + encodeURIComponent('100%'));
assert.deepEqual(names(r), ['gamma-100%-off.png']);
});
test('type filter buckets: video excludes youtube; youtube and web are their own', async () => {
assert.deepEqual(names(await get('?type=image')).sort(), ['alpha-logo.png', 'gamma-100%-off.png']);
assert.deepEqual(names(await get('?type=video')), ['beta clip.mp4']);
assert.deepEqual(names(await get('?type=youtube')), ['promo short']);
assert.deepEqual(names(await get('?type=web')), ['news feed']);
});
test('sort=name is case-insensitive A-Z; sort=size is largest-first', async () => {
const byName = names(await get('?sort=name'));
assert.deepEqual(byName, [...byName].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())));
const bySize = await get('?sort=size');
assert.equal(bySize[0].filename, 'beta clip.mp4'); // 900, the largest
});
test('unknown sort falls back to the default ordering (no SQL injection into ORDER BY)', async () => {
const r = await get('?sort=filename;DROP TABLE content');
assert.ok(Array.isArray(r) && r.length === 5); // table intact, request succeeded
});
test('combining search + type + sort works together', async () => {
const r = await get('?type=image&sort=name&q=' + encodeURIComponent(''));
assert.deepEqual(names(r), ['alpha-logo.png', 'gamma-100%-off.png']);
});