Skip to content
149 linesCodeBlameRaw

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
10use g1t_contracts::identity::*;
11use g1t_contracts::time::SQL_NOW;
12use g1t_contracts::{FailureCode, Outcome};
13use serde::Deserialize;
14use worker::Result;
15
16use crate::Identity;
17use 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.
21pub 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.
44fn 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)]
50struct Row {
51 apps: String,
52}
53
54impl 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)]
110mod 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}