From 43ac42eae97b1224a76bcea851043920f2782425 Mon Sep 17 00:00:00 2001 From: mkn Date: Wed, 12 Aug 2026 19:51:57 +0200 Subject: [PATCH] fix: unbreak the WordPress tab layout, decode mail subjects, drop `du` from page load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LAYOUT REGRESSION, mine, from the staging-card guard in the previous release. The `{% endif %}` landed INSIDE the actions row instead of after it, so when no staging site existed the opening `
` was skipped while its `
` was still emitted. That closed an ancestor early and every card below it — Plugins, Themes — reflowed into the wrong tab. Balanced now: the guard wraps the whole row, both branches emit the same number of tags. MAIL SUBJECTS rendered raw: `=?UTF-8?Q?[Ingratia.cz]_Web_byl_aktualizov=C3=A1n?=`. Mail headers are ASCII-only on the wire, so any subject with an accent in it — which for a Czech site is most of them — arrives RFC 2047 encoded. Added a decoder for Q- and B-encoded words. Deliberately lenient: an unknown charset, an unknown encoding or a truncated word comes back UNCHANGED rather than mangled, because an ugly subject is still useful and one rewritten into nonsense is not. Display-only; the raw header stays in the log file. HOSTING DETAIL SPEED. The quota probe fell back to `du -sk` over the site's entire tree, with no timeout, whenever the kernel probe returned 0 — which is every box without kernel quotas, i.e. the default. That ran on EVERY hosting-detail page load, before anything rendered: on a 20 GB site with a cold page cache, seconds to tens of seconds, for a figure that is only enforced when quotas are actually on. The fallback is gone; the caller substitutes the last sampled usage row instead. Five minutes stale and instant beats exact and unusable, and `du` still runs — on the sampler's schedule, where taking a while is fine. Co-Authored-By: Claude Opus 4.8 --- .../templates/hostings_detail.html | 2 +- crates/hyperion-core/src/service.rs | 45 +++-- crates/hyperion-types/src/lib.rs | 1 + crates/hyperion-types/src/stats.rs | 158 ++++++++++++++++++ 4 files changed, 187 insertions(+), 19 deletions(-) diff --git a/bin/hyperion-web/templates/hostings_detail.html b/bin/hyperion-web/templates/hostings_detail.html index ea8831cf..4820b7eb 100644 --- a/bin/hyperion-web/templates/hostings_detail.html +++ b/bin/hyperion-web/templates/hostings_detail.html @@ -1361,8 +1361,8 @@

Open staging ↗ - {% endif %} + {% endif %} {% endif %} diff --git a/crates/hyperion-core/src/service.rs b/crates/hyperion-core/src/service.rs index 81155940..3aee4d6c 100644 --- a/crates/hyperion-core/src/service.rs +++ b/crates/hyperion-core/src/service.rs @@ -7911,9 +7911,13 @@ impl HostingService { .take(limit) .filter_map(|line| serde_json::from_str(line).ok()) .collect(); - // Truncate the body excerpt for wire safety (the wrapper - // already caps at ~1 KB but defence in depth). for r in &mut out { + // Subjects arrive RFC 2047 encoded whenever they contain a + // non-ASCII character, which for a Czech site is most of them. + // Decode for display; an undecodable header comes back unchanged. + r.subject = hyperion_types::decode_mime_header(&r.subject); + // Truncate the body excerpt for wire safety (the wrapper + // already caps at ~1 KB but defence in depth). if r.body_excerpt.len() > 2048 { r.body_excerpt.truncate(2048); } @@ -11536,8 +11540,18 @@ impl HostingService { updated_at: row.updated_at, exceed_action: exceed_action.as_str().to_string(), }; - let (current_disk_kib, quotas_enabled_on_fs, setup_hint) = + let (mut current_disk_kib, quotas_enabled_on_fs, setup_hint) = quota_probe_current(&detail.system_user, &detail.root_dir).await; + // Without kernel quotas the probe deliberately returns 0 rather than + // walking the tree — fall back to the usage sampler, which already + // measures exactly this and does it off the request path. + if current_disk_kib == 0 { + if let Ok(rows) = hyperion_state::limits::usage_for(&self.pool, &detail.id, 1).await { + if let Some(u) = rows.first() { + current_disk_kib = u.disk_used_bytes / 1024; + } + } + } Ok(hyperion_types::HostingQuotaReport { policy, current_disk_kib, @@ -24063,21 +24077,16 @@ async fn quota_probe_current(user: &str, home_dir: &str) -> (i64, bool, String) } } } - if used == 0 { - if let Ok(o) = tokio::process::Command::new("/usr/bin/du") - .args(["-sk", home_dir]) - .output() - .await - { - if o.status.success() { - used = String::from_utf8_lossy(&o.stdout) - .split_whitespace() - .next() - .and_then(|n| n.parse::().ok()) - .unwrap_or(0); - } - } - } + // NO `du` FALLBACK HERE. It used to run whenever the kernel probe + // returned 0 — which is every box without kernel quotas, i.e. the default + // — walking the site's entire tree, with no timeout, on every hosting + // detail page load. On a 20 GB site with a cold page cache that is + // seconds to tens of seconds of wall clock before anything renders, for a + // number that is only ENFORCED when quotas are on. + // + // The caller substitutes the last sampled figure instead (see quota_get): + // five minutes stale and instant beats exact and unusable. `du` still + // runs, but on the sampler's schedule, where taking a while is fine. let hint = if enabled { String::new() diff --git a/crates/hyperion-types/src/lib.rs b/crates/hyperion-types/src/lib.rs index 0a362d54..cf946b54 100644 --- a/crates/hyperion-types/src/lib.rs +++ b/crates/hyperion-types/src/lib.rs @@ -56,6 +56,7 @@ pub use package::{ pub use php::PhpVersion; pub use profile::{HostingProfile, ProfileApply, ProfileInput, WpAssetSummary}; pub use spf::SpfCheckResult; +pub use stats::decode_mime_header; pub use stats::{ AcmeConfigView, AgentConfigView, BackupRemoteConfigView, BackupRetentionConfigView, ClusterConfigView, ClusterStats, CustomRoleSummary, DashboardAlert, EffectiveRoleWire, diff --git a/crates/hyperion-types/src/stats.rs b/crates/hyperion-types/src/stats.rs index 38047c0d..0c55be9f 100644 --- a/crates/hyperion-types/src/stats.rs +++ b/crates/hyperion-types/src/stats.rs @@ -1066,6 +1066,124 @@ pub struct FtpAccountSummary { /// site-mail-wrapper). Distinct from the Hyperion-sent emails in /// EmailLogEntry — those flow through our SMTP config, these flow /// through the local sendmail. +/// Decode RFC 2047 encoded-words (`=?UTF-8?Q?...?=` / `?B?`) into readable +/// text. +/// +/// Mail headers are ASCII-only on the wire, so anything with an accent in it +/// arrives encoded — which is why a Czech subject line rendered as +/// `=?UTF-8?Q?[Ingratia.cz]_Web_byl_aktualizov=C3=A1n?=` in the panel. This +/// is display-only: the raw header stays in the log file. +/// +/// Deliberately lenient. A header we cannot decode is returned UNCHANGED +/// rather than mangled or dropped — a subject that is ugly is still useful, +/// and one that has been "helpfully" rewritten into nonsense is not. Only +/// UTF-8 and the ASCII/Latin-1 aliases are decoded; any other charset is +/// left alone rather than guessed at. +pub fn decode_mime_header(raw: &str) -> String { + // Fast path: the overwhelming majority of subjects are plain ASCII. + if !raw.contains("=?") { + return raw.to_string(); + } + let mut out = String::with_capacity(raw.len()); + let mut rest = raw; + while let Some(start) = rest.find("=?") { + out.push_str(&rest[..start]); + let after = &rest[start + 2..]; + // charset ? encoding ? payload ?= + let Some(c1) = after.find('?') else { + out.push_str(&rest[start..]); + return out; + }; + let charset = &after[..c1]; + let after_cs = &after[c1 + 1..]; + let Some(c2) = after_cs.find('?') else { + out.push_str(&rest[start..]); + return out; + }; + let encoding = &after_cs[..c2]; + let after_enc = &after_cs[c2 + 1..]; + let Some(end) = after_enc.find("?=") else { + out.push_str(&rest[start..]); + return out; + }; + let payload = &after_enc[..end]; + + let cs = charset.to_ascii_lowercase(); + let decoded = + if cs == "utf-8" || cs == "utf8" || cs.starts_with("iso-8859-1") || cs == "us-ascii" { + match encoding.to_ascii_uppercase().as_str() { + "Q" => decode_q(payload), + "B" => decode_b(payload), + _ => None, + } + } else { + None + }; + match decoded { + Some(t) => out.push_str(&t), + // Unknown charset or malformed payload — keep the original word + // so the operator can still read what was there. + None => out.push_str(&rest[start..start + 2 + c1 + 1 + c2 + 1 + end + 2]), + } + rest = &after_enc[end + 2..]; + // Encoded words may be separated by whitespace that is NOT part of + // the text (RFC 2047 §6.2). Swallow a single run between two words. + if rest.starts_with(' ') && rest.trim_start().starts_with("=?") { + rest = rest.trim_start(); + } + } + out.push_str(rest); + out +} + +/// Q-encoding: `_` is a space, `=XX` is a hex byte, everything else literal. +fn decode_q(payload: &str) -> Option { + let b = payload.as_bytes(); + let mut bytes = Vec::with_capacity(b.len()); + let mut i = 0; + while i < b.len() { + match b[i] { + b'_' => { + bytes.push(b' '); + i += 1; + } + b'=' if i + 2 < b.len() => { + let hex = std::str::from_utf8(&b[i + 1..i + 3]).ok()?; + bytes.push(u8::from_str_radix(hex, 16).ok()?); + i += 3; + } + // A bare '=' with nothing decodable after it is malformed. + b'=' => return None, + c => { + bytes.push(c); + i += 1; + } + } + } + String::from_utf8(bytes).ok() +} + +/// B-encoding: standard base64. +fn decode_b(payload: &str) -> Option { + const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut acc: u32 = 0; + let mut bits = 0u32; + let mut bytes = Vec::new(); + for ch in payload.bytes() { + if ch == b'=' { + break; + } + let v = T.iter().position(|&t| t == ch)? as u32; + acc = (acc << 6) | v; + bits += 6; + if bits >= 8 { + bits -= 8; + bytes.push((acc >> bits) as u8); + } + } + String::from_utf8(bytes).ok() +} + /// One captured outbound message. /// /// `#[serde(default)]` on the struct, and aliases on the two renamed fields, @@ -1527,6 +1645,46 @@ impl Default for UpdateStatus { mod tests { use super::*; + /// The real subject from the user's own box, which rendered raw in the + /// panel. Anything with an accent arrives RFC 2047 encoded, so a Czech + /// subject was unreadable for every message that had one. + #[test] + fn decodes_the_subject_that_was_showing_raw() { + assert_eq!( + decode_mime_header("=?UTF-8?Q?[Ingratia.cz]_Web_byl_aktualizov=C3=A1n_na_WordPre?="), + "[Ingratia.cz] Web byl aktualizován na WordPre" + ); + } + + #[test] + fn decodes_base64_words_and_leaves_plain_text_alone() { + assert_eq!(decode_mime_header("=?UTF-8?B?UMWZw61saXM=?="), "Přílis"); + assert_eq!(decode_mime_header("Plain old subject"), "Plain old subject"); + } + + /// A header we cannot decode comes back UNCHANGED. An ugly subject is + /// still useful; one rewritten into nonsense is not. + #[test] + fn undecodable_headers_survive_intact() { + for raw in [ + "=?ISO-2022-JP?Q?whatever?=", // charset we do not handle + "=?UTF-8?X?nope?=", // unknown encoding + "=?UTF-8?Q?truncated", // no terminator + ] { + assert_eq!(decode_mime_header(raw), raw, "must not mangle {raw}"); + } + } + + /// Mixed plain text and encoded words, which is what a "Re:" reply looks + /// like once a mail client gets hold of it. + #[test] + fn decodes_a_word_embedded_in_plain_text() { + assert_eq!( + decode_mime_header("Re: =?UTF-8?Q?p=C5=99=C3=ADloha?= (fwd)"), + "Re: příloha (fwd)" + ); + } + /// The exact line shape `site-mail-wrapper.sh` writes must parse. /// /// It did not, for the whole life of the feature: the wrapper emits