chore(lightrag, lightrag_webui): improve code quality and security

- Extract PostgreSQL storage check into named variable for clarity
- Move APIRouter initialization into create_table_routes function scope
- Add robust type handling for database query results
- Add input validation for table names and pagination parameters
- Add regex-based SQL injection prevention for table name sanitization
- Improve clipboard copy fallback logic and error handling
- Add memoization for JSON serialization to prevent unnecessary recalculations
- Hide meta column from table explorer UI display
- Sort table columns alphabetically for consistent ordering
- Add keyboard accessibility to status filter buttons
- Add preprocessed status filter to document manager
- Update @tanstack/react-query from 5.60.0 to 5.87.1
- Extract dev storage config into constant to reduce duplication
- Update documentation comments for clarity
This commit is contained in:
clssck 2025-11-27 21:39:42 +01:00
parent a9edadef45
commit b6074b9a81
9 changed files with 91 additions and 43 deletions

View file

@ -1065,10 +1065,13 @@ def create_app(args):
app.include_router(create_graph_routes(rag, api_key))
# Register table routes if all storages are PostgreSQL
if (args.kv_storage == "PGKVStorage" and
all_postgres_storages = (
args.kv_storage == "PGKVStorage" and
args.doc_status_storage == "PGDocStatusStorage" and
args.graph_storage == "PGGraphStorage" and
args.vector_storage == "PGVectorStorage"):
args.vector_storage == "PGVectorStorage"
)
if all_postgres_storages:
app.include_router(create_table_routes(rag, api_key), prefix="/tables")
# Add Ollama API routes

View file

@ -7,8 +7,6 @@ from lightrag.api.utils_api import get_combined_auth_dependency
from lightrag.kg.postgres_impl import TABLES
from lightrag.utils import logger
router = APIRouter(tags=["Tables"])
def get_order_clause(ddl: str) -> str:
"""Determine the best ORDER BY clause based on available columns in DDL."""
ddl_lower = ddl.lower()
@ -25,6 +23,7 @@ def get_order_clause(ddl: str) -> str:
return ""
def create_table_routes(rag: LightRAG, api_key: Optional[str] = None) -> APIRouter:
router = APIRouter(tags=["Tables"])
combined_auth = get_combined_auth_dependency(api_key)
def get_workspace_from_request(request: Request) -> Optional[str]:
@ -81,12 +80,22 @@ def create_table_routes(rag: LightRAG, api_key: Optional[str] = None) -> APIRout
# 1. Get total count
count_sql = f"SELECT COUNT(*) as count FROM {table_name} WHERE workspace = $1"
count_res = await db.query(count_sql, [target_workspace])
total = count_res['count'] if count_res else 0
total = 0
if isinstance(count_res, dict):
total = count_res.get('count', 0)
elif isinstance(count_res, list) and len(count_res) > 0:
first_row = count_res[0]
if isinstance(first_row, dict):
total = first_row.get('count', 0)
# 2. Get data
# Try to determine order column
ddl = TABLES[table_name]['ddl']
order_clause = get_order_clause(ddl)
if 'ddl' in TABLES[table_name]:
ddl = TABLES[table_name]['ddl']
order_clause = get_order_clause(ddl)
else:
order_clause = ""
sql = f"SELECT * FROM {table_name} WHERE workspace = $1 {order_clause} LIMIT $2 OFFSET $3"
rows = await db.query(sql, [target_workspace, page_size, offset], multirows=True)
@ -103,4 +112,4 @@ def create_table_routes(rag: LightRAG, api_key: Optional[str] = None) -> APIRout
logger.error(traceback.format_exc())
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}") from e
return router
return router

View file

@ -28,7 +28,7 @@
"@react-sigma/minimap": "^5.0.5",
"@sigma/edge-curve": "^3.1.0",
"@sigma/node-border": "^3.0.0",
"@tanstack/react-query": "^5.60.0",
"@tanstack/react-query": "^5.87.1",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.12.2",
"class-variance-authority": "^0.7.1",

View file

@ -40,7 +40,7 @@
"@react-sigma/minimap": "^5.0.5",
"@sigma/edge-curve": "^3.1.0",
"@sigma/node-border": "^3.0.0",
"@tanstack/react-query": "^5.60.0",
"@tanstack/react-query": "^5.87.1",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.12.2",
"class-variance-authority": "^0.7.1",

View file

@ -886,6 +886,8 @@ const mockTableData: Record<string, any[]> = {
]
}
const SAFE_TABLE_NAME_REGEX = /^[a-zA-Z0-9_.-]+$/
export const getTableList = async (): Promise<string[]> => {
if (import.meta.env.DEV) {
return mockTables
@ -898,6 +900,9 @@ export const getTableSchema = async (tableName: string): Promise<TableSchema> =>
if (!tableName || typeof tableName !== 'string') {
throw new Error('Invalid table name')
}
if (!SAFE_TABLE_NAME_REGEX.test(tableName)) {
throw new Error('Invalid table name: contains forbidden characters')
}
if (import.meta.env.DEV) {
return { ddl: mockSchemas[tableName] || `-- Schema not available for ${tableName}` }
}
@ -909,7 +914,10 @@ export const getTableData = async (tableName: string, page: number, pageSize: nu
if (!tableName || typeof tableName !== 'string') {
throw new Error('Invalid table name')
}
if (page < 1 || pageSize < 1 || pageSize > 1000) {
if (!SAFE_TABLE_NAME_REGEX.test(tableName)) {
throw new Error('Invalid table name: contains forbidden characters')
}
if (!Number.isInteger(page) || !Number.isInteger(pageSize) || page < 1 || pageSize < 1 || pageSize > 1000) {
throw new Error('Page must be >= 1 and page size must be between 1 and 1000')
}

View file

@ -32,7 +32,7 @@ import { errorMessage } from '@/lib/utils'
import { toast } from 'sonner'
import { useBackendState } from '@/stores/state'
import { RefreshCwIcon, ActivityIcon, ArrowUpIcon, ArrowDownIcon, RotateCcwIcon, CheckSquareIcon, XIcon, AlertTriangle, Info, FileText, CheckCircle2, Loader2, Clock, AlertCircle, Brain, Shell } from 'lucide-react'
import { RefreshCwIcon, ArrowUpIcon, ArrowDownIcon, CheckSquareIcon, XIcon, AlertTriangle, Info, FileText, CheckCircle2, Loader2, AlertCircle, Brain, Shell } from 'lucide-react'
import PipelineStatusDialog from '@/components/documents/PipelineStatusDialog'
type StatusFilter = DocStatus | 'all';
@ -495,6 +495,14 @@ export default function DocumentManager() {
color: "text-emerald-500",
activeColor: "text-emerald-600 dark:text-emerald-500"
},
{
id: 'preprocessed' as StatusFilter,
label: t('documentPanel.documentManager.status.preprocessed'),
icon: Brain,
count: preprocessedCount,
color: "text-purple-500",
activeColor: "text-purple-600 dark:text-purple-500"
},
{
id: 'processing' as StatusFilter,
label: t('documentPanel.documentManager.status.processing'),
@ -521,7 +529,7 @@ export default function DocumentManager() {
color: "text-red-500",
activeColor: "text-red-600 dark:text-red-500"
}
], [t, statusCounts, documentCounts, processedCount, processingCount, pendingCount, failedCount]);
], [t, statusCounts, documentCounts, processedCount, preprocessedCount, processingCount, pendingCount, failedCount]);
// Store previous status counts
const prevStatusCounts = useRef({
@ -1175,6 +1183,15 @@ export default function DocumentManager() {
<div
key={item.id}
onClick={() => handleStatusFilterChange(item.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleStatusFilterChange(item.id);
}
}}
role="button"
tabIndex={0}
aria-pressed={isActive}
className={cn(
"flex-1 min-w-[140px] flex flex-col gap-1 p-3 rounded-lg transition-all cursor-pointer border select-none",
isActive

View file

@ -35,7 +35,7 @@ function shouldShowTableExplorer(storageConfig: any) {
// Always show for now - TODO: fix storageConfig state propagation from health check
return true
// Original logic:
// Original logic (storageConfig not being populated from health endpoint):
// if (import.meta.env.DEV) return true
// return (
// storageConfig &&
@ -80,7 +80,6 @@ export default function SiteHeader() {
const { t } = useTranslation()
const { isGuestMode, username, webuiTitle, webuiDescription } = useAuthStore()
const enableHealthCheck = useSettingsStore.use.enableHealthCheck()
const storageConfig = useSettingsStore.use.storageConfig()
const handleLogout = () => {
navigationService.navigateToLogin();

View file

@ -48,23 +48,28 @@ function isJsonLike(value: any): boolean {
// Copy to clipboard helper
async function copyToClipboard(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
// Fallback for older browsers
const textarea = document.createElement('textarea')
textarea.value = text
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
if (navigator.clipboard?.writeText) {
try {
document.execCommand('copy')
await navigator.clipboard.writeText(text)
return true
} catch {
return false
} finally {
// Fall through to legacy approach
}
}
// Fallback for older browsers
const textarea = document.createElement('textarea')
textarea.value = text
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
try {
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
return true
} catch {
return false
} finally {
if (textarea.parentNode) {
document.body.removeChild(textarea)
}
}
@ -114,7 +119,13 @@ function RowDetailModal({
if (!row) return null
const entries = Object.entries(row)
const fullRowJson = JSON.stringify(row, null, 2)
const fullRowJson = useMemo(() => {
try {
return JSON.stringify(row, null, 2)
} catch {
return '[Unable to serialize row]'
}
}, [row])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@ -197,6 +208,9 @@ export default function TableExplorer() {
setModalOpen(true)
}, [])
// Columns to hide from UI (exist in schema but not populated)
const HIDDEN_COLUMNS = ['meta']
// Generate columns dynamically from data
const columns = useMemo<ColumnDef<any>[]>(() => {
const cols: ColumnDef<any>[] = []
@ -206,7 +220,8 @@ export default function TableExplorer() {
Object.keys(row).forEach(key => allKeys.add(key))
})
allKeys.forEach((key) => {
Array.from(allKeys).sort().forEach((key) => {
if (HIDDEN_COLUMNS.includes(key)) return // Skip hidden columns
cols.push({
accessorKey: key,
header: () => (

View file

@ -4,6 +4,13 @@ import { createSelectors } from '@/lib/utils'
import { defaultQueryLabel } from '@/lib/constants'
import { Message, QueryRequest } from '@/api/lightrag'
const DEV_STORAGE_CONFIG = import.meta.env.DEV ? {
kv_storage: 'PGKVStorage',
doc_status_storage: 'PGDocStatusStorage',
graph_storage: 'PGGraphStorage',
vector_storage: 'PGVectorStorage'
} : null
type Theme = 'dark' | 'light' | 'system'
type Language = 'en' | 'zh' | 'fr' | 'ar' | 'zh_TW'
type Tab = 'documents' | 'knowledge-graph' | 'retrieval' | 'api' | 'table-explorer'
@ -118,12 +125,7 @@ const useSettingsStoreBase = create<SettingsState>()(
apiKey: null,
// In dev mode, mock PG storage config so Tables tab is visible
storageConfig: import.meta.env.DEV ? {
kv_storage: 'PGKVStorage',
doc_status_storage: 'PGDocStatusStorage',
graph_storage: 'PGGraphStorage',
vector_storage: 'PGVectorStorage'
} : null,
storageConfig: DEV_STORAGE_CONFIG,
currentTab: 'documents',
showFileName: false,
@ -365,12 +367,7 @@ const useSettingsStoreBase = create<SettingsState>()(
}
if (version < 21) {
// Reset storageConfig to pick up dev mode default
state.storageConfig = import.meta.env.DEV ? {
kv_storage: 'PGKVStorage',
doc_status_storage: 'PGDocStatusStorage',
graph_storage: 'PGGraphStorage',
vector_storage: 'PGVectorStorage'
} : null
state.storageConfig = DEV_STORAGE_CONFIG
}
return state
}