fix(web): harden playback capabilities and SPA routing

pull/433/head
zijiren233 1 month ago
parent 43a90d69ce
commit f8ba767ed9
No known key found for this signature in database
GPG Key ID: 534E082AAA9B39DC

@ -102,8 +102,9 @@ make web-release-build SYNCTV_APP_DIR=/path/to/synctv-app
The target builds Flutter without runtime CDN resources, then compiles the
release server with `SYNCTV_WEB_DIST` and the `web-ui` feature. The server
provides SPA fallback, content types, ETags, Brotli/gzip variants, cache policy,
CSP, and the OAuth/provider-verification callback pages. API and media routes
remain outside the application-shell cache.
and CSP. OAuth uses the normal SPA entry point, while provider verification
uses its dedicated static page. API and media routes remain outside the
application-shell cache.
Keep the app and server protobuf snapshots aligned. Browser playback sends a
versioned `PlaybackClientProfile`; Providers use it with the configured proxy

@ -155,8 +155,6 @@ impl AdminApiImpl {
actor_id: &public_user_id,
playback_generation: state.playback_generation,
resource_owner_id: resource_owner_id.as_deref(),
supports_p2p_media_loader: playback_client_profile
.is_none_or(|profile| profile.supports_p2p_media_loader),
};
let mut playback =
try_playback_to_proto(&full_result, &self.public_id_codec, Some(&signing))?;
@ -282,8 +280,6 @@ impl AdminApiImpl {
actor_id: &public_user_id,
playback_generation: state.playback_generation,
resource_owner_id: resource_owner_id.as_deref(),
supports_p2p_media_loader: playback_client_profile
.is_none_or(|profile| profile.supports_p2p_media_loader),
};
let mut playback =
try_playback_to_proto(&full_result, &self.public_id_codec, Some(&signing))?;

@ -82,7 +82,6 @@ pub struct PlaybackHttpSigningContext<'a> {
pub actor_id: &'a str,
pub playback_generation: i64,
pub resource_owner_id: Option<&'a str>,
pub supports_p2p_media_loader: bool,
}
fn proto_encode_error(kind: &str, error: &str) -> crate::impls::ApiError {
@ -2623,16 +2622,6 @@ pub fn playback_client_profile_from_proto(
} else {
profile.supports_provider_proxy
},
supports_p2p_media_loader: if uses_legacy_defaults {
default_profile.supports_p2p_media_loader
} else {
profile.supports_p2p_media_loader
},
supports_media_source_extensions: profile.supports_media_source_extensions,
supports_managed_media_source: profile.supports_managed_media_source,
supports_web_rtc_data_channel: profile.supports_web_rtc_data_channel,
supports_indexed_db: profile.supports_indexed_db,
supports_opfs: profile.supports_opfs,
supports_insecure_http_media: if uses_legacy_defaults {
default_profile.supports_insecure_http_media
} else {
@ -4180,13 +4169,9 @@ fn playback_media_to_proto(
.into_iter()
.flatten()
.min();
let p2p_delivery = if signing.is_none_or(|context| context.supports_p2p_media_loader) {
synctv_core::provider::playback_media_p2p_delivery(media)
.map(|delivery| p2p_resource_delivery_to_proto(delivery, signing))
.transpose()?
} else {
None
};
let p2p_delivery = synctv_core::provider::playback_media_p2p_delivery(media)
.map(|delivery| p2p_resource_delivery_to_proto(delivery, signing))
.transpose()?;
Ok(synctv_proto::client::PlaybackMedia {
name: media.name.clone(),
url: require_non_empty_url(&url_value, "playback")?,
@ -4251,13 +4236,9 @@ fn subtitle_to_proto(
signing: Option<&PlaybackHttpSigningContext<'_>>,
) -> Result<synctv_proto::client::PlaybackSubtitle, crate::impls::ApiError> {
let url_value = playback_subtitle_url(subtitle, signing)?;
let p2p_delivery = if signing.is_none_or(|context| context.supports_p2p_media_loader) {
synctv_core::provider::playback_subtitle_p2p_delivery(subtitle)
.map(|delivery| p2p_resource_delivery_to_proto(delivery, signing))
.transpose()?
} else {
None
};
let p2p_delivery = synctv_core::provider::playback_subtitle_p2p_delivery(subtitle)
.map(|delivery| p2p_resource_delivery_to_proto(delivery, signing))
.transpose()?;
Ok(synctv_proto::client::PlaybackSubtitle {
name: subtitle.name.clone(),
language: subtitle.language.clone(),
@ -4275,13 +4256,9 @@ fn danmaku_to_proto(
signing: Option<&PlaybackHttpSigningContext<'_>>,
) -> Result<synctv_proto::client::PlaybackDanmaku, crate::impls::ApiError> {
let url_value = playback_danmaku_url(danmaku, public_id_codec, signing)?;
let p2p_delivery = if signing.is_none_or(|context| context.supports_p2p_media_loader) {
synctv_core::provider::playback_danmaku_p2p_delivery(danmaku)
.map(|delivery| p2p_resource_delivery_to_proto(delivery, signing))
.transpose()?
} else {
None
};
let p2p_delivery = synctv_core::provider::playback_danmaku_p2p_delivery(danmaku)
.map(|delivery| p2p_resource_delivery_to_proto(delivery, signing))
.transpose()?;
Ok(synctv_proto::client::PlaybackDanmaku {
name: danmaku.name.clone(),
url: require_non_empty_url(&url_value, "danmaku")?,
@ -5412,7 +5389,6 @@ mod playback_conversion_tests {
actor_id: "user-1",
playback_generation: 7,
resource_owner_id: Some("user-owner"),
supports_p2p_media_loader: true,
}
}
@ -6291,7 +6267,7 @@ mod playback_conversion_tests {
})
.build();
let key = signing_key();
let mut signing = signing_context(&key);
let signing = signing_context(&key);
let result = playback_result_with_mode("direct", info);
let proto = try_playback_to_proto(&result, &codec(), Some(&signing))
.expect("playback should convert");
@ -6331,13 +6307,5 @@ mod playback_conversion_tests {
)
.expect("attachment ticket should bind the current playback");
}
signing.supports_p2p_media_loader = false;
let proto = try_playback_to_proto(&result, &codec(), Some(&signing))
.expect("playback should convert without P2P delivery");
let info = &proto.playback_infos["direct"];
assert!(info.medias[0].p2p_delivery.is_none());
assert!(info.subtitles[0].p2p_delivery.is_none());
assert!(info.danmakus[0].p2p_delivery.is_none());
}
}

@ -535,9 +535,6 @@ impl ClientApiImpl {
actor_id: &actor_id,
playback_generation,
resource_owner_id: resource_owner_id.as_deref(),
supports_p2p_media_loader: ctx
.playback_client_profile()
.is_none_or(|profile| profile.supports_p2p_media_loader),
};
let mut playback =
try_playback_to_proto(full_result, &self.public_id_codec, Some(&signing))?;

@ -899,7 +899,9 @@ fn register_all_routes() -> Router<AppState> {
let mut router = Router::new();
#[cfg(feature = "web-ui")]
{
router = router.route("/", get(web_ui::index));
router = router
.route("/", get(web_ui::index))
.route("/{*webUiPath}", get(web_ui::fallback));
}
#[cfg(not(feature = "web-ui"))]
{

@ -149,7 +149,7 @@ fn request_allowed_web_callback(
let Ok(parsed) = url::Url::parse(redirect_url) else {
return Ok(None);
};
if parsed.path() != "/auth.html"
if parsed.path() != "/oauth2/callback"
|| parsed.query().is_some()
|| parsed.fragment().is_some()
|| !parsed.username().is_empty()
@ -623,7 +623,7 @@ mod tests {
headers.insert("x-forwarded-proto", "https".parse()?);
let allowed = request_allowed_web_callback(
Some("https://app.example.test/auth.html"),
Some("https://app.example.test/oauth2/callback"),
Some(false),
&headers,
Some("127.0.0.1".parse()?),
@ -632,7 +632,7 @@ mod tests {
assert_eq!(
allowed.as_deref(),
Some("https://app.example.test/auth.html")
Some("https://app.example.test/oauth2/callback")
);
Ok(())
}
@ -646,11 +646,11 @@ mod tests {
let peer = Some("127.0.0.1".parse()?);
for redirect in [
"https://evil.example.test:8443/auth.html",
"https://app.example.test/auth.html",
"https://app.example.test:8443/oauth2/callback",
"https://app.example.test:8443/auth.html?next=/rooms",
"https://app.example.test:8443/auth.html#fragment",
"https://evil.example.test:8443/oauth2/callback",
"https://app.example.test/oauth2/callback",
"https://app.example.test:8443/auth.html",
"https://app.example.test:8443/oauth2/callback?next=/rooms",
"https://app.example.test:8443/oauth2/callback#fragment",
] {
assert_eq!(
request_allowed_web_callback(Some(redirect), Some(false), &headers, peer, &server,)?,
@ -666,7 +666,7 @@ mod tests {
let server = synctv_api_common::ApiServerSettings::default();
let mut headers = callback_headers("app.example.test")?;
headers.insert("x-forwarded-proto", "https".parse()?);
let redirect = Some("https://app.example.test/auth.html");
let redirect = Some("https://app.example.test/oauth2/callback");
assert_eq!(
request_allowed_web_callback(
@ -699,7 +699,7 @@ mod tests {
headers.insert("x-forwarded-proto", "javascript".parse()?);
let error = request_allowed_web_callback(
Some("https://app.example.test/auth.html"),
Some("https://app.example.test/oauth2/callback"),
Some(false),
&headers,
Some("127.0.0.1".parse()?),

@ -190,7 +190,6 @@ fn test_build_get_playback_request_decodes_versioned_profile() -> TestResult {
codec_string: Some("avc1.42E01E,mp4a.40.2".to_string()),
}],
supports_provider_proxy: true,
supports_media_source_extensions: true,
..Default::default()
};
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(expected.encode_to_vec());

@ -22,6 +22,14 @@ use tower::ServiceExt;
type TestResult<T = ()> = anyhow::Result<T>;
#[cfg(feature = "web-ui")]
#[test]
fn web_ui_routes_can_merge_with_a_grpc_style_fallback() {
let grpc_router = Router::<super::AppState>::new().fallback(StatusCode::NOT_FOUND);
let _combined = register_all_routes().merge(grpc_router);
}
fn test_error(message: impl Into<String>) -> anyhow::Error {
anyhow::anyhow!(message.into())
}

@ -5,7 +5,6 @@ use axum::response::{IntoResponse, Response};
include!(concat!(env!("OUT_DIR"), "/web_assets.rs"));
const PROVIDER_VERIFICATION_PAGE: &str = "provider_verification.html";
const OAUTH_CALLBACK_PAGE: &str = "auth.html";
const PROVIDER_VERIFICATION_CSP: &str = "default-src 'none'; \
script-src 'self' https://static.geetest.com https://*.geetest.com https://dn-staticdown.qbox.me; \
connect-src https://geetest.com https://*.geetest.com https://monitor.geetest.com https://dn-staticdown.qbox.me; \
@ -77,7 +76,7 @@ fn find_asset(path: &str) -> Option<&'static Asset> {
}
fn asset_response(asset: &'static Asset, html_navigation: bool, headers: &HeaderMap) -> Response {
let cache_control = if matches!(asset.path, PROVIDER_VERIFICATION_PAGE | OAUTH_CALLBACK_PAGE) {
let cache_control = if asset.path == PROVIDER_VERIFICATION_PAGE {
"no-store"
} else if versioned_playback_asset(asset.path) {
"public, max-age=31536000, immutable"
@ -399,7 +398,6 @@ mod tests {
#[test]
fn browser_update_metadata_always_revalidates() {
assert!(update_metadata_asset("index.html"));
assert!(update_metadata_asset("auth.html"));
assert!(update_metadata_asset("manifest.json"));
assert!(update_metadata_asset("version.json"));
assert!(update_metadata_asset("flutter_service_worker.js"));
@ -407,17 +405,23 @@ mod tests {
}
#[tokio::test]
async fn oauth_callback_page_is_never_cached() {
async fn oauth_callback_uses_the_spa_entrypoint() {
let response = fallback(
"/auth.html".parse::<Uri>().expect("valid callback URI"),
"/oauth2/callback"
.parse::<Uri>()
.expect("valid callback URI"),
HeaderMap::new(),
)
.await;
if !WEB_UI_AVAILABLE {
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
return;
}
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers().get(header::CACHE_CONTROL),
Some(&HeaderValue::from_static("no-store"))
Some(&HeaderValue::from_static("no-cache"))
);
}
@ -522,6 +526,9 @@ mod tests {
#[tokio::test]
async fn fallback_negotiates_encoded_assets_and_revalidates_each_representation() {
if !WEB_UI_AVAILABLE {
return;
}
let asset = ASSETS
.iter()
.find(|asset| asset.brotli.is_some() && asset.gzip.is_some())

@ -5,8 +5,6 @@ pub mod http;
pub mod openapi;
pub(crate) mod providers;
#[cfg(feature = "web-ui")]
pub use http::web_ui::fallback as web_ui_fallback;
pub use http::{
build_app_state, create_health_router, create_metrics_router, create_router_from_options,
create_router_from_shared_state, create_router_with_state_from_options, extract_client_ip,

@ -5,8 +5,6 @@ pub use synctv_api_grpc::{
build_axum_router, serve, AdminServiceImpl, ClientServiceImpl, ClientServiceOptions,
ClusterAuthInterceptor, GrpcServerOptions,
};
#[cfg(feature = "web-ui")]
pub use synctv_api_http::web_ui_fallback;
pub use synctv_api_http::{
create_health_router, create_metrics_router, create_router_from_options,
create_router_from_shared_state, create_router_with_state_from_options, extract_client_ip,

@ -4522,7 +4522,6 @@ mod tests {
}],
supports_custom_http_headers: false,
supports_provider_proxy: true,
supports_media_source_extensions: true,
..PlaybackClientProfile::default()
}
}
@ -4944,6 +4943,22 @@ mod tests {
};
assert!(!super::bilibili_live_uses_hls(Some(&native_profile)));
let web_flv_profile = super::super::PlaybackClientProfile {
profile_version: super::super::CURRENT_PLAYBACK_CLIENT_PROFILE_VERSION,
environment: super::super::PlaybackClientEnvironment::Web,
media_capabilities: vec![super::super::PlaybackMediaCapability {
transport: super::super::PlaybackMediaTransport::Flv,
container: None,
video_codec: Some(super::super::PlaybackVideoCodec::H264),
audio_codec: Some(super::super::PlaybackAudioCodec::Aac),
pipeline: super::super::PlaybackMediaPipeline::MediaSource,
codec_string: Some("avc1.42E01E,mp4a.40.2".to_string()),
}],
supported_live_transports: Vec::new(),
..Default::default()
};
assert!(!super::bilibili_live_uses_hls(Some(&web_flv_profile)));
let config = super::BilibiliSourceConfig::Live(crate::models::BilibiliLiveSourceConfig {
room_id: 42,
shared: false,
@ -4959,8 +4974,14 @@ mod tests {
"anonymous",
Some(&native_profile),
))?;
let (web_flv_key, _) = provider_ok(super::playback_cache_entry(
&config,
"anonymous",
Some(&web_flv_profile),
))?;
assert!(hls_key.ends_with(":hls"));
assert!(flv_key.ends_with(":flv"));
assert!(web_flv_key.ends_with(":flv"));
assert_ne!(hls_key, flv_key);
Ok(())
}
@ -6360,9 +6381,8 @@ fn bilibili_live_streams_request(
fn bilibili_live_uses_hls(profile: Option<&super::PlaybackClientProfile>) -> bool {
!profile.is_some_and(|profile| {
profile
.supported_live_transports
.contains(&super::PlaybackLiveTransport::Flv)
profile.supports_transport(super::PlaybackMediaTransport::Flv)
&& !profile.supports_transport(super::PlaybackMediaTransport::Hls)
})
}

@ -242,12 +242,6 @@ pub struct PlaybackClientProfile {
pub media_capabilities: Vec<PlaybackMediaCapability>,
pub supports_custom_http_headers: bool,
pub supports_provider_proxy: bool,
pub supports_p2p_media_loader: bool,
pub supports_media_source_extensions: bool,
pub supports_managed_media_source: bool,
pub supports_web_rtc_data_channel: bool,
pub supports_indexed_db: bool,
pub supports_opfs: bool,
pub supports_insecure_http_media: bool,
}
@ -276,12 +270,6 @@ impl Default for PlaybackClientProfile {
media_capabilities: Vec::new(),
supports_custom_http_headers: true,
supports_provider_proxy: true,
supports_p2p_media_loader: true,
supports_media_source_extensions: false,
supports_managed_media_source: false,
supports_web_rtc_data_channel: false,
supports_indexed_db: false,
supports_opfs: false,
supports_insecure_http_media: true,
}
}
@ -398,7 +386,7 @@ impl PlaybackClientProfile {
.collect::<Vec<_>>();
capabilities.sort_unstable();
format!(
"v={}:environment={}:stream={}:bitrate={}:channels={}:video_codecs={}:containers={}:audio={}:subtitle={}:live_transports={}:media={}:headers={}:proxy={}:p2p={}:mse={}:managed_mse={}:webrtc={}:idb={}:opfs={}:insecure_http_media={}",
"v={}:environment={}:stream={}:bitrate={}:channels={}:video_codecs={}:containers={}:audio={}:subtitle={}:live_transports={}:media={}:headers={}:proxy={}:insecure_http_media={}",
self.profile_version,
self.environment.cache_token(),
self.stream_preference.cache_token(),
@ -426,12 +414,6 @@ impl PlaybackClientProfile {
capabilities.join(","),
self.supports_custom_http_headers,
self.supports_provider_proxy,
self.supports_p2p_media_loader,
self.supports_media_source_extensions,
self.supports_managed_media_source,
self.supports_web_rtc_data_channel,
self.supports_indexed_db,
self.supports_opfs,
self.supports_insecure_http_media,
)
}
@ -502,18 +484,12 @@ mod tests {
}],
supports_custom_http_headers: false,
supports_provider_proxy: true,
supports_p2p_media_loader: true,
supports_media_source_extensions: true,
supports_managed_media_source: false,
supports_web_rtc_data_channel: true,
supports_indexed_db: true,
supports_opfs: false,
supports_insecure_http_media: false,
};
assert_eq!(
profile.cache_fingerprint(),
"v=2:environment=web:stream=transcode:bitrate=8000000:channels=2:video_codecs=h264,av1:containers=mp4,webm:audio=surround:subtitle=embedded_or_external:live_transports=hls,flv:media=progressive+mp4+h264+aac+media_source+avc1.42E01E,mp4a.40.2:headers=false:proxy=true:p2p=true:mse=true:managed_mse=false:webrtc=true:idb=true:opfs=false:insecure_http_media=false"
"v=2:environment=web:stream=transcode:bitrate=8000000:channels=2:video_codecs=h264,av1:containers=mp4,webm:audio=surround:subtitle=embedded_or_external:live_transports=hls,flv:media=progressive+mp4+h264+aac+media_source+avc1.42E01E,mp4a.40.2:headers=false:proxy=true:insecure_http_media=false"
);
}

@ -798,7 +798,7 @@ async fn test_request_allowed_redirect_requires_an_exact_match() {
Box::new(TestOAuth2Provider::new()),
)
.await;
let callback = "https://app.example.test/auth.html".to_string();
let callback = "https://app.example.test/oauth2/callback".to_string();
let prepared = ok(
service

@ -2687,12 +2687,7 @@ message PlaybackClientProfile {
repeated PlaybackMediaCapability media_capabilities = 11 [(buf.validate.field).repeated.max_items = 64];
bool supports_custom_http_headers = 12;
bool supports_provider_proxy = 13;
bool supports_p2p_media_loader = 14;
bool supports_media_source_extensions = 15;
bool supports_managed_media_source = 16;
bool supports_web_rtc_data_channel = 17;
bool supports_indexed_db = 18;
bool supports_opfs = 19;
reserved 14 to 19;
// True when the page/runtime may load http:// media without mixed-content blocking.
bool supports_insecure_http_media = 20;
}

@ -2897,8 +2897,6 @@ impl SyncTvServer {
};
let router = http_router.merge(grpc_router);
#[cfg(feature = "web-ui")]
let router = router.fallback(synctv_api::web_ui_fallback);
let server = axum::serve(
listener,

Loading…
Cancel
Save