use std::path::Path;
use solid_pod_rs_activitypub::{generate_actor_keypair, render_actor, with_also_known_as};
#[derive(Debug, Clone)]
pub struct ApConfig {
pub username: String,
pub display_name: String,
pub summary: String,
pub nostr_pubkey: Option<String>,
pub public_key_pem: String,
}
fn valid_username(s: &str) -> bool {
!s.is_empty()
&& s.len() <= 64
&& s.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-'))
}
impl ApConfig {
pub fn from_knobs(
username: Option<&str>,
display_name: Option<&str>,
summary: Option<&str>,
nostr_pubkey: Option<&str>,
public_key_pem: String,
) -> Result<Self, String> {
fn non_empty(o: Option<&str>) -> Option<&str> {
o.map(str::trim).filter(|s| !s.is_empty())
}
let given_username = non_empty(username);
if let Some(u) = given_username {
if !valid_username(u) {
return Err(format!("invalid ActivityPub username {u:?}"));
}
}
let nostr_pubkey = match non_empty(nostr_pubkey) {
Some(pk) => {
let pk = pk.to_ascii_lowercase();
if !crate::cross_identity::is_hex_pubkey(&pk) {
return Err("ActivityPub nostr pubkey must be 64 hex characters".into());
}
Some(pk)
}
None => None,
};
Ok(Self {
username: given_username.unwrap_or("me").to_string(),
display_name: non_empty(display_name)
.or(given_username)
.unwrap_or("Anonymous")
.to_string(),
summary: non_empty(summary).unwrap_or("").to_string(),
nostr_pubkey,
public_key_pem,
})
}
pub fn actor_json(&self, base_url: &str) -> serde_json::Value {
let summary = if self.summary.is_empty() {
String::new()
} else {
format!("<p>{}</p>", html_escape(&self.summary))
};
let mut actor = render_actor(
base_url,
&self.username,
&self.display_name,
Some(&summary),
&self.public_key_pem,
);
if let Some(pk) = &self.nostr_pubkey {
actor = with_also_known_as(actor, [format!("did:nostr:{pk}")]);
}
serde_json::to_value(actor).unwrap_or(serde_json::Value::Null)
}
}
impl crate::ActorDocument for ApConfig {
fn actor_json(&self, base_url: &str) -> serde_json::Value {
ApConfig::actor_json(self, base_url)
}
}
pub fn html_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(c),
}
}
out
}
pub fn load_or_create_public_key(data_root: Option<&Path>) -> Result<String, String> {
let file = data_root.map(|r| r.join(".ap").join("keys.json"));
let Some(f) = &file else {
return generate_actor_keypair()
.map(|(_, public)| public)
.map_err(|e| e.to_string());
};
for _ in 0..4 {
if let Some(public) = read_public_key(f)? {
return Ok(public);
}
let (private_pem, public_pem) = generate_actor_keypair().map_err(|e| e.to_string())?;
if let Some(dir) = f.parent() {
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
}
let body = serde_json::json!({"publicKey": public_pem, "privateKey": private_pem});
match publish_private(f, body.to_string().as_bytes()) {
Ok(()) => return Ok(public_pem),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(e) => return Err(format!("{}: {e}", f.display())),
}
}
Err(format!("{}: could not settle on a key file", f.display()))
}
fn read_public_key(f: &Path) -> Result<Option<String>, String> {
let text = match std::fs::read_to_string(f) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(format!("{}: {e}", f.display())),
};
if text.trim().is_empty() {
let _ = std::fs::remove_file(f);
return Ok(None);
}
let v: serde_json::Value =
serde_json::from_str(&text).map_err(|e| format!("{}: {e}", f.display()))?;
v.get("publicKey")
.and_then(|p| p.as_str())
.map(|p| Some(p.to_string()))
.ok_or_else(|| format!("{}: missing publicKey", f.display()))
}
fn publish_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
use std::io::Write as _;
use std::sync::atomic::{AtomicU64, Ordering};
static SEQ: AtomicU64 = AtomicU64::new(0);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
let tmp = path.with_extension(format!(
"{}.{}.{nanos}.tmp",
std::process::id(),
SEQ.fetch_add(1, Ordering::Relaxed)
));
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
opts.mode(0o600);
}
let written = opts.open(&tmp).and_then(|mut f| {
f.write_all(bytes)?;
f.sync_all()
});
let linked = written.and_then(|()| std::fs::hard_link(&tmp, path));
let _ = std::fs::remove_file(&tmp);
linked
}
#[cfg(test)]
mod tests {
use super::*;
const PK: &str = "abcd000000000000000000000000000000000000000000000000000000000001";
#[test]
fn jss_defaults() {
let c = ApConfig::from_knobs(None, None, None, None, "PEM".into()).unwrap();
assert_eq!(c.username, "me");
assert_eq!(c.display_name, "Anonymous");
let j = c.actor_json("https://pod.example");
assert_eq!(j["preferredUsername"], "me");
assert_eq!(j["name"], "Anonymous");
assert_eq!(j["summary"], "");
assert!(j.get("alsoKnownAs").is_none());
assert_eq!(j["id"], "https://pod.example/profile/card.jsonld#me");
}
#[test]
fn display_name_falls_back_to_given_username() {
let c = ApConfig::from_knobs(Some("alice"), None, None, None, "P".into()).unwrap();
assert_eq!(c.display_name, "alice");
}
#[test]
fn nostr_pubkey_links_also_known_as_and_summary_is_escaped() {
let c = ApConfig::from_knobs(
Some("alice"),
Some("Alice"),
Some("hi <script>x</script> & co"),
Some(&PK.to_uppercase()),
"P".into(),
)
.unwrap();
let j = c.actor_json("https://pod.example");
assert_eq!(j["alsoKnownAs"][0], format!("did:nostr:{PK}"));
assert_eq!(
j["summary"],
"<p>hi <script>x</script> & co</p>"
);
}
#[test]
fn bad_knobs_are_rejected() {
assert!(ApConfig::from_knobs(Some("a b"), None, None, None, "P".into()).is_err());
assert!(ApConfig::from_knobs(Some("a/../b"), None, None, None, "P".into()).is_err());
assert!(ApConfig::from_knobs(None, None, None, Some("nothex"), "P".into()).is_err());
}
#[test]
fn keypair_persists_across_loads() {
let dir = tempfile::tempdir().unwrap();
let a = load_or_create_public_key(Some(dir.path())).unwrap();
let b = load_or_create_public_key(Some(dir.path())).unwrap();
assert_eq!(a, b);
assert!(a.contains("BEGIN PUBLIC KEY"));
}
#[test]
fn empty_key_file_is_regenerated() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".ap")).unwrap();
std::fs::write(dir.path().join(".ap/keys.json"), b"").unwrap();
let pem = load_or_create_public_key(Some(dir.path())).unwrap();
assert!(pem.contains("PUBLIC KEY"));
assert_eq!(load_or_create_public_key(Some(dir.path())).unwrap(), pem);
}
#[test]
fn racing_creators_agree_on_one_key() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().to_path_buf();
let handles: Vec<_> = (0..6)
.map(|_| {
let r = root.clone();
std::thread::spawn(move || load_or_create_public_key(Some(&r)).unwrap())
})
.collect();
let keys: Vec<String> = handles.into_iter().map(|h| h.join().unwrap()).collect();
assert!(keys.windows(2).all(|w| w[0] == w[1]), "one key for all");
let names: Vec<_> = std::fs::read_dir(dir.path().join(".ap"))
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
assert_eq!(names, vec!["keys.json".to_string()], "no temp files left");
}
}