Skip to content
149 linesCodeBlameRaw
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}