//! The server's state: accounts, projects, changes and saves in SQLite, //! objects in a folder. Each worker thread opens its own connection. use std::path::Path; use anyhow::{Result, anyhow}; use rusqlite::{OptionalExtension, params}; use sha2::{Digest, Sha256}; use onset_cli::db::Db; use onset_cli::host::{LandRequest, Landed}; use onset_cli::objects::Store; use onset_cli::wire::{Can, ChangeRecord, Me, MineFile, Rule, SaveRecord, Share}; use onset_core::version::Version; use onset_core::{Id, tree}; const SCHEMA: &str = r#" CREATE TABLE IF NOT EXISTS s_users (handle TEXT PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL, created INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS s_tokens (hash TEXT PRIMARY KEY, handle TEXT NOT NULL, created INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS s_projects (key TEXT PRIMARY KEY, public INTEGER NOT NULL DEFAULT 0, created INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS s_members (project TEXT NOT NULL, handle TEXT NOT NULL, role TEXT NOT NULL, PRIMARY KEY (project, handle)); CREATE TABLE IF NOT EXISTS s_changes ( project TEXT NOT NULL, id TEXT NOT NULL, owner TEXT NOT NULL, record TEXT NOT NULL, holder TEXT, holder_label TEXT, state TEXT NOT NULL, updated INTEGER NOT NULL, PRIMARY KEY (project, id) ); CREATE TABLE IF NOT EXISTS s_saves (project TEXT NOT NULL, change TEXT NOT NULL, n INTEGER NOT NULL, tree TEXT NOT NULL, base_tree TEXT NOT NULL, at INTEGER NOT NULL, PRIMARY KEY (project, change, n)); CREATE TABLE IF NOT EXISTS s_roots (project TEXT NOT NULL, tree TEXT NOT NULL, change TEXT NOT NULL, PRIMARY KEY (project, tree, change)); CREATE TABLE IF NOT EXISTS s_rules (project TEXT NOT NULL, path TEXT NOT NULL, who TEXT NOT NULL, PRIMARY KEY (project, path)); CREATE TABLE IF NOT EXISTS s_visible (project TEXT NOT NULL, key TEXT NOT NULL, visible TEXT NOT NULL, full TEXT NOT NULL, PRIMARY KEY (project, key, visible)); CREATE INDEX IF NOT EXISTS s_visible_full ON s_visible(project, key, full); CREATE TABLE IF NOT EXISTS s_namespaces (name TEXT PRIMARY KEY, handle TEXT NOT NULL); CREATE TABLE IF NOT EXISTS s_mine (project TEXT NOT NULL, handle TEXT NOT NULL, files TEXT NOT NULL, PRIMARY KEY (project, handle)); CREATE TABLE IF NOT EXISTS s_groups (project TEXT NOT NULL, name TEXT NOT NULL, members TEXT NOT NULL, PRIMARY KEY (project, name)); CREATE TABLE IF NOT EXISTS s_settings (project TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (project, key)); CREATE TABLE IF NOT EXISTS s_approvals (project TEXT NOT NULL, change TEXT NOT NULL, handle TEXT NOT NULL, tree TEXT NOT NULL, at INTEGER NOT NULL, PRIMARY KEY (project, change, handle)); CREATE TABLE IF NOT EXISTS s_sessions (project TEXT NOT NULL, change TEXT NOT NULL, n INTEGER NOT NULL, owner TEXT NOT NULL, agent TEXT NOT NULL, file TEXT NOT NULL, bytes INTEGER NOT NULL, messages INTEGER NOT NULL, at INTEGER NOT NULL, PRIMARY KEY (project, change, n)); CREATE TABLE IF NOT EXISTS s_edit_keys (project TEXT NOT NULL, key TEXT NOT NULL, change TEXT NOT NULL, at INTEGER NOT NULL, PRIMARY KEY (project, key)); "#; /// How long the same edits sent again answer as the first time (a retry). const EDIT_KEY_SECS: i64 = 24 * 3600; /// What makes edits sent over the API the same edits again: the caller's own /// key, or the edits themselves on the version they name. pub fn edits_key(handle: &str, given: Option<&str>, base: Option, put: &[tree::Put], delete: &[String], title: &str, body: &str) -> String { use sha2::{Digest, Sha256}; let mut h = Sha256::new(); h.update(handle.as_bytes()); h.update([0]); match given { Some(k) => { h.update(b"key\0"); h.update(k.as_bytes()); } None => { h.update(format!("{base:?}\0{title}\0{body}\0").as_bytes()); for p in put { h.update(format!("{}\0{}\0{}\0", p.path, p.kind.name(), p.content.len()).as_bytes()); h.update(&p.content); } for d in delete { h.update(format!("-{d}\0").as_bytes()); } } } h.finalize().iter().map(|b| format!("{b:02x}")).collect() } pub struct State { pub db: Db, pub store: Store, } #[derive(Debug)] pub struct Problem { pub status: u16, pub code: &'static str, pub message: String, pub holder: Option, } pub fn problem(status: u16, code: &'static str, message: impl Into) -> Problem { Problem { status, code, message: message.into(), holder: None } } impl From for Problem { fn from(e: anyhow::Error) -> Problem { // A refusal the shared landing code made says what it is. if let Some(onset_cli::out::FailBox(fail)) = e.downcast_ref::() { let status = match fail.code { "bad_usage" | "empty_change" | "no_description" => 400, "not_logged_in" => 401, "not_allowed" => 403, "not_found" => 404, "conflict" => 409, "secret" => 422, _ => 500, }; return problem(status, fail.code, fail.message.clone()); } problem(500, "failed", format!("{e:#}")) } } impl From for Problem { fn from(e: rusqlite::Error) -> Problem { problem(500, "failed", e.to_string()) } } impl From for Problem { fn from(e: onset_core::Error) -> Problem { problem(500, "failed", e.to_string()) } } pub type Res = Result; pub fn token_hash(token: &str) -> String { let digest = Sha256::digest(token.as_bytes()); digest.iter().map(|b| format!("{b:02x}")).collect() } fn now() -> i64 { std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0) } pub struct Access { pub user: Option, pub can: Can, } impl State { pub fn open(data: &Path) -> Result { std::fs::create_dir_all(data)?; let conn = rusqlite::Connection::open(data.join("server.db"))?; let db = Db::setup(conn)?; db.conn.execute_batch(SCHEMA)?; Ok(State { db, store: Store::open(&data.join("objects"))? }) } // Accounts. pub fn add_user(&self, handle: &str, name: &str, email: &str) -> Result { self.db.conn.execute( "INSERT OR REPLACE INTO s_users (handle, name, email, created) VALUES (?1, ?2, ?3, ?4)", params![handle, name, email, now()], )?; let token = format!("onset_{}", (0..40).map(|_| b"abcdefghijklmnopqrstuvwxyz0123456789"[rand::random_range(0..36)] as char).collect::()); self.db.conn.execute("INSERT INTO s_tokens (hash, handle, created) VALUES (?1, ?2, ?3)", params![token_hash(&token), handle, now()])?; Ok(token) } pub fn add_project(&self, key: &str, owner: &str, public: bool) -> Result<()> { if key.split('/').count() != 2 || key.split('/').any(|p| p.is_empty()) { return Err(anyhow!("projects are OWNER/NAME")); } self.db.conn.execute("INSERT OR REPLACE INTO s_projects (key, public, created) VALUES (?1, ?2, ?3)", params![key, public as i64, now()])?; self.add_member(key, owner, "owner") } /// A namespace is yours if it's your handle, or you claimed it first. pub fn may_create_under(&self, owner: &str, handle: &str) -> Res { if owner == handle { return Ok(true); } let claimed: Option = self.db.conn.query_row("SELECT handle FROM s_namespaces WHERE name = ?1", [owner], |r| r.get(0)).optional()?; match claimed { Some(h) => Ok(h == handle), None => { let taken = self.db.conn.query_row("SELECT 1 FROM s_users WHERE handle = ?1", [owner], |_| Ok(())).optional()?.is_some(); if taken { return Ok(false); } self.db.conn.execute("INSERT INTO s_namespaces (name, handle) VALUES (?1, ?2)", params![owner, handle])?; Ok(true) } } } pub fn add_member(&self, key: &str, handle: &str, role: &str) -> Result<()> { self.db.conn.execute("INSERT OR REPLACE INTO s_members (project, handle, role) VALUES (?1, ?2, ?3)", params![key, handle, role])?; Ok(()) } pub fn user_for_token(&self, token: &str) -> Res> { let handle: Option = self.db.conn.query_row("SELECT handle FROM s_tokens WHERE hash = ?1", [token_hash(token)], |r| r.get(0)).optional()?; let Some(handle) = handle else { return Ok(None) }; Ok(self .db .conn .query_row("SELECT handle, name, email FROM s_users WHERE handle = ?1", [&handle], |r| { Ok(Me { handle: r.get(0)?, name: r.get(1)?, email: r.get(2)? }) }) .optional()?) } pub fn project_exists(&self, key: &str) -> Res> { Ok(self.db.conn.query_row("SELECT public FROM s_projects WHERE key = ?1", [key], |r| r.get::<_, i64>(0)).optional()?.map(|p| p != 0)) } pub fn access(&self, key: &str, user: Option) -> Res { let public = self.project_exists(key)?.ok_or_else(|| problem(404, "not_found", format!("no project {key}")))?; let role: Option = match &user { Some(u) => self .db .conn .query_row("SELECT role FROM s_members WHERE project = ?1 AND handle = ?2", params![key, u.handle], |r| r.get(0)) .optional()?, None => None, }; let can = Can { read: public || role.is_some(), land: matches!(role.as_deref(), Some("owner") | Some("member")), admin: role.as_deref() == Some("owner"), }; if !can.read { // Private projects look like missing ones to outsiders. return Err(problem(404, "not_found", format!("no project {key}"))); } Ok(Access { user, can }) } pub fn public(&self, key: &str) -> Res { Ok(self.project_exists(key)?.unwrap_or(false)) } pub fn landers(&self, key: &str) -> Res> { let mut stmt = self.db.conn.prepare("SELECT handle FROM s_members WHERE project = ?1 AND role IN ('owner', 'member') ORDER BY handle")?; let rows = stmt.query_map([key], |r| r.get::<_, String>(0))?; Ok(rows.collect::>>()?) } // Private paths. /// Paths `handle` can't see in the project (none for owners). pub fn hidden_for(&self, key: &str, handle: Option<&str>) -> Res> { let rules = self.rules(key)?; if rules.is_empty() { return Ok(Vec::new()); } let role: Option = match handle { Some(h) => self.db.conn.query_row("SELECT role FROM s_members WHERE project = ?1 AND handle = ?2", params![key, h], |r| r.get(0)).optional()?, None => None, }; if role.as_deref() == Some("owner") { return Ok(Vec::new()); } let mut hidden = Vec::new(); for rule in rules { let allowed = rule.who.iter().any(|w| self.matches(key, w, handle, role.is_some())); if !allowed { hidden.push(rule.path); } } Ok(hidden) } pub fn audience(hidden: &[String]) -> String { if hidden.is_empty() { return String::new(); } let mut sorted = hidden.to_vec(); sorted.sort(); token_hash(&sorted.join("\n")) } /// The tree someone with these hidden paths sees for `full`. pub fn to_visible(&self, key: &str, full: &Id, hidden: &[String]) -> Res { if hidden.is_empty() { return Ok(*full); } let audience = Self::audience(hidden); let known: Option = self .db .conn .query_row("SELECT visible FROM s_visible WHERE project = ?1 AND key = ?2 AND full = ?3", params![key, audience, full.hex()], |r| r.get(0)) .optional()?; if let Some(v) = known.and_then(|t| Id::parse(&t)) { return Ok(v); } let (visible, objects) = onset_core::filter::filter(&self.store, full, hidden)?; for o in &objects { self.store.put(o)?; } self.remember(key, &audience, &visible, full)?; Ok(visible) } fn remember(&self, key: &str, audience: &str, visible: &Id, full: &Id) -> Res<()> { self.db.conn.execute( "INSERT OR REPLACE INTO s_visible (project, key, visible, full) VALUES (?1, ?2, ?3, ?4)", params![key, audience, visible.hex(), full.hex()], )?; Ok(()) } fn to_full(&self, key: &str, audience: &str, visible: &Id) -> Res> { let full: Option = self .db .conn .query_row("SELECT full FROM s_visible WHERE project = ?1 AND key = ?2 AND visible = ?3", params![key, audience, visible.hex()], |r| r.get(0)) .optional()?; Ok(full.and_then(|t| Id::parse(&t))) } fn is_version_tree(&self, key: &str, tree: &Id) -> Res { Ok(self .db .conn .query_row("SELECT 1 FROM s_roots WHERE project = ?1 AND tree = ?2 AND change = ''", params![key, tree.hex()], |_| Ok(())) .optional()? .is_some()) } // Main. pub fn main(&self, key: &str) -> Res> { Ok(self.db.latest_version(key)?) } /// Main as `access` sees it: private paths left out, and versions that /// only touched them shown as "a private change". pub fn main_for(&self, key: &str, access: &Access) -> Res> { let Some(v) = self.main(key)? else { return Ok(None) }; Ok(self.versions_for(key, access, vec![v])?.pop()) } pub fn versions_for(&self, key: &str, access: &Access, versions: Vec) -> Res> { let hidden = self.hidden_for(key, access.user.as_ref().map(|u| u.handle.as_str()))?; if hidden.is_empty() { return Ok(versions); } let mut out = Vec::with_capacity(versions.len()); for mut v in versions { let full = v.tree; v.tree = self.to_visible(key, &full, &hidden)?; if v.n > 1 && let Some(prev) = self.db.version(key, v.n - 1)? && self.to_visible(key, &prev.tree, &hidden)? == v.tree && prev.tree != full { v.title = "a private change".into(); v.body = String::new(); v.change = String::new(); } out.push(v); } Ok(out) } pub fn versions_since(&self, key: &str, since: u64, limit: i64) -> Res> { let mut stmt = self.db.conn.prepare( "SELECT n, tree, prev, change, author, time, title, body, git FROM versions WHERE project = ?1 AND n > ?2 ORDER BY n LIMIT ?3", )?; let rows = stmt.query_map(params![key, since as i64, limit], |row| { let id = |i: usize| -> rusqlite::Result> { Ok(row.get::<_, Option>(i)?.and_then(|t| Id::parse(&t))) }; Ok(Version { n: row.get::<_, i64>(0)? as u64, tree: id(1)?.unwrap_or(Id::ZERO), prev: id(2)?, change: row.get(3)?, author: row.get(4)?, time: row.get(5)?, title: row.get(6)?, body: row.get(7)?, git: row.get(8)?, }) })?; Ok(rows.collect::>>()?) } // Objects. pub fn missing(&self, ids: &[Id]) -> Vec { ids.iter().filter(|i| !self.store.has(i)).copied().collect() } fn add_root(&self, key: &str, tree: &Id, change: &str) -> Res<()> { self.db.conn.execute("INSERT OR IGNORE INTO s_roots (project, tree, change) VALUES (?1, ?2, ?3)", params![key, tree.hex(), change])?; Ok(()) } /// Whether `user` may read the objects under `tree`: it's a version's /// tree, or a save of a change they can see. pub fn may_read_tree(&self, key: &str, access: &Access, tree: &Id) -> Res { let hidden = self.hidden_for(key, access.user.as_ref().map(|u| u.handle.as_str()))?; if !hidden.is_empty() { // Only trees made for this audience, or this person's own work. let audience = Self::audience(&hidden); if self.to_full(key, &audience, tree)?.is_some() { return Ok(true); } let handle = access.user.as_ref().map(|u| u.handle.clone()).unwrap_or_default(); let mut stmt = self.db.conn.prepare("SELECT r.change FROM s_roots r JOIN s_changes c ON c.project = r.project AND c.id = r.change WHERE r.project = ?1 AND r.tree = ?2 AND c.owner = ?3")?; let own = stmt.query_map(params![key, tree.hex(), handle], |r| r.get::<_, String>(0))?.next().is_some(); return Ok(own || tree == &tree::empty_id()); } let mut stmt = self.db.conn.prepare("SELECT change FROM s_roots WHERE project = ?1 AND tree = ?2")?; let changes: Vec = stmt.query_map(params![key, tree.hex()], |r| r.get(0))?.collect::>()?; if changes.iter().any(|c| c.is_empty()) { return Ok(true); } for c in changes { if let Some((record, _)) = self.change_row(key, &c)? && self.visible(key, access, &record)? { return Ok(true); } } Ok(tree == &tree::empty_id()) } /// Every object under `tree` that `have` doesn't share, as a bundle. /// A tree's folders only (a lazy workspace fetches files as they're opened). pub fn folder_bundle(&self, tree: &Id) -> Res> { let mut ids = Vec::new(); let mut stack = vec![*tree]; while let Some(t) = stack.pop() { ids.push(t); stack.extend(tree::read(&self.store, &t)?.into_iter().filter(|e| e.kind == tree::EntryKind::Tree).map(|e| e.id)); } Ok(self.store.bundle(&ids)?) } /// Each file's size under `tree`, by its ID. pub fn file_sizes(&self, tree: &Id) -> Res> { let mut out = std::collections::BTreeMap::new(); for e in tree::flatten(&self.store, tree)?.into_values().filter(|e| e.kind != tree::EntryKind::Gitlink) { if let Some(size) = self.store.file_size(&e.id)? { out.insert(e.id.hex(), size); } } Ok(out) } /// The files at `paths` in `tree`, a big file's parts included. pub fn files_bundle(&self, tree: &Id, paths: &[String]) -> Res> { let mut all = Vec::new(); for p in paths.iter().take(1000) { if let Some(e) = tree::lookup(&self.store, tree, p)?.filter(|e| e.kind != tree::EntryKind::Tree && e.kind != tree::EntryKind::Gitlink) { all.extend(self.store.file_parts(&e.id)?); } } Ok(self.store.bundle(&all)?) } pub fn tree_bundle(&self, tree: &Id, have: Option<&Id>) -> Res> { let mut ids = Vec::new(); tree::new_since(&self.store, have, tree, &mut ids)?; let mut all = Vec::with_capacity(ids.len()); for id in ids { all.extend(self.store.file_parts(&id)?); } Ok(self.store.bundle(&all)?) } // Changes. fn change_row(&self, key: &str, id: &str) -> Res)>> { let row: Option<(String, String, Option, Option)> = self .db .conn .query_row("SELECT record, owner, holder, holder_label FROM s_changes WHERE project = ?1 AND id = ?2", params![key, id], |r| { Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)) }) .optional()?; Ok(row.map(|(record, owner, holder, label)| { let mut c: ChangeRecord = serde_json::from_str(&record).unwrap_or_else(|_| empty_record(id)); c.owner = owner; c.holder = label; (c, holder) })) } pub fn visible(&self, key: &str, access: &Access, c: &ChangeRecord) -> Res { let Some(user) = &access.user else { return Ok(false) }; if c.owner == user.handle || access.can.admin { return Ok(true); } if c.state == "waiting" && access.can.land { return Ok(true); } let member = access.can.land || self.is_member(key, &user.handle)?; Ok(c.shared.iter().any(|s| self.matches(key, &s.who, Some(&user.handle), member))) } /// Whether `who` (from a rule or a share) takes in `handle`: themselves, /// a group they're in, `team` or `everyone`. fn matches(&self, key: &str, who: &str, handle: Option<&str>, member: bool) -> bool { match who { "everyone" => true, "team" => member, other => { let Some(h) = handle else { return false }; let name = other.trim_start_matches('@'); name == h || self.group(key, name).is_some_and(|people| people.iter().any(|p| p == h)) } } } fn group(&self, key: &str, name: &str) -> Option> { let text: Option = self.db.conn.query_row("SELECT members FROM s_groups WHERE project = ?1 AND name = ?2", params![key, name], |r| r.get(0)).optional().ok()?; text.and_then(|t| serde_json::from_str(&t).ok()) } pub fn groups(&self, key: &str) -> Res>> { let mut stmt = self.db.conn.prepare("SELECT name, members FROM s_groups WHERE project = ?1 ORDER BY name")?; let rows = stmt.query_map([key], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?; let mut out = std::collections::BTreeMap::new(); for row in rows { let (name, members) = row?; out.insert(name, serde_json::from_str(&members).unwrap_or_default()); } Ok(out) } /// Sets a group's people (owners only); no people removes it. pub fn set_group(&self, key: &str, access: &Access, name: &str, members: &[String]) -> Res>> { if !access.can.admin { return Err(problem(403, "not_allowed", "only the project's owners can change groups")); } let mut people: Vec = members.iter().map(|m| m.trim_start_matches('@').to_string()).collect(); people.dedup(); if people.is_empty() { self.db.conn.execute("DELETE FROM s_groups WHERE project = ?1 AND name = ?2", params![key, name])?; } else { self.db.conn.execute("INSERT OR REPLACE INTO s_groups (project, name, members) VALUES (?1, ?2, ?3)", params![key, name, serde_json::to_string(&people).unwrap()])?; } self.groups(key) } /// Private paths, and folders holding them, look missing to people who /// can't see them. fn no_private(&self, key: &str, access: &Access, path: &str) -> Res<()> { let hidden = self.hidden_for(key, access.user.as_ref().map(|u| u.handle.as_str()))?; if hidden.iter().any(|h| tree::under(path, h) || tree::under(h, path)) { return Err(problem(404, "not_found", format!("there's no {path} here"))); } Ok(()) } /// `on log `: the versions that changed it, newest first. pub fn history(&self, key: &str, access: &Access, path: &str, limit: usize) -> Res { let path = path.trim_matches('/'); self.no_private(key, access, path)?; let (changes, more) = onset_cli::history::following(&self.store, &self.db, key, path, limit)?; let mut picked: Vec = Vec::with_capacity(changes.len()); for (n, ..) in &changes { picked.extend(self.db.version(key, *n)?); } let shown = self.versions_for(key, access, picked)?; let kinds = changes.iter().map(|(_, k, _, _, from)| match k { _ if from.is_some() => "renamed", onset_core::history::Kind::Added => "added", onset_core::history::Kind::Modified => "modified", onset_core::history::Kind::Removed => "removed", }); let list: Vec = shown .iter() .zip(kinds.zip(changes.iter().map(|c| c.4.clone()))) .map(|(v, (kind, from))| { let mut x = serde_json::to_value(v).unwrap_or_default(); x["kind"] = kind.into(); x["from"] = from.into(); x }) .collect(); Ok(serde_json::json!({ "path": path, "versions": list, "more": more })) } /// Names a version (`on tag`): people who can land. pub fn set_tag(&self, key: &str, access: &Access, name: &str, n: u64) -> Res { let Some(user) = access.user.as_ref().filter(|_| access.can.land) else { return Err(problem(403, "not_allowed", "only people who can land name versions")); }; if let Some(why) = onset_cli::tags::bad_name(name) { return Err(problem(400, "bad_usage", format!("{name}: {why}"))); } if self.db.version(key, n)?.is_none() { return Err(problem(404, "not_found", format!("main has no version {n}"))); } if let Some(t) = self.db.tag(key, name)? { return if t.n == n { Ok(t) } else { Err(problem(409, "conflict", format!("tag {name} is on main {}; remove it first to move it", t.n))) }; } let t = onset_cli::tags::Tag { name: name.to_string(), n, by: user.name.clone(), time: now() }; self.db.put_tag(key, &t)?; Ok(t) } pub fn remove_tag(&self, key: &str, access: &Access, name: &str) -> Res<()> { if !access.can.land { return Err(problem(403, "not_allowed", "only people who can land remove tags")); } if !self.db.drop_tag(key, name)? { return Err(problem(404, "not_found", format!("there's no tag {name}"))); } Ok(()) } // Agent sessions (on sessions): kept with the change that landed them; // only its owner sees one, and people the change is shared with. pub fn keep_session(&self, key: &str, access: &Access, change: &str, row: &onset_cli::db::SessionRow) -> Res { let user = access.user.as_ref().ok_or_else(|| problem(401, "not_logged_in", "sign in first"))?; let owner = self.change_row(key, change)?.map(|(c, _)| c.owner).unwrap_or_else(|| user.handle.clone()); if owner != user.handle { return Err(problem(403, "not_allowed", "a session is kept only with your own change")); } if !self.store.has(&row.file) { return Err(problem(400, "bad_usage", "upload the session first")); } let n: i64 = self.db.conn.query_row("SELECT coalesce(max(n), 0) + 1 FROM s_sessions WHERE project = ?1 AND change = ?2", params![key, change], |r| r.get(0))?; self.db.conn.execute( "INSERT INTO s_sessions (project, change, n, owner, agent, file, bytes, messages, at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", params![key, change, n, user.handle, row.agent, row.file.hex(), row.bytes as i64, row.messages as i64, now()], )?; Ok(serde_json::json!({ "n": n })) } fn sees_sessions(&self, key: &str, access: &Access, change: &str, owner: &str) -> Res { let Some(user) = access.user.as_ref() else { return Ok(false) }; if user.handle == owner { return Ok(true); } let member = self.is_member(key, &user.handle)?; Ok(self.change_row(key, change)?.is_some_and(|(c, _)| c.shared.iter().any(|s| self.matches(key, &s.who, Some(&user.handle), member)))) } fn session_rows(&self, key: &str, change: Option<&str>, owner: Option<&str>) -> Res> { let mut stmt = self.db.conn.prepare( "SELECT change, owner, n, agent, file, bytes, messages, at FROM s_sessions WHERE project = ?1 AND (?2 IS NULL OR change = ?2) AND (?3 IS NULL OR owner = ?3) ORDER BY at DESC, n", )?; let rows = stmt.query_map(params![key, change, owner], |r| { let file: String = r.get(4)?; Ok(( r.get::<_, String>(0)?, r.get::<_, String>(1)?, onset_cli::db::SessionRow { n: r.get::<_, i64>(2)? as u64, agent: r.get(3)?, file: Id::parse(&file).unwrap_or(Id::ZERO), bytes: r.get::<_, i64>(5)? as u64, messages: r.get::<_, i64>(6)? as u64, at: r.get(7)? }, )) })?; Ok(rows.collect::>>()?) } pub fn sessions_of(&self, key: &str, access: &Access, change: &str) -> Res> { let mut out = Vec::new(); for (_, owner, row) in self.session_rows(key, Some(change), None)? { if self.sees_sessions(key, access, change, &owner)? { out.push(row); } } out.sort_by_key(|r| r.n); Ok(out) } pub fn session_bundle(&self, key: &str, access: &Access, change: &str, n: u64) -> Res> { let row = self.sessions_of(key, access, change)?.into_iter().find(|r| r.n == n).ok_or_else(|| problem(404, "not_found", format!("no session {n} with change {change}")))?; Ok(self.store.bundle(&self.store.file_parts(&row.file)?)?) } pub fn my_sessions(&self, key: &str, access: &Access) -> Res { let user = access.user.as_ref().ok_or_else(|| problem(401, "not_logged_in", "sign in first"))?; let list: Vec = self .session_rows(key, None, Some(&user.handle))? .into_iter() .map(|(change, _, row)| { let mut v = serde_json::to_value(&row).unwrap_or_default(); v["change"] = serde_json::Value::String(change); v }) .collect(); Ok(serde_json::json!({ "sessions": list })) } /// Deletes the caller's sessions (one change's, or all of them here), their text included. pub fn delete_sessions(&self, key: &str, access: &Access, change: Option<&str>) -> Res { let user = access.user.as_ref().ok_or_else(|| problem(401, "not_logged_in", "sign in first"))?; let rows = self.session_rows(key, change, Some(&user.handle))?; for (c, _, row) in &rows { self.db.conn.execute("DELETE FROM s_sessions WHERE project = ?1 AND change = ?2 AND n = ?3", params![key, c, row.n as i64])?; } let live = self.live_objects()?; for (_, _, row) in &rows { for part in self.store.file_parts(&row.file).unwrap_or_else(|_| vec![row.file]) { if !live.contains(&part) { self.store.remove(&part); } } } Ok(rows.len()) } // Deleting from history (on purge). /// What on purge has taken out of the project, oldest first: [{n, paths, at}]. pub fn purges(&self, key: &str) -> Res> { let text: Option = self.db.conn.query_row("SELECT value FROM s_settings WHERE project = ?1 AND key = 'purges'", [key], |r| r.get(0)).optional()?; Ok(text.and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default()) } /// Takes `paths` out of the project's history (owners): every version of /// main and of its lines, every change and save. What was at them is /// deleted once nothing on this server still holds it (the store is /// every project's). pub fn purge(&self, key: &str, access: &Access, paths: &[String], dry_run: bool) -> Res { use std::collections::{BTreeMap, BTreeSet}; if !access.can.admin { return Err(problem(403, "not_allowed", "only the project's owners delete paths from history")); } let paths: Vec = paths.iter().map(|p| p.trim_matches('/').to_string()).collect::>().into_iter().collect(); if paths.is_empty() || paths.len() > 100 { return Err(problem(400, "bad_usage", "name 1 to 100 paths")); } if let Some(bad) = paths.iter().find(|p| !tree::valid_path(p)) { return Err(problem(400, "bad_usage", format!("{bad:?} isn't a path in the project"))); } let mut versions = self.db.versions(key, i64::MAX)?; versions.reverse(); let mut line_rows: Vec<(String, u64, Id)> = Vec::new(); for l in self.db.lines(key)? { for v in self.db.line_versions(key, &l.name, i64::MAX)? { line_rows.push((v.line, v.n, v.tree)); } } let changes: Vec = { let mut stmt = self.db.conn.prepare("SELECT record FROM s_changes WHERE project = ?1")?; stmt.query_map([key], |r| r.get::<_, String>(0))?.filter_map(|r| r.ok()).filter_map(|t| serde_json::from_str(&t).ok()).collect() }; let pairs = |sql: &str| -> Res> { let mut stmt = self.db.conn.prepare(sql)?; Ok(stmt.query_map([key], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?.collect::>()?) }; let saves = pairs("SELECT change || ':' || n, tree, base_tree FROM s_saves WHERE project = ?1")?; let roots = pairs("SELECT change, tree, tree FROM s_roots WHERE project = ?1")?; let approvals = pairs("SELECT change, handle, tree FROM s_approvals WHERE project = ?1")?; let mut trees: BTreeSet = BTreeSet::new(); trees.extend(versions.iter().map(|v| v.tree)); trees.extend(line_rows.iter().map(|r| r.2)); for c in &changes { trees.insert(c.base_tree); trees.extend(c.head_tree); } for (_, a, b) in saves.iter().chain(&roots).chain(&approvals) { trees.extend(Id::parse(a)); trees.extend(Id::parse(b)); } let mut map: BTreeMap = BTreeMap::new(); let mut gone = BTreeSet::new(); for t in &trees { let (after, objects) = match onset_core::purge::without(&self.store, t, &paths, &mut gone) { Ok(done) => done, Err(onset_core::Error::Missing(_)) => continue, Err(e) => return Err(e.into()), }; if after != *t { if !dry_run { for o in &objects { self.store.put(o)?; } } map.insert(*t, after); } } for id in gone.clone() { gone.extend(self.store.file_parts(&id).unwrap_or_default()); } let first = versions.iter().find(|v| map.contains_key(&v.tree)).map(|v| v.n); let mut purged = onset_cli::purge::Purged { paths: paths.clone(), versions: versions.iter().filter(|v| map.contains_key(&v.tree)).count() as u64, first, lines: line_rows.iter().filter(|r| map.contains_key(&r.2)).count() as u64, changes: changes.iter().filter(|c| map.contains_key(&c.base_tree) || c.head_tree.is_some_and(|h| map.contains_key(&h))).count() as u64, objects: gone.iter().filter(|id| self.store.has(id)).count() as u64, bytes: gone.iter().map(|id| self.store.stored_len(id)).sum(), done: false, }; if dry_run || (map.is_empty() && gone.is_empty()) { return Ok(purged); } let swap = |id: &Id| map.get(id).copied().unwrap_or(*id); let swap_text = |text: &str| Id::parse(text).map(|id| swap(&id).hex()).unwrap_or_else(|| text.to_string()); let tx = self.db.conn.unchecked_transaction()?; if let Some(first) = first { let mut prev = versions.iter().find(|v| v.n + 1 == first).map(|v| v.id(key)); for v in versions.iter().filter(|v| v.n >= first) { let mut v = v.clone(); v.tree = swap(&v.tree); v.prev = prev; prev = Some(v.id(key)); self.db.put_version(key, &v)?; self.add_root(key, &v.tree, "")?; } } for (line, n, t) in &line_rows { if map.contains_key(t) { self.db.conn.execute("UPDATE line_versions SET tree = ?4 WHERE project = ?1 AND line = ?2 AND n = ?3", params![key, line, *n as i64, swap(t).hex()])?; self.add_root(key, &swap(t), "")?; } } for c in &changes { if map.contains_key(&c.base_tree) || c.head_tree.is_some_and(|h| map.contains_key(&h)) { let mut c = c.clone(); c.base_tree = swap(&c.base_tree); c.head_tree = c.head_tree.map(|h| swap(&h)); self.db.conn.execute("UPDATE s_changes SET record = ?3 WHERE project = ?1 AND id = ?2", params![key, c.id, serde_json::to_string(&c).unwrap()])?; } } for (at, t, b) in &saves { let (change, n) = at.rsplit_once(':').unwrap_or((at, "0")); self.db.conn.execute("UPDATE s_saves SET tree = ?4, base_tree = ?5 WHERE project = ?1 AND change = ?2 AND n = ?3", params![key, change, n, swap_text(t), swap_text(b)])?; } for (change, t, _) in &roots { if let Some(id) = Id::parse(t).filter(|id| map.contains_key(id)) { self.db.conn.execute("INSERT OR IGNORE INTO s_roots (project, tree, change) VALUES (?1, ?2, ?3)", params![key, swap(&id).hex(), change])?; self.db.conn.execute("DELETE FROM s_roots WHERE project = ?1 AND tree = ?2 AND change = ?3", params![key, t, change])?; } } for (change, handle, t) in &approvals { self.db.conn.execute("UPDATE s_approvals SET tree = ?4 WHERE project = ?1 AND change = ?2 AND handle = ?3", params![key, change, handle, swap_text(t)])?; } // Views of private folders are worked out again as they're needed. self.db.conn.execute("DELETE FROM s_visible WHERE project = ?1", [key])?; let mut purges = self.purges(key)?; purges.push(serde_json::json!({ "n": purges.len() + 1, "paths": paths, "at": now() })); self.db.conn.execute("INSERT OR REPLACE INTO s_settings (project, key, value) VALUES (?1, 'purges', ?2)", params![key, serde_json::Value::Array(purges).to_string()])?; tx.commit()?; // What was at them goes, unless anything kept on this server still has it. let live = self.live_objects()?; purged.objects = 0; purged.bytes = 0; for id in gone.iter().filter(|id| !live.contains(id)) { let freed = self.store.remove(id); if freed > 0 { purged.objects += 1; purged.bytes += freed; } } purged.done = true; Ok(purged) } /// Everything every project here keeps reaches: main, lines, changes, /// saves, approvals and people's private files. fn live_objects(&self) -> Res> { let mut roots: Vec = Vec::new(); for sql in [ "SELECT tree FROM versions", "SELECT tree FROM line_versions", "SELECT tree FROM s_saves UNION SELECT base_tree FROM s_saves", "SELECT tree FROM s_roots", "SELECT tree FROM s_approvals", ] { let mut stmt = self.db.conn.prepare(sql)?; roots.extend(stmt.query_map([], |r| r.get::<_, String>(0))?.filter_map(|r| r.ok()).filter_map(|t| Id::parse(&t))); } let mut live = std::collections::HashSet::new(); let mut stmt = self.db.conn.prepare("SELECT record FROM s_changes")?; for record in stmt.query_map([], |r| r.get::<_, String>(0))?.filter_map(|r| r.ok()) { if let Ok(c) = serde_json::from_str::(&record) { roots.push(c.base_tree); roots.extend(c.head_tree); } } // Agent sessions still kept. let mut stmt = self.db.conn.prepare("SELECT file FROM s_sessions")?; for file in stmt.query_map([], |r| r.get::<_, String>(0))?.filter_map(|r| r.ok()).filter_map(|t| Id::parse(&t)) { live.extend(self.store.file_parts(&file).unwrap_or_else(|_| vec![file])); } let mut stmt = self.db.conn.prepare("SELECT files FROM s_mine")?; for files in stmt.query_map([], |r| r.get::<_, String>(0))?.filter_map(|r| r.ok()) { for f in serde_json::from_str::>(&files).unwrap_or_default() { live.extend(self.store.file_parts(&f.file).unwrap_or_else(|_| vec![f.file])); } } let mut stack = roots; while let Some(t) = stack.pop() { if !live.insert(t) { continue; } let Ok(entries) = tree::read(&self.store, &t) else { continue }; for e in entries { match e.kind { tree::EntryKind::Tree => stack.push(e.id), tree::EntryKind::Gitlink => {} _ => live.extend(self.store.file_parts(&e.id).unwrap_or_else(|_| vec![e.id])), } } } Ok(live) } // Release lines. /// The project's lines, each with its latest version's tree as the caller sees it. pub fn lines(&self, key: &str, access: &Access) -> Res { let hidden = self.hidden_for(key, access.user.as_ref().map(|u| u.handle.as_str()))?; let mut out = Vec::new(); for l in self.db.lines(key)? { let Some(tree) = self.db.line_tree(key, &l, l.head)? else { continue }; out.push(serde_json::json!({ "name": l.name, "from": l.from, "head": l.head, "head_tree": self.to_visible(key, &tree, &hidden)? })); } Ok(serde_json::json!({ "lines": out })) } /// Starts a line from version `from` of main: people who can land. pub fn add_line(&self, key: &str, access: &Access, name: &str, from: u64) -> Res { if !access.can.land { return Err(problem(403, "not_allowed", "only people who can land start release lines")); } if let Some(why) = onset_cli::lines::bad_name(name) { return Err(problem(400, "bad_usage", format!("{name:?} can't be a line: {why}"))); } if self.db.version(key, from)?.is_none() { return Err(problem(404, "not_found", format!("main has no version {from}"))); } if self.db.line(key, name)?.is_some() { return Err(problem(409, "exists", format!("there's a line {name} already"))); } self.db.put_line(key, name, from)?; Ok(serde_json::json!({ "name": name, "from": from })) } /// A line's versions, newest first, as the caller sees them. pub fn line_versions(&self, key: &str, access: &Access, name: &str, limit: i64) -> Res { if self.db.line(key, name)?.is_none() { return Err(problem(404, "not_found", format!("there's no line {name}"))); } let hidden = self.hidden_for(key, access.user.as_ref().map(|u| u.handle.as_str()))?; let mut list = self.db.line_versions(key, name, limit.clamp(1, 500))?; for v in &mut list { v.tree = self.to_visible(key, &v.tree, &hidden)?; } Ok(serde_json::json!({ "versions": list })) } /// A file at a version (the latest when None), as the caller sees it, /// for reading without a workspace (up to 1 MB). pub fn read_path(&self, key: &str, access: &Access, version: Option, path: &str) -> Res { use base64::Engine; let path = path.trim_matches('/'); let v = match version { Some(n) => self.db.version(key, n)?, None => self.db.latest_version(key)?, } .ok_or_else(|| problem(404, "not_found", "there's no such version"))?; let n = v.n; let shown = self.versions_for(key, access, vec![v])?.pop().ok_or_else(|| problem(404, "not_found", "there's no such version"))?; let entry = match tree::lookup(&self.store, &shown.tree, path)? { Some(e) if e.kind != tree::EntryKind::Tree => e, _ => return Err(problem(404, "not_found", format!("there's no file {path} in version {n}"))), }; if entry.kind == tree::EntryKind::Gitlink { return Ok(serde_json::json!({ "version": n, "path": path, "kind": entry.kind.name() })); } let content = onset_core::chunk::read_file(&self.store, &entry.id)?; if content.len() > 1 << 20 { return Ok(serde_json::json!({ "version": n, "path": path, "kind": entry.kind.name(), "too_big": content.len() })); } Ok(serde_json::json!({ "version": n, "path": path, "kind": entry.kind.name(), "base64": base64::engine::general_purpose::STANDARD.encode(content) })) } /// `on blame `: the version that last changed each line. pub fn blame(&self, key: &str, access: &Access, path: &str) -> Res { use base64::Engine; let path = path.trim_matches('/'); self.no_private(key, access, path)?; let Some((owners, content)) = onset_cli::history::blame(&self.store, &self.db, key, path)? else { return Err(problem(404, "not_found", format!("there's no file {path} in main"))); }; if content.len() > 1 << 20 { return Err(problem(413, "too_big", format!("{path} is over 1 MB; blame reads files up to 1 MB"))); } let mut picked: Vec = Vec::new(); for (n, _) in &owners { if !picked.iter().any(|v| v.n == *n) { picked.extend(self.db.version(key, *n)?); } } let shown = self.versions_for(key, access, picked)?; let info: serde_json::Map = shown.iter().map(|v| (v.n.to_string(), serde_json::json!({ "title": v.title, "author": v.author, "time": v.time }))).collect(); Ok(serde_json::json!({ "path": path, "owners": owners, "versions": info, "base64": base64::engine::general_purpose::STANDARD.encode(content) })) } /// Checks a project: each version follows the one before it (its `prev` /// is that version's ID), and everything main needs is stored. pub fn check(&self, key: &str, access: &Access) -> Res { if !access.can.admin { return Err(problem(403, "not_allowed", "only the project's owners can check it")); } let all = self.db.versions(key, i64::MAX)?; let mut chain = None; let mut prev: Option = None; for (i, v) in all.iter().rev().enumerate() { if v.n != i as u64 + 1 { chain = Some(format!("version {} is missing", i + 1)); break; } if v.prev != prev { chain = Some(format!("version {} doesn't follow version {i}", v.n)); break; } prev = Some(v.id(key)); } let (mut files, mut folders, mut missing) = (0, std::collections::HashSet::::new(), Vec::new()); if let Some(main) = all.first() { match tree::flatten(&self.store, &main.tree) { Err(e) => missing.push(format!("a folder of main ({e})")), Ok(flat) => { for (path, e) in &flat { files += 1; let mut at = 0; while let Some(i) = path[at..].find('/') { folders.insert(path[..at + i].to_string()); at += i + 1; } if e.kind == tree::EntryKind::Gitlink { continue; } let whole = match self.store.read(&e.id)? { None => false, Some(o) if o.kind == onset_core::Kind::Chunks => onset_core::object::decode_chunks(&o.data)?.iter().all(|(c, _)| self.store.has(c)), Some(_) => true, }; if !whole { missing.push(path.clone()); } } } } } missing.truncate(100); Ok(serde_json::json!({ "versions": all.len(), "chain": chain, "folders": folders.len(), "files": files, "missing": missing })) } pub fn approvals_required(&self, key: &str) -> Res { let v: Option = self.db.conn.query_row("SELECT value FROM s_settings WHERE project = ?1 AND key = 'approvals'", [key], |r| r.get(0)).optional()?; Ok(v.and_then(|v| v.parse().ok()).unwrap_or(0)) } pub fn set_approvals(&self, key: &str, access: &Access, n: u32) -> Res { if !access.can.admin { return Err(problem(403, "not_allowed", "only the project's owners can change settings")); } if n > 10 { return Err(problem(400, "bad_usage", "approvals is a number from 0 to 10")); } self.db.conn.execute("INSERT OR REPLACE INTO s_settings (project, key, value) VALUES (?1, 'approvals', ?2)", params![key, n.to_string()])?; Ok(n) } /// Approvals of a change for exactly `tree`, from people other than its owner. fn approvals_for(&self, key: &str, change: &str, tree: &Id, owner: &str) -> Res> { let mut stmt = self.db.conn.prepare("SELECT handle FROM s_approvals WHERE project = ?1 AND change = ?2 AND tree = ?3 AND handle != ?4 ORDER BY at")?; let rows = stmt.query_map(params![key, change, tree.hex(), owner], |r| r.get::<_, String>(0))?; Ok(rows.collect::>>()?) } /// Approves someone else's change as it is now; the last approval the /// project needs lands it. pub fn approve(&self, key: &str, access: &Access, id: &str) -> Res { let user = access.user.as_ref().ok_or_else(|| problem(401, "not_logged_in", "sign in first"))?; if !access.can.land { return Err(problem(403, "not_allowed", "only people who can land can approve")); } let (c, _) = self.change_row(key, id)?.filter(|(c, _)| self.visible(key, access, c).unwrap_or(false)).ok_or_else(|| problem(404, "not_found", format!("no change {id}")))?; if c.owner == user.handle { return Err(problem(403, "not_allowed", "someone else has to approve your change")); } if c.state != "open" && c.state != "waiting" { return Err(problem(400, "bad_usage", format!("change {id} is {}", c.state))); } let tree = c.head_tree.unwrap_or(c.base_tree); self.db.conn.execute( "INSERT OR REPLACE INTO s_approvals (project, change, handle, tree, at) VALUES (?1, ?2, ?3, ?4, ?5)", params![key, id, user.handle, tree.hex(), now()], )?; let required = self.approvals_required(key)?; let approvals = self.approvals_for(key, id, &tree, &c.owner)?; if c.state == "waiting" && approvals.len() as u32 >= required { let request = LandRequest { change: c.id.clone(), base_tree: c.base_tree, tree, title: c.title.clone(), body: c.body.clone(), author: c.author.clone(), allow_secrets: true, record: None, at: None, signature: None, line: None, undoes: None }; return Ok(serde_json::to_value(self.land(key, access, &request)?).unwrap_or_default()); } Ok(serde_json::json!({ "outcome": "approved", "approvals": approvals, "required": required })) } fn is_member(&self, key: &str, handle: &str) -> Res { Ok(self .db .conn .query_row("SELECT 1 FROM s_members WHERE project = ?1 AND handle = ?2", params![key, handle], |_| Ok(())) .optional()? .is_some()) } fn may_edit(&self, key: &str, access: &Access, c: &ChangeRecord) -> bool { let Some(user) = &access.user else { return false }; let member = access.can.land || self.is_member(key, &user.handle).unwrap_or(false); c.owner == user.handle || c.shared.iter().any(|s| s.edit && self.matches(key, &s.who, Some(&user.handle), member)) } /// A change as a viewer sees it: trees made for their audience when the /// change belongs to someone who sees more. fn for_viewer(&self, key: &str, access: &Access, mut c: ChangeRecord) -> Res { let viewer = access.user.as_ref().map(|u| u.handle.as_str()); if viewer == Some(c.owner.as_str()) { return Ok(c); } let hidden = self.hidden_for(key, viewer)?; if hidden.is_empty() { return Ok(c); } c.base_tree = self.to_visible(key, &c.base_tree, &hidden)?; if let Some(h) = c.head_tree { c.head_tree = Some(self.to_visible(key, &h, &hidden)?); } Ok(c) } pub fn changes(&self, key: &str, access: &Access) -> Res> { let mut stmt = self.db.conn.prepare("SELECT id FROM s_changes WHERE project = ?1 AND state IN ('open', 'waiting') ORDER BY updated DESC LIMIT 500")?; let ids: Vec = stmt.query_map([key], |r| r.get(0))?.collect::>()?; let mut out = Vec::new(); for id in ids { if let Some((c, _)) = self.change_row(key, &id)? && self.visible(key, access, &c)? { out.push(self.for_viewer(key, access, c)?); } } Ok(out) } pub fn find_change(&self, key: &str, access: &Access, prefix: &str) -> Res<(ChangeRecord, Vec)> { let mut stmt = self.db.conn.prepare("SELECT id FROM s_changes WHERE project = ?1 AND id LIKE ?2 || '%' LIMIT 10")?; let ids: Vec = stmt.query_map(params![key, prefix], |r| r.get(0))?.collect::>()?; let mut found = Vec::new(); for id in ids { if let Some((c, _)) = self.change_row(key, &id)? && self.visible(key, access, &c)? { found.push(c); } } match found.len() { 0 => Err(problem(404, "not_found", format!("no change {prefix}"))), 1 => { let c = found.remove(0); let saves = self.saves(key, &c.id)?; Ok((self.for_viewer(key, access, c)?, saves)) } _ => Err(problem(400, "bad_usage", format!("{prefix} matches several changes"))), } } fn saves(&self, key: &str, change: &str) -> Res> { let mut stmt = self.db.conn.prepare("SELECT n, tree, base_tree, at FROM s_saves WHERE project = ?1 AND change = ?2 ORDER BY n")?; let rows = stmt.query_map(params![key, change], |r| { let tree: String = r.get(1)?; let base: String = r.get(2)?; Ok(SaveRecord { n: r.get(0)?, tree: Id::parse(&tree).unwrap_or(Id::ZERO), base_tree: Id::parse(&base).unwrap_or(Id::ZERO), at: r.get(3)?, workspace: String::new() }) })?; Ok(rows.collect::>>()?) } /// Someone who can't see a path may only put trees without it in their /// changes. Otherwise naming a full tree's ID in a change of their own /// would let them read what's hidden. fn claim(&self, key: &str, access: &Access, trees: &[Option]) -> Res<()> { let hidden = self.hidden_for(key, access.user.as_ref().map(|u| u.handle.as_str()))?; if hidden.is_empty() { return Ok(()); } for t in trees.iter().flatten() { if self.may_read_tree(key, access, t)? || !self.store.has(t) { continue; } if let Some(path) = onset_core::filter::holds_hidden(&self.store, t, &hidden)? { return Err(problem(403, "not_allowed", format!("you can't change {path}: it's private"))); } } Ok(()) } fn held_elsewhere(holder: &Option, workspace: &str, label: Option) -> Res<()> { if let Some(h) = holder && h != workspace { return Err(Problem { status: 409, code: "held_elsewhere", message: "another workspace took this change over".into(), holder: label }); } Ok(()) } pub fn put_change(&self, key: &str, access: &Access, mut record: ChangeRecord, workspace: &str, label: &str) -> Res { let user = access.user.as_ref().ok_or_else(|| problem(401, "not_logged_in", "sign in first"))?; self.claim(key, access, &[Some(record.base_tree), record.head_tree])?; let existing = self.change_row(key, &record.id)?; match &existing { Some((old, holder)) => { if !self.may_edit(key, access, old) { // Landers may move a waiting change to landed; nobody else may write. return Err(problem(403, "not_allowed", "you can't change someone else's change")); } Self::held_elsewhere(holder, workspace, old.holder.clone())?; record.owner = old.owner.clone(); record.shared = old.shared.clone(); // A landed change stays as it landed (a client that missed the // answer still sends it as open); `on undo` reopens it. if old.state == "landed" { record = old.clone(); } } None => { record.owner = user.handle.clone(); record.shared = Vec::new(); } } record.updated = now(); let holder = match &existing { Some((_, Some(h))) => Some(h.clone()), _ => Some(workspace.to_string()), }; let label = match &existing { Some((old, Some(_))) => old.holder.clone().unwrap_or_default(), _ => label.to_string(), }; self.db.conn.execute( "INSERT OR REPLACE INTO s_changes (project, id, owner, record, holder, holder_label, state, updated) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", params![key, record.id, record.owner, serde_json::to_string(&record).unwrap(), holder, label, record.state, record.updated], )?; if let Some(t) = record.head_tree { self.add_root(key, &t, &record.id)?; } self.add_root(key, &record.base_tree, &record.id)?; record.holder = Some(label); Ok(record) } pub fn hold(&self, key: &str, access: &Access, id: &str, workspace: &str, label: &str) -> Res> { let (c, holder) = self.change_row(key, id)?.ok_or_else(|| problem(404, "not_found", format!("no change {id}")))?; if !self.may_edit(key, access, &c) { return Err(problem(403, "not_allowed", "you can't work on someone else's change unless they share it with --edit")); } let previous = if holder.as_deref() == Some(workspace) { None } else { c.holder.clone() }; self.db.conn.execute( "UPDATE s_changes SET holder = ?3, holder_label = ?4, updated = ?5 WHERE project = ?1 AND id = ?2", params![key, id, workspace, label, now()], )?; Ok(previous) } pub fn add_save(&self, key: &str, access: &Access, id: &str, save: &SaveRecord) -> Res<()> { let (c, holder) = self.change_row(key, id)?.ok_or_else(|| problem(404, "not_found", format!("no change {id}")))?; if !self.may_edit(key, access, &c) { return Err(problem(403, "not_allowed", "not your change")); } Self::held_elsewhere(&holder, &save.workspace, c.holder.clone())?; if !self.store.has(&save.tree) { return Err(problem(400, "bad_usage", "upload the save's objects first")); } self.claim(key, access, &[Some(save.tree)])?; self.db.conn.execute( "INSERT OR REPLACE INTO s_saves (project, change, n, tree, base_tree, at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", params![key, id, save.n, save.tree.hex(), save.base_tree.hex(), save.at], )?; self.add_root(key, &save.tree, id)?; Ok(()) } pub fn share(&self, key: &str, access: &Access, id: &str, who: &[String], edit: bool) -> Res> { let (mut c, holder) = self.change_row(key, id)?.ok_or_else(|| problem(404, "not_found", format!("no change {id}")))?; let user = access.user.as_ref().ok_or_else(|| problem(401, "not_logged_in", "sign in first"))?; if c.owner != user.handle { return Err(problem(403, "not_allowed", "only the change's owner can share it")); } if who.len() == 1 && who[0] == "me" { c.shared.clear(); } else { for w in who { c.shared.retain(|s| &s.who != w); c.shared.push(Share { who: w.clone(), edit }); } } let shared = c.shared.clone(); self.db.conn.execute( "UPDATE s_changes SET record = ?3 WHERE project = ?1 AND id = ?2", params![key, id, serde_json::to_string(&c).unwrap()], )?; let _ = holder; Ok(shared) } // Landing. pub fn land(&self, key: &str, access: &Access, request: &LandRequest) -> Res { let user = access.user.as_ref().ok_or_else(|| problem(401, "not_logged_in", "sign in first"))?; if !self.store.has(&request.tree) || !self.store.has(&request.base_tree) { return Err(problem(400, "bad_usage", "upload the change's objects first")); } let mut existing = self.change_row(key, &request.change)?; // A change made on the line is the line's; any other is brought over to it. let brought = request.line.is_some() && existing.as_ref().map(|(c, _)| c.line.as_deref()) != Some(request.line.as_deref()); // A land that already happened answers as it did: the first answer can // be lost (a dropped connection, a client that gave up), and landing // again would put the change on main twice. if !brought && let Some((c, _)) = &existing && c.state == "landed" && let Some(n) = c.version { return self.landed_again(key, access, c, n); } if !access.can.land { if brought { return Err(problem(403, "not_allowed", "only people who can land bring changes over to a release line")); } // Ask the people who can. let mut record = existing.map(|(c, _)| c).unwrap_or_else(|| empty_record(&request.change)); record.line = request.line.clone(); if record.owner.is_empty() { record.owner = user.handle.clone(); } if record.owner != user.handle { return Err(problem(403, "not_allowed", "you can't land someone else's change")); } self.claim(key, access, &[Some(request.tree), Some(request.base_tree)])?; record.title = request.title.clone(); record.body = request.body.clone(); record.base_tree = request.base_tree; record.head_tree = Some(request.tree); record.author = request.author.clone(); record.state = "waiting".into(); record.updated = now(); self.db.conn.execute( "INSERT INTO s_changes (project, id, owner, record, holder, holder_label, state, updated) VALUES (?1, ?2, ?3, ?4, NULL, '', 'waiting', ?5) ON CONFLICT(project, id) DO UPDATE SET record = excluded.record, state = 'waiting', updated = excluded.updated", params![key, record.id, record.owner, serde_json::to_string(&record).unwrap(), record.updated], )?; self.add_root(key, &request.tree, &request.change)?; self.add_root(key, &request.base_tree, &request.change)?; return Ok(Landed::Waiting { asked: self.landers(key)?.into_iter().map(|h| format!("@{h}")).collect(), reason: "needs someone with landing rights to land it".into() }); } if let Some((c, _)) = &existing && c.owner != user.handle && !self.visible(key, access, c)? { return Err(problem(403, "not_allowed", "you can't land a change you can't see")); } // What a change brought over to a line lands is in the request. if brought { existing = None; } // A project that requires approvals lands a change once enough people // other than its owner approved exactly what lands. (A release line's // versions are landed by people who can land, as with Upstream's host.) let required = if request.line.is_some() { 0 } else { self.approvals_required(key)? }; if required > 0 { let owner = existing.as_ref().map(|(c, _)| c.owner.clone()).unwrap_or_else(|| user.handle.clone()); let tree = if owner == user.handle { request.tree } else { existing.as_ref().and_then(|(c, _)| c.head_tree).unwrap_or(request.tree) }; let approvals = self.approvals_for(key, &request.change, &tree, &owner)?; if (approvals.len() as u32) < required { if let Some((mut c, _)) = existing.clone() { if owner == user.handle { c.title = request.title.clone(); c.body = request.body.clone(); c.base_tree = request.base_tree; c.head_tree = Some(request.tree); } c.state = "waiting".into(); c.updated = now(); self.db.conn.execute( "UPDATE s_changes SET record = ?3, state = 'waiting', updated = ?4 WHERE project = ?1 AND id = ?2", params![key, c.id, serde_json::to_string(&c).unwrap(), c.updated], )?; self.add_root(key, &tree, &c.id)?; } let asked: Vec = self.landers(key)?.into_iter().filter(|h| *h != owner).map(|h| format!("@{h}")).collect(); return Ok(Landed::Waiting { asked, reason: format!("needs {required} {} (has {}): someone else runs on approve {}", if required == 1 { "approval" } else { "approvals" }, approvals.len(), &request.change[..4.min(request.change.len())]), }); } } // Someone else's change lands as its owner made it, in the owner's view. let (owner, base, tree) = match &existing { Some((c, _)) if c.owner != user.handle => (c.owner.clone(), c.base_tree, c.head_tree.unwrap_or(c.base_tree)), _ => (user.handle.clone(), request.base_tree, request.tree), }; if let Some(bad) = tree::unsafe_path(&self.store, &base, &tree)? { return Err(problem(400, "bad_usage", format!("{bad} can't be in a project: it's Onset's or git's own folder, or not a safe path"))); } let hidden = self.hidden_for(key, Some(&owner))?; let mut full = request.clone(); full.base_tree = base; full.tree = tree; if !hidden.is_empty() { let audience = Self::audience(&hidden); let full_base = match self.to_full(key, &audience, &base)? { Some(f) => f, None if self.is_version_tree(key, &base)? => base, None => { return Err(problem(409, "conflict", "this change started from something the host can't place; on sync, then land again")); } }; match onset_core::filter::graft(&self.store, &tree, &full_base, &hidden)? { Ok((t, objects)) => { for o in &objects { self.store.put(o)?; } self.remember(key, &audience, &tree, &t)?; full.base_tree = full_base; full.tree = t; } Err(path) => return Err(problem(403, "not_allowed", format!("you can't change {path}: it's private"))), } } let outcome = onset_cli::host::Local.land_on(&self.db, &self.store, key, &full)?; if let Landed::OnLine { line, n, .. } = &outcome { if let Some(l) = self.db.line(key, line)? && let Some(t) = self.db.line_tree(key, &l, *n)? { self.add_root(key, &t, "")?; } // A change made on the line is done; one brought over from elsewhere stays as it is. if let Some((mut c, _)) = existing && c.line.as_deref() == Some(line.as_str()) { c.state = "landed".into(); c.version = Some(*n); c.updated = now(); self.db.conn.execute( "UPDATE s_changes SET record = ?3, state = 'landed', updated = ?4 WHERE project = ?1 AND id = ?2", params![key, c.id, serde_json::to_string(&c).unwrap(), c.updated], )?; } self.reopen_taken_back(key, request)?; return Ok(outcome); } if let Landed::Landed { version } = &outcome { self.add_root(key, &version.tree, "")?; if let Some((mut c, holder)) = existing { c.state = "landed".into(); c.version = Some(version.n); c.updated = now(); self.db.conn.execute( "UPDATE s_changes SET record = ?3, state = 'landed', updated = ?4 WHERE project = ?1 AND id = ?2", params![key, c.id, serde_json::to_string(&c).unwrap(), c.updated], )?; let _ = holder; } self.reopen_taken_back(key, request)?; } Ok(outcome) } /// The answer a land gave when it happened, for a change that landed already. fn landed_again(&self, key: &str, access: &Access, c: &ChangeRecord, n: u64) -> Res { if let Some(line) = &c.line { return Ok(Landed::OnLine { line: line.clone(), n, title: c.title.clone() }); } let v = self.db.version(key, n)?.ok_or_else(|| problem(500, "failed", format!("change {} landed as main {n}, which isn't here", c.id)))?; let version = self.versions_for(key, access, vec![v])?.pop().ok_or_else(|| problem(500, "failed", "no version"))?; Ok(Landed::Landed { version }) } /// After a land that takes a version back out (`on undo`), the change /// that made that version is open again, so it can be fixed and landed. fn reopen_taken_back(&self, key: &str, request: &LandRequest) -> Res<()> { let Some((line, n)) = request.takes_back() else { return Ok(()) }; let change = match &line { None => self.db.version(key, n)?.map(|v| v.change), Some(name) => self.db.line_version(key, name, n)?.map(|v| v.change), }; let Some(id) = change else { return Ok(()) }; if let Some((mut c, _)) = self.change_row(key, &id)? && c.state == "landed" && c.version == Some(n) && c.line == line { c.state = "open".into(); c.version = None; c.updated = now(); self.db.conn.execute( "UPDATE s_changes SET record = ?3, state = 'open', updated = ?4 WHERE project = ?1 AND id = ?2", params![key, c.id, serde_json::to_string(&c).unwrap(), c.updated], )?; } Ok(()) } /// Lands file edits without a workspace (see the hosted service's /// landEdits): a new change of the caller's on version `base`. #[allow(clippy::too_many_arguments)] pub fn land_edits(&self, key: &str, access: &Access, base: Option, put: Vec, delete: Vec, title: &str, body: &str, allow_secrets: bool, given_key: Option<&str>) -> Res<(String, Landed)> { let user = access.user.as_ref().ok_or_else(|| problem(401, "not_logged_in", "sign in first"))?; // The same edits again (a retry after a lost answer) answer as they did. let edit_key = edits_key(&user.handle, given_key, base, &put, &delete, title, body); let known: Option = self .db .conn .query_row("SELECT change FROM s_edit_keys WHERE project = ?1 AND key = ?2 AND at > ?3", params![key, edit_key, now() - EDIT_KEY_SECS], |r| r.get(0)) .optional()?; if let Some(id) = known && let Some((c, _)) = self.change_row(key, &id)? { match (c.state.as_str(), c.version) { ("landed", Some(n)) => return Ok((id, self.landed_again(key, access, &c, n)?)), ("waiting", _) => return Ok((id, Landed::Waiting { asked: self.landers(key)?.into_iter().map(|h| format!("@{h}")).collect(), reason: "these edits are waiting already".into() })), _ => {} } } let main = self.main(key)?; // A file is replaced whole, so edits to one that's there need the // version they were made on: on a later main they'd drop what others // changed in it since. if base.is_none() && let Some(m) = &main { let latest = self.versions_for(key, access, vec![m.clone()])?.pop().map(|v| v.tree).unwrap_or_else(tree::empty_id); for path in put.iter().map(|p| p.path.as_str()).chain(delete.iter().map(String::as_str)) { if tree::lookup(&self.store, &latest, path)?.is_some() { return Err(problem( 400, "bad_usage", format!("{path} is on main already: send base, the version you read it at, so edits others landed since aren't lost (main is at {})", m.n), )); } } } let n = base.unwrap_or_else(|| main.as_ref().map(|v| v.n).unwrap_or(0)); if n > main.as_ref().map(|v| v.n).unwrap_or(0) { return Err(problem(400, "bad_usage", format!("there's no version {n}"))); } let base_tree = match n { 0 => tree::empty_id(), _ => { let v = self.db.version(key, n)?.ok_or_else(|| problem(400, "bad_usage", format!("there's no version {n}")))?; self.versions_for(key, access, vec![v])?.pop().map(|v| v.tree).unwrap_or_else(tree::empty_id) } }; let (tree_id, objects) = match tree::edit(&self.store, &base_tree, &put, &delete)? { Ok(done) => done, Err(path) => return Err(problem(400, "bad_usage", format!("{path:?} isn't a path"))), }; if tree_id == base_tree { return Err(problem(400, "empty_change", "these edits change nothing")); } self.store.put(&tree::empty())?; for o in &objects { self.store.put(o)?; } if !allow_secrets && let Some(hit) = onset_core::secrets::scan(&self.store, &base_tree, &tree_id)?.first() { return Err(problem(422, "secret", format!("{} line {} looks like a {}; remove it, or send allow_secrets", hit.path, hit.line, hit.kind))); } let id = onset_cli::ids::new_change_id(); let author = if user.email.is_empty() { user.name.clone() } else { format!("{} <{}>", user.name, user.email) }; let mut record = empty_record(&id); record.title = title.to_string(); record.body = body.to_string(); record.base_tree = base_tree; record.base_version = (n > 0).then_some(n); record.head_tree = Some(tree_id); record.author = author.clone(); self.put_change(key, access, record, "api", "the API")?; self.db.conn.execute( "INSERT OR REPLACE INTO s_edit_keys (project, key, change, at) VALUES (?1, ?2, ?3, ?4)", params![key, edit_key, id, now()], )?; self.db.conn.execute("DELETE FROM s_edit_keys WHERE project = ?1 AND at < ?2", params![key, now() - EDIT_KEY_SECS])?; let request = LandRequest { change: id.clone(), base_tree, tree: tree_id, title: title.to_string(), body: body.to_string(), author, allow_secrets: true, record: None, at: None, signature: None, line: None, undoes: None }; Ok((id, self.land(key, access, &request)?)) } /// Takes a published project's history (only into an empty project). pub fn import(&self, key: &str, access: &Access, versions: Vec) -> Res> { if !access.can.admin { return Err(problem(403, "not_allowed", "only the project's owners can import history")); } if self.main(key)?.is_some() { return Err(problem(409, "exists", "this project already has versions")); } for v in &versions { if !self.store.has(&v.tree) { return Err(problem(400, "bad_usage", format!("upload version {}'s objects first", v.n))); } } let tx = self.db.conn.unchecked_transaction()?; let mut prev = None; let mut last = None; for (i, v) in versions.into_iter().enumerate() { let v = Version { n: i as u64 + 1, prev, ..v }; self.db.put_version(key, &v)?; self.add_root(key, &v.tree, "")?; prev = Some(v.id(key)); last = Some(v.n); } tx.commit()?; Ok(last) } // Rules and mine. pub fn rules(&self, key: &str) -> Res> { let mut stmt = self.db.conn.prepare("SELECT path, who FROM s_rules WHERE project = ?1 ORDER BY path")?; let rows = stmt.query_map([key], |r| { let who: String = r.get(1)?; Ok(Rule { path: r.get(0)?, who: serde_json::from_str(&who).unwrap_or_default(), sealed: false }) })?; Ok(rows.collect::>>()?) } pub fn set_rule(&self, key: &str, access: &Access, rule: &Rule) -> Res> { if !access.can.admin { return Err(problem(403, "not_allowed", "only the project's owners can set who sees a path")); } let path = rule.path.trim_matches('/'); if rule.who.is_empty() || (rule.who.len() == 1 && rule.who[0] == "team") { self.db.conn.execute("DELETE FROM s_rules WHERE project = ?1 AND path = ?2", params![key, path])?; } else { self.db.conn.execute( "INSERT OR REPLACE INTO s_rules (project, path, who) VALUES (?1, ?2, ?3)", params![key, path, serde_json::to_string(&rule.who).unwrap()], )?; } self.rules(key) } pub fn mine(&self, key: &str, handle: &str) -> Res> { let text: Option = self.db.conn.query_row("SELECT files FROM s_mine WHERE project = ?1 AND handle = ?2", params![key, handle], |r| r.get(0)).optional()?; Ok(text.and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default()) } pub fn set_mine(&self, key: &str, handle: &str, files: &[MineFile]) -> Res<()> { for f in files { if !self.store.has(&f.file) { return Err(problem(400, "bad_usage", format!("upload {} first", f.path))); } } self.db.conn.execute( "INSERT OR REPLACE INTO s_mine (project, handle, files) VALUES (?1, ?2, ?3)", params![key, handle, serde_json::to_string(files).unwrap()], )?; Ok(()) } } fn empty_record(id: &str) -> ChangeRecord { ChangeRecord { id: id.to_string(), title: String::new(), body: String::new(), base_tree: tree::empty_id(), base_version: None, on_change: None, after_change: None, head_tree: None, saves: 0, state: "open".into(), version: None, author: String::new(), owner: String::new(), shared: Vec::new(), holder: None, updated: 0, review: None, line: None, } }