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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bin/hyperion-web/templates/hostings_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -1361,8 +1361,8 @@ <h2>
<button class="btn danger" type="submit">Push staging → production</button>
</form>
<a class="btn ghost" href="https://{{ staging_domain }}" target="_blank" rel="noopener noreferrer">Open staging ↗</a>
{% endif %}
</div>
{% endif %}
</div>
{% endif %}

Expand Down
45 changes: 27 additions & 18 deletions crates/hyperion-core/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7911,9 +7911,13 @@ impl<A: AdapterPort + 'static> HostingService<A> {
.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);
}
Expand Down Expand Up @@ -11536,8 +11540,18 @@ impl<A: AdapterPort + 'static> HostingService<A> {
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,
Expand Down Expand Up @@ -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::<i64>().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()
Expand Down
1 change: 1 addition & 0 deletions crates/hyperion-types/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
158 changes: 158 additions & 0 deletions crates/hyperion-types/src/stats.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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<String> {
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,
Expand Down Expand Up @@ -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
Expand Down