1use rusqlite::{params, Connection, OptionalExtension};
15use serde::Serialize;
16use sha2::{Digest, Sha256};
17
18pub fn init_schema(conn: &Connection) -> Result<(), String> {
20 conn.execute_batch(
21 "CREATE TABLE IF NOT EXISTS ics_feeds (
22 id TEXT PRIMARY KEY,
23 user_id TEXT,
24 token_hash TEXT NOT NULL UNIQUE,
25 name TEXT NOT NULL,
26 kinds TEXT NOT NULL,
27 scope TEXT NOT NULL DEFAULT '',
28 created_at TEXT NOT NULL,
29 last_fetched_at TEXT,
30 revoked_at TEXT
31 );
32 CREATE INDEX IF NOT EXISTS ics_feeds_token ON ics_feeds(token_hash);",
33 )
34 .map_err(|e| e.to_string())
35}
36
37#[derive(Clone, Serialize)]
38pub struct FeedRecord {
39 pub id: String,
40 pub user_id: Option<String>,
41 pub name: String,
42 pub kinds: String,
44 pub scope: String,
49 pub created_at: String,
50 pub last_fetched_at: Option<String>,
51 pub revoked_at: Option<String>,
52}
53
54impl FeedRecord {
55 pub fn is_revoked(&self) -> bool {
56 self.revoked_at.is_some()
57 }
58
59 pub fn include_account_names(&self) -> bool {
60 self.scope.split(',').any(|s| s == "account_names")
61 }
62
63 pub fn kind_list(&self) -> Vec<String> {
64 self.kinds
65 .split(',')
66 .filter(|s| !s.is_empty())
67 .map(str::to_string)
68 .collect()
69 }
70}
71
72fn sha256_hex(input: &str) -> String {
73 hex::encode(Sha256::digest(input.as_bytes()))
74}
75
76fn row_to_record(row: &rusqlite::Row) -> rusqlite::Result<FeedRecord> {
77 Ok(FeedRecord {
78 id: row.get(0)?,
79 user_id: row.get(1)?,
80 name: row.get(2)?,
81 kinds: row.get(3)?,
82 scope: row.get(4)?,
83 created_at: row.get(5)?,
84 last_fetched_at: row.get(6)?,
85 revoked_at: row.get(7)?,
86 })
87}
88
89const COLS: &str = "id, user_id, name, kinds, scope, created_at, last_fetched_at, revoked_at";
90
91pub fn create_feed(
94 conn: &Connection,
95 user_id: Option<&str>,
96 name: &str,
97 kinds: &[String],
98 include_account_names: bool,
99) -> Result<(FeedRecord, String), String> {
100 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
101 use rand::RngCore;
102 let mut raw = [0u8; 32];
103 rand::thread_rng().fill_bytes(&mut raw);
104 let token = URL_SAFE_NO_PAD.encode(raw);
105 let token_hash = sha256_hex(&token);
106 let id = crate::new_uuid();
107 let created_at = crate::iso_now();
108 let kinds_joined = kinds.join(",");
109 let scope = if include_account_names {
110 "account_names".to_string()
111 } else {
112 String::new()
113 };
114 conn.execute(
115 "INSERT INTO ics_feeds (id, user_id, token_hash, name, kinds, scope, created_at) \
116 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
117 params![
118 id,
119 user_id,
120 token_hash,
121 name,
122 kinds_joined,
123 scope,
124 created_at
125 ],
126 )
127 .map_err(|e| e.to_string())?;
128 Ok((
129 FeedRecord {
130 id,
131 user_id: user_id.map(str::to_string),
132 name: name.to_string(),
133 kinds: kinds_joined,
134 scope,
135 created_at,
136 last_fetched_at: None,
137 revoked_at: None,
138 },
139 token,
140 ))
141}
142
143pub fn list_feeds(
146 conn: &Connection,
147 is_owner: bool,
148 user_id: &str,
149) -> Result<Vec<FeedRecord>, String> {
150 let sql = format!(
151 "SELECT {COLS} FROM ics_feeds {} ORDER BY created_at DESC",
152 if is_owner { "" } else { "WHERE user_id = ?1" }
153 );
154 let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
155 let rows = if is_owner {
156 stmt.query_map([], row_to_record)
157 } else {
158 stmt.query_map(params![user_id], row_to_record)
159 }
160 .map_err(|e| e.to_string())?;
161 rows.collect::<Result<Vec<_>, _>>()
162 .map_err(|e| e.to_string())
163}
164
165pub fn revoke_feed(
169 conn: &Connection,
170 feed_id: &str,
171 actor_user_id: &str,
172 is_owner: bool,
173) -> Result<bool, String> {
174 let n = if is_owner {
175 conn.execute(
176 "UPDATE ics_feeds SET revoked_at = ?1 WHERE id = ?2 AND revoked_at IS NULL",
177 params![crate::iso_now(), feed_id],
178 )
179 } else {
180 conn.execute(
181 "UPDATE ics_feeds SET revoked_at = ?1 \
182 WHERE id = ?2 AND user_id = ?3 AND revoked_at IS NULL",
183 params![crate::iso_now(), feed_id, actor_user_id],
184 )
185 }
186 .map_err(|e| e.to_string())?;
187 Ok(n > 0)
188}
189
190pub fn find_active_feed_by_token(
194 conn: &Connection,
195 raw_token: &str,
196) -> Result<Option<FeedRecord>, String> {
197 let hash = sha256_hex(raw_token);
198 conn.query_row(
199 &format!("SELECT {COLS} FROM ics_feeds WHERE token_hash = ?1 AND revoked_at IS NULL"),
200 params![hash],
201 row_to_record,
202 )
203 .optional()
204 .map_err(|e| e.to_string())
205}
206
207pub fn touch_feed(conn: &Connection, feed_id: &str) -> Result<(), String> {
211 conn.execute(
212 "UPDATE ics_feeds SET last_fetched_at = ?1 WHERE id = ?2",
213 params![crate::iso_now(), feed_id],
214 )
215 .map(|_| ())
216 .map_err(|e| e.to_string())
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222
223 fn mem() -> Connection {
224 let conn = Connection::open_in_memory().unwrap();
225 init_schema(&conn).unwrap();
226 conn
227 }
228
229 #[test]
230 fn a_revoked_feed_is_not_found_by_its_token() {
231 let conn = mem();
232 let (feed, token) =
233 create_feed(&conn, None, "Everything", &["expires".to_string()], false).unwrap();
234 assert!(find_active_feed_by_token(&conn, &token).unwrap().is_some());
235 assert!(revoke_feed(&conn, &feed.id, "owner", true).unwrap());
236 assert!(find_active_feed_by_token(&conn, &token).unwrap().is_none());
237 }
238
239 #[test]
240 fn a_sub_user_cannot_revoke_someone_elses_feed() {
241 let conn = mem();
242 let (feed, _) = create_feed(
243 &conn,
244 Some("alice"),
245 "Alice's feed",
246 &["expires".to_string()],
247 false,
248 )
249 .unwrap();
250 assert!(!revoke_feed(&conn, &feed.id, "bob", false).unwrap());
251 assert!(revoke_feed(&conn, &feed.id, "alice", false).unwrap());
252 }
253
254 #[test]
255 fn account_names_are_opt_in() {
256 let conn = mem();
257 let (feed, _) = create_feed(&conn, None, "X", &["expires".to_string()], false).unwrap();
258 assert!(!feed.include_account_names());
259 let (feed2, _) = create_feed(&conn, None, "Y", &["expires".to_string()], true).unwrap();
260 assert!(feed2.include_account_names());
261 }
262
263 #[test]
264 fn an_unknown_token_finds_nothing() {
265 let conn = mem();
266 assert!(find_active_feed_by_token(&conn, "not-a-real-token")
267 .unwrap()
268 .is_none());
269 }
270}