atagen

malice

public
Star0
Watch0
dffd63b58af439639de9f8e155d0365d83605a8b
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git
crates/malice-server/src/routes.rsRust790 lines21.6 KB
1
use std::sync::Arc;
2
3
use axum::{
4
    Router,
5
    extract::{DefaultBodyLimit, Multipart, Query, State},
6
    http::{HeaderMap, StatusCode},
7
    middleware,
8
    response::{IntoResponse, Response, Sse, sse::Event as SseEvent},
9
    routing::{get, post},
10
};
11
use futures_util::StreamExt;
12
use macltok::Retain;
13
use malice_core::{
14
    feed::subscribe,
15
    login::{LoginInput, sanitise_auth_error},
16
    request::{Reply, Request, dispatch},
17
};
18
use serde::{Deserialize, Serialize};
19
use tower_http::{
20
    services::{ServeDir, ServeFile},
21
    trace::TraceLayer,
22
};
23
24
use crate::{
25
    csrf::{csrf_guard, session_sid, set_cookie_value},
26
    error::{ApiError, json_ok, no_content},
27
    state::{AppState, RestoreError, UserSession},
28
};
29
30
#[derive(Debug, Deserialize)]
31
struct RoomIdBody {
32
    #[serde(default)]
33
    room_id: String,
34
}
35
36
#[derive(Debug, Deserialize)]
37
struct SendBody {
38
    #[serde(default)]
39
    room_id: String,
40
    #[serde(default)]
41
    body: String,
42
}
43
44
#[derive(Debug, Deserialize)]
45
struct PaginateBody {
46
    #[serde(default)]
47
    room_id: String,
48
}
49
50
#[derive(Debug, Deserialize)]
51
struct JoinBody {
52
    #[serde(default)]
53
    room: String,
54
}
55
56
#[derive(Debug, Deserialize)]
57
struct CreateBody {
58
    #[serde(default)]
59
    name: String,
60
    #[serde(default)]
61
    topic: Option<String>,
62
}
63
64
#[derive(Debug, Deserialize)]
65
struct ReactBody {
66
    #[serde(default)]
67
    room_id: String,
68
    #[serde(default)]
69
    event_id: String,
70
    #[serde(default)]
71
    key: String,
72
}
73
74
#[derive(Debug, Deserialize)]
75
struct ReadBody {
76
    #[serde(default)]
77
    room_id: String,
78
    #[serde(default)]
79
    event_id: String,
80
}
81
82
#[derive(Debug, Deserialize)]
83
struct MembersQuery {
84
    #[serde(default)]
85
    room_id: String,
86
}
87
#[derive(Debug, Deserialize)]
88
struct RecoverBody {
89
    recovery_key: String,
90
}
91
92
#[derive(Debug, Deserialize)]
93
struct VerifyBody {
94
    action: String,
95
}
96
97
#[derive(Debug, Deserialize)]
98
struct MediaQuery {
99
    mxc: String,
100
}
101
#[derive(Debug, Deserialize)]
102
struct MessageMediaQuery {
103
    media_id: String,
104
    size: String,
105
}
106
107
#[derive(Debug, Deserialize)]
108
struct TypingBody {
109
    #[serde(default)]
110
    room_id: String,
111
    #[serde(default)]
112
    typing: bool,
113
}
114
115
#[derive(Debug, Serialize)]
116
struct SoftLogoutInfo {
117
    user_id: String,
118
    homeserver: String,
119
}
120
121
#[derive(Debug, Serialize)]
122
struct SessionResponse {
123
    user_id: Option<String>,
124
    #[serde(skip_serializing_if = "Option::is_none")]
125
    soft_logout: Option<SoftLogoutInfo>,
126
}
127
128
async fn auth(
129
    state: &AppState,
130
    headers: &HeaderMap,
131
) -> Result<(Arc<UserSession>, String), ApiError> {
132
    let Some(sid) = session_sid(headers) else {
133
        return Err(ApiError::unauthorized("not signed in"));
134
    };
135
    match state.get_or_restore(&sid).await {
136
        Ok(user) => Ok((user, sid)),
137
        Err(RestoreError::NotFound) => Err(ApiError::unauthorized_stale(
138
            "session expired; sign in again",
139
            state.cookie_secure(),
140
        )),
141
        Err(RestoreError::SoftLoggedOut { .. }) => Err(ApiError::unauthorized(
142
            "signed out by the server, sign in again",
143
        )),
144
        Err(RestoreError::Stale(message)) => {
145
            state.remove_session(&sid, Retain::Nothing).await;
146
            Err(ApiError::unauthorized_stale(message, state.cookie_secure()))
147
        }
148
        Err(RestoreError::Unavailable(message)) => {
149
            Err(ApiError::new(StatusCode::BAD_GATEWAY, message))
150
        }
151
    }
152
}
153
154
/// commands answer when they're done, so the browser gets the outcome in the response
155
fn respond(result: Result<Reply, malice_core::error::CoreError>) -> Response {
156
    match result {
157
        Ok(Reply::Done) => no_content(None),
158
        Ok(Reply::Json(value)) => json_ok(&value, None),
159
        Ok(Reply::Avatar {
160
            bytes,
161
            content_type,
162
        }) => (
163
            [
164
                (axum::http::header::CONTENT_TYPE, content_type),
165
                (axum::http::header::CACHE_CONTROL, "private, max-age=3600"),
166
                (axum::http::header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
167
            ],
168
            bytes,
169
        )
170
            .into_response(),
171
        Ok(Reply::Attachment {
172
            bytes,
173
            content_type,
174
        }) => (
175
            [
176
                (
177
                    axum::http::header::CONTENT_TYPE,
178
                    content_type.unwrap_or("application/octet-stream"),
179
                ),
180
                (
181
                    axum::http::header::CONTENT_DISPOSITION,
182
                    if content_type.is_some() {
183
                        "inline"
184
                    } else {
185
                        "attachment"
186
                    },
187
                ),
188
                (axum::http::header::CACHE_CONTROL, "private, max-age=3600"),
189
                (axum::http::header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
190
                (axum::http::header::CONTENT_SECURITY_POLICY, "sandbox"),
191
            ],
192
            bytes,
193
        )
194
            .into_response(),
195
        Err(error) => ApiError::from(error).into_response(),
196
    }
197
}
198
199
async fn get_session(State(state): State<AppState>, headers: HeaderMap) -> Response {
200
    let Some(sid) = session_sid(&headers) else {
201
        return json_ok(
202
            &SessionResponse {
203
                user_id: None,
204
                soft_logout: None,
205
            },
206
            None,
207
        );
208
    };
209
    match state.get_or_restore(&sid).await {
210
        Ok(user) => json_ok(
211
            &SessionResponse {
212
                user_id: Some(user.user_id.clone()),
213
                soft_logout: None,
214
            },
215
            None,
216
        ),
217
        Err(RestoreError::NotFound) => json_ok(
218
            &SessionResponse {
219
                user_id: None,
220
                soft_logout: None,
221
            },
222
            None,
223
        ),
224
        Err(RestoreError::SoftLoggedOut {
225
            user_id,
226
            homeserver,
227
        }) => json_ok(
228
            &SessionResponse {
229
                user_id: None,
230
                soft_logout: Some(SoftLogoutInfo {
231
                    user_id,
232
                    homeserver,
233
                }),
234
            },
235
            None,
236
        ),
237
        Err(RestoreError::Stale(message)) => {
238
            state.remove_session(&sid, Retain::Nothing).await;
239
            ApiError::unauthorized_stale(message, state.cookie_secure()).into_response()
240
        }
241
        Err(RestoreError::Unavailable(message)) => {
242
            ApiError::new(StatusCode::BAD_GATEWAY, message).into_response()
243
        }
244
    }
245
}
246
247
async fn post_login(
248
    State(state): State<AppState>,
249
    headers: HeaderMap,
250
    body: axum::Json<LoginInput>,
251
) -> Response {
252
    let input = match body.0.validate(state.allow_http_homeserver()) {
253
        Ok(input) => input,
254
        Err(error) => return ApiError::from(error).into_response(),
255
    };
256
    let cookie_sid = session_sid(&headers);
257
    let password = input.password.clone();
258
    match state
259
        .login(
260
            cookie_sid.as_deref(),
261
            input.homeserver,
262
            input.username,
263
            input.password,
264
        )
265
        .await
266
    {
267
        Ok(user) => json_ok(
268
            &serde_json::json!({ "user_id": user.user_id }),
269
            Some(set_cookie_value(&user.sid, state.cookie_secure())),
270
        ),
271
        Err(message) => {
272
            let status = if message.contains("starting sync engine")
273
                || message.contains("could not save the session")
274
                || message.contains("another engine is using this session")
275
            {
276
                StatusCode::BAD_GATEWAY
277
            } else {
278
                StatusCode::UNAUTHORIZED
279
            };
280
            ApiError::new(status, sanitise_auth_error(&message, &password)).into_response()
281
        }
282
    }
283
}
284
285
async fn post_logout(State(state): State<AppState>, headers: HeaderMap) -> Response {
286
    if let Some(sid) = session_sid(&headers) {
287
        state.logout(&sid).await;
288
    }
289
    no_content(Some(crate::csrf::clear_cookie_value(state.cookie_secure())))
290
}
291
292
async fn get_rooms(State(state): State<AppState>, headers: HeaderMap) -> Response {
293
    let (user, _) = match auth(&state, &headers).await {
294
        Ok(pair) => pair,
295
        Err(error) => return error.into_response(),
296
    };
297
    respond(dispatch(&user.handle, &user.mirror, Request::Rooms).await)
298
}
299
300
async fn get_members(
301
    State(state): State<AppState>,
302
    headers: HeaderMap,
303
    Query(query): Query<MembersQuery>,
304
) -> Response {
305
    let (user, _) = match auth(&state, &headers).await {
306
        Ok(pair) => pair,
307
        Err(error) => return error.into_response(),
308
    };
309
    respond(
310
        dispatch(
311
            &user.handle,
312
            &user.mirror,
313
            Request::Members {
314
                room_id: query.room_id,
315
            },
316
        )
317
        .await,
318
    )
319
}
320
321
async fn rooms_open(
322
    State(state): State<AppState>,
323
    headers: HeaderMap,
324
    body: axum::Json<RoomIdBody>,
325
) -> Response {
326
    let (user, _) = match auth(&state, &headers).await {
327
        Ok(pair) => pair,
328
        Err(error) => return error.into_response(),
329
    };
330
    respond(
331
        dispatch(
332
            &user.handle,
333
            &user.mirror,
334
            Request::Open {
335
                room_id: body.0.room_id,
336
            },
337
        )
338
        .await,
339
    )
340
}
341
342
async fn rooms_close(
343
    State(state): State<AppState>,
344
    headers: HeaderMap,
345
    body: axum::Json<RoomIdBody>,
346
) -> Response {
347
    let (user, _) = match auth(&state, &headers).await {
348
        Ok(pair) => pair,
349
        Err(error) => return error.into_response(),
350
    };
351
    respond(
352
        dispatch(
353
            &user.handle,
354
            &user.mirror,
355
            Request::Close {
356
                room_id: body.0.room_id,
357
            },
358
        )
359
        .await,
360
    )
361
}
362
363
async fn rooms_send(
364
    State(state): State<AppState>,
365
    headers: HeaderMap,
366
    body: axum::Json<SendBody>,
367
) -> Response {
368
    let (user, _) = match auth(&state, &headers).await {
369
        Ok(pair) => pair,
370
        Err(error) => return error.into_response(),
371
    };
372
    respond(
373
        dispatch(
374
            &user.handle,
375
            &user.mirror,
376
            Request::Send {
377
                room_id: body.0.room_id,
378
                body: body.0.body,
379
            },
380
        )
381
        .await,
382
    )
383
}
384
385
async fn rooms_upload(
386
    State(state): State<AppState>,
387
    headers: HeaderMap,
388
    mut upload: Multipart,
389
) -> Response {
390
    let (user, _) = match auth(&state, &headers).await {
391
        Ok(pair) => pair,
392
        Err(error) => return error.into_response(),
393
    };
394
    let mut room_id = None;
395
    let mut file = None;
396
    loop {
397
        let field = match upload.next_field().await {
398
            Ok(Some(field)) => field,
399
            Ok(None) => break,
400
            Err(_) => return ApiError::bad_request("invalid upload form").into_response(),
401
        };
402
        match field.name() {
403
            Some("room_id") if room_id.is_none() => {
404
                room_id = match field.text().await {
405
                    Ok(value) => Some(value),
406
                    Err(_) => return ApiError::bad_request("invalid room_id").into_response(),
407
                };
408
            }
409
            Some("file") if file.is_none() => {
410
                let filename = field.file_name().unwrap_or("attachment").to_owned();
411
                let mimetype = field
412
                    .content_type()
413
                    .unwrap_or("application/octet-stream")
414
                    .to_owned();
415
                let bytes = match field.bytes().await {
416
                    Ok(bytes) => bytes,
417
                    Err(_) => return ApiError::bad_request("invalid file").into_response(),
418
                };
419
                if bytes.is_empty() || bytes.len() > 20 * 1024 * 1024 {
420
                    return ApiError::bad_request("file must be between 1 byte and 20 MiB")
421
                        .into_response();
422
                }
423
                file = Some((filename, mimetype, bytes));
424
            }
425
            _ => return ApiError::bad_request("unexpected upload field").into_response(),
426
        }
427
    }
428
    let Some(room_id) = room_id else {
429
        return ApiError::bad_request("room_id is required").into_response();
430
    };
431
    let Some((filename, mimetype, bytes)) = file else {
432
        return ApiError::bad_request("file is required").into_response();
433
    };
434
    respond(
435
        dispatch(
436
            &user.handle,
437
            &user.mirror,
438
            Request::upload(room_id, filename, mimetype, bytes.to_vec()),
439
        )
440
        .await,
441
    )
442
}
443
444
async fn rooms_paginate(
445
    State(state): State<AppState>,
446
    headers: HeaderMap,
447
    body: axum::Json<PaginateBody>,
448
) -> Response {
449
    let (user, _) = match auth(&state, &headers).await {
450
        Ok(pair) => pair,
451
        Err(error) => return error.into_response(),
452
    };
453
    respond(
454
        dispatch(
455
            &user.handle,
456
            &user.mirror,
457
            Request::Paginate {
458
                room_id: body.0.room_id,
459
            },
460
        )
461
        .await,
462
    )
463
}
464
465
async fn rooms_join(
466
    State(state): State<AppState>,
467
    headers: HeaderMap,
468
    body: axum::Json<JoinBody>,
469
) -> Response {
470
    let (user, _) = match auth(&state, &headers).await {
471
        Ok(pair) => pair,
472
        Err(error) => return error.into_response(),
473
    };
474
    respond(
475
        dispatch(
476
            &user.handle,
477
            &user.mirror,
478
            Request::Join { room: body.0.room },
479
        )
480
        .await,
481
    )
482
}
483
484
async fn rooms_create(
485
    State(state): State<AppState>,
486
    headers: HeaderMap,
487
    body: axum::Json<CreateBody>,
488
) -> Response {
489
    let (user, _) = match auth(&state, &headers).await {
490
        Ok(pair) => pair,
491
        Err(error) => return error.into_response(),
492
    };
493
    respond(
494
        dispatch(
495
            &user.handle,
496
            &user.mirror,
497
            Request::Create {
498
                name: body.0.name,
499
                topic: body.0.topic,
500
            },
501
        )
502
        .await,
503
    )
504
}
505
506
async fn rooms_leave(
507
    State(state): State<AppState>,
508
    headers: HeaderMap,
509
    body: axum::Json<RoomIdBody>,
510
) -> Response {
511
    let (user, _) = match auth(&state, &headers).await {
512
        Ok(pair) => pair,
513
        Err(error) => return error.into_response(),
514
    };
515
    respond(
516
        dispatch(
517
            &user.handle,
518
            &user.mirror,
519
            Request::Leave {
520
                room_id: body.0.room_id,
521
            },
522
        )
523
        .await,
524
    )
525
}
526
527
async fn rooms_react(
528
    State(state): State<AppState>,
529
    headers: HeaderMap,
530
    body: axum::Json<ReactBody>,
531
) -> Response {
532
    let (user, _) = match auth(&state, &headers).await {
533
        Ok(pair) => pair,
534
        Err(error) => return error.into_response(),
535
    };
536
    respond(
537
        dispatch(
538
            &user.handle,
539
            &user.mirror,
540
            Request::React {
541
                room_id: body.0.room_id,
542
                event_id: body.0.event_id,
543
                key: body.0.key,
544
            },
545
        )
546
        .await,
547
    )
548
}
549
550
async fn rooms_read(
551
    State(state): State<AppState>,
552
    headers: HeaderMap,
553
    body: axum::Json<ReadBody>,
554
) -> Response {
555
    let (user, _) = match auth(&state, &headers).await {
556
        Ok(pair) => pair,
557
        Err(error) => return error.into_response(),
558
    };
559
    respond(
560
        dispatch(
561
            &user.handle,
562
            &user.mirror,
563
            Request::Read {
564
                room_id: body.0.room_id,
565
                event_id: body.0.event_id,
566
            },
567
        )
568
        .await,
569
    )
570
}
571
572
async fn get_profile(State(state): State<AppState>, headers: HeaderMap) -> Response {
573
    let (user, _) = match auth(&state, &headers).await {
574
        Ok(pair) => pair,
575
        Err(error) => return error.into_response(),
576
    };
577
    respond(dispatch(&user.handle, &user.mirror, Request::Profile).await)
578
}
579
580
async fn get_space_children(
581
    State(state): State<AppState>,
582
    headers: HeaderMap,
583
    Query(query): Query<MembersQuery>,
584
) -> Response {
585
    let (user, _) = match auth(&state, &headers).await {
586
        Ok(pair) => pair,
587
        Err(error) => return error.into_response(),
588
    };
589
    respond(
590
        dispatch(
591
            &user.handle,
592
            &user.mirror,
593
            Request::SpaceChildren {
594
                room_id: query.room_id,
595
            },
596
        )
597
        .await,
598
    )
599
}
600
601
async fn get_media(
602
    State(state): State<AppState>,
603
    headers: HeaderMap,
604
    Query(query): Query<MediaQuery>,
605
) -> Response {
606
    let (user, _) = match auth(&state, &headers).await {
607
        Ok(pair) => pair,
608
        Err(error) => return error.into_response(),
609
    };
610
    respond(
611
        dispatch(
612
            &user.handle,
613
            &user.mirror,
614
            Request::Avatar { mxc: query.mxc },
615
        )
616
        .await,
617
    )
618
}
619
620
async fn get_message_media(
621
    State(state): State<AppState>,
622
    headers: HeaderMap,
623
    Query(query): Query<MessageMediaQuery>,
624
) -> Response {
625
    let (user, _) = match auth(&state, &headers).await {
626
        Ok(pair) => pair,
627
        Err(error) => return error.into_response(),
628
    };
629
    // the declared mimetype is the sender's claim, so the bytes get sniffed instead
630
    respond(
631
        dispatch(
632
            &user.handle,
633
            &user.mirror,
634
            Request::Media {
635
                media_id: query.media_id,
636
                size: query.size,
637
            },
638
        )
639
        .await,
640
    )
641
}
642
643
async fn get_encryption(State(state): State<AppState>, headers: HeaderMap) -> Response {
644
    let (user, _) = match auth(&state, &headers).await {
645
        Ok(pair) => pair,
646
        Err(error) => return error.into_response(),
647
    };
648
    respond(dispatch(&user.handle, &user.mirror, Request::Encryption).await)
649
}
650
651
async fn recover_encryption(
652
    State(state): State<AppState>,
653
    headers: HeaderMap,
654
    body: axum::Json<RecoverBody>,
655
) -> Response {
656
    let (user, _) = match auth(&state, &headers).await {
657
        Ok(pair) => pair,
658
        Err(error) => return error.into_response(),
659
    };
660
    respond(
661
        dispatch(
662
            &user.handle,
663
            &user.mirror,
664
            Request::Recover {
665
                recovery_key: body.0.recovery_key,
666
            },
667
        )
668
        .await,
669
    )
670
}
671
672
async fn start_verification(State(state): State<AppState>, headers: HeaderMap) -> Response {
673
    let (user, _) = match auth(&state, &headers).await {
674
        Ok(pair) => pair,
675
        Err(error) => return error.into_response(),
676
    };
677
    respond(dispatch(&user.handle, &user.mirror, Request::StartVerification).await)
678
}
679
680
async fn verify_encryption(
681
    State(state): State<AppState>,
682
    headers: HeaderMap,
683
    body: axum::Json<VerifyBody>,
684
) -> Response {
685
    let (user, _) = match auth(&state, &headers).await {
686
        Ok(pair) => pair,
687
        Err(error) => return error.into_response(),
688
    };
689
    respond(
690
        dispatch(
691
            &user.handle,
692
            &user.mirror,
693
            Request::Verify {
694
                action: body.0.action,
695
            },
696
        )
697
        .await,
698
    )
699
}
700
701
async fn rooms_typing(
702
    State(state): State<AppState>,
703
    headers: HeaderMap,
704
    body: axum::Json<TypingBody>,
705
) -> Response {
706
    let (user, _) = match auth(&state, &headers).await {
707
        Ok(pair) => pair,
708
        Err(error) => return error.into_response(),
709
    };
710
    respond(
711
        dispatch(
712
            &user.handle,
713
            &user.mirror,
714
            Request::Typing {
715
                room_id: body.0.room_id,
716
                typing: body.0.typing,
717
            },
718
        )
719
        .await,
720
    )
721
}
722
723
async fn get_events(State(state): State<AppState>, headers: HeaderMap) -> Response {
724
    let (user, _) = match auth(&state, &headers).await {
725
        Ok(pair) => pair,
726
        Err(error) => return error.into_response(),
727
    };
728
    let body = subscribe(&user.mirror).map(|event| {
729
        let json = serde_json::to_string(&event).unwrap_or_else(|_| "{\"type\":\"error\"}".into());
730
        Ok::<_, std::convert::Infallible>(SseEvent::default().data(json))
731
    });
732
    Sse::new(body).into_response()
733
}
734
735
async fn get_health() -> Response {
736
    json_ok(&serde_json::json!({ "status": "ok" }), None)
737
}
738
739
async fn static_shell() -> Response {
740
    (
741
        StatusCode::SERVICE_UNAVAILABLE,
742
        axum::Json(serde_json::json!({ "message": "frontend assets missing; run npm --prefix web run build or use the Nix package" })),
743
    )
744
        .into_response()
745
}
746
747
pub fn router(state: AppState, web_dir: Option<String>) -> Router {
748
    let api = Router::new()
749
        .route("/session", get(get_session))
750
        .route("/login", post(post_login))
751
        .route("/logout", post(post_logout))
752
        .route("/rooms", get(get_rooms))
753
        .route("/events", get(get_events))
754
        .route("/health", get(get_health))
755
        .route("/rooms/open", post(rooms_open))
756
        .route("/rooms/close", post(rooms_close))
757
        .route("/rooms/send", post(rooms_send))
758
        .route("/rooms/paginate", post(rooms_paginate))
759
        .route(
760
            "/rooms/upload",
761
            post(rooms_upload).layer(DefaultBodyLimit::max(21 * 1024 * 1024)),
762
        )
763
        .route("/rooms/join", post(rooms_join))
764
        .route("/rooms/create", post(rooms_create))
765
        .route("/rooms/leave", post(rooms_leave))
766
        .route("/rooms/react", post(rooms_react))
767
        .route("/rooms/read", post(rooms_read))
768
        .route("/rooms/typing", post(rooms_typing))
769
        .route("/profile", get(get_profile))
770
        .route("/media", get(get_media))
771
        .route("/message-media", get(get_message_media))
772
        .route("/spaces/children", get(get_space_children))
773
        .route("/rooms/members", get(get_members))
774
        .route("/encryption", get(get_encryption))
775
        .route("/encryption/recover", post(recover_encryption))
776
        .route("/encryption/start", post(start_verification))
777
        .route("/encryption/verify", post(verify_encryption));
778
    let api = Router::new()
779
        .nest("/api", api)
780
        .layer(middleware::from_fn(csrf_guard));
781
    let router = api.with_state(state).layer(TraceLayer::new_for_http());
782
    match web_dir {
783
        Some(dir) => {
784
            let files =
785
                ServeDir::new(&dir).not_found_service(ServeFile::new(format!("{dir}/index.html")));
786
            router.fallback_service(files)
787
        }
788
        None => router.fallback(static_shell),
789
    }
790
}