diff --git a/api/routes/sources.js b/api/routes/sources.js index e93ed18..54aa430 100644 --- a/api/routes/sources.js +++ b/api/routes/sources.js @@ -284,7 +284,7 @@ module.exports = (pool) => { router.get('/:name/view-data', async (req, res, next) => { try { - const { limit = 100, offset = 0 } = req.query; + const { limit = 100, offset = 0, sort_col, sort_dir } = req.query; const viewName = `dfv.${req.params.name}`; // Check view exists @@ -298,8 +298,23 @@ module.exports = (pool) => { return res.json({ exists: false, rows: [] }); } + // Validate sort_col against actual view columns to prevent injection + let orderClause = ''; + if (sort_col) { + const cols = await pool.query( + `SELECT column_name FROM information_schema.columns + WHERE table_schema = 'dfv' AND table_name = $1`, + [req.params.name] + ); + const validCols = cols.rows.map(r => r.column_name); + if (validCols.includes(sort_col)) { + const dir = sort_dir === 'desc' ? 'DESC' : 'ASC'; + orderClause = ` ORDER BY "${sort_col}" ${dir} NULLS LAST`; + } + } + const result = await pool.query( - `SELECT * FROM ${viewName} LIMIT $1 OFFSET $2`, + `SELECT * FROM ${viewName}${orderClause} LIMIT $1 OFFSET $2`, [parseInt(limit), parseInt(offset)] ); diff --git a/ui/src/api.js b/ui/src/api.js index 9db694e..d657c31 100644 --- a/ui/src/api.js +++ b/ui/src/api.js @@ -63,7 +63,11 @@ export const api = { reprocess: (name) => request('POST', `/sources/${name}/reprocess`), generateView: (name) => request('POST', `/sources/${name}/view`), getFields: (name) => request('GET', `/sources/${name}/fields`), - getViewData: (name, limit = 100, offset = 0) => request('GET', `/sources/${name}/view-data?limit=${limit}&offset=${offset}`), + getViewData: (name, limit = 100, offset = 0, sortCol = null, sortDir = 'asc') => { + const params = new URLSearchParams({ limit, offset }) + if (sortCol) { params.set('sort_col', sortCol); params.set('sort_dir', sortDir) } + return request('GET', `/sources/${name}/view-data?${params}`) + }, // Rules getRules: (source) => request('GET', `/rules/source/${source}`), diff --git a/ui/src/pages/Records.jsx b/ui/src/pages/Records.jsx index 846a18f..8c62ad8 100644 --- a/ui/src/pages/Records.jsx +++ b/ui/src/pages/Records.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo } from 'react' +import { useState, useEffect } from 'react' import { api } from '../api' const DATE_RE = /^\d{4}-\d{2}-\d{2}(T[\d:.Z+-]+)?$/ @@ -30,13 +30,13 @@ export default function Records({ source }) { if (!source) return setOffset(0) setSort({ col: null, dir: 'asc' }) - load(0) + load(0, null, 'asc') }, [source]) - async function load(off) { + async function load(off, col, dir) { setLoading(true) try { - const res = await api.getViewData(source, LIMIT, off) + const res = await api.getViewData(source, LIMIT, off, col, dir) setExists(res.exists) setRows(res.rows) } catch (err) { @@ -47,24 +47,16 @@ export default function Records({ source }) { } function toggleSort(col) { - setSort(s => s.col === col - ? { col, dir: s.dir === 'asc' ? 'desc' : 'asc' } + const next = sort.col === col + ? { col, dir: sort.dir === 'asc' ? 'desc' : 'asc' } : { col, dir: 'asc' } - ) + setSort(next) + setOffset(0) + load(0, next.col, next.dir) } - const sorted = useMemo(() => { - if (!sort.col) return rows - return [...rows].sort((a, b) => { - const av = a[sort.col] ?? '' - const bv = b[sort.col] ?? '' - const cmp = String(av).localeCompare(String(bv), undefined, { numeric: true }) - return sort.dir === 'asc' ? cmp : -cmp - }) - }, [rows, sort]) - - function prev() { const o = Math.max(0, offset - LIMIT); setOffset(o); load(o) } - function next() { const o = offset + LIMIT; setOffset(o); load(o) } + function prev() { const o = Math.max(0, offset - LIMIT); setOffset(o); load(o, sort.col, sort.dir) } + function next() { const o = offset + LIMIT; setOffset(o); load(o, sort.col, sort.dir) } if (!source) return