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:
parent
a9edadef45
commit
b6074b9a81
9 changed files with 91 additions and 43 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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: () => (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue