v0.1.8
This commit is contained in:
1 parent
b6e8ddb9c7
commit
818cae8592
12 files changed
+240
-20
No files matched your search
@@ -5,4 +5,3 @@ dist/
|
|||||||
*.db
|
*.db
|
||||||
*.db-wal
|
*.db-wal
|
||||||
*.db-shm
|
*.db-shm
|
||||||
/*.*
|
|
||||||
@@ -1,5 +1,28 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.1.8 - 2026-11-08
|
||||||
|
|
||||||
|
- Port 8081 landing page reduced to the flat application logo centred on the page (no gradients, shadows, text
|
||||||
|
or status indicator).
|
||||||
|
|
||||||
|
## 0.1.7 - 2026-11-08
|
||||||
|
|
||||||
|
- Port 8081: `/` serves a small landing page with the application logo (self-contained, no scripts, CSP pinned by
|
||||||
|
hash, light and dark theme) instead of plain text; `/favicon.svg` added. `/healthz` and every other path unchanged.
|
||||||
|
|
||||||
|
## 0.1.6 - 2026-11-08
|
||||||
|
|
||||||
|
- Autodiscover: the panel page lists the last 30 requests as received (headers without credentials, login as typed,
|
||||||
|
request and response bodies) to debug phones that still ask for the server. A 200 response carrying an
|
||||||
|
Autodiscover error (e.g. unsupported schema) is labelled "invalid request" in Live instead of "ok".
|
||||||
|
|
||||||
|
## 0.1.5 - 2026-11-08
|
||||||
|
|
||||||
|
- Autodiscover: iOS sends the same password twice, with `user@domain` and with the bare `user`; IMAP servers that
|
||||||
|
only know the full address refused the second attempt and the phone treated the whole Autodiscover as failed
|
||||||
|
(manual "Server" field). When the bare name is the local part of the requested address, the full address is now
|
||||||
|
tried first, then the name as given.
|
||||||
|
|
||||||
## 0.1.4 - 2026-11-07
|
## 0.1.4 - 2026-11-07
|
||||||
|
|
||||||
- Autodiscover: several domains, each optionally with its own ActiveSync URL - one line per domain, e.g.
|
- Autodiscover: several domains, each optionally with its own ActiveSync URL - one line per domain, e.g.
|
||||||
|
|||||||
Generated
+1
-1
@@ -4,7 +4,7 @@ version = 4
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "active-push"
|
name = "active-push"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "active-push"
|
name = "active-push"
|
||||||
version = "0.1.4"
|
version = "0.1.8"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.85"
|
rust-version = "1.85"
|
||||||
description = "ActivePush - Exchange ActiveSync server (Z-Push rewrite) with built-in administration panel"
|
description = "ActivePush - Exchange ActiveSync server (Z-Push rewrite) with built-in administration panel"
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 448 KiB |
@@ -220,6 +220,7 @@ pub fn router(app: Arc<App>) -> Router {
|
|||||||
.route("/api/settings", get(get_settings).put(put_settings))
|
.route("/api/settings", get(get_settings).put(put_settings))
|
||||||
.route("/api/test/{what}", post(test_backend))
|
.route("/api/test/{what}", post(test_backend))
|
||||||
.route("/api/autodiscover/check", post(autodiscover_check))
|
.route("/api/autodiscover/check", post(autodiscover_check))
|
||||||
|
.route("/api/autodiscover/trace", get(autodiscover_trace))
|
||||||
.route("/api/logs", get(logs))
|
.route("/api/logs", get(logs))
|
||||||
.route("/api/audit", get(audit))
|
.route("/api/audit", get(audit))
|
||||||
.route("/api/lockouts", get(lockouts))
|
.route("/api/lockouts", get(lockouts))
|
||||||
@@ -1003,6 +1004,11 @@ struct AdCheckReq {
|
|||||||
domain: String,
|
domain: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Last Autodiscover requests as received (headers without credentials, bodies).
|
||||||
|
async fn autodiscover_trace(State(app): State<Arc<App>>) -> Response {
|
||||||
|
Json(app.ad_trace_list()).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
/// DNS + endpoint diagnostics for one of the configured Autodiscover domains.
|
/// DNS + endpoint diagnostics for one of the configured Autodiscover domains.
|
||||||
async fn autodiscover_check(State(app): State<Arc<App>>, Json(req): Json<AdCheckReq>) -> Response {
|
async fn autodiscover_check(State(app): State<Arc<App>>, Json(req): Json<AdCheckReq>) -> Response {
|
||||||
let s = app.settings();
|
let s = app.settings();
|
||||||
|
|||||||
+21
@@ -45,9 +45,29 @@ pub struct App {
|
|||||||
/// Collection ids a device still uses but that do not exist here (e.g. left over from
|
/// Collection ids a device still uses but that do not exist here (e.g. left over from
|
||||||
/// Z-Push). They are sent as FolderHierarchy Delete on the next FolderSync.
|
/// Z-Push). They are sent as FolderHierarchy Delete on the next FolderSync.
|
||||||
stale: Mutex<std::collections::HashMap<i64, std::collections::BTreeSet<String>>>,
|
stale: Mutex<std::collections::HashMap<i64, std::collections::BTreeSet<String>>>,
|
||||||
|
/// Last Autodiscover requests with headers (no secrets) and bodies, for the panel.
|
||||||
|
ad_trace: Mutex<std::collections::VecDeque<serde_json::Value>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
|
pub fn ad_trace_push(&self, v: serde_json::Value) {
|
||||||
|
let mut t = self.ad_trace.lock().unwrap();
|
||||||
|
if t.len() >= 30 {
|
||||||
|
t.pop_front();
|
||||||
|
}
|
||||||
|
t.push_back(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ad_trace_list(&self) -> Vec<serde_json::Value> {
|
||||||
|
self.ad_trace
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn new(boot: Boot, logs: Arc<LogRing>) -> anyhow::Result<Arc<App>> {
|
pub async fn new(boot: Boot, logs: Arc<LogRing>) -> anyhow::Result<Arc<App>> {
|
||||||
let db = db::open(&boot.data_dir).await?;
|
let db = db::open(&boot.data_dir).await?;
|
||||||
let (settings, rev) = db::load_settings(&db).await?;
|
let (settings, rev) = db::load_settings(&db).await?;
|
||||||
@@ -73,6 +93,7 @@ impl App {
|
|||||||
loops: Mutex::new(std::collections::HashMap::new()),
|
loops: Mutex::new(std::collections::HashMap::new()),
|
||||||
shutdown: tokio_util::sync::CancellationToken::new(),
|
shutdown: tokio_util::sync::CancellationToken::new(),
|
||||||
stale: Mutex::new(std::collections::HashMap::new()),
|
stale: Mutex::new(std::collections::HashMap::new()),
|
||||||
|
ad_trace: Mutex::new(std::collections::VecDeque::new()),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+116
-14
@@ -96,12 +96,71 @@ pub async fn handler(
|
|||||||
.map(|(l, _)| l)
|
.map(|(l, _)| l)
|
||||||
.unwrap_or_else(|| "-".into());
|
.unwrap_or_else(|| "-".into());
|
||||||
let size = hdr("content-length").parse::<u64>().unwrap_or(0);
|
let size = hdr("content-length").parse::<u64>().unwrap_or(0);
|
||||||
let resp = inner(app.clone(), peer, method.clone(), headers, body).await;
|
// request headers for the trace (never credentials or cookies)
|
||||||
let mut resp = resp;
|
let hdr_dump: Vec<String> = headers
|
||||||
|
.iter()
|
||||||
|
.filter(|(k, _)| {
|
||||||
|
!matches!(
|
||||||
|
k.as_str(),
|
||||||
|
"authorization" | "cookie" | "proxy-authorization"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.map(|(k, v)| {
|
||||||
|
format!(
|
||||||
|
"{}: {}",
|
||||||
|
k.as_str(),
|
||||||
|
String::from_utf8_lossy(v.as_bytes())
|
||||||
|
.chars()
|
||||||
|
.filter(|c| !c.is_control())
|
||||||
|
.take(200)
|
||||||
|
.collect::<String>()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let typed_user = headers
|
||||||
|
.get("authorization")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|v| v.split_once(' '))
|
||||||
|
.and_then(|(_, d)| {
|
||||||
|
use base64::Engine;
|
||||||
|
base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(d.trim())
|
||||||
|
.ok()
|
||||||
|
})
|
||||||
|
.and_then(|b| String::from_utf8(b).ok())
|
||||||
|
.and_then(|d| {
|
||||||
|
d.split_once(':')
|
||||||
|
.map(|(u, _)| u.chars().take(120).collect::<String>())
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
let body_bytes = match http_body_util::Limited::new(body, 64 * 1024)
|
||||||
|
.collect()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(b) => b.to_bytes(),
|
||||||
|
Err(_) => return StatusCode::PAYLOAD_TOO_LARGE.into_response(),
|
||||||
|
};
|
||||||
|
let resp = inner(
|
||||||
|
app.clone(),
|
||||||
|
peer,
|
||||||
|
method.clone(),
|
||||||
|
headers,
|
||||||
|
Body::from(body_bytes.clone()),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let (mut parts, rbody) = resp.into_parts();
|
||||||
|
let rbytes = rbody
|
||||||
|
.collect()
|
||||||
|
.await
|
||||||
|
.map(|b| b.to_bytes())
|
||||||
|
.unwrap_or_default();
|
||||||
|
parts.headers.remove("content-length");
|
||||||
|
let mut resp = Response::from_parts(parts, Body::from(rbytes.clone()));
|
||||||
resp.headers_mut()
|
resp.headers_mut()
|
||||||
.insert("X-ActivePush", HeaderValue::from_static("autodiscover"));
|
.insert("X-ActivePush", HeaderValue::from_static("autodiscover"));
|
||||||
let code = resp.status().as_u16();
|
let code = resp.status().as_u16();
|
||||||
let status = match code {
|
let status = match code {
|
||||||
|
200 if String::from_utf8_lossy(&rbytes).contains("<ErrorCode>") => "invalid request",
|
||||||
200 => "ok",
|
200 => "ok",
|
||||||
401 => "auth required",
|
401 => "auth required",
|
||||||
429 => "locked out",
|
429 => "locked out",
|
||||||
@@ -109,6 +168,18 @@ pub async fn handler(
|
|||||||
_ => "error",
|
_ => "error",
|
||||||
};
|
};
|
||||||
tracing::info!(target: "active_push::autodiscover", ip = %ip, host = %host, path = %uri.path(), method = %method, http = code, user = %user, ua = %ua, "autodiscover {status}");
|
tracing::info!(target: "active_push::autodiscover", ip = %ip, host = %host, path = %uri.path(), method = %method, http = code, user = %user, ua = %ua, "autodiscover {status}");
|
||||||
|
let clip = |b: &[u8]| -> String {
|
||||||
|
String::from_utf8_lossy(&b[..b.len().min(3000)])
|
||||||
|
.chars()
|
||||||
|
.filter(|c| !c.is_control() || *c == '\n')
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
app.ad_trace_push(serde_json::json!({
|
||||||
|
"ts": now(), "ip": ip, "host": host, "method": method.as_str(), "path": uri.path(),
|
||||||
|
"typed_user": typed_user, "http": code, "ms": started.elapsed().as_millis() as u64,
|
||||||
|
"request_headers": hdr_dump, "request_body": clip(&body_bytes),
|
||||||
|
"response_body": clip(&rbytes),
|
||||||
|
}));
|
||||||
app.metrics.record(Event {
|
app.metrics.record(Event {
|
||||||
ts: now(),
|
ts: now(),
|
||||||
user,
|
user,
|
||||||
@@ -199,20 +270,56 @@ async fn inner(
|
|||||||
if !user_allowed(&login, &s) {
|
if !user_allowed(&login, &s) {
|
||||||
return unauthorized();
|
return unauthorized();
|
||||||
}
|
}
|
||||||
if let Some((l, p)) = &creds {
|
// iOS sends the same password twice: once with "user@domain" and once with the bare "user",
|
||||||
let key = app.pool.key(l, p);
|
// and treats a refusal of either as a failed Autodiscover. When the bare name is the local
|
||||||
if !app.auth.is_cached(&key) {
|
// part of the requested address, try the full address first (the form most IMAP servers with
|
||||||
let mut sess = app.session(l, p);
|
// several domains know), then the name as given.
|
||||||
|
let mut login = login;
|
||||||
|
if let Some((given, pass)) = &creds {
|
||||||
|
let mut candidates: Vec<String> = Vec::new();
|
||||||
|
if !given.contains('@') {
|
||||||
|
if let Some((local, _)) = email.rsplit_once('@') {
|
||||||
|
if local.eq_ignore_ascii_case(given) {
|
||||||
|
if let Some(full) = normalize_login(&email, &s) {
|
||||||
|
if user_allowed(&full, &s) {
|
||||||
|
candidates.push(full);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
candidates.push(given.clone());
|
||||||
|
|
||||||
|
let mut accepted: Option<String> = candidates
|
||||||
|
.iter()
|
||||||
|
.find(|c| app.auth.is_cached(&app.pool.key(c, pass)))
|
||||||
|
.cloned();
|
||||||
|
if accepted.is_none() {
|
||||||
|
for cand in &candidates {
|
||||||
|
let mut sess = app.session(cand, pass);
|
||||||
match sess.authenticate().await {
|
match sess.authenticate().await {
|
||||||
Ok(AuthResult::Ok) => {
|
Ok(AuthResult::Ok) => {
|
||||||
app.auth.success(&user_key);
|
app.auth.success(&user_key);
|
||||||
app.auth.cache_ok(key, s.security.auth_cache_secs);
|
app.auth
|
||||||
|
.cache_ok(app.pool.key(cand, pass), s.security.auth_cache_secs);
|
||||||
sess.close().await;
|
sess.close().await;
|
||||||
|
accepted = Some(cand.clone());
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
Ok(AuthResult::Denied) => {
|
Ok(AuthResult::Denied) => {}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("autodiscover: backend unavailable: {e:#}");
|
||||||
|
return StatusCode::SERVICE_UNAVAILABLE.into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match accepted {
|
||||||
|
Some(l) => login = l,
|
||||||
|
None => {
|
||||||
app.metrics.auth_failure();
|
app.metrics.auth_failure();
|
||||||
if s.security.log_auth_failures {
|
if s.security.log_auth_failures {
|
||||||
tracing::warn!(target: "active_push::auth", "authentication failed user={l} ip={ip} service=autodiscover");
|
tracing::warn!(target: "active_push::auth", "authentication failed user={given} ip={ip} service=autodiscover");
|
||||||
}
|
}
|
||||||
let (m, w, lo) = (
|
let (m, w, lo) = (
|
||||||
s.security.auth_fail_max,
|
s.security.auth_fail_max,
|
||||||
@@ -223,11 +330,6 @@ async fn inner(
|
|||||||
app.auth.fail(&user_key, m, w, lo);
|
app.auth.fail(&user_key, m, w, lo);
|
||||||
return unauthorized();
|
return unauthorized();
|
||||||
}
|
}
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("autodiscover: backend unavailable: {e:#}");
|
|
||||||
return StatusCode::SERVICE_UNAVAILABLE.into_response();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if email.is_empty() || !email.contains('@') {
|
if email.is_empty() || !email.contains('@') {
|
||||||
|
|||||||
+38
-2
@@ -34,6 +34,7 @@ pub fn public_router(app: Arc<app::App>) -> Router {
|
|||||||
.route("/autodiscover/autodiscover.xml", any(autodiscover::handler))
|
.route("/autodiscover/autodiscover.xml", any(autodiscover::handler))
|
||||||
.route("/healthz", axum::routing::get(healthz))
|
.route("/healthz", axum::routing::get(healthz))
|
||||||
.route("/", axum::routing::get(index))
|
.route("/", axum::routing::get(index))
|
||||||
|
.route("/favicon.svg", axum::routing::get(favicon))
|
||||||
.fallback(not_found)
|
.fallback(not_found)
|
||||||
.layer(axum::middleware::map_response(mark))
|
.layer(axum::middleware::map_response(mark))
|
||||||
.with_state(app)
|
.with_state(app)
|
||||||
@@ -47,10 +48,45 @@ async fn mark(mut r: axum::response::Response) -> axum::response::Response {
|
|||||||
r
|
r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LANDING: &str = include_str!("../web/landing.html");
|
||||||
|
const FAVICON: &str = include_str!("../web/favicon.svg");
|
||||||
|
|
||||||
|
/// Landing page for browsers opening the ActiveSync host. Self-contained (inline SVG, one inline
|
||||||
|
/// style block pinned by a hash in the CSP, no scripts, no external requests).
|
||||||
async fn index() -> impl axum::response::IntoResponse {
|
async fn index() -> impl axum::response::IntoResponse {
|
||||||
|
static CSP: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
||||||
|
let csp = CSP.get_or_init(|| {
|
||||||
|
use base64::Engine;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
let css = LANDING
|
||||||
|
.split_once("<style>")
|
||||||
|
.and_then(|(_, r)| r.split_once("</style>"))
|
||||||
|
.map(|(c, _)| c)
|
||||||
|
.unwrap_or("");
|
||||||
|
let hash = base64::engine::general_purpose::STANDARD.encode(Sha256::digest(css.as_bytes()));
|
||||||
|
format!(
|
||||||
|
"default-src 'none'; style-src 'sha256-{hash}'; img-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
|
||||||
|
)
|
||||||
|
});
|
||||||
(
|
(
|
||||||
[("Content-Type", "text/plain; charset=utf-8")],
|
[
|
||||||
"ActivePush - Exchange ActiveSync endpoint.\nDevices connect to /Microsoft-Server-ActiveSync.\n",
|
("Content-Type", "text/html; charset=utf-8"),
|
||||||
|
("Content-Security-Policy", csp.as_str()),
|
||||||
|
("X-Content-Type-Options", "nosniff"),
|
||||||
|
("Referrer-Policy", "no-referrer"),
|
||||||
|
("Cache-Control", "no-cache"),
|
||||||
|
],
|
||||||
|
LANDING,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn favicon() -> impl axum::response::IntoResponse {
|
||||||
|
(
|
||||||
|
[
|
||||||
|
("Content-Type", "image/svg+xml"),
|
||||||
|
("Cache-Control", "public, max-age=86400"),
|
||||||
|
],
|
||||||
|
FAVICON,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-1
@@ -715,10 +715,19 @@
|
|||||||
['autodiscover.enabled', T('Włączony', 'Enabled'), 'bool'], ['autodiscover.require_auth', T('Wymagaj poprawnego hasła', 'Require valid password'), 'bool'],
|
['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('Adres przekazywany telefonom. Puste = z nagłówka Host żądania (zwykle autodiscover.<domena>, co bywa błędne).', 'Address handed to phones. Empty = from the Host header of the request (usually autodiscover.<domain>, which is often wrong).') }],
|
['autodiscover.server_url', T('Publiczny URL ActiveSync', 'Public ActiveSync URL'), 'text', { wide: true, ph: 'https://mail.example.com/Microsoft-Server-ActiveSync', help: T('Adres przekazywany telefonom. Puste = z nagłówka Host żądania (zwykle autodiscover.<domena>, co bywa błędne).', 'Address handed to phones. Empty = from the Host header of the request (usually autodiscover.<domain>, which is often wrong).') }],
|
||||||
['autodiscover.domains', T('Domeny e-mail', 'E-mail domains'), 'list', { ph: 'example.com\nexample.org https://mail.example.org/Microsoft-Server-ActiveSync', help: T('Jedna domena na linię, opcjonalnie z własnym adresem ActiveSync po spacji (domyślnie: Publiczny URL powyżej). Gdy lista nie jest pusta, żądania dla innych domen są odrzucane (pusta = wszystkie domeny). Zapisz zmiany przed uruchomieniem diagnostyki.', 'One domain per line, optionally followed by its own ActiveSync URL after a space (default: the Public URL above). When the list is not empty, requests for other domains are refused (empty = all domains). Save before running diagnostics.') }],
|
['autodiscover.domains', T('Domeny e-mail', 'E-mail domains'), 'list', { ph: 'example.com\nexample.org https://mail.example.org/Microsoft-Server-ActiveSync', help: T('Jedna domena na linię, opcjonalnie z własnym adresem ActiveSync po spacji (domyślnie: Publiczny URL powyżej). Gdy lista nie jest pusta, żądania dla innych domen są odrzucane (pusta = wszystkie domeny). Zapisz zmiany przed uruchomieniem diagnostyki.', 'One domain per line, optionally followed by its own ActiveSync URL after a space (default: the Public URL above). When the list is not empty, requests for other domains are refused (empty = all domains). Save before running diagnostics.') }],
|
||||||
], `<div class="diag-bar"><button class="outline" id="diag-all">${T('Uruchom diagnostykę wszystkich domen', 'Run diagnostics for all domains')}</button><span class="fine" id="diag-hint"></span></div><div id="diag-out"></div>`),
|
], `<div class="diag-bar"><button class="outline" id="diag-all">${T('Uruchom diagnostykę wszystkich domen', 'Run diagnostics for all domains')}</button><span class="fine" id="diag-hint"></span></div><div id="diag-out"></div><h3 class="sub-h">${T('Ostatnie żądania Autodiscover', 'Recent Autodiscover requests')}</h3><div class="diag-bar"><button class="outline" id="trace-refresh">${T('Odśwież', 'Refresh')}</button><span class="fine">${T('Nagłówki (bez haseł), treść żądania i odpowiedzi - do diagnozy telefonów. Ostatnie 30, tylko w pamięci.', 'Headers (no passwords), request and response bodies - for debugging phones. Last 30, memory only.')}</span></div><div id="trace-out"></div>`),
|
||||||
{ bind: (form) => {
|
{ bind: (form) => {
|
||||||
const doms = (S.settings.autodiscover.domains || []).filter(Boolean).map((e) => e.trim().split(/[\s=]+/)[0].toLowerCase().replace(/\.$/, ''));
|
const doms = (S.settings.autodiscover.domains || []).filter(Boolean).map((e) => e.trim().split(/[\s=]+/)[0].toLowerCase().replace(/\.$/, ''));
|
||||||
const out = $('#diag-out', form);
|
const out = $('#diag-out', form);
|
||||||
|
const traceOut = $('#trace-out', form);
|
||||||
|
const loadTrace = async () => {
|
||||||
|
try {
|
||||||
|
const rows = await api('GET', '/api/autodiscover/trace');
|
||||||
|
traceOut.innerHTML = rows.length ? rows.map((r) => `<details class="trace"><summary><span class="pill ${r.http === 200 ? 'ok' : 'warn'}">${r.http}</span> ${esc(new Date(r.ts * 1000).toLocaleTimeString())} ${esc(r.method)} ${esc(r.path)} <span class="fine">${esc(r.host)} - ${esc(r.ip)} - ${T('login', 'login')}: ${esc(r.typed_user || '-')} - ${r.ms} ms</span></summary><pre class="code">${esc(r.request_headers.join('\n'))}</pre><pre class="code">${esc(r.request_body || '(empty body)')}</pre><pre class="code">${esc(r.response_body)}</pre></details>`).join('') : `<div class="fine">${T('Brak żądań od ostatniego startu.', 'No requests since the last start.')}</div>`;
|
||||||
|
} catch (err) { traceOut.innerHTML = `<div class="result bad">${esc(err.message)}</div>`; }
|
||||||
|
};
|
||||||
|
$('#trace-refresh', form).addEventListener('click', loadTrace);
|
||||||
|
loadTrace();
|
||||||
$('#diag-hint', form).textContent = doms.length ? doms.map((d) => '@' + d).join(' ') : T('Dodaj domeny i zapisz, aby uruchomić diagnostykę.', 'Add domains and save to run diagnostics.');
|
$('#diag-hint', form).textContent = doms.length ? doms.map((d) => '@' + d).join(' ') : T('Dodaj domeny i zapisz, aby uruchomić diagnostykę.', 'Add domains and save to run diagnostics.');
|
||||||
$('#diag-all', form).disabled = !doms.length;
|
$('#diag-all', form).disabled = !doms.length;
|
||||||
$('#diag-all', form).addEventListener('click', async (e) => {
|
$('#diag-all', form).addEventListener('click', async (e) => {
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<title>ActivePush</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: dark; --bg: #131615; --line: #323a34; }
|
||||||
|
@media (prefers-color-scheme: light) { :root { color-scheme: light; --bg: #f2f4ee; --line: #cbd3c7; } }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { height: 100%; margin: 0; }
|
||||||
|
body { background: var(--bg); display: flex; align-items: center; justify-content: center; }
|
||||||
|
.mark { display: block; width: 128px; height: 128px; border: 1px solid var(--line); border-radius: 28px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<svg class="mark" viewBox="0 0 64 64" role="img" aria-label="ActivePush"><rect width="64" height="64" fill="#1b1f1d"/><text x="32" y="41" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Consolas, monospace" font-size="24" font-weight="700" fill="#f69b74">a<tspan fill="#cfe0ac">/</tspan>p</text></svg>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -368,3 +368,6 @@ td.wrap { white-space: normal; max-width: 420px; }
|
|||||||
.sub-h { margin: 16px 0 6px; font-size: 13px; }
|
.sub-h { margin: 16px 0 6px; font-size: 13px; }
|
||||||
pre.code { margin: 0 0 8px; padding: 9px 11px; background: var(--code-bg); border: 1px solid var(--line); border-radius: var(--r-control); font: 11px/1.55 var(--mono); overflow-x: auto; white-space: pre; user-select: all; }
|
pre.code { margin: 0 0 8px; padding: 9px 11px; background: var(--code-bg); border: 1px solid var(--line); border-radius: var(--r-control); font: 11px/1.55 var(--mono); overflow-x: auto; white-space: pre; user-select: all; }
|
||||||
td.mono-cell { font-family: var(--mono); font-size: 11px; }
|
td.mono-cell { font-family: var(--mono); font-size: 11px; }
|
||||||
|
details.trace { border-top: 1px solid var(--line); padding: 6px 0; }
|
||||||
|
details.trace summary { cursor: pointer; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||||||
|
details.trace pre.code { max-height: 260px; overflow: auto; margin: 6px 0; }
|
||||||
Reference in new issue
Block a user