1use 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";
36const PER_HOLDER: usize = 8;
38const PER_PROCESS: usize = 10_000;
39const TOPICS_LIMIT: usize = 32;
41const HEARTBEAT: Duration = Duration::from_secs(20);
43const LIFETIME: Duration = Duration::from_mins(10);
45const SETTLE: Duration = Duration::from_millis(50);
47const SEAL_LIFETIME: i64 = 60 * 60;
50const 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";
59const 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 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 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
129fn 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
148struct 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
176pub(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
223fn 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}&{UNTIL}{until}",
242 target.audience.name
243 );
244 let text = sealed_text(&stream.replace("&", "&"), sealed.principal);
245 let seal = sealed.keys.sign(Signed::Link, text.as_bytes());
246 format!(
247 "<div id=\"{MARKER}\" hidden{oob} data-stream=\"{stream}&{SEAL}{seal}\" \
248 data-fragments=\"{}\"></div>",
249 target.admission.rules.live.join(" ")
250 )
251}
252
253pub(crate) fn seal_until(now: i64) -> i64 {
255 (now + SEAL_LIFETIME) / SEAL_STEP * SEAL_STEP + SEAL_STEP
256}
257
258fn 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
266fn 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
377fn 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 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}