| 1 | //! Dock pins: the apps each person pins to their dock, per workspace, kept |
| 2 | //! with their account so the dock is the same on every device. |
| 3 | //! |
| 4 | //! A row per person and workspace (migration 0044), keyed by the |
| 5 | //! workspace's id so a rename keeps it. Only the person reads or sets |
| 6 | //! their own, and only in a workspace they belong to. The keys are checked |
| 7 | //! for shape here; which apps exist is the web app's list (apps/web's |
| 8 | //! app/lib/apps.ts), so a new app needs no change to this service. |
| 9 | |
| 10 | use g1t_contracts::identity::*; |
| 11 | use g1t_contracts::time::SQL_NOW; |
| 12 | use g1t_contracts::{FailureCode, Outcome}; |
| 13 | use serde::Deserialize; |
| 14 | use worker::Result; |
| 15 | |
| 16 | use crate::Identity; |
| 17 | use crate::security::is_person; |
| 18 | |
| 19 | /// The pins as they are kept: each key checked, repeats dropped, in the |
| 20 | /// order given. Refused whole when a key is malformed or there are too many. |
| 21 | pub fn check_pins(apps: &[String]) -> std::result::Result<Vec<String>, String> { |
| 22 | let mut kept: Vec<String> = Vec::with_capacity(apps.len()); |
| 23 | for app in apps { |
| 24 | let key = app.trim(); |
| 25 | let well_formed = !key.is_empty() |
| 26 | && key.len() <= MAX_DOCK_APP_KEY |
| 27 | && key.starts_with(|c: char| c.is_ascii_lowercase()) |
| 28 | && key.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'); |
| 29 | if !well_formed { |
| 30 | return Err("That is not an app you can pin.".to_owned()); |
| 31 | } |
| 32 | if !kept.iter().any(|seen| seen == key) { |
| 33 | kept.push(key.to_owned()); |
| 34 | } |
| 35 | } |
| 36 | if kept.len() > MAX_DOCK_PINS { |
| 37 | return Err(format!("Pin at most {MAX_DOCK_PINS} apps.")); |
| 38 | } |
| 39 | Ok(kept) |
| 40 | } |
| 41 | |
| 42 | /// The pins in a row, or none when the row cannot be read: a bad row is |
| 43 | /// treated as never saved rather than failing the page. |
| 44 | fn parse_pins(apps: &str) -> Option<Vec<String>> { |
| 45 | let keys: Vec<String> = serde_json::from_str(apps).ok()?; |
| 46 | check_pins(&keys).ok() |
| 47 | } |
| 48 | |
| 49 | #[derive(Deserialize)] |
| 50 | struct Row { |
| 51 | apps: String, |
| 52 | } |
| 53 | |
| 54 | impl Identity { |
| 55 | pub async fn dock_pins(&self, a: DockPinsArgs) -> Result<Option<Vec<String>>> { |
| 56 | let slug = a.workspace.trim().to_lowercase(); |
| 57 | if !is_person(&a.user) || !a.user.is_member(&slug) { |
| 58 | return Ok(None); |
| 59 | } |
| 60 | // One query: the workspace by slug, and only while they belong to it. |
| 61 | let row = self |
| 62 | .db |
| 63 | .prepare( |
| 64 | "SELECT d.apps FROM dock_pins d |
| 65 | JOIN workspaces w ON w.id = d.workspace_id AND w.deleted_at IS NULL |
| 66 | JOIN workspace_members m ON m.workspace_id = w.id AND m.user_id = d.user_id |
| 67 | WHERE d.user_id = ? AND w.slug = ?", |
| 68 | ) |
| 69 | .bind(&[a.user.id.as_str().into(), slug.into()])? |
| 70 | .first::<Row>(None) |
| 71 | .await?; |
| 72 | Ok(row.and_then(|row| parse_pins(&row.apps))) |
| 73 | } |
| 74 | |
| 75 | pub async fn set_dock_pins(&self, a: SetDockPinsArgs) -> Result<Outcome<Vec<String>>> { |
| 76 | let slug = a.workspace.trim().to_lowercase(); |
| 77 | if !is_person(&a.user) { |
| 78 | return Ok(Outcome::fail(FailureCode::Forbidden, "Only a person has a dock.")); |
| 79 | } |
| 80 | if !a.user.is_member(&slug) { |
| 81 | return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found.")); |
| 82 | } |
| 83 | let apps = match check_pins(&a.apps) { |
| 84 | Ok(apps) => apps, |
| 85 | Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)), |
| 86 | }; |
| 87 | let json = serde_json::to_string(&apps)?; |
| 88 | // Written only where they are still a member, in one statement. |
| 89 | let row = self |
| 90 | .db |
| 91 | .prepare(format!( |
| 92 | "INSERT INTO dock_pins (user_id, workspace_id, apps, updated_at) |
| 93 | SELECT ?1, w.id, ?2, {SQL_NOW} FROM workspaces w |
| 94 | JOIN workspace_members m ON m.workspace_id = w.id AND m.user_id = ?1 |
| 95 | WHERE w.slug = ?3 AND w.deleted_at IS NULL |
| 96 | ON CONFLICT (user_id, workspace_id) DO UPDATE SET apps = excluded.apps, updated_at = excluded.updated_at |
| 97 | RETURNING apps" |
| 98 | )) |
| 99 | .bind(&[a.user.id.as_str().into(), json.as_str().into(), slug.into()])? |
| 100 | .first::<Row>(None) |
| 101 | .await?; |
| 102 | Ok(match row { |
| 103 | Some(_) => Outcome::Ok(apps), |
| 104 | None => Outcome::fail(FailureCode::NotFound, "Workspace not found."), |
| 105 | }) |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | #[cfg(test)] |
| 110 | mod tests { |
| 111 | use super::*; |
| 112 | |
| 113 | fn keys(list: &[&str]) -> Vec<String> { |
| 114 | list.iter().map(|key| (*key).to_owned()).collect() |
| 115 | } |
| 116 | |
| 117 | #[test] |
| 118 | fn pins_keep_their_order_without_repeats() { |
| 119 | assert_eq!(check_pins(&keys(&["usage", "projects", "usage", " teams "])).unwrap(), keys(&["usage", "projects", "teams"])); |
| 120 | assert_eq!(check_pins(&[]).unwrap(), Vec::<String>::new()); |
| 121 | } |
| 122 | |
| 123 | #[test] |
| 124 | fn a_malformed_key_is_refused() { |
| 125 | for bad in ["", "Projects", "1st", "a b", "../x", "pro_jects", "<script>", &"a".repeat(MAX_DOCK_APP_KEY + 1)] { |
| 126 | assert!(check_pins(&keys(&["projects", bad])).is_err(), "{bad}"); |
| 127 | } |
| 128 | assert!(check_pins(&keys(&["ai-gateway", "v2"])).is_ok()); |
| 129 | } |
| 130 | |
| 131 | #[test] |
| 132 | fn at_most_the_limit() { |
| 133 | let many: Vec<String> = (0..MAX_DOCK_PINS).map(|n| format!("app-{n}")).collect(); |
| 134 | assert_eq!(check_pins(&many).unwrap().len(), MAX_DOCK_PINS); |
| 135 | let too_many: Vec<String> = (0..=MAX_DOCK_PINS).map(|n| format!("app-{n}")).collect(); |
| 136 | assert!(check_pins(&too_many).is_err()); |
| 137 | // Repeats do not count against it. |
| 138 | let repeated: Vec<String> = many.iter().chain(many.iter()).cloned().collect(); |
| 139 | assert_eq!(check_pins(&repeated).unwrap(), many); |
| 140 | } |
| 141 | |
| 142 | #[test] |
| 143 | fn a_bad_row_reads_as_never_saved() { |
| 144 | assert_eq!(parse_pins(r#"["projects","usage"]"#), Some(keys(&["projects", "usage"]))); |
| 145 | assert_eq!(parse_pins("[]"), Some(Vec::new())); |
| 146 | assert_eq!(parse_pins("not json"), None); |
| 147 | assert_eq!(parse_pins(r#"["BAD"]"#), None); |
| 148 | } |
| 149 | } |