19bb31ff Andy McCall 1 month ago ↶
1
2
3
4
use super :: { filters, Error, Page, Shell} ;
5
use crate :: auth:: { self , totp, Viewer, ViewerAccount} ;
6
use crate :: db:: { self , accounts} ;
7
use crate :: Shared;
8
use askama:: Template;
83940d53 Andy McCall 1 month ago ↶
9
use axum:: extract:: { Form, Multipart, Path, Query, State} ;
c40c2d3e Andy McCall 1 month ago ↶
10
use axum:: http:: { header, HeaderMap, StatusCode} ;
0024ccb1 Andy McCall 20 days ago ↶
11
use axum:: response:: { IntoResponse, Redirect, Response} ;
19bb31ff Andy McCall 1 month ago ↶
12
use oxy_core:: identity:: { Identity, Signature} ;
13
use serde:: Deserialize;
14
15
16
pub fn require ( viewer : & Viewer, shell : & Shell) -> Result< ViewerAccount, Error> {
17
viewer.0. clone ( ) .ok_or_else ( | | Error:: redirect( format! ( " /login?next={} " , urlencode ( & shell.path) ) ) )
18
}
19
20
pub fn urlencode ( s : & str ) -> String {
21
let mut out = String :: new( ) ;
22
for b in s.bytes ( ) {
23
match b {
24
b ' A' .. = b ' Z' | b ' a' .. = b ' z' | b ' 0' .. = b ' 9' | b ' -' | b ' _' | b ' .' | b ' ~' | b ' /' => out.push ( b as char ) ,
25
_ => out.push_str ( & format! ( " %{b:02X} " ) ) ,
26
}
27
}
28
out
29
}
30
31
pub fn csrf ( viewer : & Viewer, token : & str , shell : & Shell) -> Result< ( ) , Error> {
32
if viewer.csrf_ok ( token) {
33
Ok ( ( ) )
34
} else {
35
Err ( Error:: bad( shell.clone ( ) , " This form has expired; reload the page and try again." ) )
36
}
37
}
38
39
# [ derive ( Template ) ]
40
# [ template ( path = " settings.html" ) ]
41
pub struct SettingsPage {
42
shell : Shell,
43
me : accounts:: Account,
44
notice : Option< String> ,
45
error : Option< String> ,
c40c2d3e Andy McCall 1 month ago ↶
46
47
theme : & 'static str ,
48
themes : & 'static [( & 'static str , & 'static str ) ],
17ca6cdd Andy McCall 22 days ago ↶
49
50
backdrop : & 'static str ,
51
backdrops : & 'static [( & 'static str , & 'static str ) ],
34beb1a7 Andy McCall 1 month ago ↶
52
53
mail : bool ,
c40c2d3e Andy McCall 1 month ago ↶
54
}
55
56
d98a93c1 Andy McCall 1 month ago ↶
57
const THEMES : & [ ( & str , & str ) ] = & [ ( " light" , " Light" ) , ( " dark" , " Dark" ) , ( " auto" , " Match the system" ) ] ;
c40c2d3e Andy McCall 1 month ago ↶
58
17ca6cdd Andy McCall 22 days ago ↶
59
60
const BACKDROPS : & [ ( & str , & str ) ] = & [ ( " moving" , " Moving" ) , ( " still" , " Still" ) , ( " off" , " Off" ) ] ;
61
62
63
fn backdrop_of ( headers : & HeaderMap) -> & 'static str {
64
let v = auth:: viewer:: cookie( headers, " backdrop" ) .unwrap_or_default ( ) ;
65
BACKDROPS .iter ( ) .map ( | ( t , _) | * t) .find ( | t | * t = = v) .unwrap_or ( " moving" )
66
}
67
d98a93c1 Andy McCall 1 month ago ↶
68
c40c2d3e Andy McCall 1 month ago ↶
69
fn theme_of ( headers : & HeaderMap) -> & 'static str {
70
let v = auth:: viewer:: cookie( headers, " theme" ) .unwrap_or_default ( ) ;
d98a93c1 Andy McCall 1 month ago ↶
71
THEMES .iter ( ) .map ( | ( t , _) | * t) .find ( | t | * t = = v) .unwrap_or ( " light" )
c40c2d3e Andy McCall 1 month ago ↶
72
}
73
74
impl SettingsPage {
75
fn new ( shell : Shell, me : accounts:: Account, headers : & HeaderMap) -> SettingsPage {
17ca6cdd Andy McCall 22 days ago ↶
76
SettingsPage {
77
shell,
78
me,
79
notice: None ,
80
error: None ,
81
theme: theme_of ( headers) ,
82
themes: THEMES ,
83
backdrop: backdrop_of ( headers) ,
84
backdrops: BACKDROPS ,
85
mail: false ,
86
}
c40c2d3e Andy McCall 1 month ago ↶
87
}
88
34beb1a7 Andy McCall 1 month ago ↶
89
fn with_mail ( mut self , app : & Shared) -> Self {
90
self .mail = crate :: mail:: enabled( app) ;
91
self
92
}
93
c40c2d3e Andy McCall 1 month ago ↶
94
fn notice ( mut self , n : impl Into< String> ) -> Self {
95
self .notice = Some ( n.into ( ) ) ;
96
self
97
}
98
99
fn error ( mut self , e : impl Into< String> ) -> Self {
100
self .error = Some ( e.into ( ) ) ;
101
self
102
}
19bb31ff Andy McCall 1 month ago ↶
103
}
104
c40c2d3e Andy McCall 1 month ago ↶
105
pub async fn profile ( State( app ) : State< Shared> , viewer : Viewer, headers : HeaderMap) -> Result< Page< SettingsPage> , Error> {
19bb31ff Andy McCall 1 month ago ↶
106
let shell = Shell:: new( & app, " /settings" , & viewer) ;
107
let me = require ( & viewer, & shell) ? ;
34beb1a7 Andy McCall 1 month ago ↶
108
Ok ( Page( SettingsPage:: new( shell, me.account, & headers) .with_mail ( & app) ) )
109
}
110
a160b98f Andy McCall 1 month ago ↶
111
112
pub async fn avatar (
113
State( app ) : State< Shared> ,
114
viewer : Viewer,
115
headers : HeaderMap,
116
multipart : Multipart,
117
) -> Result< Page< SettingsPage> , Error> {
118
let shell = Shell:: new( & app, " /settings" , & viewer) ;
119
let me = require ( & viewer, & shell) ? ;
120
let upload = super :: avatar:: read_upload( multipart) .await;
121
csrf ( & viewer, & upload.csrf, & shell) ? ;
122
let result = super :: avatar:: apply( & app, " account" , me.account.id, upload) .await;
123
if result.is_ok ( ) {
124
accounts:: audit( & app.db, Some ( & me.account) , " profile.picture" , & me.account.username, " " ) .await.ok ( ) ;
125
}
126
let fresh = accounts:: account_by_id( & app.db, me.account.id) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? .unwrap_or ( me.account) ;
127
let page = SettingsPage:: new( shell, fresh, & headers) .with_mail ( & app) ;
128
Ok ( Page( match result {
129
Ok ( m) => page.notice ( m) ,
130
Err ( e) => page.error ( e) ,
131
} ) )
132
}
133
34beb1a7 Andy McCall 1 month ago ↶
134
135
pub async fn resend_verification (
136
State( app ) : State< Shared> ,
137
viewer : Viewer,
138
headers : HeaderMap,
139
Form( f ) : Form< super:: session:: CsrfForm> ,
140
) -> Result< Page< SettingsPage> , Error> {
141
let shell = Shell:: new( & app, " /settings" , & viewer) ;
142
let me = require ( & viewer, & shell) ? ;
143
csrf ( & viewer, & f.csrf, & shell) ? ;
144
let page = SettingsPage:: new( shell, me.account.clone ( ) , & headers) .with_mail ( & app) ;
145
Ok ( Page( match super :: recovery:: send_verification( & app, & me.account) .await {
146
Ok ( ( ) ) if me.account.email_verified => page.notice ( " Your email is already verified." ) ,
147
Ok ( ( ) ) => page.notice ( format! ( " A verification link is on its way to {} ." , me.account.email) ) ,
148
Err ( e) => page.error ( e) ,
149
} ) )
19bb31ff Andy McCall 1 month ago ↶
150
}
151
152
# [ derive ( Deserialize ) ]
c40c2d3e Andy McCall 1 month ago ↶
153
pub struct AppearanceForm {
154
#[serde(rename = "_csrf")]
155
csrf_token : String,
156
theme : String,
17ca6cdd Andy McCall 22 days ago ↶
157
#[serde(default)]
158
backdrop : String,
c40c2d3e Andy McCall 1 month ago ↶
159
}
160
17ca6cdd Andy McCall 22 days ago ↶
161
162
c40c2d3e Andy McCall 1 month ago ↶
163
pub async fn appearance ( State( app ) : State< Shared> , viewer : Viewer, Form( f ) : Form< AppearanceForm> ) -> Result< Response, Error> {
164
let shell = Shell:: new( & app, " /settings" , & viewer) ;
165
require ( & viewer, & shell) ? ;
166
csrf ( & viewer, & f.csrf_token, & shell) ? ;
167
let Some ( ( theme, _ ) ) = THEMES .iter ( ) .find ( | ( t , _) | * t = = f.theme) else {
168
return Err ( Error:: bad( shell, " Pick dark, light or match the system." ) ) ;
17ca6cdd Andy McCall 22 days ago ↶
169
} ;
9a8e3b44 Andy McCall 20 days ago ↶
170
let secure = if app.config.base_url ( ) .starts_with ( " https://" ) { " ; Secure" } else { " " } ;
17ca6cdd Andy McCall 22 days ago ↶
171
let backdrop = if f.backdrop.is_empty ( ) { " moving" } else { f.backdrop.as_str ( ) } ;
172
let Some ( ( backdrop, _ ) ) = BACKDROPS .iter ( ) .find ( | ( b , _) | * b = = backdrop) else {
173
return Err ( Error:: bad( shell, " Pick a moving, still or no background." ) ) ;
c40c2d3e Andy McCall 1 month ago ↶
174
} ;
175
Ok ( (
176
StatusCode:: SEE_OTHER ,
17ca6cdd Andy McCall 22 days ago ↶
177
axum:: response:: AppendHeaders( [
9a8e3b44 Andy McCall 20 days ago ↶
178
( header:: SET_COOKIE , format! ( " theme={theme} ; Path=/; Max-Age=31536000; SameSite=Lax{secure} " ) ) ,
179
( header:: SET_COOKIE , format! ( " backdrop={backdrop} ; Path=/; Max-Age=31536000; SameSite=Lax{secure} " ) ) ,
c40c2d3e Andy McCall 1 month ago ↶
180
( header:: LOCATION , " /settings" .to_string ( ) ) ,
17ca6cdd Andy McCall 22 days ago ↶
181
] ) ,
c40c2d3e Andy McCall 1 month ago ↶
182
)
183
.into_response ( ) )
184
}
185
186
# [ derive ( Deserialize ) ]
19bb31ff Andy McCall 1 month ago ↶
187
pub struct ProfileForm {
188
#[serde(rename = "_csrf")]
189
csrf_token : String,
190
display_name : String,
191
email : String,
10174045 Andy McCall 1 month ago ↶
192
#[serde(default)]
193
bio : String,
194
#[serde(default)]
195
website : String,
196
#[serde(default)]
197
location : String,
89e89836 Andy McCall 1 month ago ↶
198
#[serde(default)]
199
bluesky : String,
200
#[serde(default)]
201
mastodon : String,
202
#[serde(default)]
203
github : String,
19bb31ff Andy McCall 1 month ago ↶
204
}
205
c40c2d3e Andy McCall 1 month ago ↶
206
pub async fn update_profile (
207
State( app ) : State< Shared> ,
208
viewer : Viewer,
209
headers : HeaderMap,
210
Form( f ) : Form< ProfileForm> ,
211
) -> Result< Page< SettingsPage> , Error> {
19bb31ff Andy McCall 1 month ago ↶
212
let shell = Shell:: new( & app, " /settings" , & viewer) ;
213
let me = require ( & viewer, & shell) ? ;
214
csrf ( & viewer, & f.csrf_token, & shell) ? ;
10174045 Andy McCall 1 month ago ↶
215
let website = f.website.trim ( ) ;
216
if ! ( website.is_empty ( ) | | website.starts_with ( " https://" ) | | website.starts_with ( " http://" ) ) | | website.len ( ) > 200 {
34beb1a7 Andy McCall 1 month ago ↶
217
return Ok ( Page(
218
SettingsPage:: new( shell, me.account, & headers) .with_mail ( & app) .error ( " The website must be an http:// or https:// address." ) ,
219
) ) ;
10174045 Andy McCall 1 month ago ↶
220
}
89e89836 Andy McCall 1 month ago ↶
221
222
let link = | given : & str , what : & str , parse : fn ( & str ) -> Option< String> | -> Result< String, String> {
223
if given.trim ( ) .is_empty ( ) {
224
Ok ( String :: new( ) )
225
} else {
226
parse ( given) .ok_or_else ( | | format! ( " That is not a {what} ." ) )
227
}
228
} ;
229
let links = ( | | {
230
Ok : : < _ , String> ( (
231
link ( & f.bluesky, " Bluesky handle (such as andy.bsky.social)" , crate :: social:: bluesky) ? ,
232
link ( & f.mastodon, " Mastodon address (such as @[email protected] )" , crate :: social:: mastodon) ? ,
233
link ( & f.github, " GitHub username" , crate :: social:: github) ? ,
234
) )
235
} ) ( ) ;
236
let ( bluesky, mastodon, github) = match links {
237
Ok ( l) => l,
238
Err ( msg) => return Ok ( Page( SettingsPage:: new( shell, me.account, & headers) .with_mail ( & app) .error ( & msg) ) ) ,
239
} ;
10174045 Andy McCall 1 month ago ↶
240
let bio: String = f.bio.trim ( ) .chars ( ) .take ( 280 ) .collect ( ) ;
241
let location: String = f.location.trim ( ) .chars ( ) .take ( 80 ) .collect ( ) ;
19bb31ff Andy McCall 1 month ago ↶
242
accounts:: set_profile( & app.db, me.account.id, f.display_name.trim ( ) , f.email.trim ( ) )
243
.await
244
.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
10174045 Andy McCall 1 month ago ↶
245
accounts:: set_bio( & app.db, me.account.id, & bio, website, & location) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
89e89836 Andy McCall 1 month ago ↶
246
accounts:: set_links( & app.db, me.account.id, & bluesky, & mastodon, & github) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
34beb1a7 Andy McCall 1 month ago ↶
247
let changed = me.account.email ! = f.email.trim ( ) ;
19bb31ff Andy McCall 1 month ago ↶
248
let updated =
249
accounts:: account_by_id( & app.db, me.account.id) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? .unwrap_or ( me.account) ;
34beb1a7 Andy McCall 1 month ago ↶
250
let mut notice = " Profile saved." .to_string ( ) ;
251
if changed & & ! updated.email.is_empty ( ) {
252
accounts:: audit( & app.db, Some ( & updated) , " email.changed" , & updated.username, " " ) .await.ok ( ) ;
253
match super :: recovery:: send_verification( & app, & updated) .await {
254
Ok ( ( ) ) => notice = format! ( " Profile saved. A verification link is on its way to {} ." , updated.email) ,
255
Err ( e) => notice = format! ( " Profile saved. {e} " ) ,
256
}
257
}
258
Ok ( Page( SettingsPage:: new( shell, updated, & headers) .with_mail ( & app) .notice ( notice) ) )
19bb31ff Andy McCall 1 month ago ↶
259
}
260
261
# [ derive ( Deserialize ) ]
262
pub struct PasswordForm {
263
#[serde(rename = "_csrf")]
264
csrf_token : String,
265
current : String,
266
password : String,
267
}
268
c40c2d3e Andy McCall 1 month ago ↶
269
pub async fn change_password (
270
State( app ) : State< Shared> ,
271
viewer : Viewer,
272
headers : HeaderMap,
273
Form( f ) : Form< PasswordForm> ,
274
) -> Result< Response, Error> {
19bb31ff Andy McCall 1 month ago ↶
275
let shell = Shell:: new( & app, " /settings" , & viewer) ;
276
let me = require ( & viewer, & shell) ? ;
277
csrf ( & viewer, & f.csrf_token, & shell) ? ;
278
if ! auth:: verify_password( & me.account.password_hash, & f.current) {
34beb1a7 Andy McCall 1 month ago ↶
279
return Ok (
280
Page( SettingsPage:: new( shell, me.account, & headers) .with_mail ( & app) .error ( " Your current password is wrong." ) ) .into_response ( )
281
) ;
19bb31ff Andy McCall 1 month ago ↶
282
}
283
if let Err ( e) = auth:: password_ok( & f.password) {
34beb1a7 Andy McCall 1 month ago ↶
284
return Ok ( Page( SettingsPage:: new( shell, me.account, & headers) .with_mail ( & app) .error ( e) ) .into_response ( ) ) ;
19bb31ff Andy McCall 1 month ago ↶
285
}
286
let hash = auth:: hash_password( & f.password) .map_err ( | e | Error:: bad( shell.clone ( ) , e.to_string ( ) ) ) ? ;
287
288
accounts:: set_password( & app.db, me.account.id, & hash, & auth:: random_hex( 16 ) ) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
289
accounts:: audit( & app.db, Some ( & me.account) , " password.changed" , & me.account.username, " " ) .await.ok ( ) ;
290
let fresh = accounts:: account_by_id( & app.db, me.account.id) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? .unwrap_or ( me.account) ;
0024ccb1 Andy McCall 20 days ago ↶
291
Ok ( super :: session:: signed_in( & app, & fresh, " /settings/security" ) .await)
19bb31ff Andy McCall 1 month ago ↶
292
}
293
294
# [ derive ( Template ) ]
295
# [ template ( path = " settings_security.html" ) ]
296
pub struct SecurityPage {
297
shell : Shell,
298
me : accounts:: Account,
299
notice : Option< String> ,
300
error : Option< String> ,
301
302
enrol : Option< ( String, String, String) > ,
303
recovery : Vec< String> ,
83940d53 Andy McCall 1 month ago ↶
304
305
connected : Vec< ( & 'static str , & 'static str , Option< String> ) > ,
0024ccb1 Andy McCall 20 days ago ↶
306
307
sessions : Vec< SessionView> ,
a22e4aec Andy McCall 3 days ago ↶
308
309
phones : Vec< crate:: db:: approvals:: Authenticator> ,
19bb31ff Andy McCall 1 month ago ↶
310
}
311
0024ccb1 Andy McCall 20 days ago ↶
312
313
pub struct SessionView {
314
pub id : i64 ,
315
pub current : bool ,
316
pub browser : String,
317
pub address : String,
318
pub seen_ms : i64 ,
319
pub created_ms : i64 ,
320
}
321
322
323
pub fn browser_of ( agent : & str ) -> String {
324
let has = | s : & str | agent.contains ( s) ;
325
let browser = if has ( " Edg/" ) {
326
" Edge"
327
} else if has ( " OPR/" ) {
328
" Opera"
329
} else if has ( " Firefox/" ) {
330
" Firefox"
331
} else if has ( " Chrome/" ) | | has ( " Chromium/" ) {
332
" Chrome"
333
} else if has ( " Safari/" ) {
334
" Safari"
335
} else if has ( " curl/" ) {
336
" curl"
337
} else {
338
" "
339
} ;
340
let os = if has ( " Android" ) {
341
" Android"
342
} else if has ( " iPhone" ) | | has ( " iPad" ) {
343
" iOS"
344
} else if has ( " Windows" ) {
345
" Windows"
346
} else if has ( " Mac OS X" ) | | has ( " Macintosh" ) {
347
" macOS"
348
} else if has ( " CrOS" ) {
349
" ChromeOS"
350
} else if has ( " Linux" ) {
351
" Linux"
352
} else {
353
" "
354
} ;
355
match ( browser, os) {
356
( " " , " " ) => " Unknown browser" .into ( ) ,
357
( b, " " ) => b.into ( ) ,
358
( " " , o) => format! ( " A browser on {o} " ) ,
359
( b, o) => format! ( " {b} on {o} " ) ,
360
}
361
}
362
363
async fn sessions_of ( app : & Shared, me : & crate:: auth:: ViewerAccount) -> sqlx:: Result< Vec< SessionView> > {
364
Ok ( crate :: db:: sessions:: of_account( & app.db, me.account.id, & me.account.session_salt)
365
.await?
366
.into_iter ( )
367
.map ( | s | SessionView {
368
id: s.id,
369
current: s.id = = me.session_id,
370
browser: browser_of ( & s.agent) ,
371
address: s.address,
372
seen_ms: s.seen_ms,
373
created_ms: s.created_ms,
374
} )
375
.collect ( ) )
376
}
377
83940d53 Andy McCall 1 month ago ↶
378
# [ derive ( Deserialize ) ]
379
pub struct SecurityQuery {
380
notice : Option< String> ,
381
error : Option< String> ,
382
}
383
384
pub async fn security ( State( app ) : State< Shared> , viewer : Viewer, Query( q ) : Query< SecurityQuery> ) -> Result< Page< SecurityPage> , Error> {
19bb31ff Andy McCall 1 month ago ↶
385
let shell = Shell:: new( & app, " /settings/security" , & viewer) ;
386
let me = require ( & viewer, & shell) ? ;
83940d53 Andy McCall 1 month ago ↶
387
let connected = connected_of ( & app, me.account.id) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
0024ccb1 Andy McCall 20 days ago ↶
388
let sessions = sessions_of ( & app, & me) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
a22e4aec Andy McCall 3 days ago ↶
389
let phones = crate :: db:: approvals:: authenticators( & app.db, me.account.id) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
390
Ok ( Page( SecurityPage {
391
shell,
392
me: me.account,
393
notice: q.notice,
394
error: q.error,
395
enrol: None ,
396
recovery: vec! [ ] ,
397
connected,
398
sessions,
399
phones,
400
} ) )
19bb31ff Andy McCall 1 month ago ↶
401
}
402
83940d53 Andy McCall 1 month ago ↶
403
404
pub async fn connected_of ( app : & Shared, account_id : i64 ) -> sqlx:: Result< Vec< ( & 'static str , & 'static str , Option< String> ) > > {
405
let links = crate :: signin:: links_of( & app.db, account_id) .await? ;
406
let mut out = vec! [ ] ;
407
for p in crate :: signin:: enabled( app) .await {
408
out.push ( ( p.id, p.name, links.iter ( ) .find ( | l | l.provider = = p.id ) .map ( | l | l.login.clone ( ) ) ) ) ;
409
}
410
Ok ( out)
411
}
412
19bb31ff Andy McCall 1 month ago ↶
413
# [ derive ( Deserialize ) ]
414
pub struct TotpForm {
415
#[serde(rename = "_csrf")]
416
csrf_token : String,
417
#[serde(default)]
418
secret : String,
419
#[serde(default)]
420
code : String,
421
#[serde(default)]
422
password : String,
423
}
424
425
426
pub async fn totp_begin ( State( app ) : State< Shared> , viewer : Viewer, Form( f ) : Form< TotpForm> ) -> Result< Page< SecurityPage> , Error> {
427
let shell = Shell:: new( & app, " /settings/security" , & viewer) ;
428
let me = require ( & viewer, & shell) ? ;
429
csrf ( & viewer, & f.csrf_token, & shell) ? ;
430
let secret = totp:: new_secret( ) ;
431
let uri = totp:: uri( & app.config.site.name, & me.account.username, & secret) ;
432
let qr = totp:: qr_svg( & uri) .unwrap_or_default ( ) ;
83940d53 Andy McCall 1 month ago ↶
433
Ok ( Page( SecurityPage {
434
shell,
435
me: me.account,
436
notice: None ,
437
error: None ,
438
enrol: Some ( ( secret, uri, qr) ) ,
439
recovery: vec! [ ] ,
440
connected: vec! [ ] ,
0024ccb1 Andy McCall 20 days ago ↶
441
sessions: vec! [ ] ,
a22e4aec Andy McCall 3 days ago ↶
442
phones: vec! [ ] ,
83940d53 Andy McCall 1 month ago ↶
443
} ) )
19bb31ff Andy McCall 1 month ago ↶
444
}
445
446
447
pub async fn totp_enable ( State( app ) : State< Shared> , viewer : Viewer, Form( f ) : Form< TotpForm> ) -> Result< Page< SecurityPage> , Error> {
448
let shell = Shell:: new( & app, " /settings/security" , & viewer) ;
449
let me = require ( & viewer, & shell) ? ;
450
csrf ( & viewer, & f.csrf_token, & shell) ? ;
451
let now = ( db:: now_ms( ) / 1000 ) as u64 ;
452
if f.secret.is_empty ( ) | | ! totp:: verify( & f.secret, & f.code, now) {
453
let uri = totp:: uri( & app.config.site.name, & me.account.username, & f.secret) ;
454
let qr = totp:: qr_svg( & uri) .unwrap_or_default ( ) ;
455
return Ok ( Page( SecurityPage {
456
shell,
457
me: me.account,
458
notice: None ,
459
error: Some ( " That code did not match; scan again and enter the current code." .into ( ) ) ,
460
enrol: Some ( ( f.secret, uri, qr) ) ,
461
recovery: vec! [ ] ,
83940d53 Andy McCall 1 month ago ↶
462
connected: vec! [ ] ,
0024ccb1 Andy McCall 20 days ago ↶
463
sessions: vec! [ ] ,
a22e4aec Andy McCall 3 days ago ↶
464
phones: vec! [ ] ,
19bb31ff Andy McCall 1 month ago ↶
465
} ) ) ;
466
}
467
let ( codes, stored) = totp:: new_recovery_codes( ) ;
468
accounts:: set_totp( & app.db, me.account.id, Some ( & f.secret) , & stored) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
469
accounts:: audit( & app.db, Some ( & me.account) , " totp.enabled" , & me.account.username, " " ) .await.ok ( ) ;
470
let fresh = accounts:: account_by_id( & app.db, me.account.id) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? .unwrap_or ( me.account) ;
471
Ok ( Page( SecurityPage {
472
shell,
473
me: fresh,
474
notice: Some ( " Two-factor authentication is on. Keep these recovery codes somewhere safe; each works once." .into ( ) ) ,
475
error: None ,
476
enrol: None ,
477
recovery: codes,
83940d53 Andy McCall 1 month ago ↶
478
connected: vec! [ ] ,
0024ccb1 Andy McCall 20 days ago ↶
479
sessions: vec! [ ] ,
a22e4aec Andy McCall 3 days ago ↶
480
phones: vec! [ ] ,
19bb31ff Andy McCall 1 month ago ↶
481
} ) )
482
}
483
484
pub async fn totp_disable ( State( app ) : State< Shared> , viewer : Viewer, Form( f ) : Form< TotpForm> ) -> Result< Page< SecurityPage> , Error> {
485
let shell = Shell:: new( & app, " /settings/security" , & viewer) ;
486
let me = require ( & viewer, & shell) ? ;
487
csrf ( & viewer, & f.csrf_token, & shell) ? ;
488
if ! auth:: verify_password( & me.account.password_hash, & f.password) {
489
return Ok ( Page( SecurityPage {
490
shell,
491
me: me.account,
492
notice: None ,
493
error: Some ( " Your password is wrong." .into ( ) ) ,
494
enrol: None ,
495
recovery: vec! [ ] ,
83940d53 Andy McCall 1 month ago ↶
496
connected: vec! [ ] ,
0024ccb1 Andy McCall 20 days ago ↶
497
sessions: vec! [ ] ,
a22e4aec Andy McCall 3 days ago ↶
498
phones: vec! [ ] ,
19bb31ff Andy McCall 1 month ago ↶
499
} ) ) ;
500
}
501
accounts:: set_totp( & app.db, me.account.id, None , " " ) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
502
accounts:: audit( & app.db, Some ( & me.account) , " totp.disabled" , & me.account.username, " " ) .await.ok ( ) ;
a22e4aec Andy McCall 3 days ago ↶
503
504
for p in crate :: db:: approvals:: authenticators( & app.db, me.account.id) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? {
505
crate :: db:: approvals:: remove_authenticator( & app.db, me.account.id, p.token_id)
506
.await
507
.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
508
}
19bb31ff Andy McCall 1 month ago ↶
509
let fresh = accounts:: account_by_id( & app.db, me.account.id) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? .unwrap_or ( me.account) ;
510
Ok ( Page( SecurityPage {
511
shell,
512
me: fresh,
a22e4aec Andy McCall 3 days ago ↶
513
notice: Some ( " Two-factor authentication is off, and no phone approves for you now." .into ( ) ) ,
19bb31ff Andy McCall 1 month ago ↶
514
error: None ,
515
enrol: None ,
516
recovery: vec! [ ] ,
83940d53 Andy McCall 1 month ago ↶
517
connected: vec! [ ] ,
0024ccb1 Andy McCall 20 days ago ↶
518
sessions: vec! [ ] ,
a22e4aec Andy McCall 3 days ago ↶
519
phones: vec! [ ] ,
19bb31ff Andy McCall 1 month ago ↶
520
} ) )
521
}
522
523
524
525
pub async fn sessions_reset ( State( app ) : State< Shared> , viewer : Viewer, Form( f ) : Form< super:: session:: CsrfForm> ) -> Result< Response, Error> {
526
let shell = Shell:: new( & app, " /settings/security" , & viewer) ;
527
let me = require ( & viewer, & shell) ? ;
528
csrf ( & viewer, & f.csrf, & shell) ? ;
529
accounts:: set_session_salt( & app.db, me.account.id, & auth:: random_hex( 16 ) ) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
530
accounts:: audit( & app.db, Some ( & me.account) , " sessions.reset" , & me.account.username, " " ) .await.ok ( ) ;
531
let fresh = accounts:: account_by_id( & app.db, me.account.id) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? .unwrap_or ( me.account) ;
0024ccb1 Andy McCall 20 days ago ↶
532
Ok ( super :: session:: signed_in( & app, & fresh, " /settings/security" ) .await)
533
}
534
535
536
pub async fn session_end (
537
State( app ) : State< Shared> ,
538
viewer : Viewer,
539
Path( id ) : Path< i64 > ,
540
Form( f ) : Form< super:: session:: CsrfForm> ,
541
) -> Result< Response, Error> {
542
let shell = Shell:: new( & app, " /settings/security" , & viewer) ;
543
let me = require ( & viewer, & shell) ? ;
544
csrf ( & viewer, & f.csrf, & shell) ? ;
545
let ended = crate :: db:: sessions:: end( & app.db, me.account.id, id) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
546
if ended {
547
accounts:: audit( & app.db, Some ( & me.account) , " session.ended" , & me.account.username, & id.to_string ( ) ) .await.ok ( ) ;
548
}
549
let to = if id = = me.session_id { " /" } else { " /settings/security?notice=That+browser+is+signed+out." } ;
550
Ok ( Redirect:: to( to) .into_response ( ) )
a22e4aec Andy McCall 3 days ago ↶
551
}
552
553
554
555
pub async fn phone_remove (
556
State( app ) : State< Shared> ,
557
viewer : Viewer,
558
Path( id ) : Path< i64 > ,
559
Form( f ) : Form< super:: session:: CsrfForm> ,
560
) -> Result< Response, Error> {
561
let shell = Shell:: new( & app, " /settings/security" , & viewer) ;
562
let me = require ( & viewer, & shell) ? ;
563
csrf ( & viewer, & f.csrf, & shell) ? ;
564
let removed =
565
crate :: db:: approvals:: remove_authenticator( & app.db, me.account.id, id) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
566
if removed {
567
accounts:: audit( & app.db, Some ( & me.account) , " 2fa.phone_removed" , & me.account.username, & id.to_string ( ) ) .await.ok ( ) ;
568
}
569
Ok ( Redirect:: to( " /settings/security?notice=That+phone+no+longer+approves+for+you.#phones" ) .into_response ( ) )
19bb31ff Andy McCall 1 month ago ↶
570
}
571
572
573
574
# [ derive ( Template ) ]
575
# [ template ( path = " settings_keys.html" ) ]
576
pub struct KeysPage {
577
shell : Shell,
578
me : accounts:: Account,
579
keys : Vec< accounts:: LinkedIdentity> ,
580
notice : Option< String> ,
581
error : Option< String> ,
582
583
pending : Option< ( i64 , String, String) > ,
584
}
585
586
async fn keys_page (
587
app : & Shared,
588
shell : Shell,
589
me : accounts:: Account,
590
notice : Option< String> ,
591
error : Option< String> ,
592
pending : Option< ( i64 , String, String) > ,
593
) -> Result< Page< KeysPage> , Error> {
594
let keys = accounts:: identities_of( & app.db, me.id) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
595
Ok ( Page( KeysPage { shell, me, keys, notice, error, pending } ) )
596
}
597
598
pub async fn keys ( State( app ) : State< Shared> , viewer : Viewer) -> Result< Page< KeysPage> , Error> {
599
let shell = Shell:: new( & app, " /settings/keys" , & viewer) ;
600
let me = require ( & viewer, & shell) ? ;
601
keys_page ( & app, shell, me.account, None , None , None ) .await
602
}
603
604
605
606
pub async fn keys_upload ( State( app ) : State< Shared> , viewer : Viewer, mut multipart : Multipart) -> Result< Page< KeysPage> , Error> {
607
let shell = Shell:: new( & app, " /settings/keys" , & viewer) ;
608
let me = require ( & viewer, & shell) ? ;
609
let mut token = String :: new( ) ;
610
let mut object: Vec< u8 > = Vec :: new( ) ;
611
while let Ok ( Some ( field) ) = multipart.next_field ( ) .await {
612
match field.name ( ) {
613
Some ( " _csrf" ) => token = field.text ( ) .await.unwrap_or_default ( ) ,
614
Some ( " identity" ) => object = field.bytes ( ) .await.map ( | b | b.to_vec ( ) ) .unwrap_or_default ( ) ,
615
_ => { }
616
}
617
}
618
csrf ( & viewer, & token, & shell) ? ;
619
let identity = match Identity:: decode( & object) {
620
Ok ( i) => i,
621
Err ( e) => return keys_page ( & app, shell, me.account, None , Some ( format! ( " That is not an identity file: {e} " ) ) , None ) .await,
622
} ;
78763257 Andy McCall 15 days ago ↶
623
if identity.hash ( ) = = app.hub_key.key {
624
let msg = " That is this hub's own key, which only the hub holds. Upload your identity: after `oxy identity delegate add` with the hub's key, run `oxy identity export` in that checkout and upload the file it writes." ;
625
return keys_page ( & app, shell, me.account, None , Some ( msg.into ( ) ) , None ) .await;
626
}
19bb31ff Andy McCall 1 month ago ↶
627
let key = identity.hash ( ) .to_hex ( ) ;
628
if let Some ( existing) = accounts:: identity_by_key( & app.db, & key) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? {
629
let msg = if existing.account_id = = me.account.id {
78763257 Andy McCall 15 days ago ↶
630
" That key is already linked to you. If you have delegated since (`oxy identity delegate add`), export again: a delegation makes a new version of your identity."
19bb31ff Andy McCall 1 month ago ↶
631
} else {
632
" That key is linked to another account."
633
} ;
634
return keys_page ( & app, shell, me.account, None , Some ( msg.into ( ) ) , None ) .await;
635
}
636
let bytes = auth:: random_hex( 32 ) ;
637
let id = accounts:: create_challenge( & app.db, me.account.id, & bytes) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
5d90e0e0 Andy McCall 20 days ago
638
sqlx:: query( " UPDATE challenges SET object = $1 WHERE id = $2" )
19bb31ff Andy McCall 1 month ago ↶
639
.bind ( & object)
640
.bind ( id)
641
.execute ( & app.db)
642
.await
643
.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
644
keys_page ( & app, shell, me.account, None , None , Some ( ( id, bytes, identity.name) ) ) .await
645
}
646
647
# [ derive ( Deserialize ) ]
648
pub struct KeyVerifyForm {
649
#[serde(rename = "_csrf")]
650
csrf_token : String,
651
challenge : i64 ,
652
signature : String,
653
}
654
655
pub async fn keys_verify ( State( app ) : State< Shared> , viewer : Viewer, Form( f ) : Form< KeyVerifyForm> ) -> Result< Page< KeysPage> , Error> {
656
let shell = Shell:: new( & app, " /settings/keys" , & viewer) ;
657
let me = require ( & viewer, & shell) ? ;
658
csrf ( & viewer, & f.csrf_token, & shell) ? ;
5d90e0e0 Andy McCall 20 days ago
659
let object: Option< Vec< u8 > > = sqlx:: query_scalar( " SELECT object FROM challenges WHERE id = $1 AND account_id = $2" )
19bb31ff Andy McCall 1 month ago ↶
660
.bind ( f.challenge)
661
.bind ( me.account.id)
662
.fetch_optional ( & app.db)
663
.await
664
.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
665
let bytes = accounts:: take_challenge( & app.db, me.account.id, f.challenge) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
666
let ( Some ( object) , Some ( bytes) ) = ( object, bytes) else {
667
return keys_page ( & app, shell, me.account, None , Some ( " That challenge has expired; upload the identity again." .into ( ) ) , None ) .await;
668
} ;
669
let Ok ( identity) = Identity:: decode( & object) else {
670
return keys_page ( & app, shell, me.account, None , Some ( " The stored identity is unreadable." .into ( ) ) , None ) .await;
671
} ;
672
let sig_bytes = hex:: decode( f.signature.trim ( ) ) .unwrap_or_default ( ) ;
673
let sigs = Signature:: decode_list( & sig_bytes) .unwrap_or_default ( ) ;
674
let challenge = hex:: decode( & bytes) .unwrap_or_default ( ) ;
675
let ok = sigs.first ( ) .is_some_and ( | sig | oxy_core:: identity:: verify_challenge( & identity, sig, & challenge) .is_ok ( ) ) ;
676
if ! ok {
78763257 Andy McCall 15 days ago ↶
677
678
679
let msg = match sigs.first ( ) {
680
Some ( sig) if sig.key ! = identity.hash ( ) => format! (
681
" That signature is by key {} , but the identity uploaded is {} ({} ). Sign in a checkout whose `oxy identity export` gives that identity, without an `author.signer` set, or upload the identity that checkout exports." ,
682
sig.key.short ( 12 ) ,
683
identity.hash ( ) .short ( 12 ) ,
684
identity.name
685
) ,
686
_ => " The signature does not verify against that identity and challenge." .into ( ) ,
687
} ;
688
return keys_page ( & app, shell, me.account, None , Some ( msg) , None ) .await;
19bb31ff Andy McCall 1 month ago ↶
689
}
690
let key = identity.hash ( ) .to_hex ( ) ;
691
accounts:: link_identity( & app.db, me.account.id, & key, & identity.name, & identity.email, & object)
692
.await
693
.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
694
accounts:: audit( & app.db, Some ( & me.account) , " identity.linked" , & key, & identity.name) .await.ok ( ) ;
695
keys_page ( & app, shell, me.account, Some ( " Key linked. Changes signed with it are now yours on every page." .into ( ) ) , None , None ) .await
696
}
697
698
# [ derive ( Deserialize ) ]
699
pub struct KeyForm {
700
#[serde(rename = "_csrf")]
701
csrf_token : String,
702
key : String,
703
}
704
705
pub async fn keys_unlink ( State( app ) : State< Shared> , viewer : Viewer, Form( f ) : Form< KeyForm> ) -> Result< Page< KeysPage> , Error> {
706
let shell = Shell:: new( & app, " /settings/keys" , & viewer) ;
707
let me = require ( & viewer, & shell) ? ;
708
csrf ( & viewer, & f.csrf_token, & shell) ? ;
709
accounts:: unlink_identity( & app.db, me.account.id, & f.key) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
710
accounts:: audit( & app.db, Some ( & me.account) , " identity.unlinked" , & f.key, " " ) .await.ok ( ) ;
711
keys_page ( & app, shell, me.account, Some ( " Key unlinked." .into ( ) ) , None , None ) .await
712
}
713
714
715
716
# [ derive ( Template ) ]
717
# [ template ( path = " settings_tokens.html" ) ]
718
pub struct TokensPage {
719
shell : Shell,
b45384c9 Andy McCall 1 month ago ↶
720
scopes : & 'static [( & 'static str , & 'static str ) ],
19bb31ff Andy McCall 1 month ago ↶
721
me : accounts:: Account,
722
tokens : Vec< accounts:: Token> ,
723
724
fresh : Option< String> ,
725
notice : Option< String> ,
726
error : Option< String> ,
727
}
728
729
async fn tokens_page (
730
app : & Shared,
731
shell : Shell,
732
me : accounts:: Account,
733
fresh : Option< String> ,
734
notice : Option< String> ,
735
error : Option< String> ,
736
) -> Result< Page< TokensPage> , Error> {
737
let tokens = accounts:: tokens_of( & app.db, me.id) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
b45384c9 Andy McCall 1 month ago ↶
738
Ok ( Page( TokensPage { shell, scopes: SCOPES , me, tokens, fresh, notice, error } ) )
19bb31ff Andy McCall 1 month ago ↶
739
}
740
741
pub async fn tokens ( State( app ) : State< Shared> , viewer : Viewer) -> Result< Page< TokensPage> , Error> {
742
let shell = Shell:: new( & app, " /settings/tokens" , & viewer) ;
743
let me = require ( & viewer, & shell) ? ;
744
tokens_page ( & app, shell, me.account, None , None , None ) .await
745
}
746
747
# [ derive ( Deserialize ) ]
748
pub struct TokenForm {
749
#[serde(rename = "_csrf")]
750
csrf_token : String,
751
name : String,
752
#[serde(default)]
753
scopes : Vec< String> ,
754
#[serde(default)]
755
repos : String,
756
#[serde(default)]
757
expires_days : String,
bff9106e Andy McCall 21 days ago ↶
758
759
#[serde(default)]
760
agent : String,
19bb31ff Andy McCall 1 month ago ↶
761
}
762
b45384c9 Andy McCall 1 month ago ↶
763
764
19bb31ff Andy McCall 1 month ago ↶
765
pub const SCOPES : & [ ( & str , & str ) ] = & [
b45384c9 Andy McCall 1 month ago ↶
766
( " repo:read" , " read repositories you can see: clone, fetch, pull, and the read API" ) ,
767
( " repo:write" , " push to repositories you can write to; also covers proposals, issues and checks" ) ,
768
( " repo:admin" , " create repositories and change settings through the API" ) ,
769
( " proposals:write" , " open, comment on and review proposals, without push access" ) ,
770
( " issues:write" , " open, comment on and close issues, without push access" ) ,
771
( " checks:write" , " report checks on states, for CI systems, without push access" ) ,
772
( " hooks:admin" , " manage webhooks through the API" ) ,
773
( " user:read" , " read your own profile through the API" ) ,
19bb31ff Andy McCall 1 month ago ↶
774
] ;
775
776
pub async fn tokens_create (
777
State( app ) : State< Shared> ,
778
viewer : Viewer,
779
axum_extra:: extract:: Form( f ) : axum_extra:: extract:: Form< TokenForm> ,
780
) -> Result< Page< TokensPage> , Error> {
781
let shell = Shell:: new( & app, " /settings/tokens" , & viewer) ;
782
let me = require ( & viewer, & shell) ? ;
783
csrf ( & viewer, & f.csrf_token, & shell) ? ;
784
let name = f.name.trim ( ) ;
785
if name.is_empty ( ) {
786
return tokens_page ( & app, shell, me.account, None , None , Some ( " Give the token a name." .into ( ) ) ) .await;
787
}
788
let scopes: Vec< & str > = f.scopes.iter ( ) .map ( String :: as_str) .filter ( | s | SCOPES .iter ( ) .any ( | ( k , _) | k = = s ) ) .collect ( ) ;
789
if scopes.is_empty ( ) {
790
return tokens_page ( & app, shell, me.account, None , None , Some ( " Pick at least one scope." .into ( ) ) ) .await;
791
}
792
let expires_ms = f.expires_days.trim ( ) .parse:: < i64 > ( ) .ok ( ) .filter ( | d | * d > 0 ) .map ( | d | db:: now_ms( ) + d * 86_400_000 ) ;
793
let repos = f.repos.split ( ' ,' ) .map ( str :: trim) .filter ( | r | ! r.is_empty ( ) ) .collect:: < Vec< _ > > ( ) .join ( " ," ) ;
794
let ( secret, hash) = auth:: new_token( ) ;
795
accounts:: create_token( & app.db, me.account.id, name, & hash, & scopes.join ( " ," ) , & repos, expires_ms)
796
.await
797
.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
bff9106e Andy McCall 21 days ago ↶
798
if ! f.agent.is_empty ( ) {
799
if let Ok ( Some ( t) ) = accounts:: token_by_hash( & app.db, & hash) .await {
800
accounts:: set_token_agent( & app.db, me.account.id, t.id, true ) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
801
}
802
}
19bb31ff Andy McCall 1 month ago ↶
803
accounts:: audit( & app.db, Some ( & me.account) , " token.created" , name, & scopes.join ( " ," ) ) .await.ok ( ) ;
804
tokens_page ( & app, shell, me.account, Some ( secret) , Some ( " Copy the token now; it is not shown again." .into ( ) ) , None ) .await
bff9106e Andy McCall 21 days ago ↶
805
}
806
807
808
809
pub async fn tokens_agent (
810
State( app ) : State< Shared> ,
811
viewer : Viewer,
812
Path( id ) : Path< i64 > ,
813
Form( f ) : Form< super:: session:: CsrfForm> ,
814
) -> Result< Page< TokensPage> , Error> {
815
let shell = Shell:: new( & app, " /settings/tokens" , & viewer) ;
816
let me = require ( & viewer, & shell) ? ;
817
csrf ( & viewer, & f.csrf, & shell) ? ;
818
let tokens = accounts:: tokens_of( & app.db, me.account.id) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
819
let Some ( t) = tokens.iter ( ) .find ( | t | t.id = = id ) else {
820
return tokens_page ( & app, shell, me.account, None , None , Some ( " No such token." .into ( ) ) ) .await;
821
} ;
822
accounts:: set_token_agent( & app.db, me.account.id, id, ! t.agent) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
823
accounts:: audit( & app.db, Some ( & me.account) , " token.agent" , & t.name, if t.agent { " off" } else { " on" } ) .await.ok ( ) ;
824
let notice = if t.agent {
825
format! ( " {} is no longer marked as an agent's." , t.name)
826
} else {
827
format! ( " {} is an agent's: you will hear what it does." , t.name)
828
} ;
829
tokens_page ( & app, shell, me.account, None , Some ( notice) , None ) .await
19bb31ff Andy McCall 1 month ago ↶
830
}
831
832
pub async fn tokens_delete (
833
State( app ) : State< Shared> ,
834
viewer : Viewer,
835
Path( id ) : Path< i64 > ,
836
Form( f ) : Form< super:: session:: CsrfForm> ,
837
) -> Result< Page< TokensPage> , Error> {
838
let shell = Shell:: new( & app, " /settings/tokens" , & viewer) ;
839
let me = require ( & viewer, & shell) ? ;
840
csrf ( & viewer, & f.csrf, & shell) ? ;
841
accounts:: delete_token( & app.db, me.account.id, id) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
842
accounts:: audit( & app.db, Some ( & me.account) , " token.deleted" , & id.to_string ( ) , " " ) .await.ok ( ) ;
843
tokens_page ( & app, shell, me.account, None , Some ( " Token deleted." .into ( ) ) , None ) .await
0024ccb1 Andy McCall 20 days ago ↶
844
}
845
846
# [ cfg ( test ) ]
847
mod tests {
848
use super :: browser_of;
849
850
# [ test ]
851
fn browsers_are_named_from_their_agent ( ) {
852
let ff = " Mozilla/5.0 (X11; Linux x86_64; rv:131.0) Gecko/20100101 Firefox/131.0" ;
853
let chrome_mac =
854
" Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0 Safari/537.36" ;
855
let edge = " Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0 Safari/537.36 Edg/129.0" ;
856
let iphone = " Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1" ;
857
assert_eq! ( browser_of ( ff) , " Firefox on Linux" ) ;
858
assert_eq! ( browser_of ( chrome_mac) , " Chrome on macOS" ) ;
859
assert_eq! ( browser_of ( edge) , " Edge on Windows" ) ;
860
assert_eq! ( browser_of ( iphone) , " Safari on iOS" ) ;
861
assert_eq! ( browser_of ( " " ) , " Unknown browser" ) ;
862
}
19bb31ff Andy McCall 1 month ago ↶
863
}