diff --git a/app/list/[id].tsx b/app/list/[id].tsx index 6f13c70..e81c045 100644 --- a/app/list/[id].tsx +++ b/app/list/[id].tsx @@ -15,6 +15,7 @@ import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { colors } from '@/src/theme/colors'; import { useSettingsStore } from '@/src/stores/useSettingsStore'; +import { isValidUUID } from '@/src/lib/validation'; import { useTaskStore } from '@/src/stores/useTaskStore'; import { getTasksByList, toggleComplete, deleteTask, reorderTasks } from '@/src/db/repository/tasks'; import { getAllLists } from '@/src/db/repository/lists'; @@ -64,7 +65,7 @@ export default function ListDetailScreen() { const { sortBy, sortOrder, filterPriority, filterTag, filterCompleted, filterDueDate, hasActiveFilters } = useTaskStore(); const loadData = useCallback(async () => { - if (!id || isDraggingRef.current) return; + if (!isValidUUID(id) || isDraggingRef.current) return; const result = await getTasksByList(id, { sortBy, sortOrder, filterPriority, filterTag, filterCompleted, filterDueDate, }); diff --git a/app/task/[id].tsx b/app/task/[id].tsx index bc7a3f0..2a3360a 100644 --- a/app/task/[id].tsx +++ b/app/task/[id].tsx @@ -17,6 +17,7 @@ import DateTimePicker, { DateTimePickerEvent } from '@react-native-community/dat import { colors } from '@/src/theme/colors'; import { useSettingsStore } from '@/src/stores/useSettingsStore'; +import { isValidUUID } from '@/src/lib/validation'; import { getPriorityOptions } from '@/src/lib/priority'; import { RECURRENCE_OPTIONS } from '@/src/lib/recurrence'; import { @@ -65,7 +66,10 @@ export default function TaskDetailScreen() { const [selectedTagIds, setSelectedTagIds] = useState([]); useEffect(() => { - if (!id) return; + if (!isValidUUID(id)) { + router.back(); + return; + } loadTask(); loadSubtasks(); getAllTags().then(setAvailableTags); diff --git a/app/task/new.tsx b/app/task/new.tsx index 71da841..55b025d 100644 --- a/app/task/new.tsx +++ b/app/task/new.tsx @@ -21,6 +21,7 @@ import DateTimePicker, { DateTimePickerEvent } from '@react-native-community/dat import { colors } from '@/src/theme/colors'; import { useSettingsStore } from '@/src/stores/useSettingsStore'; +import { isValidUUID } from '@/src/lib/validation'; import { createTask } from '@/src/db/repository/tasks'; import { getInboxId, getAllLists } from '@/src/db/repository/lists'; import { getAllTags, setTagsForTask } from '@/src/db/repository/tags'; @@ -47,7 +48,9 @@ export default function NewTaskScreen() { const [priority, setPriority] = useState(0); const [dueDate, setDueDate] = useState(null); const [showDatePicker, setShowDatePicker] = useState(false); - const [selectedListId, setSelectedListId] = useState(params.listId ?? getInboxId()); + const [selectedListId, setSelectedListId] = useState( + isValidUUID(params.listId) ? params.listId : getInboxId() + ); const [lists, setLists] = useState<{ id: string; name: string; color: string | null; icon: string | null; isInbox: boolean }[]>([]); const [recurrence, setRecurrence] = useState(null); const [availableTags, setAvailableTags] = useState<{ id: string; name: string; color: string }[]>([]); @@ -60,18 +63,23 @@ export default function NewTaskScreen() { const handleSave = async () => { if (!title.trim()) return; - const taskId = await createTask({ - title: title.trim(), - notes: notes.trim() || undefined, - priority, - dueDate: dueDate ?? undefined, - listId: selectedListId, - recurrence: recurrence ?? undefined, - }); - if (selectedTagIds.length > 0) { - await setTagsForTask(taskId, selectedTagIds); + try { + const taskId = await createTask({ + title: title.trim(), + notes: notes.trim() || undefined, + priority, + dueDate: dueDate ?? undefined, + listId: selectedListId, + recurrence: recurrence ?? undefined, + }); + if (selectedTagIds.length > 0) { + await setTagsForTask(taskId, selectedTagIds); + } + router.back(); + } catch { + // FK constraint or other DB error — fallback to inbox + setSelectedListId(getInboxId()); } - router.back(); }; const handleDateChange = (_: DateTimePickerEvent, date?: Date) => { diff --git a/src/db/repository/lists.ts b/src/db/repository/lists.ts index 7250f1f..0f3041f 100644 --- a/src/db/repository/lists.ts +++ b/src/db/repository/lists.ts @@ -2,6 +2,7 @@ import { eq } from 'drizzle-orm'; import { db } from '../client'; import { lists } from '../schema'; import { randomUUID } from '@/src/lib/uuid'; +import { truncate } from '@/src/lib/validation'; const INBOX_ID = '00000000-0000-0000-0000-000000000001'; @@ -37,7 +38,7 @@ export async function createList(name: string, color?: string, icon?: string) { await db.insert(lists).values({ id, - name, + name: truncate(name, 200), color: color ?? null, icon: icon ?? null, position: maxPosition + 1, @@ -49,9 +50,11 @@ export async function createList(name: string, color?: string, icon?: string) { } export async function updateList(id: string, data: { name?: string; color?: string; icon?: string | null }) { + const sanitized = { ...data }; + if (sanitized.name !== undefined) sanitized.name = truncate(sanitized.name, 200); await db .update(lists) - .set({ ...data, updatedAt: new Date() }) + .set({ ...sanitized, updatedAt: new Date() }) .where(eq(lists.id, id)); } diff --git a/src/db/repository/tags.ts b/src/db/repository/tags.ts index 348c941..71a015e 100644 --- a/src/db/repository/tags.ts +++ b/src/db/repository/tags.ts @@ -2,6 +2,7 @@ import { eq, and } from 'drizzle-orm'; import { db } from '../client'; import { tags, taskTags } from '../schema'; import { randomUUID } from '@/src/lib/uuid'; +import { truncate } from '@/src/lib/validation'; export async function getAllTags() { return db.select().from(tags).orderBy(tags.name); @@ -11,7 +12,7 @@ export async function createTag(name: string, color: string) { const id = randomUUID(); await db.insert(tags).values({ id, - name, + name: truncate(name, 100), color, createdAt: new Date(), }); @@ -19,7 +20,7 @@ export async function createTag(name: string, color: string) { } export async function updateTag(id: string, name: string, color: string) { - await db.update(tags).set({ name, color }).where(eq(tags.id, id)); + await db.update(tags).set({ name: truncate(name, 100), color }).where(eq(tags.id, id)); } export async function deleteTag(id: string) { diff --git a/src/db/repository/tasks.ts b/src/db/repository/tasks.ts index 37687ef..0d5d376 100644 --- a/src/db/repository/tasks.ts +++ b/src/db/repository/tasks.ts @@ -9,6 +9,8 @@ import { scheduleTaskReminder, cancelTaskReminder } from '@/src/services/notific import { addTaskToCalendar, updateCalendarEvent, removeCalendarEvent } from '@/src/services/calendar'; import { useSettingsStore } from '@/src/stores/useSettingsStore'; import { syncWidgetData } from '@/src/services/widgetSync'; +import { clamp, truncate } from '@/src/lib/validation'; +import { RECURRENCE_OPTIONS } from '@/src/lib/recurrence'; export interface TaskFilters { sortBy?: SortBy; @@ -145,17 +147,21 @@ export async function createTask(data: { } } + const sanitizedRecurrence = data.recurrence && RECURRENCE_OPTIONS.includes(data.recurrence as any) + ? data.recurrence + : null; + await db.insert(tasks).values({ id, - title: data.title, - notes: data.notes ?? null, - priority: data.priority ?? 0, + title: truncate(data.title, 500), + notes: data.notes ? truncate(data.notes, 10000) : null, + priority: clamp(data.priority ?? 0, 0, 3), dueDate: data.dueDate ?? null, listId: data.listId, parentId: data.parentId ?? null, completed: false, position: maxPosition + 1, - recurrence: data.recurrence ?? null, + recurrence: sanitizedRecurrence, calendarEventId, createdAt: now, updatedAt: now, @@ -187,10 +193,20 @@ export async function updateTask( recurrence?: string | null; } ) { - const updates: Record = { ...data, updatedAt: new Date() }; - if (data.completed === true) { + const sanitized = { ...data }; + if (sanitized.title !== undefined) sanitized.title = truncate(sanitized.title, 500); + if (sanitized.notes !== undefined) sanitized.notes = truncate(sanitized.notes, 10000); + if (sanitized.priority !== undefined) sanitized.priority = clamp(sanitized.priority, 0, 3); + if (sanitized.recurrence !== undefined && sanitized.recurrence !== null) { + sanitized.recurrence = RECURRENCE_OPTIONS.includes(sanitized.recurrence as any) + ? sanitized.recurrence + : null; + } + + const updates: Record = { ...sanitized, updatedAt: new Date() }; + if (sanitized.completed === true) { updates.completedAt = new Date(); - } else if (data.completed === false) { + } else if (sanitized.completed === false) { updates.completedAt = null; } await db.update(tasks).set(updates).where(eq(tasks.id, id)); diff --git a/src/lib/validation.ts b/src/lib/validation.ts new file mode 100644 index 0000000..bccd278 --- /dev/null +++ b/src/lib/validation.ts @@ -0,0 +1,13 @@ +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export function isValidUUID(value: unknown): value is string { + return typeof value === 'string' && UUID_RE.test(value); +} + +export function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +export function truncate(value: string, maxLength: number): string { + return value.length > maxLength ? value.slice(0, maxLength) : value; +} diff --git a/src/widgets/widgetTaskHandler.ts b/src/widgets/widgetTaskHandler.ts index 5eaa7c3..2310cda 100644 --- a/src/widgets/widgetTaskHandler.ts +++ b/src/widgets/widgetTaskHandler.ts @@ -2,12 +2,27 @@ import type { WidgetTaskHandlerProps } from 'react-native-android-widget'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { TaskListWidget } from './TaskListWidget'; import { WIDGET_DATA_KEY, type WidgetTask } from '../services/widgetSync'; +import { isValidUUID } from '../lib/validation'; + +function isWidgetTask(item: unknown): item is WidgetTask { + if (typeof item !== 'object' || item === null) return false; + const obj = item as Record; + return ( + typeof obj.id === 'string' && + typeof obj.title === 'string' && + typeof obj.priority === 'number' && + typeof obj.completed === 'boolean' && + (obj.dueDate === null || typeof obj.dueDate === 'string') + ); +} async function getWidgetTasks(): Promise { try { const data = await AsyncStorage.getItem(WIDGET_DATA_KEY); if (!data) return []; - return JSON.parse(data) as WidgetTask[]; + const parsed: unknown = JSON.parse(data); + if (!Array.isArray(parsed)) return []; + return parsed.filter(isWidgetTask); } catch { return []; } @@ -38,8 +53,8 @@ export async function widgetTaskHandler( case 'WIDGET_CLICK': { if (props.clickAction === 'TOGGLE_COMPLETE') { - const taskId = props.clickActionData?.taskId as string; - if (!taskId) break; + const taskId = props.clickActionData?.taskId; + if (!isValidUUID(taskId)) break; // Update the cached data to remove the completed task immediately const tasks = await getWidgetTasks();