479 lines
14 KiB
TypeScript
479 lines
14 KiB
TypeScript
/**
|
||
* 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
|
||
}
|