//! Hosted projects: the Onset protocol client (see `wire` for the endpoints). use std::collections::HashMap; use std::path::PathBuf; use std::sync::OnceLock; use std::time::Duration; use anyhow::{Context, Result, anyhow}; use serde::{Deserialize, Serialize}; use serde_json::json; use ureq::Agent; use onset_core::version::Version; use onset_core::{Id, tree}; use crate::app::{App, Session}; use crate::db::{Change, Db, Project}; use crate::host::{Host, LandRequest, Landed}; use crate::ids; use crate::objects::Store; use crate::out::{Fail, Out, Report}; use crate::paths; use crate::wire::{self, ChangeRecord, ErrorBody, Hold, MineFile, ProjectView, PutChange, Rule, SaveRecord, ShareRequest}; /// Hosts whose Onset API isn't on the host itself. const KNOWN: &[(&str, &str)] = &[("upstream.codes", "https://onset.coline.dev/v1"), ("git.coline.app", "https://onset.coline.dev/v1")]; /// Where Upstream's hosts sign people in (Upstream's CLI sign-in). const UPSTREAM_LOGIN: &str = "https://coline.app/api/v1/upstream/cli/login"; /// The HTTP client every request uses. pub fn http() -> &'static Agent { agent() } /// ONSET_TRACE=1 prints each request to the host with its status and time. fn trace(req: ureq::http::Request, next: ureq::middleware::MiddlewareNext) -> Result, ureq::Error> { static ON: OnceLock = OnceLock::new(); if !*ON.get_or_init(|| paths::var_os("TRACE").is_some_and(|v| v != "0")) { return next.handle(req); } let (method, uri) = (req.method().clone(), req.uri().clone()); let started = std::time::Instant::now(); let result = next.handle(req); let status = result.as_ref().map(|r| r.status().as_u16().to_string()).unwrap_or_else(|e| e.to_string()); eprintln!("on: {method} {uri} {status} {} ms", started.elapsed().as_millis()); result } fn agent() -> &'static Agent { static AGENT: OnceLock = OnceLock::new(); AGENT.get_or_init(|| { Agent::config_builder() .http_status_as_error(false) .timeout_connect(Some(Duration::from_secs(10))) .timeout_global(Some(Duration::from_secs(900))) .user_agent(format!("onset/{}", crate::VERSION)) .middleware(trace) .build() .into() }) } // Sign-in. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Credential { pub token: String, #[serde(default)] pub handle: String, #[serde(default)] pub name: String, #[serde(default)] pub email: String, #[serde(default)] pub api: String, } #[derive(Debug, Default, Serialize, Deserialize)] struct Credentials { #[serde(default)] hosts: HashMap, } fn load_credentials() -> Credentials { std::fs::read_to_string(paths::credentials()).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default() } fn save_credentials(c: &Credentials) -> Result<()> { let path = paths::credentials(); if let Some(dir) = path.parent() { std::fs::create_dir_all(dir)?; } let tmp = path.with_extension("tmp"); std::fs::write(&tmp, serde_json::to_vec_pretty(c)?)?; crate::sys::owner_only(&tmp, false)?; std::fs::rename(tmp, path)?; Ok(()) } /// The sign-in for a host. ONSET_TOKEN (for agents and CI) wins. pub fn credential(host: &str) -> Option { let mut c = load_credentials().hosts.remove(host); if let Some(token) = paths::var("TOKEN").filter(|t| !t.is_empty()) { let mut cred = c.unwrap_or_default(); cred.token = token; c = Some(cred); } c } /// The signed-in account for a project's host: (name, email). pub fn account(project: &Project) -> Option<(String, String)> { let host = split_address(&project.address).ok()?.0; let c = credential(&host)?; if c.name.is_empty() || c.email.is_empty() { return None; } Some((c.name, c.email)) } // Addresses and discovery. /// "host/owner/name" (with or without https://) → (host, owner, name). pub fn split_address(text: &str) -> Result<(String, String, String), Fail> { let t = text.trim().trim_end_matches('/'); let t = t.strip_prefix("https://").or_else(|| t.strip_prefix("http://")).unwrap_or(t); let t = t.strip_suffix(".git").unwrap_or(t); let parts: Vec<&str> = t.split('/').collect(); if parts.len() != 3 || parts.iter().any(|p| p.is_empty()) { return Err(Fail::new("bad_usage", format!("{text} isn't a project address")) .state("nothing changed") .next("projects look like upstream.codes/owner/name")); } Ok((parts[0].to_string(), parts[1].to_string(), parts[2].to_string())) } fn scheme(host: &str) -> &'static str { let h = host.split(':').next().unwrap_or(host); if h == "localhost" || h == "127.0.0.1" || h == "::1" || paths::var("INSECURE").is_some_and(|v| v == "1") { "http" } else { "https" } } /// What a host says about itself: `/.well-known/onset.json`, or `vcs.json` on /// a host from before Onset had its name. fn well_known(base: &str) -> Result, ureq::Error> { let response = agent().get(format!("{base}/.well-known/onset.json")).call()?; if response.status().as_u16() == 200 { return Ok(response); } agent().get(format!("{base}/.well-known/vcs.json")).call() } /// Onset's API base URL for a host. pub fn discover(host: &str) -> Result { if let Some(api) = paths::var("API").filter(|a| !a.is_empty()) { return Ok(api.trim_end_matches('/').to_string()); } if let Some(c) = load_credentials().hosts.get(host) && !c.api.is_empty() { return Ok(c.api.clone()); } if let Some((_, api)) = KNOWN.iter().find(|(h, _)| *h == host) { return Ok(api.to_string()); } let base = format!("{}://{host}", scheme(host)); let mut response = well_known(&base).map_err(|e| Fail::new("offline", format!("can't reach {host}: {e}")).state("nothing changed").into_anyhow())?; if response.status().as_u16() != 200 { return Err(Fail::new("not_found", format!("{host} doesn't host Onset projects")).state("nothing changed").into_anyhow()); } #[derive(Deserialize)] struct Known { api: String, } let known: Known = response.body_mut().read_json().context("reading the host's onset.json")?; Ok(if known.api.starts_with('/') { format!("{base}{}", known.api) } else { known.api }.trim_end_matches('/').to_string()) } /// Where a host signs people in through the browser, if it does. fn login_url(host: &str) -> Option { if let Some(url) = paths::var("LOGIN").filter(|u| !u.is_empty()) { return Some(url); } if KNOWN.iter().any(|(h, _)| *h == host) { return Some(UPSTREAM_LOGIN.to_string()); } let base = format!("{}://{host}", scheme(host)); #[derive(Deserialize)] struct Known { #[serde(default)] login: Option, } let known: Known = well_known(&base).ok()?.body_mut().read_json().ok()?; known.login.map(|l| if l.starts_with('/') { format!("{base}{l}") } else { l }) } /// A hosted project by address, recorded on this machine. pub fn project(app: &App, target: &str) -> Result { let (host, owner, name) = split_address(target)?; let address = format!("{host}/{owner}/{name}"); let api = match app.db.project_by_address(&address)? { Some(p) if p.api.is_some() => p.api.unwrap(), _ => discover(&host)?, }; let remote = Remote { api: api.clone(), host: host.clone(), key: format!("{owner}/{name}"), token: credential(&host).map(|c| c.token) }; remote.view()?; let existing = app.db.project_by_address(&address)?; let project = Project { id: existing.map(|p| p.id).unwrap_or_else(|| address.clone()), address, kind: "hosted".into(), api: Some(api), checked: ids::now() }; app.db.put_project(&project)?; Ok(project) } pub struct Remote { pub api: String, pub host: String, /// owner/name. pub key: String, pub token: Option, } /// How others see a workspace: "machine:/path". pub fn label(path: &std::path::Path) -> String { format!("{}:{}", crate::sys::hostname(), path.display()) } type Builder = ureq::RequestBuilder; impl Remote { pub fn for_project(project: &Project) -> Remote { let (host, owner, name) = split_address(&project.address).unwrap_or_else(|_| (String::new(), String::new(), String::new())); Remote { api: project.api.clone().unwrap_or_default(), token: credential(&host).map(|c| c.token), host, key: format!("{owner}/{name}") } } fn url(&self, tail: &str) -> String { format!("{}/projects/{}{tail}", self.api, self.key) } fn auth(&self, req: Builder) -> Builder { match &self.token { Some(t) => req.header("authorization", format!("Bearer {t}")), None => req, } } fn finish(&self, result: Result, ureq::Error>) -> Result> { let mut response = result.map_err(|e| { Fail::new("offline", format!("can't reach {}: {e}", self.host)) .state("nothing on the host changed; your work is saved here") .next("run it again when you're online") .into_anyhow() })?; let status = response.status().as_u16(); let body = response.body_mut().with_config().limit(2 << 30).read_to_vec().map_err(|e| anyhow!("reading {}'s answer: {e}", self.host))?; if (200..300).contains(&status) { return Ok(body); } let info = serde_json::from_slice::(&body).ok().map(|b| b.error); let message = info.as_ref().map(|i| i.message.clone()).unwrap_or_else(|| format!("{} answered {status}", self.host)); let fail = match (status, info.as_ref().map(|i| i.code.as_str())) { (401, _) => Fail::new("not_logged_in", message).state("nothing changed").next(format!("on login {}", self.host)), (403, _) => Fail::new("not_allowed", message).state("nothing changed"), (404, _) => { let f = Fail::new("not_found", message).state("nothing changed"); if self.token.is_none() { f.next(format!("on login {}", self.host)) } else { f } } (409, Some("held_elsewhere")) => { let holder = info.and_then(|i| i.holder).unwrap_or_else(|| "another workspace".into()); Fail::new("held_elsewhere", format!("{holder} took this change over")).set("holder", holder) } (409, _) => Fail::new("exists", message).state("nothing changed"), _ => Fail::new("failed", message), }; Err(fail.into_anyhow()) } fn get(&self, tail: &str) -> Result> { self.finish(self.auth(agent().get(self.url(tail))).call()) } pub fn get_json(&self, tail: &str) -> Result { Ok(serde_json::from_slice(&self.get(tail)?)?) } /// A GET from the host's API itself, not one project's (an account's keys). pub fn get_host(&self, path: &str) -> Result { Ok(serde_json::from_slice(&self.finish(self.auth(agent().get(format!("{}{path}", self.api))).call())?)?) } /// A POST to the host's API itself, not one project's (land-together). pub fn post_host(&self, path: &str, body: &impl Serialize) -> Result { Ok(serde_json::from_slice(&self.finish(self.auth(agent().post(format!("{}{path}", self.api))).send_json(body))?)?) } pub fn send_json(&self, method: &str, tail: &str, body: &impl Serialize) -> Result { let url = self.url(tail); let req = if method == "PUT" { agent().put(url) } else { agent().post(url) }; Ok(serde_json::from_slice(&self.finish(self.auth(req).send_json(body))?)?) } /// A GET whose answer is bytes (a bundle). pub fn get_bytes(&self, tail: &str) -> Result> { self.get(tail) } /// A POST of JSON whose answer is bytes (a bundle). pub fn post_for_bytes(&self, tail: &str, body: &impl Serialize) -> Result> { self.finish(self.auth(agent().post(self.url(tail))).send_json(body)) } fn send_bytes(&self, tail: &str, body: &[u8]) -> Result> { self.finish(self.auth(agent().post(self.url(tail))).header("content-type", "application/octet-stream").send(body)) } pub fn view(&self) -> Result { let url = format!("{}/projects/{}", self.api, self.key); Ok(serde_json::from_slice(&self.finish(self.auth(agent().get(url)).call())?)?) } /// Uploads whatever the host lacks of `ids`. pub fn upload(&self, store: &Store, ids: &[Id]) -> Result { if ids.is_empty() { return Ok(0); } // A little is cheaper to send than to ask about first. let small = ids.len() <= 256 && ids.iter().map(|i| store.stored_len(i)).sum::() <= 1 << 20; let mut missing = Vec::new(); if small { missing.extend_from_slice(ids); } else { for chunk in ids.chunks(20_000) { missing.extend(wire::decode_ids(&self.send_bytes("/objects/missing", &wire::encode_ids(chunk))?)); } } let mut sent = 0; let mut batch = Vec::new(); let mut size = 0usize; for id in &missing { batch.push(*id); size += store.frame(id)?.map(|f| f.payload.len()).unwrap_or(0); if size > 16 << 20 { self.send_bytes("/objects", &store.bundle(&batch)?)?; sent += batch.len(); batch.clear(); size = 0; } } if !batch.is_empty() { self.send_bytes("/objects", &store.bundle(&batch)?)?; sent += batch.len(); } Ok(sent) } /// Objects under `tree` that `base` doesn't share, chunks included. pub fn objects_since(store: &Store, base: Option<&Id>, tree: &Id) -> Result> { let mut ids = Vec::new(); tree::new_since(store, base, tree, &mut ids)?; let mut all = Vec::with_capacity(ids.len()); for id in ids { all.extend(store.file_parts(&id)?); } Ok(all) } /// Uploads what the host lacks of a change's trees. The host has the /// base already when it's a version of main (the host made it) or a tree /// this machine uploaded before. pub fn upload_change(&self, db: &Db, store: &Store, project: &Project, base: &Id, head: Option<&Id>) -> Result<()> { let host_has_base = db.versions_with_tree(&project.id, base)? || db.uploaded_tree(&project.id, base)?; let mut ids = if host_has_base { Vec::new() } else { Self::objects_since(store, None, base)? }; if let Some(h) = head { ids.extend(Self::objects_since(store, Some(base), h)?); } self.upload(store, &ids)?; db.mark_uploaded_tree(&project.id, base)?; if let Some(h) = head { db.mark_uploaded_tree(&project.id, h)?; } Ok(()) } pub fn record(change: &Change) -> ChangeRecord { ChangeRecord { id: change.id.clone(), title: change.title.clone(), body: change.body.clone(), base_tree: change.base_tree, base_version: change.base_version, on_change: change.on_change.clone(), after_change: change.after_change.clone(), head_tree: change.head_tree, saves: change.saves, state: change.state.clone(), version: change.version, author: change.author.clone(), owner: String::new(), shared: Vec::new(), holder: None, updated: change.updated, review: None, line: change.line.clone(), } } /// Takes a host record into the local database, keeping newer local edits. pub fn adopt(db: &Db, project: &Project, r: &ChangeRecord) -> Result { db.set_review(&r.id, r.review.as_ref().map(|l| format!("#{} {}", l.number, l.url)).as_deref())?; if let Some(mut local) = db.change(&r.id)? && local.updated >= r.updated && local.state == r.state { local.shared = r.shared.iter().map(|s| s.who.clone()).collect(); db.put_change(&local)?; return Ok(local); } let mut c = Change::new(r.id.clone(), &project.id, r.base_tree, r.base_version, &r.author); if let Some(local) = db.change(&r.id)? { c.workspace = local.workspace; c.conflicts = local.conflicts; c.created = local.created; } c.title = r.title.clone(); c.body = r.body.clone(); c.on_change = r.on_change.clone(); c.after_change = r.after_change.clone(); c.head_tree = r.head_tree; c.saves = r.saves; c.state = r.state.clone(); c.version = r.version; c.line = r.line.clone(); c.shared = r.shared.iter().map(|s| s.who.clone()).collect(); if !r.owner.is_empty() && r.author.is_empty() { c.author = r.owner.clone(); } db.put_change(&c)?; Ok(c) } pub fn fetch_change(&self, db: &Db, store: &Store, project: &Project, prefix: &str) -> Result> { #[derive(Deserialize)] struct Found { change: ChangeRecord, } let found: Found = match self.get_json(&format!("/changes/{prefix}")) { Ok(f) => f, Err(e) => { let fail = Fail::from(e); if fail.code == "not_found" { return Ok(None); } return Err(fail.into_anyhow()); } }; let c = Self::adopt(db, project, &found.change)?; self.fetch_tree(store, project, &c.base_tree, None)?; self.fetch_tree(store, project, &c.tree(), Some(&c.base_tree))?; Ok(Some(c)) } /// Brings a change's latest content from the host. pub fn fetch_saves(&self, db: &Db, store: &Store, project: &Project, change: &Change) -> Result<()> { self.fetch_change(db, store, project, &change.id)?; Ok(()) } pub fn hold(&self, change: &str, workspace: &str, label: &str) -> Result> { #[derive(Deserialize)] struct Prev { previous: Option, } let p: Prev = self.send_json("POST", &format!("/changes/{change}/hold"), &Hold { workspace: workspace.into(), label: label.into() })?; Ok(p.previous) } pub fn changes(&self) -> Result> { #[derive(Deserialize)] struct List { changes: Vec, } Ok(self.get_json::("/changes")?.changes) } fn is_complete(store: &Store, db: &Db, tree: &Id) -> bool { if db.conn.query_row("SELECT 1 FROM complete WHERE tree = ?1", [tree.hex()], |_| Ok(())).is_ok() { return true; } if !store.has(tree) { return false; } let mut ids = Vec::new(); if tree::reachable(store, tree, &mut ids).is_err() { return false; } ids.iter().all(|i| store.file_parts(i).is_ok_and(|parts| parts.iter().all(|p| store.has(p)))) } } fn mark_complete(db: &Db, tree: &Id) { let _ = db.conn.execute("INSERT OR IGNORE INTO complete (tree) VALUES (?1)", [tree.hex()]); } thread_local! { static OWN_DB: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; } /// The fetch path keeps its own connection, so Host methods needn't carry one. fn with_db(f: impl FnOnce(&Db) -> T) -> Result { OWN_DB.with(|cell| { let mut slot = cell.borrow_mut(); if slot.is_none() { *slot = Some(Db::open(&paths::home())?); } Ok(f(slot.as_ref().unwrap())) }) } /// Versions of main a new machine starts with; older ones are fetched when /// a command asks for one. const HISTORY_KEPT: u64 = 1000; /// A page of main's versions. #[derive(Deserialize)] struct List { versions: Vec, /// How many times paths were purged from the project's history (on purge). #[serde(default)] purges: Option, /// How many times sealed folders changed (seal.rs): this machine reads them again when it differs. #[serde(default)] seals: Option, } impl Host for Remote { fn refresh(&self, db: &Db, _store: &Store, project: &Project) -> Result> { // The newest version is always read again: what someone sees of it // changes when a path is made private, and an owner can roll the // whole project back. let latest = db.latest_version(&project.id)?.map(|v| v.n).unwrap_or(0); let kept = paths::var("HISTORY_KEPT").and_then(|k| k.parse().ok()).unwrap_or(HISTORY_KEPT).max(1); if latest == 0 && let Some(main) = self.view()?.main && main.n > kept { // A new machine starts with recent history; older versions come when a command asks for one. db.set_history_from(&project.id, main.n - kept + 1)?; } let mut since = if latest == 0 { db.history_from(&project.id)? - 1 } else { db.versions_complete_to(&project.id)?.min(latest - 1) }; let mut newest = 0; // Pages of up to 5,000 versions; a short page is the last. loop { let list: List = self.get_json(&format!("/versions?since={since}"))?; if let Some(n) = list.purges && n > db.purges(&project.id)?.0 { // History was rewritten: App::refresh takes the paths out here, then reads it again. db.set_purges(&project.id, None, Some(n))?; return Ok(db.latest_version(&project.id)?); } if let Some(n) = list.seals { crate::seal::set_state(db, &project.id, None, Some(n))?; } let tx = db.conn.unchecked_transaction()?; for v in &list.versions { db.put_version(&project.id, v)?; since = v.n; newest = newest.max(v.n); } tx.commit()?; if list.versions.len() < 5000 { break; } } if newest < latest { // The host's main is behind what this machine saw: rolled back. let host = if newest > 0 { newest } else { self.view()?.main.map(|m| m.n).unwrap_or(0) }; db.drop_versions_after(&project.id, host)?; } Ok(db.latest_version(&project.id)?) } fn fetch_history(&self, db: &Db, project: &Project, from: u64) -> Result<()> { let start = db.history_from(&project.id)?; let from = from.max(1); if from >= start { return Ok(()); } let mut since = from - 1; while since + 1 < start { let list: List = self.get_json(&format!("/versions?since={since}&limit={}", (start - 1 - since).min(5000)))?; let Some(last) = list.versions.last().map(|v| v.n) else { break }; let tx = db.conn.unchecked_transaction()?; for v in list.versions.iter().filter(|v| v.n < start) { db.put_version(&project.id, v)?; } tx.commit()?; since = last; } db.set_history_from(&project.id, from)?; Ok(()) } fn fetch_version(&self, db: &Db, project: &Project, n: u64) -> Result<()> { let list: List = self.get_json(&format!("/versions?since={}&limit=1", n.saturating_sub(1)))?; for v in list.versions.iter().filter(|v| v.n == n) { db.put_version(&project.id, v)?; } Ok(()) } fn fetch_tree(&self, store: &Store, project: &Project, tree: &Id, have: Option<&Id>) -> Result<()> { if with_db(|db| Self::is_complete(store, db, tree))? { with_db(|db| mark_complete(db, tree))?; return Ok(()); } // Skip what a tree we already have shares with it. let have = match have.filter(|h| with_db(|db| Self::is_complete(store, db, h)).unwrap_or(false)) { Some(h) => Some(*h), None => with_db(|db| -> Option { db.conn .query_row( "SELECT v.tree FROM versions v JOIN complete c ON c.tree = v.tree WHERE v.project = ?1 ORDER BY v.n DESC LIMIT 1", [&project.id], |r| r.get::<_, String>(0), ) .ok() .and_then(|t| Id::parse(&t)) })?, }; let tail = match have { Some(h) => format!("/trees/{tree}?have={h}"), None => format!("/trees/{tree}"), }; let bundle = self.get(&tail)?; store.ingest(&bundle)?; with_db(|db| mark_complete(db, tree))?; Ok(()) } fn land(&self, db: &Db, store: &Store, project: &Project, request: &LandRequest) -> Result { self.upload_change(db, store, project, &request.base_tree, Some(&request.tree))?; self.send_json("POST", "/land", request) } fn publish_change(&self, db: &Db, store: &Store, project: &Project, change: &Change, workspace: &str) -> Result<()> { let holder = db.workspace(workspace)?.map(|w| label(&w.path)).unwrap_or_default(); // Content first, so the record never points at missing objects. self.upload_change(db, store, project, &change.base_tree, change.head_tree.as_ref())?; // The record and its newest save in one call. let pending = db.unuploaded_saves(&change.id)?; let save = pending.last().map(|last| SaveRecord { n: last.n, tree: last.tree, base_tree: last.base_tree, at: last.at, workspace: workspace.to_string() }); let _: serde_json::Value = self.send_json( "PUT", &format!("/changes/{}", change.id), &PutChange { change: Self::record(change), workspace: workspace.to_string(), label: holder, save }, )?; for s in &pending { db.mark_uploaded(&change.id, s.n)?; } Ok(()) } } /// Changes this person can see in the project: the host's merged into the /// local database. pub fn visible_changes(app: &App, project: &Project) -> Result> { if project.hosted() { let remote = Remote::for_project(project); for r in remote.changes()? { Remote::adopt(&app.db, project, &r)?; } } app.db.changes(&project.id) } pub fn access_change(app: &App, s: &Session, target: &str, who: Vec, edit: bool) -> Out { let c = app.find_change(&s.project, target)?; if !s.project.hosted() { return Err(Fail::new("bad_usage", "a project on this machine only has you; sharing changes needs a hosted project") .state("nothing changed") .next("on publish ")); } let remote = Remote::for_project(&s.project); let mut r = Report::new(); if who.is_empty() { let found = remote.fetch_change(&app.db, &app.store, &s.project, &c.id)?; let shared = found.map(|f| f.shared).unwrap_or_default(); r.line("access", format!("change {}: you{}", app.short(&s.project, &c.id), if shared.is_empty() { String::new() } else { format!(", {}", shared.join(", ")) })); r.set("who", shared); return Ok(r); } remote.publish_change(&app.db, &app.store, &s.project, &app.change(&c.id)?, &s.ws.id)?; #[derive(Deserialize)] struct Shared { shared: Vec, } let shared: Shared = remote.send_json("POST", &format!("/changes/{}/access", c.id), &ShareRequest { who: who.clone(), edit })?; app.db.add_op(&s.ws.id, "access", &json!({ "target": c.id, "who": who.join(" "), "change_before": c.shared }))?; let list: Vec = shared.shared.iter().map(|s| if s.edit { format!("{} (can edit)", s.who) } else { s.who.clone() }).collect(); r.line("access", format!("change {}: you, {}", app.short(&s.project, &c.id), list.join(", "))); r.set("who", list); r.next(if edit { format!("they can on get --change {}", app.short(&s.project, &c.id)) } else { "they see it in on log --changes".into() }); Ok(r) } /// The host's path rules: (path, who). pub fn path_rules(_app: &App, project: &Project) -> Result)>> { if !project.hosted() { return Ok(Vec::new()); } #[derive(Deserialize)] struct Rules { rules: Vec, } let rules: Rules = Remote::for_project(project).get_json("/rules")?; Ok(rules.rules.into_iter().map(|r| (r.path, r.who)).collect()) } pub fn set_path_rule(app: &App, s: &Session, path: &str, who: Vec) -> Out { let remote = Remote::for_project(&s.project); let before = path_rules(app, &s.project)?.into_iter().find(|(p, _)| p == path).map(|(_, w)| w).unwrap_or_default(); let _: serde_json::Value = remote.send_json("PUT", "/rules", &Rule { path: path.to_string(), who: who.clone(), sealed: false })?; app.db.add_op(&s.ws.id, "access", &json!({ "target": path, "who": who.join(" "), "rule_before": before }))?; // A sealed folder given a plain rule is unsealed: its files are kept plain from the next save. let _ = crate::seal::sync(app, &s.project); let mut r = Report::new(); if who.len() == 1 && who[0] == "team" { r.line("access", format!("{path}: everyone with access to the project")); } else { r.line("access", format!("{path}: only {} (and the project's owners)", who.join(", "))); } r.set("path", path).set("who", who); Ok(r) } /// Uploads the user's own files. pub fn push_mine(app: &App, project: &Project) -> Result<()> { if !project.hosted() { return Ok(()); } let remote = Remote::for_project(project); let files: Vec = app.db.mine(&project.id)?.into_iter().map(|(path, file, exec)| MineFile { path, file, exec }).collect(); let mut ids = Vec::new(); for f in &files { ids.extend(app.store.file_parts(&f.file)?); } remote.upload(&app.store, &ids)?; let _: serde_json::Value = remote.send_json("PUT", "/mine", &json!({ "files": files }))?; Ok(()) } /// Brings the user's own files from the host into this machine's list. pub fn pull_mine(app: &App, project: &Project) -> Result<()> { if !project.hosted() { return Ok(()); } let remote = Remote::for_project(project); #[derive(Deserialize)] struct Files { files: Vec, } let files: Files = remote.get_json("/mine")?; if files.files.is_empty() { return Ok(()); } app.store.ingest(&remote.get("/mine/bundle")?)?; let local = app.db.mine(&project.id)?; for f in files.files { if !local.iter().any(|(p, _, _)| p == &f.path) { app.db.put_mine(&project.id, &f.path, &f.file, f.exec)?; } } Ok(()) } pub fn login(_app: &App, host: Option, token: Option) -> Out { let host = host.unwrap_or_else(|| "upstream.codes".into()); let host = host.trim_start_matches("https://").trim_start_matches("http://").trim_end_matches('/').to_string(); let api = discover(&host)?; let token = match token.or_else(|| paths::var("TOKEN").filter(|t| !t.is_empty())) { Some(t) => t, None => crate::signin::browser(&host, login_url(&host).as_deref())?, }; let mut response = agent() .get(format!("{api}/me")) .header("authorization", format!("Bearer {token}")) .call() .map_err(|e| Fail::new("offline", format!("can't reach {host}: {e}")).state("nothing changed"))?; if response.status().as_u16() != 200 { return Err(Fail::new("not_logged_in", format!("{host} didn't accept that token")).state("nothing changed").next(format!("on login {host}"))); } let me: wire::Me = response.body_mut().read_json().map_err(|e| anyhow!("{e}"))?; let mut creds = load_credentials(); creds.hosts.insert(host.clone(), Credential { token, handle: me.handle.clone(), name: me.name.clone(), email: me.email.clone(), api }); save_credentials(&creds)?; let mut r = Report::new(); r.line("signed in", format!("to {host} as {} ({})", me.handle, me.email)).set("host", host).set("handle", me.handle); r.next("on get "); Ok(r) } /// `on publish`: puts a local project on a host, history and all. pub fn publish(app: &App, target: &str) -> Out { let mut s = app.session()?; if s.project.hosted() { return Err(Fail::new("bad_usage", format!("this project is already on {}", s.project.address)).state("nothing changed")); } app.save(&mut s)?; let (host, owner, name) = split_address(target)?; let api = discover(&host)?; let token = credential(&host).map(|c| c.token); if token.is_none() { return Err(Fail::new("not_logged_in", format!("sign in to {host} first")).state("nothing changed").next(format!("on login {host}"))); } let remote = Remote { api: api.clone(), host: host.clone(), key: format!("{owner}/{name}"), token: token.clone() }; // Every version, oldest first, with its objects. let mut versions = app.db.versions(&s.project.id, i64::MAX)?; versions.reverse(); // A SHA-256 repository's history: the git view is SHA-1, so its commits // are new ones there (Onset's own), not the originals. let sha256 = versions.iter().any(|v| v.git.as_ref().is_some_and(|g| g.len() == 64)); if sha256 { for v in &mut versions { v.git = None; } app.notes.borrow_mut().push("the history came from a SHA-256 git repository; the git view is SHA-1, so its commits there are new ones".into()); } // History imported from git goes to a repository that may already hold it. let imported = versions.iter().any(|v| v.git.is_some()); if let Err(e) = remote.finish(remote.auth(agent().post(format!("{api}/projects"))).send_json(json!({ "owner": owner, "name": name, "import": imported }))) { let fail = Fail::from(e); if fail.code != "exists" { return Err(fail); } } let view = remote.view()?; if view.main.is_some() { return Err(Fail::new("bad_usage", format!("{target} already has versions; publish only to an empty project")).state("nothing changed")); } let mut prev: Option = None; for v in &versions { let ids = Remote::objects_since(&app.store, prev.as_ref(), &v.tree)?; remote.upload(&app.store, &ids)?; prev = Some(v.tree); } #[derive(Deserialize)] struct Imported { main: Option, } let landed: Imported = remote.send_json("POST", "/import", &json!({ "versions": versions }))?; if imported { // The git view carries on from the imported commits: the host needs // their git IDs, and the repository needs the commits. let ids = app.db.git_ids(&s.project.id)?; for batch in ids.chunks(20_000) { let map: serde_json::Map = batch.iter().map(|(n, g)| (n.clone(), serde_json::Value::String(g.clone()))).collect(); let _: serde_json::Value = remote.send_json("POST", "/git/ids", &json!({ "ids": map }))?; } if let (Some(url), Some(token)) = (&view.git, &token) && let Some(note) = crate::bridge::push_history(app, &s.project, url, token)? { app.notes.borrow_mut().push(note); } } let address = format!("{host}/{owner}/{name}"); let project = Project { id: s.project.id.clone(), address: address.clone(), kind: "hosted".into(), api: Some(api), checked: ids::now() }; app.db.put_project(&project)?; // Open changes and the user's own files follow. for c in app.db.changes(&project.id)? { if c.state == "open" && !c.is_empty() { let _ = remote.publish_change(&app.db, &app.store, &project, &c, &s.ws.id); } } let _ = push_mine(app, &project); let mut r = Report::new(); r.line("published", format!("{} versions to {address}", versions.len())); r.line("main", format!("{}", landed.main.unwrap_or(0))); r.set("address", address).set("versions", versions.len()); r.next("keep working; on land now lands on the host"); Ok(r) } fn hosted_only(s: &Session, what: &str) -> Result<(), Fail> { if s.project.hosted() { return Ok(()); } Err(Fail::new("bad_usage", format!("{what} belong to hosted projects; a project on this machine only has you")) .state("nothing changed") .next("on publish ")) } /// `on approve `: approves someone else's change as it is now. The /// last approval a project needs lands it. pub fn approve(app: &App, target: &str) -> Out { let mut s = app.session()?; app.save(&mut s)?; hosted_only(&s, "approvals")?; for r in Remote::for_project(&s.project).changes()? { Remote::adopt(&app.db, &s.project, &r)?; } let c = app.find_change(&s.project, target)?; let answer: serde_json::Value = Remote::for_project(&s.project).send_json("POST", &format!("/changes/{}/approve", c.id), &json!({}))?; let mut r = Report::new(); let short = app.short(&s.project, &c.id); match answer["outcome"].as_str() { Some("landed") => { let n = answer["version"]["n"].as_u64().unwrap_or(0); r.line("approved", format!("{short}, the last approval it needed")); r.line("landed", format!("{short} as main {n} \"{}\"", c.title)); r.set("landed", n); r.next("on sync to bring it into this workspace"); } Some("conflict") => { r.line("approved", short.clone()); r.line("conflict", format!("{short} conflicts with main {}; its owner syncs and lands it again", answer["main"])); r.exit = 3; } Some("approved") => { let have = answer["approvals"].as_array().map(|a| a.len()).unwrap_or(0); let need = answer["required"].as_u64().unwrap_or(0); r.line("approved", format!("{short} ({have} of {need} approvals)")); if need == 0 { r.next("this project doesn't require approvals; landing doesn't wait for them"); } } _ => { r.line("approved", short.clone()); } } r.set("change", c.id.clone()).set("answer", answer); Ok(r) } /// `on check`: every object this machine keeps against its ID, and for a /// hosted project, the host's check of its versions and of what main needs. pub fn check(app: &App) -> Out { let s = app.session()?; let mut r = Report::new(); let (count, damaged) = app.store.verify_all()?; r.line("here", if damaged.is_empty() { format!("{count} objects, each matching its ID") } else { format!("{} of {count} objects are damaged", damaged.len()) }); r.set("objects", count).set("damaged", damaged.iter().map(|d| d.hex()).collect::>()); let mut bad = !damaged.is_empty(); if s.project.hosted() { #[derive(Deserialize)] struct HostCheck { versions: u64, chain: Option, folders: u64, files: u64, missing: Vec, } let c: HostCheck = Remote::for_project(&s.project).send_json("POST", "/check", &json!({}))?; r.line("versions", match &c.chain { None => format!("{} versions, each following the one before", c.versions), Some(why) => why.clone(), }); r.line("main", if c.missing.is_empty() { format!("{} files in {} folders, all stored", c.files, c.folders) } else { format!("missing: {}", c.missing.join(", ")) }); r.set("versions", c.versions).set("chain", c.chain.clone()).set("missing", c.missing.clone()); bad |= c.chain.is_some() || !c.missing.is_empty(); } if bad { r.exit = 1; r.next("damage on this machine: on get the project into a new folder, which fetches good copies; damage on the host: tell its admins"); } Ok(r) } /// `on rollback --to