use cyberbrain_core::{Error, Result};
use cyberbrain_policy::bundle;
use std::path::PathBuf;
pub mod access;
pub mod admin;
pub mod api;
pub mod attempts;
pub mod client;
pub mod licence;
pub mod page;
pub mod report;
pub mod service;
pub mod store;
pub mod sync_access;
pub mod tls;
#[cfg(test)]
mod tests;
#[cfg_attr(not(test), allow(unused_imports))]
pub use store::{Device, HubStore};
pub const DEFAULT_DATA: &str = "cyberbrain-hub.db";
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub struct Accepted {
pub device: String,
pub accepted: usize,
pub next_anchor: String,
pub total_rows: i64,
}
#[derive(Debug, Clone, PartialEq)]
pub enum LicenceState {
Missing,
Invalid(String),
Valid {
customer: String,
seats: usize,
valid_until: String,
warning: Option<String>,
},
Expired {
customer: String,
valid_until: String,
},
}
impl LicenceState {
pub fn read(hub: &HubStore, now: jiff::Timestamp) -> Self {
let Ok(Some(text)) = hub.licence_text() else {
return LicenceState::Missing;
};
match licence::parse(&text) {
Err(e) => LicenceState::Invalid(e.to_string()),
Ok(l) if l.not_yet_valid(now) => LicenceState::Invalid(format!(
"the licence for {} does not start until {}",
l.licence().customer,
l.licence().valid_from
)),
Ok(l) if l.expired(now) => LicenceState::Expired {
customer: l.licence().customer.clone(),
valid_until: l.licence().valid_until.clone(),
},
Ok(l) => LicenceState::Valid {
customer: l.licence().customer.clone(),
seats: l.licence().seats,
valid_until: l.licence().valid_until.clone(),
warning: l.warning(now),
},
}
}
pub fn may_collect(&self) -> bool {
matches!(self, LicenceState::Valid { .. })
}
pub fn seats(&self) -> Option<usize> {
match self {
LicenceState::Valid { seats, .. } => Some(*seats),
_ => None,
}
}
pub fn line(&self) -> String {
match self {
LicenceState::Missing => concat!(
"no licence installed: the hub will not accept rows. ",
"Install one with `cyberbrain hub licence install <file>`."
)
.to_string(),
LicenceState::Invalid(why) => format!("licence not usable: {why}"),
LicenceState::Expired {
customer,
valid_until,
} => format!(
concat!(
"licence for {} ended on {}. New rows are not accepted; the record ",
"stays readable and exportable, and clients keep buffering."
),
customer, valid_until
),
LicenceState::Valid {
customer,
seats,
valid_until,
warning,
} => match warning {
Some(w) => format!("⚠ {w}"),
None => format!("licence: {customer}, {seats} seat(s), until {valid_until}"),
},
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum Refusal {
NotAuthorised(String),
BadBundle(String),
WrongAnchor { expected: String, got: String },
NotCollecting(String),
}
impl std::fmt::Display for Refusal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Refusal::NotAuthorised(m) => write!(f, "{m}"),
Refusal::BadBundle(m) => write!(f, "{m}"),
Refusal::WrongAnchor { expected, got } => write!(
f,
"this device's chain is at {expected}, the delivery starts at {got}: \
something is missing between them, or this was already delivered"
),
Refusal::NotCollecting(m) => write!(f, "{m}"),
}
}
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct WireNote {
pub id: String,
pub name: String,
pub ring: u8,
pub kind: String,
pub bereich: Option<String>,
pub updated: String,
pub frontmatter: String,
pub body: String,
#[serde(default)]
pub based_on: Option<String>,
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct NoteDelivery {
pub notes: Vec<WireNote>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct NotesAccepted {
pub accepted: usize,
pub stored: usize,
pub refused: Vec<RefusedNote>,
pub conflicts: Vec<ConflictedNote>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ConflictedNote {
pub name: String,
pub conflict: String,
pub held_updated: String,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct RefusedNote {
pub name: String,
pub why: String,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Fetched {
pub notes: Vec<store::SyncedNote>,
pub erased: Vec<ErasedElsewhere>,
pub cursor: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ErasedElsewhere {
pub bereich: String,
pub name: String,
pub erased_at: String,
}
pub fn fetch_notes(
hub: &store::HubStore,
token: Option<&str>,
since: Option<&str>,
) -> std::result::Result<Fetched, Refusal> {
let token = token.ok_or_else(|| {
Refusal::NotAuthorised("no device token; send it as `Authorization: Bearer …`".into())
})?;
let device = hub
.device_by_token(token)
.map_err(|e| Refusal::NotAuthorised(format!("cannot check the token: {e}")))?
.ok_or_else(|| Refusal::NotAuthorised("unknown device token".into()))?;
if !device.is_active() {
return Err(Refusal::NotAuthorised(format!(
"device {} was revoked",
device.name
)));
}
let notes = hub
.notes_for_device(&device.id, since)
.map_err(|e| Refusal::BadBundle(format!("cannot read notes: {e}")))?;
let erased: Vec<ErasedElsewhere> = hub
.erasures_for_device(&device.id, since)
.map_err(|e| Refusal::BadBundle(format!("cannot read erasures: {e}")))?
.into_iter()
.map(|(bereich, name, erased_at)| ErasedElsewhere {
bereich,
name,
erased_at,
})
.collect();
let cursor = notes
.iter()
.map(|n| n.updated.clone())
.chain(erased.iter().map(|e| e.erased_at.clone()))
.max();
Ok(Fetched {
notes,
erased,
cursor,
})
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct EraseRequest {
pub bereich: String,
pub name: String,
}
pub fn erase_note(
hub: &mut store::HubStore,
token: Option<&str>,
body: &str,
now: &str,
) -> std::result::Result<store::ErasureCount, Refusal> {
let token = token.ok_or_else(|| {
Refusal::NotAuthorised("no device token; send it as `Authorization: Bearer …`".into())
})?;
let device = hub
.device_by_token(token)
.map_err(|e| Refusal::NotAuthorised(format!("cannot check the token: {e}")))?
.ok_or_else(|| Refusal::NotAuthorised("unknown device token".into()))?;
if !device.is_active() {
return Err(Refusal::NotAuthorised(format!(
"device {} was revoked",
device.name
)));
}
let req: EraseRequest = serde_json::from_str(body)
.map_err(|e| Refusal::BadBundle(format!("not an erase request: {e}")))?;
let grants = hub
.grants_for_device(&device.id)
.map_err(|e| Refusal::BadBundle(format!("cannot read this device's grants: {e}")))?;
let allowed = grants
.iter()
.any(|g| g.is_effective() && g.bereich == req.bereich);
if !allowed {
return Err(Refusal::NotAuthorised(format!(
"device {} has no grant in bereich {}",
device.name, req.bereich
)));
}
let count = hub
.erase_note(&req.bereich, &req.name, &device.id, now)
.map_err(|e| Refusal::BadBundle(format!("erasure failed: {e}")))?;
let _ = hub.record(
&device.id,
"notes.erased",
serde_json::json!({
"bereich": req.bereich,
"name": req.name,
"notes_removed": count.notes,
"conflict_rows_removed": count.conflicts,
}),
now,
);
Ok(count)
}
pub fn ingest_notes(
hub: &mut store::HubStore,
licence: &LicenceState,
token: Option<&str>,
body: &str,
now: &str,
) -> std::result::Result<NotesAccepted, Refusal> {
if !licence.may_collect() {
return Err(Refusal::NotCollecting(format!(
"{} Keep buffering: nothing is lost, and a renewed licence takes what you held.",
licence.line()
)));
}
let token = token.ok_or_else(|| {
Refusal::NotAuthorised("no device token; send it as `Authorization: Bearer …`".into())
})?;
let device = hub
.device_by_token(token)
.map_err(|e| Refusal::NotAuthorised(format!("cannot check the token: {e}")))?
.ok_or_else(|| Refusal::NotAuthorised("unknown device token".into()))?;
if !device.is_active() {
return Err(Refusal::NotAuthorised(format!(
"device {} was revoked",
device.name
)));
}
let delivery: NoteDelivery = serde_json::from_str(body)
.map_err(|e| Refusal::BadBundle(format!("delivery is not a note batch: {e}")))?;
let grants = hub
.grants_for_device(&device.id)
.map_err(|e| Refusal::BadBundle(format!("cannot read this device's grants: {e}")))?;
let mut out = NotesAccepted {
accepted: 0,
stored: 0,
refused: Vec::new(),
conflicts: Vec::new(),
};
for n in delivery.notes {
let ring = match cyberbrain_core::Ring::try_from(n.ring) {
Ok(r) => r,
Err(e) => {
out.refused.push(RefusedNote {
name: n.name,
why: format!("ring {}: {e}", n.ring),
});
continue;
}
};
if let Err(denied) = sync_access::may_move(
&device.id,
ring,
n.bereich.as_deref(),
sync_access::Direction::Send,
&grants,
) {
out.refused.push(RefusedNote {
name: n.name,
why: denied.line(),
});
continue;
}
let Some(bereich) = n.bereich.as_deref() else {
out.refused.push(RefusedNote {
name: n.name,
why: "no bereich".into(),
});
continue;
};
match hub.erased_at(bereich, &n.name) {
Ok(Some(when)) => {
out.refused.push(RefusedNote {
name: n.name,
why: format!(
"erased at {when}; delete your copy rather than re-offering it \
(GDPR Art. 17)"
),
});
continue;
}
Ok(None) => {}
Err(e) => {
out.refused.push(RefusedNote {
name: n.name,
why: format!("cannot check whether it was erased: {e}"),
});
continue;
}
}
match hub.offer_synced_note(
&n.id,
bereich,
&n.name,
n.ring,
&n.kind,
&n.updated,
&n.frontmatter,
&n.body,
n.based_on.as_deref(),
&device.id,
now,
) {
Ok(store::NoteOutcome::Stored) => {
out.accepted += 1;
out.stored += 1;
}
Ok(store::NoteOutcome::Unchanged) => out.accepted += 1,
Ok(store::NoteOutcome::Conflict { id, held_updated }) => {
out.accepted += 1;
out.conflicts.push(ConflictedNote {
name: n.name,
conflict: id,
held_updated,
});
}
Err(e) => out.refused.push(RefusedNote {
name: n.name,
why: format!("could not be stored: {e}"),
}),
}
}
let _ = hub.record(
&device.id,
"notes.ingested",
serde_json::json!({
"device": device.name,
"accepted": out.accepted,
"stored": out.stored,
"refused": out.refused.len(),
"conflicts": out.conflicts.len(),
}),
now,
);
Ok(out)
}
pub fn ingest(
hub: &mut HubStore,
licence: &LicenceState,
token: Option<&str>,
body: &str,
version: Option<&str>,
now: &str,
) -> std::result::Result<Accepted, Refusal> {
if !licence.may_collect() {
return Err(Refusal::NotCollecting(format!(
"{} Keep buffering: nothing is lost, and a renewed licence takes what you held.",
licence.line()
)));
}
let token = token.ok_or_else(|| {
Refusal::NotAuthorised("no device token; send it as `Authorization: Bearer …`".into())
})?;
let device = hub
.device_by_token(token)
.map_err(|e| Refusal::NotAuthorised(format!("cannot check the token: {e}")))?
.ok_or_else(|| Refusal::NotAuthorised("unknown device token".into()))?;
if !device.is_active() {
return Err(Refusal::NotAuthorised(format!(
"device {} was revoked; its record is kept, but it may not send",
device.id
)));
}
let note = |r: Refusal| -> Refusal {
let _ = hub.note_refusal(&device.id, &r.to_string(), now);
r
};
let (report, rows) =
bundle::verify_rows(body).map_err(|e| note(Refusal::BadBundle(e.to_string())))?;
let fresh: &[cyberbrain_policy::AuditEvent] = if report.anchor == device.anchor {
&rows
} else if let Some(i) = rows
.iter()
.position(|r| r.chain_hash() == Some(device.anchor.as_str()))
{
&rows[i + 1..]
} else {
return Err(note(Refusal::WrongAnchor {
expected: device.anchor.clone(),
got: report.anchor,
}));
};
let new_anchor = fresh
.last()
.and_then(|r| r.chain_hash())
.unwrap_or(&device.anchor)
.to_string();
let total = hub
.append(&device, fresh, &new_anchor, version, now)
.map_err(|e| Refusal::BadBundle(format!("could not store the delivery: {e}")))?;
Ok(Accepted {
device: device.id,
accepted: fresh.len(),
next_anchor: new_anchor,
total_rows: total,
})
}
const PIN_SETTING: &str = "tls_cert_sha256";
pub fn remember_pin(hub: &HubStore, pin: Option<&str>) -> Result<()> {
match pin {
Some(p) => hub.set_setting(PIN_SETTING, p),
None => hub.clear_setting(PIN_SETTING),
}
}
pub fn pin_to_offer(hub: &HubStore) -> Option<String> {
hub.setting(PIN_SETTING)
.ok()
.flatten()
.filter(|p| !p.is_empty())
}
pub fn normalise_machine(raw: &str) -> Option<String> {
let m = raw.trim().to_lowercase();
(!m.is_empty()
&& m.chars().count() <= 253
&& !m.chars().any(|c| c.is_control() || c.is_whitespace()))
.then_some(m)
}
pub fn data_path(explicit: Option<PathBuf>) -> PathBuf {
explicit.unwrap_or_else(|| PathBuf::from(DEFAULT_DATA))
}
pub fn parse_addr(s: &str) -> Result<std::net::SocketAddr> {
s.parse()
.map_err(|e| Error::Config(format!("--addr {s:?} is not an address:port pair: {e}")))
}