embystream 0.0.36

Another Emby streaming application (frontend/backend separation) written in Rust.
Documentation
use std::{borrow::Cow, fs, path::Path};

use axum::{
    extract::{OriginalUri, State},
    http::{HeaderValue, StatusCode, header},
    response::IntoResponse,
};

use super::api::WebAppState;

pub const FRONTEND_DIST_DIR: &str = "web/dist";

include!(concat!(env!("OUT_DIR"), "/generated_web_assets.rs"));

pub async fn serve_frontend(
    State(_state): State<WebAppState>,
    OriginalUri(uri): OriginalUri,
) -> impl IntoResponse {
    let requested = normalize_request_path(uri.path());
    let is_spa_route = !requested.contains('.');

    let resolved =
        embedded_or_filesystem_asset(requested.as_ref()).or_else(|| {
            if is_spa_route {
                embedded_or_filesystem_asset("index.html")
            } else {
                None
            }
        });

    let Some((bytes, content_type)) = resolved else {
        return (
            StatusCode::NOT_FOUND,
            security_headers("text/plain; charset=utf-8", false, true),
            Cow::Borrowed("Not Found"),
        )
            .into_response();
    };

    (
        StatusCode::OK,
        security_headers(
            content_type,
            content_type.starts_with("text/html"),
            false,
        ),
        bytes,
    )
        .into_response()
}

fn security_headers(
    content_type: &'static str,
    is_html: bool,
    no_store: bool,
) -> [(header::HeaderName, HeaderValue); 6] {
    let cache_control = if no_store {
        HeaderValue::from_static("no-store")
    } else {
        HeaderValue::from_static("no-cache")
    };

    let csp = if is_html {
        HeaderValue::from_static(
            "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self' 'unsafe-eval'; style-src 'self'; img-src 'self' https: data:; font-src 'self' data:; connect-src 'self' https://api.iconify.design https://api.unisvg.com https://api.simplesvg.com; worker-src 'self'",
        )
    } else {
        HeaderValue::from_static("default-src 'none'; frame-ancestors 'none'")
    };

    [
        (header::CONTENT_TYPE, HeaderValue::from_static(content_type)),
        (header::CACHE_CONTROL, cache_control),
        (header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY")),
        (
            header::X_CONTENT_TYPE_OPTIONS,
            HeaderValue::from_static("nosniff"),
        ),
        (
            header::REFERRER_POLICY,
            HeaderValue::from_static("strict-origin-when-cross-origin"),
        ),
        (header::CONTENT_SECURITY_POLICY, csp),
    ]
}

fn normalize_request_path(path: &str) -> Cow<'_, str> {
    let trimmed = path.trim_start_matches('/');
    if trimmed.is_empty() {
        Cow::Borrowed("index.html")
    } else {
        Cow::Owned(trimmed.to_string())
    }
}

fn embedded_or_filesystem_asset(path: &str) -> Option<(Vec<u8>, &'static str)> {
    if let Some(asset) = embedded_asset(path) {
        return Some((asset.bytes.to_vec(), asset.content_type));
    }

    filesystem_asset(path)
}

fn filesystem_asset(path: &str) -> Option<(Vec<u8>, &'static str)> {
    let file_path = Path::new(FRONTEND_DIST_DIR).join(path);
    let bytes = fs::read(file_path).ok()?;
    Some((bytes, content_type_for(path)))
}

fn content_type_for(path: &str) -> &'static str {
    match Path::new(path).extension().and_then(|value| value.to_str()) {
        Some("css") => "text/css; charset=utf-8",
        Some("html") => "text/html; charset=utf-8",
        Some("js") => "application/javascript; charset=utf-8",
        Some("json") => "application/json; charset=utf-8",
        Some("map") => "application/json; charset=utf-8",
        Some("png") => "image/png",
        Some("svg") => "image/svg+xml",
        Some("txt") => "text/plain; charset=utf-8",
        _ => "application/octet-stream",
    }
}