axum_sql_viewer/
frontend.rs

1//! Frontend asset serving
2//!
3//! This module handles serving the embedded React SPA with proper caching,
4//! MIME types, and base path injection for routing.
5
6use axum::{
7    body::Body,
8    extract::{Path, State},
9    http::{header, StatusCode},
10    response::Response,
11    routing::get,
12    Router,
13};
14use include_dir::{include_dir, Dir};
15use std::sync::Arc;
16
17// Embed the frontend dist directory at compile time
18static FRONTEND_DISTRIBUTION: Dir = include_dir!("$CARGO_MANIFEST_DIR/frontend/dist");
19
20/// State for frontend serving (stores base path for routing)
21#[derive(Clone)]
22pub struct FrontendState {
23    pub base_path: Arc<String>,
24}
25
26impl FrontendState {
27    /// Create a new frontend state with the given base path
28    pub fn new(base_path: String) -> Self {
29        Self {
30            base_path: Arc::new(base_path),
31        }
32    }
33}
34
35/// Create a router for serving frontend assets
36///
37/// This returns a Router that serves:
38/// - GET / -> index.html with injected <base href> tag
39/// - GET /assets/* -> static assets with long-term caching
40///
41/// # Arguments
42///
43/// * `base_path` - The base URL path where the frontend is mounted (e.g., "/sql-viewer")
44pub fn create_frontend_router(base_path: String) -> Router {
45    let state = FrontendState::new(base_path);
46
47    // Note: Axum 0.8 uses {*wildcard} syntax for wildcard captures
48    Router::new()
49        .route("/", get(serve_index_page))
50        .route("/assets/{*path}", get(serve_static_asset))
51        .with_state(state)
52}
53
54/// Serve the index.html file at the root path
55///
56/// This handler serves the main HTML file and injects a <base href> tag
57/// to ensure all relative asset paths work correctly regardless of the
58/// mount point.
59///
60/// Caching: max-age=3600 (1 hour) for index.html
61async fn serve_index_page(State(state): State<FrontendState>) -> Response {
62    // Try to serve embedded index.html, fallback to placeholder
63    if let Some(file) = FRONTEND_DISTRIBUTION.get_file("index.html") {
64        let mut contents = String::from_utf8_lossy(file.contents()).to_string();
65
66        // Inject base tag with absolute path to make assets work correctly
67        // This ensures assets load from the correct base path
68        if let Some(head_position) = contents.find("<head>") {
69            let insert_position = head_position + "<head>".len();
70            let base_tag = format!("\n    <base href=\"{}/\">", state.base_path);
71            contents.insert_str(insert_position, &base_tag);
72        }
73
74        Response::builder()
75            .status(StatusCode::OK)
76            .header(header::CONTENT_TYPE, "text/html; charset=utf-8")
77            .header(header::CACHE_CONTROL, "public, max-age=3600") // 1 hour cache
78            .body(Body::from(contents))
79            .unwrap()
80    } else {
81        serve_fallback_page()
82    }
83}
84
85/// Serve static assets with proper MIME types
86///
87/// This handler serves files from the embedded assets directory with
88/// appropriate content types and long-term caching headers.
89///
90/// Caching: max-age=31536000 (1 year) for static assets
91async fn serve_static_asset(Path(path): Path<String>) -> Response {
92    // Path already has the wildcard part extracted (e.g., "index-Dm3cA5i_.js")
93    // We need to prepend "assets/" to match the embedded directory structure from Vite
94    let asset_path = format!("assets/{}", path);
95
96    // Try to serve from embedded assets
97    if let Some(file) = FRONTEND_DISTRIBUTION.get_file(&asset_path) {
98        let contents = file.contents();
99        let mime_type = mime_guess::from_path(&asset_path)
100            .first_or_octet_stream()
101            .to_string();
102
103        Response::builder()
104            .status(StatusCode::OK)
105            .header(header::CONTENT_TYPE, mime_type)
106            .header(header::CACHE_CONTROL, "public, max-age=31536000, immutable") // 1 year cache
107            .body(Body::from(contents))
108            .unwrap()
109    } else {
110        Response::builder()
111            .status(StatusCode::NOT_FOUND)
112            .header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
113            .body(Body::from(format!("Asset not found: {}", asset_path)))
114            .unwrap()
115    }
116}
117
118/// Fallback handler for when frontend assets are not built yet
119///
120/// This page is shown when the frontend/dist directory is not present
121/// or index.html is not found. It provides clear instructions on how
122/// to build the frontend.
123fn serve_fallback_page() -> Response {
124    let html = r#"<!DOCTYPE html>
125<html lang="en">
126<head>
127    <meta charset="UTF-8">
128    <meta name="viewport" content="width=device-width, initial-scale=1.0">
129    <title>axum-sql-viewer - Frontend Not Built</title>
130    <style>
131        body {
132            font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
133            max-width: 800px;
134            margin: 100px auto;
135            padding: 20px;
136            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
137            min-height: 100vh;
138        }
139        .container {
140            background: white;
141            padding: 40px;
142            border-radius: 12px;
143            box-shadow: 0 10px 40px rgba(0,0,0,0.2);
144        }
145        h1 {
146            color: #333;
147            margin-top: 0;
148            font-size: 2em;
149        }
150        h2 {
151            color: #555;
152            font-size: 1.3em;
153            margin-top: 30px;
154            border-bottom: 2px solid #667eea;
155            padding-bottom: 10px;
156        }
157        code {
158            background: #f5f5f5;
159            padding: 3px 8px;
160            border-radius: 4px;
161            font-family: 'Courier New', Consolas, monospace;
162            font-size: 0.9em;
163            color: #e83e8c;
164        }
165        pre {
166            background: #2d2d2d;
167            color: #f8f8f2;
168            padding: 20px;
169            border-radius: 6px;
170            overflow-x: auto;
171            font-family: 'Courier New', Consolas, monospace;
172            line-height: 1.5;
173        }
174        .warning {
175            background: #fff3cd;
176            border-left: 4px solid #ffc107;
177            padding: 15px 20px;
178            margin: 20px 0;
179            border-radius: 4px;
180        }
181        .warning strong {
182            color: #856404;
183            display: block;
184            margin-bottom: 8px;
185            font-size: 1.1em;
186        }
187        .warning p {
188            color: #856404;
189            margin: 0;
190        }
191        ul {
192            line-height: 1.8;
193        }
194        li code {
195            background: #e9ecef;
196            color: #495057;
197        }
198        .info {
199            background: #d1ecf1;
200            border-left: 4px solid #17a2b8;
201            padding: 15px 20px;
202            margin: 20px 0;
203            border-radius: 4px;
204            color: #0c5460;
205        }
206    </style>
207</head>
208<body>
209    <div class="container">
210        <h1>🔍 axum-sql-viewer</h1>
211
212        <div class="warning">
213            <strong>⚠️ Frontend Not Built</strong>
214            <p>The frontend has not been built yet. To use axum-sql-viewer, you need to build the React frontend.</p>
215        </div>
216
217        <h2>📦 Development Setup</h2>
218        <p>To build the frontend during development:</p>
219        <pre>cd axum-sql-viewer/frontend
220pnpm install
221pnpm build</pre>
222
223        <h2>🚀 Using Pre-built Package</h2>
224        <div class="info">
225            <p>If you're using the crate from crates.io, the frontend should already be included. If you see this message, please report it as a bug on GitHub.</p>
226        </div>
227
228        <h2>🔌 API Endpoints</h2>
229        <p>The REST API is still available for direct access:</p>
230        <ul>
231            <li><code>GET /api/tables</code> - List all tables in the database</li>
232            <li><code>GET /api/tables/:name</code> - Get table schema information</li>
233            <li><code>GET /api/tables/:name/rows</code> - Fetch rows with pagination and filtering</li>
234            <li><code>GET /api/tables/:name/count</code> - Get total row count</li>
235            <li><code>POST /api/query</code> - Execute raw SQL queries</li>
236        </ul>
237
238        <h2>📚 Documentation</h2>
239        <p>For more information, visit:</p>
240        <ul>
241            <li><a href="https://docs.rs/axum-sql-viewer" target="_blank">Documentation on docs.rs</a></li>
242            <li><a href="https://github.com/firstdorsal/axum-sql-viewer" target="_blank">GitHub Repository</a></li>
243        </ul>
244
245        <h2>⚠️ Security Warning</h2>
246        <div class="warning">
247            <strong>Development Tool Only</strong>
248            <p>This tool exposes your entire database and should NEVER be used in production or on public networks. It has no authentication or authorization built in.</p>
249        </div>
250    </div>
251</body>
252</html>
253"#;
254
255    Response::builder()
256        .status(StatusCode::OK)
257        .header(header::CONTENT_TYPE, "text/html; charset=utf-8")
258        .header(header::CACHE_CONTROL, "no-cache") // Don't cache fallback page
259        .body(Body::from(html))
260        .unwrap()
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    #[test]
268    fn test_frontend_state_creation() {
269        let state = FrontendState::new("/sql-viewer".to_string());
270        assert_eq!(*state.base_path, "/sql-viewer");
271    }
272
273    #[test]
274    fn test_mime_type_guessing() {
275        use mime_guess::from_path;
276
277        // JavaScript files
278        let javascript_mime = from_path("application.js").first_or_octet_stream();
279        assert_eq!(javascript_mime.as_ref(), "text/javascript");
280
281        // CSS files
282        let css_mime = from_path("styles.css").first_or_octet_stream();
283        assert_eq!(css_mime.as_ref(), "text/css");
284
285        // HTML files
286        let html_mime = from_path("index.html").first_or_octet_stream();
287        assert_eq!(html_mime.as_ref(), "text/html");
288
289        // Image files
290        let png_mime = from_path("image.png").first_or_octet_stream();
291        assert_eq!(png_mime.as_ref(), "image/png");
292
293        let svg_mime = from_path("icon.svg").first_or_octet_stream();
294        assert_eq!(svg_mime.as_ref(), "image/svg+xml");
295
296        // Font files
297        let woff2_mime = from_path("font.woff2").first_or_octet_stream();
298        assert_eq!(woff2_mime.as_ref(), "font/woff2");
299    }
300
301    #[test]
302    fn test_fallback_page_has_content() {
303        let response = serve_fallback_page();
304        assert_eq!(response.status(), StatusCode::OK);
305
306        // Verify content-type header
307        let content_type = response.headers().get(header::CONTENT_TYPE);
308        assert!(content_type.is_some());
309        assert_eq!(content_type.unwrap(), "text/html; charset=utf-8");
310    }
311
312    #[test]
313    fn test_router_creation() {
314        let router = create_frontend_router("/sql-viewer".to_string());
315        // Just verify it compiles and can be created
316        drop(router);
317    }
318}