'use strict'; (() => { // ------------------------------------------------------------------ basics const $ = (s, r = document) => r.querySelector(s); const $$ = (s, r = document) => Array.from(r.querySelectorAll(s)); const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ESC[c]); const store = { get(k, d) { try { return localStorage.getItem(k) ?? d; } catch { return d; } }, set(k, v) { try { localStorage.setItem(k, v); } catch { /* private mode */ } }, }; let LANG = store.get('ap.lang', 'en'); const T = (pl, en) => (LANG === 'pl' ? pl : en); const S = { session: null, csrf: '', settings: null, timezones: [], timers: [], sse: null, page: 'overview', devices: [] }; function clearTimers() { S.timers.forEach((t) => clearInterval(t)); S.timers = []; if (S.sse) { S.sse.close(); S.sse = null; } } function every(ms, fn) { fn(); S.timers.push(setInterval(fn, ms)); } // ------------------------------------------------------------------ formatting const fmtNum = (n) => (n == null ? '-' : Number(n).toLocaleString(LANG === 'pl' ? 'pl-PL' : 'en-US')); function fmtBytes(b) { b = Number(b || 0); const u = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0; while (b >= 1024 && i < u.length - 1) { b /= 1024; i++; } return `${b.toFixed(b < 10 && i ? 1 : 0)} ${u[i]}`; } function fmtDur(s) { s = Math.max(0, Math.floor(s)); const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60); if (d) return `${d}d ${h}h`; if (h) return `${h}h ${m}m`; if (m) return `${m}m ${s % 60}s`; return `${s}s`; } const fmtTs = (t) => (t ? new Date(t * 1000).toLocaleString(LANG === 'pl' ? 'pl-PL' : 'en-GB') : '-'); const fmtTime = (t) => (t ? new Date(t * 1000).toLocaleTimeString(LANG === 'pl' ? 'pl-PL' : 'en-GB') : '-'); function ago(t) { if (!t) return T('nigdy', 'never'); const s = Math.floor(Date.now() / 1000) - t; if (s < 60) return T(`${s}s temu`, `${s}s ago`); return T(`${fmtDur(s)} temu`, `${fmtDur(s)} ago`); } // ------------------------------------------------------------------ api async function api(method, path, body) { const opt = { method, credentials: 'same-origin', headers: {} }; if (method !== 'GET') opt.headers['X-CSRF-Token'] = S.csrf; if (body !== undefined) { opt.headers['Content-Type'] = 'application/json'; opt.body = JSON.stringify(body); } const r = await fetch(path, opt); if (r.status === 401 && !path.startsWith('/api/login') && !path.startsWith('/api/setup')) { showAuth(); throw new Error(T('Sesja wygasła', 'Session expired')); } const ct = r.headers.get('content-type') || ''; const data = ct.includes('json') ? await r.json() : await r.text(); if (!r.ok) throw new Error((data && data.error) || `HTTP ${r.status}`); return data; } let toastT; function toast(msg, error) { const t = $('#toast'); t.textContent = msg; t.classList.toggle('error', !!error); t.hidden = false; clearTimeout(toastT); toastT = setTimeout(() => { t.hidden = true; }, error ? 6000 : 3000); } function applyWidths(root) { $$('[data-w]', root).forEach((el) => { el.style.width = `${Math.max(0, Math.min(100, Number(el.dataset.w)))}%`; }); } // ------------------------------------------------------------------ charts (SVG) const NS = 'http://www.w3.org/2000/svg'; function niceMax(v) { if (v <= 0) return 1; const p = 10 ** Math.floor(Math.log10(v)); for (const m of [1, 2, 2.5, 5, 10]) if (m * p >= v) return m * p; return 10 * p; } /** Line/area chart. series: [{name, cls:'k1', values:[...] }], labels: [string] */ function lineChart(el, { labels, series, height = 170, area = true, fmt = fmtNum, bars = false, min = 1 }) { const W = Math.max(el.clientWidth || 600, 280), H = height, L = 46, R = 22, Tp = 8, B = 20; const n = labels.length; const max = niceMax(Math.max(min, ...series.flatMap((s) => s.values))); const slot = (W - L - R) / Math.max(1, n); const x = bars ? (i) => L + slot * (i + 0.5) : (i) => L + (n <= 1 ? 0 : (i * (W - L - R)) / (n - 1)); const y = (v) => Tp + (H - Tp - B) * (1 - v / max); let svg = ``; for (let g = 0; g <= 4; g++) { const v = (max * g) / 4, yy = y(v); svg += `${esc(fmt(Math.round(v * 100) / 100))}`; } const step = Math.max(1, Math.ceil(n / 8)); for (let i = 0; i < n; i += step) { const anchor = !bars && n > 1 && i === 0 ? 'start' : (x(i) > W - R - 30 ? 'end' : 'middle'); svg += `${esc(labels[i])}`; } if (bars) { const bw = Math.min(26, Math.max(1, (slot * 0.7) / series.length)); series.forEach((s, si) => { s.values.forEach((v, i) => { const xx = x(i) - (bw * series.length) / 2 + si * bw; svg += ``; }); }); } else { series.forEach((s) => { const pts = s.values.map((v, i) => `${x(i)},${y(v)}`).join(' '); if (area && n > 1) svg += ``; svg += ``; }); } svg += ``; el.innerHTML = `
${svg}
${series.map((s) => `${esc(s.name)}`).join('')}
`; const wrap = $('.chart-wrap', el), tip = $('.chart-tip', el), cur = $('#cur', el), svgEl = $('svg', el); wrap.addEventListener('mousemove', (e) => { const rect = svgEl.getBoundingClientRect(); const px = ((e.clientX - rect.left) / rect.width) * W; const i = Math.max(0, Math.min(n - 1, bars ? Math.floor((px - L) / slot) : Math.round(((px - L) / (W - L - R)) * (n - 1)))); cur.setAttribute('x1', x(i)); cur.setAttribute('x2', x(i)); cur.setAttribute('visibility', 'visible'); tip.innerHTML = `${esc(labels[i])}
${series.map((s) => `${esc(s.name)}: ${esc(fmt(s.values[i]))}`).join('
')}`; tip.hidden = false; const left = Math.min(e.clientX - rect.left + 12, rect.width - tip.offsetWidth - 4); tip.style.left = `${Math.max(0, left)}px`; tip.style.top = '6px'; }); wrap.addEventListener('mouseleave', () => { tip.hidden = true; cur.setAttribute('visibility', 'hidden'); }); } function spark(values, cls = 'k1') { const n = values.length; if (n < 2) return ''; const max = Math.max(1, ...values); const pts = values.map((v, i) => `${(i * 100) / (n - 1)},${30 - (v / max) * 28}`).join(' '); return ``; } function donut(el, items, center) { const total = items.reduce((a, b) => a + b.value, 0) || 1; const r = 48, C = 2 * Math.PI * r; let off = 0, arcs = ''; items.forEach((it, i) => { const len = (it.value / total) * C; arcs += `${esc(it.label)}: ${it.value}`; off += len; }); el.innerHTML = `
${arcs}${esc(center ?? total)}
${items.map((it, i) => `
${esc(it.label)} - ${fmtNum(it.value)}
`).join('') || `
${T('Brak danych', 'No data')}
`}
`; } function hbars(items, cls = 'k1') { const max = Math.max(1, ...items.map((i) => i.value)); return items.map((i) => `
${esc(i.label)}
${fmtNum(i.value)}
`).join('') || `
${T('Brak danych', 'No data')}
`; } // ------------------------------------------------------------------ modal function modal(html, cls = '') { const root = $('#modal-root'); root.innerHTML = `
`; const bd = $('.backdrop', root); bd.addEventListener('mousedown', (e) => { if (e.target === bd) closeModal(); }); $$('[data-close]', root).forEach((b) => b.addEventListener('click', closeModal)); document.addEventListener('keydown', escClose); applyWidths(root); return $('.dialog', root); } function escClose(e) { if (e.key === 'Escape') closeModal(); } function closeModal() { $('#modal-root').innerHTML = ''; document.removeEventListener('keydown', escClose); } function confirmBox(title, text, danger, typed) { return new Promise((resolve) => { const d = modal(`

${esc(title)}

${esc(text)}

${typed ? `` : ''}
`, 'small'); $('#cf-ok', d).addEventListener('click', () => { if (typed && $('#cf-typed', d).value.trim() !== typed) { toast(T('Wpisany tekst się nie zgadza', 'Text does not match'), true); return; } closeModal(); resolve(true); }); $$('[data-close]', d).forEach((b) => b.addEventListener('click', () => resolve(false))); }); } // ------------------------------------------------------------------ navigation const NAV = [ ['overview', T('Przegląd', 'Overview')], ['live', T('Na żywo', 'Live')], ['devices', T('Urządzenia', 'Devices')], ['users', T('Skrzynki', 'Mailboxes')], ['backends', T('Backendy', 'Backends')], ['activesync', 'ActiveSync'], ['policies', T('Polityki', 'Policies')], ['security', T('Bezpieczeństwo', 'Security')], ['logs', T('Logi i audyt', 'Logs & audit')], ['about', T('O programie', 'About')], ]; const navLabel = (id) => (NAV.find((n) => n[0] === id) || NAV[0])[1]; function renderNav() { $('#nav').innerHTML = NAV.map((n, i) => ``).join(''); $$('#nav .nav').forEach((b) => b.addEventListener('click', () => { location.hash = b.dataset.page; $('#nav').classList.remove('open'); })); } function go() { const id = (location.hash || '#overview').slice(1).split('/')[0]; S.page = PAGES[id] ? id : 'overview'; clearTimers(); closeModal(); renderNav(); $('#page-label').textContent = navLabel(S.page); const v = $('#view'); v.innerHTML = ''; PAGES[S.page](v).catch((e) => { v.innerHTML = `
${esc(e.message)}
`; }); } function head(eyebrow, title, desc, actions = '') { return `
${esc(eyebrow)}

${esc(title)}

${desc ? `

${esc(desc)}

` : ''}
${actions}
`; } // ------------------------------------------------------------------ pages const PAGES = {}; PAGES.overview = async (v) => { v.innerHTML = head(T('PANEL STEROWANIA', 'CONTROL ROOM'), T('Przegląd', 'Overview'), T('Ruch ActiveSync, aktywne połączenia push, urządzenia i stan backendów.', 'ActiveSync traffic, push connections, devices and backend state.'), ``) + `
${T('ŻĄDANIA / MIN', 'REQUESTS / MIN')}

${T('Ruch', 'Traffic')}

${T('ELEMENTY / MIN', 'ITEMS / MIN')}

${T('Synchronizacja danych', 'Data synchronisation')}

${T('TRANSFER / MIN', 'TRANSFER / MIN')}

${T('Dane', 'Data')}

${T('OSTATNIE 7 DNI', 'LAST 7 DAYS')}

${T('Żądania na godzinę', 'Requests per hour')}

${T('OD STARTU', 'SINCE START')}

${T('Komendy', 'Commands')}

${T('URZĄDZENIA', 'DEVICES')}

${T('Typy urządzeń', 'Device types')}

RUNTIME

${T('Serwer', 'Server')}

`; const rangeSel = $('#range', v); rangeSel.value = store.get('ap.range', '60'); rangeSel.addEventListener('change', () => { store.set('ap.range', rangeSel.value); refresh(); }); async function refresh() { const [o, ts] = await Promise.all([api('GET', '/api/overview'), api('GET', `/api/timeseries?minutes=${rangeSel.value}`)]); const lab = ts.map((b) => new Date(b.t * 1000).toLocaleTimeString(LANG === 'pl' ? 'pl-PL' : 'en-GB', { hour: '2-digit', minute: '2-digit' })); const last = ts.slice(-15); const rpm = last.length ? Math.round(last.reduce((a, b) => a + b.requests, 0) / last.length * 10) / 10 : 0; const errRate = o.requests ? ((o.errors / o.requests) * 100).toFixed(2) : '0.00'; const tile = (label, value, sub, cls = '', sp = '') => `
${esc(label)}
${value}
${sub}
${sp}
`; $('#tiles').innerHTML = [ tile(T('Żądania / min', 'Requests / min'), esc(rpm), T(`łącznie ${fmtNum(o.requests)}`, `total ${fmtNum(o.requests)}`), 'accent', spark(ts.map((b) => b.requests))), tile(T('Połączenia push', 'Push connections'), fmtNum(o.pings), T(`${o.active_requests} aktywnych żądań`, `${o.active_requests} active requests`), 'good', spark(ts.map((b) => b.pings), 'k2')), tile(T('Urządzenia', 'Devices'), `${fmtNum(o.devices_24h)}/ ${fmtNum(o.devices_total)}`, T(`aktywne 24h / wszystkie · ${fmtNum(o.partnerships_total)} partnerstw`, `active 24h / total · ${fmtNum(o.partnerships_total)} partnerships`)), tile(T('Skrzynki', 'Mailboxes'), `${fmtNum(o.users_active_15m)}/ ${fmtNum(o.users_total)}`, T('aktywne 15 min / wszystkie', 'active 15 min / total')), tile(T('Wysłane maile', 'Mails sent'), fmtNum(o.mails_sent), T(`elementy ↓${fmtNum(o.items_out)} ↑${fmtNum(o.items_in)}`, `items ↓${fmtNum(o.items_out)} ↑${fmtNum(o.items_in)}`), '', spark(ts.map((b) => b.mails_sent), 'k3')), tile(T('Błędy', 'Errors'), fmtNum(o.errors), `${errRate}% ${T('żądań', 'of requests')}`, o.errors ? 'bad' : '', spark(ts.map((b) => b.errors), 'k5')), tile(T('Nieudane logowania', 'Failed logins'), fmtNum(o.auth_failures), T(`${o.lockouts} blokad aktywnych`, `${o.lockouts} active lockouts`), o.lockouts ? 'bad' : '', spark(ts.map((b) => b.auth_failures), 'k5')), tile(T('Transfer', 'Transfer'), fmtBytes(o.bytes_out), `↑ ${fmtBytes(o.bytes_in)}`, '', spark(ts.map((b) => b.bytes_out), 'k4')), ].join(''); if (o.devices_pending || o.wipes_pending) { $('#tiles').insertAdjacentHTML('afterbegin', `
${o.devices_pending ? `${o.devices_pending} ${T('urządzeń czeka na zatwierdzenie', 'devices await approval')}` : ''}${o.wipes_pending ? `${o.wipes_pending} ${T('zdalnych wymazań w toku', 'remote wipes pending')}` : ''}
`); } lineChart($('#ch-req'), { labels: lab, series: [ { name: T('żądania', 'requests'), cls: 'k1', values: ts.map((b) => b.requests) }, { name: 'ping', cls: 'k2', values: ts.map((b) => b.pings) }, { name: T('błędy', 'errors'), cls: 'k5', values: ts.map((b) => b.errors) }, ] }); lineChart($('#ch-items'), { labels: lab, bars: true, series: [ { name: T('do urządzeń', 'to devices'), cls: 'k3', values: ts.map((b) => b.items_out) }, { name: T('z urządzeń', 'from devices'), cls: 'k4', values: ts.map((b) => b.items_in) }, ] }); lineChart($('#ch-bytes'), { labels: lab, fmt: fmtBytes, min: 1024, series: [ { name: T('wysłane', 'sent'), cls: 'k4', values: ts.map((b) => b.bytes_out) }, { name: T('odebrane', 'received'), cls: 'k6', values: ts.map((b) => b.bytes_in) }, ] }); $('#cmds').innerHTML = hbars(o.commands.slice(0, 12).map(([k, s]) => ({ label: k, value: s.count }))); applyWidths($('#cmds')); donut($('#types'), o.device_types.map(([t, n]) => ({ label: t, value: n })), o.devices_total); $('#runtime').innerHTML = [ [T('Wersja', 'Version'), o.version], ['Uptime', fmtDur(o.uptime)], [T('Pamięć RSS', 'Memory RSS'), fmtBytes(o.rss)], [T('Pula IMAP', 'IMAP pool'), o.imap_pool], [T('Baza danych', 'Database'), `${fmtBytes(o.db_bytes)} + WAL ${fmtBytes(o.wal_bytes)}`], ['IMAP', o.backends.imap], ['SMTP', o.backends.smtp], ['CalDAV', o.backends.caldav || T('wyłączony', 'disabled')], ['CardDAV', o.backends.carddav || T('wyłączony', 'disabled')], ['Provisioning', o.provisioning ? T('włączony', 'enabled') : T('wyłączony', 'disabled')], ['Autodiscover', fmtNum(o.autodiscover)], ].map(([k, val]) => `
${esc(k)}
${esc(val)}
`).join(''); $('#push-count').textContent = `PUSH ${o.pings}`; } async function hourly() { const h = await api('GET', '/api/stats/hourly?hours=168'); const lab = h.map((b) => { const d = new Date(b.t * 1000); return `${d.toLocaleDateString(LANG === 'pl' ? 'pl-PL' : 'en-GB', { weekday: 'short' })} ${String(d.getHours()).padStart(2, '0')}:00`; }); lineChart($('#ch-hourly'), { labels: lab, bars: true, series: [ { name: T('żądania', 'requests'), cls: 'k1', values: h.map((b) => b.requests) }, { name: T('błędy', 'errors'), cls: 'k5', values: h.map((b) => b.errors) }, ] }); } every(5000, () => refresh().catch(() => {})); every(300000, () => hourly().catch(() => {})); }; PAGES.live = async (v) => { v.innerHTML = head(T('NA ŻYWO', 'LIVE'), T('Ruch na żywo', 'Live traffic'), T('Trwające żądania, połączenia push (Ping / hanging Sync) i strumień zakończonych żądań.', 'In-flight requests, push connections (Ping / hanging Sync) and a stream of finished requests.'), ``) + `
PUSH

${T('Aktywne połączenia push', 'Active push connections')}

0
IN-FLIGHT

${T('Trwające żądania', 'Requests in progress')}

0
STREAM

${T('Zakończone żądania', 'Finished requests')}

`; let paused = false; $('#pause', v).addEventListener('click', (e) => { paused = !paused; e.target.textContent = paused ? T('Wznów', 'Resume') : T('Pauza', 'Pause'); }); const filter = $('#feed-filter', v); const feed = $('#feed', v); const row = (e, fresh) => `
${esc(fmtTime(e.ts))}${esc(e.user)} ${esc(e.command)} ${esc(e.device_type)} ${esc(e.http)} ${esc(e.status)}${e.items_out || e.items_in ? ` ↓${e.items_out} ↑${e.items_in}` : ''} ${e.command === 'Ping' ? fmtDur(e.ms / 1000) : `${e.ms} ms`}${fmtBytes(e.bytes_out)}
`; const applyFilter = () => { const q = filter.value.trim().toLowerCase(); $$('.feed-row', feed).forEach((r) => { r.hidden = q && !r.dataset.s.includes(q); }); }; filter.addEventListener('input', applyFilter); const data = await api('GET', '/api/live'); feed.innerHTML = data.recent.map((e) => row(e, false)).join('') || `
${T('Brak żądań', 'No requests yet')}
`; S.sse = new EventSource('/api/events'); S.sse.addEventListener('request', (m) => { if (paused) return; const e = JSON.parse(m.data); $('.empty', feed)?.remove(); feed.insertAdjacentHTML('afterbegin', row(e, true)); while (feed.children.length > 400) feed.lastElementChild.remove(); applyFilter(); }); every(2000, async () => { const d = await api('GET', '/api/live').catch(() => null); if (!d) return; $('#ping-n').textContent = d.pings.length; $('#act-n').textContent = d.active.length; $('#pings').innerHTML = d.pings.length ? `${d.pings.sort((a, b) => a.started - b.started).map((p) => { const el = d.now - p.started, pct = Math.min(100, (el / Math.max(1, p.lifetime)) * 100); return ``; }).join('')}
${T('Użytkownik', 'User')}${T('Urządzenie', 'Device')}${T('Typ', 'Kind')}${T('Foldery', 'Folders')}${T('Heartbeat', 'Heartbeat')}
${esc(p.user)}${esc(p.device_type)}${esc(p.device_id)}${esc(p.kind)}${p.folders}${fmtDur(el)} / ${fmtDur(p.lifetime)}
` : `
${T('Brak połączeń push', 'No push connections')}
`; $('#active').innerHTML = d.active.length ? `${d.active.map((a) => ``).join('')}
${T('Komenda', 'Command')}${T('Użytkownik', 'User')}${T('Urządzenie', 'Device')}IP${T('Czas', 'Time')}
${esc(a.command)}${esc(a.user)}${esc(a.device_type)}AS ${esc(a.version)}${esc(a.ip)}${fmtDur(d.now - a.started)}
` : `
${T('Brak', 'None')}
`; applyWidths(v); }); }; function deviceState(d) { const p = []; if (d.pinging) p.push(`PUSH`); if (d.blocked) p.push(`${T('ZABLOKOWANE', 'BLOCKED')}`); if (!d.approved) p.push(`${T('DO ZATWIERDZENIA', 'PENDING')}`); if (d.wipe_status === 2 || d.wipe_status === 4) p.push(`WIPE ${d.wipe_status === 4 ? T('WYSŁANY', 'SENT') : T('CZEKA', 'PENDING')}`); if (d.wipe_status === 8) p.push(`${T('WYMAZANE', 'WIPED')}`); if (!p.length) p.push(`OK`); return p.join(' '); } PAGES.devices = async (v) => { v.innerHTML = head(T('PARTNERSTWA', 'PARTNERSHIPS'), T('Urządzenia', 'Devices'), T('Jeden wiersz to jedno fizyczne urządzenie; skrzynki skonfigurowane na nim są widoczne jako osobne partnerstwa. Kliknij skrzynkę, aby zobaczyć foldery, historię i akcje (wymazanie, resynchronizacja, blokada).', 'One row per physical device; each mailbox configured on it is a separate partnership. Click a mailbox for folders, history and actions (wipe, resync, block).')) + `
`; const q = $('#dev-q', v), f = $('#dev-f', v); q.value = store.get('ap.devq', ''); const draw = () => { store.set('ap.devq', q.value); const s = q.value.trim().toLowerCase(), now = Date.now() / 1000; const list = S.devices.filter((d) => { const hay = `${d.username} ${d.device_id} ${d.device_type} ${d.model} ${d.friendly_name} ${d.os} ${d.user_agent} ${d.last_ip}`.toLowerCase(); if (s && !hay.includes(s)) return false; if (f.value === 'push') return d.pinging; if (f.value === 'pending') return !d.approved; if (f.value === 'blocked') return d.blocked; if (f.value === 'stale') return now - d.last_seen > 30 * 86400; return true; }); // one row per physical device (DeviceId); every mailbox on it is a partnership chip const groups = new Map(); list.forEach((d) => { if (!groups.has(d.device_id)) groups.set(d.device_id, []); groups.get(d.device_id).push(d); }); const rows = Array.from(groups.values()).map((g) => g.sort((a, b2) => b2.last_seen - a.last_seen)) .sort((a, b2) => b2[0].last_seen - a[0].last_seen); const physical = new Set(S.devices.map((d) => d.device_id)).size; $('#dev-n').textContent = T(`${rows.length} / ${physical} urządzeń · ${list.length} partnerstw`, `${rows.length} / ${physical} devices · ${list.length} partnerships`); $('#dev-table').innerHTML = rows.length ? `${rows.map((g) => { const d = g[0]; const merged = { ...d, pinging: g.some((x) => x.pinging), blocked: g.some((x) => x.blocked), approved: g.every((x) => x.approved), wipe_status: Math.max(...g.map((x) => x.wipe_status)) }; return ``; }).join('')}
${T('Urządzenie', 'Device')}${T('Skrzynki', 'Mailboxes')}OSAS${T('Ostatnio', 'Last seen')}IP${T('Żądania', 'Requests')}${T('Stan', 'State')}
${esc(d.friendly_name || d.model || d.device_type)}${esc(d.device_type)} · ${esc(d.device_id)} ${g.map((x) => ``).join('')} ${esc(d.os || '-')}${esc(d.as_version || '-')}${esc(ago(d.last_seen))}${esc(d.last_command)} ${esc(d.last_ip)}${fmtNum(g.reduce((a, x) => a + x.requests, 0))}${deviceState(merged)}
` : `
${T('Brak urządzeń', 'No devices')}
`; $$('.chip[data-pid]', v).forEach((c) => c.addEventListener('click', (e) => { e.stopPropagation(); deviceDialog(Number(c.dataset.pid), load); })); $$('tr.clickable', v).forEach((tr) => tr.addEventListener('click', () => deviceDialog(Number(tr.dataset.id), load))); }; async function load() { S.devices = await api('GET', '/api/devices'); const nc = $('#nav-dev-count'); if (nc) nc.textContent = new Set(S.devices.map((d) => d.device_id)).size; draw(); } q.addEventListener('input', draw); f.addEventListener('change', draw); every(10000, () => load().catch(() => {})); }; async function deviceDialog(id, reload) { const d = await api('GET', `/api/devices/${id}`); const kv = [ [T('Użytkownik', 'User'), d.username], ['Device ID', d.device_id], [T('Typ', 'Type'), d.device_type], [T('Model', 'Model'), d.model], [T('Nazwa', 'Friendly name'), d.friendly_name], ['OS', `${d.os} ${d.os_language}`], ['IMEI', d.imei], [T('Telefon', 'Phone'), d.phone_number], [T('Operator', 'Operator'), d.mobile_operator], ['User-Agent', d.user_agent], ['ActiveSync', d.as_version], [T('Polityka', 'Policy'), `${d.policy_name || '-'} (key ${d.policy_key || 0})`], [T('Pierwszy raz', 'First seen'), fmtTs(d.first_seen)], [T('Ostatnio', 'Last seen'), `${fmtTs(d.last_seen)} · ${d.last_command} · ${d.last_ip}`], [T('Żądania', 'Requests'), fmtNum(d.requests)], [T('Ping', 'Ping'), d.ping_lifetime ? `${d.ping_lifetime}s` : '-'], ['Wipe', d.wipe_requested_at ? `${T('zlecony', 'requested')} ${fmtTs(d.wipe_requested_at)} ${T('przez', 'by')} ${d.wipe_requested_by || ''}${d.wipe_completed_at ? ` · ${T('potwierdzony', 'acknowledged')} ${fmtTs(d.wipe_completed_at)}` : ''}` : '-'], ].filter(([, val]) => val && String(val).trim()); const folders = d.folders.length ? `${d.folders.map((f) => ``).join('')}
${T('Folder', 'Folder')}${T('Klasa', 'Class')}${T('Elementy', 'Items')}↓↑${T('Ostatnia sync.', 'Last sync')}${T('Okno', 'Window')}${T('Pętle', 'Loops')}
${esc(f.folder_name)}id ${esc(f.collection)}${esc(f.folder_class)}${fmtNum(f.item_count)}${fmtNum(f.items_out)}${fmtNum(f.items_in)}${esc(ago(f.last_sync))}${f.window_size}${f.loop_hits ? `${f.loop_hits}` : 0}
` : `
${T('Brak zsynchronizowanych folderów', 'No synchronised folders')}
`; const recent = d.recent.length ? `
${d.recent.map((e) => `
${esc(fmtTime(e.ts))}${esc(e.ip)}${esc(e.command)} ${e.http} ${esc(e.status)}${e.ms} ms${fmtBytes(e.bytes_out)}
`).join('')}
` : `
-
`; const btn = (act, label, cls = 'outline') => ``; const dlg = modal(`
${T('URZĄDZENIE', 'DEVICE')} #${d.id}

${esc(d.friendly_name || d.model || d.device_type)} - ${esc(d.username)}

${deviceState(d)}
${d.approved ? '' : btn('approve', T('Zatwierdź', 'Approve'), 'primary')} ${btn('resync', T('Resynchronizuj wszystko', 'Full resync'))} ${btn('reprovision', T('Wymuś provisioning', 'Force re-provision'))} ${d.blocked ? btn('unblock', T('Odblokuj', 'Unblock')) : btn('block', T('Zablokuj', 'Block'), 'danger')} ${d.wipe_status === 2 || d.wipe_status === 4 ? btn('cancel-wipe', T('Anuluj wymazanie', 'Cancel wipe')) : btn('wipe', T('Zdalnie wymaż urządzenie', 'Remote wipe device'), 'danger')} ${d.has_recovery_password ? btn('recovery-password', T('Hasło odzyskiwania', 'Recovery password'), 'quiet') : ''} ${btn('remove', T('Usuń partnerstwo', 'Remove partnership'), 'danger')}

${T('Szczegóły', 'Details')}

${kv.map(([k, val]) => `
${esc(k)}
${esc(val)}
`).join('')}

${T('Notatki', 'Notes')}

${T('Ostatnie żądania', 'Recent requests')}

${recent}

${T('Foldery', 'Folders')}

${folders}
`); $$('[data-act]', dlg).forEach((b) => b.addEventListener('click', async () => { const act = b.dataset.act; if (act === 'wipe' && !(await confirmBox(T('Zdalne wymazanie', 'Remote wipe'), T('Urządzenie przy następnym połączeniu wykona reset do ustawień fabrycznych i usunie WSZYSTKIE dane. Tej operacji nie można cofnąć po jej wykonaniu przez urządzenie.', 'On its next connection the device performs a factory reset and erases ALL data. This cannot be undone once the device executes it.'), true, d.device_id))) return; if (act === 'remove' && !(await confirmBox(T('Usunąć partnerstwo?', 'Remove partnership?'), T('Stany synchronizacji zostaną usunięte; urządzenie zsynchronizuje się od nowa przy następnym połączeniu.', 'Sync states are removed; the device will resync from scratch on its next connection.'), true))) return; if (act === 'block' && !(await confirmBox(T('Zablokować urządzenie?', 'Block device?'), T('Urządzenie otrzyma HTTP 403 przy każdej próbie synchronizacji.', 'The device receives HTTP 403 on every sync attempt.'), true))) return; try { if (act === 'recovery-password') { const r = await api('POST', `/api/devices/${id}/recovery-password`); toast(`${T('Hasło odzyskiwania', 'Recovery password')}: ${r.password}`); return; } const body = act === 'notes' ? { notes: $('#dev-notes', dlg).value } : undefined; await api('POST', `/api/devices/${id}/${act}`, body); toast(T('Zapisano', 'Done')); if (act === 'remove') { closeModal(); } else if (act !== 'notes') { deviceDialog(id, reload); } reload && reload(); } catch (e) { toast(e.message, true); } })); } PAGES.users = async (v) => { v.innerHTML = head(T('KONTA', 'ACCOUNTS'), T('Skrzynki', 'Mailboxes'), T('Skrzynki (loginy) synchronizowane przez ActiveSync. Jedna osoba może mieć kilka skrzynek na tym samym telefonie - kolumna "Na tym samym urządzeniu" je łączy. Blokada działa natychmiast na wszystkich urządzeniach.', 'Mailboxes (logins) syncing through ActiveSync. One person may have several mailboxes on the same phone - the "Same device as" column links them. Blocking applies immediately on every device.')) + `
${T('OCHRONA', 'PROTECTION')}

${T('Aktywne blokady logowania', 'Active login lockouts')}

`; let users = []; const draw = () => { const s = $('#u-q', v).value.trim().toLowerCase(); const list = users.filter((u) => !s || u.username.includes(s)); $('#u-table').innerHTML = list.length ? `${list.map((u) => ``).join('')}
${T('Skrzynka', 'Mailbox')}${T('Urządzenia', 'Devices')}${T('Na tym samym urządzeniu', 'Same device as')}PUSH${T('Żądania', 'Requests')}${T('Ostatnio', 'Last seen')}${T('Polityka', 'Policy')}${T('Stan', 'State')}
${esc(u.username)} ${esc((u.device_names || '-').split(',').join(', '))}${u.devices} ${T('partnerstw', 'partnership(s)')} ${u.same_device ? u.same_device.split(',').map((x) => `${esc(x)}`).join('') : '-'} ${u.pings}${fmtNum(u.requests)}${esc(ago(u.last_seen))}${esc(u.policy)}${u.blocked ? `${T('ZABLOKOWANA', 'BLOCKED')}` : 'OK'}
` : `
${T('Brak skrzynek', 'No mailboxes')}
`; $$('[data-u]', v).forEach((b) => b.addEventListener('click', async () => { const name = b.dataset.u, block = b.dataset.b === '1'; if (block && !(await confirmBox(T('Zablokować skrzynkę?', 'Block mailbox?'), name, true))) return; const s2 = await loadSettings(); const set = new Set(s2.security.blocked_users.map((x) => x.toLowerCase())); if (block) set.add(name.toLowerCase()); else set.delete(name.toLowerCase()); s2.security.blocked_users = Array.from(set); await saveSettings(s2); load(); })); $$('[data-dev]', v).forEach((b) => b.addEventListener('click', () => { store.set('ap.devq', b.dataset.dev); location.hash = 'devices'; })); }; async function load() { users = await api('GET', '/api/users'); draw(); const locks = await api('GET', '/api/lockouts'); $('#locks').innerHTML = locks.length ? `${locks.map((l) => ``).join('')}
${T('Klucz', 'Key')}${T('Do', 'Until')}
${esc(l.key)}${esc(fmtTs(l.until))}
` : `
${T('Brak blokad', 'No lockouts')}
`; $$('[data-unlock]', v).forEach((b) => b.addEventListener('click', async () => { await api('POST', '/api/lockouts/unlock', { key: b.dataset.unlock }); load(); })); } $('#u-q', v).addEventListener('input', draw); every(15000, () => load().catch(() => {})); }; // ------------------------------------------------------------------ settings forms async function loadSettings() { const r = await api('GET', '/api/settings'); S.settings = r.settings; S.timezones = r.timezones; return JSON.parse(JSON.stringify(r.settings)); } async function saveSettings(s) { const r = await api('PUT', '/api/settings', s); toast(T(`Zapisano i zastosowano (rewizja ${r.revision})`, `Saved and applied (revision ${r.revision})`)); return loadSettings(); } const getP = (o, p) => p.split('.').reduce((a, k) => (a == null ? a : a[k]), o); function setP(o, p, val) { const ks = p.split('.'); let c = o; ks.slice(0, -1).forEach((k) => { c = c[k]; }); c[ks[ks.length - 1]] = val; } /** field: [path, label, type, opts] type: text|number|bool|select|list|secret|tz|textarea */ function fieldHtml(s, f) { const [path, label, type, opt = {}] = f; const val = getP(s, path); const id = `f-${path.replace(/\./g, '-')}`; const help = opt.help ? `
${esc(opt.help)}
` : ''; const wide = opt.wide || type === 'list' || type === 'textarea' ? ' wide' : ''; if (type === 'bool') return `
${help}
`; let input; if (type === 'select') input = ``; else if (type === 'tz') input = `${S.timezones.map((t) => ``; else if (type === 'list') input = ``; else if (type === 'secret') input = ``; else if (type === 'number') input = ``; else input = ``; return `
${input}${help}
`; } function collect(root, s) { $$('[data-path]', root).forEach((el) => { const p = el.dataset.path, t = el.dataset.type; let val; if (t === 'bool') val = el.checked; else if (t === 'number') val = el.value === '' ? 0 : Number(el.value); else if (t === 'list') val = el.value.split('\n').map((x) => x.trim()).filter(Boolean); else if (t === 'secret') val = el.value === '' ? (getP(S.settings, p) === '__SET__' ? '__SET__' : '') : el.value; else val = el.value.trim(); setP(s, p, val); }); return s; } function section(s, title, desc, fields, extra = '') { return `

${esc(title)}

${desc ? `

${esc(desc)}

` : ''}
${fields.map((f) => fieldHtml(s, f)).join('')}
${extra}
`; } async function settingsPage(v, eyebrow, title, desc, build, after) { const s = await loadSettings(); v.innerHTML = head(eyebrow, title, desc, ``) + `
${build(s)}
`; const form = $('#form', v); form.addEventListener('input', () => { $('#dirty').hidden = false; }); form.addEventListener('change', () => { $('#dirty').hidden = false; }); $('#reset', v).addEventListener('click', () => go()); $('#save', v).addEventListener('click', async (e) => { e.target.disabled = true; try { const cur = JSON.parse(JSON.stringify(S.settings)); collect(form, cur); if (after?.collect) after.collect(form, cur); await saveSettings(cur); go(); } catch (err) { toast(err.message, true); } finally { e.target.disabled = false; } }); after?.bind?.(form, s); } function testBlock(kind) { return `
`; } function bindTests(form) { $$('[data-test]', form).forEach((b) => b.addEventListener('click', async () => { const k = b.dataset.test; const out = $(`[data-result="${k}"]`, form); out.hidden = false; out.className = 'result'; out.textContent = T('Testowanie…', 'Testing…'); b.disabled = true; try { const cur = collect(form, JSON.parse(JSON.stringify(S.settings))); const r = await api('POST', `/api/test/${k}`, { login: $(`[data-test-user="${k}"]`, form).value, password: $(`[data-test-pass="${k}"]`, form).value, settings: cur }); out.classList.add(r.ok ? 'ok' : 'bad'); out.textContent = r.message; } catch (e) { out.classList.add('bad'); out.textContent = e.message; } finally { b.disabled = false; } })); } const TLS = () => [['none', T('brak (tylko localhost)', 'none (localhost only)')], ['start_tls', 'STARTTLS'], ['tls', T('TLS (implicit)', 'TLS (implicit)')]]; PAGES.backends = (v) => settingsPage(v, T('ŹRÓDŁA DANYCH', 'DATA SOURCES'), T('Backendy', 'Backends'), T('Poczta przez IMAP/SMTP, kalendarze i zadania przez CalDAV, kontakty przez CardDAV - odpowiednik backendu "combined" z Z-Push.', 'Mail via IMAP/SMTP, calendars and tasks via CalDAV, contacts via CardDAV - the Z-Push "combined" backend.'), (s) => section(s, 'IMAP', T('Serwer poczty przychodzącej (np. Dovecot). Uwierzytelnianie urządzeń odbywa się loginem IMAP.', 'Incoming mail server (e.g. Dovecot). Devices authenticate with their IMAP login.'), [ ['imap.host', T('Host', 'Host'), 'text'], ['imap.port', T('Port', 'Port'), 'number', { min: 1, max: 65535 }], ['imap.security', T('Szyfrowanie', 'Encryption'), 'select', { options: TLS() }], ['imap.verify_tls', T('Weryfikuj certyfikat', 'Verify certificate'), 'bool'], ['imap.folder_prefix', T('Prefiks folderów', 'Folder prefix'), 'text', { ph: 'INBOX.' }], ['imap.use_special_use', T('Rozpoznawaj foldery po SPECIAL-USE', 'Detect folders via SPECIAL-USE'), 'bool'], ['imap.folder_inbox', 'Inbox', 'text'], ['imap.folder_sent', T('Wysłane', 'Sent'), 'text'], ['imap.folder_drafts', T('Szkice', 'Drafts'), 'text'], ['imap.folder_trash', T('Kosz', 'Trash'), 'text'], ['imap.folder_spam', 'Spam', 'text'], ['imap.folder_archive', T('Archiwum', 'Archive'), 'text'], ['imap.excluded_folders', T('Ukryte foldery', 'Hidden folders'), 'list', { ph: 'Shared/*\nPublic' }], ['imap.autoseen_on_delete', T('Oznacz jako przeczytane przed usunięciem', 'Mark read before delete'), 'bool'], ['imap.inline_forward', T('Przekazywanie inline (zamiast załącznika .eml)', 'Inline forward (instead of .eml)'), 'bool'], ['imap.timeout_secs', T('Timeout (s)', 'Timeout (s)'), 'number', { min: 5 }], ['imap.pool_idle_secs', T('Czas życia połączeń w puli (s)', 'Pool idle time (s)'), 'number'], ['imap.pool_max_per_user', T('Maks. połączeń w puli / użytkownika', 'Max pooled per user'), 'number', { min: 0, max: 20 }], ], testBlock('imap')) + section(s, 'SMTP', T('Wysyłka poczty z urządzeń (SendMail / SmartReply / SmartForward).', 'Outgoing mail from devices (SendMail / SmartReply / SmartForward).'), [ ['smtp.method', T('Metoda', 'Method'), 'select', { options: [['smtp', 'SMTP'], ['sendmail', 'sendmail']] }], ['smtp.host', 'Host', 'text'], ['smtp.port', 'Port', 'number'], ['smtp.security', T('Szyfrowanie', 'Encryption'), 'select', { options: TLS() }], ['smtp.verify_tls', T('Weryfikuj certyfikat', 'Verify certificate'), 'bool'], ['smtp.auth', T('Uwierzytelnianie', 'Authentication'), 'select', { options: [['user_credentials', T('dane użytkownika urządzenia', 'device user credentials')], ['static', T('stałe konto relay', 'static relay account')], ['none', T('brak', 'none')]] }], ['smtp.username', T('Login relay', 'Relay username'), 'text'], ['smtp.password', T('Hasło relay', 'Relay password'), 'secret'], ['smtp.sendmail_path', T('Ścieżka sendmail', 'sendmail path'), 'text'], ['smtp.timeout_secs', 'Timeout (s)', 'number'], ['smtp.max_message_mb', T('Maks. rozmiar wiadomości (MB)', 'Max message size (MB)'), 'number'], ['smtp.save_in_sent', T('Zapisuj kopię w Wysłanych', 'Save copy to Sent'), 'bool'], ['smtp.enforce_from', T('Wymuszaj adres nadawcy = adres konta', 'Force sender = account address'), 'bool', { help: T('Zapobiega podszywaniu się pod inne adresy.', 'Prevents sender spoofing.') }], ], testBlock('smtp')) + section(s, 'CalDAV', T('Kalendarze i zadania (VEVENT/VTODO). Placeholders: %u login, %l część lokalna, %d domena.', 'Calendars and tasks (VEVENT/VTODO). Placeholders: %u login, %l local part, %d domain.'), [ ['caldav.enabled', T('Włączony', 'Enabled'), 'bool'], ['caldav.base_url', 'URL', 'text', { ph: 'https://dav.example.com' }], ['caldav.path', T('Ścieżka kalendarzy', 'Calendar home path'), 'text', { ph: '/dav/calendars/%u/' }], ['caldav.verify_tls', T('Weryfikuj certyfikat', 'Verify certificate'), 'bool'], ['caldav.default_collection', T('Domyślny kalendarz', 'Default calendar'), 'text'], ['caldav.include_regex', T('Tylko kolekcje pasujące do regex', 'Include regex'), 'text'], ['caldav.exclude_regex', T('Pomiń kolekcje pasujące do regex', 'Exclude regex'), 'text'], ['caldav.timeout_secs', 'Timeout (s)', 'number'], ], testBlock('caldav')) + section(s, 'CardDAV', T('Kontakty oraz globalna książka adresowa (GAL) do wyszukiwania i rozwiązywania odbiorców.', 'Contacts plus a global address list (GAL) for search and recipient resolution.'), [ ['carddav.enabled', T('Włączony', 'Enabled'), 'bool'], ['carddav.base_url', 'URL', 'text'], ['carddav.path', T('Ścieżka książek adresowych', 'Address book home path'), 'text', { ph: '/dav/addressbooks/%u/' }], ['carddav.verify_tls', T('Weryfikuj certyfikat', 'Verify certificate'), 'bool'], ['carddav.default_collection', T('Domyślna książka', 'Default book'), 'text'], ['carddav.gal_path', T('Ścieżka GAL', 'GAL path'), 'text', { ph: '/dav/addressbooks/%d/gal/' }], ['carddav.gal_min_length', T('Min. długość zapytania GAL', 'GAL min query length'), 'number'], ['carddav.max_picture_kb', T('Maks. zdjęcie kontaktu (KB)', 'Max contact picture (KB)'), 'number'], ['carddav.include_regex', 'Include regex', 'text'], ['carddav.exclude_regex', 'Exclude regex', 'text'], ['carddav.timeout_secs', 'Timeout (s)', 'number'], ], testBlock('carddav')), { bind: bindTests }); PAGES.activesync = (v) => settingsPage(v, 'PROTOCOL', 'ActiveSync', T('Logowanie, protokół, synchronizacja, push i Autodiscover.', 'Login, protocol, synchronisation, push and Autodiscover.'), (s) => section(s, T('Ogólne', 'General'), '', [ ['general.server_name', T('Nazwa serwera', 'Server name'), 'text'], ['general.default_timezone', T('Domyślna strefa czasowa', 'Default time zone'), 'tz'], ['general.login_format', T('Format loginu', 'Login format'), 'select', { options: [['as_given', T('jak podany przez urządzenie', 'as given by device')], ['local_part', T('tylko część lokalna (bez @domena)', 'local part only')], ['full_email', T('pełny e-mail (dopisz domenę)', 'full e-mail (append domain)')]], help: T('Odpowiednik USE_FULLEMAIL_FOR_LOGIN. Prefiks DOMENA\\ jest zawsze usuwany.', 'Equivalent of USE_FULLEMAIL_FOR_LOGIN. DOMAIN\\ prefixes are always stripped.') }], ['general.default_domain', T('Domyślna domena', 'Default domain'), 'text'], ['general.email_template', T('Szablon adresu e-mail', 'E-mail address template'), 'text', { help: '{login} {local} {domain}' }], ['general.max_protocol', T('Najwyższa wersja protokołu', 'Highest protocol version'), 'select', { options: ['14.1', '14.0', '12.1', '12.0', '2.5'].map((x) => [x, x]) }], ['general.allowed_domains', T('Dozwolone domeny (puste = wszystkie)', 'Allowed domains (empty = all)'), 'list'], ]) + section(s, T('Synchronizacja', 'Synchronisation'), '', [ ['sync.max_items', T('Maks. elementów w paczce (SYNC_MAX_ITEMS)', 'Max items per batch'), 'number', { min: 1, max: 2000 }], ['sync.filter_time_max', T('Maks. zakres czasu', 'Max time window'), 'select', { num: true, options: [[0, T('bez limitu', 'unlimited')], [1, T('1 dzień', '1 day')], [2, T('3 dni', '3 days')], [3, T('1 tydzień', '1 week')], [4, T('2 tygodnie', '2 weeks')], [5, T('1 miesiąc', '1 month')], [6, T('3 miesiące', '3 months')], [7, T('6 miesięcy', '6 months')]] }], ['sync.server_wins', T('Konflikty: wygrywa serwer', 'Conflicts: server wins'), 'bool'], ['sync.loop_detection', T('Wykrywanie pętli synchronizacji', 'Sync loop detection'), 'bool'], ['sync.max_attachment_mb', T('Maks. załącznik (MB)', 'Max attachment (MB)'), 'number'], ['sync.max_request_mb', T('Maks. żądanie (MB)', 'Max request (MB)'), 'number'], ['sync.heartbeat_min', T('Hanging Sync min (s)', 'Hanging Sync min (s)'), 'number'], ['sync.heartbeat_max', T('Hanging Sync max (s)', 'Hanging Sync max (s)'), 'number'], ['sync.search_wait_secs', T('Limit czasu wyszukiwania (s)', 'Search time limit (s)'), 'number'], ['sync.search_max_results', T('Maks. wyników wyszukiwania', 'Max search results'), 'number'], ['sync.retry_after_secs', 'Retry-After (s)', 'number'], ]) + section(s, 'Push (Ping)', T('Urządzenia trzymają otwarte połączenie; serwer sprawdza zmiany w backendzie co zadany interwał.', 'Devices keep a connection open; the server checks the backend for changes every interval.'), [ ['ping.check_interval_secs', T('Interwał sprawdzania (s)', 'Check interval (s)'), 'number', { min: 5 }], ['ping.lifetime_min', T('Min. heartbeat (s)', 'Min heartbeat (s)'), 'number'], ['ping.lifetime_max', T('Maks. heartbeat (s)', 'Max heartbeat (s)'), 'number'], ['ping.max_folders', T('Maks. folderów', 'Max folders'), 'number'], ]) + section(s, 'Autodiscover', T('Automatyczna konfiguracja kont na telefonach (/autodiscover/autodiscover.xml).', 'Automatic account configuration (/autodiscover/autodiscover.xml).'), [ ['autodiscover.enabled', T('Włączony', 'Enabled'), 'bool'], ['autodiscover.require_auth', T('Wymagaj poprawnego hasła', 'Require valid password'), 'bool'], ['autodiscover.server_url', T('Publiczny URL ActiveSync', 'Public ActiveSync URL'), 'text', { wide: true, ph: 'https://mail.example.com/Microsoft-Server-ActiveSync', help: T('Puste = wyznaczony z nagłówka Host.', 'Empty = derived from the Host header.') }], ]) + section(s, T('Logowanie i retencja', 'Logging & retention'), '', [ ['logging.log_wbxml', T('Loguj zdekodowany WBXML (debug, zawiera treść poczty!)', 'Log decoded WBXML (debug, contains mail data!)'), 'bool'], ['logging.debug_users', T('Użytkownicy z logowaniem debug', 'Debug users'), 'list'], ['logging.ring_size', T('Linii logu w panelu', 'Log lines in panel'), 'number'], ['logging.stats_retention_days', T('Retencja statystyk (dni)', 'Stats retention (days)'), 'number'], ['logging.audit_retention_days', T('Retencja audytu (dni)', 'Audit retention (days)'), 'number'], ['panel.device_inactive_days', T('Usuwaj urządzenia nieaktywne (dni, 0 = nigdy)', 'Remove inactive devices (days, 0 = never)'), 'number'], ])); const POLICY_FIELDS = () => [ ['devpwenabled', T('Wymagaj hasła urządzenia', 'Require device password'), 'bool'], ['alphanumpwreq', T('Hasło alfanumeryczne', 'Alphanumeric password'), 'bool'], ['allowsimpledevpw', T('Pozwól na proste hasła', 'Allow simple passwords'), 'bool'], ['mindevpwlenngth', T('Min. długość hasła', 'Min password length'), 'number'], ['mindevcomplexchars', T('Min. znaków złożonych', 'Min complex characters'), 'number'], ['maxinacttimedevlock', T('Blokada po bezczynności (s)', 'Lock after inactivity (s)'), 'number'], ['maxdevpwfailedattempts', T('Wymaż po N błędnych hasłach', 'Wipe after N failed passwords'), 'number'], ['devpwexpiration', T('Wygaśnięcie hasła (dni)', 'Password expiration (days)'), 'number'], ['devpwhistory', T('Historia haseł', 'Password history'), 'number'], ['pwrecoveryenabled', T('Odzyskiwanie hasła', 'Password recovery'), 'bool'], ['devencenabled', T('Szyfrowanie urządzenia', 'Device encryption'), 'bool'], ['reqdevenc', T('Wymagaj szyfrowania', 'Require encryption'), 'bool'], ['attenabled', T('Załączniki dozwolone', 'Attachments allowed'), 'bool'], ['maxattsize', T('Maks. załącznik (bajty, -1 = bez limitu)', 'Max attachment (bytes, -1 unlimited)'), 'number'], ['allowhtmlemail', T('Poczta HTML', 'HTML email'), 'bool'], ['maxemailagefilter', T('Maks. wiek poczty (0-5)', 'Max email age filter'), 'number'], ['maxcalagefilter', T('Maks. wiek kalendarza (0,4-7)', 'Max calendar age filter'), 'number'], ['maxemailbodytruncsize', T('Obcięcie treści (bajty, -1)', 'Body truncation (bytes, -1)'), 'number'], ['maxemailhtmlbodytruncsize', T('Obcięcie HTML (bajty, -1)', 'HTML truncation (bytes, -1)'), 'number'], ['allowstoragecard', T('Karta pamięci', 'Storage card'), 'bool'], ['allowcam', T('Aparat', 'Camera'), 'bool'], ['allowwifi', 'Wi-Fi', 'bool'], ['allowbluetooth', 'Bluetooth (0/1/2)', 'number'], ['allowirda', 'IrDA', 'bool'], ['allowtextmessaging', 'SMS', 'bool'], ['allowpopimapemail', 'POP/IMAP', 'bool'], ['allowbrowser', T('Przeglądarka', 'Browser'), 'bool'], ['allowconsumeremail', T('Prywatna poczta', 'Consumer email'), 'bool'], ['allowinternetsharing', T('Udostępnianie internetu', 'Internet sharing'), 'bool'], ['allowremotedesktop', T('Pulpit zdalny', 'Remote desktop'), 'bool'], ['allowdesktopsync', T('Synchronizacja z PC', 'Desktop sync'), 'bool'], ['allowunsignedapps', T('Niepodpisane aplikacje', 'Unsigned apps'), 'bool'], ['allowunsignedinstallationpackages', T('Niepodpisane pakiety', 'Unsigned packages'), 'bool'], ['reqmansyncroam', T('Ręczna synchronizacja w roamingu', 'Manual sync when roaming'), 'bool'], ['reqsignedsmimemessages', T('Wymagaj podpisu S/MIME', 'Require signed S/MIME'), 'bool'], ['reqencsmimemessages', T('Wymagaj szyfrowania S/MIME', 'Require encrypted S/MIME'), 'bool'], ['allowsmimesoftcerts', T('Certyfikaty programowe S/MIME', 'S/MIME soft certs'), 'bool'], ]; PAGES.policies = async (v) => { let sel = 0; await settingsPage(v, 'PROVISIONING', T('Polityki urządzeń', 'Device policies'), T('Polityki bezpieczeństwa przekazywane urządzeniom (odpowiednik policies.ini). Zmiana polityki wymusza ponowny provisioning urządzeń.', 'Security policies pushed to devices (policies.ini equivalent). Changing a policy forces devices to re-provision.'), (s) => { const pols = s.provisioning.policies; return section(s, 'Provisioning', '', [ ['provisioning.enabled', T('Wymuszaj provisioning', 'Enforce provisioning'), 'bool'], ['provisioning.loose', T('Luźny provisioning (urządzenia bez obsługi polityk)', 'Loose provisioning (devices without policy support)'), 'bool'], ['provisioning.default_policy', T('Domyślna polityka', 'Default policy'), 'select', { options: pols.map((p) => [p.name, p.name]) }], ]) + `

${T('Polityka', 'Policy')}

${T('Przypisania użytkowników', 'User assignments')}

${T('Jedna linia: login=nazwa_polityki', 'One per line: login=policy_name')}

`; }, { bind(form, s) { const pf = $('#pol-form', form); const drawPol = () => { const p = s.provisioning.policies[sel]; pf.innerHTML = `
${fieldHtml({ p }, ['p.name', T('Nazwa', 'Name'), 'text'])}${POLICY_FIELDS().map((f) => fieldHtml({ p }, [`p.${f[0]}`, f[1], f[2]])).join('')}
`; $$('[data-path]', pf).forEach((el) => { el.dataset.pol = el.dataset.path.slice(2); el.removeAttribute('data-path'); }); }; const keep = () => { const p = s.provisioning.policies[sel]; $$('[data-pol]', pf).forEach((el) => { const k = el.dataset.pol; p[k] = el.type === 'checkbox' ? el.checked : el.type === 'number' ? Number(el.value) : el.value.trim(); }); }; $('#pol-sel', form).addEventListener('change', (e) => { keep(); sel = Number(e.target.value); drawPol(); }); $('#pol-add', form).addEventListener('click', () => { keep(); const c = JSON.parse(JSON.stringify(s.provisioning.policies[sel])); c.name = `${c.name}-copy`; s.provisioning.policies.push(c); sel = s.provisioning.policies.length - 1; $('#pol-sel', form).insertAdjacentHTML('beforeend', ``); $('#pol-sel', form).value = String(sel); drawPol(); $('#dirty').hidden = false; }); $('#pol-del', form).addEventListener('click', () => { if (s.provisioning.policies.length < 2) { toast(T('Musi zostać przynajmniej jedna polityka', 'At least one policy is required'), true); return; } s.provisioning.policies.splice(sel, 1); sel = 0; $('#pol-sel', form).innerHTML = s.provisioning.policies.map((p, i) => ``).join(''); drawPol(); $('#dirty').hidden = false; }); drawPol(); this.keep = keep; this.s = s; }, collect(form, cur) { this.keep(); cur.provisioning.policies = this.s.provisioning.policies; cur.provisioning.user_policies = $('#pol-users', form).value.split('\n').map((l) => l.trim()).filter((l) => l.includes('=')).map((l) => { const [u, p] = l.split('='); return { username: u.trim(), policy: p.trim() }; }); }, }); }; PAGES.security = async (v) => { await settingsPage(v, 'HARDENING', T('Bezpieczeństwo', 'Security'), T('Ochrona przed zgadywaniem haseł, kontrola dostępu sieciowego, urządzeń i panelu.', 'Brute-force protection, network/device access control and panel security.'), (s) => section(s, T('Ochrona logowania', 'Login protection'), T('Po przekroczeniu limitu konto (i IP przy 3× limicie) jest blokowane. Linia logu jest zgodna z fail2ban.', 'Exceeding the limit locks the account (and the IP at 3× the limit). The log line is fail2ban friendly.'), [ ['security.auth_fail_max', T('Maks. błędnych logowań', 'Max failed logins'), 'number'], ['security.auth_fail_window_secs', T('Okno (s)', 'Window (s)'), 'number'], ['security.lockout_secs', T('Czas blokady (s)', 'Lockout (s)'), 'number'], ['security.auth_cache_secs', T('Cache poprawnego logowania (s)', 'Successful login cache (s)'), 'number', { help: T('Przechowywany jest tylko HMAC, nigdy hasło.', 'Only an HMAC is kept, never the password.') }], ['security.log_auth_failures', T('Loguj nieudane logowania', 'Log failed logins'), 'bool'], ]) + section(s, T('Sieć', 'Network'), '', [ ['security.trusted_proxies', T('Zaufane proxy (CIDR)', 'Trusted proxies (CIDR)'), 'list', { help: T('Tylko od nich akceptowany jest nagłówek z prawdziwym IP klienta.', 'Only these may set the real client IP header.') }], ['security.real_ip_header', T('Nagłówek z IP klienta', 'Client IP header'), 'text'], ['security.eas_allowed_networks', T('Sieci z dostępem do ActiveSync (puste = wszystkie)', 'Networks allowed to use ActiveSync (empty = all)'), 'list'], ['panel.allowed_networks', T('Sieci z dostępem do panelu (puste = wszystkie)', 'Networks allowed to use the panel (empty = all)'), 'list'], ]) + section(s, T('Użytkownicy i urządzenia', 'Users & devices'), '', [ ['security.require_device_approval', T('Nowe urządzenia wymagają zatwierdzenia', 'New devices require approval'), 'bool'], ['security.allowed_users', T('Tylko ci użytkownicy (puste = wszyscy)', 'Only these users (empty = all)'), 'list'], ['security.blocked_users', T('Zablokowani użytkownicy', 'Blocked users'), 'list'], ['security.blocked_device_types', T('Zablokowane typy urządzeń / User-Agent', 'Blocked device types / User-Agent'), 'list', { ph: 'WindowsMail\nOutlook' }], ]) + section(s, T('Panel', 'Panel'), '', [ ['panel.session_ttl_minutes', T('Wygasanie sesji (min)', 'Session lifetime (min)'), 'number'], ['panel.prometheus_token', T('Token Prometheus (/metrics)', 'Prometheus token (/metrics)'), 'secret', { help: T('Puste = endpoint wyłączony. Użycie: Authorization: Bearer ', 'Empty = endpoint disabled. Use: Authorization: Bearer ') }], ]) + `

${T('Konto administratora', 'Administrator account')}

`, { bind(form) { $('#pw-save', form).addEventListener('click', async () => { const n = $('#pw-new', form).value; if (n !== $('#pw-new2', form).value) { toast(T('Hasła się różnią', 'Passwords differ'), true); return; } try { await api('POST', '/api/password', { current: $('#pw-cur', form).value, new: n }); toast(T('Hasło zmienione', 'Password changed')); $$('#pw-cur,#pw-new,#pw-new2', form).forEach((i) => { i.value = ''; }); } catch (e) { toast(e.message, true); } }); $('#revoke', form).addEventListener('click', async () => { await api('POST', '/api/sessions/revoke-others'); toast(T('Inne sesje wylogowane', 'Other sessions revoked')); }); // password fields are not settings $$('#pw-cur,#pw-new,#pw-new2', form).forEach((i) => i.addEventListener('input', (e) => e.stopPropagation())); }, }); }; PAGES.logs = async (v) => { v.innerHTML = head(T('DIAGNOSTYKA', 'DIAGNOSTICS'), T('Logi i audyt', 'Logs & audit'), T('Ostatnie wpisy logu serwera oraz dziennik działań administracyjnych.', 'Recent server log lines and the administrative audit trail.')) + `
`; $$('.tab', v).forEach((t) => t.addEventListener('click', () => { $$('.tab', v).forEach((x) => x.classList.toggle('active', x === t)); $('#tab-log').hidden = t.dataset.tab !== 'log'; $('#tab-audit').hidden = t.dataset.tab !== 'audit'; })); async function logs() { if (!$('#lf').checked) return; const q = new URLSearchParams({ n: '800' }); if ($('#lv').value) q.set('level', $('#lv').value); if ($('#lq').value.trim()) q.set('q', $('#lq').value.trim()); const l = await api('GET', `/api/logs?${q}`); const box = $('#logbox'); const atBottom = box.scrollTop + box.clientHeight >= box.scrollHeight - 30; box.innerHTML = l.map((x) => `
${esc(fmtTime(x.ts))}${esc(x.level)}${esc(x.message)}
`).join('') || `
-
`; if (atBottom) box.scrollTop = box.scrollHeight; } async function audit() { const a = await api('GET', '/api/audit'); $('#auditbox').innerHTML = a.length ? `${a.map((e) => ``).join('')}
${T('Czas', 'Time')}${T('Kto', 'Actor')}${T('Akcja', 'Action')}${T('Cel', 'Target')}IP${T('Szczegóły', 'Details')}
${esc(fmtTs(e.ts))}${esc(e.actor)}${esc(e.action)}${esc(e.target)}${esc(e.ip)}${esc(e.details)}
` : `
-
`; } $('#lv').addEventListener('change', logs); $('#lq').addEventListener('input', () => { clearTimeout(PAGES._lq); PAGES._lq = setTimeout(logs, 300); }); every(3000, () => logs().catch(() => {})); every(30000, () => audit().catch(() => {})); }; PAGES.about = async (v) => { const o = await api('GET', '/api/overview'); v.innerHTML = head('ABOUT', 'ActivePush', T('Serwer Exchange ActiveSync dla IMAP/SMTP + CalDAV/CardDAV - przepisany z Z-Push na Rust.', 'Exchange ActiveSync server for IMAP/SMTP + CalDAV/CardDAV - Z-Push rewritten in Rust.')) + `

${T('Wersja', 'Version')} ${esc(o.version)}

ActiveSync
2.5, 12.0, 12.1, 14.0, 14.1
${T('Komendy', 'Commands')}
Sync, FolderSync, FolderCreate/Update/Delete, Ping, Provision, SendMail, SmartReply, SmartForward, GetAttachment, ItemOperations, GetItemEstimate, MoveItems, MeetingResponse, Search (GAL, Mailbox), ResolveRecipients, Settings, ValidateCert
Endpoints
/Microsoft-Server-ActiveSync
/autodiscover/autodiscover.xml (+ AutoDiscover/AutoDiscover.xml, Autodiscover/Autodiscover.xml)
CLI
active-push admin | config | device | probe

nginx

${T('Zastępuje lokalizacje fastcgi Z-Push:', 'Replaces the Z-Push fastcgi locations:')}

location = /Microsoft-Server-ActiveSync {
    proxy_pass http://127.0.0.1:8081;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $remote_addr;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_read_timeout 3660s;
    proxy_buffering off;
    client_max_body_size 64m;
}
location ~* ^/autodiscover/autodiscover\\.xml$ {
    proxy_pass http://127.0.0.1:8081;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $remote_addr;
}
`; }; // ------------------------------------------------------------------ auth / boot function setAuthTexts(setup) { $('#auth-eyebrow').textContent = setup ? T('PIERWSZE URUCHOMIENIE', 'FIRST RUN') : T('ADMINISTRACJA', 'ADMINISTRATION'); $('#auth-title').textContent = setup ? T('Utwórz administratora', 'Create administrator') : T('Zaloguj się', 'Sign in'); $('#setup-help').textContent = T('Wygeneruj jednorazowy token na serwerze i wklej go poniżej:', 'Generate a one-time token on the server and paste it below:'); $('#lbl-token').textContent = T('Token instalacyjny', 'Setup token'); $('#lbl-user').textContent = T('Login', 'Username'); $('#lbl-pass').textContent = setup ? T('Hasło (min. 12 znaków)', 'Password (min. 12 characters)') : T('Hasło', 'Password'); $('#lbl-pass2').textContent = T('Powtórz hasło', 'Repeat password'); $('#auth-submit').textContent = setup ? T('Utwórz i zaloguj', 'Create & sign in') : T('Zaloguj', 'Sign in'); $('#setup-fields').hidden = !setup; $('#confirm-wrap').hidden = !setup; $('#password').autocomplete = setup ? 'new-password' : 'current-password'; } function showAuth(setup) { clearTimers(); $('#loading').hidden = true; $('#shell').hidden = true; $('#auth').hidden = false; S.setup = !!setup; setAuthTexts(S.setup); $('#username').focus(); } async function boot() { let sess; try { sess = await api('GET', '/api/session'); } catch (e) { $('#loading').textContent = e.message; return; } if (!sess.authenticated) { showAuth(sess.setup_required); return; } S.session = sess; S.csrf = sess.csrf; $('#loading').hidden = true; $('#auth').hidden = true; $('#shell').hidden = false; $('#whoami').textContent = sess.username; $('#logout').textContent = T('Zablokuj', 'Lock'); $('#theme').title = T('Motyw jasny/ciemny', 'Light/dark theme'); document.title = `${sess.server_name || 'ActivePush'} / Control room`; go(); } $('#auth-form').addEventListener('submit', async (e) => { e.preventDefault(); const msg = $('#auth-msg'); msg.textContent = ''; const body = { username: $('#username').value.trim(), password: $('#password').value, token: $('#setup-token').value.trim() }; if (S.setup && body.password !== $('#password2').value) { msg.textContent = T('Hasła się różnią', 'Passwords differ'); return; } $('#auth-submit').disabled = true; try { await api('POST', S.setup ? '/api/setup' : '/api/login', body); $('#password').value = ''; $('#password2').value = ''; await boot(); } catch (err) { msg.textContent = err.message; } finally { $('#auth-submit').disabled = false; } }); $('#logout').addEventListener('click', async () => { try { await api('POST', '/api/logout'); } catch { /* ignore */ } showAuth(false); }); $('#menu-toggle').addEventListener('click', () => $('#nav').classList.toggle('open')); const theme = store.get('ap.theme', 'dark'); document.documentElement.dataset.theme = theme; $('#theme').addEventListener('click', () => { const t = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'; document.documentElement.dataset.theme = t; store.set('ap.theme', t); if (S.session) go(); }); const langSel = $('#lang'); langSel.value = LANG; document.documentElement.lang = LANG; langSel.addEventListener('change', () => { LANG = langSel.value; store.set('ap.lang', LANG); location.reload(); }); window.addEventListener('hashchange', () => { if (S.session) go(); }); let resizeT; window.addEventListener('resize', () => { clearTimeout(resizeT); resizeT = setTimeout(() => { if (S.session && S.page === 'overview') go(); }, 400); }); boot(); })();