Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .trivyignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ CVE-2026-33671
# our deployment. Will be removed once upstream Caddy pulls otel >= 1.43.0.
CVE-2026-39883

# brace-expansion 5.0.7 in npm's OWN bundled node_modules (usr/local/lib/
# node_modules/npm/node_modules/brace-expansion) — used only by npm itself for
# glob expansion during CLI operations, NOT reachable from gatecontrol runtime.
# Our own brace-expansion (5.0.6) is a devDependency and never enters the image:
# the Dockerfile builds with `npm ci --production`. No fix available without npm
# shipping a patched bundle — npm is pinned to @11 because @12 broke the build
# on Node 20. Will be removed once npm@11 bundles brace-expansion >= 5.0.8.
CVE-2026-14257

# undici in npm's OWN bundled node_modules (usr/local/lib/node_modules/npm/
# node_modules/undici) — pulled in by `npm install -g npm@latest` in the image,
# used only by npm itself for registry fetches, NOT reachable from gatecontrol
Expand Down
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
# Changelog

## [Unreleased]

### Added
- Skoda: Abfahrtstimer (Klima-Timer) je Fahrzeug lesen und setzen — An/Aus, Uhrzeit und Wochentage, in der Admin-Seite `/skoda` und im Portal-Widget. Neuer Command `timer_set` über `POST /api/v2/air-conditioning/{vin}/timers`. Einmal-Timer (`ONE_OFF`) werden nur angezeigt, nicht geschrieben.
- Skoda: Im Portal sind die Abfahrtszeiten nur für eingeloggte Besitzer sichtbar — sie sind ein Anwesenheitsprofil und werden wie die GPS-Position behandelt, nicht wie der Klimastatus.

### Changed
- Skoda: `state.climate.timers` wird aus dem ohnehin geholten Klima-Payload übernommen — kein zusätzlicher Cloud-Abruf.

### Notes
- Die Skoda-Cloud übernimmt Timer-Änderungen asynchron (gemessen rund 55 Sekunden). Bis zum nächsten vollständigen Sync kann die Anzeige noch die alten Werte zeigen, ohne dass etwas fehlgeschlagen wäre.
- Direkt nach dem Update zeigen die Karten bis zum nächsten Poll „Keine Timer konfiguriert", weil der gespeicherte Fahrzeugzustand das Feld noch nicht kennt.

---

## [1.118.2] — 2026-07-23

### Dokumentation
Expand Down
5 changes: 4 additions & 1 deletion caddy-plugins/mirror/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,8 @@ require (
github.com/go-jose/go-jose/v3 v3.0.5
github.com/go-jose/go-jose/v4 v4.1.4
github.com/smallstep/certificates v0.30.0
google.golang.org/grpc v1.79.3
// Mindestversion erzwingt den Fix für GHSA-hrxh-6v49-42gf (gRPC-Go: xDS RBAC
// und HTTP/2). Der Plugin-Graph zog sonst v1.81.0 herein, was Trivy als HIGH
// meldet; behoben in v1.82.1.
google.golang.org/grpc v1.82.1
)
13 changes: 13 additions & 0 deletions public/css/portal.css
Original file line number Diff line number Diff line change
Expand Up @@ -489,3 +489,16 @@ body::before{
*,*::before{transition:none !important; animation:none !important}
.card{opacity:1; transform:none}
}

.skoda-timers { margin-top: 10px; font-size: .88em; color: var(--muted); }
.skoda-timers summary { cursor: pointer; }
.skoda-timer { border-top: 1px solid var(--line); padding: 8px 0; }
.skoda-timer-head { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
.skoda-timer-days { display: flex; flex-wrap: wrap; gap: 4px; margin: 6px 0; }
.skoda-timer-day { padding: 3px 8px; border-radius: 999px; border: 1px solid var(--line); background: transparent; color: inherit; cursor: pointer; font-size: .85em; }
.skoda-timer-day[aria-pressed="true"] { background: color-mix(in srgb, var(--teal) 20%, transparent); border-color: var(--teal); color: var(--teal); }
.skoda-timer-day[disabled] { opacity: .5; cursor: default; }
.skoda-timer-foot { display: flex; gap: 8px; align-items: center; }
.skoda-timer-foot button { padding: 5px 10px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-3); color: inherit; cursor: pointer; font-size: .85em; }
.skoda-timer-msg { font-size: .85em; }
.skoda-timer-msg.skoda-timer-ok { color: var(--green); }
11 changes: 11 additions & 0 deletions public/css/skoda.css
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,14 @@
.skoda-details-equipment { display: flex; flex-wrap: wrap; gap: 6px; align-items: baseline; }
.skoda-chip { font-size: .82em; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--border-color, rgba(128,128,128,0.25)); opacity: .85; }
.skoda-details-error { opacity: .7; font-style: italic; }
.skoda-timers-block { margin-top: 8px; font-size: .9em; }
.skoda-timers-block summary { cursor: pointer; }
.skoda-timer { border-top: 1px solid var(--border-color, rgba(128,128,128,0.25)); padding: 8px 0; }
.skoda-timer-head { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
.skoda-timer-days { display: flex; flex-wrap: wrap; gap: 4px; margin: 6px 0; }
.skoda-timer-day { padding: 3px 8px; border-radius: 999px; border: 1px solid var(--border-color, rgba(128,128,128,0.25)); background: transparent; color: inherit; cursor: pointer; font-size: .85em; }
.skoda-timer-day[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: var(--text-inverse, #fff); }
.skoda-timer-day[disabled] { opacity: .5; cursor: default; }
.skoda-timer-foot { display: flex; gap: 8px; align-items: center; }
.skoda-timer-msg { font-size: .85em; }
.skoda-timer-msg.skoda-timer-ok { color: var(--green); }
154 changes: 145 additions & 9 deletions public/js/portal.js
Original file line number Diff line number Diff line change
Expand Up @@ -914,6 +914,84 @@
.then(function () { clearTimeout(watchdog); setTimeout(reset, 3000); });
}

// Ungespeicherte Eingaben werden PRO ZEILE markiert: pro Block oder pro Karte
// zu markieren hieße, dass das Speichern von Timer 1 die Eingabe in Timer 2
// zum Überschreiben freigibt. Zuklappen räumt bewusst NICHT auf.
var _skodaDirtyTimeout = null;
function markSkodaTimerDirty(node) {
var row = node.closest('.skoda-timer'); if (!row) return;
row.dataset.dirty = '1';
if (_skodaDirtyTimeout) clearTimeout(_skodaDirtyTimeout);
// Notbremse gegen einen vergessenen Tab: nach 10 Minuten ohne Eingabe wird
// freigegeben — und die Eingabe damit bewusst verworfen.
_skodaDirtyTimeout = setTimeout(function () { clearSkodaTimerDirty(); }, 600000);
}
function clearSkodaTimerDirty(row) {
if (row) { delete row.dataset.dirty; } else {
var all = skodaEl(); if (!all) return;
var rows = all.querySelectorAll('.skoda-timer[data-dirty]');
for (var i = 0; i < rows.length; i++) delete rows[i].dataset.dirty;
}
var el = skodaEl();
if (_skodaDirtyTimeout && el && !el.querySelector('.skoda-timer[data-dirty]')) {
clearTimeout(_skodaDirtyTimeout); _skodaDirtyTimeout = null;
}
}

var SKODA_TIMER_ERRORS = {
SKODA_TIMER_NOT_FOUND: 'skodaTimersNotFound',
SKODA_TIMER_READONLY: 'skodaTimersReadonly',
SKODA_VALIDATION: 'skodaTimersInvalid',
};

// Eigener Sender statt skodaCommand(): der ruft bei Erfolg hydrateSkoda() nach
// 3s, und die Cloud übernimmt Timer-Änderungen erst nach rund 55 Sekunden —
// der Rebuild würde die optimistisch gesetzten Werte durch alte ersetzen.
function saveSkodaTimer(row, btn) {
if (!row || btn.disabled) return;
var msg = row.querySelector('.skoda-timer-msg');
var time = row.querySelector('[data-timer-time]').value;
var chips = row.querySelectorAll('.skoda-timer-day[aria-pressed="true"]');
var days = [];
for (var i = 0; i < chips.length; i++) days.push(chips[i].dataset.day);
if (!time || !days.length) { msg.textContent = PT.skodaTimersInvalid; return; }
// Ganze Zeile einfrieren, sonst quittiert "Gespeichert" auch Änderungen,
// die nach dem Absenden getippt und nie gesendet wurden.
var fields = row.querySelectorAll('input, .skoda-timer-day');
var release = function () {
btn.disabled = false;
for (var f = 0; f < fields.length; f++) fields[f].disabled = false;
};
btn.disabled = true;
for (var k = 0; k < fields.length; k++) fields[k].disabled = true;
msg.textContent = '';
msg.classList.remove('skoda-timer-ok');
// fetch hat kein eigenes Timeout.
var watchdog = setTimeout(function () { release(); msg.textContent = PT.skodaTimersSaveFailed; }, 30000);
fetch('/api/v1/portal/skoda/vehicles/' + encodeURIComponent(row.dataset.veh) + '/command', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'timer_set',
args: { id: Number(row.dataset.timer), enabled: row.querySelector('[data-timer-enabled]').checked, time: time, days: days },
}),
}).then(function (r) { return r.json().catch(function () { return {}; }); })
.then(function (body) {
// reason (login_required/unavailable) kommt als data:null,reason MIT ok:true → auch ein Fehlschlag
if (!body || !body.ok || body.reason) {
var code = body && body.error;
var key = Object.prototype.hasOwnProperty.call(SKODA_TIMER_ERRORS, code) ? SKODA_TIMER_ERRORS[code] : null;
msg.textContent = (key && PT[key]) || PT.skodaTimersSaveFailed;
return;
}
clearSkodaTimerDirty(row);
msg.textContent = PT.skodaTimersSaved;
msg.classList.add('skoda-timer-ok');
setTimeout(function () { msg.textContent = ''; msg.classList.remove('skoda-timer-ok'); }, 3000);
})
.catch(function () { msg.textContent = PT.skodaTimersSaveFailed; })
.then(function () { clearTimeout(watchdog); release(); });
}

// Read-only enrichment (meta/equipment/connection/drivingScore) — same four
// groups as the admin side, redacted (masked VIN). Every value is escHtml'd
// before it reaches innerHTML, including numbers, per the XSS guard convention
Expand Down Expand Up @@ -997,6 +1075,56 @@
var s = ev.target.closest('select[data-cmd="charge_limit"]'); if (!s) return;
skodaCommand(Number(s.closest('.skoda-cmds').dataset.veh), 'charge_limit', { limit: Number(s.value) }, s);
});
el.addEventListener('click', function (ev) {
var chip = ev.target.closest('.skoda-timer-day');
if (chip && !chip.disabled) {
chip.setAttribute('aria-pressed', chip.getAttribute('aria-pressed') === 'true' ? 'false' : 'true');
markSkodaTimerDirty(chip);
return;
}
var save = ev.target.closest('[data-timer-save]');
if (save) saveSkodaTimer(save.closest('.skoda-timer'), save);
});
el.addEventListener('input', function (ev) {
if (ev.target.closest('.skoda-timer')) markSkodaTimerDirty(ev.target);
});
el.addEventListener('change', function (ev) {
if (ev.target.closest('.skoda-timer')) markSkodaTimerDirty(ev.target);
});
}

var SKODA_DAYS = [['MONDAY', 'skodaTimersDayMon'], ['TUESDAY', 'skodaTimersDayTue'], ['WEDNESDAY', 'skodaTimersDayWed'],
['THURSDAY', 'skodaTimersDayThu'], ['FRIDAY', 'skodaTimersDayFri'], ['SATURDAY', 'skodaTimersDaySat'], ['SUNDAY', 'skodaTimersDaySun']];

function skodaTimerRow(vehId, t) {
var days = Array.isArray(t.days) ? t.days : [];
var editable = t.type === 'RECURRING';
var dis = editable ? '' : ' disabled';
var chips = SKODA_DAYS.map(function (d) {
return '<button type="button" class="skoda-timer-day" data-day="' + escHtml(d[0]) + '" aria-pressed="'
+ (days.indexOf(d[0]) >= 0 ? 'true' : 'false') + '"' + dis + '>' + escHtml(PT[d[1]]) + '</button>';
}).join('');
return '<div class="skoda-timer" data-veh="' + escHtml(vehId) + '" data-timer="' + escHtml(t.id) + '">'
+ '<div class="skoda-timer-head"><strong>' + escHtml(PT.skodaTimersTimer) + ' ' + escHtml(t.id) + '</strong>'
+ '<label><input type="checkbox" data-timer-enabled' + (t.enabled ? ' checked' : '') + dis + '> ' + escHtml(PT.skodaTimersActive) + '</label>'
+ '<label>' + escHtml(PT.skodaTimersTime) + ' <input type="time" data-timer-time value="' + escHtml(t.time || '') + '"' + dis + '></label></div>'
+ '<div class="skoda-timer-days" role="group" aria-label="' + escHtml(PT.skodaTimersDays) + '">' + chips + '</div>'
+ '<div class="skoda-timer-foot">'
+ (editable ? '<button data-timer-save>' + escHtml(PT.skodaTimersSave) + '</button>' : '')
+ '<span class="skoda-timer-msg">' + (t.type === 'ONE_OFF' ? escHtml(PT.skodaTimersReadonly) : '') + '</span>'
+ '</div></div>';
}

function skodaTimersBlock(v, loggedIn) {
// Abfahrtszeiten sind ein Anwesenheitsprofil — ohne Login liefert der Server
// sie gar nicht, also auch keinen Abschnitt zeigen (sonst stünde dort die
// Unwahrheit "Keine Timer konfiguriert").
if (!loggedIn) return '';
var timers = (v.state && v.state.climate && v.state.climate.timers) || [];
var body = timers.length
? timers.map(function (t) { return skodaTimerRow(v.id, t); }).join('')
: '<div>' + escHtml(PT.skodaTimersNone) + '</div>';
return '<details class="skoda-timers"><summary>' + escHtml(PT.skodaTimersTitle) + '</summary>' + body + '</details>';
}

function renderSkodaCard(v, loggedIn) {
Expand Down Expand Up @@ -1045,6 +1173,7 @@
+ (hl.warnings && hl.warnings.length ? '<div>' + escHtml(PT.skodaWarnings) + ': ' + escHtml(hl.warnings.join(', ')) + '</div>' : '')
+ '<div class="skoda-enrich" data-veh="' + escHtml(v.id) + '"></div>'
+ '</details>'
+ skodaTimersBlock(v, loggedIn)
+ (loggedIn ? '<div class="skoda-cmds" data-veh="' + escHtml(v.id) + '">'
+ '<button data-cmd="ac_start" data-temp="21">' + escHtml(PT.skodaCmdAcOn) + '</button>'
+ '<button data-cmd="ac_stop">' + escHtml(PT.skodaCmdAcOff) + '</button>'
Expand All @@ -1063,27 +1192,34 @@

function renderSkoda(vehicles, loggedIn) {
var el = skodaEl(); if (!el) return;
// Preserve which cards had their <details> expanded across the full rebuild,
// so a 120s poll never collapses what the user opened. Tracked by position
// (card order is stable — server returns vehicles ORDER BY id) using only
// the boolean `details.open`, so no DOM text ever feeds back into innerHTML.
var wasOpen = [];
// Ein Rebuild ersetzt innerHTML komplett. Steht irgendwo eine ungespeicherte
// Timer-Eingabe, wird NICHT gerendert — egal ob der Aufruf aus dem 120s-Poll
// oder aus hydrateSkoda() kommt.
if (el.querySelector('.skoda-timer[data-dirty]')) return;
// Offen-Zustand je Fahrzeug-ID merken, nicht positionsindiziert, und je
// Aufklapper klassengebunden — ein nacktes details-Selektorziel ohne Klasse
// träfe je nach DOM-Reihenfolge den falschen Block.
var wasOpen = {};
var oldCards = el.querySelectorAll('.skoda-card');
for (var i = 0; i < oldCards.length; i++) {
var od = oldCards[i].querySelector('details');
wasOpen[i] = !!(od && od.open);
var od = oldCards[i].querySelector('details.skoda-details');
var ot = oldCards[i].querySelector('details.skoda-timers');
wasOpen[oldCards[i].dataset.id] = { details: !!(od && od.open), timers: !!(ot && ot.open) };
}
el.innerHTML = vehicles.map(function (v) { return renderSkodaCard(v, loggedIn); }).join('');
var newCards = el.querySelectorAll('.skoda-card');
for (var j = 0; j < newCards.length; j++) {
var nd = newCards[j].querySelector('details'); if (!nd) continue;
var st = wasOpen[newCards[j].dataset.id] || { details: false, timers: false };
var nt = newCards[j].querySelector('details.skoda-timers');
if (nt && st.timers) nt.open = true;
var nd = newCards[j].querySelector('details.skoda-details'); if (!nd) continue;
// toggle never bubbles — (re)bind on every rebuilt <details> instance.
nd.addEventListener('toggle', function (ev) {
if (!ev.target.open) return;
var box = ev.target.querySelector('.skoda-enrich'); if (!box) return;
loadSkodaDetails(Number(box.dataset.veh), box);
});
if (wasOpen[j]) {
if (st.details) {
nd.open = true;
// 120s poll rebuild would otherwise blank an already-fetched block.
var box = nd.querySelector('.skoda-enrich');
Expand Down
Loading
Loading