| 1 | //! Workspace aliases: a name g1t's staff point at a workspace, so that its |
| 2 | //! addresses lead to the workspace under its own name. `g1t` is the |
| 3 | //! product Flagon, Inc. builds, and leads to `flagon-io`, the organization |
| 4 | //! (migration 0029), so nobody is confused by the trading name. |
| 5 | //! |
| 6 | //! Staff set and remove them from sudo; there is no way for a workspace to |
| 7 | //! make one. An alias is a reserved or unclaimed name, never a person's or |
| 8 | //! a workspace's, and points at the workspace's id, so it follows the |
| 9 | //! workspace through renames. While it exists nobody can register or |
| 10 | //! rename a workspace to it. |
| 11 | //! |
| 12 | //! An alias is resolved wherever an old slug is (`resolve_slug`): the site |
| 13 | //! and the API redirect or run again under the workspace's slug. Git over |
| 14 | //! HTTPS resolves it in place (`resolve_alias`), as pushes do not follow |
| 15 | //! redirects. |
| 16 | |
| 17 | use g1t_contracts::identity::*; |
| 18 | use g1t_contracts::time::rfc3339; |
| 19 | use g1t_contracts::{FailureCode, Outcome, aliasable_name}; |
| 20 | use g1t_kit::now_ms; |
| 21 | use serde::Deserialize; |
| 22 | use worker::Result; |
| 23 | |
| 24 | use crate::Identity; |
| 25 | |
| 26 | /// The longest note or reason staff may give. |
| 27 | pub const MAX_NOTE_LENGTH: usize = 500; |
| 28 | |
| 29 | /// Everything about a wanted alias that decides whether staff may set it, |
| 30 | /// as read from the database. |
| 31 | #[derive(Debug, Default)] |
| 32 | pub struct Facts<'a> { |
| 33 | /// The workspace it would lead to, if there is one by that slug: its id. |
| 34 | pub target: Option<&'a str>, |
| 35 | /// A person has the name as their username. |
| 36 | pub username: bool, |
| 37 | /// A workspace has it as its slug, deleted or not. |
| 38 | pub workspace: bool, |
| 39 | /// A renamed workspace's old slug still held: the workspace it is held for. |
| 40 | pub held_for: Option<&'a str>, |
| 41 | /// A purged workspace had it; it is never given to anyone else. |
| 42 | pub purged: bool, |
| 43 | /// It is already an alias: of which workspace's slug. |
| 44 | pub alias_of: Option<&'a str>, |
| 45 | } |
| 46 | |
| 47 | /// The alias and note to store, or why not, in words for staff. |
| 48 | pub fn check(alias: &str, note: &str, facts: &Facts) -> std::result::Result<(String, String), (FailureCode, String)> { |
| 49 | let refuse = |code, message: String| Err((code, message)); |
| 50 | let Some(alias) = aliasable_name(alias) else { |
| 51 | return refuse( |
| 52 | FailureCode::Invalid, |
| 53 | "An alias uses lowercase letters, digits and single hyphens, up to 39 characters, and cannot be one of the site's routes.".into(), |
| 54 | ); |
| 55 | }; |
| 56 | let note = note.trim(); |
| 57 | if note.is_empty() { |
| 58 | return refuse(FailureCode::Invalid, "Say why the alias exists.".into()); |
| 59 | } |
| 60 | if note.chars().count() > MAX_NOTE_LENGTH { |
| 61 | return refuse(FailureCode::Invalid, format!("Keep the note to {MAX_NOTE_LENGTH} characters.")); |
| 62 | } |
| 63 | let Some(target) = facts.target else { |
| 64 | return refuse(FailureCode::NotFound, "There is no workspace with that slug.".into()); |
| 65 | }; |
| 66 | if let Some(slug) = facts.alias_of { |
| 67 | return refuse(FailureCode::Conflict, format!("{alias} is already an alias of {slug}.")); |
| 68 | } |
| 69 | if facts.username { |
| 70 | return refuse(FailureCode::Conflict, format!("{alias} is someone's username.")); |
| 71 | } |
| 72 | if facts.workspace { |
| 73 | return refuse(FailureCode::Conflict, format!("{alias} is a workspace's slug.")); |
| 74 | } |
| 75 | if facts.purged { |
| 76 | return refuse(FailureCode::Conflict, format!("{alias} belonged to a deleted workspace.")); |
| 77 | } |
| 78 | // A workspace's own old slug can become its alias for good. |
| 79 | if facts.held_for.is_some_and(|holder| holder != target) { |
| 80 | return refuse(FailureCode::Conflict, format!("{alias} is held for a renamed workspace.")); |
| 81 | } |
| 82 | Ok((alias, note.to_owned())) |
| 83 | } |
| 84 | |
| 85 | /// Where a first path segment leads, in the order `resolve_slug` asks: a |
| 86 | /// workspace that has it leads nowhere else; then an alias, which is for |
| 87 | /// good; then a renamed workspace's old slug, while it is held. |
| 88 | pub fn resolve(in_use: bool, alias: Option<String>, renamed: impl FnOnce() -> Option<String>) -> Option<String> { |
| 89 | if in_use { |
| 90 | return None; |
| 91 | } |
| 92 | alias.or_else(renamed) |
| 93 | } |
| 94 | |
| 95 | #[derive(Deserialize)] |
| 96 | struct AliasRow { |
| 97 | alias: String, |
| 98 | workspace_id: String, |
| 99 | slug: String, |
| 100 | name: String, |
| 101 | note: String, |
| 102 | created_by: String, |
| 103 | created_at: String, |
| 104 | } |
| 105 | |
| 106 | impl From<AliasRow> for WorkspaceAlias { |
| 107 | fn from(row: AliasRow) -> Self { |
| 108 | WorkspaceAlias { |
| 109 | alias: row.alias, |
| 110 | workspace_id: row.workspace_id, |
| 111 | workspace: row.slug, |
| 112 | workspace_name: row.name, |
| 113 | note: row.note, |
| 114 | created_by: row.created_by, |
| 115 | created_at: row.created_at, |
| 116 | } |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | /// Aliases with their workspace as it is now. Never a deleted one's. |
| 121 | const ALIAS_ROWS: &str = "SELECT a.alias, a.workspace_id, w.slug, w.name, a.note, a.created_by, a.created_at |
| 122 | FROM workspace_aliases a JOIN workspaces w ON w.id = a.workspace_id AND w.deleted_at IS NULL"; |
| 123 | |
| 124 | #[derive(Deserialize)] |
| 125 | struct Id { |
| 126 | id: String, |
| 127 | } |
| 128 | |
| 129 | impl Identity { |
| 130 | async fn alias_row(&self, alias: &str) -> Result<Option<AliasRow>> { |
| 131 | self.db |
| 132 | .prepare(format!("{ALIAS_ROWS} WHERE a.alias = ?")) |
| 133 | .bind(&[alias.into()])? |
| 134 | .first::<AliasRow>(None) |
| 135 | .await |
| 136 | } |
| 137 | |
| 138 | /// `resolve_alias`: the slug now of the workspace `slug` is an alias of. |
| 139 | pub async fn resolve_alias(&self, a: SlugArgs) -> Result<Option<String>> { |
| 140 | let slug = a.slug.trim().to_lowercase(); |
| 141 | Ok(self.alias_row(&slug).await?.map(|row| row.slug)) |
| 142 | } |
| 143 | |
| 144 | /// Whether `slug` is an alias, of a workspace deleted or not, so nobody |
| 145 | /// may register or rename a workspace to it. |
| 146 | pub async fn is_alias(&self, slug: &str) -> Result<bool> { |
| 147 | Ok(self |
| 148 | .db |
| 149 | .prepare("SELECT 1 AS held FROM workspace_aliases WHERE alias = ?") |
| 150 | .bind(&[slug.trim().to_lowercase().into()])? |
| 151 | .first::<serde_json::Value>(None) |
| 152 | .await? |
| 153 | .is_some()) |
| 154 | } |
| 155 | |
| 156 | /// `admin_aliases`: every alias, by name. Staff only. |
| 157 | pub async fn admin_aliases(&self) -> Result<Vec<WorkspaceAlias>> { |
| 158 | Ok(self |
| 159 | .db |
| 160 | .prepare(format!("{ALIAS_ROWS} ORDER BY a.alias")) |
| 161 | .all() |
| 162 | .await? |
| 163 | .results::<AliasRow>()? |
| 164 | .into_iter() |
| 165 | .map(WorkspaceAlias::from) |
| 166 | .collect()) |
| 167 | } |
| 168 | |
| 169 | /// `admin_set_alias`: staff only. Recorded in sudo's audit log. |
| 170 | pub async fn admin_set_alias(&self, a: AdminSetAliasArgs) -> Result<Outcome<WorkspaceAlias>> { |
| 171 | let staff = a.staff.trim(); |
| 172 | if staff.is_empty() { |
| 173 | return Ok(Outcome::fail(FailureCode::Forbidden, "Say which staff member is setting it.")); |
| 174 | } |
| 175 | let alias = a.alias.trim().to_lowercase(); |
| 176 | let workspace = a.workspace.trim().to_lowercase(); |
| 177 | let target = self |
| 178 | .db |
| 179 | .prepare("SELECT id FROM workspaces WHERE slug = ? AND deleted_at IS NULL") |
| 180 | .bind(&[workspace.as_str().into()])? |
| 181 | .first::<Id>(None) |
| 182 | .await?; |
| 183 | let username = self |
| 184 | .db |
| 185 | .prepare("SELECT 1 AS taken FROM users WHERE username = ?") |
| 186 | .bind(&[alias.as_str().into()])? |
| 187 | .first::<serde_json::Value>(None) |
| 188 | .await? |
| 189 | .is_some(); |
| 190 | #[derive(Deserialize)] |
| 191 | struct Held { |
| 192 | workspace_id: String, |
| 193 | created_at: String, |
| 194 | } |
| 195 | let held = self |
| 196 | .db |
| 197 | .prepare("SELECT workspace_id, created_at FROM workspace_redirects WHERE old_slug = ?") |
| 198 | .bind(&[alias.as_str().into()])? |
| 199 | .first::<Held>(None) |
| 200 | .await? |
| 201 | .filter(|row| row.created_at >= crate::rename::hold_cutoff(now_ms())); |
| 202 | let existing = self.alias_row(&alias).await?; |
| 203 | // An alias of a deleted workspace is not listed, but still holds. |
| 204 | let alias_of = match &existing { |
| 205 | Some(row) => Some(row.slug.clone()), |
| 206 | None => self.is_alias(&alias).await?.then(|| "a deleted workspace".to_owned()), |
| 207 | }; |
| 208 | let facts = Facts { |
| 209 | target: target.as_ref().map(|row| row.id.as_str()), |
| 210 | username, |
| 211 | workspace: self.slug_in_use(&alias).await?, |
| 212 | held_for: held.as_ref().map(|row| row.workspace_id.as_str()), |
| 213 | purged: self.slug_deleted(&alias).await?, |
| 214 | alias_of: alias_of.as_deref(), |
| 215 | }; |
| 216 | let (alias, note) = match check(&alias, &a.note, &facts) { |
| 217 | Ok(checked) => checked, |
| 218 | Err((code, message)) => return Ok(Outcome::fail(code, message)), |
| 219 | }; |
| 220 | let Some(target) = target else { |
| 221 | return Ok(Outcome::fail(FailureCode::NotFound, "There is no workspace with that slug.")); |
| 222 | }; |
| 223 | self.db |
| 224 | .batch(vec![ |
| 225 | // Its own old slug, made its alias: the redirect gives way. |
| 226 | self.db |
| 227 | .prepare("DELETE FROM workspace_redirects WHERE old_slug = ? AND workspace_id = ?") |
| 228 | .bind(&[alias.as_str().into(), target.id.as_str().into()])?, |
| 229 | self.db |
| 230 | .prepare( |
| 231 | "INSERT INTO workspace_aliases (alias, workspace_id, created_by, created_at, note) |
| 232 | VALUES (?, ?, ?, ?, ?)", |
| 233 | ) |
| 234 | .bind(&[ |
| 235 | alias.as_str().into(), |
| 236 | target.id.as_str().into(), |
| 237 | staff.into(), |
| 238 | rfc3339(now_ms()).into(), |
| 239 | note.as_str().into(), |
| 240 | ])?, |
| 241 | ]) |
| 242 | .await?; |
| 243 | self.record_for_staff(&workspace, "alias_added", &format!("Alias {alias} leads to {workspace}: {note}"), staff) |
| 244 | .await; |
| 245 | Ok(match self.alias_row(&alias).await? { |
| 246 | Some(row) => Outcome::Ok(row.into()), |
| 247 | None => Outcome::fail(FailureCode::NotFound, "There is no workspace with that slug."), |
| 248 | }) |
| 249 | } |
| 250 | |
| 251 | /// `admin_remove_alias`: staff only. Recorded in sudo's audit log. |
| 252 | pub async fn admin_remove_alias(&self, a: AdminRemoveAliasArgs) -> Result<Outcome<bool>> { |
| 253 | let staff = a.staff.trim(); |
| 254 | if staff.is_empty() { |
| 255 | return Ok(Outcome::fail(FailureCode::Forbidden, "Say which staff member is removing it.")); |
| 256 | } |
| 257 | let reason = a.reason.trim(); |
| 258 | if reason.is_empty() { |
| 259 | return Ok(Outcome::fail(FailureCode::Invalid, "Say why the alias is being removed.")); |
| 260 | } |
| 261 | if reason.chars().count() > MAX_NOTE_LENGTH { |
| 262 | return Ok(Outcome::fail( |
| 263 | FailureCode::Invalid, |
| 264 | format!("Keep the reason to {MAX_NOTE_LENGTH} characters."), |
| 265 | )); |
| 266 | } |
| 267 | let alias = a.alias.trim().to_lowercase(); |
| 268 | let Some(row) = self.alias_row(&alias).await? else { |
| 269 | return Ok(Outcome::fail(FailureCode::NotFound, "There is no alias by that name.")); |
| 270 | }; |
| 271 | self.db |
| 272 | .prepare("DELETE FROM workspace_aliases WHERE alias = ?") |
| 273 | .bind(&[alias.as_str().into()])? |
| 274 | .run() |
| 275 | .await?; |
| 276 | self.record_for_staff( |
| 277 | &row.slug, |
| 278 | "alias_removed", |
| 279 | &format!("Alias {alias} no longer leads to {}: {reason}", row.slug), |
| 280 | staff, |
| 281 | ) |
| 282 | .await; |
| 283 | Ok(Outcome::Ok(true)) |
| 284 | } |
| 285 | } |
| 286 | |
| 287 | #[cfg(test)] |
| 288 | mod tests { |
| 289 | use super::*; |
| 290 | use std::collections::HashMap; |
| 291 | |
| 292 | fn facts<'a>() -> Facts<'a> { |
| 293 | Facts { |
| 294 | target: Some("wsp_flagon"), |
| 295 | ..Facts::default() |
| 296 | } |
| 297 | } |
| 298 | |
| 299 | fn refused(alias: &str, facts: &Facts) -> FailureCode { |
| 300 | check(alias, "The product's name", facts).unwrap_err().0 |
| 301 | } |
| 302 | |
| 303 | #[test] |
| 304 | fn a_reserved_or_unclaimed_name_can_be_an_alias() { |
| 305 | assert_eq!( |
| 306 | check(" G1T ", " The product's name, for Flagon, Inc. ", &facts()).unwrap(), |
| 307 | ("g1t".to_owned(), "The product's name, for Flagon, Inc.".to_owned()) |
| 308 | ); |
| 309 | assert!(check("flagon", "Short name", &facts()).is_ok()); |
| 310 | } |
| 311 | |
| 312 | #[test] |
| 313 | fn routes_and_malformed_names_are_never_aliases() { |
| 314 | for bad in ["settings", "api", "login", "-g1t", "g1t-", "g--1t", "g1t_inc", "", "a.b"] { |
| 315 | assert_eq!(refused(bad, &facts()), FailureCode::Invalid, "{bad}"); |
| 316 | } |
| 317 | } |
| 318 | |
| 319 | #[test] |
| 320 | fn a_reason_is_required_and_bounded() { |
| 321 | assert_eq!(check("g1t", " ", &facts()).unwrap_err().0, FailureCode::Invalid); |
| 322 | let long = "x".repeat(MAX_NOTE_LENGTH + 1); |
| 323 | assert_eq!(check("g1t", &long, &facts()).unwrap_err().0, FailureCode::Invalid); |
| 324 | } |
| 325 | |
| 326 | #[test] |
| 327 | fn a_persons_or_workspaces_name_is_never_an_alias() { |
| 328 | let missing = Facts { target: None, ..facts() }; |
| 329 | assert_eq!(refused("g1t", &missing), FailureCode::NotFound); |
| 330 | let person = Facts { username: true, ..facts() }; |
| 331 | assert_eq!(refused("ana", &person), FailureCode::Conflict); |
| 332 | let workspace = Facts { workspace: true, ..facts() }; |
| 333 | assert_eq!(refused("acme", &workspace), FailureCode::Conflict); |
| 334 | let purged = Facts { purged: true, ..facts() }; |
| 335 | assert_eq!(refused("initech", &purged), FailureCode::Conflict); |
| 336 | let aliased = Facts { |
| 337 | alias_of: Some("globex"), |
| 338 | ..facts() |
| 339 | }; |
| 340 | let (code, message) = check("g1t", "x", &aliased).unwrap_err(); |
| 341 | assert_eq!(code, FailureCode::Conflict); |
| 342 | assert_eq!(message, "g1t is already an alias of globex."); |
| 343 | } |
| 344 | |
| 345 | #[test] |
| 346 | fn only_its_own_old_slug_can_become_a_workspaces_alias() { |
| 347 | let others = Facts { |
| 348 | held_for: Some("wsp_other"), |
| 349 | ..facts() |
| 350 | }; |
| 351 | assert_eq!(refused("acme", &others), FailureCode::Conflict); |
| 352 | let own = Facts { |
| 353 | held_for: Some("wsp_flagon"), |
| 354 | ..facts() |
| 355 | }; |
| 356 | assert!(check("flagon", "Its old name, for good", &own).is_ok()); |
| 357 | } |
| 358 | |
| 359 | #[test] |
| 360 | fn a_workspace_in_use_is_never_resolved_elsewhere() { |
| 361 | let alias = || Some("flagon-io".to_owned()); |
| 362 | assert_eq!(resolve(true, alias(), || Some("x".into())), None); |
| 363 | assert_eq!(resolve(false, alias(), || Some("x".into())).as_deref(), Some("flagon-io")); |
| 364 | assert_eq!(resolve(false, None, || Some("acme-inc".into())).as_deref(), Some("acme-inc")); |
| 365 | assert_eq!(resolve(false, None, || None), None); |
| 366 | } |
| 367 | |
| 368 | /// Aliases point at ids, as the table does; renames change the slug. |
| 369 | #[test] |
| 370 | fn an_alias_follows_its_workspace_through_renames() { |
| 371 | let mut slugs: HashMap<&str, &str> = HashMap::from([("wsp_flagon", "flagon-io")]); |
| 372 | let aliases: HashMap<&str, &str> = HashMap::from([("g1t", "wsp_flagon")]); |
| 373 | let lookup = |slugs: &HashMap<&str, &str>, alias: &str| { |
| 374 | aliases.get(alias).and_then(|id| slugs.get(id)).map(|slug| (*slug).to_owned()) |
| 375 | }; |
| 376 | assert_eq!(lookup(&slugs, "g1t").as_deref(), Some("flagon-io")); |
| 377 | slugs.insert("wsp_flagon", "flagon"); |
| 378 | assert_eq!(lookup(&slugs, "g1t").as_deref(), Some("flagon")); |
| 379 | slugs.insert("wsp_flagon", "flagon-inc"); |
| 380 | assert_eq!(lookup(&slugs, "g1t").as_deref(), Some("flagon-inc")); |
| 381 | assert_eq!(lookup(&slugs, "acme"), None); |
| 382 | } |
| 383 | } |