//! 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_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)); "#; 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::>>()?) } // Main. pub fn main(&self, key: &str) -> Res> { Ok(self.db.latest_version(key)?) } 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 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. 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| s.who == format!("@{}", user.handle) || s.who == user.handle || (s.who == "team" && member))) } 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, access: &Access, c: &ChangeRecord) -> bool { let Some(user) = &access.user else { return false }; c.owner == user.handle || c.shared.iter().any(|s| s.edit && (s.who == format!("@{}", user.handle) || s.who == user.handle || s.who == "team")) } 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(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((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::>>()?) } 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"))?; let existing = self.change_row(key, &record.id)?; match &existing { Some((old, holder)) => { if !self.may_edit(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(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(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.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 existing = self.change_row(key, &request.change)?; if !access.can.land { // Ask the people who can. let mut record = existing.map(|(c, _)| c).unwrap_or_else(|| empty_record(&request.change)); 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")); } 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")); } let outcome = vcs_cli::host::Local.land_on(&self.db, &self.store, key, request)?; 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) } /// 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, } }