Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| The apps you pin to your dock are kept with your account, per workspace and in your order, so the dock is the same on every device: identity keeps them in dock_pins and answers dock_pins and set_dock_pins, the dock reads them with the rest of the page, pins kept only on this device carry over with your first change, this device's copy still draws the dock when identity can't be reached, a pin that can't be saved says so, and they go when you or the workspace do; the workspaces guide says how. | 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 | } |