//! 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 vcs_cli::db::Db; use vcs_cli::host::{LandRequest, Landed}; use vcs_cli::objects::Store; use vcs_cli::wire::{Can, ChangeRecord, Me, MineFile, Rule, SaveRecord, Share}; use vcs_core::version::Version; use vcs_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)); "#; 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 { 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: vcs_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!("vcs_{}", (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) = vcs_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(()) } /// `vcs 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) = vcs_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", vcs_core::history::Kind::Added => "added", vcs_core::history::Kind::Modified => "modified", vcs_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 (`vcs 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) = vcs_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 = vcs_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(()) } // Deleting from history (vcs purge). /// What vcs 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 vcs_core::purge::without(&self.store, t, &paths, &mut gone) { Ok(done) => done, Err(vcs_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 = vcs_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); } } 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) = vcs_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 = vcs_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) })) } /// `vcs 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)) = vcs_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 == vcs_core::Kind::Chunks => vcs_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 }; 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) = vcs_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(); if old.state == "landed" { record.state = "landed".into(); record.version = old.version; } } 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()); 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 vcs 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 vcs'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; vcs sync, then land again")); } }; match vcs_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 = vcs_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], )?; } 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; } } Ok(outcome) } /// Lands file edits without a workspace (see the hosted service's /// landEdits): a new change of the caller's on version `base`. pub fn land_edits(&self, key: &str, access: &Access, base: Option, put: Vec, delete: Vec, title: &str, body: &str, allow_secrets: bool) -> Res<(String, Landed)> { let user = access.user.as_ref().ok_or_else(|| problem(401, "not_logged_in", "sign in first"))?; let main = self.main(key)?; 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) = vcs_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 = vcs_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")?; 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 }; 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() }) })?; 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, } }