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