Files
laude/src/main/validate.ts

479 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Hand-rolled runtime validators for IPC payloads.
*
* TypeScript types are erased at compile time — a compromised or buggy
* renderer can still send arbitrary JSON across the IPC boundary. These
* helpers enforce shape, type and range BEFORE the data hits the store.
*
* Philosophy: be lenient with unknown fields (drop them silently), strict
* about known fields (reject the call if a known field is the wrong type
* or out of range). Never throw to the renderer; return a sanitised value
* or `null` and the caller decides what to do.
*/
import type {
Challenge,
Exercise,
GameId,
GameStat,
Meal,
Settings,
Theme,
Language,
NotificationMode,
ReminderCategory
} from '@shared/types'
const MAX_STR_LEN = 200
const VALID_THEMES: Theme[] = ['system', 'light', 'dark']
const VALID_LANGS: Language[] = ['ru', 'en']
const VALID_NOTIFY: NotificationMode[] = ['toast', 'modal', 'both']
const VALID_GAME_IDS: GameId[] = ['dota2']
const VALID_STATS: GameStat[] = [
'deaths',
'kills',
'assists',
'last_hits',
'denies',
'duration_min'
]
const VALID_CATEGORIES: ReminderCategory[] = [
'exercise',
'hydration',
'eyes',
'posture'
]
function isObj(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v)
}
function safeStr(v: unknown, max = MAX_STR_LEN): string | undefined {
if (typeof v !== 'string') return undefined
if (v.length === 0 || v.length > max) return undefined
return v
}
function intInRange(v: unknown, min: number, max: number): number | undefined {
if (typeof v !== 'number' || !Number.isFinite(v)) return undefined
const n = Math.trunc(v)
if (n < min || n > max) return undefined
return n
}
function numInRange(v: unknown, min: number, max: number): number | undefined {
if (typeof v !== 'number' || !Number.isFinite(v)) return undefined
if (v < min || v > max) return undefined
return v
}
function bool(v: unknown): boolean | undefined {
return typeof v === 'boolean' ? v : undefined
}
function oneOf<T extends string>(
v: unknown,
allowed: readonly T[]
): T | undefined {
return typeof v === 'string' && (allowed as readonly string[]).includes(v)
? (v as T)
: undefined
}
/**
* Строгая проверка "HH:MM": не только форма, но и диапазон (часы 0..23,
* минуты 0..59). В отличие от HHMM_RE (используется в quietHours лишь для
* формы) — приём пищи с временем '25:00' сломал бы nextMealOccurrence.
*/
function validHHMM(v: unknown): string | undefined {
const s = safeStr(v, 8)
if (s === undefined) return undefined
const m = /^(\d{1,2}):(\d{2})$/.exec(s)
if (!m) return undefined
const h = Number(m[1])
const min = Number(m[2])
if (h > 23 || min > 59) return undefined
return s
}
/** Дни недели: массив целых 0..6 без дубликатов. null = невалидно. */
function weekdays(v: unknown): number[] | null {
if (!Array.isArray(v)) return null
const out: number[] = []
for (const d of v) {
const n = intInRange(d, 0, 6)
if (n === undefined) return null
if (!out.includes(n)) out.push(n)
}
return out
}
// -----------------------------------------------------------------------
// Exercise validators
// -----------------------------------------------------------------------
export function validateExerciseInput(
raw: unknown
): Omit<Exercise, 'id' | 'nextFireAt' | 'lastDoneAt'> | null {
if (!isObj(raw)) return null
const name = safeStr(raw.name)
const reps = intInRange(raw.reps, 1, 9999)
const intervalMinutes = intInRange(raw.intervalMinutes, 1, 24 * 60)
const icon = safeStr(raw.icon, 64) ?? 'Activity'
const enabled = bool(raw.enabled) ?? true
const category = oneOf(raw.category, VALID_CATEGORIES) // undefined OK = default
const dailyGoal =
raw.dailyGoal === undefined || raw.dailyGoal === null
? undefined
: intInRange(raw.dailyGoal, 1, 100_000)
// dailyGoal: undefined = не задан (нет soft-cap'a), null от UI приводим к
// undefined; иначе — должен пройти int-range, иначе reject (нельзя
// отправить из renderer'а NaN/негатив и тихо обнулить).
if (
raw.dailyGoal !== undefined &&
raw.dailyGoal !== null &&
dailyGoal === undefined
) {
return null
}
if (
name === undefined ||
reps === undefined ||
intervalMinutes === undefined
) {
return null
}
const out: Omit<Exercise, 'id' | 'nextFireAt' | 'lastDoneAt'> = {
name,
reps,
intervalMinutes,
icon,
enabled
}
if (category !== undefined) out.category = category
if (dailyGoal !== undefined) out.dailyGoal = dailyGoal
const adaptive = bool(raw.adaptive)
if (adaptive !== undefined) out.adaptive = adaptive
return out
}
export function validateExercisePatch(
raw: unknown
): Partial<Omit<Exercise, 'id'>> | null {
if (!isObj(raw)) return null
const out: Partial<Omit<Exercise, 'id'>> = {}
if ('name' in raw) {
const v = safeStr(raw.name)
if (v === undefined) return null
out.name = v
}
if ('reps' in raw) {
const v = intInRange(raw.reps, 1, 9999)
if (v === undefined) return null
out.reps = v
}
if ('intervalMinutes' in raw) {
const v = intInRange(raw.intervalMinutes, 1, 24 * 60)
if (v === undefined) return null
out.intervalMinutes = v
}
if ('icon' in raw) {
const v = safeStr(raw.icon, 64)
if (v === undefined) return null
out.icon = v
}
if ('enabled' in raw) {
const v = bool(raw.enabled)
if (v === undefined) return null
out.enabled = v
}
if ('category' in raw) {
const v = oneOf(raw.category, VALID_CATEGORIES)
if (v === undefined) return null
out.category = v
}
if ('dailyGoal' in raw) {
// Допустим null/undefined как «снять goal».
if (raw.dailyGoal === null || raw.dailyGoal === undefined) {
out.dailyGoal = undefined
} else {
const v = intInRange(raw.dailyGoal, 1, 100_000)
if (v === undefined) return null
out.dailyGoal = v
}
}
if ('adaptive' in raw) {
const v = bool(raw.adaptive)
if (v === undefined) return null
out.adaptive = v
}
// Allow scheduler-controlled fields to be patched (used by store.markDone
// through this same boundary), but range-check them.
if ('nextFireAt' in raw) {
const v = numInRange(raw.nextFireAt, 0, Number.MAX_SAFE_INTEGER)
if (v === undefined) return null
out.nextFireAt = v
}
if ('lastDoneAt' in raw) {
const v = numInRange(raw.lastDoneAt, 0, Number.MAX_SAFE_INTEGER)
if (v === undefined) return null
out.lastDoneAt = v
}
return out
}
// -----------------------------------------------------------------------
// Meal validators (приёмы пищи — по времени суток)
// -----------------------------------------------------------------------
export function validateMealInput(
raw: unknown
): Omit<Meal, 'id' | 'nextFireAt' | 'lastDoneAt'> | null {
if (!isObj(raw)) return null
const name = safeStr(raw.name)
const time = validHHMM(raw.time)
const icon = safeStr(raw.icon, 64) ?? 'UtensilsCrossed'
const enabled = bool(raw.enabled) ?? true
const days = weekdays(raw.days)
if (name === undefined || time === undefined || days === null) {
return null
}
return { name, time, icon, enabled, days }
}
export function validateMealPatch(
raw: unknown
): Partial<Omit<Meal, 'id'>> | null {
if (!isObj(raw)) return null
const out: Partial<Omit<Meal, 'id'>> = {}
if ('name' in raw) {
const v = safeStr(raw.name)
if (v === undefined) return null
out.name = v
}
if ('time' in raw) {
const v = validHHMM(raw.time)
if (v === undefined) return null
out.time = v
}
if ('icon' in raw) {
const v = safeStr(raw.icon, 64)
if (v === undefined) return null
out.icon = v
}
if ('enabled' in raw) {
const v = bool(raw.enabled)
if (v === undefined) return null
out.enabled = v
}
if ('days' in raw) {
const v = weekdays(raw.days)
if (v === null) return null
out.days = v
}
// Scheduler-controlled fields (store reschedules через тот же boundary).
if ('nextFireAt' in raw) {
const v = numInRange(raw.nextFireAt, 0, Number.MAX_SAFE_INTEGER)
if (v === undefined) return null
out.nextFireAt = v
}
if ('lastDoneAt' in raw) {
const v = numInRange(raw.lastDoneAt, 0, Number.MAX_SAFE_INTEGER)
if (v === undefined) return null
out.lastDoneAt = v
}
return out
}
// -----------------------------------------------------------------------
// Challenge validators
// -----------------------------------------------------------------------
export function validateChallengeInput(
raw: unknown
): Omit<Challenge, 'id'> | null {
if (!isObj(raw)) return null
const name = safeStr(raw.name)
const gameId = oneOf(raw.gameId, VALID_GAME_IDS)
const stat = oneOf(raw.stat, VALID_STATS)
const multiplier = numInRange(raw.multiplier, 0, 1000)
const exerciseName = safeStr(raw.exerciseName)
const icon = safeStr(raw.icon, 64) ?? 'Activity'
const enabled = bool(raw.enabled) ?? true
if (
name === undefined ||
gameId === undefined ||
stat === undefined ||
multiplier === undefined ||
exerciseName === undefined
) {
return null
}
return {
name,
gameId,
stat,
multiplier,
exerciseName,
icon,
enabled
}
}
export function validateChallengePatch(
raw: unknown
): Partial<Omit<Challenge, 'id'>> | null {
if (!isObj(raw)) return null
const out: Partial<Omit<Challenge, 'id'>> = {}
if ('name' in raw) {
const v = safeStr(raw.name)
if (v === undefined) return null
out.name = v
}
if ('exerciseName' in raw) {
const v = safeStr(raw.exerciseName)
if (v === undefined) return null
out.exerciseName = v
}
if ('stat' in raw) {
const v = oneOf(raw.stat, VALID_STATS)
if (v === undefined) return null
out.stat = v
}
if ('multiplier' in raw) {
const v = numInRange(raw.multiplier, 0, 1000)
if (v === undefined) return null
out.multiplier = v
}
if ('icon' in raw) {
const v = safeStr(raw.icon, 64)
if (v === undefined) return null
out.icon = v
}
if ('enabled' in raw) {
const v = bool(raw.enabled)
if (v === undefined) return null
out.enabled = v
}
return out
}
// -----------------------------------------------------------------------
// Settings validators
// -----------------------------------------------------------------------
export function validateSettingsPatch(raw: unknown): Partial<Settings> | null {
if (!isObj(raw)) return null
const out: Partial<Settings> = {}
if ('globalEnabled' in raw) {
const v = bool(raw.globalEnabled)
if (v === undefined) return null
out.globalEnabled = v
}
if ('startWithWindows' in raw) {
const v = bool(raw.startWithWindows)
if (v === undefined) return null
out.startWithWindows = v
}
if ('startMinimized' in raw) {
const v = bool(raw.startMinimized)
if (v === undefined) return null
out.startMinimized = v
}
if ('minimizeToTray' in raw) {
const v = bool(raw.minimizeToTray)
if (v === undefined) return null
out.minimizeToTray = v
}
if ('soundEnabled' in raw) {
const v = bool(raw.soundEnabled)
if (v === undefined) return null
out.soundEnabled = v
}
if ('voicePromptsEnabled' in raw) {
const v = bool(raw.voicePromptsEnabled)
if (v === undefined) return null
out.voicePromptsEnabled = v
}
if ('meetingAutoPause' in raw) {
const v = bool(raw.meetingAutoPause)
if (v === undefined) return null
out.meetingAutoPause = v
}
if ('lastSeenVersion' in raw) {
// Принимаем строку 0.0.0 .. 999.999.999 (semver-light) или null/undefined
// для сброса.
if (raw.lastSeenVersion === null || raw.lastSeenVersion === undefined) {
out.lastSeenVersion = undefined
} else {
const v = safeStr(raw.lastSeenVersion, 32)
if (v === undefined || !/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(v)) return null
out.lastSeenVersion = v
}
}
if ('notificationMode' in raw) {
const v = oneOf(raw.notificationMode, VALID_NOTIFY)
if (v === undefined) return null
out.notificationMode = v
}
if ('theme' in raw) {
const v = oneOf(raw.theme, VALID_THEMES)
if (v === undefined) return null
out.theme = v
}
if ('language' in raw) {
const v = oneOf(raw.language, VALID_LANGS)
if (v === undefined) return null
out.language = v
}
if ('snoozeMinutes' in raw) {
const v = intInRange(raw.snoozeMinutes, 1, 24 * 60)
if (v === undefined) return null
out.snoozeMinutes = v
}
if ('quietHours' in raw) {
const qh = raw.quietHours
if (!isObj(qh)) return null
const enabled = bool(qh.enabled)
const from = safeStr(qh.from, 8)
const to = safeStr(qh.to, 8)
if (
enabled === undefined ||
from === undefined ||
to === undefined ||
validHHMM(from) === undefined ||
validHHMM(to) === undefined
) {
return null
}
if (!Array.isArray(qh.days)) return null
const days: number[] = []
for (const d of qh.days) {
const n = intInRange(d, 0, 6)
if (n === undefined) return null
if (!days.includes(n)) days.push(n)
}
out.quietHours = { enabled, from, to, days }
}
return out
}
// -----------------------------------------------------------------------
// Misc tiny validators
// -----------------------------------------------------------------------
export function validateId(raw: unknown): string | null {
// UUIDs from store.ts via randomUUID(); accept any reasonable string id.
const v = safeStr(raw, 64)
return v ?? null
}
export function validateActualReps(raw: unknown): number | undefined {
if (raw === undefined || raw === null) return undefined
return intInRange(raw, 0, 100000) ?? undefined
}
export function validateSnoozeMinutes(raw: unknown): number | null {
return intInRange(raw, 1, 24 * 60) ?? null
}