atagen

malice

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