fix: comprehensive security, consistency, and dead code cleanup
Security: - Add auth + file type/size validation to upload API - Add admin auth to /api/admin/ endpoints - Add SSRF protection to scrape action - Whitelist fields in PUT /api/notes/[id] to prevent mass assignment - Protect /lab, /agents, /chat, /canvas, /notebooks routes in middleware AI provider fixes: - Add deepseek/openrouter to factory ProviderType (was silently falling back to ollama) - Fix title-suggestion.service.ts to use factory instead of hardcoded OpenAI - Fix getAIProvider→getChatProvider in memory-echo, notebook-summary, agent-executor - Fix getAIProvider→getTagsProvider in notebook-suggestion, title-suggestions, transform-markdown Functional bugs: - Fix ALLOW_REGISTRATION AND→OR logic - Fix note-editor.tsx passing stale props to useAutoTagging instead of local state - Fix stale Note.embedding type (migrated to NoteEmbedding table) - Remove hardcoded SQLite path from prisma.ts Frontend: - Add AbortController to useAutoTagging and useTitleSuggestions hooks - Add error rollback to optimistic UI in note-inline-editor - Remove stale closure over notebookId/language in useAutoTagging Cleanup: - Rename docker-compose from keepnotes→memento - Remove unused unstable_cache import from config.ts - Remove dead useUndoRedo hook - Fix TagSuggestion type (add isNewLabel, reasoning) - Remove dead AIConfig/AIProviderType types - Fix ghost-tags unused isEmpty var and as any cast - Fix note-editor titleSuggestions typed as any[] Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useDebounce } from './use-debounce';
|
||||
import { TagSuggestion } from '@/lib/ai/types';
|
||||
|
||||
@@ -20,13 +20,20 @@ export function useAutoTagging({ content, notebookId, enabled = true }: UseAutoT
|
||||
|
||||
// Track previous notebookId to detect when note is moved to a notebook
|
||||
const previousNotebookId = useRef<string | null | undefined>(notebookId);
|
||||
// AbortController for cancelling in-flight requests
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const analyzeContent = async (contentToAnalyze: string) => {
|
||||
const analyzeContent = useCallback(async (contentToAnalyze: string, currentNotebookId?: string | null, currentLanguage?: string) => {
|
||||
if (!contentToAnalyze || contentToAnalyze.length < 10) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel previous request
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
setIsAnalyzing(true);
|
||||
setError(null);
|
||||
|
||||
@@ -36,23 +43,29 @@ export function useAutoTagging({ content, notebookId, enabled = true }: UseAutoT
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
content: contentToAnalyze,
|
||||
notebookId: notebookId || undefined,
|
||||
language: language || document.documentElement.lang || 'en',
|
||||
notebookId: currentNotebookId || undefined,
|
||||
language: currentLanguage || document.documentElement.lang || 'en',
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Error during analysis');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setSuggestions(data.tags || []);
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
if (err.name === 'AbortError') return;
|
||||
setError('Failed to generate suggestions');
|
||||
} finally {
|
||||
setIsAnalyzing(false);
|
||||
if (!controller.signal.aborted) {
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Trigger on content change
|
||||
useEffect(() => {
|
||||
@@ -61,24 +74,27 @@ export function useAutoTagging({ content, notebookId, enabled = true }: UseAutoT
|
||||
return;
|
||||
}
|
||||
|
||||
analyzeContent(debouncedContent);
|
||||
}, [debouncedContent, enabled]);
|
||||
analyzeContent(debouncedContent, notebookId, language);
|
||||
}, [debouncedContent, enabled, notebookId, language, analyzeContent]);
|
||||
|
||||
// CRITICAL: Also trigger when notebookId changes from null/undefined to a value (note moved to notebook)
|
||||
// Trigger when notebookId changes from null/undefined to a value
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const prev = previousNotebookId.current;
|
||||
previousNotebookId.current = notebookId;
|
||||
|
||||
// Detect when note is moved FROM "General Notes" (null) TO a notebook
|
||||
const wasMovedToNotebook = (prev === null || prev === undefined) && notebookId;
|
||||
|
||||
if (wasMovedToNotebook && content && content.length >= 10) {
|
||||
// Use current content immediately (no debounce) when moving to notebook
|
||||
analyzeContent(content);
|
||||
analyzeContent(content, notebookId, language);
|
||||
}
|
||||
}, [notebookId, content, enabled]);
|
||||
}, [notebookId, content, enabled, language, analyzeContent]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => { abortRef.current?.abort(); };
|
||||
}, []);
|
||||
|
||||
return {
|
||||
suggestions,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useDebounce } from './use-debounce'
|
||||
|
||||
export interface TitleSuggestion {
|
||||
@@ -16,6 +16,7 @@ export function useTitleSuggestions({ content, enabled = true }: UseTitleSuggest
|
||||
const [suggestions, setSuggestions] = useState<TitleSuggestion[]>([])
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
|
||||
// Debounce content by 2s to avoid excessive API calls
|
||||
const debouncedContent = useDebounce(content, 2000)
|
||||
@@ -34,6 +35,10 @@ export function useTitleSuggestions({ content, enabled = true }: UseTitleSuggest
|
||||
return
|
||||
}
|
||||
|
||||
// Cancel previous request
|
||||
abortRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
|
||||
const generateTitles = async () => {
|
||||
setIsAnalyzing(true)
|
||||
@@ -44,8 +49,10 @@ export function useTitleSuggestions({ content, enabled = true }: UseTitleSuggest
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: debouncedContent }),
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
if (controller.signal.aborted) return
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
@@ -54,17 +61,25 @@ export function useTitleSuggestions({ content, enabled = true }: UseTitleSuggest
|
||||
|
||||
const data = await response.json()
|
||||
setSuggestions(data.suggestions || [])
|
||||
} catch (err) {
|
||||
console.error('❌ Title suggestions error:', err)
|
||||
} catch (err: any) {
|
||||
if (err.name === 'AbortError') return
|
||||
console.error('Title suggestions error:', err)
|
||||
setError('Failed to generate title suggestions')
|
||||
} finally {
|
||||
setIsAnalyzing(false)
|
||||
if (!controller.signal.aborted) {
|
||||
setIsAnalyzing(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
generateTitles()
|
||||
}, [debouncedContent, enabled])
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => { abortRef.current?.abort(); };
|
||||
}, []);
|
||||
|
||||
return {
|
||||
suggestions,
|
||||
isAnalyzing,
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import { deepEqual } from '@/lib/utils'
|
||||
|
||||
export interface UndoRedoState<T> {
|
||||
past: T[]
|
||||
present: T
|
||||
future: T[]
|
||||
}
|
||||
|
||||
interface UseUndoRedoReturn<T> {
|
||||
state: T
|
||||
setState: (newState: T | ((prev: T) => T)) => void
|
||||
undo: () => void
|
||||
redo: () => void
|
||||
canUndo: boolean
|
||||
canRedo: boolean
|
||||
clear: () => void
|
||||
}
|
||||
|
||||
const MAX_HISTORY_SIZE = 50
|
||||
|
||||
export function useUndoRedo<T>(initialState: T): UseUndoRedoReturn<T> {
|
||||
const [history, setHistory] = useState<UndoRedoState<T>>({
|
||||
past: [],
|
||||
present: initialState,
|
||||
future: [],
|
||||
})
|
||||
|
||||
// Track if we're in an undo/redo operation to prevent adding to history
|
||||
const isUndoRedoAction = useRef(false)
|
||||
|
||||
const setState = useCallback((newState: T | ((prev: T) => T)) => {
|
||||
// Skip if this is an undo/redo action
|
||||
if (isUndoRedoAction.current) {
|
||||
isUndoRedoAction.current = false
|
||||
return
|
||||
}
|
||||
|
||||
setHistory((currentHistory) => {
|
||||
const resolvedNewState =
|
||||
typeof newState === 'function'
|
||||
? (newState as (prev: T) => T)(currentHistory.present)
|
||||
: newState
|
||||
|
||||
// Don't add to history if state hasn't changed
|
||||
if (deepEqual(resolvedNewState, currentHistory.present)) {
|
||||
return currentHistory
|
||||
}
|
||||
|
||||
const newPast = [...currentHistory.past, currentHistory.present]
|
||||
|
||||
// Limit history size
|
||||
if (newPast.length > MAX_HISTORY_SIZE) {
|
||||
newPast.shift()
|
||||
}
|
||||
|
||||
return {
|
||||
past: newPast,
|
||||
present: resolvedNewState,
|
||||
future: [], // Clear future on new action
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const undo = useCallback(() => {
|
||||
setHistory((currentHistory) => {
|
||||
if (currentHistory.past.length === 0) return currentHistory
|
||||
|
||||
const previous = currentHistory.past[currentHistory.past.length - 1]
|
||||
const newPast = currentHistory.past.slice(0, currentHistory.past.length - 1)
|
||||
|
||||
isUndoRedoAction.current = true
|
||||
|
||||
return {
|
||||
past: newPast,
|
||||
present: previous,
|
||||
future: [currentHistory.present, ...currentHistory.future],
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const redo = useCallback(() => {
|
||||
setHistory((currentHistory) => {
|
||||
if (currentHistory.future.length === 0) return currentHistory
|
||||
|
||||
const next = currentHistory.future[0]
|
||||
const newFuture = currentHistory.future.slice(1)
|
||||
|
||||
isUndoRedoAction.current = true
|
||||
|
||||
return {
|
||||
past: [...currentHistory.past, currentHistory.present],
|
||||
present: next,
|
||||
future: newFuture,
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setHistory({
|
||||
past: [],
|
||||
present: initialState,
|
||||
future: [],
|
||||
})
|
||||
}, [initialState])
|
||||
|
||||
return {
|
||||
state: history.present,
|
||||
setState,
|
||||
undo,
|
||||
redo,
|
||||
canUndo: history.past.length > 0,
|
||||
canRedo: history.future.length > 0,
|
||||
clear,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user