forge: upload hyperhive logo as the core user's avatar
The 'core' Forgejo user (hive-c0re's identity for commits in core/meta + agent-configs/*) was showing the default hash identicon. Adds a one-shot ensure_core_avatar in the ensure_all bootstrap that POSTs the branding PNG to the admin avatar API and writes a marker file (CORE_AVATAR_MARKER) so subsequent startups skip the call (delete the marker to re-upload). Best-effort: a non-2xx is logged and swallowed, doesn't gate startup. PNG bytes baked in via include_bytes! from branding/hyperhive.png. Base64 is hand-rolled (one small image in one cold path, not worth a new workspace dep) with RFC 4648 §10 test vectors. Closes #320.
This commit is contained in:
parent
832c2f86c5
commit
dbb2ca4393
1 changed files with 96 additions and 0 deletions
|
|
@ -32,6 +32,16 @@ const TOKEN_NAME_PREFIX: &str = "hyperhive";
|
||||||
/// itself to push the meta repo + drive admin API calls (org
|
/// itself to push the meta repo + drive admin API calls (org
|
||||||
/// creation, future webhook setup, etc.). Root-only.
|
/// creation, future webhook setup, etc.). Root-only.
|
||||||
const CORE_TOKEN_PATH: &str = "/var/lib/hyperhive/forge-core-token";
|
const CORE_TOKEN_PATH: &str = "/var/lib/hyperhive/forge-core-token";
|
||||||
|
/// Marker that records whether `ensure_core_avatar` has successfully
|
||||||
|
/// uploaded the hyperhive logo as `core`'s avatar (issue #320). One-shot:
|
||||||
|
/// the upload runs once, the marker is written, subsequent startups skip
|
||||||
|
/// the call. Delete to force re-upload.
|
||||||
|
const CORE_AVATAR_MARKER: &str = "/var/lib/hyperhive/forge-core-avatar-set";
|
||||||
|
/// Hyperhive logo bytes, baked into the daemon. Uploaded once via the
|
||||||
|
/// admin avatar API so the `core` Forgejo user shows the project mark
|
||||||
|
/// next to commits in `agent-configs/*`, `core/meta`, etc. instead of
|
||||||
|
/// the default hash identicon.
|
||||||
|
const CORE_AVATAR_PNG: &[u8] = include_bytes!("../../branding/hyperhive.png");
|
||||||
/// Forgejo org grouping every agent's applied config repo. Core is a
|
/// Forgejo org grouping every agent's applied config repo. Core is a
|
||||||
/// site admin and reads + writes every repo here; agents are NOT
|
/// site admin and reads + writes every repo here; agents are NOT
|
||||||
/// members and the repos are private, so no agent — not even the one
|
/// members and the repos are private, so no agent — not even the one
|
||||||
|
|
@ -266,6 +276,89 @@ pub async fn ensure_user_for(name: &str) -> Result<()> {
|
||||||
mint_and_persist_token(name, &token_path(name)).await
|
mint_and_persist_token(name, &token_path(name)).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set `core`'s Forgejo avatar to the hyperhive logo once, then
|
||||||
|
/// remember it so subsequent startups don't re-upload (issue #320).
|
||||||
|
/// Best-effort — any non-2xx is logged at the caller; the project
|
||||||
|
/// runs fine with the default hash identicon.
|
||||||
|
async fn ensure_core_avatar(token: &str) -> Result<()> {
|
||||||
|
let marker = std::path::Path::new(CORE_AVATAR_MARKER);
|
||||||
|
if marker.exists() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let body = format!(
|
||||||
|
r#"{{"image":"{}"}}"#,
|
||||||
|
base64_encode(CORE_AVATAR_PNG),
|
||||||
|
);
|
||||||
|
let url = format!("{FORGE_HTTP}/api/v1/admin/users/core/avatar");
|
||||||
|
let status = forge_http(reqwest::Method::POST, &url, token, &body).await?;
|
||||||
|
if !status.is_success() {
|
||||||
|
anyhow::bail!("set core avatar: HTTP {status}");
|
||||||
|
}
|
||||||
|
if let Some(parent) = marker.parent() {
|
||||||
|
std::fs::create_dir_all(parent).ok();
|
||||||
|
}
|
||||||
|
std::fs::write(marker, "").ok();
|
||||||
|
tracing::info!("forge: set core user avatar to hyperhive logo");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Standard base64 encode (RFC 4648 §4, with `=` padding). Used by
|
||||||
|
/// `ensure_core_avatar` to embed the hyperhive logo PNG in the
|
||||||
|
/// Forgejo admin avatar JSON body — the payload is one small image
|
||||||
|
/// in one cold path, so pulling in a base64 crate isn't worth the
|
||||||
|
/// dep churn.
|
||||||
|
fn base64_encode(bytes: &[u8]) -> String {
|
||||||
|
const ALPHABET: &[u8; 64] =
|
||||||
|
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||||
|
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
|
||||||
|
for chunk in bytes.chunks(3) {
|
||||||
|
let b0 = chunk[0];
|
||||||
|
let b1 = chunk.get(1).copied().unwrap_or(0);
|
||||||
|
let b2 = chunk.get(2).copied().unwrap_or(0);
|
||||||
|
out.push(ALPHABET[(b0 >> 2) as usize] as char);
|
||||||
|
out.push(ALPHABET[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char);
|
||||||
|
if chunk.len() > 1 {
|
||||||
|
out.push(ALPHABET[(((b1 & 0x0F) << 2) | (b2 >> 6)) as usize] as char);
|
||||||
|
} else {
|
||||||
|
out.push('=');
|
||||||
|
}
|
||||||
|
if chunk.len() > 2 {
|
||||||
|
out.push(ALPHABET[(b2 & 0x3F) as usize] as char);
|
||||||
|
} else {
|
||||||
|
out.push('=');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod base64_tests {
|
||||||
|
use super::base64_encode;
|
||||||
|
|
||||||
|
// RFC 4648 §10 test vectors.
|
||||||
|
#[test]
|
||||||
|
fn rfc4648_vectors() {
|
||||||
|
assert_eq!(base64_encode(b""), "");
|
||||||
|
assert_eq!(base64_encode(b"f"), "Zg==");
|
||||||
|
assert_eq!(base64_encode(b"fo"), "Zm8=");
|
||||||
|
assert_eq!(base64_encode(b"foo"), "Zm9v");
|
||||||
|
assert_eq!(base64_encode(b"foob"), "Zm9vYg==");
|
||||||
|
assert_eq!(base64_encode(b"fooba"), "Zm9vYmE=");
|
||||||
|
assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn full_byte_range_roundtrip_length() {
|
||||||
|
// 256 bytes → 256 / 3 = 85 chunks + 1 leftover (chunks_of_3 walks 85
|
||||||
|
// full + 1 padded), 86 chunks * 4 chars = 344. Cheap sanity check
|
||||||
|
// that the size calc + padding don't drift on a non-multiple input.
|
||||||
|
let bytes: Vec<u8> = (0..=255u8).collect();
|
||||||
|
let encoded = base64_encode(&bytes);
|
||||||
|
assert_eq!(encoded.len(), 344);
|
||||||
|
assert!(encoded.ends_with('='));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Ensure the bootstrap `core` admin user + a token at
|
/// Ensure the bootstrap `core` admin user + a token at
|
||||||
/// `CORE_TOKEN_PATH`. The token is what hive-c0re uses for forgejo
|
/// `CORE_TOKEN_PATH`. The token is what hive-c0re uses for forgejo
|
||||||
/// API calls (org creation now, meta-repo push later). Returns the
|
/// API calls (org creation now, meta-repo push later). Returns the
|
||||||
|
|
@ -591,6 +684,9 @@ pub async fn ensure_all() {
|
||||||
if let Err(e) = ensure_repo("meta", token).await {
|
if let Err(e) = ensure_repo("meta", token).await {
|
||||||
tracing::warn!(error = ?e, "forge: ensure_repo core/meta failed");
|
tracing::warn!(error = ?e, "forge: ensure_repo core/meta failed");
|
||||||
}
|
}
|
||||||
|
if let Err(e) = ensure_core_avatar(token).await {
|
||||||
|
tracing::warn!(error = ?e, "forge: ensure_core_avatar failed");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let Ok(containers) = crate::lifecycle::list().await else {
|
let Ok(containers) = crate::lifecycle::list().await else {
|
||||||
tracing::warn!("forge: nixos-container list failed; skipping user sweep");
|
tracing::warn!("forge: nixos-container list failed; skipping user sweep");
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue