mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
refactor(svc): tidy the rust services and build tooling (#2735)
This commit is contained in:
@@ -10,6 +10,7 @@ anyhow = "1.0.104"
|
||||
axum = { version = "0.8.9", features = ["macros"] }
|
||||
base64 = "0.22"
|
||||
fluxer_common = { path = "../fluxer_common" }
|
||||
futures-util = { version = "0.3.32", default-features = false, features = ["std"] }
|
||||
hex = "0.4"
|
||||
rand = "0.10"
|
||||
reqwest = { version = "0.13.4", default-features = false, features = ["json", "rustls", "stream", "gzip", "brotli", "deflate"] }
|
||||
|
||||
@@ -132,7 +132,7 @@ pub fn inject_bootstrap(
|
||||
let media = media_endpoint.trim_end_matches('/');
|
||||
|
||||
let nonced = html.replace("{{CSP_NONCE_PLACEHOLDER}}", nonce);
|
||||
let nonced = apply_static_preconnect(&nonced, static_cdn);
|
||||
let nonced = apply_static_preconnect(nonced, static_cdn);
|
||||
let nonced = nonced.replace("{{STATIC_CDN_ENDPOINT}}", static_cdn);
|
||||
let nonced = apply_media_preconnect(&nonced, media, static_cdn);
|
||||
|
||||
@@ -143,20 +143,14 @@ pub fn inject_bootstrap(
|
||||
return nonced.replace("{{FLUXER_BOOTSTRAP}}", script_tag);
|
||||
}
|
||||
|
||||
if let Some(pos) = nonced.find("<head>") {
|
||||
let insert_at = pos + "<head>".len();
|
||||
let mut result = String::with_capacity(nonced.len() + script_tag.len() + 3);
|
||||
result.push_str(&nonced[..insert_at]);
|
||||
result.push_str("\n\t\t");
|
||||
result.push_str(script_tag);
|
||||
result.push_str(&nonced[insert_at..]);
|
||||
return result;
|
||||
}
|
||||
|
||||
if let Some(pos) = nonced.find("<head ")
|
||||
&& let Some(close) = nonced[pos..].find('>')
|
||||
{
|
||||
let insert_at = pos + close + 1;
|
||||
let insert_at = nonced
|
||||
.find("<head>")
|
||||
.map(|pos| pos + "<head>".len())
|
||||
.or_else(|| {
|
||||
let pos = nonced.find("<head ")?;
|
||||
nonced[pos..].find('>').map(|close| pos + close + 1)
|
||||
});
|
||||
if let Some(insert_at) = insert_at {
|
||||
let mut result = String::with_capacity(nonced.len() + script_tag.len() + 3);
|
||||
result.push_str(&nonced[..insert_at]);
|
||||
result.push_str("\n\t\t");
|
||||
@@ -168,15 +162,14 @@ pub fn inject_bootstrap(
|
||||
nonced
|
||||
}
|
||||
|
||||
fn apply_static_preconnect(html: &str, static_cdn: &str) -> String {
|
||||
fn apply_static_preconnect(mut html: String, static_cdn: &str) -> String {
|
||||
if !static_cdn.is_empty() {
|
||||
return html.to_owned();
|
||||
return html;
|
||||
}
|
||||
let mut stripped = html.to_owned();
|
||||
for tag in STATIC_PRECONNECT_TAGS {
|
||||
stripped = stripped.replace(&format!("{tag}\n"), "").replace(tag, "");
|
||||
html = html.replace(&format!("{tag}\n"), "").replace(tag, "");
|
||||
}
|
||||
stripped
|
||||
html
|
||||
}
|
||||
|
||||
fn apply_media_preconnect(html: &str, media: &str, static_cdn: &str) -> String {
|
||||
|
||||
@@ -309,30 +309,20 @@ impl fmt::Display for CspReportUri {
|
||||
}
|
||||
}
|
||||
|
||||
fn warn_invalid(error: InvalidAppProxyEnvironmentError) {
|
||||
fn warn_invalid(error: &InvalidAppProxyEnvironmentError) {
|
||||
tracing::warn!(%error, "ignoring invalid app proxy environment value");
|
||||
}
|
||||
|
||||
fn parse_optional_http_url(name: &'static str, value: Option<String>) -> Option<HttpUrl> {
|
||||
let value = value?;
|
||||
match HttpUrl::parse(name, &value) {
|
||||
Ok(url) => Some(url),
|
||||
Err(error) => {
|
||||
warn_invalid(error);
|
||||
None
|
||||
}
|
||||
}
|
||||
HttpUrl::parse(name, &value).inspect_err(warn_invalid).ok()
|
||||
}
|
||||
|
||||
fn parse_optional_http_endpoint(name: &'static str, value: Option<String>) -> Option<HttpEndpoint> {
|
||||
let value = value?;
|
||||
match HttpEndpoint::parse(name, &value) {
|
||||
Ok(endpoint) => Some(endpoint),
|
||||
Err(error) => {
|
||||
warn_invalid(error);
|
||||
None
|
||||
}
|
||||
}
|
||||
HttpEndpoint::parse(name, &value)
|
||||
.inspect_err(warn_invalid)
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn parse_env_or_warn<T: std::str::FromStr>(name: &str, raw: &str, default: T) -> T {
|
||||
@@ -434,25 +424,19 @@ fn read_csp_sources(name: &'static str) -> Vec<CspSource> {
|
||||
.split([',', ' ', '\t', '\n'])
|
||||
.map(str::trim)
|
||||
.filter(|source| !source.is_empty())
|
||||
.filter_map(|source| match CspSource::parse(name, source) {
|
||||
Ok(source) => Some(source),
|
||||
Err(error) => {
|
||||
warn_invalid(error);
|
||||
None
|
||||
}
|
||||
.filter_map(|source| {
|
||||
CspSource::parse(name, source)
|
||||
.inspect_err(warn_invalid)
|
||||
.ok()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn read_csp_report_uri(name: &'static str) -> Option<CspReportUri> {
|
||||
let value = cfg::non_empty_env(name)?;
|
||||
match CspReportUri::parse(name, &value) {
|
||||
Ok(report_uri) => Some(report_uri),
|
||||
Err(error) => {
|
||||
warn_invalid(error);
|
||||
None
|
||||
}
|
||||
}
|
||||
CspReportUri::parse(name, &value)
|
||||
.inspect_err(warn_invalid)
|
||||
.ok()
|
||||
}
|
||||
|
||||
impl AppProxyConfig {
|
||||
@@ -474,13 +458,10 @@ impl AppProxyConfig {
|
||||
);
|
||||
let s3_uploads_bucket = cfg::read_env("FLUXER_S3_BUCKET_UPLOADS", "fluxer-uploads");
|
||||
let s3_uploads_endpoint = s3_public_endpoint.as_ref().and_then(|endpoint| {
|
||||
match endpoint.with_host_prefix("FLUXER_S3_BUCKET_UPLOADS", s3_uploads_bucket.trim()) {
|
||||
Ok(endpoint) => Some(endpoint),
|
||||
Err(error) => {
|
||||
warn_invalid(error);
|
||||
None
|
||||
}
|
||||
}
|
||||
endpoint
|
||||
.with_host_prefix("FLUXER_S3_BUCKET_UPLOADS", s3_uploads_bucket.trim())
|
||||
.inspect_err(warn_invalid)
|
||||
.ok()
|
||||
});
|
||||
|
||||
Self {
|
||||
@@ -499,7 +480,7 @@ impl AppProxyConfig {
|
||||
"FLUXER_STATIC_CDN_ENDPOINT",
|
||||
cfg::non_empty_env("FLUXER_STATIC_CDN_ENDPOINT"),
|
||||
),
|
||||
s3_public_endpoint: s3_public_endpoint.clone(),
|
||||
s3_public_endpoint,
|
||||
s3_uploads_endpoint,
|
||||
discovery_upstream_url: resolve_discovery_upstream_url_from_env(),
|
||||
discovery_refresh_interval_ms: parse_env_or_warn(
|
||||
|
||||
@@ -287,15 +287,11 @@ fn extend_runtime_s3_sources(target: &mut Vec<String>, runtime_sources: &Runtime
|
||||
}
|
||||
|
||||
fn extend_from(target: &mut Vec<String>, extra: &[CspSource], defaults: &[&str]) {
|
||||
for source in defaults {
|
||||
if target.iter().any(|existing| existing == source) {
|
||||
continue;
|
||||
}
|
||||
target.push((*source).to_owned());
|
||||
}
|
||||
|
||||
for source in extra {
|
||||
let source = source.as_str();
|
||||
for source in defaults
|
||||
.iter()
|
||||
.copied()
|
||||
.chain(extra.iter().map(CspSource::as_str))
|
||||
{
|
||||
if target.iter().any(|existing| existing == source) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -2,47 +2,33 @@
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
http::{HeaderValue, header},
|
||||
http::header,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
pub async fn assetlinks() -> Response {
|
||||
let body = json!([
|
||||
{
|
||||
let body = ["com.fluxer", "com.fluxer.canary"].map(|package_name| {
|
||||
json!({
|
||||
"relation": [
|
||||
"delegate_permission/common.handle_all_urls",
|
||||
"delegate_permission/common.get_login_creds"
|
||||
],
|
||||
"target": {
|
||||
"namespace": "android_app",
|
||||
"package_name": "com.fluxer",
|
||||
"package_name": package_name,
|
||||
"sha256_cert_fingerprints": [
|
||||
"91:E4:98:E1:B8:A6:C8:BA:99:41:5E:DB:29:78:29:6B:6C:58:BA:A5:E2:D2:A6:49:CE:C6:2D:A7:A8:29:C7:BC"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"relation": [
|
||||
"delegate_permission/common.handle_all_urls",
|
||||
"delegate_permission/common.get_login_creds"
|
||||
],
|
||||
"target": {
|
||||
"namespace": "android_app",
|
||||
"package_name": "com.fluxer.canary",
|
||||
"sha256_cert_fingerprints": [
|
||||
"91:E4:98:E1:B8:A6:C8:BA:99:41:5E:DB:29:78:29:6B:6C:58:BA:A5:E2:D2:A6:49:CE:C6:2D:A7:A8:29:C7:BC"
|
||||
]
|
||||
}
|
||||
}
|
||||
]);
|
||||
})
|
||||
});
|
||||
|
||||
let mut response = Json(body).into_response();
|
||||
response.headers_mut().insert(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("public, max-age=1800"),
|
||||
);
|
||||
response
|
||||
(
|
||||
[(header::CACHE_CONTROL, "public, max-age=1800")],
|
||||
Json(body),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
http::{HeaderValue, header},
|
||||
http::header,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde_json::json;
|
||||
@@ -19,10 +19,9 @@ pub async fn apple_app_site_association() -> Response {
|
||||
}
|
||||
});
|
||||
|
||||
let mut response = Json(body).into_response();
|
||||
response.headers_mut().insert(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("public, max-age=1800"),
|
||||
);
|
||||
response
|
||||
(
|
||||
[(header::CACHE_CONTROL, "public, max-age=1800")],
|
||||
Json(body),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
@@ -9,9 +9,7 @@ use axum::{
|
||||
};
|
||||
use std::path::{Path as FsPath, PathBuf};
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncWriteExt, DuplexStream};
|
||||
use tokio::sync::{OwnedSemaphorePermit, TryAcquireError};
|
||||
use tokio_util::io::ReaderStream;
|
||||
|
||||
use super::file_stream::stream_file;
|
||||
use super::spa_static::{CORS_ALLOW_ANY_VALUE, asset_cache_control, guess_mime, is_font_mime};
|
||||
@@ -19,7 +17,6 @@ use super::spa_static::{CORS_ALLOW_ANY_VALUE, asset_cache_control, guess_mime, i
|
||||
const ASSET_REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const PRECOMPRESSED_VARIANTS: &[(&str, &str)] = &[("br", "br"), ("gzip", "gz")];
|
||||
const MAX_ASSET_SIZE_BYTES: u64 = 100 * 1024 * 1024;
|
||||
const UPSTREAM_ASSET_PUMP_BUFFER_BYTES: usize = 64 * 1024;
|
||||
const UPSTREAM_FAILURE_CACHE_CONTROL: &str = "no-store";
|
||||
const UPSTREAM_FAILURE_STRIPPED_HEADERS: &[&str] = &[
|
||||
"cdn-cache-control",
|
||||
@@ -148,38 +145,59 @@ pub async fn proxy_assets(
|
||||
response_headers.insert(header::CONTENT_SECURITY_POLICY, state.csp.asset_header());
|
||||
response_headers.remove("content-security-policy-report-only");
|
||||
|
||||
let body = Body::from_stream(upstream_asset_body(upstream_response, upstream_slot));
|
||||
let body = upstream_asset_body(upstream_response, upstream_slot);
|
||||
let mut response = Response::new(body);
|
||||
*response.status_mut() = status;
|
||||
*response.headers_mut() = response_headers;
|
||||
response
|
||||
}
|
||||
|
||||
struct UpstreamAssetReadState {
|
||||
response: reqwest::Response,
|
||||
_permit: OwnedSemaphorePermit,
|
||||
remaining_bytes: u64,
|
||||
}
|
||||
|
||||
fn upstream_asset_body(
|
||||
mut upstream_response: reqwest::Response,
|
||||
upstream_response: reqwest::Response,
|
||||
upstream_slot: OwnedSemaphorePermit,
|
||||
) -> ReaderStream<DuplexStream> {
|
||||
let (writer, reader) = tokio::io::duplex(UPSTREAM_ASSET_PUMP_BUFFER_BYTES);
|
||||
tokio::spawn(async move {
|
||||
let _upstream_slot = upstream_slot;
|
||||
let mut writer = writer;
|
||||
loop {
|
||||
match upstream_response.chunk().await {
|
||||
Ok(Some(chunk)) => {
|
||||
if writer.write_all(&chunk).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(err) => {
|
||||
) -> Body {
|
||||
let state = UpstreamAssetReadState {
|
||||
response: upstream_response,
|
||||
_permit: upstream_slot,
|
||||
remaining_bytes: MAX_ASSET_SIZE_BYTES,
|
||||
};
|
||||
Body::from_stream(futures_util::stream::try_unfold(
|
||||
state,
|
||||
|mut state| async move {
|
||||
let Some(chunk) = state
|
||||
.response
|
||||
.chunk()
|
||||
.await
|
||||
.inspect_err(|err| {
|
||||
tracing::warn!(%err, "upstream asset body ended early");
|
||||
return;
|
||||
}
|
||||
})
|
||||
.map_err(axum::Error::new)?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let chunk_bytes = chunk.len() as u64;
|
||||
if chunk_bytes > state.remaining_bytes {
|
||||
tracing::warn!(
|
||||
maximum_bytes = MAX_ASSET_SIZE_BYTES,
|
||||
remaining_bytes = state.remaining_bytes,
|
||||
chunk_bytes,
|
||||
"upstream asset body exceeds size cap"
|
||||
);
|
||||
return Err(axum::Error::new(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("upstream asset body exceeds {MAX_ASSET_SIZE_BYTES} bytes"),
|
||||
)));
|
||||
}
|
||||
}
|
||||
let _ = writer.shutdown().await;
|
||||
});
|
||||
ReaderStream::new(reader)
|
||||
state.remaining_bytes -= chunk_bytes;
|
||||
Ok(Some((chunk, state)))
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) async fn serve_local_asset(
|
||||
|
||||
@@ -13,7 +13,7 @@ use axum::{
|
||||
Router,
|
||||
extract::Request,
|
||||
http::{HeaderName, HeaderValue, header},
|
||||
middleware::{Next, from_fn, from_fn_with_state},
|
||||
middleware::{Next, from_fn},
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
};
|
||||
@@ -56,10 +56,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.fallback(get(spa_index::spa_catch_all))
|
||||
.layer(from_fn(request_id_middleware))
|
||||
.layer(from_fn(cache_headers_middleware))
|
||||
.layer(from_fn_with_state(
|
||||
state.clone(),
|
||||
security_headers_middleware,
|
||||
))
|
||||
.layer(from_fn(security_headers_middleware))
|
||||
.layer(
|
||||
CompressionLayer::new()
|
||||
.compress_when(DefaultPredicate::new().and(NotForContentType::const_new("font/"))),
|
||||
@@ -68,14 +65,13 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
async fn security_headers_middleware(
|
||||
axum::extract::State(_state): axum::extract::State<AppState>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
async fn security_headers_middleware(request: Request, next: Next) -> Response {
|
||||
let mut response = next.run(request).await;
|
||||
let headers = response.headers_mut();
|
||||
set_security_headers(response.headers_mut());
|
||||
response
|
||||
}
|
||||
|
||||
fn set_security_headers(headers: &mut axum::http::HeaderMap) {
|
||||
set_static_header(
|
||||
headers,
|
||||
header::STRICT_TRANSPORT_SECURITY,
|
||||
@@ -89,8 +85,6 @@ async fn security_headers_middleware(
|
||||
HeaderName::from_static("permissions-policy"),
|
||||
PERMISSIONS_POLICY_VALUE,
|
||||
);
|
||||
|
||||
response
|
||||
}
|
||||
|
||||
async fn cache_headers_middleware(request: Request, next: Next) -> Response {
|
||||
|
||||
@@ -410,19 +410,7 @@ fn build_spa_response(
|
||||
} else {
|
||||
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
|
||||
}
|
||||
headers.insert(
|
||||
header::STRICT_TRANSPORT_SECURITY,
|
||||
HeaderValue::from_static("max-age=31536000; includeSubDomains; preload"),
|
||||
);
|
||||
headers.insert(
|
||||
header::X_CONTENT_TYPE_OPTIONS,
|
||||
HeaderValue::from_static("nosniff"),
|
||||
);
|
||||
headers.insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
|
||||
headers.insert(
|
||||
header::REFERRER_POLICY,
|
||||
HeaderValue::from_static("strict-origin-when-cross-origin"),
|
||||
);
|
||||
super::set_security_headers(headers);
|
||||
headers.insert(
|
||||
axum::http::HeaderName::from_static("accept-ch"),
|
||||
HeaderValue::from_static(ACCEPT_CH_VALUE),
|
||||
@@ -431,11 +419,6 @@ fn build_spa_response(
|
||||
axum::http::HeaderName::from_static("critical-ch"),
|
||||
HeaderValue::from_static(CRITICAL_CH_VALUE),
|
||||
);
|
||||
headers.insert(
|
||||
axum::http::HeaderName::from_static("permissions-policy"),
|
||||
HeaderValue::from_static(super::PERMISSIONS_POLICY_VALUE),
|
||||
);
|
||||
|
||||
#[cfg(feature = "time-freeze")]
|
||||
{
|
||||
if let Some(tf) = time_freeze_header
|
||||
|
||||
Reference in New Issue
Block a user