1use crate::auth::{AuthConfig, AuthError, AuthQuery};
2use crate::config::{decode_config_segment, strip_json_suffix, ConfigError, UserConfig};
3use crate::models::{
4 CatalogExtraArgs, CatalogResponse, Manifest, MetaResponse, StreamRequest, StreamResponse,
5};
6use crate::signing::{SignedPlayback, SigningError};
7use async_trait::async_trait;
8use axum::extract::{Path, Query, State};
9use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
10use axum::response::{IntoResponse, Redirect, Response};
11use axum::routing::{get, post};
12use axum::{Json, Router};
13use percent_encoding::percent_decode_str;
14use serde::Serialize;
15use std::collections::BTreeMap;
16use std::sync::Arc;
17use tower_http::cors::CorsLayer;
18
19#[derive(Clone, Debug, Default)]
20pub struct AddonContext {
21 pub user_config: UserConfig,
22}
23
24#[async_trait]
25pub trait AddonAdapter: Send + Sync + 'static {
26 async fn manifest(&self, ctx: AddonContext) -> Result<Manifest, AddonError>;
27
28 async fn stream(
29 &self,
30 ctx: AddonContext,
31 content_type: String,
32 id: String,
33 ) -> Result<StreamResponse, AddonError>;
34
35 async fn stream_request(
36 &self,
37 ctx: AddonContext,
38 request: StreamRequest,
39 ) -> Result<StreamResponse, AddonError> {
40 let Some(content_type) = request.content_type else {
41 return Ok(StreamResponse::default());
42 };
43 let Some(id) = request.id else {
44 return Ok(StreamResponse::default());
45 };
46 self.stream(ctx, content_type, id).await
47 }
48
49 async fn catalog(
50 &self,
51 ctx: AddonContext,
52 content_type: String,
53 id: String,
54 extra: CatalogExtraArgs,
55 ) -> Result<CatalogResponse, AddonError>;
56
57 async fn meta(
58 &self,
59 ctx: AddonContext,
60 content_type: String,
61 id: String,
62 ) -> Result<MetaResponse, AddonError>;
63
64 async fn playback(
65 &self,
66 ctx: AddonContext,
67 ident: String,
68 ) -> Result<PlaybackResponse, AddonError>;
69}
70
71#[derive(Debug, thiserror::Error)]
72pub enum AddonError {
73 #[error("addon auth failed: {0}")]
74 Auth(#[from] AuthError),
75 #[error("invalid config: {0}")]
76 Config(#[from] ConfigError),
77 #[error("not found")]
78 NotFound,
79 #[error("bad request: {0}")]
80 BadRequest(String),
81 #[error("provider error: {0}")]
82 Provider(String),
83 #[error("playback error: {0}")]
84 Playback(String),
85}
86
87impl IntoResponse for AddonError {
88 fn into_response(self) -> Response {
89 let status = match self {
90 AddonError::Auth(_) => StatusCode::UNAUTHORIZED,
91 AddonError::NotFound => StatusCode::NOT_FOUND,
92 AddonError::Config(_) | AddonError::BadRequest(_) => StatusCode::BAD_REQUEST,
93 AddonError::Provider(_) | AddonError::Playback(_) => StatusCode::INTERNAL_SERVER_ERROR,
94 };
95
96 let body = ErrorBody {
97 error: self.to_string(),
98 };
99
100 (status, Json(body)).into_response()
101 }
102}
103
104#[derive(Clone, Debug)]
105pub struct PlaybackResponse {
106 pub location: String,
107 pub cache_max_age_seconds: u64,
108}
109
110#[derive(Serialize)]
111struct ErrorBody {
112 error: String,
113}
114
115#[derive(Clone)]
116struct AppState {
117 adapter: Arc<dyn AddonAdapter>,
118 auth: AuthConfig,
119 options: RouterOptions,
120}
121
122pub fn build_router(adapter: Arc<dyn AddonAdapter>, auth: AuthConfig) -> Router {
123 build_router_with_options(adapter, auth, RouterOptions::default())
124}
125
126#[derive(Clone, Debug)]
127pub struct RouterOptions {
128 pub catalog_routes: bool,
129 pub meta_routes: bool,
130 pub playback_routes: bool,
131 pub alias_routes: bool,
132 pub path_key_routes: bool,
133 pub health_routes: bool,
134 pub playback_signing_key: Option<String>,
135 pub require_stream_json_suffix: bool,
136}
137
138impl Default for RouterOptions {
139 fn default() -> Self {
140 Self {
141 catalog_routes: true,
142 meta_routes: true,
143 playback_routes: true,
144 alias_routes: true,
145 path_key_routes: true,
146 health_routes: true,
147 playback_signing_key: None,
148 require_stream_json_suffix: false,
149 }
150 }
151}
152
153pub fn build_router_with_options(
154 adapter: Arc<dyn AddonAdapter>,
155 auth: AuthConfig,
156 options: RouterOptions,
157) -> Router {
158 let mut router = Router::new()
159 .route("/manifest.json", get(manifest))
160 .route("/:config/manifest.json", get(manifest_with_config))
161 .route("/stream", post(stream_post))
162 .route("/stream/:type/:id", get(stream_without_config))
163 .route("/api/streams", post(stream_post))
164 .route("/api/streams/:type/:id", get(stream_api_without_config))
165 .route("/:config/stream/:type/:id", get(stream_with_config));
166
167 if options.alias_routes {
168 router = router
169 .route("/", get(manifest))
170 .route("/stremio/manifest.json", get(manifest))
171 .route("/api/manifest", get(manifest));
172 }
173
174 if options.path_key_routes {
175 router = router
176 .route("/u/:path_key/manifest.json", get(manifest_with_path_key))
177 .route("/u/:path_key/stream/:type/:id", get(stream_with_path_key));
178 }
179
180 if options.catalog_routes {
181 router = router
182 .route("/catalog/:type/:id/*extra", get(catalog_without_config))
183 .route(
184 "/:config/catalog/:type/:id/*extra",
185 get(catalog_with_config),
186 );
187 }
188
189 if options.meta_routes {
190 router = router
191 .route("/meta/:type/:id", get(meta_without_config))
192 .route("/:config/meta/:type/:id", get(meta_with_config));
193 }
194
195 if options.playback_routes {
196 router = router
197 .route("/play/:ident", get(play_without_config))
198 .route("/:config/play/:ident", get(play_with_config));
199 }
200
201 if options.health_routes {
202 router = router
203 .route("/health", get(health))
204 .route("/healthz", get(health));
205 }
206
207 let state = AppState {
208 adapter,
209 auth,
210 options,
211 };
212
213 router.layer(CorsLayer::permissive()).with_state(state)
214}
215
216async fn manifest(
217 State(state): State<AppState>,
218 Query(query): Query<AuthQuery>,
219 headers: HeaderMap,
220) -> Result<Json<Manifest>, AddonError> {
221 let ctx = context_from_parts(&state.auth, None, Some(&query), &headers, None)?;
222 state.adapter.manifest(ctx).await.map(Json)
223}
224
225async fn stream_post(
226 State(state): State<AppState>,
227 Query(query): Query<AuthQuery>,
228 headers: HeaderMap,
229 Json(body): Json<StreamRequest>,
230) -> Result<Json<StreamResponse>, AddonError> {
231 let ctx = context_from_parts(&state.auth, None, Some(&query), &headers, None)?;
232 state.adapter.stream_request(ctx, body).await.map(Json)
233}
234
235async fn manifest_with_config(
236 State(state): State<AppState>,
237 Path(config): Path<String>,
238 Query(query): Query<AuthQuery>,
239 headers: HeaderMap,
240) -> Result<Json<Manifest>, AddonError> {
241 let cfg = decode_config_segment(&config)?;
242 let ctx = context_from_parts(&state.auth, Some(cfg), Some(&query), &headers, None)?;
243 state.adapter.manifest(ctx).await.map(Json)
244}
245
246async fn manifest_with_path_key(
247 State(state): State<AppState>,
248 Path(path_key): Path<String>,
249 Query(query): Query<AuthQuery>,
250 headers: HeaderMap,
251) -> Result<Json<Manifest>, AddonError> {
252 let ctx = context_from_parts(
253 &state.auth,
254 None,
255 Some(&query),
256 &headers,
257 Some(path_key.as_str()),
258 )?;
259 state.adapter.manifest(ctx).await.map(Json)
260}
261
262async fn stream_without_config(
263 State(state): State<AppState>,
264 Path((content_type, id)): Path<(String, String)>,
265 Query(query): Query<AuthQuery>,
266 headers: HeaderMap,
267) -> Result<Json<StreamResponse>, AddonError> {
268 let ctx = context_from_parts(&state.auth, None, Some(&query), &headers, None)?;
269 let id = stream_path_id(&state.options, &id)?;
270 state.adapter.stream(ctx, content_type, id).await.map(Json)
271}
272
273async fn stream_api_without_config(
274 State(state): State<AppState>,
275 Path((content_type, id)): Path<(String, String)>,
276 Query(query): Query<AuthQuery>,
277 headers: HeaderMap,
278) -> Result<Json<StreamResponse>, AddonError> {
279 let ctx = context_from_parts(&state.auth, None, Some(&query), &headers, None)?;
280 state
281 .adapter
282 .stream(ctx, content_type, strip_json_suffix(&id).to_string())
283 .await
284 .map(Json)
285}
286
287async fn stream_with_path_key(
288 State(state): State<AppState>,
289 Path((path_key, content_type, id)): Path<(String, String, String)>,
290 Query(query): Query<AuthQuery>,
291 headers: HeaderMap,
292) -> Result<Json<StreamResponse>, AddonError> {
293 let ctx = context_from_parts(
294 &state.auth,
295 None,
296 Some(&query),
297 &headers,
298 Some(path_key.as_str()),
299 )?;
300 let id = stream_path_id(&state.options, &id)?;
301 state.adapter.stream(ctx, content_type, id).await.map(Json)
302}
303
304async fn stream_with_config(
305 State(state): State<AppState>,
306 Path((config, content_type, id)): Path<(String, String, String)>,
307 Query(query): Query<AuthQuery>,
308 headers: HeaderMap,
309) -> Result<Json<StreamResponse>, AddonError> {
310 let cfg = decode_config_segment(&config)?;
311 let ctx = context_from_parts(&state.auth, Some(cfg), Some(&query), &headers, None)?;
312 let id = stream_path_id(&state.options, &id)?;
313 state.adapter.stream(ctx, content_type, id).await.map(Json)
314}
315
316async fn catalog_without_config(
317 State(state): State<AppState>,
318 Path((content_type, id, extra)): Path<(String, String, String)>,
319 Query(query): Query<AuthQuery>,
320 headers: HeaderMap,
321) -> Result<Json<CatalogResponse>, AddonError> {
322 let ctx = context_from_parts(&state.auth, None, Some(&query), &headers, None)?;
323 state
324 .adapter
325 .catalog(ctx, content_type, id, parse_extra_args(&extra)?)
326 .await
327 .map(Json)
328}
329
330async fn catalog_with_config(
331 State(state): State<AppState>,
332 Path((config, content_type, id, extra)): Path<(String, String, String, String)>,
333 Query(query): Query<AuthQuery>,
334 headers: HeaderMap,
335) -> Result<Json<CatalogResponse>, AddonError> {
336 let cfg = decode_config_segment(&config)?;
337 let ctx = context_from_parts(&state.auth, Some(cfg), Some(&query), &headers, None)?;
338 state
339 .adapter
340 .catalog(ctx, content_type, id, parse_extra_args(&extra)?)
341 .await
342 .map(Json)
343}
344
345async fn meta_without_config(
346 State(state): State<AppState>,
347 Path((content_type, id)): Path<(String, String)>,
348 Query(query): Query<AuthQuery>,
349 headers: HeaderMap,
350) -> Result<Json<MetaResponse>, AddonError> {
351 let ctx = context_from_parts(&state.auth, None, Some(&query), &headers, None)?;
352 state
353 .adapter
354 .meta(ctx, content_type, strip_json_suffix(&id).to_string())
355 .await
356 .map(Json)
357}
358
359async fn meta_with_config(
360 State(state): State<AppState>,
361 Path((config, content_type, id)): Path<(String, String, String)>,
362 Query(query): Query<AuthQuery>,
363 headers: HeaderMap,
364) -> Result<Json<MetaResponse>, AddonError> {
365 let cfg = decode_config_segment(&config)?;
366 let ctx = context_from_parts(&state.auth, Some(cfg), Some(&query), &headers, None)?;
367 state
368 .adapter
369 .meta(ctx, content_type, strip_json_suffix(&id).to_string())
370 .await
371 .map(Json)
372}
373
374async fn play_without_config(
375 State(state): State<AppState>,
376 Path(ident): Path<String>,
377 Query(query): Query<AuthQuery>,
378 headers: HeaderMap,
379) -> Result<Response, AddonError> {
380 let ctx = context_from_parts(&state.auth, None, Some(&query), &headers, None)?;
381 let ident = verified_playback_ident(&state.options, &ident, &query)?;
382 playback_response(state.adapter.playback(ctx, ident).await?)
383}
384
385async fn play_with_config(
386 State(state): State<AppState>,
387 Path((config, ident)): Path<(String, String)>,
388 Query(query): Query<AuthQuery>,
389 headers: HeaderMap,
390) -> Result<Response, AddonError> {
391 let cfg = decode_config_segment(&config)?;
392 let ctx = context_from_parts(&state.auth, Some(cfg), Some(&query), &headers, None)?;
393 let ident = verified_playback_ident(&state.options, &ident, &query)?;
394 playback_response(state.adapter.playback(ctx, ident).await?)
395}
396
397fn context_from_parts(
398 auth: &AuthConfig,
399 user_config: Option<UserConfig>,
400 query: Option<&AuthQuery>,
401 headers: &HeaderMap,
402 path_key: Option<&str>,
403) -> Result<AddonContext, AddonError> {
404 let user_config = user_config.unwrap_or_default();
405 auth.validate(Some(&user_config), query, headers, path_key)?;
406 Ok(AddonContext { user_config })
407}
408
409async fn health() -> Json<serde_json::Value> {
410 Json(serde_json::json!({ "status": "ok" }))
411}
412
413fn verified_playback_ident(
414 options: &RouterOptions,
415 path_ident: &str,
416 query: &AuthQuery,
417) -> Result<String, AddonError> {
418 let Some(signing_key) = options.playback_signing_key.as_deref() else {
419 return Ok(path_ident.to_string());
420 };
421
422 let sig = query
423 .sig
424 .as_deref()
425 .ok_or_else(|| AddonError::Playback("missing signed playback token".to_string()))?;
426 let payload = SignedPlayback::verify(sig, signing_key.as_bytes()).map_err(signing_error)?;
427
428 if payload.ident != path_ident {
429 return Err(AddonError::Playback(
430 "signed playback token does not match path ident".to_string(),
431 ));
432 }
433
434 Ok(payload.ident)
435}
436
437fn signing_error(error: SigningError) -> AddonError {
438 AddonError::Playback(error.to_string())
439}
440
441fn parse_extra_args(extra: &str) -> Result<CatalogExtraArgs, AddonError> {
442 let mut values = BTreeMap::new();
443 let extra = strip_json_suffix(extra);
444
445 for segment in extra.split('/') {
446 let Some((key, value)) = segment.split_once('=') else {
447 return Err(AddonError::BadRequest(format!(
448 "invalid catalog extra segment: {segment}"
449 )));
450 };
451
452 values.insert(percent_decode(key)?, percent_decode(value)?);
453 }
454
455 Ok(CatalogExtraArgs { values })
456}
457
458fn percent_decode(value: &str) -> Result<String, AddonError> {
459 percent_decode_str(value)
460 .decode_utf8()
461 .map(|value| value.into_owned())
462 .map_err(|_| AddonError::BadRequest("invalid percent encoding".to_string()))
463}
464
465fn stream_path_id(options: &RouterOptions, id: &str) -> Result<String, AddonError> {
466 if options.require_stream_json_suffix && !id.ends_with(".json") {
467 return Err(AddonError::NotFound);
468 }
469
470 Ok(strip_json_suffix(id).to_string())
471}
472
473fn playback_response(playback: PlaybackResponse) -> Result<Response, AddonError> {
474 let mut response = Redirect::temporary(&playback.location).into_response();
475 let headers = response.headers_mut();
476 let cache_control = format!(
477 "max-age={}, must-revalidate, proxy-revalidate",
478 playback.cache_max_age_seconds
479 );
480 headers.insert(
481 header::CACHE_CONTROL,
482 HeaderValue::from_str(&cache_control)
483 .map_err(|_| AddonError::Playback("invalid cache control header".to_string()))?,
484 );
485
486 Ok(response)
487}
488
489#[cfg(test)]
490mod tests {
491 use super::*;
492 use crate::models::{
493 BehaviorHints, CatalogDef, Meta, MetaPreview, Resource, ResourceSpec, Stream,
494 };
495 use axum::body::Body;
496 use axum::http::{Request, StatusCode};
497 use tower::ServiceExt;
498
499 struct DummyAdapter;
500
501 #[async_trait]
502 impl AddonAdapter for DummyAdapter {
503 async fn manifest(&self, _ctx: AddonContext) -> Result<Manifest, AddonError> {
504 Ok(Manifest {
505 id: "test.addon".to_string(),
506 version: "0.1.0".to_string(),
507 name: "Test Addon".to_string(),
508 description: None,
509 resources: vec![ResourceSpec::Object(Resource {
510 name: "stream".to_string(),
511 types: vec!["movie".to_string()],
512 id_prefixes: vec!["tt".to_string()],
513 })],
514 types: vec!["movie".to_string()],
515 catalogs: vec![CatalogDef {
516 id: "direct".to_string(),
517 r#type: "movie".to_string(),
518 name: "Direct".to_string(),
519 extra: vec![],
520 }],
521 id_prefixes: vec!["tt".to_string()],
522 behavior_hints: Some(BehaviorHints {
523 configurable: Some(false),
524 configuration_required: Some(false),
525 p2p: Some(false),
526 adult: Some(false),
527 }),
528 config: vec![],
529 logo: None,
530 background: None,
531 contact_email: None,
532 extra: Default::default(),
533 })
534 }
535
536 async fn stream(
537 &self,
538 _ctx: AddonContext,
539 _content_type: String,
540 id: String,
541 ) -> Result<StreamResponse, AddonError> {
542 Ok(StreamResponse {
543 streams: vec![Stream {
544 ident: Some(id),
545 url: Some("https://example.com/play".to_string()),
546 ..Stream::default()
547 }],
548 })
549 }
550
551 async fn catalog(
552 &self,
553 _ctx: AddonContext,
554 content_type: String,
555 _id: String,
556 extra: CatalogExtraArgs,
557 ) -> Result<CatalogResponse, AddonError> {
558 Ok(CatalogResponse {
559 metas: vec![MetaPreview {
560 id: extra.get("search").unwrap_or_default().to_string(),
561 r#type: content_type,
562 name: "Result".to_string(),
563 poster: None,
564 }],
565 cache_max_age: Some(3600),
566 })
567 }
568
569 async fn meta(
570 &self,
571 _ctx: AddonContext,
572 content_type: String,
573 id: String,
574 ) -> Result<MetaResponse, AddonError> {
575 Ok(MetaResponse {
576 meta: Meta {
577 id,
578 r#type: content_type,
579 name: "Meta".to_string(),
580 ..Meta::default()
581 },
582 })
583 }
584
585 async fn playback(
586 &self,
587 _ctx: AddonContext,
588 ident: String,
589 ) -> Result<PlaybackResponse, AddonError> {
590 Ok(PlaybackResponse {
591 location: format!("https://example.com/{ident}"),
592 cache_max_age_seconds: 18_000,
593 })
594 }
595 }
596
597 #[tokio::test]
598 async fn manifest_requires_auth() {
599 let app = build_router(Arc::new(DummyAdapter), AuthConfig::required("secret"));
600 let response = app
601 .oneshot(
602 Request::builder()
603 .uri("/manifest.json")
604 .body(Body::empty())
605 .unwrap(),
606 )
607 .await
608 .unwrap();
609
610 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
611 }
612
613 #[tokio::test]
614 async fn manifest_accepts_query_auth() {
615 let app = build_router(Arc::new(DummyAdapter), AuthConfig::required("secret"));
616 let response = app
617 .oneshot(
618 Request::builder()
619 .uri("/manifest.json?authKey=secret")
620 .body(Body::empty())
621 .unwrap(),
622 )
623 .await
624 .unwrap();
625
626 assert_eq!(response.status(), StatusCode::OK);
627 }
628
629 #[tokio::test]
630 async fn manifest_accepts_path_key() {
631 let app = build_router(Arc::new(DummyAdapter), AuthConfig::required("secret"));
632 let response = app
633 .oneshot(
634 Request::builder()
635 .uri("/u/secret/manifest.json")
636 .body(Body::empty())
637 .unwrap(),
638 )
639 .await
640 .unwrap();
641
642 assert_eq!(response.status(), StatusCode::OK);
643 }
644
645 #[tokio::test]
646 async fn manifest_does_not_mount_php_alias() {
647 let app = build_router(Arc::new(DummyAdapter), AuthConfig::required("secret"));
648 let response = app
649 .oneshot(
650 Request::builder()
651 .uri("/index.php?authKey=secret")
652 .body(Body::empty())
653 .unwrap(),
654 )
655 .await
656 .unwrap();
657
658 assert_eq!(response.status(), StatusCode::NOT_FOUND);
659 }
660
661 #[tokio::test]
662 async fn stream_accepts_config_auth_and_strips_json_suffix() {
663 let app = build_router(Arc::new(DummyAdapter), AuthConfig::required("secret"));
664 let response = app
665 .oneshot(
666 Request::builder()
667 .uri("/%7B%22authKey%22%3A%22secret%22%7D/stream/movie/tt123.json")
668 .body(Body::empty())
669 .unwrap(),
670 )
671 .await
672 .unwrap();
673
674 assert_eq!(response.status(), StatusCode::OK);
675 }
676
677 #[tokio::test]
678 async fn stream_can_require_json_suffix_without_requiring_it_for_api_alias() {
679 let app = build_router_with_options(
680 Arc::new(DummyAdapter),
681 AuthConfig::disabled(),
682 RouterOptions {
683 require_stream_json_suffix: true,
684 ..RouterOptions::default()
685 },
686 );
687
688 let response = app
689 .clone()
690 .oneshot(
691 Request::builder()
692 .uri("/stream/movie/tt123")
693 .body(Body::empty())
694 .unwrap(),
695 )
696 .await
697 .unwrap();
698 assert_eq!(response.status(), StatusCode::NOT_FOUND);
699
700 let response = app
701 .oneshot(
702 Request::builder()
703 .uri("/api/streams/movie/tt123")
704 .body(Body::empty())
705 .unwrap(),
706 )
707 .await
708 .unwrap();
709 assert_eq!(response.status(), StatusCode::OK);
710 }
711
712 #[tokio::test]
713 async fn stream_post_accepts_body_and_key_alias() {
714 let app = build_router(Arc::new(DummyAdapter), AuthConfig::required("secret"));
715 let response = app
716 .oneshot(
717 Request::builder()
718 .method("POST")
719 .uri("/stream?key=secret")
720 .header(header::CONTENT_TYPE, "application/json")
721 .body(Body::from(
722 r#"{"type":"movie","id":"tt123","name":"Movie","year":"2024"}"#,
723 ))
724 .unwrap(),
725 )
726 .await
727 .unwrap();
728
729 assert_eq!(response.status(), StatusCode::OK);
730 }
731
732 #[tokio::test]
733 async fn parses_catalog_extra_args() {
734 let extra = parse_extra_args("search=hello%20world.json").unwrap();
735 assert_eq!(extra.get("search"), Some("hello world"));
736 }
737
738 #[tokio::test]
739 async fn play_requires_auth() {
740 let app = build_router(Arc::new(DummyAdapter), AuthConfig::required("secret"));
741 let response = app
742 .oneshot(
743 Request::builder()
744 .uri("/play/abc123")
745 .body(Body::empty())
746 .unwrap(),
747 )
748 .await
749 .unwrap();
750
751 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
752 }
753
754 #[tokio::test]
755 async fn play_redirects_with_cache_header() {
756 let app = build_router(Arc::new(DummyAdapter), AuthConfig::required("secret"));
757 let response = app
758 .oneshot(
759 Request::builder()
760 .uri("/play/abc123?authKey=secret")
761 .body(Body::empty())
762 .unwrap(),
763 )
764 .await
765 .unwrap();
766
767 assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT);
768 assert_eq!(
769 response.headers().get(header::LOCATION).unwrap(),
770 "https://example.com/abc123"
771 );
772 assert_eq!(
773 response.headers().get(header::CACHE_CONTROL).unwrap(),
774 "max-age=18000, must-revalidate, proxy-revalidate"
775 );
776 }
777
778 #[tokio::test]
779 async fn signed_playback_rejects_missing_sig() {
780 let app = build_router_with_options(
781 Arc::new(DummyAdapter),
782 AuthConfig::required("secret"),
783 RouterOptions {
784 playback_signing_key: Some("signing-key".to_string()),
785 ..RouterOptions::default()
786 },
787 );
788 let response = app
789 .oneshot(
790 Request::builder()
791 .uri("/play/abc123?authKey=secret")
792 .body(Body::empty())
793 .unwrap(),
794 )
795 .await
796 .unwrap();
797
798 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
799 }
800
801 #[tokio::test]
802 async fn signed_playback_accepts_matching_sig() {
803 let token = crate::signing::SignedPlayback::new("abc123", 60)
804 .sign(b"signing-key")
805 .unwrap();
806 let app = build_router_with_options(
807 Arc::new(DummyAdapter),
808 AuthConfig::required("secret"),
809 RouterOptions {
810 playback_signing_key: Some("signing-key".to_string()),
811 ..RouterOptions::default()
812 },
813 );
814 let response = app
815 .oneshot(
816 Request::builder()
817 .uri(format!("/play/abc123?authKey=secret&sig={token}"))
818 .body(Body::empty())
819 .unwrap(),
820 )
821 .await
822 .unwrap();
823
824 assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT);
825 }
826}