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 23 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 23 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 23 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 23 days ago ↶
157
#[serde(default)]
158
backdrop : String,
c40c2d3e Andy McCall 1 month ago ↶
159
}
160
17ca6cdd Andy McCall 23 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 23 days ago ↶
169
} ;
9a8e3b44 Andy McCall 21 days ago ↶
170
let secure = if app.config.base_url ( ) .starts_with ( " https://" ) { " ; Secure" } else { " " } ;
17ca6cdd Andy McCall 23 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 23 days ago ↶
177
axum:: response:: AppendHeaders( [
9a8e3b44 Andy McCall 21 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 23 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> ,
19bb31ff Andy McCall 1 month ago ↶
308
}
309
0024ccb1 Andy McCall 20 days ago ↶
310
311
pub struct SessionView {
312
pub id : i64 ,
313
pub current : bool ,
314
pub browser : String,
315
pub address : String,
316
pub seen_ms : i64 ,
317
pub created_ms : i64 ,
318
}
319
320
321
pub fn browser_of ( agent : & str ) -> String {
322
let has = | s : & str | agent.contains ( s) ;
323
let browser = if has ( " Edg/" ) {
324
" Edge"
325
} else if has ( " OPR/" ) {
326
" Opera"
327
} else if has ( " Firefox/" ) {
328
" Firefox"
329
} else if has ( " Chrome/" ) | | has ( " Chromium/" ) {
330
" Chrome"
331
} else if has ( " Safari/" ) {
332
" Safari"
333
} else if has ( " curl/" ) {
334
" curl"
335
} else {
336
" "
337
} ;
338
let os = if has ( " Android" ) {
339
" Android"
340
} else if has ( " iPhone" ) | | has ( " iPad" ) {
341
" iOS"
342
} else if has ( " Windows" ) {
343
" Windows"
344
} else if has ( " Mac OS X" ) | | has ( " Macintosh" ) {
345
" macOS"
346
} else if has ( " CrOS" ) {
347
" ChromeOS"
348
} else if has ( " Linux" ) {
349
" Linux"
350
} else {
351
" "
352
} ;
353
match ( browser, os) {
354
( " " , " " ) => " Unknown browser" .into ( ) ,
355
( b, " " ) => b.into ( ) ,
356
( " " , o) => format! ( " A browser on {o} " ) ,
357
( b, o) => format! ( " {b} on {o} " ) ,
358
}
359
}
360
361
async fn sessions_of ( app : & Shared, me : & crate:: auth:: ViewerAccount) -> sqlx:: Result< Vec< SessionView> > {
362
Ok ( crate :: db:: sessions:: of_account( & app.db, me.account.id, & me.account.session_salt)
363
.await?
364
.into_iter ( )
365
.map ( | s | SessionView {
366
id: s.id,
367
current: s.id = = me.session_id,
368
browser: browser_of ( & s.agent) ,
369
address: s.address,
370
seen_ms: s.seen_ms,
371
created_ms: s.created_ms,
372
} )
373
.collect ( ) )
374
}
375
83940d53 Andy McCall 1 month ago ↶
376
# [ derive ( Deserialize ) ]
377
pub struct SecurityQuery {
378
notice : Option< String> ,
379
error : Option< String> ,
380
}
381
382
pub async fn security ( State( app ) : State< Shared> , viewer : Viewer, Query( q ) : Query< SecurityQuery> ) -> Result< Page< SecurityPage> , Error> {
19bb31ff Andy McCall 1 month ago ↶
383
let shell = Shell:: new( & app, " /settings/security" , & viewer) ;
384
let me = require ( & viewer, & shell) ? ;
83940d53 Andy McCall 1 month ago ↶
385
let connected = connected_of ( & app, me.account.id) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
0024ccb1 Andy McCall 20 days ago ↶
386
let sessions = sessions_of ( & app, & me) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
387
Ok ( Page( SecurityPage { shell, me: me.account, notice: q.notice, error: q.error, enrol: None , recovery: vec! [ ] , connected, sessions } ) )
19bb31ff Andy McCall 1 month ago ↶
388
}
389
83940d53 Andy McCall 1 month ago ↶
390
391
pub async fn connected_of ( app : & Shared, account_id : i64 ) -> sqlx:: Result< Vec< ( & 'static str , & 'static str , Option< String> ) > > {
392
let links = crate :: signin:: links_of( & app.db, account_id) .await? ;
393
let mut out = vec! [ ] ;
394
for p in crate :: signin:: enabled( app) .await {
395
out.push ( ( p.id, p.name, links.iter ( ) .find ( | l | l.provider = = p.id ) .map ( | l | l.login.clone ( ) ) ) ) ;
396
}
397
Ok ( out)
398
}
399
19bb31ff Andy McCall 1 month ago ↶
400
# [ derive ( Deserialize ) ]
401
pub struct TotpForm {
402
#[serde(rename = "_csrf")]
403
csrf_token : String,
404
#[serde(default)]
405
secret : String,
406
#[serde(default)]
407
code : String,
408
#[serde(default)]
409
password : String,
410
}
411
412
413
pub async fn totp_begin ( State( app ) : State< Shared> , viewer : Viewer, Form( f ) : Form< TotpForm> ) -> Result< Page< SecurityPage> , Error> {
414
let shell = Shell:: new( & app, " /settings/security" , & viewer) ;
415
let me = require ( & viewer, & shell) ? ;
416
csrf ( & viewer, & f.csrf_token, & shell) ? ;
417
let secret = totp:: new_secret( ) ;
418
let uri = totp:: uri( & app.config.site.name, & me.account.username, & secret) ;
419
let qr = totp:: qr_svg( & uri) .unwrap_or_default ( ) ;
83940d53 Andy McCall 1 month ago ↶
420
Ok ( Page( SecurityPage {
421
shell,
422
me: me.account,
423
notice: None ,
424
error: None ,
425
enrol: Some ( ( secret, uri, qr) ) ,
426
recovery: vec! [ ] ,
427
connected: vec! [ ] ,
0024ccb1 Andy McCall 20 days ago ↶
428
sessions: vec! [ ] ,
83940d53 Andy McCall 1 month ago ↶
429
} ) )
19bb31ff Andy McCall 1 month ago ↶
430
}
431
432
433
pub async fn totp_enable ( State( app ) : State< Shared> , viewer : Viewer, Form( f ) : Form< TotpForm> ) -> Result< Page< SecurityPage> , Error> {
434
let shell = Shell:: new( & app, " /settings/security" , & viewer) ;
435
let me = require ( & viewer, & shell) ? ;
436
csrf ( & viewer, & f.csrf_token, & shell) ? ;
437
let now = ( db:: now_ms( ) / 1000 ) as u64 ;
438
if f.secret.is_empty ( ) | | ! totp:: verify( & f.secret, & f.code, now) {
439
let uri = totp:: uri( & app.config.site.name, & me.account.username, & f.secret) ;
440
let qr = totp:: qr_svg( & uri) .unwrap_or_default ( ) ;
441
return Ok ( Page( SecurityPage {
442
shell,
443
me: me.account,
444
notice: None ,
445
error: Some ( " That code did not match; scan again and enter the current code." .into ( ) ) ,
446
enrol: Some ( ( f.secret, uri, qr) ) ,
447
recovery: vec! [ ] ,
83940d53 Andy McCall 1 month ago ↶
448
connected: vec! [ ] ,
0024ccb1 Andy McCall 20 days ago ↶
449
sessions: vec! [ ] ,
19bb31ff Andy McCall 1 month ago ↶
450
} ) ) ;
451
}
452
let ( codes, stored) = totp:: new_recovery_codes( ) ;
453
accounts:: set_totp( & app.db, me.account.id, Some ( & f.secret) , & stored) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
454
accounts:: audit( & app.db, Some ( & me.account) , " totp.enabled" , & me.account.username, " " ) .await.ok ( ) ;
455
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) ;
456
Ok ( Page( SecurityPage {
457
shell,
458
me: fresh,
459
notice: Some ( " Two-factor authentication is on. Keep these recovery codes somewhere safe; each works once." .into ( ) ) ,
460
error: None ,
461
enrol: None ,
462
recovery: codes,
83940d53 Andy McCall 1 month ago ↶
463
connected: vec! [ ] ,
0024ccb1 Andy McCall 20 days ago ↶
464
sessions: vec! [ ] ,
19bb31ff Andy McCall 1 month ago ↶
465
} ) )
466
}
467
468
pub async fn totp_disable ( State( app ) : State< Shared> , viewer : Viewer, Form( f ) : Form< TotpForm> ) -> Result< Page< SecurityPage> , Error> {
469
let shell = Shell:: new( & app, " /settings/security" , & viewer) ;
470
let me = require ( & viewer, & shell) ? ;
471
csrf ( & viewer, & f.csrf_token, & shell) ? ;
472
if ! auth:: verify_password( & me.account.password_hash, & f.password) {
473
return Ok ( Page( SecurityPage {
474
shell,
475
me: me.account,
476
notice: None ,
477
error: Some ( " Your password is wrong." .into ( ) ) ,
478
enrol: None ,
479
recovery: vec! [ ] ,
83940d53 Andy McCall 1 month ago ↶
480
connected: vec! [ ] ,
0024ccb1 Andy McCall 20 days ago ↶
481
sessions: vec! [ ] ,
19bb31ff Andy McCall 1 month ago ↶
482
} ) ) ;
483
}
484
accounts:: set_totp( & app.db, me.account.id, None , " " ) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
485
accounts:: audit( & app.db, Some ( & me.account) , " totp.disabled" , & me.account.username, " " ) .await.ok ( ) ;
486
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) ;
487
Ok ( Page( SecurityPage {
488
shell,
489
me: fresh,
490
notice: Some ( " Two-factor authentication is off." .into ( ) ) ,
491
error: None ,
492
enrol: None ,
493
recovery: vec! [ ] ,
83940d53 Andy McCall 1 month ago ↶
494
connected: vec! [ ] ,
0024ccb1 Andy McCall 20 days ago ↶
495
sessions: vec! [ ] ,
19bb31ff Andy McCall 1 month ago ↶
496
} ) )
497
}
498
499
500
501
pub async fn sessions_reset ( State( app ) : State< Shared> , viewer : Viewer, Form( f ) : Form< super:: session:: CsrfForm> ) -> Result< Response, Error> {
502
let shell = Shell:: new( & app, " /settings/security" , & viewer) ;
503
let me = require ( & viewer, & shell) ? ;
504
csrf ( & viewer, & f.csrf, & shell) ? ;
505
accounts:: set_session_salt( & app.db, me.account.id, & auth:: random_hex( 16 ) ) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
506
accounts:: audit( & app.db, Some ( & me.account) , " sessions.reset" , & me.account.username, " " ) .await.ok ( ) ;
507
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 ↶
508
Ok ( super :: session:: signed_in( & app, & fresh, " /settings/security" ) .await)
509
}
510
511
512
pub async fn session_end (
513
State( app ) : State< Shared> ,
514
viewer : Viewer,
515
Path( id ) : Path< i64 > ,
516
Form( f ) : Form< super:: session:: CsrfForm> ,
517
) -> Result< Response, Error> {
518
let shell = Shell:: new( & app, " /settings/security" , & viewer) ;
519
let me = require ( & viewer, & shell) ? ;
520
csrf ( & viewer, & f.csrf, & shell) ? ;
521
let ended = crate :: db:: sessions:: end( & app.db, me.account.id, id) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
522
if ended {
523
accounts:: audit( & app.db, Some ( & me.account) , " session.ended" , & me.account.username, & id.to_string ( ) ) .await.ok ( ) ;
524
}
525
let to = if id = = me.session_id { " /" } else { " /settings/security?notice=That+browser+is+signed+out." } ;
526
Ok ( Redirect:: to( to) .into_response ( ) )
19bb31ff Andy McCall 1 month ago ↶
527
}
528
529
530
531
# [ derive ( Template ) ]
532
# [ template ( path = " settings_keys.html" ) ]
533
pub struct KeysPage {
534
shell : Shell,
535
me : accounts:: Account,
536
keys : Vec< accounts:: LinkedIdentity> ,
537
notice : Option< String> ,
538
error : Option< String> ,
539
540
pending : Option< ( i64 , String, String) > ,
541
}
542
543
async fn keys_page (
544
app : & Shared,
545
shell : Shell,
546
me : accounts:: Account,
547
notice : Option< String> ,
548
error : Option< String> ,
549
pending : Option< ( i64 , String, String) > ,
550
) -> Result< Page< KeysPage> , Error> {
551
let keys = accounts:: identities_of( & app.db, me.id) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
552
Ok ( Page( KeysPage { shell, me, keys, notice, error, pending } ) )
553
}
554
555
pub async fn keys ( State( app ) : State< Shared> , viewer : Viewer) -> Result< Page< KeysPage> , Error> {
556
let shell = Shell:: new( & app, " /settings/keys" , & viewer) ;
557
let me = require ( & viewer, & shell) ? ;
558
keys_page ( & app, shell, me.account, None , None , None ) .await
559
}
560
561
562
563
pub async fn keys_upload ( State( app ) : State< Shared> , viewer : Viewer, mut multipart : Multipart) -> Result< Page< KeysPage> , Error> {
564
let shell = Shell:: new( & app, " /settings/keys" , & viewer) ;
565
let me = require ( & viewer, & shell) ? ;
566
let mut token = String :: new( ) ;
567
let mut object: Vec< u8 > = Vec :: new( ) ;
568
while let Ok ( Some ( field) ) = multipart.next_field ( ) .await {
569
match field.name ( ) {
570
Some ( " _csrf" ) => token = field.text ( ) .await.unwrap_or_default ( ) ,
571
Some ( " identity" ) => object = field.bytes ( ) .await.map ( | b | b.to_vec ( ) ) .unwrap_or_default ( ) ,
572
_ => { }
573
}
574
}
575
csrf ( & viewer, & token, & shell) ? ;
576
let identity = match Identity:: decode( & object) {
577
Ok ( i) => i,
578
Err ( e) => return keys_page ( & app, shell, me.account, None , Some ( format! ( " That is not an identity file: {e} " ) ) , None ) .await,
579
} ;
78763257 Andy McCall 16 days ago ↶
580
if identity.hash ( ) = = app.hub_key.key {
581
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." ;
582
return keys_page ( & app, shell, me.account, None , Some ( msg.into ( ) ) , None ) .await;
583
}
19bb31ff Andy McCall 1 month ago ↶
584
let key = identity.hash ( ) .to_hex ( ) ;
585
if let Some ( existing) = accounts:: identity_by_key( & app.db, & key) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? {
586
let msg = if existing.account_id = = me.account.id {
78763257 Andy McCall 16 days ago ↶
587
" 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 ↶
588
} else {
589
" That key is linked to another account."
590
} ;
591
return keys_page ( & app, shell, me.account, None , Some ( msg.into ( ) ) , None ) .await;
592
}
593
let bytes = auth:: random_hex( 32 ) ;
594
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
595
sqlx:: query( " UPDATE challenges SET object = $1 WHERE id = $2" )
19bb31ff Andy McCall 1 month ago ↶
596
.bind ( & object)
597
.bind ( id)
598
.execute ( & app.db)
599
.await
600
.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
601
keys_page ( & app, shell, me.account, None , None , Some ( ( id, bytes, identity.name) ) ) .await
602
}
603
604
# [ derive ( Deserialize ) ]
605
pub struct KeyVerifyForm {
606
#[serde(rename = "_csrf")]
607
csrf_token : String,
608
challenge : i64 ,
609
signature : String,
610
}
611
612
pub async fn keys_verify ( State( app ) : State< Shared> , viewer : Viewer, Form( f ) : Form< KeyVerifyForm> ) -> Result< Page< KeysPage> , Error> {
613
let shell = Shell:: new( & app, " /settings/keys" , & viewer) ;
614
let me = require ( & viewer, & shell) ? ;
615
csrf ( & viewer, & f.csrf_token, & shell) ? ;
5d90e0e0 Andy McCall 20 days ago
616
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 ↶
617
.bind ( f.challenge)
618
.bind ( me.account.id)
619
.fetch_optional ( & app.db)
620
.await
621
.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
622
let bytes = accounts:: take_challenge( & app.db, me.account.id, f.challenge) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
623
let ( Some ( object) , Some ( bytes) ) = ( object, bytes) else {
624
return keys_page ( & app, shell, me.account, None , Some ( " That challenge has expired; upload the identity again." .into ( ) ) , None ) .await;
625
} ;
626
let Ok ( identity) = Identity:: decode( & object) else {
627
return keys_page ( & app, shell, me.account, None , Some ( " The stored identity is unreadable." .into ( ) ) , None ) .await;
628
} ;
629
let sig_bytes = hex:: decode( f.signature.trim ( ) ) .unwrap_or_default ( ) ;
630
let sigs = Signature:: decode_list( & sig_bytes) .unwrap_or_default ( ) ;
631
let challenge = hex:: decode( & bytes) .unwrap_or_default ( ) ;
632
let ok = sigs.first ( ) .is_some_and ( | sig | oxy_core:: identity:: verify_challenge( & identity, sig, & challenge) .is_ok ( ) ) ;
633
if ! ok {
78763257 Andy McCall 16 days ago ↶
634
635
636
let msg = match sigs.first ( ) {
637
Some ( sig) if sig.key ! = identity.hash ( ) => format! (
638
" 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." ,
639
sig.key.short ( 12 ) ,
640
identity.hash ( ) .short ( 12 ) ,
641
identity.name
642
) ,
643
_ => " The signature does not verify against that identity and challenge." .into ( ) ,
644
} ;
645
return keys_page ( & app, shell, me.account, None , Some ( msg) , None ) .await;
19bb31ff Andy McCall 1 month ago ↶
646
}
647
let key = identity.hash ( ) .to_hex ( ) ;
648
accounts:: link_identity( & app.db, me.account.id, & key, & identity.name, & identity.email, & object)
649
.await
650
.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
651
accounts:: audit( & app.db, Some ( & me.account) , " identity.linked" , & key, & identity.name) .await.ok ( ) ;
652
keys_page ( & app, shell, me.account, Some ( " Key linked. Changes signed with it are now yours on every page." .into ( ) ) , None , None ) .await
653
}
654
655
# [ derive ( Deserialize ) ]
656
pub struct KeyForm {
657
#[serde(rename = "_csrf")]
658
csrf_token : String,
659
key : String,
660
}
661
662
pub async fn keys_unlink ( State( app ) : State< Shared> , viewer : Viewer, Form( f ) : Form< KeyForm> ) -> Result< Page< KeysPage> , Error> {
663
let shell = Shell:: new( & app, " /settings/keys" , & viewer) ;
664
let me = require ( & viewer, & shell) ? ;
665
csrf ( & viewer, & f.csrf_token, & shell) ? ;
666
accounts:: unlink_identity( & app.db, me.account.id, & f.key) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
667
accounts:: audit( & app.db, Some ( & me.account) , " identity.unlinked" , & f.key, " " ) .await.ok ( ) ;
668
keys_page ( & app, shell, me.account, Some ( " Key unlinked." .into ( ) ) , None , None ) .await
669
}
670
671
672
673
# [ derive ( Template ) ]
674
# [ template ( path = " settings_tokens.html" ) ]
675
pub struct TokensPage {
676
shell : Shell,
b45384c9 Andy McCall 1 month ago ↶
677
scopes : & 'static [( & 'static str , & 'static str ) ],
19bb31ff Andy McCall 1 month ago ↶
678
me : accounts:: Account,
679
tokens : Vec< accounts:: Token> ,
680
681
fresh : Option< String> ,
682
notice : Option< String> ,
683
error : Option< String> ,
684
}
685
686
async fn tokens_page (
687
app : & Shared,
688
shell : Shell,
689
me : accounts:: Account,
690
fresh : Option< String> ,
691
notice : Option< String> ,
692
error : Option< String> ,
693
) -> Result< Page< TokensPage> , Error> {
694
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 ↶
695
Ok ( Page( TokensPage { shell, scopes: SCOPES , me, tokens, fresh, notice, error } ) )
19bb31ff Andy McCall 1 month ago ↶
696
}
697
698
pub async fn tokens ( State( app ) : State< Shared> , viewer : Viewer) -> Result< Page< TokensPage> , Error> {
699
let shell = Shell:: new( & app, " /settings/tokens" , & viewer) ;
700
let me = require ( & viewer, & shell) ? ;
701
tokens_page ( & app, shell, me.account, None , None , None ) .await
702
}
703
704
# [ derive ( Deserialize ) ]
705
pub struct TokenForm {
706
#[serde(rename = "_csrf")]
707
csrf_token : String,
708
name : String,
709
#[serde(default)]
710
scopes : Vec< String> ,
711
#[serde(default)]
712
repos : String,
713
#[serde(default)]
714
expires_days : String,
bff9106e Andy McCall 22 days ago ↶
715
716
#[serde(default)]
717
agent : String,
19bb31ff Andy McCall 1 month ago ↶
718
}
719
b45384c9 Andy McCall 1 month ago ↶
720
721
19bb31ff Andy McCall 1 month ago ↶
722
pub const SCOPES : & [ ( & str , & str ) ] = & [
b45384c9 Andy McCall 1 month ago ↶
723
( " repo:read" , " read repositories you can see: clone, fetch, pull, and the read API" ) ,
724
( " repo:write" , " push to repositories you can write to; also covers proposals, issues and checks" ) ,
725
( " repo:admin" , " create repositories and change settings through the API" ) ,
726
( " proposals:write" , " open, comment on and review proposals, without push access" ) ,
727
( " issues:write" , " open, comment on and close issues, without push access" ) ,
728
( " checks:write" , " report checks on states, for CI systems, without push access" ) ,
729
( " hooks:admin" , " manage webhooks through the API" ) ,
730
( " user:read" , " read your own profile through the API" ) ,
19bb31ff Andy McCall 1 month ago ↶
731
] ;
732
733
pub async fn tokens_create (
734
State( app ) : State< Shared> ,
735
viewer : Viewer,
736
axum_extra:: extract:: Form( f ) : axum_extra:: extract:: Form< TokenForm> ,
737
) -> Result< Page< TokensPage> , Error> {
738
let shell = Shell:: new( & app, " /settings/tokens" , & viewer) ;
739
let me = require ( & viewer, & shell) ? ;
740
csrf ( & viewer, & f.csrf_token, & shell) ? ;
741
let name = f.name.trim ( ) ;
742
if name.is_empty ( ) {
743
return tokens_page ( & app, shell, me.account, None , None , Some ( " Give the token a name." .into ( ) ) ) .await;
744
}
745
let scopes: Vec< & str > = f.scopes.iter ( ) .map ( String :: as_str) .filter ( | s | SCOPES .iter ( ) .any ( | ( k , _) | k = = s ) ) .collect ( ) ;
746
if scopes.is_empty ( ) {
747
return tokens_page ( & app, shell, me.account, None , None , Some ( " Pick at least one scope." .into ( ) ) ) .await;
748
}
749
let expires_ms = f.expires_days.trim ( ) .parse:: < i64 > ( ) .ok ( ) .filter ( | d | * d > 0 ) .map ( | d | db:: now_ms( ) + d * 86_400_000 ) ;
750
let repos = f.repos.split ( ' ,' ) .map ( str :: trim) .filter ( | r | ! r.is_empty ( ) ) .collect:: < Vec< _ > > ( ) .join ( " ," ) ;
751
let ( secret, hash) = auth:: new_token( ) ;
752
accounts:: create_token( & app.db, me.account.id, name, & hash, & scopes.join ( " ," ) , & repos, expires_ms)
753
.await
754
.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
bff9106e Andy McCall 22 days ago ↶
755
if ! f.agent.is_empty ( ) {
756
if let Ok ( Some ( t) ) = accounts:: token_by_hash( & app.db, & hash) .await {
757
accounts:: set_token_agent( & app.db, me.account.id, t.id, true ) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
758
}
759
}
19bb31ff Andy McCall 1 month ago ↶
760
accounts:: audit( & app.db, Some ( & me.account) , " token.created" , name, & scopes.join ( " ," ) ) .await.ok ( ) ;
761
tokens_page ( & app, shell, me.account, Some ( secret) , Some ( " Copy the token now; it is not shown again." .into ( ) ) , None ) .await
bff9106e Andy McCall 22 days ago ↶
762
}
763
764
765
766
pub async fn tokens_agent (
767
State( app ) : State< Shared> ,
768
viewer : Viewer,
769
Path( id ) : Path< i64 > ,
770
Form( f ) : Form< super:: session:: CsrfForm> ,
771
) -> Result< Page< TokensPage> , Error> {
772
let shell = Shell:: new( & app, " /settings/tokens" , & viewer) ;
773
let me = require ( & viewer, & shell) ? ;
774
csrf ( & viewer, & f.csrf, & shell) ? ;
775
let tokens = accounts:: tokens_of( & app.db, me.account.id) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
776
let Some ( t) = tokens.iter ( ) .find ( | t | t.id = = id ) else {
777
return tokens_page ( & app, shell, me.account, None , None , Some ( " No such token." .into ( ) ) ) .await;
778
} ;
779
accounts:: set_token_agent( & app.db, me.account.id, id, ! t.agent) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
780
accounts:: audit( & app.db, Some ( & me.account) , " token.agent" , & t.name, if t.agent { " off" } else { " on" } ) .await.ok ( ) ;
781
let notice = if t.agent {
782
format! ( " {} is no longer marked as an agent's." , t.name)
783
} else {
784
format! ( " {} is an agent's: you will hear what it does." , t.name)
785
} ;
786
tokens_page ( & app, shell, me.account, None , Some ( notice) , None ) .await
19bb31ff Andy McCall 1 month ago ↶
787
}
788
789
pub async fn tokens_delete (
790
State( app ) : State< Shared> ,
791
viewer : Viewer,
792
Path( id ) : Path< i64 > ,
793
Form( f ) : Form< super:: session:: CsrfForm> ,
794
) -> Result< Page< TokensPage> , Error> {
795
let shell = Shell:: new( & app, " /settings/tokens" , & viewer) ;
796
let me = require ( & viewer, & shell) ? ;
797
csrf ( & viewer, & f.csrf, & shell) ? ;
798
accounts:: delete_token( & app.db, me.account.id, id) .await.map_err ( | e | Error:: from_db( shell.clone ( ) , e) ) ? ;
799
accounts:: audit( & app.db, Some ( & me.account) , " token.deleted" , & id.to_string ( ) , " " ) .await.ok ( ) ;
800
tokens_page ( & app, shell, me.account, None , Some ( " Token deleted." .into ( ) ) , None ) .await
0024ccb1 Andy McCall 20 days ago ↶
801
}
802
803
# [ cfg ( test ) ]
804
mod tests {
805
use super :: browser_of;
806
807
# [ test ]
808
fn browsers_are_named_from_their_agent ( ) {
809
let ff = " Mozilla/5.0 (X11; Linux x86_64; rv:131.0) Gecko/20100101 Firefox/131.0" ;
810
let chrome_mac =
811
" Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0 Safari/537.36" ;
812
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" ;
813
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" ;
814
assert_eq! ( browser_of ( ff) , " Firefox on Linux" ) ;
815
assert_eq! ( browser_of ( chrome_mac) , " Chrome on macOS" ) ;
816
assert_eq! ( browser_of ( edge) , " Edge on Windows" ) ;
817
assert_eq! ( browser_of ( iphone) , " Safari on iOS" ) ;
818
assert_eq! ( browser_of ( " " ) , " Unknown browser" ) ;
819
}
19bb31ff Andy McCall 1 month ago ↶
820
}