embacle_server/uhp/
share.rs1use std::collections::HashMap;
23use std::sync::RwLock;
24
25use axum::extract::{Path, State};
26use axum::Json;
27use serde::Serialize;
28use uuid::Uuid;
29
30use super::error::UhpFailure;
31use super::sessions::Turn;
32use super::state::UhpState;
33
34#[derive(Debug, Default)]
39pub struct Shares {
40 by_share: RwLock<HashMap<String, String>>,
41 by_session: RwLock<HashMap<String, String>>,
42}
43
44impl Shares {
45 pub fn mint(&self, session_id: &str) -> String {
51 if let Some(existing) = self.for_session(session_id) {
52 return existing;
53 }
54 let share_id = format!("share_{}", Uuid::new_v4().simple());
55 if let Ok(mut by_share) = self.by_share.write() {
56 by_share.insert(share_id.clone(), session_id.to_owned());
57 }
58 if let Ok(mut by_session) = self.by_session.write() {
59 by_session.insert(session_id.to_owned(), share_id.clone());
60 }
61 share_id
62 }
63
64 pub fn for_session(&self, session_id: &str) -> Option<String> {
66 self.by_session.read().ok()?.get(session_id).cloned()
67 }
68
69 pub fn session_of(&self, share_id: &str) -> Option<String> {
71 self.by_share.read().ok()?.get(share_id).cloned()
72 }
73
74 pub fn revoke(&self, session_id: &str) -> bool {
79 let share_id = self
80 .by_session
81 .write()
82 .map_or(None, |mut by_session| by_session.remove(session_id));
83 let Some(share_id) = share_id else {
84 return false;
85 };
86 if let Ok(mut by_share) = self.by_share.write() {
87 by_share.remove(&share_id);
88 }
89 true
90 }
91}
92
93#[derive(Debug, Clone, Serialize)]
95pub struct Share {
96 pub id: String,
98 pub object: &'static str,
100 pub session_id: String,
102 pub url: String,
104}
105
106fn share_path(share_id: &str) -> String {
108 format!("/share/{share_id}")
109}
110
111pub async fn create(
119 State(state): State<UhpState>,
120 Path(session_id): Path<String>,
121) -> Result<Json<Share>, UhpFailure> {
122 if state.store.session_turns(&session_id).is_none() {
123 return Err(UhpFailure::not_found(
124 "session_not_found",
125 "no session with that id exists",
126 ));
127 }
128 let id = state.shares.mint(&session_id);
129 Ok(Json(Share {
130 url: share_path(&id),
131 id,
132 object: "session.share",
133 session_id,
134 }))
135}
136
137pub async fn get(
143 State(state): State<UhpState>,
144 Path(session_id): Path<String>,
145) -> Result<Json<Share>, UhpFailure> {
146 let id = state.shares.for_session(&session_id).ok_or_else(|| {
147 UhpFailure::not_found("share_not_found", "this session has no shared view")
148 })?;
149 Ok(Json(Share {
150 url: share_path(&id),
151 id,
152 object: "session.share",
153 session_id,
154 }))
155}
156
157pub async fn revoke(
163 State(state): State<UhpState>,
164 Path(session_id): Path<String>,
165) -> Result<Json<super::sessions::Deleted>, UhpFailure> {
166 if state.shares.revoke(&session_id) {
167 Ok(Json(super::sessions::Deleted {
168 id: session_id,
169 object: "session.share.deleted",
170 deleted: true,
171 }))
172 } else {
173 Err(UhpFailure::not_found(
174 "share_not_found",
175 "this session has no shared view",
176 ))
177 }
178}
179
180#[derive(Debug, Clone, Serialize)]
186pub struct SharedView {
187 pub object: &'static str,
189 pub session_id: String,
191 pub turns: Vec<Turn>,
193 pub read_only: bool,
195}
196
197pub async fn view(
205 State(state): State<UhpState>,
206 Path(share_id): Path<String>,
207) -> Result<Json<SharedView>, UhpFailure> {
208 let session_id = state.shares.session_of(&share_id).ok_or_else(|| {
209 UhpFailure::not_found("share_not_found", "this link does not open anything")
210 })?;
211 let turns = state.store.session_turns(&session_id).ok_or_else(|| {
212 UhpFailure::not_found("share_not_found", "this link does not open anything")
213 })?;
214 Ok(Json(SharedView {
215 object: "session.shared_view",
216 session_id,
217 turns: turns.iter().map(Turn::from).collect(),
218 read_only: true,
219 }))
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225
226 #[test]
227 fn minting_twice_returns_one_link() {
228 let shares = Shares::default();
231 let first = shares.mint("sess_1");
232 let second = shares.mint("sess_1");
233 assert_eq!(first, second);
234 assert!(first.starts_with("share_"));
235 }
236
237 #[test]
238 fn revocation_kills_the_link_itself() {
239 let shares = Shares::default();
240 let id = shares.mint("sess_1");
241 assert_eq!(shares.session_of(&id).as_deref(), Some("sess_1"));
242
243 assert!(shares.revoke("sess_1"));
244 assert!(
245 shares.session_of(&id).is_none(),
246 "a revoked link must stop opening for every holder, not just the minter"
247 );
248 assert!(shares.for_session("sess_1").is_none());
249 assert!(!shares.revoke("sess_1"), "revoking twice reports false");
250 }
251
252 #[test]
253 fn a_share_id_is_not_a_session_id() {
254 let shares = Shares::default();
257 let id = shares.mint("sess_1");
258 assert_ne!(id, "sess_1");
259 assert!(id.starts_with("share_"), "distinct namespace from sess_");
260 }
261}