Constellation, Spacedust, Slingshot, UFOs: atproto crates and services for microcosm

cancel expiring tasks and demooooooo

Changed files
+186 -54
who-am-i
+23 -9
who-am-i/demo/index.html
··· 1 1 <!doctype html> 2 - <style> 3 - body { 4 - background: #333; 5 - color: #ccc; 6 - font-family: sans-serif; 7 - } 8 - </style> 2 + <html> 3 + <head> 4 + <style> 5 + body { 6 + background: #333; 7 + color: #ccc; 8 + font-family: sans-serif; 9 + } 10 + </style> 11 + </head> 9 12 10 - <h1>hey</h1> 13 + <body> 14 + <h1>hey <span id="who"></span></h1> 11 15 12 - <iframe src="http://127.0.0.1:9997/prompt" style="border: none" height="140" width="280" /> 16 + <iframe src="http://127.0.0.1:9997/prompt" id="whoami" style="border: none" height="130" width="280"></iframe> 17 + 18 + <script type="text/javascript"> 19 + window.onmessage = message => { 20 + if (!message || !message.data || message.data.source !== 'whoami') return; 21 + document.getElementById('whoami').remove(); 22 + document.getElementById('who').textContent = message.data.handle; 23 + }; 24 + </script> 25 + </body> 26 + </html>
+28 -16
who-am-i/src/expiring_task_map.rs
··· 3 3 use std::sync::Arc; 4 4 use std::time::Duration; 5 5 use tokio::task::{JoinHandle, spawn}; 6 - use tokio::time::sleep; // 0.8 6 + use tokio::time::sleep; 7 + use tokio_util::sync::{CancellationToken, DropGuard}; 7 8 8 9 #[derive(Clone)] 9 - pub struct ExpiringTaskMap<T>(Arc<TaskMap<T>>); 10 + pub struct ExpiringTaskMap<T>(TaskMap<T>); 10 11 11 12 impl<T: Send + 'static> ExpiringTaskMap<T> { 12 13 pub fn new(expiration: Duration) -> Self { 13 14 let map = TaskMap { 14 - map: DashMap::new(), 15 + map: Arc::new(DashMap::new()), 15 16 expiration, 16 17 }; 17 - Self(Arc::new(map)) 18 + Self(map) 18 19 } 19 20 20 - pub fn dispatch(&self, task: impl Future<Output = T> + Send + 'static) -> String { 21 + pub fn dispatch<F>(&self, task: F, cancel: CancellationToken) -> String 22 + where 23 + F: Future<Output = T> + Send + 'static, 24 + { 25 + let TaskMap { 26 + ref map, 27 + expiration, 28 + } = self.0; 21 29 let task_key: String = rand::rng() 22 30 .sample_iter(&Alphanumeric) 23 31 .take(24) ··· 25 33 .collect(); 26 34 27 35 // spawn a tokio task and put the join handle in the map for later retrieval 28 - self.0.map.insert(task_key.clone(), spawn(task)); 36 + map.insert(task_key.clone(), (cancel.clone().drop_guard(), spawn(task))); 29 37 30 38 // spawn a second task to clean up the map in case it doesn't get claimed 31 - spawn({ 32 - let me = self.0.clone(); 33 - let key = task_key.clone(); 34 - async move { 35 - sleep(me.expiration).await; 36 - let _ = me.map.remove(&key); 37 - // TODO: also use a cancellation token so taking and expiring can mutually cancel 39 + let k = task_key.clone(); 40 + let map = map.clone(); 41 + spawn(async move { 42 + if cancel 43 + .run_until_cancelled(sleep(expiration)) 44 + .await 45 + .is_some() 46 + { 47 + map.remove(&k); 48 + cancel.cancel(); 38 49 } 39 50 }); 40 51 ··· 42 53 } 43 54 44 55 pub fn take(&self, key: &str) -> Option<JoinHandle<T>> { 45 - eprintln!("trying to take..."); 46 - self.0.map.remove(key).map(|(_, handle)| handle) 56 + // when the _guard drops, the token gets cancelled for us 57 + self.0.map.remove(key).map(|(_, (_guard, handle))| handle) 47 58 } 48 59 } 49 60 61 + #[derive(Clone)] 50 62 struct TaskMap<T> { 51 - map: DashMap<String, JoinHandle<T>>, 63 + map: Arc<DashMap<String, (DropGuard, JoinHandle<T>)>>, 52 64 expiration: Duration, 53 65 }
+8 -6
who-am-i/src/identity_resolver.rs
··· 4 4 use atrium_oauth::DefaultHttpClient; 5 5 use std::sync::Arc; 6 6 7 - pub async fn resolve_identity(did: String) -> String { 7 + pub async fn resolve_identity(did: String) -> Option<String> { 8 8 let http_client = Arc::new(DefaultHttpClient::default()); 9 9 let resolver = CommonDidResolver::new(CommonDidResolverConfig { 10 10 plc_directory_url: DEFAULT_PLC_DIRECTORY_URL.to_string(), 11 11 http_client: Arc::clone(&http_client), 12 12 }); 13 13 let doc = resolver.resolve(&Did::new(did).unwrap()).await.unwrap(); // TODO: this is only half the resolution? or is atrium checking dns? 14 - if let Some(aka) = doc.also_known_as { 15 - if let Some(f) = aka.first() { 16 - return f.to_string(); 14 + tokio::time::sleep(std::time::Duration::from_secs(2)).await; 15 + doc.also_known_as.and_then(|mut aka| { 16 + if aka.is_empty() { 17 + None 18 + } else { 19 + Some(aka.remove(0)) 17 20 } 18 - } 19 - "who knows".to_string() 21 + }) 20 22 }
+22 -9
who-am-i/src/server.rs
··· 4 4 Router, 5 5 extract::{FromRef, Query, State}, 6 6 http::header::{HeaderMap, REFERER}, 7 - response::{Html, IntoResponse, Redirect}, 7 + response::{Html, IntoResponse, Json, Redirect}, 8 8 routing::get, 9 9 }; 10 10 use axum_extra::extract::cookie::{Cookie, Key, SameSite, SignedCookieJar}; ··· 12 12 use handlebars::{Handlebars, handlebars_helper}; 13 13 14 14 use serde::{Deserialize, Serialize}; 15 - use serde_json::Value; 15 + use serde_json::{Value, json}; 16 16 use std::sync::Arc; 17 17 use std::time::Duration; 18 18 use tokio::net::TcpListener; ··· 34 34 pub key: Key, 35 35 pub engine: AppEngine, 36 36 pub client: Arc<Client>, 37 - pub resolving: ExpiringTaskMap<String>, 37 + pub resolving: ExpiringTaskMap<Option<String>>, 38 + pub shutdown: CancellationToken, 38 39 } 39 40 40 41 impl FromRef<AppState> for Key { ··· 60 61 key: Key::from(app_secret.as_bytes()), // TODO: via config 61 62 client: Arc::new(client()), 62 63 resolving: ExpiringTaskMap::new(task_pickup_expiration), 64 + shutdown: shutdown.clone(), 63 65 }; 64 66 65 67 let app = Router::new() ··· 89 91 } 90 92 async fn prompt( 91 93 State(AppState { 92 - engine, resolving, .. 94 + engine, 95 + resolving, 96 + shutdown, 97 + .. 93 98 }): State<AppState>, 94 99 jar: SignedCookieJar, 95 100 headers: HeaderMap, ··· 109 114 let m = if let Some(did) = jar.get(DID_COOKIE_KEY) { 110 115 let did = did.value_trimmed().to_string(); 111 116 112 - let fetch_key = resolving.dispatch(resolve_identity(did.clone())); 117 + let task_shutdown = shutdown.child_token(); 118 + let fetch_key = resolving.dispatch(resolve_identity(did.clone()), task_shutdown); 113 119 114 120 let json_did = Value::String(did); 115 121 let json_fetch_key = Value::String(fetch_key); ··· 134 140 State(AppState { resolving, .. }): State<AppState>, 135 141 Query(params): Query<UserInfoParams>, 136 142 ) -> impl IntoResponse { 137 - // let fetch_key: [char; 16] = params.fetch_key.chars().collect::<Vec<_>>().try_into().unwrap(); 138 - let Some(handle) = resolving.take(&params.fetch_key) else { 143 + let Some(task_handle) = resolving.take(&params.fetch_key) else { 139 144 return "oops, task does not exist or is gone".into_response(); 140 145 }; 141 - let s = handle.await.unwrap(); 142 - format!("sup: {s}").into_response() 146 + if let Some(handle) = task_handle.await.unwrap() { 147 + // TODO: get active state etc. 148 + // ...but also, that's a bsky thing? 149 + let Some(handle) = handle.strip_prefix("at://") else { 150 + return "hmm, handle did not start with at://".into_response(); 151 + }; 152 + Json(json!({ "handle": handle })).into_response() 153 + } else { 154 + "no handle?".into_response() 155 + } 143 156 } 144 157 145 158 #[derive(Debug, Deserialize)]
+105 -14
who-am-i/templates/prompt-known.hbs
··· 30 30 header > * { 31 31 flex-basis: 33%; 32 32 } 33 + header > .empty { 34 + font-size: 0.8rem; 35 + opacity: 0.5; 36 + } 33 37 header > .title { 34 38 text-align: center; 35 39 } ··· 43 47 opacity: 1; 44 48 } 45 49 main { 46 - padding: 0.25rem 0.5rem; 47 50 background: #ccc; 51 + display: flex; 52 + flex-direction: column; 48 53 flex-grow: 1; 54 + padding: 0.25rem 0.5rem; 49 55 } 50 56 p { 51 57 margin: 0.5rem 0; 52 58 } 59 + 60 + #loader { 61 + display: flex; 62 + flex-grow: 1; 63 + justify-content: center; 64 + align-items: center; 65 + margin-bottom: 1rem; 66 + } 67 + .spinner { 68 + animation: rotation 1.618s ease-in-out infinite; 69 + border-radius: 50%; 70 + border: 3px dashed #434; 71 + box-sizing: border-box; 72 + display: inline-block; 73 + height: 1.5em; 74 + width: 1.5em; 75 + } 76 + @keyframes rotation { 77 + 0% { transform: rotate(0deg) } 78 + 100% { transform: rotate(360deg) } 79 + } 80 + 81 + #user-info { 82 + flex-grow: 1; 83 + display: flex; 84 + flex-direction: column; 85 + justify-content: center; 86 + margin-bottom: 1rem; 87 + } 88 + #action { 89 + background: #eee; 90 + display: flex; 91 + justify-content: space-between; 92 + padding: 0.5rem 0.25rem 0.5rem 0.5rem; 93 + font-size: 0.8rem; 94 + align-items: baseline; 95 + border-radius: 0.5rem; 96 + border: 1px solid #bbb; 97 + cursor: pointer; 98 + } 99 + #action:hover { 100 + background: #fff; 101 + } 102 + #allow { 103 + background: transparent; 104 + border: none; 105 + border-left: 1px solid #bbb; 106 + padding: 0 0.5rem; 107 + color: #375; 108 + font: inherit; 109 + cursor: pointer; 110 + } 111 + #action:hover #allow { 112 + color: #396; 113 + } 114 + 115 + 116 + .hidden { 117 + display: none !important; 118 + } 119 + 53 120 </style> 54 121 55 122 <div class="wrap"> 56 123 <header> 57 - <div class="empty"></div> 124 + <div class="empty">🔒</div> 58 125 <code class="title" style="font-family: monospace;" 59 126 >who-am-i</code> 60 127 <a href="https://microcosm.blue" target="_blank" class="micro" ··· 72 139 73 140 <main> 74 141 <p>Share your identity with {{ parent_host }}?</p> 75 - <div id="user-info">Loading&hellip;</div> 142 + <div id="loader"> 143 + <span class="spinner"></span> 144 + </div> 145 + <div id="user-info" class="hidden"> 146 + <div id="action"> 147 + <span id="handle"></span> 148 + <button id="allow">Allow</button> 149 + </div> 150 + </div> 76 151 </main> 77 152 </div> 78 153 79 154 80 155 <script> 81 - const infoEl = document.getElementById('user-info'); 156 + var loaderEl = document.getElementById('loader'); 157 + var infoEl = document.getElementById('user-info'); 158 + var actionEl = document.getElementById('action'); 159 + var handleEl = document.getElementById('handle'); 160 + var allowEl = document.getElementById('allow'); 161 + 82 162 var DID = {{{json did}}}; 83 163 let user_info = new URL('/user-info', window.location); 84 164 user_info.searchParams.set('fetch-key', {{{json fetch_key}}}); 85 - fetch(user_info).then( 86 - info => { 87 - infoEl.textContent = 'yay'; 88 - console.log(info); 89 - }, 90 - err => { 91 - infoEl.textContent = 'ohno'; 92 - console.error(err); 93 - }, 94 - ); 165 + fetch(user_info) 166 + .then(resp => { 167 + if (!resp.ok) throw new Error('request failed'); 168 + return resp.json(); 169 + }) 170 + .then( 171 + ({ handle }) => { 172 + loaderEl.remove(); 173 + handleEl.textContent = `@${handle}`; 174 + infoEl.classList.remove('hidden'); 175 + actionEl.addEventListener('click', () => share(handle)); 176 + }, 177 + err => { 178 + infoEl.textContent = 'ohno'; 179 + console.error(err); 180 + }, 181 + ); 182 + 183 + function share(handle) { 184 + top.postMessage({ source: 'whoami', handle }, '*'); // TODO: pass the referrer back from server 185 + } 95 186 96 187 </script>