diff --git a/server/src/ledgrab/static/js/core/events-ws.ts b/server/src/ledgrab/static/js/core/events-ws.ts index 4c78c3a..339170b 100644 --- a/server/src/ledgrab/static/js/core/events-ws.ts +++ b/server/src/ledgrab/static/js/core/events-ws.ts @@ -14,6 +14,55 @@ import { showRestartingOverlay } from './api.ts'; import { logError } from './log.ts'; import { openAuthedWs } from './ws-auth.ts'; +/** + * Allowed ``type`` values on inbound server-event messages. Anything outside + * this list is rejected before dispatch so a malformed message can't + * synthesise an arbitrary ``server:*`` CustomEvent. New event types must be + * added here intentionally — the server is the schema's source of truth. + * + * Audit (matches Python sources for ``fire_event`` / ``_fire_event`` / + * ``self._emit`` / ``fire_entity_event`` call sites — see the parity + * regression test in ``server/tests/test_events_ws_parity.py``): + * server_restarting — server_ref.py / update_service.py + * state_change — wled_target_processor.py / auto_restart.py + * automation_state_changed — automation_engine.py + * entity_changed — dependencies.fire_entity_event + * device_health_changed — device_health.py + * update_available — update_service.py (consumed by features/update.ts) + * update_download_progress — update_service.py (consumed by features/update.ts) + * device_discovered — discovery_watcher.py (consumed by features/notifications-watcher.ts) + * device_lost — discovery_watcher.py (consumed by features/notifications-watcher.ts) + * + * Missing any of these silently breaks the corresponding UI flow — keep + * this list in sync when adding new event types on the server side. + */ +const _ALLOWED_SERVER_EVENT_TYPES: ReadonlySet = new Set([ + 'server_restarting', + 'state_change', + 'automation_state_changed', + 'entity_changed', + 'device_health_changed', + 'update_available', + 'update_download_progress', + 'device_discovered', + 'device_lost', +]); + +interface ServerEventEnvelope { + type: string; + [key: string]: unknown; +} + +function _isServerEventEnvelope(value: unknown): value is ServerEventEnvelope { + if (!value || typeof value !== 'object') return false; + const t = (value as { type?: unknown }).type; + if (typeof t !== 'string' || !_ALLOWED_SERVER_EVENT_TYPES.has(t)) return false; + // Event-name character set: identifiers only. CustomEvent names can be + // anything but pinning them keeps the listener namespace predictable. + if (!/^[a-zA-Z0-9_]+$/.test(t)) return false; + return true; +} + /** True when the server has signalled it is restarting (not crashed). */ export let serverRestarting = false; @@ -40,7 +89,15 @@ export function startEventsWS() { ws.onmessage = (event) => { try { - const data = JSON.parse(event.data); + const data: unknown = JSON.parse(event.data); + // Validate the envelope before we dispatch — without this, + // a malformed/hostile server message becomes an arbitrary + // ``server:*`` CustomEvent on document, which feature + // listeners then trust. + if (!_isServerEventEnvelope(data)) { + logError('events-ws.message', `Discarded malformed server message`); + return; + } if (data.type === 'server_restarting') { serverRestarting = true; showRestartingOverlay(); @@ -53,7 +110,9 @@ export function startEventsWS() { _reconnectTimer = setTimeout(startEventsWS, _reconnectDelay); _reconnectDelay = Math.min(_reconnectDelay * 2, _RECONNECT_MAX); }; - ws.onerror = () => {}; + ws.onerror = (err) => { + logError('events-ws.onerror', err); + }; }).catch(() => { _ws = null; _reconnectTimer = setTimeout(startEventsWS, _reconnectDelay); diff --git a/server/src/ledgrab/static/js/core/state.ts b/server/src/ledgrab/static/js/core/state.ts index ca659c0..1fac506 100644 --- a/server/src/ledgrab/static/js/core/state.ts +++ b/server/src/ledgrab/static/js/core/state.ts @@ -16,7 +16,7 @@ import { DataCache } from './cache.ts'; import type { Device, OutputTarget, ColorStripSource, PatternTemplate, ValueSource, AudioSource, PictureSource, ScenePreset, - SyncClock, WeatherSource, HomeAssistantSource, MQTTSource, Asset, Automation, Display, FilterDef, EngineInfo, + SyncClock, WeatherSource, HomeAssistantSource, MQTTSource, HTTPEndpoint, Asset, Automation, Display, FilterDef, EngineInfo, CaptureTemplate, PostprocessingTemplate, AudioTemplate, ColorStripProcessingTemplate, FilterInstance, KeyColorRectangle, GameIntegration, GameAdapterInfo, @@ -371,6 +371,14 @@ export const mqttSourcesCache = new DataCache({ }); mqttSourcesCache.subscribe(v => { _cachedMQTTSources = v; }); +export let _cachedHTTPEndpoints: HTTPEndpoint[] = []; + +export const httpEndpointsCache = new DataCache({ + endpoint: '/http/endpoints', + extractData: json => json.endpoints || [], +}); +httpEndpointsCache.subscribe(v => { _cachedHTTPEndpoints = v; }); + export const assetsCache = new DataCache({ endpoint: '/assets', extractData: json => json.assets || [], diff --git a/server/src/ledgrab/static/js/core/storage.ts b/server/src/ledgrab/static/js/core/storage.ts index 27f7c54..1527d16 100644 --- a/server/src/ledgrab/static/js/core/storage.ts +++ b/server/src/ledgrab/static/js/core/storage.ts @@ -43,6 +43,23 @@ export function writeJson(key: string, value: unknown): void { } } +/** + * Parse a JSON string, returning ``fallback`` on any parse failure. + * + * Use for hot-path string-to-JSON conversions where a malformed payload + * (corrupt WebSocket frame, stale ``data-*`` attribute, hand-edited + * mapping in storage) would otherwise raise an uncaught exception. + */ +export function safeJsonParse(raw: string | null | undefined, fallback: T): T { + if (raw == null || raw === '') return fallback; + try { + return JSON.parse(raw) as T; + } catch (err) { + logError('safeJsonParse', err); + return fallback; + } +} + // ── Common type guards ──────────────────────────────────────── export function isObject(v: unknown): v is Record { diff --git a/server/src/ledgrab/static/js/features/integrations.ts b/server/src/ledgrab/static/js/features/integrations.ts index 444ed46..bc1f9af 100644 --- a/server/src/ledgrab/static/js/features/integrations.ts +++ b/server/src/ledgrab/static/js/features/integrations.ts @@ -4,9 +4,9 @@ */ import { - _cachedWeatherSources, _cachedHASources, _cachedMQTTSources, + _cachedWeatherSources, _cachedHASources, _cachedMQTTSources, _cachedHTTPEndpoints, _cachedGameIntegrations, _cachedGameAdapters, - weatherSourcesCache, haSourcesCache, mqttSourcesCache, + weatherSourcesCache, haSourcesCache, mqttSourcesCache, httpEndpointsCache, gameIntegrationsCache, gameAdaptersCache, apiKey, } from '../core/state.ts'; @@ -20,6 +20,7 @@ import { showToast, setTabRefreshing } from '../core/ui.ts'; import { createWeatherSourceCard, initWeatherSourceDelegation } from './weather-sources.ts'; import { createHASourceCard, initHASourceDelegation } from './home-assistant-sources.ts'; import { createMQTTSourceCard, initMQTTSourceDelegation } from './mqtt-sources.ts'; +import { createHTTPEndpointCard, initHTTPEndpointDelegation } from './http-endpoints.ts'; import { createGameIntegrationCard, csGameIntegrations } from './game-integration.ts'; import { ICON_GAMEPAD, ICON_TRASH, ICON_HELP } from '../core/icons.ts'; import * as P from '../core/icon-paths.ts'; @@ -42,12 +43,14 @@ function _bulkDeleteFactory(endpoint: string, cache: any, toast: string) { const _weatherSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('weather-sources', weatherSourcesCache, 'weather_source.deleted') }]; const _haSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('home-assistant/sources', haSourcesCache, 'ha_source.deleted') }]; const _mqttSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('mqtt/sources', mqttSourcesCache, 'mqtt_source.deleted') }]; +const _httpEndpointDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('http/endpoints', httpEndpointsCache, 'http_endpoint.deleted') }]; // ── Card section instances ── const csWeatherSources = new CardSection('weather-sources', { titleKey: 'weather_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showWeatherSourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.weather_sources', bulkActions: _weatherSourceDeleteAction }); const csHASources = new CardSection('ha-sources', { titleKey: 'ha_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showHASourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.ha_sources', bulkActions: _haSourceDeleteAction }); const csMQTTSources = new CardSection('mqtt-sources', { titleKey: 'mqtt_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showMQTTSourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.mqtt_sources', bulkActions: _mqttSourceDeleteAction }); +const csHTTPEndpoints = new CardSection('http-endpoints', { titleKey: 'http_endpoint.group.title', gridClass: 'templates-grid', addCardOnclick: "showHTTPEndpointModal()", keyAttr: 'data-id', emptyKey: 'section.empty.http_endpoints', bulkActions: _httpEndpointDeleteAction }); // Re-render integrations when language changes document.addEventListener('languageChanged', () => { if (apiKey) loadIntegrations(); }); @@ -87,6 +90,7 @@ const _integrationSectionMap: Record = { weather: [csWeatherSources], home_assistant: [csHASources], mqtt: [csMQTTSources], + http: [csHTTPEndpoints], game: [csGameIntegrations], }; @@ -101,6 +105,7 @@ export async function loadIntegrations() { weatherSourcesCache.fetch(), haSourcesCache.fetch(), mqttSourcesCache.fetch(), + httpEndpointsCache.fetch(), gameIntegrationsCache.fetch(), gameAdaptersCache.fetch(), ]); @@ -127,6 +132,7 @@ function renderIntegrationsList() { { key: 'weather', icon: `${P.cloudSun}`, titleKey: 'streams.group.weather', count: _cachedWeatherSources.length }, { key: 'home_assistant', icon: `${P.home}`, titleKey: 'streams.group.home_assistant', count: _cachedHASources.length }, { key: 'mqtt', icon: `${P.radio}`, titleKey: 'streams.group.mqtt', count: _cachedMQTTSources.length }, + { key: 'http', icon: `${P.globe}`, titleKey: 'streams.group.http', count: _cachedHTTPEndpoints.length }, { key: 'game', icon: ICON_GAMEPAD, titleKey: 'streams.group.game', count: _cachedGameIntegrations.length }, ]; @@ -135,6 +141,7 @@ function renderIntegrationsList() { { key: 'weather', titleKey: 'streams.group.weather', icon: `${P.cloudSun}`, count: _cachedWeatherSources.length }, { key: 'home_assistant', titleKey: 'streams.group.home_assistant', icon: `${P.home}`, count: _cachedHASources.length }, { key: 'mqtt', titleKey: 'streams.group.mqtt', icon: `${P.radio}`, count: _cachedMQTTSources.length }, + { key: 'http', titleKey: 'streams.group.http', icon: `${P.globe}`, count: _cachedHTTPEndpoints.length }, { key: 'game', titleKey: 'streams.group.game', icon: ICON_GAMEPAD, count: _cachedGameIntegrations.length }, ]; @@ -142,6 +149,7 @@ function renderIntegrationsList() { const weatherSourceItems = csWeatherSources.applySortOrder(_cachedWeatherSources.map(s => ({ key: s.id, html: createWeatherSourceCard(s) }))); const haSourceItems = csHASources.applySortOrder(_cachedHASources.map(s => ({ key: s.id, html: createHASourceCard(s) }))); const mqttSourceItems = csMQTTSources.applySortOrder(_cachedMQTTSources.map(s => ({ key: s.id, html: createMQTTSourceCard(s) }))); + const httpEndpointItems = csHTTPEndpoints.applySortOrder(_cachedHTTPEndpoints.map(e => ({ key: e.id, html: createHTTPEndpointCard(e) }))); const gameIntegrationItems = csGameIntegrations.applySortOrder(_cachedGameIntegrations.map(g => ({ key: g.id, html: createGameIntegrationCard(g) }))); if (csWeatherSources.isMounted()) { @@ -150,11 +158,13 @@ function renderIntegrationsList() { weather: _cachedWeatherSources.length, home_assistant: _cachedHASources.length, mqtt: _cachedMQTTSources.length, + http: _cachedHTTPEndpoints.length, game: _cachedGameIntegrations.length, }); csWeatherSources.reconcile(weatherSourceItems); csHASources.reconcile(haSourceItems); csMQTTSources.reconcile(mqttSourceItems); + csHTTPEndpoints.reconcile(httpEndpointItems); csGameIntegrations.reconcile(gameIntegrationItems); } else { // First render: build full HTML @@ -163,17 +173,19 @@ function renderIntegrationsList() { if (tab.key === 'weather') panelContent = csWeatherSources.render(weatherSourceItems); else if (tab.key === 'home_assistant') panelContent = csHASources.render(haSourceItems); else if (tab.key === 'mqtt') panelContent = csMQTTSources.render(mqttSourceItems); + else if (tab.key === 'http') panelContent = csHTTPEndpoints.render(httpEndpointItems); else if (tab.key === 'game') panelContent = csGameIntegrations.render(gameIntegrationItems); return `
${panelContent}
`; }).join(''); container.innerHTML = panels; - CardSection.bindAll([csWeatherSources, csHASources, csMQTTSources, csGameIntegrations]); + CardSection.bindAll([csWeatherSources, csHASources, csMQTTSources, csHTTPEndpoints, csGameIntegrations]); // Event delegation for card actions initWeatherSourceDelegation(container); initHASourceDelegation(container); initMQTTSourceDelegation(container); + initHTTPEndpointDelegation(container); // Render tree sidebar with tutorial trigger button _integrationsTree.setExtraHtml(``); @@ -182,6 +194,7 @@ function renderIntegrationsList() { 'weather-sources': 'weather', 'ha-sources': 'home_assistant', 'mqtt-sources': 'mqtt', + 'http-endpoints': 'http', 'game-integrations': 'game', }); } diff --git a/server/src/ledgrab/static/js/features/streams-audio-templates.ts b/server/src/ledgrab/static/js/features/streams-audio-templates.ts index 8d4c06f..983064d 100644 --- a/server/src/ledgrab/static/js/features/streams-audio-templates.ts +++ b/server/src/ledgrab/static/js/features/streams-audio-templates.ts @@ -23,6 +23,7 @@ import { import * as P from '../core/icon-paths.ts'; import { TagInput } from '../core/tag-input.ts'; import { IconSelect } from '../core/icon-select.ts'; +import { enhanceMiniSelects } from '../core/mini-select.ts'; import { loadPictureSources } from './streams.ts'; import { openAuthedWs } from '../core/ws-auth.ts'; @@ -150,6 +151,11 @@ export async function onAudioEngineChange() { }); gridHtml += ''; configFields.innerHTML = gridHtml; + // Convert the boolean toggles into MiniSelect popups — plain + // ```` + // never reaches the user. ``enhanceMiniSelects`` skips elements + // already hidden by IconSelect so this is safe to run after the + // loop above. + enhanceMiniSelects(configFields, 'select[data-config-key]'); } configSection.style.display = 'block'; @@ -584,6 +591,8 @@ export function _runTestViaWS(wsPath: string, queryParams: any = {}, firstMessag export async function saveTemplate() { const templateId = (document.getElementById('template-id') as HTMLInputElement).value; + if (templateModal.closeIfPristine(templateId)) return; + const name = (document.getElementById('template-name') as HTMLInputElement).value.trim(); const engineType = (document.getElementById('template-engine') as HTMLSelectElement).value; diff --git a/server/src/ledgrab/static/js/features/streams.ts b/server/src/ledgrab/static/js/features/streams.ts index 329d1df..73cb0ae 100644 --- a/server/src/ledgrab/static/js/features/streams.ts +++ b/server/src/ledgrab/static/js/features/streams.ts @@ -749,7 +749,7 @@ function renderPictureSourcesList(streams: any) { { key: 'processed', icon: getPictureSourceIcon('processed'), titleKey: 'streams.group.processed', count: processedStreams.length }, { key: 'proc_templates', icon: ICON_PP_TEMPLATE, titleKey: 'streams.group.proc_templates', count: _cachedPPTemplates.length }, { key: 'css_processing', icon: ICON_CSPT, titleKey: 'streams.group.css_processing', count: csptTemplates.length }, - { key: 'color_strip', icon: getColorStripIcon('static'), titleKey: 'streams.group.color_strip', count: colorStrips.length }, + { key: 'color_strip', icon: getColorStripIcon('single_color'), titleKey: 'streams.group.color_strip', count: colorStrips.length }, { key: 'gradients', icon: ICON_PALETTE, titleKey: 'streams.group.gradients', count: gradients.length }, { key: 'audio_capture', icon: getAudioSourceIcon('capture'), titleKey: 'audio_source.group.capture', count: captureSources.length }, { key: 'audio_processed', icon: getAudioSourceIcon('processed'), titleKey: 'audio_source.group.processed', count: processedAudioSources.length }, @@ -789,9 +789,9 @@ function renderPictureSourcesList(streams: any) { ] }, { - key: 'strip_group', icon: getColorStripIcon('static'), titleKey: 'tree.group.strip', + key: 'strip_group', icon: getColorStripIcon('single_color'), titleKey: 'tree.group.strip', children: [ - { key: 'color_strip', titleKey: 'tree.leaf.sources', icon: getColorStripIcon('static'), count: colorStrips.length }, + { key: 'color_strip', titleKey: 'tree.leaf.sources', icon: getColorStripIcon('single_color'), count: colorStrips.length }, { key: 'gradients', titleKey: 'streams.group.gradients', icon: ICON_PALETTE, count: gradients.length }, { key: 'css_processing', titleKey: 'tree.leaf.processing_templates', icon: ICON_CSPT, count: csptTemplates.length }, ] @@ -1460,6 +1460,8 @@ async function _refreshStreamDisplaysForEngine(engineType: any) { export async function saveStream() { const streamId = (document.getElementById('stream-id') as HTMLInputElement).value; + if (streamModal.closeIfPristine(streamId)) return; + const name = (document.getElementById('stream-name') as HTMLInputElement).value.trim(); const streamType = (document.getElementById('stream-type') as HTMLSelectElement).value; const description = (document.getElementById('stream-description') as HTMLInputElement).value.trim(); @@ -2050,6 +2052,8 @@ export async function editPPTemplate(templateId: any) { export async function savePPTemplate() { const templateId = (document.getElementById('pp-template-id') as HTMLInputElement).value; + if (ppTemplateModal.closeIfPristine(templateId)) return; + const name = (document.getElementById('pp-template-name') as HTMLInputElement).value.trim(); const description = (document.getElementById('pp-template-description') as HTMLInputElement).value.trim(); const errorEl = document.getElementById('pp-template-error')!; @@ -2265,6 +2269,8 @@ export async function editCSPT(templateId: any) { export async function saveCSPT() { const templateId = (document.getElementById('cspt-id') as HTMLInputElement).value; + if (csptModal.closeIfPristine(templateId)) return; + const name = (document.getElementById('cspt-name') as HTMLInputElement).value.trim(); const description = (document.getElementById('cspt-description') as HTMLInputElement).value.trim(); const errorEl = document.getElementById('cspt-error')!; diff --git a/server/src/ledgrab/static/js/features/z2m-light-targets.ts b/server/src/ledgrab/static/js/features/z2m-light-targets.ts index 154983f..9602945 100644 --- a/server/src/ledgrab/static/js/features/z2m-light-targets.ts +++ b/server/src/ledgrab/static/js/features/z2m-light-targets.ts @@ -18,6 +18,7 @@ import { } from '../core/state.ts'; import { fetchWithAuth, escapeHtml } from '../core/api.ts'; import { logError } from '../core/log.ts'; +import { safeJsonParse } from '../core/storage.ts'; import { t } from '../core/i18n.ts'; import { Modal } from '../core/modal.ts'; import { showToast, showConfirm, formatUptime } from '../core/ui.ts'; @@ -161,25 +162,35 @@ export function addZ2MLightMapping(data: any = null): void { // Per-source-kind row layout: CSS shows LED ranges; color_vs hides them // and promotes the brightness scale to a single inline field. + // Numeric fields are coerced via ``Number()`` so a hostile/stale JSON + // mapping (where a number got serialised as a string with markup) can't + // smuggle HTML into the attribute context. ``Number.isFinite`` filters + // ``NaN`` so the fallback always renders a sane default. + const brightnessVal = Number.isFinite(Number(data?.brightness_scale)) + ? Number(data?.brightness_scale) + : 1.0; + const ledStartVal = Number.isFinite(Number(data?.led_start)) ? Number(data?.led_start) : 0; + const ledEndVal = Number.isFinite(Number(data?.led_end)) ? Number(data?.led_end) : -1; + const rangeBlock = _editorSourceKind === 'color_vs' ? `
- +
` : `
- +
- +
- +
`; @@ -224,7 +235,10 @@ export function removeZ2MLightMapping(btn: HTMLElement): void { function _rerenderMappingsForMode(): void { const list = document.getElementById('z2m-light-mappings-list'); if (!list) return; - const snapshot = JSON.parse(_getMappingsJSON()); + // Guarded JSON parse — _getMappingsJSON reads live DOM values so a + // partially-rendered row (or hand-edited input) used to throw and + // wipe the editor mid-toggle. ``safeJsonParse`` falls back to []. + const snapshot = safeJsonParse(_getMappingsJSON(), []); list.innerHTML = ''; snapshot.forEach((m: any) => addZ2MLightMapping(m)); _setMappingsModeHint(); @@ -390,6 +404,8 @@ export async function closeZ2MLightEditor(): Promise { export async function saveZ2MLightEditor(): Promise { const targetId = (document.getElementById('z2m-light-editor-id') as HTMLInputElement).value; + if (z2mLightEditorModal.closeIfPristine(targetId)) return; + const name = (document.getElementById('z2m-light-editor-name') as HTMLInputElement).value.trim(); const mqttSourceId = (document.getElementById('z2m-light-editor-mqtt-source') as HTMLSelectElement).value; const baseTopic = (document.getElementById('z2m-light-editor-base-topic') as HTMLInputElement).value.trim() || 'zigbee2mqtt'; @@ -420,7 +436,8 @@ export async function saveZ2MLightEditor(): Promise { return; } - const mappings = JSON.parse(_getMappingsJSON()).filter((m: any) => m.friendly_name); + const mappings = safeJsonParse>>(_getMappingsJSON(), []) + .filter((m) => m && typeof m === 'object' && 'friendly_name' in m && (m as { friendly_name: unknown }).friendly_name); if (mappings.length === 0) { z2mLightEditorModal.showError(t('z2m_light.error.mapping_required') || 'At least one bulb mapping is required'); return; @@ -713,10 +730,8 @@ export function connectZ2MLightWS(targetId: string): void { openAuthedWs(url).then((ws) => { _z2mLightWS[targetId] = ws; ws.onmessage = (ev) => { - try { - const data = JSON.parse(ev.data); - if (data.type === 'colors_update') _updateSwatchColors(targetId, data.colors); - } catch (err) { logError('z2m-light-targets.ws.message', err); } + const data = safeJsonParse<{ type?: string; colors?: unknown }>(ev.data, {}); + if (data.type === 'colors_update') _updateSwatchColors(targetId, data.colors as never); }; ws.onclose = () => { delete _z2mLightWS[targetId]; }; ws.onerror = () => { delete _z2mLightWS[targetId]; }; diff --git a/server/src/ledgrab/templates/modals/css-editor.html b/server/src/ledgrab/templates/modals/css-editor.html index 841fa1c..0eae785 100644 --- a/server/src/ledgrab/templates/modals/css-editor.html +++ b/server/src/ledgrab/templates/modals/css-editor.html @@ -39,11 +39,11 @@ - +