+23
-9
who-am-i/demo/index.html
+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
+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
+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
+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(¶ms.fetch_key) else {
143
+
let Some(task_handle) = resolving.take(¶ms.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
+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…</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>