fix(security): eliminate XSS vectors and restore zoom accessibility
- Extract shared esc() utility (public/utils/html.js) replacing 8 duplicate escHtml() functions across all page modules - Apply HTML escaping to all user-controlled data in innerHTML templates: titles, names, locations, descriptions, colors, notes content, weather data, autocomplete suggestions - Remove user-scalable=no and maximum-scale=1 from viewport meta tag, restoring pinch-to-zoom for WCAG 1.4.4 compliance - Bump version to 0.7.1
This commit is contained in:
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.7.1] - 2026-04-04
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Fix stored XSS across all pages - extract shared `esc()` utility (`public/utils/html.js`) and apply HTML escaping to all user-controlled data in innerHTML templates (titles, names, locations, descriptions, colors, notes content, autocomplete suggestions)
|
||||||
|
- Remove `user-scalable=no` and `maximum-scale=1` from viewport meta tag - restores pinch-to-zoom accessibility (WCAG 1.4.4)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Deduplicate 8 identical `escHtml()` functions (tasks, shopping, calendar, notes, meals, contacts, budget, settings) into single shared `esc()` import from `utils/html.js`
|
||||||
|
- Shared `esc()` also escapes single quotes (`'` to `'`) for safer attribute contexts
|
||||||
|
|
||||||
## [0.7.0] - 2026-04-04
|
## [0.7.0] - 2026-04-04
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oikos",
|
"name": "oikos",
|
||||||
"version": "0.7.0",
|
"version": "0.7.1",
|
||||||
"description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.",
|
"description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.",
|
||||||
"main": "server/index.js",
|
"main": "server/index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<!-- Viewport: edge-to-edge, kein Auto-Zoom bei Inputs -->
|
<!-- Viewport: edge-to-edge, kein Auto-Zoom bei Inputs -->
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=1, user-scalable=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
|
||||||
<!-- PWA / Theme -->
|
<!-- PWA / Theme -->
|
||||||
<meta name="theme-color" content="#007AFF" media="(prefers-color-scheme: light)" />
|
<meta name="theme-color" content="#007AFF" media="(prefers-color-scheme: light)" />
|
||||||
|
|||||||
+5
-10
@@ -9,6 +9,7 @@ import { api } from '/api.js';
|
|||||||
import { openModal as openSharedModal, closeModal } from '/components/modal.js';
|
import { openModal as openSharedModal, closeModal } from '/components/modal.js';
|
||||||
import { stagger, vibrate } from '/utils/ux.js';
|
import { stagger, vibrate } from '/utils/ux.js';
|
||||||
import { t, formatDate, getLocale } from '/i18n.js';
|
import { t, formatDate, getLocale } from '/i18n.js';
|
||||||
|
import { esc } from '/utils/html.js';
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// Konstanten
|
// Konstanten
|
||||||
@@ -253,7 +254,7 @@ function renderCategoryBars(byCategory) {
|
|||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="budget-bar-row">
|
<div class="budget-bar-row">
|
||||||
<div class="budget-bar-row__label" title="${escHtml(c.category)}">${escHtml(c.category)}</div>
|
<div class="budget-bar-row__label" title="${esc(c.category)}">${esc(c.category)}</div>
|
||||||
<div class="budget-bar-row__track">
|
<div class="budget-bar-row__track">
|
||||||
<div class="budget-bar-row__fill ${cls}" style="width:${pct}%;"></div>
|
<div class="budget-bar-row__fill ${cls}" style="width:${pct}%;"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -289,8 +290,8 @@ function renderEntries() {
|
|||||||
<div class="budget-entry" data-id="${e.id}">
|
<div class="budget-entry" data-id="${e.id}">
|
||||||
<div class="budget-entry__indicator ${indClass}"></div>
|
<div class="budget-entry__indicator ${indClass}"></div>
|
||||||
<div class="budget-entry__body">
|
<div class="budget-entry__body">
|
||||||
<div class="budget-entry__title">${escHtml(e.title)}</div>
|
<div class="budget-entry__title">${esc(e.title)}</div>
|
||||||
<div class="budget-entry__meta">${date} · ${escHtml(e.category)}${recurTag}</div>
|
<div class="budget-entry__meta">${date} · ${esc(e.category)}${recurTag}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="budget-entry__amount ${amtClass}">${sign}${formatAmount(e.amount)}</div>
|
<div class="budget-entry__amount ${amtClass}">${sign}${formatAmount(e.amount)}</div>
|
||||||
<button class="budget-entry__delete" data-action="delete" data-id="${e.id}" aria-label="${t('budget.deleteLabel')}">
|
<button class="budget-entry__delete" data-action="delete" data-id="${e.id}" aria-label="${t('budget.deleteLabel')}">
|
||||||
@@ -354,7 +355,7 @@ function openBudgetModal({ mode, entry = null }) {
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="bm-title">${t('budget.titleLabel')}</label>
|
<label class="form-label" for="bm-title">${t('budget.titleLabel')}</label>
|
||||||
<input type="text" class="form-input" id="bm-title"
|
<input type="text" class="form-input" id="bm-title"
|
||||||
placeholder="${t('budget.titlePlaceholder')}" value="${escHtml(isEdit ? entry.title : '')}">
|
placeholder="${t('budget.titlePlaceholder')}" value="${esc(isEdit ? entry.title : '')}">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -484,9 +485,3 @@ async function deleteEntry(id) {
|
|||||||
// Hilfsfunktion
|
// Hilfsfunktion
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
|
|
||||||
function escHtml(str) {
|
|
||||||
if (!str) return '';
|
|
||||||
return String(str)
|
|
||||||
.replace(/&/g, '&').replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>').replace(/"/g, '"');
|
|
||||||
}
|
|
||||||
|
|||||||
+24
-35
@@ -9,6 +9,7 @@ import { renderRRuleFields, bindRRuleEvents, getRRuleValues } from '/rrule-ui.js
|
|||||||
import { openModal as openSharedModal, closeModal } from '/components/modal.js';
|
import { openModal as openSharedModal, closeModal } from '/components/modal.js';
|
||||||
import { stagger } from '/utils/ux.js';
|
import { stagger } from '/utils/ux.js';
|
||||||
import { t, formatTime } from '/i18n.js';
|
import { t, formatTime } from '/i18n.js';
|
||||||
|
import { esc } from '/utils/html.js';
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// Konstanten
|
// Konstanten
|
||||||
@@ -380,9 +381,9 @@ function renderMonthDay(date, inMonth) {
|
|||||||
const evHtml = shown.map((ev) => `
|
const evHtml = shown.map((ev) => `
|
||||||
<div class="month-day__event"
|
<div class="month-day__event"
|
||||||
data-id="${ev.id}"
|
data-id="${ev.id}"
|
||||||
style="background-color:${escHtml(ev.color)};"
|
style="background-color:${esc(ev.color)};"
|
||||||
title="${escHtml(ev.title)}"
|
title="${esc(ev.title)}"
|
||||||
>${escHtml(ev.title)}</div>
|
>${esc(ev.title)}</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
|
|
||||||
return `
|
return `
|
||||||
@@ -428,8 +429,8 @@ function renderWeekView(container) {
|
|||||||
${days.map((d, i) => `
|
${days.map((d, i) => `
|
||||||
<div class="allday-cell">
|
<div class="allday-cell">
|
||||||
${alldayEvs[i].map((ev) => `
|
${alldayEvs[i].map((ev) => `
|
||||||
<div class="allday-event" data-id="${ev.id}" style="background-color:${escHtml(ev.color)};"
|
<div class="allday-event" data-id="${ev.id}" style="background-color:${esc(ev.color)};"
|
||||||
title="${escHtml(ev.title)}">${escHtml(ev.title)}</div>
|
title="${esc(ev.title)}">${esc(ev.title)}</div>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
</div>
|
</div>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
@@ -500,8 +501,8 @@ function renderWeekEvent(ev) {
|
|||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="week-event" data-id="${ev.id}"
|
<div class="week-event" data-id="${ev.id}"
|
||||||
style="top:${top}px;height:${height}px;background-color:${escHtml(ev.color)};">
|
style="top:${top}px;height:${height}px;background-color:${esc(ev.color)};">
|
||||||
<div class="week-event__title">${escHtml(ev.title)}</div>
|
<div class="week-event__title">${esc(ev.title)}</div>
|
||||||
<div class="week-event__time">${formatTime(ev.start_datetime)}${ev.end_datetime ? '–' + formatTime(ev.end_datetime) : ''}</div>
|
<div class="week-event__time">${formatTime(ev.start_datetime)}${ev.end_datetime ? '–' + formatTime(ev.end_datetime) : ''}</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -539,8 +540,8 @@ function renderDayView(container) {
|
|||||||
<div style="padding:2px 4px 2px 0;font-size:10px;color:var(--color-text-disabled);text-align:right;line-height:24px;">${t('calendar.allDayShort')}</div>
|
<div style="padding:2px 4px 2px 0;font-size:10px;color:var(--color-text-disabled);text-align:right;line-height:24px;">${t('calendar.allDayShort')}</div>
|
||||||
<div class="allday-cell">
|
<div class="allday-cell">
|
||||||
${allday.map((ev) => `
|
${allday.map((ev) => `
|
||||||
<div class="allday-event" data-id="${ev.id}" style="background-color:${escHtml(ev.color)};">
|
<div class="allday-event" data-id="${ev.id}" style="background-color:${esc(ev.color)};">
|
||||||
${escHtml(ev.title)}
|
${esc(ev.title)}
|
||||||
</div>`).join('')}
|
</div>`).join('')}
|
||||||
</div>
|
</div>
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
@@ -634,16 +635,16 @@ function renderAgendaEvent(ev) {
|
|||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="agenda-event" data-id="${ev.id}">
|
<div class="agenda-event" data-id="${ev.id}">
|
||||||
<div class="agenda-event__color" style="background-color:${escHtml(ev.color)};"></div>
|
<div class="agenda-event__color" style="background-color:${esc(ev.color)};"></div>
|
||||||
<div class="agenda-event__body">
|
<div class="agenda-event__body">
|
||||||
<div class="agenda-event__title">${escHtml(ev.title)}${(ev.recurrence_rule || ev.is_recurring_instance) ? ' <i data-lucide="repeat" style="width:12px;height:12px;display:inline;vertical-align:middle;opacity:0.5" aria-hidden="true"></i>' : ''}</div>
|
<div class="agenda-event__title">${esc(ev.title)}${(ev.recurrence_rule || ev.is_recurring_instance) ? ' <i data-lucide="repeat" style="width:12px;height:12px;display:inline;vertical-align:middle;opacity:0.5" aria-hidden="true"></i>' : ''}</div>
|
||||||
<div class="agenda-event__meta">
|
<div class="agenda-event__meta">
|
||||||
<span>${timeStr}</span>
|
<span>${timeStr}</span>
|
||||||
${ev.location ? `<span>📍 ${escHtml(ev.location)}</span>` : ''}
|
${ev.location ? `<span>📍 ${esc(ev.location)}</span>` : ''}
|
||||||
${ev.assigned_name ? `
|
${ev.assigned_name ? `
|
||||||
<span class="agenda-event__assigned">
|
<span class="agenda-event__assigned">
|
||||||
<span class="agenda-event__avatar" style="background-color:${escHtml(ev.assigned_color || '#8E8E93')}">${initials}</span>
|
<span class="agenda-event__avatar" style="background-color:${esc(ev.assigned_color || '#8E8E93')}">${initials}</span>
|
||||||
${escHtml(ev.assigned_name)}
|
${esc(ev.assigned_name)}
|
||||||
</span>` : ''}
|
</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -668,13 +669,13 @@ function showEventPopup(ev, anchor) {
|
|||||||
+ (ev.end_datetime ? ` – ${formatTime(ev.end_datetime)}${t('calendar.timeSuffix') ? ' ' + t('calendar.timeSuffix') : ''}`.trim() : '');
|
+ (ev.end_datetime ? ` – ${formatTime(ev.end_datetime)}${t('calendar.timeSuffix') ? ' ' + t('calendar.timeSuffix') : ''}`.trim() : '');
|
||||||
|
|
||||||
popup.innerHTML = `
|
popup.innerHTML = `
|
||||||
<div class="event-popup__color-bar" style="background-color:${escHtml(ev.color)};"></div>
|
<div class="event-popup__color-bar" style="background-color:${esc(ev.color)};"></div>
|
||||||
<div class="event-popup__title">${escHtml(ev.title)}</div>
|
<div class="event-popup__title">${esc(ev.title)}</div>
|
||||||
<div class="event-popup__meta">
|
<div class="event-popup__meta">
|
||||||
<div>${timeStr}</div>
|
<div>${timeStr}</div>
|
||||||
${ev.location ? `<div>📍 ${escHtml(ev.location)}</div>` : ''}
|
${ev.location ? `<div>📍 ${esc(ev.location)}</div>` : ''}
|
||||||
${ev.description ? `<div>${escHtml(ev.description)}</div>` : ''}
|
${ev.description ? `<div>${esc(ev.description)}</div>` : ''}
|
||||||
${ev.assigned_name ? `<div>👤 ${escHtml(ev.assigned_name)}</div>` : ''}
|
${ev.assigned_name ? `<div>👤 ${esc(ev.assigned_name)}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="event-popup__actions">
|
<div class="event-popup__actions">
|
||||||
<button class="btn btn--secondary" style="flex:1;" id="popup-edit">${t('calendar.popupEdit')}</button>
|
<button class="btn btn--secondary" style="flex:1;" id="popup-edit">${t('calendar.popupEdit')}</button>
|
||||||
@@ -782,7 +783,7 @@ function buildEventModalContent({ mode, event, date }) {
|
|||||||
const userOpts = [
|
const userOpts = [
|
||||||
`<option value="">${t('calendar.assignedNobody')}</option>`,
|
`<option value="">${t('calendar.assignedNobody')}</option>`,
|
||||||
...state.users.map((u) =>
|
...state.users.map((u) =>
|
||||||
`<option value="${u.id}" ${isEdit && event.assigned_to === u.id ? 'selected' : ''}>${escHtml(u.display_name)}</option>`
|
`<option value="${u.id}" ${isEdit && event.assigned_to === u.id ? 'selected' : ''}>${esc(u.display_name)}</option>`
|
||||||
),
|
),
|
||||||
].join('');
|
].join('');
|
||||||
|
|
||||||
@@ -790,7 +791,7 @@ function buildEventModalContent({ mode, event, date }) {
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="modal-title">${t('calendar.titleLabel')}</label>
|
<label class="form-label" for="modal-title">${t('calendar.titleLabel')}</label>
|
||||||
<input type="text" class="form-input" id="modal-title"
|
<input type="text" class="form-input" id="modal-title"
|
||||||
placeholder="${t('calendar.titlePlaceholder')}" value="${escHtml(isEdit ? event.title : '')}">
|
placeholder="${t('calendar.titlePlaceholder')}" value="${esc(isEdit ? event.title : '')}">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -839,7 +840,7 @@ function buildEventModalContent({ mode, event, date }) {
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="modal-location">${t('calendar.locationLabel')}</label>
|
<label class="form-label" for="modal-location">${t('calendar.locationLabel')}</label>
|
||||||
<input type="text" class="form-input" id="modal-location"
|
<input type="text" class="form-input" id="modal-location"
|
||||||
placeholder="${t('calendar.locationPlaceholder')}" value="${escHtml(isEdit && event.location ? event.location : '')}">
|
placeholder="${t('calendar.locationPlaceholder')}" value="${esc(isEdit && event.location ? event.location : '')}">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -860,7 +861,7 @@ function buildEventModalContent({ mode, event, date }) {
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="modal-description">${t('calendar.descriptionLabel')}</label>
|
<label class="form-label" for="modal-description">${t('calendar.descriptionLabel')}</label>
|
||||||
<textarea class="form-input" id="modal-description" rows="2"
|
<textarea class="form-input" id="modal-description" rows="2"
|
||||||
placeholder="${t('calendar.descriptionPlaceholder')}">${escHtml(isEdit && event.description ? event.description : '')}</textarea>
|
placeholder="${t('calendar.descriptionPlaceholder')}">${esc(isEdit && event.description ? event.description : '')}</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${renderRRuleFields('event', isEdit ? event.recurrence_rule : null)}
|
${renderRRuleFields('event', isEdit ? event.recurrence_rule : null)}
|
||||||
@@ -950,15 +951,3 @@ async function deleteEvent(id) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------
|
|
||||||
// Hilfsfunktion
|
|
||||||
// --------------------------------------------------------
|
|
||||||
|
|
||||||
function escHtml(str) {
|
|
||||||
if (!str) return '';
|
|
||||||
return String(str)
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"');
|
|
||||||
}
|
|
||||||
|
|||||||
+10
-15
@@ -8,6 +8,7 @@ import { api } from '/api.js';
|
|||||||
import { openModal as openSharedModal, closeModal } from '/components/modal.js';
|
import { openModal as openSharedModal, closeModal } from '/components/modal.js';
|
||||||
import { stagger, vibrate } from '/utils/ux.js';
|
import { stagger, vibrate } from '/utils/ux.js';
|
||||||
import { t } from '/i18n.js';
|
import { t } from '/i18n.js';
|
||||||
|
import { esc } from '/utils/html.js';
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// Konstanten
|
// Konstanten
|
||||||
@@ -78,7 +79,7 @@ export async function render(container, { user }) {
|
|||||||
<div class="contacts-filters" id="contacts-filters">
|
<div class="contacts-filters" id="contacts-filters">
|
||||||
<button class="contact-filter-chip contact-filter-chip--active" data-cat="">${t('contacts.filterAll')}</button>
|
<button class="contact-filter-chip contact-filter-chip--active" data-cat="">${t('contacts.filterAll')}</button>
|
||||||
${CATEGORIES.map((c) => `
|
${CATEGORIES.map((c) => `
|
||||||
<button class="contact-filter-chip" data-cat="${escHtml(c)}">${CATEGORY_ICONS[c] || ''} ${CATEGORY_LABELS()[c] || escHtml(c)}</button>
|
<button class="contact-filter-chip" data-cat="${esc(c)}">${CATEGORY_ICONS[c] || ''} ${CATEGORY_LABELS()[c] || esc(c)}</button>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
</div>
|
</div>
|
||||||
<div id="contacts-list" class="contacts-list"></div>
|
<div id="contacts-list" class="contacts-list"></div>
|
||||||
@@ -196,7 +197,7 @@ function renderList() {
|
|||||||
.sort(([a], [b]) => CATEGORIES.indexOf(a) - CATEGORIES.indexOf(b))
|
.sort(([a], [b]) => CATEGORIES.indexOf(a) - CATEGORIES.indexOf(b))
|
||||||
.map(([cat, items]) => `
|
.map(([cat, items]) => `
|
||||||
<div class="contact-group">
|
<div class="contact-group">
|
||||||
<div class="contact-group__header">${CATEGORY_ICONS[cat] || ''} ${CATEGORY_LABELS()[cat] || escHtml(cat)}</div>
|
<div class="contact-group__header">${CATEGORY_ICONS[cat] || ''} ${CATEGORY_LABELS()[cat] || esc(cat)}</div>
|
||||||
${items.map((c) => renderContactItem(c)).join('')}
|
${items.map((c) => renderContactItem(c)).join('')}
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
@@ -220,8 +221,8 @@ function renderList() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderContactItem(c) {
|
function renderContactItem(c) {
|
||||||
const phone = c.phone ? `<a href="tel:${escHtml(c.phone)}" class="contact-action-btn contact-action-btn--call" aria-label="${t('contacts.callLabel')}"><i data-lucide="phone" style="width:16px;height:16px;" aria-hidden="true"></i></a>` : '';
|
const phone = c.phone ? `<a href="tel:${esc(c.phone)}" class="contact-action-btn contact-action-btn--call" aria-label="${t('contacts.callLabel')}"><i data-lucide="phone" style="width:16px;height:16px;" aria-hidden="true"></i></a>` : '';
|
||||||
const email = c.email ? `<a href="mailto:${escHtml(c.email)}" class="contact-action-btn contact-action-btn--mail" aria-label="${t('contacts.emailActionLabel')}"><i data-lucide="mail" style="width:16px;height:16px;" aria-hidden="true"></i></a>` : '';
|
const email = c.email ? `<a href="mailto:${esc(c.email)}" class="contact-action-btn contact-action-btn--mail" aria-label="${t('contacts.emailActionLabel')}"><i data-lucide="mail" style="width:16px;height:16px;" aria-hidden="true"></i></a>` : '';
|
||||||
const maps = c.address ? `<a href="https://maps.google.com/?q=${encodeURIComponent(c.address)}" target="_blank" rel="noopener" class="contact-action-btn contact-action-btn--maps" aria-label="${t('contacts.mapsLabel')}"><i data-lucide="map-pin" style="width:16px;height:16px;" aria-hidden="true"></i></a>` : '';
|
const maps = c.address ? `<a href="https://maps.google.com/?q=${encodeURIComponent(c.address)}" target="_blank" rel="noopener" class="contact-action-btn contact-action-btn--maps" aria-label="${t('contacts.mapsLabel')}"><i data-lucide="map-pin" style="width:16px;height:16px;" aria-hidden="true"></i></a>` : '';
|
||||||
const meta = [c.phone, c.email].filter(Boolean).join(' · ');
|
const meta = [c.phone, c.email].filter(Boolean).join(' · ');
|
||||||
|
|
||||||
@@ -229,12 +230,12 @@ function renderContactItem(c) {
|
|||||||
<div class="contact-item" data-id="${c.id}">
|
<div class="contact-item" data-id="${c.id}">
|
||||||
<div class="contact-item__icon">${CATEGORY_ICONS[c.category] || '📋'}</div>
|
<div class="contact-item__icon">${CATEGORY_ICONS[c.category] || '📋'}</div>
|
||||||
<div class="contact-item__body">
|
<div class="contact-item__body">
|
||||||
<div class="contact-item__name">${escHtml(c.name)}</div>
|
<div class="contact-item__name">${esc(c.name)}</div>
|
||||||
${meta ? `<div class="contact-item__meta">${escHtml(meta)}</div>` : ''}
|
${meta ? `<div class="contact-item__meta">${esc(meta)}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="contact-item__actions">
|
<div class="contact-item__actions">
|
||||||
${phone}${email}${maps}
|
${phone}${email}${maps}
|
||||||
<a href="/api/v1/contacts/${c.id}/vcard" download="${escHtml(c.name)}.vcf"
|
<a href="/api/v1/contacts/${c.id}/vcard" download="${esc(c.name)}.vcf"
|
||||||
class="contact-action-btn" aria-label="${t('contacts.exportLabel')}" title="${t('contacts.exportTooltip')}">
|
class="contact-action-btn" aria-label="${t('contacts.exportLabel')}" title="${t('contacts.exportTooltip')}">
|
||||||
<i data-lucide="download" style="width:16px;height:16px;" aria-hidden="true"></i>
|
<i data-lucide="download" style="width:16px;height:16px;" aria-hidden="true"></i>
|
||||||
</a>
|
</a>
|
||||||
@@ -252,11 +253,11 @@ function renderContactItem(c) {
|
|||||||
|
|
||||||
function openContactModal({ mode, contact = null }) {
|
function openContactModal({ mode, contact = null }) {
|
||||||
const isEdit = mode === 'edit';
|
const isEdit = mode === 'edit';
|
||||||
const v = (field) => escHtml(isEdit && contact[field] ? contact[field] : '');
|
const v = (field) => esc(isEdit && contact[field] ? contact[field] : '');
|
||||||
|
|
||||||
const catLabels = CATEGORY_LABELS();
|
const catLabels = CATEGORY_LABELS();
|
||||||
const catOpts = CATEGORIES.map((c) =>
|
const catOpts = CATEGORIES.map((c) =>
|
||||||
`<option value="${c}" ${isEdit && contact.category === c ? 'selected' : ''}>${catLabels[c] || escHtml(c)}</option>`
|
`<option value="${c}" ${isEdit && contact.category === c ? 'selected' : ''}>${catLabels[c] || esc(c)}</option>`
|
||||||
).join('');
|
).join('');
|
||||||
|
|
||||||
const content = `
|
const content = `
|
||||||
@@ -362,12 +363,6 @@ async function deleteContact(id) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function escHtml(str) {
|
|
||||||
if (!str) return '';
|
|
||||||
return String(str)
|
|
||||||
.replace(/&/g, '&').replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>').replace(/"/g, '"');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimaler vCard 3.0/4.0 Parser.
|
* Minimaler vCard 3.0/4.0 Parser.
|
||||||
|
|||||||
+20
-19
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import { api } from '/api.js';
|
import { api } from '/api.js';
|
||||||
import { t, formatDate, formatTime, getLocale } from '/i18n.js';
|
import { t, formatDate, formatTime, getLocale } from '/i18n.js';
|
||||||
|
import { esc } from '/utils/html.js';
|
||||||
|
|
||||||
// Hält den AbortController des aktuellen FAB-Listeners - wird bei jedem render() erneuert.
|
// Hält den AbortController des aktuellen FAB-Listeners - wird bei jedem render() erneuert.
|
||||||
let _fabController = null;
|
let _fabController = null;
|
||||||
@@ -16,9 +17,9 @@ let _fabController = null;
|
|||||||
|
|
||||||
function greeting(displayName) {
|
function greeting(displayName) {
|
||||||
const h = new Date().getHours();
|
const h = new Date().getHours();
|
||||||
if (h < 12) return t('dashboard.greetingMorning', { name: displayName });
|
if (h < 12) return t('dashboard.greetingMorning', { name: esc(displayName) });
|
||||||
if (h < 18) return t('dashboard.greetingDay', { name: displayName });
|
if (h < 18) return t('dashboard.greetingDay', { name: esc(displayName) });
|
||||||
return t('dashboard.greetingEvening', { name: displayName });
|
return t('dashboard.greetingEvening', { name: esc(displayName) });
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDateTime(isoString) {
|
function formatDateTime(isoString) {
|
||||||
@@ -130,7 +131,7 @@ function renderGreeting(user, stats = {}) {
|
|||||||
if (todayMealTitle)
|
if (todayMealTitle)
|
||||||
statChips.push(`<span class="greeting-chip">
|
statChips.push(`<span class="greeting-chip">
|
||||||
<i data-lucide="utensils" style="${chipIcon}" aria-hidden="true"></i>
|
<i data-lucide="utensils" style="${chipIcon}" aria-hidden="true"></i>
|
||||||
${t('dashboard.todayMealChip', { title: todayMealTitle })}
|
${t('dashboard.todayMealChip', { title: esc(todayMealTitle) })}
|
||||||
</span>`);
|
</span>`);
|
||||||
|
|
||||||
return `
|
return `
|
||||||
@@ -161,12 +162,12 @@ function renderUrgentTasks(tasks) {
|
|||||||
<div class="task-item" data-route="/tasks" role="button" tabindex="0">
|
<div class="task-item" data-route="/tasks" role="button" tabindex="0">
|
||||||
<div class="task-item__priority task-item__priority--${t.priority}"></div>
|
<div class="task-item__priority task-item__priority--${t.priority}"></div>
|
||||||
<div class="task-item__content">
|
<div class="task-item__content">
|
||||||
<div class="task-item__title">${t.title}</div>
|
<div class="task-item__title">${esc(t.title)}</div>
|
||||||
${due ? `<div class="task-item__meta ${due.overdue ? 'task-item__meta--overdue' : ''}">${due.text}</div>` : ''}
|
${due ? `<div class="task-item__meta ${due.overdue ? 'task-item__meta--overdue' : ''}">${due.text}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
${t.assigned_color ? `
|
${t.assigned_color ? `
|
||||||
<div class="task-item__avatar" style="background-color:${t.assigned_color}"
|
<div class="task-item__avatar" style="background-color:${esc(t.assigned_color)}"
|
||||||
title="${t.assigned_name || ''}">${initials(t.assigned_name || '')}</div>` : ''}
|
title="${esc(t.assigned_name)}">${esc(initials(t.assigned_name || ''))}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
@@ -196,13 +197,13 @@ function renderUpcomingEvents(events) {
|
|||||||
const timeStr = e.all_day ? t('dashboard.allDay') : `${formatTime(d)}${_suffix ? ' ' + _suffix : ''}`.trim();
|
const timeStr = e.all_day ? t('dashboard.allDay') : `${formatTime(d)}${_suffix ? ' ' + _suffix : ''}`.trim();
|
||||||
return `
|
return `
|
||||||
<div class="event-item" data-route="/calendar" role="button" tabindex="0">
|
<div class="event-item" data-route="/calendar" role="button" tabindex="0">
|
||||||
<div class="event-item__bar" style="background-color:${e.color || 'var(--color-accent)'}"></div>
|
<div class="event-item__bar" style="background-color:${esc(e.color) || 'var(--color-accent)'}"></div>
|
||||||
<div class="event-item__content">
|
<div class="event-item__content">
|
||||||
<div class="event-item__title">${e.title}</div>
|
<div class="event-item__title">${esc(e.title)}</div>
|
||||||
<div class="event-item__time">
|
<div class="event-item__time">
|
||||||
<span class="event-time-badge ${isToday ? 'event-time-badge--today' : ''}">${isToday ? t('common.today') : formatDateTime(e.start_datetime).split(',')[0]}</span>
|
<span class="event-time-badge ${isToday ? 'event-time-badge--today' : ''}">${isToday ? t('common.today') : formatDateTime(e.start_datetime).split(',')[0]}</span>
|
||||||
${timeStr}
|
${timeStr}
|
||||||
${e.location ? ` · ${e.location}` : ''}
|
${e.location ? ` · ${esc(e.location)}` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -225,7 +226,7 @@ function renderTodayMeals(meals) {
|
|||||||
<div class="meal-slot ${meal ? 'meal-slot--filled' : ''}" data-route="/meals" role="button" tabindex="0">
|
<div class="meal-slot ${meal ? 'meal-slot--filled' : ''}" data-route="/meals" role="button" tabindex="0">
|
||||||
<i data-lucide="${MEAL_ICONS[type]}" class="meal-slot__icon" aria-hidden="true"></i>
|
<i data-lucide="${MEAL_ICONS[type]}" class="meal-slot__icon" aria-hidden="true"></i>
|
||||||
<div class="meal-slot__type">${mealLabels[type]}</div>
|
<div class="meal-slot__type">${mealLabels[type]}</div>
|
||||||
<div class="meal-slot__title">${meal ? meal.title : '-'}</div>
|
<div class="meal-slot__title">${meal ? esc(meal.title) : '-'}</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
@@ -249,9 +250,9 @@ function renderPinnedNotes(notes) {
|
|||||||
|
|
||||||
const items = notes.map((n) => `
|
const items = notes.map((n) => `
|
||||||
<div class="note-item" data-route="/notes" role="button" tabindex="0"
|
<div class="note-item" data-route="/notes" role="button" tabindex="0"
|
||||||
style="--note-color:${n.color};">
|
style="--note-color:${esc(n.color)};">
|
||||||
${n.title ? `<div class="note-item__title">${n.title}</div>` : ''}
|
${n.title ? `<div class="note-item__title">${esc(n.title)}</div>` : ''}
|
||||||
<div class="note-item__content">${n.content}</div>
|
<div class="note-item__content">${esc(n.content)}</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
|
|
||||||
@@ -280,7 +281,7 @@ function renderWeatherWidget(weather) {
|
|||||||
<div class="weather-forecast__day${extraCls}">
|
<div class="weather-forecast__day${extraCls}">
|
||||||
<div class="weather-forecast__label">${label}</div>
|
<div class="weather-forecast__label">${label}</div>
|
||||||
<img class="weather-forecast__icon" src="${WEATHER_ICON_BASE}${d.icon}"
|
<img class="weather-forecast__icon" src="${WEATHER_ICON_BASE}${d.icon}"
|
||||||
alt="${d.desc}" width="32" height="32" loading="lazy">
|
alt="${esc(d.desc)}" width="32" height="32" loading="lazy">
|
||||||
<div class="weather-forecast__temps">
|
<div class="weather-forecast__temps">
|
||||||
<span class="weather-forecast__high">${d.temp_max}°</span>
|
<span class="weather-forecast__high">${d.temp_max}°</span>
|
||||||
<span class="weather-forecast__low">${d.temp_min}°</span>
|
<span class="weather-forecast__low">${d.temp_min}°</span>
|
||||||
@@ -296,15 +297,15 @@ function renderWeatherWidget(weather) {
|
|||||||
<div class="weather-widget__inner">
|
<div class="weather-widget__inner">
|
||||||
<div class="weather-widget__main">
|
<div class="weather-widget__main">
|
||||||
<div class="weather-widget__left">
|
<div class="weather-widget__left">
|
||||||
<div class="weather-widget__temp">${current.temp}°C</div>
|
<div class="weather-widget__temp">${esc(current.temp)}°C</div>
|
||||||
<div class="weather-widget__desc">${current.desc}</div>
|
<div class="weather-widget__desc">${esc(current.desc)}</div>
|
||||||
<div class="weather-widget__city">${city}</div>
|
<div class="weather-widget__city">${esc(city)}</div>
|
||||||
<div class="weather-widget__meta">
|
<div class="weather-widget__meta">
|
||||||
${t('dashboard.weatherFeelsLike', { temp: current.feels_like, humidity: current.humidity, wind: current.wind_speed })}
|
${t('dashboard.weatherFeelsLike', { temp: current.feels_like, humidity: current.humidity, wind: current.wind_speed })}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<img class="weather-widget__icon" src="${WEATHER_ICON_BASE}${current.icon}"
|
<img class="weather-widget__icon" src="${WEATHER_ICON_BASE}${current.icon}"
|
||||||
alt="${current.desc}" width="80" height="80" loading="lazy">
|
alt="${esc(current.desc)}" width="80" height="80" loading="lazy">
|
||||||
</div>
|
</div>
|
||||||
${forecast.length ? `<div class="weather-forecast">${forecastHtml}</div>` : ''}
|
${forecast.length ? `<div class="weather-forecast">${forecastHtml}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+9
-16
@@ -8,6 +8,7 @@ import { api } from '/api.js';
|
|||||||
import { openModal as openSharedModal, closeModal as closeSharedModal } from '/components/modal.js';
|
import { openModal as openSharedModal, closeModal as closeSharedModal } from '/components/modal.js';
|
||||||
import { stagger } from '/utils/ux.js';
|
import { stagger } from '/utils/ux.js';
|
||||||
import { t, formatDate } from '/i18n.js';
|
import { t, formatDate } from '/i18n.js';
|
||||||
|
import { esc } from '/utils/html.js';
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// Konstanten
|
// Konstanten
|
||||||
@@ -203,9 +204,9 @@ function renderSlot(date, type, mealsForDay) {
|
|||||||
data-action="edit-meal"
|
data-action="edit-meal"
|
||||||
data-meal-id="${meal.id}"
|
data-meal-id="${meal.id}"
|
||||||
role="button" tabindex="0">
|
role="button" tabindex="0">
|
||||||
<div class="meal-card__title">${escHtml(meal.title)}</div>
|
<div class="meal-card__title">${esc(meal.title)}</div>
|
||||||
${ingLabel ? `<div class="meal-card__meta">
|
${ingLabel ? `<div class="meal-card__meta">
|
||||||
<span class="meal-card__ingredients-count">${ingLabel}${escHtml(ingDoneLabel)}</span>
|
<span class="meal-card__ingredients-count">${ingLabel}${esc(ingDoneLabel)}</span>
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
<div class="meal-card__actions">
|
<div class="meal-card__actions">
|
||||||
${canTransfer ? `<button class="meal-card__action-btn meal-card__action-btn--shopping"
|
${canTransfer ? `<button class="meal-card__action-btn meal-card__action-btn--shopping"
|
||||||
@@ -450,7 +451,7 @@ function openMealModal(opts) {
|
|||||||
if (!res.data.length) { acDropdown.hidden = true; return; }
|
if (!res.data.length) { acDropdown.hidden = true; return; }
|
||||||
acIndex = -1;
|
acIndex = -1;
|
||||||
acDropdown.innerHTML = res.data.map((s) => `
|
acDropdown.innerHTML = res.data.map((s) => `
|
||||||
<div class="meal-modal__autocomplete-item" data-title="${escHtml(s.title)}">${escHtml(s.title)}</div>
|
<div class="meal-modal__autocomplete-item" data-title="${esc(s.title)}">${esc(s.title)}</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
acDropdown.hidden = false;
|
acDropdown.hidden = false;
|
||||||
} catch { acDropdown.hidden = true; }
|
} catch { acDropdown.hidden = true; }
|
||||||
@@ -526,7 +527,7 @@ function buildModalContent({ mode, date, mealType, meal }) {
|
|||||||
).join('');
|
).join('');
|
||||||
|
|
||||||
const listOpts = state.lists.length
|
const listOpts = state.lists.length
|
||||||
? state.lists.map((l) => `<option value="${l.id}">${escHtml(l.name)}</option>`).join('')
|
? state.lists.map((l) => `<option value="${l.id}">${esc(l.name)}</option>`).join('')
|
||||||
: `<option value="" disabled>${t('meals.noShoppingLists')}</option>`;
|
: `<option value="" disabled>${t('meals.noShoppingLists')}</option>`;
|
||||||
|
|
||||||
const ingRows = isEdit && meal.ingredients?.length
|
const ingRows = isEdit && meal.ingredients?.length
|
||||||
@@ -551,7 +552,7 @@ function buildModalContent({ mode, date, mealType, meal }) {
|
|||||||
<label class="form-label" for="modal-title">${t('meals.titleLabel')}</label>
|
<label class="form-label" for="modal-title">${t('meals.titleLabel')}</label>
|
||||||
<input type="text" class="form-input" id="modal-title"
|
<input type="text" class="form-input" id="modal-title"
|
||||||
placeholder="${t('meals.titlePlaceholder')}"
|
placeholder="${t('meals.titlePlaceholder')}"
|
||||||
value="${escHtml(isEdit ? meal.title : '')}"
|
value="${esc(isEdit ? meal.title : '')}"
|
||||||
autocomplete="off">
|
autocomplete="off">
|
||||||
<div id="modal-autocomplete" class="meal-modal__autocomplete" hidden></div>
|
<div id="modal-autocomplete" class="meal-modal__autocomplete" hidden></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -559,7 +560,7 @@ function buildModalContent({ mode, date, mealType, meal }) {
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="modal-notes">${t('meals.notesLabel')}</label>
|
<label class="form-label" for="modal-notes">${t('meals.notesLabel')}</label>
|
||||||
<textarea class="form-input" id="modal-notes" rows="2"
|
<textarea class="form-input" id="modal-notes" rows="2"
|
||||||
placeholder="${t('meals.notesPlaceholder')}">${escHtml(isEdit && meal.notes ? meal.notes : '')}</textarea>
|
placeholder="${t('meals.notesPlaceholder')}">${esc(isEdit && meal.notes ? meal.notes : '')}</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -592,8 +593,8 @@ function buildModalContent({ mode, date, mealType, meal }) {
|
|||||||
function ingredientRowHTML(name, qty, id) {
|
function ingredientRowHTML(name, qty, id) {
|
||||||
return `
|
return `
|
||||||
<div class="ingredient-row" data-ing-id="${id ?? ''}">
|
<div class="ingredient-row" data-ing-id="${id ?? ''}">
|
||||||
<input type="text" class="form-input ingredient-row__name" placeholder="${t('meals.ingredientNamePlaceholder')}" value="${escHtml(name)}">
|
<input type="text" class="form-input ingredient-row__name" placeholder="${t('meals.ingredientNamePlaceholder')}" value="${esc(name)}">
|
||||||
<input type="text" class="form-input ingredient-row__qty" placeholder="${t('meals.ingredientQtyPlaceholder')}" value="${escHtml(qty)}">
|
<input type="text" class="form-input ingredient-row__qty" placeholder="${t('meals.ingredientQtyPlaceholder')}" value="${esc(qty)}">
|
||||||
<button class="ingredient-row__remove" data-action="remove-ingredient" type="button" aria-label="${t('meals.removeIngredient')}">
|
<button class="ingredient-row__remove" data-action="remove-ingredient" type="button" aria-label="${t('meals.removeIngredient')}">
|
||||||
<i data-lucide="x" style="width:14px;height:14px;" aria-hidden="true"></i>
|
<i data-lucide="x" style="width:14px;height:14px;" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
@@ -719,11 +720,3 @@ async function transferMeal(mealId) {
|
|||||||
// Hilfsfunktion
|
// Hilfsfunktion
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
|
|
||||||
function escHtml(str) {
|
|
||||||
if (!str) return '';
|
|
||||||
return String(str)
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"');
|
|
||||||
}
|
|
||||||
|
|||||||
+9
-14
@@ -8,6 +8,7 @@ import { api } from '/api.js';
|
|||||||
import { openModal as openSharedModal, closeModal, btnError } from '/components/modal.js';
|
import { openModal as openSharedModal, closeModal, btnError } from '/components/modal.js';
|
||||||
import { stagger, vibrate } from '/utils/ux.js';
|
import { stagger, vibrate } from '/utils/ux.js';
|
||||||
import { t } from '/i18n.js';
|
import { t } from '/i18n.js';
|
||||||
|
import { esc } from '/utils/html.js';
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// Konstanten
|
// Konstanten
|
||||||
@@ -31,7 +32,7 @@ let _container = null;
|
|||||||
|
|
||||||
function renderMarkdownLight(text) {
|
function renderMarkdownLight(text) {
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
return escHtml(text)
|
return esc(text)
|
||||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||||
.replace(/^- (.+)$/gm, '• $1')
|
.replace(/^- (.+)$/gm, '• $1')
|
||||||
@@ -54,7 +55,7 @@ export async function render(container, { user }) {
|
|||||||
<i data-lucide="search" class="notes-toolbar__search-icon" aria-hidden="true"></i>
|
<i data-lucide="search" class="notes-toolbar__search-icon" aria-hidden="true"></i>
|
||||||
<input type="search" id="notes-search" class="notes-toolbar__search-input"
|
<input type="search" id="notes-search" class="notes-toolbar__search-input"
|
||||||
placeholder="${t('notes.searchPlaceholder')}" autocomplete="off"
|
placeholder="${t('notes.searchPlaceholder')}" autocomplete="off"
|
||||||
value="${escHtml(state.filterQuery)}">
|
value="${esc(state.filterQuery)}">
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn--primary" id="notes-add-btn">
|
<button class="btn btn--primary" id="notes-add-btn">
|
||||||
<i data-lucide="plus" style="width:16px;height:16px;margin-right:4px;" aria-hidden="true"></i>
|
<i data-lucide="plus" style="width:16px;height:16px;margin-right:4px;" aria-hidden="true"></i>
|
||||||
@@ -155,18 +156,18 @@ function renderNoteCard(note) {
|
|||||||
return `
|
return `
|
||||||
<div class="note-card ${note.pinned ? 'note-card--pinned' : ''}"
|
<div class="note-card ${note.pinned ? 'note-card--pinned' : ''}"
|
||||||
data-id="${note.id}"
|
data-id="${note.id}"
|
||||||
style="background-color:${escHtml(note.color)};color:${textColor};">
|
style="background-color:${esc(note.color)};color:${textColor};">
|
||||||
<button class="note-card__pin" data-action="pin" data-id="${note.id}"
|
<button class="note-card__pin" data-action="pin" data-id="${note.id}"
|
||||||
aria-label="${note.pinned ? t('notes.unpinAction') : t('notes.pinAction')}">
|
aria-label="${note.pinned ? t('notes.unpinAction') : t('notes.pinAction')}">
|
||||||
<i data-lucide="${note.pinned ? 'pin-off' : 'pin'}" style="width:12px;height:12px;" aria-hidden="true"></i>
|
<i data-lucide="${note.pinned ? 'pin-off' : 'pin'}" style="width:12px;height:12px;" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
${note.title ? `<div class="note-card__title">${escHtml(note.title)}</div>` : ''}
|
${note.title ? `<div class="note-card__title">${esc(note.title)}</div>` : ''}
|
||||||
<div class="note-card__content">${renderMarkdownLight(note.content)}</div>
|
<div class="note-card__content">${renderMarkdownLight(note.content)}</div>
|
||||||
<div class="note-card__footer">
|
<div class="note-card__footer">
|
||||||
<div class="note-card__creator">
|
<div class="note-card__creator">
|
||||||
<span class="note-card__avatar"
|
<span class="note-card__avatar"
|
||||||
style="background-color:${escHtml(note.creator_color || '#8E8E93')}">${initials}</span>
|
style="background-color:${esc(note.creator_color || '#8E8E93')}">${initials}</span>
|
||||||
<span>${escHtml(note.creator_name || '')}</span>
|
<span>${esc(note.creator_name || '')}</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="note-card__delete" data-action="delete" data-id="${note.id}" aria-label="${t('notes.deleteLabel')}">
|
<button class="note-card__delete" data-action="delete" data-id="${note.id}" aria-label="${t('notes.deleteLabel')}">
|
||||||
<i data-lucide="trash-2" style="width:12px;height:12px;" aria-hidden="true"></i>
|
<i data-lucide="trash-2" style="width:12px;height:12px;" aria-hidden="true"></i>
|
||||||
@@ -318,7 +319,7 @@ function openNoteModal({ mode, note = null }) {
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="note-title">${t('notes.titleLabel')}</label>
|
<label class="form-label" for="note-title">${t('notes.titleLabel')}</label>
|
||||||
<input type="text" class="form-input" id="note-title"
|
<input type="text" class="form-input" id="note-title"
|
||||||
placeholder="${t('notes.titlePlaceholder')}" value="${escHtml(isEdit && note.title ? note.title : '')}">
|
placeholder="${t('notes.titlePlaceholder')}" value="${esc(isEdit && note.title ? note.title : '')}">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="note-content">${t('notes.contentLabel')} <span style="font-weight:400;color:var(--text-tertiary);font-size:.85em;">${t('notes.contentMarkdownHint')}</span></label>
|
<label class="form-label" for="note-content">${t('notes.contentLabel')} <span style="font-weight:400;color:var(--text-tertiary);font-size:.85em;">${t('notes.contentMarkdownHint')}</span></label>
|
||||||
@@ -364,7 +365,7 @@ function openNoteModal({ mode, note = null }) {
|
|||||||
</div>
|
</div>
|
||||||
<textarea class="form-input" id="note-content" rows="6"
|
<textarea class="form-input" id="note-content" rows="6"
|
||||||
placeholder="${t('notes.contentPlaceholder')}"
|
placeholder="${t('notes.contentPlaceholder')}"
|
||||||
style="resize:vertical;">${escHtml(isEdit ? note.content : '')}</textarea>
|
style="resize:vertical;">${esc(isEdit ? note.content : '')}</textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">${t('notes.colorLabel')}</label>
|
<label class="form-label">${t('notes.colorLabel')}</label>
|
||||||
@@ -498,9 +499,3 @@ function isLightColor(hex) {
|
|||||||
return (r * 299 + g * 587 + b * 114) / 1000 > 150;
|
return (r * 299 + g * 587 + b * 114) / 1000 > 150;
|
||||||
}
|
}
|
||||||
|
|
||||||
function escHtml(str) {
|
|
||||||
if (!str) return '';
|
|
||||||
return String(str)
|
|
||||||
.replace(/&/g, '&').replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>').replace(/"/g, '"');
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import { api, auth } from '/api.js';
|
import { api, auth } from '/api.js';
|
||||||
import { t, formatDate, formatTime } from '/i18n.js';
|
import { t, formatDate, formatTime } from '/i18n.js';
|
||||||
|
import { esc } from '/utils/html.js';
|
||||||
import '/components/oikos-locale-picker.js';
|
import '/components/oikos-locale-picker.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -89,12 +90,12 @@ export async function render(container, { user }) {
|
|||||||
|
|
||||||
<div class="settings-card">
|
<div class="settings-card">
|
||||||
<div class="settings-user-info">
|
<div class="settings-user-info">
|
||||||
<div class="settings-avatar" style="background:${user?.avatar_color ?? '#007AFF'}">
|
<div class="settings-avatar" style="background:${esc(user?.avatar_color) || '#007AFF'}">
|
||||||
${initials(user?.display_name)}
|
${esc(initials(user?.display_name))}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="settings-user-info__name">${user?.display_name ?? ''}</div>
|
<div class="settings-user-info__name">${esc(user?.display_name)}</div>
|
||||||
<div class="settings-user-info__username">@${user?.username ?? ''}</div>
|
<div class="settings-user-info__username">@${esc(user?.username)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -484,28 +485,16 @@ function bindDeleteButtons(container, user) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------
|
|
||||||
// Helfer
|
|
||||||
// --------------------------------------------------------
|
|
||||||
|
|
||||||
function escHtml(str) {
|
|
||||||
if (!str) return '';
|
|
||||||
return String(str)
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"');
|
|
||||||
}
|
|
||||||
|
|
||||||
function memberHtml(u) {
|
function memberHtml(u) {
|
||||||
return `
|
return `
|
||||||
<li class="settings-member" data-id="${u.id}">
|
<li class="settings-member" data-id="${u.id}">
|
||||||
<div class="settings-avatar settings-avatar--sm" style="background:${escHtml(u.avatar_color)}">${initials(u.display_name)}</div>
|
<div class="settings-avatar settings-avatar--sm" style="background:${esc(u.avatar_color)}">${initials(u.display_name)}</div>
|
||||||
<div class="settings-member__info">
|
<div class="settings-member__info">
|
||||||
<span class="settings-member__name">${escHtml(u.display_name)}</span>
|
<span class="settings-member__name">${esc(u.display_name)}</span>
|
||||||
<span class="settings-member__meta">@${escHtml(u.username)} · ${u.role === 'admin' ? t('settings.roleAdmin') : t('settings.roleMember')}</span>
|
<span class="settings-member__meta">@${esc(u.username)} · ${u.role === 'admin' ? t('settings.roleAdmin') : t('settings.roleMember')}</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn--icon btn--danger-outline" data-delete-user="${u.id}" data-name="${escHtml(u.display_name)}" aria-label="${escHtml(u.display_name)} ${t('settings.deleteMemberLabel')}" title="${t('settings.deleteMemberLabel')}">
|
<button class="btn btn--icon btn--danger-outline" data-delete-user="${u.id}" data-name="${esc(u.display_name)}" aria-label="${esc(u.display_name)} ${t('settings.deleteMemberLabel')}" title="${t('settings.deleteMemberLabel')}">
|
||||||
<i data-lucide="trash-2" aria-hidden="true"></i>
|
<i data-lucide="trash-2" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import { api } from '/api.js';
|
import { api } from '/api.js';
|
||||||
import { stagger, vibrate } from '/utils/ux.js';
|
import { stagger, vibrate } from '/utils/ux.js';
|
||||||
import { t } from '/i18n.js';
|
import { t } from '/i18n.js';
|
||||||
|
import { esc } from '/utils/html.js';
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// Konstanten
|
// Konstanten
|
||||||
@@ -86,7 +87,7 @@ function renderTabs(container) {
|
|||||||
return `
|
return `
|
||||||
<button class="list-tab ${list.id === state.activeListId ? 'list-tab--active' : ''}"
|
<button class="list-tab ${list.id === state.activeListId ? 'list-tab--active' : ''}"
|
||||||
data-action="switch-list" data-id="${list.id}">
|
data-action="switch-list" data-id="${list.id}">
|
||||||
${list.name}
|
${esc(list.name)}
|
||||||
${list.item_total > 0 ? `<span class="list-tab__count">${unchecked > 0 ? unchecked : '✓'}</span>` : ''}
|
${list.item_total > 0 ? `<span class="list-tab__count">${unchecked > 0 ? unchecked : '✓'}</span>` : ''}
|
||||||
</button>`;
|
</button>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
@@ -124,7 +125,7 @@ function renderListContent(container) {
|
|||||||
<div class="list-header">
|
<div class="list-header">
|
||||||
<span class="list-header__name" data-action="rename-list" data-id="${state.activeList.id}"
|
<span class="list-header__name" data-action="rename-list" data-id="${state.activeList.id}"
|
||||||
role="button" tabindex="0" aria-label="${t('shopping.renameListLabel')}">
|
role="button" tabindex="0" aria-label="${t('shopping.renameListLabel')}">
|
||||||
${state.activeList.name}
|
${esc(state.activeList.name)}
|
||||||
<i data-lucide="pencil" class="list-header__edit-icon" aria-hidden="true"></i>
|
<i data-lucide="pencil" class="list-header__edit-icon" aria-hidden="true"></i>
|
||||||
</span>
|
</span>
|
||||||
<div class="list-header__actions">
|
<div class="list-header__actions">
|
||||||
@@ -216,15 +217,15 @@ function renderItem(item) {
|
|||||||
data-item-id="${item.id}">
|
data-item-id="${item.id}">
|
||||||
<button class="item-check ${isDone ? 'item-check--checked' : ''}"
|
<button class="item-check ${isDone ? 'item-check--checked' : ''}"
|
||||||
data-action="toggle-item" data-id="${item.id}" data-checked="${item.is_checked}"
|
data-action="toggle-item" data-id="${item.id}" data-checked="${item.is_checked}"
|
||||||
aria-label="${isDone ? t('shopping.markUndoneLabel', { name: escHtml(item.name) }) : t('shopping.markDoneLabel', { name: escHtml(item.name) })}">
|
aria-label="${isDone ? t('shopping.markUndoneLabel', { name: esc(item.name) }) : t('shopping.markDoneLabel', { name: esc(item.name) })}">
|
||||||
<i data-lucide="check" class="item-check__icon" aria-hidden="true"></i>
|
<i data-lucide="check" class="item-check__icon" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
<div class="item-body">
|
<div class="item-body">
|
||||||
<div class="item-name">${escHtml(item.name)}</div>
|
<div class="item-name">${esc(item.name)}</div>
|
||||||
${item.quantity ? `<div class="item-quantity">${escHtml(item.quantity)}</div>` : ''}
|
${item.quantity ? `<div class="item-quantity">${esc(item.quantity)}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<button class="item-delete" data-action="delete-item" data-id="${item.id}"
|
<button class="item-delete" data-action="delete-item" data-id="${item.id}"
|
||||||
aria-label="${t('shopping.deleteItemLabel', { name: escHtml(item.name) })}">
|
aria-label="${t('shopping.deleteItemLabel', { name: esc(item.name) })}">
|
||||||
<i data-lucide="x" style="width:16px;height:16px" aria-hidden="true"></i>
|
<i data-lucide="x" style="width:16px;height:16px" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -256,7 +257,7 @@ function wireAutocomplete(container) {
|
|||||||
if (!suggestions.length) { dropdown.hidden = true; return; }
|
if (!suggestions.length) { dropdown.hidden = true; return; }
|
||||||
|
|
||||||
dropdown.innerHTML = suggestions.map((s, i) =>
|
dropdown.innerHTML = suggestions.map((s, i) =>
|
||||||
`<div class="autocomplete-item" data-idx="${i}" data-value="${s}">${s}</div>`
|
`<div class="autocomplete-item" data-idx="${i}" data-value="${esc(s)}">${esc(s)}</div>`
|
||||||
).join('');
|
).join('');
|
||||||
dropdown.hidden = false;
|
dropdown.hidden = false;
|
||||||
activeIdx = -1;
|
activeIdx = -1;
|
||||||
@@ -746,15 +747,3 @@ export async function render(container, { user }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------
|
|
||||||
// HTML-Escaping
|
|
||||||
// --------------------------------------------------------
|
|
||||||
|
|
||||||
function escHtml(str) {
|
|
||||||
if (!str) return '';
|
|
||||||
return String(str)
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"');
|
|
||||||
}
|
|
||||||
|
|||||||
+12
-20
@@ -9,15 +9,7 @@ import { renderRRuleFields, bindRRuleEvents, getRRuleValues } from '/rrule-ui.js
|
|||||||
import { openModal as openSharedModal, closeModal, wireBlurValidation, btnSuccess, btnError } from '/components/modal.js';
|
import { openModal as openSharedModal, closeModal, wireBlurValidation, btnSuccess, btnError } from '/components/modal.js';
|
||||||
import { stagger, vibrate } from '/utils/ux.js';
|
import { stagger, vibrate } from '/utils/ux.js';
|
||||||
import { t, formatDate } from '/i18n.js';
|
import { t, formatDate } from '/i18n.js';
|
||||||
|
import { esc } from '/utils/html.js';
|
||||||
function escHtml(str) {
|
|
||||||
if (!str) return '';
|
|
||||||
return String(str)
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"');
|
|
||||||
}
|
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// Konstanten
|
// Konstanten
|
||||||
@@ -161,10 +153,10 @@ function renderTaskCard(task, opts = {}) {
|
|||||||
data-subtask-id="${s.id}">
|
data-subtask-id="${s.id}">
|
||||||
<button class="subtask-item__checkbox ${s.status === 'done' ? 'subtask-item__checkbox--done' : ''}"
|
<button class="subtask-item__checkbox ${s.status === 'done' ? 'subtask-item__checkbox--done' : ''}"
|
||||||
data-action="toggle-subtask" data-id="${s.id}"
|
data-action="toggle-subtask" data-id="${s.id}"
|
||||||
data-status="${s.status}" aria-label="${t('tasks.subtaskMarkDone', { title: s.title })}">
|
data-status="${s.status}" aria-label="${t('tasks.subtaskMarkDone', { title: esc(s.title) })}">
|
||||||
${s.status === 'done' ? '<i data-lucide="check" style="width:10px;height:10px;color:#fff" aria-hidden="true"></i>' : ''}
|
${s.status === 'done' ? '<i data-lucide="check" style="width:10px;height:10px;color:#fff" aria-hidden="true"></i>' : ''}
|
||||||
</button>
|
</button>
|
||||||
<span class="subtask-item__title">${escHtml(s.title)}</span>
|
<span class="subtask-item__title">${esc(s.title)}</span>
|
||||||
</div>`).join('')
|
</div>`).join('')
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
@@ -173,13 +165,13 @@ function renderTaskCard(task, opts = {}) {
|
|||||||
<div class="task-card__main">
|
<div class="task-card__main">
|
||||||
<button class="task-status-btn task-status-btn--${task.status}"
|
<button class="task-status-btn task-status-btn--${task.status}"
|
||||||
data-action="toggle-status" data-id="${task.id}" data-status="${task.status}"
|
data-action="toggle-status" data-id="${task.id}" data-status="${task.status}"
|
||||||
aria-label="${t('tasks.markDone', { title: task.title })}">
|
aria-label="${t('tasks.markDone', { title: esc(task.title) })}">
|
||||||
<i data-lucide="check" class="task-status-btn__check" aria-hidden="true"></i>
|
<i data-lucide="check" class="task-status-btn__check" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="task-card__body">
|
<div class="task-card__body">
|
||||||
<div class="task-card__title" data-action="open-task" data-id="${task.id}">
|
<div class="task-card__title" data-action="open-task" data-id="${task.id}">
|
||||||
${escHtml(task.title)}
|
${esc(task.title)}
|
||||||
</div>
|
</div>
|
||||||
<div class="task-card__meta">
|
<div class="task-card__meta">
|
||||||
${renderPriorityBadge(task.priority)}
|
${renderPriorityBadge(task.priority)}
|
||||||
@@ -190,9 +182,9 @@ function renderTaskCard(task, opts = {}) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
${task.assigned_color ? `
|
${task.assigned_color ? `
|
||||||
<div class="task-avatar" style="background-color:${task.assigned_color}"
|
<div class="task-avatar" style="background-color:${esc(task.assigned_color)}"
|
||||||
title="${task.assigned_name ?? ''}">
|
title="${esc(task.assigned_name)}">
|
||||||
${initials(task.assigned_name ?? '')}
|
${esc(initials(task.assigned_name ?? ''))}
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
|
|
||||||
<button class="btn btn--ghost btn--icon" data-action="edit-task" data-id="${task.id}"
|
<button class="btn btn--ghost btn--icon" data-action="edit-task" data-id="${task.id}"
|
||||||
@@ -252,7 +244,7 @@ function renderModalContent({ task = null, users = [] } = {}) {
|
|||||||
const isEdit = !!task;
|
const isEdit = !!task;
|
||||||
|
|
||||||
const userOptions = users.map((u) =>
|
const userOptions = users.map((u) =>
|
||||||
`<option value="${u.id}" ${task?.assigned_to === u.id ? 'selected' : ''}>${u.display_name}</option>`
|
`<option value="${u.id}" ${task?.assigned_to === u.id ? 'selected' : ''}>${esc(u.display_name)}</option>`
|
||||||
).join('');
|
).join('');
|
||||||
|
|
||||||
const catLabels = CATEGORY_LABELS();
|
const catLabels = CATEGORY_LABELS();
|
||||||
@@ -272,7 +264,7 @@ function renderModalContent({ task = null, users = [] } = {}) {
|
|||||||
<div class="form-field">
|
<div class="form-field">
|
||||||
<label class="label" for="task-title">${t('tasks.titleLabel')}</label>
|
<label class="label" for="task-title">${t('tasks.titleLabel')}</label>
|
||||||
<input class="input" type="text" id="task-title" name="title"
|
<input class="input" type="text" id="task-title" name="title"
|
||||||
value="${task?.title ?? ''}" placeholder="${t('tasks.titlePlaceholder')}"
|
value="${esc(task?.title)}" placeholder="${t('tasks.titlePlaceholder')}"
|
||||||
required autocomplete="off">
|
required autocomplete="off">
|
||||||
<div class="form-field__error">
|
<div class="form-field__error">
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
@@ -288,7 +280,7 @@ function renderModalContent({ task = null, users = [] } = {}) {
|
|||||||
<label class="label" for="task-description">${t('tasks.descriptionLabel')}</label>
|
<label class="label" for="task-description">${t('tasks.descriptionLabel')}</label>
|
||||||
<textarea class="input" id="task-description" name="description"
|
<textarea class="input" id="task-description" name="description"
|
||||||
rows="2" placeholder="${t('tasks.descriptionPlaceholder')}"
|
rows="2" placeholder="${t('tasks.descriptionPlaceholder')}"
|
||||||
style="resize:vertical">${task?.description ?? ''}</textarea>
|
style="resize:vertical">${esc(task?.description)}</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||||
@@ -513,7 +505,7 @@ function renderKanbanCard(task) {
|
|||||||
return `
|
return `
|
||||||
<div class="kanban-card ${task.status === 'done' ? 'kanban-card--done' : ''}"
|
<div class="kanban-card ${task.status === 'done' ? 'kanban-card--done' : ''}"
|
||||||
data-task-id="${task.id}" draggable="true">
|
data-task-id="${task.id}" draggable="true">
|
||||||
<div class="kanban-card__title">${escHtml(task.title)}</div>
|
<div class="kanban-card__title">${esc(task.title)}</div>
|
||||||
<div class="kanban-card__meta">
|
<div class="kanban-card__meta">
|
||||||
${renderPriorityBadge(task.priority)}
|
${renderPriorityBadge(task.priority)}
|
||||||
${due ? `<span class="due-date ${due.cls}"><i data-lucide="clock" style="width:10px;height:10px" aria-hidden="true"></i> ${due.label}</span>` : ''}
|
${due ? `<span class="due-date ${due.cls}"><i data-lucide="clock" style="width:10px;height:10px" aria-hidden="true"></i> ${due.label}</span>` : ''}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Modul: HTML Utilities
|
||||||
|
* Zweck: XSS-Schutz fuer innerHTML-basiertes Rendering
|
||||||
|
* Abhaengigkeiten: keine
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ESCAPE_MAP = {
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": ''',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ESCAPE_RE = /[&<>"']/g;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escapet einen String fuer die sichere Einbettung in HTML.
|
||||||
|
* Gibt fuer null/undefined einen Leerstring zurueck.
|
||||||
|
*
|
||||||
|
* @param {*} str - Beliebiger Wert (wird zu String konvertiert)
|
||||||
|
* @returns {string} HTML-sicherer String
|
||||||
|
*/
|
||||||
|
export function esc(str) {
|
||||||
|
if (str == null) return '';
|
||||||
|
return String(str).replace(ESCAPE_RE, (ch) => ESCAPE_MAP[ch]);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user