V13-FE-006: consolidate approved UI and contract hardening
This commit is contained in:
+103
@@ -0,0 +1,103 @@
|
||||
import type { InjectionKey, Ref } from 'vue'
|
||||
import type { KbxScreenPreference } from '@kbx/contracts'
|
||||
|
||||
const PREFIX = 'kbx.screen.preference.v2'
|
||||
const MAX_STORED_BYTES = 32_000
|
||||
const MAX_SCOPE_LENGTH = 160
|
||||
const MAX_ID_LENGTH = 128
|
||||
const MAX_VERSION_LENGTH = 40
|
||||
const MAX_SEARCH_KEYS = 24
|
||||
const MAX_STRING_LENGTH = 256
|
||||
|
||||
export const KbxScreenPreferenceScopeKey: InjectionKey<Readonly<Ref<string>>> = Symbol('KbxScreenPreferenceScope')
|
||||
|
||||
function bounded(value: unknown, max: number) {
|
||||
if (typeof value !== 'string') return ''
|
||||
const clean = value.trim()
|
||||
return clean.length <= max ? clean : ''
|
||||
}
|
||||
function normalizeScope(scope: unknown) { return bounded(scope, MAX_SCOPE_LENGTH) }
|
||||
function normalizeId(value: unknown) { return bounded(value, MAX_ID_LENGTH) }
|
||||
function normalizeVersion(value: unknown) { return bounded(value, MAX_VERSION_LENGTH) }
|
||||
function storageKey(scope: string, screenId: string, screenVersion: string) {
|
||||
return `${PREFIX}:${encodeURIComponent(scope)}:${encodeURIComponent(screenId)}:${encodeURIComponent(screenVersion)}`
|
||||
}
|
||||
function safeScalar(value: unknown): string | number | boolean | null | undefined {
|
||||
if (value === null || typeof value === 'boolean') return value
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value
|
||||
if (typeof value === 'string') return value.slice(0, MAX_STRING_LENGTH)
|
||||
return undefined
|
||||
}
|
||||
function sanitizeJsonValue(value: unknown, depth=0): unknown {
|
||||
if (depth > 4) return undefined
|
||||
const scalar = safeScalar(value)
|
||||
if (scalar !== undefined) return scalar
|
||||
if (Array.isArray(value)) return value.slice(0, 100).map(item => sanitizeJsonValue(item, depth + 1)).filter(item => item !== undefined)
|
||||
if (!value || typeof value !== 'object') return undefined
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const [key, raw] of Object.entries(value).slice(0, 100)) {
|
||||
const safeKey = bounded(key, 64)
|
||||
const safeValue = sanitizeJsonValue(raw, depth + 1)
|
||||
if (safeKey && safeValue !== undefined) result[safeKey] = safeValue
|
||||
}
|
||||
return result
|
||||
}
|
||||
function sanitizeSearchDefaults(value: unknown) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
|
||||
const result: Record<string, string | number | boolean | null> = {}
|
||||
for (const [key, raw] of Object.entries(value).slice(0, MAX_SEARCH_KEYS)) {
|
||||
const safeKey = bounded(key, 64)
|
||||
const safeValue = safeScalar(raw)
|
||||
if (safeKey && safeValue !== undefined) result[safeKey] = safeValue
|
||||
}
|
||||
return Object.keys(result).length ? result : undefined
|
||||
}
|
||||
function normalizePreference(value: unknown, screenId: string, screenVersion: string): KbxScreenPreference {
|
||||
const raw = value && typeof value === 'object' ? value as Partial<KbxScreenPreference> : {}
|
||||
const density = ['compact', 'comfortable', 'touch'].includes(String(raw.density)) ? raw.density : undefined
|
||||
const pageSize = Number(raw.grid?.pageSize)
|
||||
const columnState = sanitizeJsonValue(raw.grid?.columnState)
|
||||
const searchDefaults = sanitizeSearchDefaults(raw.searchDefaults)
|
||||
const grid = raw.grid && (Number.isFinite(pageSize) || columnState !== undefined) ? {
|
||||
...(columnState !== undefined ? { columnState } : {}),
|
||||
...(Number.isFinite(pageSize) ? { pageSize:Math.min(500, Math.max(10, pageSize)) } : {}),
|
||||
} : undefined
|
||||
return {
|
||||
screenId,
|
||||
screenVersion,
|
||||
...(density ? { density } : {}),
|
||||
...(grid ? { grid } : {}),
|
||||
...(searchDefaults ? { searchDefaults } : {}),
|
||||
rememberSearch: Boolean(raw.rememberSearch),
|
||||
}
|
||||
}
|
||||
|
||||
export function loadScreenPreference(scopeValue: string, screenIdValue: string, screenVersionValue: string): KbxScreenPreference | null {
|
||||
const scope = normalizeScope(scopeValue)
|
||||
const screenId = normalizeId(screenIdValue)
|
||||
const screenVersion = normalizeVersion(screenVersionValue)
|
||||
if (!scope || !screenId || !screenVersion || typeof localStorage === 'undefined') return null
|
||||
let raw = ''
|
||||
try { raw = localStorage.getItem(storageKey(scope, screenId, screenVersion)) ?? '' } catch { return null }
|
||||
if (!raw || raw.length > MAX_STORED_BYTES) return null
|
||||
try { return normalizePreference(JSON.parse(raw), screenId, screenVersion) } catch { return null }
|
||||
}
|
||||
|
||||
export function saveScreenPreference(scopeValue: string, preference: KbxScreenPreference) {
|
||||
const scope = normalizeScope(scopeValue)
|
||||
const screenId = normalizeId(preference.screenId)
|
||||
const screenVersion = normalizeVersion(preference.screenVersion)
|
||||
if (!scope || !screenId || !screenVersion || typeof localStorage === 'undefined') return false
|
||||
const normalized = normalizePreference(preference, screenId, screenVersion)
|
||||
const payload = JSON.stringify(normalized)
|
||||
if (payload.length > MAX_STORED_BYTES) return false
|
||||
try { localStorage.setItem(storageKey(scope, screenId, screenVersion), payload); return true } catch { return false }
|
||||
}
|
||||
|
||||
export function clearScreenPreference(scopeValue: string, screenIdValue: string, screenVersionValue: string) {
|
||||
const scope = normalizeScope(scopeValue)
|
||||
const screenId = normalizeId(screenIdValue)
|
||||
const screenVersion = normalizeVersion(screenVersionValue)
|
||||
if (!scope || !screenId || !screenVersion || typeof localStorage === 'undefined') return false
|
||||
try { localStorage.removeItem(storageKey(scope, screenId, screenVersion)); return true } catch { return false }
|
||||
}
|
||||
Reference in New Issue
Block a user