Skip to main content

sofie_http/
live.rs

1//! Open screens told when what they read changed; each refetches its fragments through its
2//! own route, so every guard applies again and nothing rendered travels down the stream.
3
4use std::collections::{BTreeSet, HashMap};
5use std::convert::Infallible;
6use std::future::poll_fn;
7use std::net::SocketAddr;
8use std::pin::{Pin, pin};
9use std::sync::{Arc, Mutex};
10use std::task::{Context, Poll};
11use std::time::Duration;
12
13use bytes::Bytes;
14use http::header::{CONTENT_TYPE, RETRY_AFTER};
15use http::request::Parts;
16use http::{HeaderValue, Method, StatusCode};
17use http_body_util::{BodyExt, Either, Full};
18use hyper::body::{Frame, Incoming};
19use sofie_auth::{Signed, SigningKeys};
20use sofie_core::{Error, Result, Uuid, unpoisoned};
21use sofie_db::{Announced, Reach, Topic, Topics};
22use tokio::sync::broadcast::error::RecvError;
23use tokio::sync::{broadcast, mpsc, watch};
24use tokio::time::{Interval, MissedTickBehavior, Sleep};
25
26use crate::answer::{self, HTML, Response, TEXT};
27use crate::caller::Caller;
28use crate::pipeline::Pipeline;
29use crate::routes::{Audience, Route, RouteRules, View};
30use crate::target::Target;
31use crate::throttle::Requester;
32use crate::{Area, PageRoute};
33
34const PATH: &str = "/_sofie/live/";
35const MARKER: &str = "sofie-live";
36/// Tabs of one person, or callers behind one address (IPv6 by /64).
37const PER_HOLDER: usize = 8;
38const PER_PROCESS: usize = 10_000;
39/// More than this and a screen hears every change instead.
40const TOPICS_LIMIT: usize = 32;
41/// Proxies close a connection idle for a minute or so.
42const HEARTBEAT: Duration = Duration::from_secs(20);
43/// A stream reconnects this often, so a session that ended stops hearing.
44const LIFETIME: Duration = Duration::from_mins(10);
45/// A burst of commits is one refetch.
46const SETTLE: Duration = Duration::from_millis(50);
47/// A stream's seal holds about this long; a reconnect refused once it passed, or twice in a row,
48/// refetches the fragments, whose marker carries a fresh seal.
49const SEAL_LIFETIME: i64 = 60 * 60;
50/// Seals end on these steps, so a page refetched within one names the same stream.
51const SEAL_STEP: i64 = 10 * 60;
52const RETRY: &str = "5";
53const SEAL: &str = "seal=";
54const UNTIL: &str = "until=";
55
56const OPENED: &[u8] = b": open\n\n";
57const BEAT: &[u8] = b": beat\n\n";
58const CHANGED: &[u8] = b"data: changed\n\n";
59/// Told apart from a lost stream, so the page reconnects without backoff.
60const END: &[u8] = b"event: end\ndata: end\n\n";
61
62pub(crate) type Body = Either<Full<Bytes>, Events>;
63
64impl<A: Area> PageRoute<'_, A> {
65    /// Refetches the screen's `fragment` whenever a record its loader read changes.
66    pub fn live(&mut self, fragment: &str) -> &mut Self {
67        let fragment = fragment.to_owned();
68        self.rule(|rules| rules.live.push(fragment))
69    }
70}
71
72#[derive(Default)]
73struct Open {
74    total: usize,
75    held: HashMap<Requester, usize>,
76}
77
78pub(crate) struct Live {
79    areas: HashMap<&'static str, Audience>,
80    open: Arc<Mutex<Open>>,
81    closing: watch::Sender<bool>,
82}
83
84impl Live {
85    pub(crate) fn new(routes: &[Route]) -> Result<Self> {
86        let mut areas = HashMap::new();
87        for route in routes.iter().filter(|route| !route.rules.live.is_empty()) {
88            require_screen(route)?;
89            require_plain(route, route.audience.name)?;
90            for fragment in &route.rules.live {
91                require_plain(route, fragment)?;
92            }
93            areas.insert(route.audience.name, route.audience);
94        }
95        Ok(Self {
96            areas,
97            open: Arc::default(),
98            closing: watch::channel(false).0,
99        })
100    }
101
102    /// Ends every stream, so a stopping server is not held open by them.
103    pub(crate) fn close(&self) {
104        self.closing.send_replace(true);
105    }
106
107    fn admit(&self, holder: Requester) -> Option<Admitted> {
108        let mut open = unpoisoned::lock(&self.open);
109        let held = open.held.get(&holder).copied().unwrap_or(0);
110        if open.total >= PER_PROCESS || held >= PER_HOLDER {
111            return None;
112        }
113        open.total += 1;
114        open.held.insert(holder, held + 1);
115        Some(Admitted {
116            open: Arc::clone(&self.open),
117            holder,
118        })
119    }
120}
121
122fn require_screen(route: &Route) -> Result<()> {
123    if matches!(route.view, View::Screen(_)) {
124        return Ok(());
125    }
126    Err(route_error(route, "is live but renders no screen"))
127}
128
129/// Written into the page and its stream's path unescaped.
130fn require_plain(route: &Route, name: &str) -> Result<()> {
131    let plain = !name.is_empty()
132        && name
133            .bytes()
134            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_');
135    if plain {
136        return Ok(());
137    }
138    Err(route_error(
139        route,
140        &format!("is live by `{name}`, which is not only letters, digits, `-` and `_`"),
141    ))
142}
143
144fn route_error(route: &Route, problem: &str) -> Error {
145    Error::internal_message(format!("route {} {} {problem}", route.method, route.path))
146}
147
148/// Frees its place when its stream ends, however it ends.
149struct Admitted {
150    open: Arc<Mutex<Open>>,
151    holder: Requester,
152}
153
154impl Drop for Admitted {
155    fn drop(&mut self) {
156        let mut open = unpoisoned::lock(&self.open);
157        open.total -= 1;
158        let held = open.held.get_mut(&self.holder).map_or(0, |held| {
159            *held -= 1;
160            *held
161        });
162        if held == 0 {
163            open.held.remove(&self.holder);
164        }
165    }
166}
167
168pub(crate) async fn heard<F: Future>(rules: &RouteRules, load: F) -> (F::Output, Option<Topics>) {
169    if rules.live.is_empty() {
170        return (load.await, None);
171    }
172    let (output, topics) = sofie_db::track_topics(load).await;
173    (output, Some(topics))
174}
175
176/// The page carries its stream and fragments; a fragment carries them out of band, so a
177/// refetch that read other tables listens to those.
178pub(crate) async fn marked(
179    response: Response,
180    heard: Option<Topics>,
181    target: &Target,
182    caller: Caller<'_>,
183    keys: &SigningKeys,
184    principal: Option<Uuid>,
185    now: i64,
186) -> Response {
187    let Some(topics) = heard.filter(|topics| *topics != Topics::Of(BTreeSet::new())) else {
188        return response;
189    };
190    let is_html = response
191        .headers()
192        .get(CONTENT_TYPE)
193        .is_some_and(|value| value == HTML);
194    if response.status() != StatusCode::OK || !is_html || caller == Caller::Json {
195        return response;
196    }
197    let (parts, body) = response.into_parts();
198    let Ok(collected) = body.collect().await;
199    let Ok(mut html) = String::from_utf8(Vec::from(collected.to_bytes())) else {
200        let error = Error::internal_message("a rendered screen is not UTF-8");
201        return answer::failure(error, caller);
202    };
203    let is_page = html.starts_with("<!doctype html>");
204    let sealed = Sealed {
205        keys,
206        principal,
207        now,
208    };
209    let marker = marker(target, &topics, !is_page, &sealed);
210    match html.rfind("</body>").filter(|_| is_page) {
211        Some(end) => html.insert_str(end, &marker),
212        None => html.push_str(&marker),
213    }
214    Response::from_parts(parts, Full::new(Bytes::from(html)))
215}
216
217struct Sealed<'k> {
218    keys: &'k SigningKeys,
219    principal: Option<Uuid>,
220    now: i64,
221}
222
223/// Sealed for whom the page was shown, so a caller hears only the topics of a page it was shown,
224/// never `*` at will nor what another principal's page reads.
225fn marker(target: &Target, topics: &Topics, out_of_band: bool, sealed: &Sealed<'_>) -> String {
226    let listened = match topics {
227        Topics::Of(topics) if topics.len() <= TOPICS_LIMIT => topics
228            .iter()
229            .map(Topic::to_string)
230            .collect::<Vec<_>>()
231            .join(","),
232        Topics::Of(_) | Topics::Every => "*".to_owned(),
233    };
234    let oob = if out_of_band {
235        " hx-swap-oob=\"true\""
236    } else {
237        ""
238    };
239    let until = seal_until(sealed.now);
240    let stream = format!(
241        "{PATH}{}?topics={listened}&amp;{UNTIL}{until}",
242        target.audience.name
243    );
244    let text = sealed_text(&stream.replace("&amp;", "&"), sealed.principal);
245    let seal = sealed.keys.sign(Signed::Link, text.as_bytes());
246    format!(
247        "<div id=\"{MARKER}\" hidden{oob} data-stream=\"{stream}&amp;{SEAL}{seal}\" \
248         data-fragments=\"{}\"></div>",
249        target.admission.rules.live.join(" ")
250    )
251}
252
253/// Also keys a live page's tag, so a page the browser kept never holds a seal that ran out.
254pub(crate) fn seal_until(now: i64) -> i64 {
255    (now + SEAL_LIFETIME) / SEAL_STEP * SEAL_STEP + SEAL_STEP
256}
257
258/// Apart from any signed link, whose text is a path.
259fn sealed_text(stream: &str, principal: Option<Uuid>) -> String {
260    match principal {
261        Some(principal) => format!("live:{principal}:{stream}"),
262        None => format!("live:anyone:{stream}"),
263    }
264}
265
266/// A page shown to no one reads what anyone may.
267fn unsealed<'r>(request: &'r Parts, sealed: &Sealed<'_>) -> Option<&'r str> {
268    let (stream, seal) = request
269        .uri
270        .path_and_query()?
271        .as_str()
272        .rsplit_once(&format!("&{SEAL}"))?;
273    let sealed_for = |holder| {
274        let text = sealed_text(stream, holder);
275        sealed.keys.verifies(Signed::Link, text.as_bytes(), seal)
276    };
277    let holds = sealed
278        .principal
279        .is_some_and(|_| sealed_for(sealed.principal))
280        || sealed_for(None);
281    let until: i64 = stream.rsplit_once(&format!("&{UNTIL}"))?.1.parse().ok()?;
282    (holds && sealed.now < until).then_some(stream)
283}
284
285pub(crate) async fn answer(
286    pipeline: &Pipeline,
287    request: http::Request<Incoming>,
288    peer: SocketAddr,
289) -> http::Response<Body> {
290    let is_stream = request.method() == Method::GET && request.uri().path().starts_with(PATH);
291    if !is_stream {
292        return pipeline.handle(request, peer).await.map(Either::Left);
293    }
294    let (request, _) = request.into_parts();
295    #[cfg(feature = "shield")]
296    if let Some(refusal) = pipeline.shielded(&request, peer) {
297        return answer::secure(refusal).map(Either::Left);
298    }
299    match pipeline.live.stream(pipeline, &request, peer).await {
300        Ok(events) => events,
301        Err(refusal) => answer::secure(*refusal).map(Either::Left),
302    }
303}
304
305impl Live {
306    #[cfg_attr(
307        not(feature = "session-list"),
308        expect(
309            clippy::unused_async_trait_impl,
310            reason = "only a session store is asked"
311        )
312    )]
313    async fn stream(
314        &self,
315        pipeline: &Pipeline,
316        request: &Parts,
317        peer: SocketAddr,
318    ) -> Result<http::Response<Body>, Box<Response>> {
319        let refuse = |status, text| Box::new(answer::response(status, Some(TEXT), text));
320        let area = &request.uri.path()[PATH.len()..];
321        let audience = self
322            .areas
323            .get(area)
324            .ok_or_else(|| refuse(StatusCode::NOT_FOUND, "Not found."))?;
325        if is_cross_site(request) {
326            return Err(refuse(StatusCode::FORBIDDEN, "Refused."));
327        }
328        let now = pipeline.db.clock().now().timestamp();
329        let session = pipeline.sessions.open(&request.headers, now);
330        #[cfg(feature = "session-list")]
331        let session = session
332            .confirmed(&pipeline.sessions, &pipeline.db, now)
333            .await
334            .map_err(|error| Box::new(answer::failure(error, Caller::Json)))?;
335        let reach = audience
336            .reach(&session)
337            .ok_or_else(|| refuse(StatusCode::UNAUTHORIZED, "Sign in first."))?;
338        let sealed = Sealed {
339            keys: &pipeline.sessions.keys,
340            principal: session.principal(),
341            now: now.as_second(),
342        };
343        let topics = unsealed(request, &sealed)
344            .and_then(|stream| listened(stream.split_once('?').map(|(_, query)| query)))
345            .ok_or_else(|| refuse(StatusCode::BAD_REQUEST, "Not a list of topics."))?;
346        let holder = Requester::of(session.principal(), pipeline.client(request, peer).0);
347        let admitted = self.admit(holder).ok_or_else(|| {
348            let mut busy = refuse(StatusCode::TOO_MANY_REQUESTS, "Too many open streams.");
349            busy.headers_mut()
350                .insert(RETRY_AFTER, HeaderValue::from_static(RETRY));
351            busy
352        })?;
353        let (frames, events) = mpsc::channel(1);
354        let listening = Listening {
355            changes: sofie_db::committed_changes(&pipeline.db),
356            topics,
357            reach,
358            closing: self.closing.subscribe(),
359            _admitted: admitted,
360        };
361        tokio::spawn(listening.forward(frames));
362        let head = answer::secure(answer::response(StatusCode::OK, None, Bytes::new()));
363        let (mut parts, _) = head.into_parts();
364        parts
365            .headers
366            .insert(CONTENT_TYPE, HeaderValue::from_static("text/event-stream"));
367        parts
368            .headers
369            .insert("x-accel-buffering", HeaderValue::from_static("no"));
370        Ok(http::Response::from_parts(
371            parts,
372            Either::Right(Events(events)),
373        ))
374    }
375}
376
377/// Unlike a page, a stream's opening is no write, so CSRF lets it pass; another site
378/// opening one would still hold a place of the person's.
379fn is_cross_site(request: &Parts) -> bool {
380    request
381        .headers
382        .get("sec-fetch-site")
383        .is_some_and(|site| site != "same-origin")
384}
385
386fn listened(query: Option<&str>) -> Option<Topics> {
387    let list = query?
388        .split('&')
389        .find_map(|pair| pair.strip_prefix("topics="))?;
390    if list == "*" {
391        return Some(Topics::Every);
392    }
393    let topics: BTreeSet<Topic> = list.split(',').map(Topic::parse).collect::<Option<_>>()?;
394    (topics.len() <= TOPICS_LIMIT).then_some(Topics::Of(topics))
395}
396
397struct Listening {
398    changes: broadcast::Receiver<Announced>,
399    topics: Topics,
400    reach: Reach,
401    closing: watch::Receiver<bool>,
402    _admitted: Admitted,
403}
404
405enum Woken {
406    Changed,
407    Beat,
408    Expired,
409    Ended,
410}
411
412impl Listening {
413    async fn forward(mut self, frames: mpsc::Sender<Bytes>) {
414        let mut heartbeat = tokio::time::interval(HEARTBEAT);
415        heartbeat.set_missed_tick_behavior(MissedTickBehavior::Delay);
416        heartbeat.reset();
417        let mut lifetime = pin!(tokio::time::sleep(LIFETIME));
418        let mut frame = OPENED;
419        loop {
420            if frames.send(Bytes::from_static(frame)).await.is_err() || frame == END {
421                return;
422            }
423            frame = match self.woken(&frames, &mut heartbeat, lifetime.as_mut()).await {
424                Woken::Changed => {
425                    tokio::time::sleep(SETTLE).await;
426                    while self.changes.try_recv().is_ok() {}
427                    CHANGED
428                }
429                Woken::Beat => BEAT,
430                Woken::Expired => END,
431                Woken::Ended => return,
432            };
433        }
434    }
435
436    async fn woken(
437        &mut self,
438        frames: &mpsc::Sender<Bytes>,
439        heartbeat: &mut Interval,
440        mut lifetime: Pin<&mut Sleep>,
441    ) -> Woken {
442        if *self.closing.borrow() {
443            return Woken::Ended;
444        }
445        loop {
446            let received = {
447                let mut received = pin!(self.changes.recv());
448                let mut closing = pin!(self.closing.changed());
449                let mut left = pin!(frames.closed());
450                let woken = poll_fn(|context: &mut Context<'_>| {
451                    if lifetime.as_mut().poll(context).is_ready() {
452                        return Poll::Ready(Err(Woken::Expired));
453                    }
454                    if closing.as_mut().poll(context).is_ready()
455                        || left.as_mut().poll(context).is_ready()
456                    {
457                        return Poll::Ready(Err(Woken::Ended));
458                    }
459                    if let Poll::Ready(received) = received.as_mut().poll(context) {
460                        return Poll::Ready(Ok(received));
461                    }
462                    heartbeat.poll_tick(context).map(|_| Err(Woken::Beat))
463                })
464                .await;
465                match woken {
466                    Ok(received) => received,
467                    Err(woken) => return woken,
468                }
469            };
470            match received {
471                Ok(announced) if !announced.concerns(&self.topics, self.reach) => {}
472                // A screen that fell behind may have missed a change it reads.
473                Ok(_) | Err(RecvError::Lagged(_)) => return Woken::Changed,
474                Err(RecvError::Closed) => return Woken::Ended,
475            }
476        }
477    }
478}
479
480pub(crate) struct Events(mpsc::Receiver<Bytes>);
481
482impl hyper::body::Body for Events {
483    type Data = Bytes;
484    type Error = Infallible;
485
486    fn poll_frame(
487        mut self: Pin<&mut Self>,
488        context: &mut Context<'_>,
489    ) -> Poll<Option<Result<Frame<Bytes>, Infallible>>> {
490        self.0
491            .poll_recv(context)
492            .map(|frame| frame.map(|bytes| Ok(Frame::data(bytes))))
493    }
494}
495
496#[cfg(test)]
497mod tests {
498    use super::*;
499
500    #[test]
501    fn a_stream_listens_to_the_topics_its_page_names() {
502        let topic = Topic::named("shop", "order");
503
504        assert_eq!(listened(Some("topics=*")), Some(Topics::Every));
505        assert_eq!(
506            listened(Some(&format!("other=1&topics={topic}"))),
507            Some(Topics::Of(BTreeSet::from([topic])))
508        );
509        assert_eq!(listened(Some("topics=nothex")), None);
510        assert_eq!(listened(None), None);
511        let too_many: Vec<String> = (0..=TOPICS_LIMIT)
512            .map(|table| Topic::named("shop", &table.to_string()).to_string())
513            .collect();
514        assert_eq!(
515            listened(Some(&format!("topics={}", too_many.join(",")))),
516            None
517        );
518    }
519
520    #[tokio::test(start_paused = true)]
521    async fn a_stream_says_when_it_ends_for_its_lifetime() {
522        let live = Live::new(&[]).unwrap();
523        let (_announcer, changes) = broadcast::channel::<Announced>(1);
524        let holder = Requester::of(None, std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST));
525        let listening = Listening {
526            changes,
527            topics: Topics::Every,
528            reach: Reach::All,
529            closing: live.closing.subscribe(),
530            _admitted: live.admit(holder).unwrap(),
531        };
532        let (frames, mut events) = mpsc::channel(1);
533        tokio::spawn(listening.forward(frames));
534
535        let mut last = None;
536        while let Some(frame) = events.recv().await {
537            last = Some(frame);
538        }
539
540        assert_eq!(last.as_deref(), Some(END));
541    }
542
543    #[test]
544    fn the_addresses_of_one_ipv6_64_share_one_holder_s_streams() {
545        let live = Live::new(&[]).unwrap();
546        let holder = |last: u16| {
547            let address = std::net::Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, last);
548            Requester::of(None, std::net::IpAddr::V6(address))
549        };
550
551        let admitted: Vec<Admitted> = (0..8).filter_map(|last| live.admit(holder(last))).collect();
552
553        assert_eq!(admitted.len(), PER_HOLDER);
554        assert!(live.admit(holder(9)).is_none());
555    }
556}