Serve vendor files (htmx) from MinIO instead of local filesystem

- Added serve_vendor_file() to serve from {bot}.gblib/vendor/ in MinIO
- Added /js/vendor/* route to app_server
- Removed local ServeDir for /js/vendor from main.rs
- Added ensure_vendor_files_in_minio() to upload htmx.min.js on startup
- Uses include_bytes! to embed htmx.min.js in binary
This commit is contained in:
Rodrigo Rodriguez (Pragmatismo) 2026-01-02 18:26:34 -03:00
parent bbbb9e190f
commit a43aea3320
2 changed files with 88 additions and 2 deletions

View file

@ -13,6 +13,69 @@ use std::sync::Arc;
/// Rewrite CDN URLs to local paths for HTMX and other vendor libraries /// Rewrite CDN URLs to local paths for HTMX and other vendor libraries
/// This ensures old apps with CDN references still work with local files /// This ensures old apps with CDN references still work with local files
#[derive(Debug, serde::Deserialize)]
pub struct VendorFilePath {
pub file_path: String,
}
pub async fn serve_vendor_file(
State(state): State<Arc<AppState>>,
Path(params): Path<VendorFilePath>,
) -> Response {
let file_path = sanitize_file_path(&params.file_path);
if file_path.is_empty() {
return (StatusCode::BAD_REQUEST, "Invalid path").into_response();
}
let bot_name = state.bucket_name
.trim_end_matches(".gbai")
.to_string();
let sanitized_bot_name = bot_name.to_lowercase().replace(' ', "-").replace('_', "-");
let bucket = format!("{}.gbai", sanitized_bot_name);
let key = format!("{}.gblib/vendor/{}", sanitized_bot_name, file_path);
info!("Serving vendor file from MinIO: bucket={}, key={}", bucket, key);
if let Some(ref drive) = state.drive {
match drive
.get_object()
.bucket(&bucket)
.key(&key)
.send()
.await
{
Ok(response) => {
match response.body.collect().await {
Ok(body) => {
let content = body.into_bytes();
let content_type = get_content_type(&file_path);
return Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, content_type)
.header(header::CACHE_CONTROL, "public, max-age=86400")
.body(Body::from(content.to_vec()))
.unwrap_or_else(|_| {
(StatusCode::INTERNAL_SERVER_ERROR, "Failed to build response")
.into_response()
});
}
Err(e) => {
error!("Failed to read MinIO response body: {}", e);
}
}
}
Err(e) => {
warn!("MinIO get_object failed for {}/{}: {}", bucket, key, e);
}
}
}
(StatusCode::NOT_FOUND, "Vendor file not found").into_response()
}
fn rewrite_cdn_urls(html: &str) -> String { fn rewrite_cdn_urls(html: &str) -> String {
html html
// HTMX from various CDNs // HTMX from various CDNs
@ -31,6 +94,8 @@ fn rewrite_cdn_urls(html: &str) -> String {
pub fn configure_app_server_routes() -> Router<Arc<AppState>> { pub fn configure_app_server_routes() -> Router<Arc<AppState>> {
Router::new() Router::new()
// Serve shared vendor files from MinIO: /js/vendor/*
.route("/js/vendor/*file_path", get(serve_vendor_file))
// Serve app files: /apps/{app_name}/* (clean URLs) // Serve app files: /apps/{app_name}/* (clean URLs)
.route("/apps/:app_name", get(serve_app_index)) .route("/apps/:app_name", get(serve_app_index))
.route("/apps/:app_name/", get(serve_app_index)) .route("/apps/:app_name/", get(serve_app_index))

View file

@ -23,6 +23,27 @@ use std::sync::Arc;
use tower_http::services::ServeDir; use tower_http::services::ServeDir;
use tower_http::trace::TraceLayer; use tower_http::trace::TraceLayer;
async fn ensure_vendor_files_in_minio(drive: &aws_sdk_s3::Client) {
use aws_sdk_s3::primitives::ByteStream;
let htmx_content = include_bytes!("../../botserver-stack/static/js/vendor/htmx.min.js");
let bucket = "default.gbai";
let key = "default.gblib/vendor/htmx.min.js";
match drive
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(htmx_content))
.content_type("application/javascript")
.send()
.await
{
Ok(_) => info!("Uploaded vendor file to MinIO: s3://{}/{}", bucket, key),
Err(e) => warn!("Failed to upload vendor file to MinIO: {}", e),
}
}
use botserver::security::{ use botserver::security::{
auth_middleware, create_cors_layer, create_rate_limit_layer, create_security_headers_layer, auth_middleware, create_cors_layer, create_rate_limit_layer, create_security_headers_layer,
request_id_middleware, security_headers_middleware, set_cors_allowed_origins, request_id_middleware, security_headers_middleware, set_cors_allowed_origins,
@ -314,8 +335,6 @@ async fn run_axum_server(
auth_config.clone(), auth_config.clone(),
auth_middleware, auth_middleware,
)) ))
// Vendor JS files (htmx, etc.) served locally - no CDN
.nest_service("/js/vendor", ServeDir::new("./botserver-stack/static/js/vendor"))
// Static files fallback for legacy /apps/* paths // Static files fallback for legacy /apps/* paths
.nest_service("/static", ServeDir::new(&site_path)) .nest_service("/static", ServeDir::new(&site_path))
// Security middleware stack (order matters - first added is outermost) // Security middleware stack (order matters - first added is outermost)
@ -730,6 +749,8 @@ async fn main() -> std::io::Result<()> {
.await .await
.map_err(|e| std::io::Error::other(format!("Failed to initialize Drive: {}", e)))?; .map_err(|e| std::io::Error::other(format!("Failed to initialize Drive: {}", e)))?;
ensure_vendor_files_in_minio(&drive).await;
let session_manager = Arc::new(tokio::sync::Mutex::new(session::SessionManager::new( let session_manager = Arc::new(tokio::sync::Mutex::new(session::SessionManager::new(
pool.get().map_err(|e| std::io::Error::other(format!("Failed to get database connection: {}", e)))?, pool.get().map_err(|e| std::io::Error::other(format!("Failed to get database connection: {}", e)))?,
redis_client.clone(), redis_client.clone(),