|
|
|
|
use std::collections::HashMap;
|
|
|
|
|
use std::time::Duration;
|
|
|
|
|
|
|
|
|
|
use axum::http::StatusCode;
|
|
|
|
|
use prost::Message as _;
|
|
|
|
|
use prost_reflect::ReflectMessage as _;
|
|
|
|
|
use synctv_proto::google::rpc;
|
|
|
|
|
use tonic::{Code, Status};
|
|
|
|
|
use tonic_types::{ErrorDetails, StatusExt};
|
|
|
|
|
|
|
|
|
|
const ERROR_DOMAIN: &str = "synctv.api";
|
|
|
|
|
const REQUEST_ID_METADATA_KEY: &str = "requestId";
|
|
|
|
|
const ERROR_CODE_METADATA_KEY: &str = "errorCode";
|
|
|
|
|
const OAUTH2_OPERATION_METADATA_KEY: &str = "oauth2Operation";
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
pub struct GoogleApiError {
|
|
|
|
|
pub grpc_code: Code,
|
|
|
|
|
pub http_status: StatusCode,
|
|
|
|
|
pub message: String,
|
|
|
|
|
pub retry_after_seconds: Option<u64>,
|
|
|
|
|
details: ErrorDetails,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl GoogleApiError {
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn from_api_error(err: &crate::impls::ApiError) -> Self {
|
|
|
|
|
let classification = match err {
|
|
|
|
|
crate::impls::ApiError::PayloadTooLarge(_) => ErrorClassification {
|
|
|
|
|
grpc_code: Code::ResourceExhausted,
|
|
|
|
|
http_status: StatusCode::PAYLOAD_TOO_LARGE,
|
|
|
|
|
reason: "PAYLOAD_TOO_LARGE",
|
|
|
|
|
},
|
|
|
|
|
_ => ErrorClassification::from_kind(err.classify()),
|
|
|
|
|
};
|
|
|
|
|
let message = err.message().to_string();
|
|
|
|
|
let mut metadata = HashMap::from([
|
|
|
|
|
(ERROR_CODE_METADATA_KEY.to_string(), err.code().to_string()),
|
|
|
|
|
(
|
|
|
|
|
"errorKind".to_string(),
|
|
|
|
|
classification.reason.to_ascii_lowercase(),
|
|
|
|
|
),
|
|
|
|
|
]);
|
|
|
|
|
if let Some(operation) = err.oauth2_operation() {
|
|
|
|
|
metadata.insert(
|
|
|
|
|
OAUTH2_OPERATION_METADATA_KEY.to_string(),
|
|
|
|
|
operation.as_str().to_string(),
|
|
|
|
|
);
|
|
|
|
|
}
|
feat(web): add client-aware playback and reproducible UI (#433)
## Summary
- add a versioned playback client profile for browser/runtime protocol,
container, codec, header, proxy, insecure-media, and P2P-loader
capabilities
- generate compatible direct and proxy resources inside each provider
and return a structured incompatibility error when route policy leaves
no viable result
- force provider proxy delivery for browser-forbidden headers, including
affected Bilibili variants, while preserving explicit direct-only
failures
- keep legacy clients compatible and isolate capability-aware playback
cache entries
- serve `/oauth2/callback` through the same Flutter SPA entry point and
keep public discovery anonymous
## Reproducible Web UI
- move Flutter acquisition/build, source configuration, asset manifests,
Brotli/gzip compression, and compile-time embedding into the independent
`synctv-web-ui` crate
- support prebuilt distributions, local projects, and Git sources pinned
to an immutable full commit, with an ignored local override
- fingerprint source, Flutter version, build arguments, dart-defines,
builder generation, output, and compression settings
- validate cached Git repository/revision/commit identity, support
offline cache reuse, and rebuild only when relevant inputs change
- pin and checksum the Flutter SDK in the Docker Web asset stage;
backend-only builds require no Flutter, Git fetch, or network
- serve embedded assets with ETags, compression negotiation, CSP, cache
policy, Origin/CORS handling, and SPA fallback outside API/media routes
## Verification
- `cargo fmt --all -- --check`
- workspace `cargo check`, test/doc-test, and all-targets Clippy with
warnings denied
- provider tests: 200 passed, 1 ignored
- OAuth core/API tests: 72 + 16 passed
- `synctv-web-ui`: 10 passed
- `synctv-api-http --features web-ui`: 12 passed
- default Git source cold build from the pinned App commit, followed by
offline hot-cache reuse in about 1.1 seconds
- `cargo check -p synctv --features web-ui` and `docker buildx build
--check .`
- real Chrome playback, P2P, OAuth/Casdoor, multi-user sync, chat,
media, playlist, upload, settings, and playback-history flows
Companion frontend PR: https://github.com/synctv-org/synctv-app/pull/52
Release pin validation:
https://github.com/synctv-org/synctv-release/pull/9
1 month ago
|
|
|
if let crate::impls::ApiError::ClientIncompatible {
|
|
|
|
|
required_capability: Some(required_capability),
|
|
|
|
|
..
|
|
|
|
|
} = err
|
|
|
|
|
{
|
|
|
|
|
metadata.insert(
|
|
|
|
|
"requiredCapability".to_string(),
|
|
|
|
|
required_capability.clone(),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut details = ErrorDetails::new();
|
|
|
|
|
details.set_error_info(classification.reason, ERROR_DOMAIN, metadata);
|
|
|
|
|
|
|
|
|
|
match err {
|
|
|
|
|
crate::impls::ApiError::InvalidInput(message) => {
|
|
|
|
|
details.add_bad_request_violation("request", message);
|
|
|
|
|
}
|
|
|
|
|
crate::impls::ApiError::InvalidRequest {
|
|
|
|
|
message,
|
|
|
|
|
violations,
|
|
|
|
|
} => {
|
|
|
|
|
if violations.is_empty() {
|
|
|
|
|
details.add_bad_request_violation("request", message);
|
|
|
|
|
} else {
|
|
|
|
|
for violation in violations {
|
|
|
|
|
details.add_bad_request_violation(
|
|
|
|
|
violation.field.as_str(),
|
|
|
|
|
violation.description.as_str(),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
crate::impls::ApiError::RangeNotSatisfiable { total_size } => {
|
|
|
|
|
details.set_resource_info(
|
|
|
|
|
"byte_range",
|
|
|
|
|
total_size.to_string(),
|
|
|
|
|
"",
|
|
|
|
|
"Requested byte range is not satisfiable",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
crate::impls::ApiError::RateLimitedWithRetry {
|
|
|
|
|
retry_after_seconds,
|
|
|
|
|
..
|
|
|
|
|
}
|
|
|
|
|
| crate::impls::ApiError::OAuth2General {
|
|
|
|
|
retry_after_seconds: Some(retry_after_seconds),
|
|
|
|
|
..
|
|
|
|
|
} => {
|
|
|
|
|
details.set_retry_info(Some(Duration::from_secs(*retry_after_seconds)));
|
|
|
|
|
}
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Self {
|
|
|
|
|
grpc_code: classification.grpc_code,
|
|
|
|
|
http_status: http_status_for_error(err, classification.http_status),
|
|
|
|
|
message,
|
|
|
|
|
retry_after_seconds: err.retry_after_seconds(),
|
|
|
|
|
details,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn with_request_id(mut self, request_id: Option<&str>) -> Self {
|
|
|
|
|
if let Some(request_id) = request_id.filter(|value| !value.is_empty()) {
|
|
|
|
|
let mut metadata = self
|
|
|
|
|
.details
|
|
|
|
|
.error_info()
|
|
|
|
|
.map(|detail| detail.metadata.clone())
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
let reason = self
|
|
|
|
|
.details
|
|
|
|
|
.error_info()
|
|
|
|
|
.map_or_else(|| "UNKNOWN".to_string(), |detail| detail.reason.clone());
|
|
|
|
|
metadata.insert(REQUEST_ID_METADATA_KEY.to_string(), request_id.to_string());
|
|
|
|
|
self.details.set_error_info(reason, ERROR_DOMAIN, metadata);
|
|
|
|
|
self.details.set_request_info(request_id, "");
|
|
|
|
|
}
|
|
|
|
|
self
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn to_tonic_status(&self) -> Status {
|
|
|
|
|
Status::with_error_details(self.grpc_code, self.message.clone(), self.details.clone())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn to_rpc_status(&self) -> Result<rpc::Status, prost::DecodeError> {
|
|
|
|
|
let status = self.to_tonic_status();
|
|
|
|
|
let tonic_status = tonic_types::pb::Status::decode(status.details())?;
|
|
|
|
|
Ok(rpc::Status {
|
|
|
|
|
code: tonic_status.code,
|
|
|
|
|
message: tonic_status.message,
|
|
|
|
|
details: tonic_status
|
|
|
|
|
.details
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(|detail| pbjson_types::Any {
|
|
|
|
|
type_url: detail.type_url,
|
|
|
|
|
value: detail.value.into(),
|
|
|
|
|
})
|
|
|
|
|
.collect(),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn to_protojson_bytes(&self) -> Result<Vec<u8>, serde_json::Error> {
|
|
|
|
|
let rpc_status = self
|
|
|
|
|
.to_rpc_status()
|
|
|
|
|
.map_err(|error| serde_json::Error::io(std::io::Error::other(error)))?;
|
|
|
|
|
let dynamic = rpc_status.transcode_to_dynamic();
|
|
|
|
|
let mut output = Vec::new();
|
|
|
|
|
let mut serializer = serde_json::Serializer::new(&mut output);
|
|
|
|
|
dynamic
|
|
|
|
|
.serialize_with_options(&mut serializer, &prost_reflect::SerializeOptions::new())
|
|
|
|
|
.map_err(|error| serde_json::Error::io(std::io::Error::other(error)))?;
|
|
|
|
|
Ok(output)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
|
|
|
struct ErrorClassification {
|
|
|
|
|
grpc_code: Code,
|
|
|
|
|
http_status: StatusCode,
|
|
|
|
|
reason: &'static str,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl ErrorClassification {
|
|
|
|
|
const fn from_kind(kind: crate::impls::ErrorKind) -> Self {
|
|
|
|
|
match kind {
|
|
|
|
|
crate::impls::ErrorKind::NotFound => Self {
|
|
|
|
|
grpc_code: Code::NotFound,
|
|
|
|
|
http_status: StatusCode::NOT_FOUND,
|
|
|
|
|
reason: "NOT_FOUND",
|
|
|
|
|
},
|
|
|
|
|
crate::impls::ErrorKind::Unauthenticated => Self {
|
|
|
|
|
grpc_code: Code::Unauthenticated,
|
|
|
|
|
http_status: StatusCode::UNAUTHORIZED,
|
|
|
|
|
reason: "UNAUTHENTICATED",
|
|
|
|
|
},
|
|
|
|
|
crate::impls::ErrorKind::PermissionDenied => Self {
|
|
|
|
|
grpc_code: Code::PermissionDenied,
|
|
|
|
|
http_status: StatusCode::FORBIDDEN,
|
|
|
|
|
reason: "PERMISSION_DENIED",
|
|
|
|
|
},
|
|
|
|
|
crate::impls::ErrorKind::AlreadyExists => Self {
|
|
|
|
|
grpc_code: Code::AlreadyExists,
|
|
|
|
|
http_status: StatusCode::CONFLICT,
|
|
|
|
|
reason: "ALREADY_EXISTS",
|
|
|
|
|
},
|
|
|
|
|
crate::impls::ErrorKind::Conflict => Self {
|
|
|
|
|
grpc_code: Code::Aborted,
|
|
|
|
|
http_status: StatusCode::CONFLICT,
|
|
|
|
|
reason: "CONFLICT",
|
|
|
|
|
},
|
|
|
|
|
crate::impls::ErrorKind::InvalidArgument => Self {
|
|
|
|
|
grpc_code: Code::InvalidArgument,
|
|
|
|
|
http_status: StatusCode::BAD_REQUEST,
|
|
|
|
|
reason: "INVALID_ARGUMENT",
|
|
|
|
|
},
|
feat(web): add client-aware playback and reproducible UI (#433)
## Summary
- add a versioned playback client profile for browser/runtime protocol,
container, codec, header, proxy, insecure-media, and P2P-loader
capabilities
- generate compatible direct and proxy resources inside each provider
and return a structured incompatibility error when route policy leaves
no viable result
- force provider proxy delivery for browser-forbidden headers, including
affected Bilibili variants, while preserving explicit direct-only
failures
- keep legacy clients compatible and isolate capability-aware playback
cache entries
- serve `/oauth2/callback` through the same Flutter SPA entry point and
keep public discovery anonymous
## Reproducible Web UI
- move Flutter acquisition/build, source configuration, asset manifests,
Brotli/gzip compression, and compile-time embedding into the independent
`synctv-web-ui` crate
- support prebuilt distributions, local projects, and Git sources pinned
to an immutable full commit, with an ignored local override
- fingerprint source, Flutter version, build arguments, dart-defines,
builder generation, output, and compression settings
- validate cached Git repository/revision/commit identity, support
offline cache reuse, and rebuild only when relevant inputs change
- pin and checksum the Flutter SDK in the Docker Web asset stage;
backend-only builds require no Flutter, Git fetch, or network
- serve embedded assets with ETags, compression negotiation, CSP, cache
policy, Origin/CORS handling, and SPA fallback outside API/media routes
## Verification
- `cargo fmt --all -- --check`
- workspace `cargo check`, test/doc-test, and all-targets Clippy with
warnings denied
- provider tests: 200 passed, 1 ignored
- OAuth core/API tests: 72 + 16 passed
- `synctv-web-ui`: 10 passed
- `synctv-api-http --features web-ui`: 12 passed
- default Git source cold build from the pinned App commit, followed by
offline hot-cache reuse in about 1.1 seconds
- `cargo check -p synctv --features web-ui` and `docker buildx build
--check .`
- real Chrome playback, P2P, OAuth/Casdoor, multi-user sync, chat,
media, playlist, upload, settings, and playback-history flows
Companion frontend PR: https://github.com/synctv-org/synctv-app/pull/52
Release pin validation:
https://github.com/synctv-org/synctv-release/pull/9
1 month ago
|
|
|
crate::impls::ErrorKind::FailedPrecondition => Self {
|
|
|
|
|
grpc_code: Code::FailedPrecondition,
|
|
|
|
|
http_status: StatusCode::PRECONDITION_FAILED,
|
|
|
|
|
reason: "CLIENT_INCOMPATIBLE",
|
|
|
|
|
},
|
|
|
|
|
crate::impls::ErrorKind::RateLimited => Self {
|
|
|
|
|
grpc_code: Code::ResourceExhausted,
|
|
|
|
|
http_status: StatusCode::TOO_MANY_REQUESTS,
|
|
|
|
|
reason: "RATE_LIMITED",
|
|
|
|
|
},
|
|
|
|
|
crate::impls::ErrorKind::ServiceUnavailable => Self {
|
|
|
|
|
grpc_code: Code::Unavailable,
|
|
|
|
|
http_status: StatusCode::SERVICE_UNAVAILABLE,
|
|
|
|
|
reason: "SERVICE_UNAVAILABLE",
|
|
|
|
|
},
|
|
|
|
|
crate::impls::ErrorKind::Timeout => Self {
|
|
|
|
|
grpc_code: Code::DeadlineExceeded,
|
|
|
|
|
http_status: StatusCode::GATEWAY_TIMEOUT,
|
|
|
|
|
reason: "TIMEOUT",
|
|
|
|
|
},
|
|
|
|
|
crate::impls::ErrorKind::Internal => Self {
|
|
|
|
|
grpc_code: Code::Internal,
|
|
|
|
|
http_status: StatusCode::INTERNAL_SERVER_ERROR,
|
|
|
|
|
reason: "INTERNAL",
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn http_status_for_error(err: &crate::impls::ApiError, default_status: StatusCode) -> StatusCode {
|
|
|
|
|
match err {
|
|
|
|
|
crate::impls::ApiError::RangeNotSatisfiable { .. } => StatusCode::RANGE_NOT_SATISFIABLE,
|
|
|
|
|
crate::impls::ApiError::PayloadTooLarge(_) => StatusCode::PAYLOAD_TOO_LARGE,
|
|
|
|
|
crate::impls::ApiError::BadGateway(_) => StatusCode::BAD_GATEWAY,
|
|
|
|
|
crate::impls::ApiError::RequestTimeout(_) => StatusCode::REQUEST_TIMEOUT,
|
|
|
|
|
_ => default_status,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn sanitized_api_error(err: &crate::impls::ApiError) -> crate::impls::ApiError {
|
|
|
|
|
match err.classify() {
|
|
|
|
|
crate::impls::ErrorKind::Internal => {
|
|
|
|
|
tracing::error!("API internal error: {}", err.message());
|
|
|
|
|
crate::impls::ApiError::Internal("Internal error".to_string())
|
|
|
|
|
}
|
|
|
|
|
_ => err.clone(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
fn detail_by_type<'a>(
|
|
|
|
|
json: &'a serde_json::Value,
|
|
|
|
|
type_name: &str,
|
|
|
|
|
) -> Option<&'a serde_json::Value> {
|
|
|
|
|
let expected = format!("type.googleapis.com/{type_name}");
|
|
|
|
|
json["details"]
|
|
|
|
|
.as_array()?
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|detail| detail["@type"].as_str() == Some(expected.as_str()))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn protojson_expands_standard_error_details() -> anyhow::Result<()> {
|
|
|
|
|
let error = GoogleApiError::from_api_error(&crate::impls::ApiError::InvalidInput(
|
|
|
|
|
"email is invalid".to_string(),
|
|
|
|
|
))
|
|
|
|
|
.with_request_id(Some("req_test_1"));
|
|
|
|
|
|
|
|
|
|
let bytes = error.to_protojson_bytes()?;
|
|
|
|
|
let json: serde_json::Value = serde_json::from_slice(&bytes)?;
|
|
|
|
|
|
|
|
|
|
assert_eq!(json["code"], tonic::Code::InvalidArgument as i32);
|
|
|
|
|
assert_eq!(json["message"], "email is invalid");
|
|
|
|
|
|
|
|
|
|
let error_info = detail_by_type(&json, "google.rpc.ErrorInfo")
|
|
|
|
|
.ok_or_else(|| anyhow::anyhow!("missing ErrorInfo detail: {json}"))?;
|
|
|
|
|
assert_eq!(error_info["reason"], "INVALID_ARGUMENT");
|
|
|
|
|
assert_eq!(error_info["domain"], ERROR_DOMAIN);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
error_info["metadata"][ERROR_CODE_METADATA_KEY],
|
|
|
|
|
crate::impls::error_codes::INVALID_ARGUMENT.to_string()
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
error_info["metadata"][REQUEST_ID_METADATA_KEY],
|
|
|
|
|
"req_test_1"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let request_info = detail_by_type(&json, "google.rpc.RequestInfo")
|
|
|
|
|
.ok_or_else(|| anyhow::anyhow!("missing RequestInfo detail: {json}"))?;
|
|
|
|
|
assert_eq!(request_info["requestId"], "req_test_1");
|
|
|
|
|
|
|
|
|
|
let bad_request = detail_by_type(&json, "google.rpc.BadRequest")
|
|
|
|
|
.ok_or_else(|| anyhow::anyhow!("missing BadRequest detail: {json}"))?;
|
|
|
|
|
let violations = bad_request["fieldViolations"]
|
|
|
|
|
.as_array()
|
|
|
|
|
.ok_or_else(|| anyhow::anyhow!("BadRequest.fieldViolations is missing"))?;
|
|
|
|
|
assert_eq!(violations[0]["field"], "request");
|
|
|
|
|
assert_eq!(violations[0]["description"], "email is invalid");
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn protojson_expands_validation_field_violations() -> anyhow::Result<()> {
|
|
|
|
|
let error = GoogleApiError::from_api_error(&crate::impls::ApiError::InvalidRequest {
|
|
|
|
|
message: "validation failed".to_string(),
|
|
|
|
|
violations: vec![
|
|
|
|
|
crate::impls::ApiFieldViolation {
|
|
|
|
|
field: "username".to_string(),
|
|
|
|
|
description: "must be at least 3 characters".to_string(),
|
|
|
|
|
},
|
|
|
|
|
crate::impls::ApiFieldViolation {
|
|
|
|
|
field: "email".to_string(),
|
|
|
|
|
description: "must be a valid email".to_string(),
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let bytes = error.to_protojson_bytes()?;
|
|
|
|
|
let json: serde_json::Value = serde_json::from_slice(&bytes)?;
|
|
|
|
|
let bad_request = detail_by_type(&json, "google.rpc.BadRequest")
|
|
|
|
|
.ok_or_else(|| anyhow::anyhow!("missing BadRequest detail: {json}"))?;
|
|
|
|
|
let violations = bad_request["fieldViolations"]
|
|
|
|
|
.as_array()
|
|
|
|
|
.ok_or_else(|| anyhow::anyhow!("BadRequest.fieldViolations is missing"))?;
|
|
|
|
|
|
|
|
|
|
assert_eq!(violations.len(), 2);
|
|
|
|
|
assert_eq!(violations[0]["field"], "username");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
violations[0]["description"],
|
|
|
|
|
"must be at least 3 characters"
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(violations[1]["field"], "email");
|
|
|
|
|
assert_eq!(violations[1]["description"], "must be a valid email");
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn tonic_status_contains_richer_error_details() -> anyhow::Result<()> {
|
|
|
|
|
let error = GoogleApiError::from_api_error(&crate::impls::ApiError::RateLimitedWithRetry {
|
|
|
|
|
message: "too many requests".to_string(),
|
|
|
|
|
retry_after_seconds: 9,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let status = error.to_tonic_status();
|
|
|
|
|
assert_eq!(status.code(), tonic::Code::ResourceExhausted);
|
|
|
|
|
assert_eq!(status.message(), "too many requests");
|
|
|
|
|
|
|
|
|
|
let rpc_status = tonic_types::pb::Status::decode(status.details())?;
|
|
|
|
|
assert_eq!(rpc_status.code, tonic::Code::ResourceExhausted as i32);
|
|
|
|
|
assert!(rpc_status
|
|
|
|
|
.details
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|detail| detail.type_url == "type.googleapis.com/google.rpc.ErrorInfo"));
|
|
|
|
|
assert!(rpc_status
|
|
|
|
|
.details
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|detail| detail.type_url == "type.googleapis.com/google.rpc.RetryInfo"));
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
feat(web): add client-aware playback and reproducible UI (#433)
## Summary
- add a versioned playback client profile for browser/runtime protocol,
container, codec, header, proxy, insecure-media, and P2P-loader
capabilities
- generate compatible direct and proxy resources inside each provider
and return a structured incompatibility error when route policy leaves
no viable result
- force provider proxy delivery for browser-forbidden headers, including
affected Bilibili variants, while preserving explicit direct-only
failures
- keep legacy clients compatible and isolate capability-aware playback
cache entries
- serve `/oauth2/callback` through the same Flutter SPA entry point and
keep public discovery anonymous
## Reproducible Web UI
- move Flutter acquisition/build, source configuration, asset manifests,
Brotli/gzip compression, and compile-time embedding into the independent
`synctv-web-ui` crate
- support prebuilt distributions, local projects, and Git sources pinned
to an immutable full commit, with an ignored local override
- fingerprint source, Flutter version, build arguments, dart-defines,
builder generation, output, and compression settings
- validate cached Git repository/revision/commit identity, support
offline cache reuse, and rebuild only when relevant inputs change
- pin and checksum the Flutter SDK in the Docker Web asset stage;
backend-only builds require no Flutter, Git fetch, or network
- serve embedded assets with ETags, compression negotiation, CSP, cache
policy, Origin/CORS handling, and SPA fallback outside API/media routes
## Verification
- `cargo fmt --all -- --check`
- workspace `cargo check`, test/doc-test, and all-targets Clippy with
warnings denied
- provider tests: 200 passed, 1 ignored
- OAuth core/API tests: 72 + 16 passed
- `synctv-web-ui`: 10 passed
- `synctv-api-http --features web-ui`: 12 passed
- default Git source cold build from the pinned App commit, followed by
offline hot-cache reuse in about 1.1 seconds
- `cargo check -p synctv --features web-ui` and `docker buildx build
--check .`
- real Chrome playback, P2P, OAuth/Casdoor, multi-user sync, chat,
media, playlist, upload, settings, and playback-history flows
Companion frontend PR: https://github.com/synctv-org/synctv-app/pull/52
Release pin validation:
https://github.com/synctv-org/synctv-release/pull/9
1 month ago
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn client_incompatible_uses_failed_precondition_and_capability_metadata() -> anyhow::Result<()>
|
|
|
|
|
{
|
|
|
|
|
let error = GoogleApiError::from_api_error(&crate::impls::ApiError::ClientIncompatible {
|
|
|
|
|
message: "Browser cannot attach the required media headers".to_string(),
|
|
|
|
|
required_capability: Some("custom_http_headers".to_string()),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
assert_eq!(error.grpc_code, tonic::Code::FailedPrecondition);
|
|
|
|
|
assert_eq!(error.http_status, StatusCode::PRECONDITION_FAILED);
|
|
|
|
|
let json: serde_json::Value = serde_json::from_slice(&error.to_protojson_bytes()?)?;
|
|
|
|
|
let error_info = detail_by_type(&json, "google.rpc.ErrorInfo")
|
|
|
|
|
.ok_or_else(|| anyhow::anyhow!("missing ErrorInfo detail: {json}"))?;
|
|
|
|
|
assert_eq!(error_info["reason"], "CLIENT_INCOMPATIBLE");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
error_info["metadata"]["requiredCapability"],
|
|
|
|
|
"custom_http_headers"
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
error_info["metadata"][ERROR_CODE_METADATA_KEY],
|
|
|
|
|
crate::impls::error_codes::FAILED_PRECONDITION.to_string()
|
|
|
|
|
);
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
}
|