OXYGENHUB

Coverage / crates/hub/src/web/live_stream.rs 86.3%

82 of 95 lines run, 19 of 35 functions · run #483 on api-unknown-fields · run missed

1
//! `GET /live?topics=`: one stream of changes for a page (docs/live.md §4,
2
//! oxygenhub#204). Every topic must be one the viewer may read, or nothing
3
//! is streamed; an event names a topic, its version and a kind, never data,
4
//! and the page fetches what changed from its normal routes.
5
6
use crate::auth::Viewer;
7
use crate::db;
8
use crate::events::{LiveEvent, Replay};
9
use crate::Shared;
10
use axum::extract::{Query, State};
11
use axum::http::{HeaderMap, StatusCode};
12
use axum::response::sse::{Event, KeepAlive, Sse};
13
use axum::response::{IntoResponse, Response};
14
use serde::Deserialize;
15
16
/// The most topics one page asks for.
17
const MAX_TOPICS: usize = 16;
18
19
/// A topic, read (docs/live.md §4).
20
#[derive(Debug, PartialEq, Eq)]
21
pub enum Topic {
22
    Repo(i64),
23
    Proposal(i64, i64),
24
    Issue(i64, i64),
25
    Run(i64),
26
    User(i64),
27
    Diffusion(i64),
28
}
29
3023×
pub fn parse_topic(s: &str) -> Option<Topic> {
3125×
    let num = |v: &str| v.parse::<i64>().ok().filter(|n| *n > 0);
3223×
    if let Some(rest) = s.strip_prefix("repo:") {
3315×
        return match rest.split_once('/') {
349×
            None => num(rest).map(Topic::Repo),
356×
            Some((id, sub)) => {
366×
                let id = num(id)?;
376×
                if let Some(n) = sub.strip_prefix("proposal:") {
384×
                    num(n).map(|n| Topic::Proposal(id, n))
392×
                } else if let Some(n) = sub.strip_prefix("issue:") {
401×
                    num(n).map(|n| Topic::Issue(id, n))
41
                } else {
421×
                    None
43
                }
44
            }
45
        };
468×
    }
478×
    if let Some(id) = s.strip_prefix("run:") {
482×
        return num(id).map(Topic::Run);
496×
    }
506×
    if let Some(id) = s.strip_prefix("user:") {
512×
        return num(id).map(Topic::User);
524×
    }
534×
    s.strip_prefix("diffusion:").and_then(num).map(Topic::Diffusion)
5423×
}
55
56
/// Whether `viewer` may hear of changes to `topic`: read access to its
57
/// repository, or, for `user:`, being that user.
588×
async fn may_hear(app: &Shared, viewer: &Viewer, topic: &Topic) -> bool {
598×
    let repo_id = match topic {
607×
        Topic::Repo(r) | Topic::Proposal(r, _) | Topic::Issue(r, _) | Topic::Diffusion(r) => *r,
610×
        Topic::Run(run) => match db::flow::run_by_id(&app.db, *run).await {
620×
            Ok(Some(r)) => r.repo_id,
630×
            _ => return false,
64
        },
651×
        Topic::User(id) => return viewer.account().is_some_and(|a| a.id == *id),
66
    };
677×
    let Ok(Some(row)) = db::repos::get_by_id(&app.db, repo_id).await else { return false };
687×
    matches!(crate::auth::roles::role_for(&app.db, viewer.account(), &row).await, Ok(Some(_)))
698×
}
70
71
#[derive(Deserialize)]
72
pub struct LiveQuery {
73
    #[serde(default)]
74
    topics: String,
75
}
76
771×
fn sse_event(e: &LiveEvent) -> Event {
781×
    Event::default()
791×
        .id(e.id.to_string())
801×
        .event("change")
811×
        .data(serde_json::json!({ "topic": e.topic, "version": e.version, "kind": e.kind }).to_string())
821×
}
83
84
/// `GET /live?topics=repo:1,repo:1/proposal:4`.
857×
pub async fn stream(State(app): State<Shared>, viewer: Viewer, headers: HeaderMap, Query(q): Query<LiveQuery>) -> Response {
86
    use futures::StreamExt;
8710×
    let mut topics: Vec<String> = q.topics.split(',').map(str::trim).filter(|t| !t.is_empty()).map(String::from).collect();
887×
    topics.sort();
897×
    topics.dedup();
907×
    if topics.is_empty() || topics.len() > MAX_TOPICS {
911×
        return (StatusCode::BAD_REQUEST, format!("ask for 1 to {MAX_TOPICS} topics, as /live?topics=repo:1")).into_response();
926×
    }
939×
    for t in &topics {
949×
        let Some(topic) = parse_topic(t) else {
951×
            return (StatusCode::BAD_REQUEST, format!("{t:?} is not a topic")).into_response();
96
        };
978×
        if !may_hear(&app, &viewer, &topic).await {
98
            // The same answer for every topic one may not hear: whether it
99
            // exists is not said either.
1002×
            return (StatusCode::FORBIDDEN, "not every topic asked for may be heard").into_response();
1016×
        }
102
    }
103
    // Subscribed before the replay is read, so nothing falls between them.
1043×
    let rx = app.bus.live.subscribe();
1053×
    let last: Option<u64> = headers.get("last-event-id").and_then(|v| v.to_str().ok()).and_then(|v| v.parse().ok());
1063×
    let (replay, after) = match last {
1072×
        Some(id) => match app.bus.live.since(id, &topics) {
1081×
            Replay::Events(e) => {
1091×
                let after = e.last().map(|e| e.id).unwrap_or(id);
1101×
                (e.iter().map(|e| Ok(sse_event(e))).collect::<Vec<_>>(), after)
111
            }
1121×
            Replay::Reload => (vec![Ok(Event::default().event("reload").data("{}"))], app.bus.live.last_id()),
113
        },
1141×
        None => (vec![], app.bus.live.last_id()),
115
    };
1163×
    let live = tokio_stream::wrappers::BroadcastStream::new(rx).filter_map(move |item| {
1170×
        let topics = topics.clone();
1180×
        async move {
1190×
            match item {
1200×
                Ok(e) if e.id > after && topics.contains(&e.topic) => Some(Ok::<_, std::convert::Infallible>(sse_event(&e))),
121
                // A subscriber that fell behind is told to reload.
1220×
                Err(_) => Some(Ok(Event::default().event("reload").data("{}"))),
1230×
                _ => None,
124
            }
1250×
        }
1260×
    });
127
    // A first comment at once, so a proxy that waits for body bytes (the
128
    // tunnel does) sends the headers now rather than at the first keep-alive.
1293×
    let hello = std::iter::once(Ok(Event::default().comment("live")));
1303×
    Sse::new(futures::StreamExt::take_until(futures::stream::iter(hello.chain(replay)).chain(live), crate::shutdown::stopping()))
1313×
        .keep_alive(KeepAlive::default())
1323×
        .into_response()
1337×
}
134
135
#[cfg(test)]
136
mod tests {
137
    use super::*;
138
139
    #[test]
1401×
    fn topics_are_read_strictly() {
1411×
        assert_eq!(parse_topic("repo:7"), Some(Topic::Repo(7)));
1421×
        assert_eq!(parse_topic("repo:7/proposal:4"), Some(Topic::Proposal(7, 4)));
1431×
        assert_eq!(parse_topic("repo:7/issue:9"), Some(Topic::Issue(7, 9)));
1441×
        assert_eq!(parse_topic("run:12"), Some(Topic::Run(12)));
1451×
        assert_eq!(parse_topic("user:3"), Some(Topic::User(3)));
1461×
        assert_eq!(parse_topic("diffusion:7"), Some(Topic::Diffusion(7)));
1478×
        for bad in ["repo:", "repo:x", "repo:-1", "repo:7/wiki:1", "repo:7/proposal:", "team:1", "", "run:0"] {
1488×
            assert_eq!(parse_topic(bad), None, "{bad}");
149
        }
1501×
    }
151
}