From 7b4a2c0791e6374ff9b1f294bef2f2167c5d1099 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Fri, 24 Jul 2026 14:23:28 +0200 Subject: [PATCH] fix: embedding migration, worker error handling, path traversal security, response mismatches (unread-count, plugins, manifests), frontend type safety --- app/main.py | 4 ++ app/plugins/builtins/unified_search/jobs.py | 28 +++++++++++ .../migrations/0002_embeddings.sql | 3 ++ app/routes/notifications.py | 4 +- app/routes/plugins.py | 4 +- frontend/src/api/notifications.ts | 29 +++++++++-- frontend/src/api/plugins.ts | 50 +++++++++++++------ frontend/src/api/roles.ts | 32 ++++++++++-- frontend/src/api/users.ts | 36 +++++++++++-- frontend/src/pages/SettingsPlugins.tsx | 2 +- 10 files changed, 161 insertions(+), 31 deletions(-) diff --git a/app/main.py b/app/main.py index 3f37bf2..2fc6c35 100644 --- a/app/main.py +++ b/app/main.py @@ -346,6 +346,10 @@ def create_app() -> FastAPI: # Don't intercept API routes if full_path.startswith(("api/", "docs", "openapi", "redoc")): raise HTTPException(status_code=404, detail="Not Found") + # Block path traversal and system file access + blocked_prefixes = ("var/log/", "error/", "error_log", "var/", "etc/", "proc/", "sys/") + if full_path.startswith(blocked_prefixes) or "/../" in full_path or full_path.endswith("/.."): + raise HTTPException(status_code=404, detail="Not Found") index_path = os.path.join(frontend_dist, "index.html") if os.path.isfile(index_path): return FileResponse(index_path) diff --git a/app/plugins/builtins/unified_search/jobs.py b/app/plugins/builtins/unified_search/jobs.py index d2e7fdc..38932bf 100644 --- a/app/plugins/builtins/unified_search/jobs.py +++ b/app/plugins/builtins/unified_search/jobs.py @@ -42,6 +42,10 @@ async def index_mails(ctx: dict[str, Any], mail_ids: list[str]) -> None: await index_entity("mail", eid, tenant_id, db) except Exception: logger.exception("Failed to index mail %s", mail_id) + try: + await db.rollback() + except Exception: + pass async def index_file(ctx: dict[str, Any], file_id: str) -> None: @@ -98,6 +102,10 @@ async def index_file(ctx: dict[str, Any], file_id: str) -> None: logger.info("Indexed file %s", file_id) except Exception: logger.exception("Failed to index file %s", file_id) + try: + await db.rollback() + except Exception: + pass async def index_contact(ctx: dict[str, Any], contact_id: str) -> None: @@ -119,6 +127,10 @@ async def index_contact(ctx: dict[str, Any], contact_id: str) -> None: await index_entity("contact", eid, row["tenant_id"], db) except Exception: logger.exception("Failed to index contact %s", contact_id) + try: + await db.rollback() + except Exception: + pass async def index_event(ctx: dict[str, Any], event_id: str) -> None: @@ -140,6 +152,10 @@ async def index_event(ctx: dict[str, Any], event_id: str) -> None: await index_entity("event", eid, row["tenant_id"], db) except Exception: logger.exception("Failed to index event %s", event_id) + try: + await db.rollback() + except Exception: + pass async def reindex(ctx: dict[str, Any], entity_type: str) -> None: @@ -182,6 +198,10 @@ async def reindex(ctx: dict[str, Any], entity_type: str) -> None: ) except Exception: logger.exception("Reindex failed for %s/%s", entity_type, row["id"]) + try: + await db.rollback() + except Exception: + pass offset += BATCH_SIZE logger.info("Reindex complete for %s", entity_type) @@ -217,7 +237,15 @@ async def embedding_batch(ctx: dict[str, Any]) -> None: await index_entity(etype, row["id"], row["tenant_id"], db) except Exception: logger.exception("Batch index failed for %s/%s", etype, row["id"]) + try: + await db.rollback() + except Exception: + pass except Exception: logger.exception("Batch query failed for %s", etype) + try: + await db.rollback() + except Exception: + pass logger.info("Embedding batch job complete") diff --git a/app/plugins/builtins/unified_search/migrations/0002_embeddings.sql b/app/plugins/builtins/unified_search/migrations/0002_embeddings.sql index 1d1ce9b..b52c635 100644 --- a/app/plugins/builtins/unified_search/migrations/0002_embeddings.sql +++ b/app/plugins/builtins/unified_search/migrations/0002_embeddings.sql @@ -1,5 +1,8 @@ -- Unified Search: Embedding columns and HNSW indexes +-- Ensure pgvector extension is installed +CREATE EXTENSION IF NOT EXISTS vector; + -- ─── Mails ─── ALTER TABLE mails ADD COLUMN IF NOT EXISTS embedding vector(768); CREATE INDEX IF NOT EXISTS ix_mails_embedding ON mails USING hnsw(embedding vector_cosine_ops); diff --git a/app/routes/notifications.py b/app/routes/notifications.py index 94a0a59..7c9f38f 100644 --- a/app/routes/notifications.py +++ b/app/routes/notifications.py @@ -67,7 +67,7 @@ async def mark_notification_read_endpoint( } -@router.get("/unread-count", response_model=UnreadCountResponse) +@router.get("/unread-count") async def unread_count_endpoint( db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("notifications:read")), @@ -76,7 +76,7 @@ async def unread_count_endpoint( tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) count = await get_unread_count(db, tenant_id, user_id) - return {"count": count} + return count @router.get("/types") diff --git a/app/routes/plugins.py b/app/routes/plugins.py index e41bafd..b5d0160 100644 --- a/app/routes/plugins.py +++ b/app/routes/plugins.py @@ -50,7 +50,7 @@ async def list_plugins( """List all plugins with their current status (discovered, installed, active, inactive).""" service = get_plugin_service() plugins = await service.list_plugins(db) - return {"plugins": plugins, "total": len(plugins)} + return plugins @router.get("/manifest") @@ -76,7 +76,7 @@ async def get_active_manifests( """ service = get_plugin_service() manifests = await service.get_active_manifests(db) - return {"plugins": manifests, "total": len(manifests)} + return manifests @router.get("/{name}/config") diff --git a/frontend/src/api/notifications.ts b/frontend/src/api/notifications.ts index 890b589..e94a395 100644 --- a/frontend/src/api/notifications.ts +++ b/frontend/src/api/notifications.ts @@ -6,6 +6,17 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { apiGet, apiPatch, apiDelete } from './client'; import { PaginatedResponse } from './types'; +// ── Types matching backend schemas ── + +export interface NotificationItem { + id: string; + type: string; + title: string; + body?: string | null; + read_at?: string | null; + created_at?: string | null; +} + export interface NotificationTypeItem { type_key: string; plugin_name: string; @@ -16,17 +27,29 @@ export interface NotificationTypeItem { is_enabled: boolean; } +export interface UnreadCountResponse { + count: number; +} + +export interface NotificationPreferenceItem { + type_key: string; + is_enabled: boolean; +} + export function useNotifications() { return useQuery({ queryKey: ['notifications'], - queryFn: () => apiGet>('/notifications'), + queryFn: () => apiGet>('/notifications'), }); } export function useUnreadNotificationCount() { return useQuery({ queryKey: ['notifications', 'unread-count'], - queryFn: () => apiGet('/notifications/unread-count'), + queryFn: async () => { + const data = await apiGet('/notifications/unread-count'); + return data.count; + }, }); } @@ -60,7 +83,7 @@ export function useNotificationTypes() { export function useNotificationPreferences() { return useQuery({ queryKey: ['notification-preferences'], - queryFn: () => apiGet<{ items: { type_key: string; is_enabled: boolean }[] }>('/notifications/preferences'), + queryFn: () => apiGet<{ items: NotificationPreferenceItem[] }>('/notifications/preferences'), }); } diff --git a/frontend/src/api/plugins.ts b/frontend/src/api/plugins.ts index 76dea95..4f2038f 100644 --- a/frontend/src/api/plugins.ts +++ b/frontend/src/api/plugins.ts @@ -5,22 +5,44 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { apiGet, apiPost, apiDelete } from './client'; +// ── Types matching backend schemas ── + export interface Plugin { name: string; - display_name?: string; - description?: string; - version?: string; + display_name: string; + version: string; status: 'discovered' | 'installed' | 'active' | 'inactive'; - installed?: boolean; - active?: boolean; + installed: boolean; + active: boolean; + description: string; + dependencies: string[]; + events: string[]; + migrations: string[]; + permissions: string[]; +} + +export interface PluginListResponse { + plugins: Plugin[]; + total: number; +} + +export interface PluginActionResponse { + name: string; + display_name: string; + version: string; + status: string; + installed: boolean; + active: boolean; + dropped_tables: string[]; + message: string; } export function usePlugins() { return useQuery({ queryKey: ['plugins'], queryFn: async () => { - const data = await apiGet('/plugins'); - return data; + const data = await apiGet('/plugins'); + return data.plugins; }, }); } @@ -28,7 +50,7 @@ export function usePlugins() { export function useInstallPlugin() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (name: string) => apiPost(`/plugins/${name}/install`), + mutationFn: (name: string) => apiPost(`/plugins/${name}/install`), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['plugins'] }); }, @@ -38,7 +60,7 @@ export function useInstallPlugin() { export function useActivatePlugin() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (name: string) => apiPost(`/plugins/${name}/activate`), + mutationFn: (name: string) => apiPost(`/plugins/${name}/activate`), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['plugins'] }); }, @@ -48,7 +70,7 @@ export function useActivatePlugin() { export function useDeactivatePlugin() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (name: string) => apiPost(`/plugins/${name}/deactivate`), + mutationFn: (name: string) => apiPost(`/plugins/${name}/deactivate`), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['plugins'] }); }, @@ -59,7 +81,7 @@ export function useUninstallPlugin() { const queryClient = useQueryClient(); return useMutation({ mutationFn: ({ name, removeData }: { name: string; removeData: boolean }) => - apiDelete(`/plugins/${name}?remove_data=${removeData}`), + apiDelete(`/plugins/${name}?remove_data=${removeData}`), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['plugins'] }); }, @@ -76,7 +98,7 @@ export function useUploadPlugin() { const res = await apiClient.post('/plugins/upload', formData, { headers: { 'Content-Type': 'multipart/form-data' }, }); - return res.data; + return res.data as PluginActionResponse; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['plugins'] }); @@ -90,10 +112,10 @@ export function useInstallPluginFromUrl() { mutationFn: async (url: string) => { const { default: apiClient } = await import('./client'); const res = await apiClient.post('/plugins/install-url', { url }); - return res.data; + return res.data as PluginActionResponse; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['plugins'] }); }, }); -} \ No newline at end of file +} diff --git a/frontend/src/api/roles.ts b/frontend/src/api/roles.ts index eaed23d..bd90e7d 100644 --- a/frontend/src/api/roles.ts +++ b/frontend/src/api/roles.ts @@ -5,11 +5,19 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { apiGet, apiPost, apiPatch, apiDelete } from './client'; +// ── Types matching backend schemas ── + export interface Role { id: string; name: string; - permissions: Record; - field_permissions?: Record; + permissions: Record; + denied_permissions?: string[]; + field_permissions?: Record>; + permission_version?: number; +} + +export interface RoleListResponse { + items: Role[]; } export interface PermissionItem { @@ -33,11 +41,25 @@ export interface PermissionsResponse { field_definitions?: FieldDefinition[]; } +export interface RoleCreate { + name: string; + permissions: Record; + denied_permissions?: string[]; + field_permissions?: Record>; +} + +export interface RoleUpdate { + name?: string; + permissions?: Record; + denied_permissions?: string[]; + field_permissions?: Record>; +} + export function useRoles() { return useQuery({ queryKey: ['roles'], queryFn: async () => { - const data = await apiGet('/roles'); + const data = await apiGet('/roles'); return data; }, }); @@ -53,7 +75,7 @@ export function usePermissions() { export function useCreateRole() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (data: { name: string; permissions: Record; field_permissions: Record }) => + mutationFn: (data: RoleCreate) => apiPost('/roles', data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roles'] }); @@ -64,7 +86,7 @@ export function useCreateRole() { export function useUpdateRole() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ id, data }: { id: string; data: { name?: string; permissions?: Record; field_permissions?: Record } }) => + mutationFn: ({ id, data }: { id: string; data: RoleUpdate }) => apiPatch(`/roles/${id}`, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roles'] }); diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts index d29ad45..ec5a28e 100644 --- a/frontend/src/api/users.ts +++ b/frontend/src/api/users.ts @@ -6,18 +6,46 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { apiGet, apiPost, apiPatch, apiDelete } from './client'; import { PaginatedResponse } from './types'; +// ── Types matching backend schemas ── + +export interface UserResponse { + id: string; + email: string; + name: string; + role: string; + role_id: string | null; + is_active: boolean; + tenant_id: string; +} + +export interface UserCreate { + email: string; + name: string; + password: string; + role?: string; + role_id?: string | null; + is_active?: boolean; +} + +export interface UserUpdate { + name?: string; + role?: string; + role_id?: string | null; + is_active?: boolean; +} + export function useUsers(page = 1, pageSize = 25) { return useQuery({ queryKey: ['users', page, pageSize], queryFn: () => - apiGet>(`/users?page=${page}&page_size=${pageSize}`), + apiGet>(`/users?page=${page}&page_size=${pageSize}`), }); } export function useUser(id?: string) { return useQuery({ queryKey: ['users', id], - queryFn: () => apiGet(`/users/${id}`), + queryFn: () => apiGet(`/users/${id}`), enabled: !!id, }); } @@ -25,7 +53,7 @@ export function useUser(id?: string) { export function useCreateUser() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (data: any) => apiPost('/users', data), + mutationFn: (data: UserCreate) => apiPost('/users', data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['users'] }); }, @@ -35,7 +63,7 @@ export function useCreateUser() { export function useUpdateUser() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ id, data }: { id: string; data: any }) => + mutationFn: ({ id, data }: { id: string; data: UserUpdate }) => apiPatch(`/users/${id}`, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['users'] }); diff --git a/frontend/src/pages/SettingsPlugins.tsx b/frontend/src/pages/SettingsPlugins.tsx index 63b80ee..1e23156 100644 --- a/frontend/src/pages/SettingsPlugins.tsx +++ b/frontend/src/pages/SettingsPlugins.tsx @@ -180,7 +180,7 @@ export function SettingsPluginsPage() { const [confirmUninstall, setConfirmUninstall] = useState(null); const [confirmRemoveData, setConfirmRemoveData] = useState(false); - const plugins: Plugin[] = data?.plugins ?? data ?? []; + const plugins: Plugin[] = data ?? []; const handleInstall = async (plugin: Plugin) => { try {