use super::error::{ApiError, ApiResult};
use super::extract::{ApiJson, ApiPath, ApiQuery};
use super::wire::{
BlockRef, DanglingLink, DryRun, ForgetNote, ForgetRemoved, ForgetReport, Graph, GraphEdge,
GraphNode, InboundFrom, InboundLink, NoteDetail, NoteListParams, NoteSummary, OutboundLink,
PiiFinding, PiiHoldResolution, ResolvedTarget,
};
use super::{ServeState, blocking};
use crate::app::{App, NoteView, WriteOutcome, WriteRequest};
use axum::Json;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use cyberbrain_core::blocks::{MAX_BLOCK_TOKENS, blocks_of};
use cyberbrain_core::links::link_targets;
use cyberbrain_core::{Block, Error, Frontmatter, Note, NoteKind, Ring};
use cyberbrain_policy::{ErasureReport, Finding, OperatorChoice, PiiKind};
use serde_json::{Value, json};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
pub struct Loaded {
pub note: Note,
pub rel_path: String,
pub size: u64,
pub blocks: Vec<Block>,
pub targets: Vec<String>,
}
pub struct Tree {
pub notes: Vec<Loaded>,
pub unreadable: Vec<(PathBuf, String)>,
by_name: HashMap<String, usize>,
inbound: HashMap<String, Vec<usize>>,
}
impl Tree {
pub fn load(app: &App) -> ApiResult<Tree> {
let store = app.store();
let root = app.root();
let listing = store.list()?;
let mut notes = Vec::with_capacity(listing.entries.len());
let mut unreadable = Vec::new();
for e in &listing.entries {
match store.read_path(&e.path) {
Ok(note) => notes.push(load_one(root, note)),
Err(err) => unreadable.push((e.path.clone(), err.to_string())),
}
}
let mut by_name = HashMap::new();
for (i, n) in notes.iter().enumerate() {
by_name.insert(n.note.front.name.clone(), i);
}
let mut inbound: HashMap<String, Vec<usize>> = HashMap::new();
for (i, n) in notes.iter().enumerate() {
for t in &n.targets {
inbound.entry(t.clone()).or_default().push(i);
}
}
Ok(Tree {
notes,
unreadable,
by_name,
inbound,
})
}
pub fn by_name(&self, name: &str) -> Option<&Loaded> {
self.by_name.get(name).map(|i| &self.notes[*i])
}
pub fn inbound_of(&self, name: &str) -> Vec<&Loaded> {
self.inbound
.get(name)
.map(|v| v.iter().map(|i| &self.notes[*i]).collect())
.unwrap_or_default()
}
pub fn total_blocks(&self) -> usize {
self.notes.iter().map(|n| n.blocks.len()).sum()
}
}
pub fn load_one(root: &Path, note: Note) -> Loaded {
let size = std::fs::metadata(¬e.path).map(|m| m.len()).unwrap_or(0);
let (blocks, _) = blocks_of(¬e, MAX_BLOCK_TOKENS);
Loaded {
rel_path: rel_path(root, ¬e.path),
size,
blocks,
targets: link_targets(¬e.body),
note,
}
}
pub fn rel_path(root: &Path, p: &Path) -> String {
let rel = p.strip_prefix(root).unwrap_or(p);
rel.components()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join("/")
}
fn preview(text: &str) -> String {
let first = text.lines().next().unwrap_or_default();
let first = first.trim_start_matches('#').trim();
first.chars().take(80).collect()
}
pub fn block_refs(blocks: &[Block]) -> Vec<BlockRef> {
blocks
.iter()
.map(|b| BlockRef {
citation: b.citation.to_string(),
idx: b.idx,
token_count: b.token_count,
preview: preview(&b.text),
})
.collect()
}
pub fn detail_in_tree(tree: &Tree, n: &Loaded, dry_run: bool) -> NoteDetail {
let outbound = n
.targets
.iter()
.map(|t| OutboundLink {
target: t.clone(),
resolved: tree.by_name(t).map(|r| ResolvedTarget {
id: r.note.front.id,
ring: r.note.front.ring,
}),
})
.collect();
let inbound = tree
.inbound_of(&n.note.front.name)
.into_iter()
.map(|m| InboundLink {
from: InboundFrom {
id: m.note.front.id,
name: m.note.front.name.clone(),
ring: m.note.front.ring,
},
})
.collect();
NoteDetail {
front: n.note.front.clone(),
body: n.note.body.clone(),
path: n.rel_path.clone(),
outbound,
inbound,
blocks: block_refs(&n.blocks),
dry_run,
}
}
pub fn detail_of(app: &App, note: Note, dry_run: bool) -> ApiResult<NoteDetail> {
let tree = Tree::load(app)?;
let loaded = load_one(app.root(), note);
Ok(detail_in_tree(&tree, &loaded, dry_run))
}
pub fn note_from_view(v: NoteView) -> Note {
Note {
front: v.front,
body: v.body,
path: v.path,
}
}
fn summary(tree: &Tree, n: &Loaded) -> NoteSummary {
let f = &n.note.front;
NoteSummary {
id: f.id,
name: f.name.clone(),
ring: f.ring,
kind: f.kind,
tags: f.tags.clone(),
updated: f.updated,
created: f.created,
retention: f.retention.clone(),
pii: f.pii,
blocks: n.blocks.len(),
bytes: n.size,
links_out: n.targets.len(),
links_in: tree.inbound_of(&f.name).len(),
dangling: n
.targets
.iter()
.filter(|t| tree.by_name(t).is_none())
.count(),
}
}
pub fn finding_kind(k: PiiKind) -> &'static str {
match k {
PiiKind::Email => "email",
PiiKind::Ipv4 | PiiKind::Ipv6 => "ip",
PiiKind::ApiKey => "api-key",
PiiKind::Iban => "iban",
PiiKind::Phone => "phone",
}
}
pub fn mask(kind: PiiKind, matched: &str) -> String {
let chars: Vec<char> = matched.chars().collect();
let head = |n: usize| chars.iter().take(n).collect::<String>();
let tail = |n: usize| {
chars
.iter()
.skip(chars.len().saturating_sub(n))
.collect::<String>()
};
match kind {
PiiKind::Email => match matched.split_once('@') {
Some((_, domain)) => format!("{}***@{domain}", head(1)),
None => format!("{}…", head(1)),
},
PiiKind::Ipv4 => {
let parts: Vec<&str> = matched.split('.').collect();
if parts.len() == 4 {
format!("{}.{}.*.*", parts[0], parts[1])
} else {
format!("{}…", head(4))
}
}
PiiKind::Ipv6 => format!("{}…", head(6)),
PiiKind::ApiKey => format!("{}…", head(6)),
PiiKind::Iban => format!("{} **** ****", head(4)),
PiiKind::Phone => format!("{}…{}", head(4), tail(2)),
}
}
pub fn render_findings(body: &str, findings: &[Finding]) -> Vec<PiiFinding> {
findings
.iter()
.map(|f| {
let start = f.start.min(body.len());
let before = &body[..start];
let line = before.matches('\n').count() + 1;
let col = before.rsplit('\n').next().unwrap_or("").chars().count() + 1;
PiiFinding {
kind: finding_kind(f.kind),
excerpt: mask(f.kind, &f.matched),
line,
col,
}
})
.collect()
}
pub async fn list_notes(
State(st): State<Arc<ServeState>>,
ApiQuery(p): ApiQuery<NoteListParams>,
) -> ApiResult<Json<Vec<NoteSummary>>> {
let ring = p.ring.map(Ring::try_from).transpose()?;
let sort = match p.sort.as_deref() {
None | Some("updated") => "updated",
Some("name") => "name",
Some("ring") => "ring",
Some(other) => {
return Err(ApiError::bad_request(format!(
"sort {other:?} is not one of updated, name, ring"
)));
}
};
let q = p.q.map(|q| q.to_lowercase()).filter(|q| !q.is_empty());
let kind = p.kind;
blocking(move || {
let tree = Tree::load(&st.app)?;
let mut out: Vec<NoteSummary> = tree
.notes
.iter()
.filter(|n| ring.is_none_or(|r| n.note.front.ring == r))
.filter(|n| kind.is_none_or(|k| n.note.front.kind == k))
.filter(|n| {
q.as_deref().is_none_or(|q| {
n.note.front.name.to_lowercase().contains(q)
|| n.note
.front
.tags
.iter()
.any(|t| t.to_lowercase().contains(q))
})
})
.map(|n| summary(&tree, n))
.collect();
match sort {
"name" => out.sort_by(|a, b| a.name.cmp(&b.name)),
"ring" => out.sort_by(|a, b| a.ring.cmp(&b.ring).then(a.name.cmp(&b.name))),
_ => out.sort_by(|a, b| b.updated.cmp(&a.updated).then(a.name.cmp(&b.name))),
}
Ok(Json(out))
})
.await
}
pub async fn get_note(
State(st): State<Arc<ServeState>>,
ApiPath(target): ApiPath<String>,
) -> ApiResult<Json<NoteDetail>> {
blocking(move || {
let view = st.app.export(&target)?;
Ok(Json(detail_of(&st.app, note_from_view(view), false)?))
})
.await
}
pub async fn graph(State(st): State<Arc<ServeState>>) -> ApiResult<Json<Graph>> {
blocking(move || {
let tree = Tree::load(&st.app)?;
let mut g = Graph {
nodes: Vec::new(),
edges: Vec::new(),
dangling: Vec::new(),
};
for n in &tree.notes {
let f = &n.note.front;
g.nodes.push(GraphNode {
id: f.id,
name: f.name.clone(),
ring: f.ring,
kind: f.kind,
links_in: tree.inbound_of(&f.name).len(),
links_out: n.targets.len(),
});
for t in &n.targets {
match tree.by_name(t) {
Some(to) => g.edges.push(GraphEdge {
from: f.id,
to: to.note.front.id,
}),
None => g.dangling.push(DanglingLink {
from: f.id,
to_name: t.clone(),
}),
}
}
}
Ok(Json(g))
})
.await
}
#[derive(Debug, Default)]
struct FrontEdits {
name: Option<String>,
ring: Option<Ring>,
kind: Option<NoteKind>,
tags: Option<Vec<String>>,
retention: Option<Option<String>>,
}
struct ParsedWrite {
body: String,
front: FrontEdits,
expected_updated: Option<jiff::Timestamp>,
}
fn bad_front(msg: impl Into<String>) -> ApiError {
ApiError::new(StatusCode::BAD_REQUEST, "bad-frontmatter", msg)
}
fn parse_write(v: &Value) -> ApiResult<ParsedWrite> {
let obj = v
.as_object()
.ok_or_else(|| ApiError::bad_request("request body must be a JSON object"))?;
let body = obj
.get("body")
.and_then(Value::as_str)
.ok_or_else(|| ApiError::bad_request("`body` must be a string"))?
.to_string();
let expected_updated = match obj.get("expected_updated") {
None | Some(Value::Null) => None,
Some(Value::String(s)) => Some(s.parse::<jiff::Timestamp>().map_err(|e| {
ApiError::bad_request(format!(
"`expected_updated` is not an RFC 3339 timestamp: {e}"
))
})?),
Some(_) => return Err(ApiError::bad_request("`expected_updated` must be a string")),
};
let mut front = FrontEdits::default();
match obj.get("front") {
None | Some(Value::Null) => {}
Some(Value::Object(f)) => {
for (k, val) in f {
match k.as_str() {
"id" | "created" | "links" | "updated" | "pii" => {
return Err(bad_front(format!(
"`{k}` is server-owned and cannot be set through a write"
)));
}
"name" => {
front.name = Some(
val.as_str()
.ok_or_else(|| bad_front("`front.name` must be a string"))?
.to_string(),
)
}
"ring" => {
let n = val
.as_u64()
.ok_or_else(|| bad_front("`front.ring` must be an integer 0..4"))?;
front.ring = Some(Ring::try_from(u8::try_from(n).unwrap_or(u8::MAX))?);
}
"kind" => {
front.kind = Some(serde_json::from_value(val.clone()).map_err(|_| {
bad_front(
"`front.kind` must be one of knowledge, bug, lesson, decision, reference, session",
)
})?)
}
"tags" => {
front.tags = Some(serde_json::from_value(val.clone()).map_err(|_| {
bad_front("`front.tags` must be an array of strings")
})?)
}
"retention" => {
front.retention = Some(match val {
Value::Null => None,
Value::String(s) if s.trim().is_empty() => None,
Value::String(s) => Some(s.clone()),
_ => return Err(bad_front("`front.retention` must be a string or null")),
})
}
other => {
return Err(bad_front(format!(
"`front.{other}` is not a frontmatter field a write may set"
)));
}
}
}
}
Some(_) => return Err(bad_front("`front` must be an object")),
}
Ok(ParsedWrite {
body,
front,
expected_updated,
})
}
fn write_error(app: &App, e: Error) -> ApiError {
if let Error::RingCapExceeded { actual, cap, .. } = &e {
let used = app.store().resident_tokens().unwrap_or(*actual);
return ApiError::from(Error::RingCapExceeded {
ring: 0,
actual: *actual,
cap: *cap,
})
.with(
"cap",
json!({ "tokens": cap, "used": used, "would_be": actual }),
)
.with("message", e.to_string());
}
ApiError::from(e)
}
fn run_write(
st: &ServeState,
req: WriteRequest,
created_new: bool,
existing_created: Option<jiff::Timestamp>,
held_findings: Option<&[Finding]>,
) -> ApiResult<Response> {
let dry_run = req.dry_run;
let outcome = st
.app
.write(req.clone())
.map_err(|e| write_error(&st.app, e))?;
match outcome {
WriteOutcome::Written(w) => {
let detail = if dry_run {
let body = match (req.choice, held_findings) {
(Some(choice), Some(f)) => {
cyberbrain_policy::write_gate::resolve_hold(&req.body, f, choice).body
}
_ => req.body.clone(),
};
let note = Note {
front: Frontmatter {
id: w.id,
name: w.name.clone(),
ring: w.ring,
kind: req.kind,
created: existing_created.unwrap_or(w.updated),
updated: w.updated,
tags: req.tags.clone(),
links: link_targets(&body),
retention: req.retention.clone(),
pii: w.pii,
},
body,
path: w.path.clone(),
};
detail_of(&st.app, note, true)?
} else {
let view = st.app.export(&w.name)?;
detail_of(&st.app, note_from_view(view), false)?
};
let status = if created_new && !dry_run {
StatusCode::CREATED
} else {
StatusCode::OK
};
Ok((status, Json(detail)).into_response())
}
WriteOutcome::Held {
name, findings, ..
} => {
let rendered = render_findings(&req.body, &findings);
let count = findings.len();
let hold = st.holds.insert(req, findings, created_new, rendered);
Err(ApiError::conflict(
"pii-held",
format!(
"write of `{name}` held: {count} possible personal data item{} found (profile {}); redact, mark reviewed, proceed flagged, or discard",
if count == 1 { "" } else { "s" },
st.app.policy().profile().as_str()
),
3,
)
.with("hold", hold))
}
WriteOutcome::Conflict {
name,
current_updated,
} => Err(ApiError::conflict(
"write-conflict",
format!(
"note `{name}` changed at {current_updated} since you loaded it; reload and reapply your edit"
),
1,
)
.with("current_updated", current_updated)),
}
}
pub async fn put_note(
State(st): State<Arc<ServeState>>,
ApiPath(target): ApiPath<String>,
ApiQuery(dry): ApiQuery<DryRun>,
ApiJson(body): ApiJson<Value>,
) -> ApiResult<Response> {
let parsed = parse_write(&body)?;
blocking(move || {
let existing = st.app.export(&target)?;
let cur = &existing.front;
if parsed.expected_updated.is_none() {
return Err(ApiError::bad_request(
"`expected_updated` is required when updating an existing note: send the \
`updated` value you read, so a concurrent write is refused instead of \
silently discarded",
));
}
if let Some(n) = &parsed.front.name
&& n != &cur.name
{
return Err(ApiError::bad_request(format!(
"renaming `{}` to `{n}` is not supported through the API yet: a write under a new name would create a second note with a new id rather than move this one",
cur.name
)));
}
let req = WriteRequest {
ring: parsed.front.ring.unwrap_or(cur.ring),
kind: parsed.front.kind.unwrap_or(cur.kind),
name: cur.name.clone(),
body: parsed.body,
tags: parsed.front.tags.unwrap_or_else(|| cur.tags.clone()),
retention: match parsed.front.retention {
Some(r) => r,
None => cur.retention.clone(),
},
force: false,
choice: None,
expected_updated: parsed.expected_updated,
dry_run: dry.is_on(),
};
run_write(&st, req, false, Some(cur.created), None)
})
.await
}
pub async fn post_note(
State(st): State<Arc<ServeState>>,
ApiQuery(dry): ApiQuery<DryRun>,
ApiJson(body): ApiJson<Value>,
) -> ApiResult<Response> {
let parsed = parse_write(&body)?;
blocking(move || {
let name = parsed
.front
.name
.clone()
.ok_or_else(|| bad_front("`front.name` is required to create a note"))?;
let ring = parsed
.front
.ring
.ok_or_else(|| bad_front("`front.ring` is required to create a note"))?;
let kind = parsed
.front
.kind
.ok_or_else(|| bad_front("`front.kind` is required to create a note"))?;
match st.app.store().read(&name) {
Ok(n) => {
return Err(ApiError::conflict(
"write-conflict",
format!(
"note `{name}` already exists (ring {}); edit it with PUT /api/v1/notes/{name}",
n.front.ring
),
1,
)
.with("current_updated", n.front.updated));
}
Err(Error::NoSuchNote(_)) => {}
Err(e) => return Err(e.into()),
}
let req = WriteRequest {
ring,
kind,
name,
body: parsed.body,
tags: parsed.front.tags.unwrap_or_default(),
retention: parsed.front.retention.flatten(),
force: false,
choice: None,
expected_updated: None,
dry_run: dry.is_on(),
};
run_write(&st, req, true, None, None)
})
.await
}
pub fn forget_report(r: ErasureReport, path: String) -> ForgetReport {
ForgetReport {
dry_run: r.dry_run,
note: ForgetNote {
id: r.note_id,
name: r.name,
path,
},
removed: ForgetRemoved {
file: r.file_removed,
blocks: r.blocks,
vectors: r.vectors,
fts_rows: r.fts_rows,
links_in: r.links_in_unresolved,
links_out: r.links_out,
derivatives: r.derivatives,
},
notes: r.notes,
}
}
pub async fn delete_note(
State(st): State<Arc<ServeState>>,
ApiPath(target): ApiPath<String>,
ApiQuery(dry): ApiQuery<DryRun>,
) -> ApiResult<Json<ForgetReport>> {
blocking(move || {
let path = st
.app
.export(&target)
.map(|v| rel_path(st.app.root(), &v.path))
.unwrap_or_default();
let r = st.app.forget(&target, dry.is_on())?;
Ok(Json(forget_report(r, path)))
})
.await
}
pub async fn resolve_hold(
State(st): State<Arc<ServeState>>,
ApiPath(id): ApiPath<String>,
ApiQuery(dry): ApiQuery<DryRun>,
ApiJson(res): ApiJson<PiiHoldResolution>,
) -> ApiResult<Response> {
let choice = match res.action.as_str() {
"redact" => Some(OperatorChoice::Redact),
"mark-reviewed" => Some(OperatorChoice::MarkReviewed),
"proceed" => Some(OperatorChoice::ProceedFlagged),
"discard" => None,
other => {
return Err(ApiError::bad_request(format!(
"action {other:?} is not one of redact, mark-reviewed, proceed, discard"
)));
}
};
blocking(move || {
let held = st.holds.take(&id).ok_or_else(|| {
ApiError::not_found(format!(
"hold {id} is unknown or has expired; resubmit the write"
))
})?;
let mut req = held.request;
req.dry_run = req.dry_run || dry.is_on();
let Some(choice) = choice else {
st.app.policy().audit().record_raw(
&st.app.actor().to_string(),
"note.write.discarded",
format!("note:{}", req.name),
json!({ "findings": held.findings.len(), "dry_run": req.dry_run, "hold": id }),
)?;
return Ok(StatusCode::NO_CONTENT.into_response());
};
req.choice = Some(choice);
let existing_created = st.app.store().read(&req.name).ok().map(|n| n.front.created);
run_write(
&st,
req,
held.created,
existing_created,
Some(&held.findings),
)
})
.await
}