ctoolbox/io/webui/controllers/
base.rs

1//! Controller for static assets and other routes shared between the app and the
2//! general web site.
3use std::borrow::Cow;
4
5use anyhow::anyhow;
6use async_trait::async_trait;
7use axum::{
8    body::Body,
9    extract::State,
10    http::{HeaderValue, Uri, header},
11    response::{IntoResponse, Redirect, Response},
12};
13use axum_extra::response::file_stream::FileStream;
14use mime_guess;
15use tokio::fs::File;
16use tokio_util::io::ReaderStream;
17
18use crate::storage::get_asset;
19use crate::storage::pc_settings::DEFAULT_SERVER_URL;
20use crate::utilities::build_info;
21use crate::{
22    io::webui::{AppState, RequestState, error_400, error_404, render_view},
23    json_value,
24};
25
26/// Build a JSON redirect response with X-CollectiveToolbox-IsJsRedirect header.
27fn json_redirect_response(location: &str) -> Response {
28    let mut resp = Response::new(Body::from(
29        serde_json::json!({ "url": location }).to_string(),
30    ));
31    resp.headers_mut().insert(
32        "X-CollectiveToolbox-IsJsRedirect",
33        HeaderValue::from_static("true"),
34    );
35    resp.headers_mut().insert(
36        header::CONTENT_TYPE,
37        HeaderValue::from_static("application/json"),
38    );
39    *resp.status_mut() = axum::http::StatusCode::OK;
40    resp
41}
42
43/// Redirect helper for 307 (Temporary Redirect, preserves method).
44/// If X-CollectiveToolbox-IsJsRequest header is present, returns JSON with
45/// target URL and X-CollectiveToolbox-IsJsRedirect response header.
46pub fn redirect_temporary_preserve_method(
47    is_js_req: bool,
48    location: &str,
49) -> Response {
50    if is_js_req {
51        json_redirect_response(location)
52    } else {
53        axum::response::Redirect::temporary(location).into_response()
54    }
55}
56
57/// Redirect helper for 303 (See Other, changes POST to GET).
58/// If X-CollectiveToolbox-IsJsRequest header is present, returns JSON with
59/// target URL and X-CollectiveToolbox-IsJsRedirect response header.
60pub fn redirect_temporary(
61    is_js_req: bool,
62    location: &str,
63) -> Response {
64    if is_js_req {
65        json_redirect_response(location)
66    } else {
67        axum::response::Redirect::to(location).into_response()
68    }
69}
70
71/// Redirect helper for 308 (Permanent Redirect, preserves method).
72/// If X-CollectiveToolbox-IsJsRequest header is present, returns JSON with
73/// target URL and X-CollectiveToolbox-IsJsRedirect response header.
74pub fn redirect_permanent(
75    is_js_req: bool,
76    location: &str,
77) -> Response {
78    if is_js_req {
79        json_redirect_response(location)
80    } else {
81        axum::response::Redirect::permanent(location).into_response()
82    }
83}
84
85pub async fn get_app_css(
86    State(state): State<AppState>,
87    req: RequestState,
88) -> Response {
89    match render_view(
90        &state.hbs,
91        "encre_css".to_string(),
92        &req,
93        &json_value!({}),
94    ) {
95        Ok(css) => {
96            let mut resp = Response::new(Body::from(css));
97            resp.headers_mut().insert(
98                header::CONTENT_TYPE,
99                HeaderValue::from_static("text/css"),
100            );
101            resp
102        }
103        Err(e) => error_400(&state, &req, e),
104    }
105}
106
107pub async fn get_installer_linux_x64(
108    State(state): State<AppState>,
109    req: RequestState,
110) -> Response {
111    // Resolve the current executable path first
112    let path = match std::env::current_exe() {
113        Ok(p) => p,
114        Err(e) => return error_400(&state, &req, anyhow!(e)),
115    };
116
117    // Open the file asynchronously and stream it back
118    let file = match File::open(&path).await {
119        Ok(f) => f,
120        Err(e) => return error_400(&state, &req, anyhow!(e)),
121    };
122
123    let stream = ReaderStream::new(file);
124
125    let build_info = build_info();
126    let version = build_info.version;
127    let commit = build_info.commit;
128
129    let file_name = format!("ctoolbox-installer-linux-x64-{version}-{commit}");
130
131    let file_stream_resp = FileStream::new(stream).file_name(file_name);
132    let mut resp = file_stream_resp.into_response();
133    resp.headers_mut().insert(
134        header::CONTENT_TYPE,
135        HeaderValue::from_static("application/x-executable"),
136    );
137    resp
138}
139
140fn get_asset_zip(state: &AppState, req: RequestState, path: &str) -> Response {
141    let path = format!("{path}.zip");
142    let asset = get_asset(&path);
143
144    if let Some(bytes) = asset {
145        let build_info = build_info();
146        let version = build_info.version;
147        let commit = build_info.commit;
148
149        let file_name = format!("ctoolbox-{path}-{version}-{commit}.zip");
150
151        let mut resp = Response::new(Body::from(bytes));
152        resp.headers_mut().insert(
153            header::CONTENT_TYPE,
154            HeaderValue::from_static("application/zip"),
155        );
156
157        // Set Content-Disposition so the response is downloaded as `file_name`.
158        if let Ok(disposition) = HeaderValue::from_str(&format!(
159            "attachment; filename=\"{file_name}\""
160        )) {
161            resp.headers_mut()
162                .insert(header::CONTENT_DISPOSITION, disposition);
163        }
164
165        return resp;
166    }
167
168    // Otherwise render 404 page
169    error_404(
170        state,
171        &req,
172        format!("The requested URL '{path}' was not found."),
173    )
174}
175
176pub async fn get_src_zip(
177    State(state): State<AppState>,
178    req: RequestState,
179) -> Response {
180    return get_asset_zip(&state, req, "src");
181}
182
183// Trying to bundle this caused OOM on compilation
184/*pub async fn get_dependencies_zip(
185    State(state): State<AppState>,
186    req: RequestState,
187) -> Response {
188    return get_asset_zip(&state, req, "dependencies");
189}*/
190
191pub async fn get_dependencies_zip(
192    State(state): State<AppState>,
193    req: RequestState,
194) -> Response {
195    // Resolve the current executable path first
196    let exe_path = match std::env::current_exe() {
197        Ok(p) => p,
198        Err(e) => return error_400(&state, &req, anyhow!(e)),
199    };
200    let build_info = build_info();
201    let version = build_info.version;
202    let commit = build_info.commit;
203    let deps_original_filename = format!("ctoolbox-dependencies-{commit}.zip");
204    let deps_filename = format!("ctoolbox-dependencies-{version}-{commit}.zip");
205    let deps_primary_url = format!("{DEFAULT_SERVER_URL}/dependencies.zip");
206
207    let deps_path = match exe_path.parent() {
208        Some(parent) => parent.join(&deps_original_filename),
209        None => {
210            return redirect_temporary(req.is_js_request, &deps_primary_url);
211        }
212    };
213
214    // Try to open dependencies.zip; if missing, redirect; other errors -> error_400.
215    match File::open(&deps_path).await {
216        Ok(file) => {
217            let stream = ReaderStream::new(file);
218            let file_stream_resp =
219                FileStream::new(stream).file_name(deps_filename);
220            let mut resp = file_stream_resp.into_response();
221            resp.headers_mut().insert(
222                header::CONTENT_TYPE,
223                HeaderValue::from_static("application/zip"),
224            );
225            resp
226        }
227        Err(e) => {
228            if e.kind() == std::io::ErrorKind::NotFound {
229                redirect_temporary(req.is_js_request, &deps_primary_url)
230            } else {
231                error_400(&state, &req, anyhow!(e))
232            }
233        }
234    }
235}
236
237pub async fn get_doc_index(
238    State(state): State<AppState>,
239    req: RequestState,
240) -> Response {
241    redirect_temporary(req.is_js_request, "/docs/rust/ctoolbox/index.html")
242}
243
244pub async fn get_doc_page(
245    State(state): State<AppState>,
246    req: RequestState,
247    path: axum::extract::Path<String>,
248) -> Response {
249    let path = path.as_str();
250
251    asset_or_404(&state, req, format!("/docs/{path}").as_str())
252}
253
254pub async fn static_or_404(
255    State(state): State<AppState>,
256    req: RequestState,
257    uri: Uri,
258) -> Response {
259    asset_or_404(&state, req, uri.path())
260}
261
262fn asset_or_404(state: &AppState, req: RequestState, path: &str) -> Response {
263    let path = path.trim_start_matches('/');
264
265    // Try serving embedded asset
266    let mut asset = get_asset(path);
267    if asset.is_none() {
268        asset = get_asset(format!("web/{path}").as_str());
269    }
270
271    if let Some(bytes) = asset {
272        let mime_guess = mime_guess::from_path(path).first();
273        let mime_guess_str: Cow<'static, str> = match mime_guess {
274            Some(mime) => Cow::Owned(mime.essence_str().to_string()),
275            None => Cow::Borrowed("application/octet-stream"),
276        };
277
278        let mut resp = Response::new(Body::from(bytes));
279        resp.headers_mut().insert(
280            header::CONTENT_TYPE,
281            HeaderValue::from_str(mime_guess_str.as_ref()).unwrap_or_else(
282                |_| HeaderValue::from_static("application/octet-stream"),
283            ),
284        );
285        return resp;
286    }
287
288    // Otherwise render 404 page
289    error_404(
290        state,
291        &req,
292        format!("The requested URL '{path}' was not found."),
293    )
294}
295
296#[cfg(test)]
297mod tests {
298    use crate::io::webui::test_helpers::{
299        test_get_no_login, test_get_no_login_json, test_get_redirect_no_login,
300    };
301    use crate::utilities::{
302        assert_string_contains, assert_string_not_contains,
303    };
304
305    #[crate::ctb_test(tokio::test)]
306    async fn can_get_doc_index() {
307        let (status, location) = test_get_redirect_no_login("/docs").await;
308        assert_eq!(status, 303);
309        assert_eq!(location, "/docs/rust/ctoolbox/index.html");
310    }
311
312    #[crate::ctb_test(tokio::test)]
313    async fn can_get_rust_index() {
314        let (status, body) =
315            test_get_no_login("/docs/rust/ctoolbox/index.html").await;
316        assert_eq!(status, 200);
317        assert!(body.contains("<title>ctoolbox - Rust</title>"));
318    }
319
320    #[crate::ctb_test(tokio::test)]
321    async fn can_get_css() {
322        let (status, body) = test_get_no_login("/app.css").await;
323        assert_eq!(status, 200);
324        assert!(body.contains("Abstract-Polygon-Background")); // Check for app code
325        assert!(body.contains("SFMono-Regular")); // Check for encrecss code
326    }
327
328    #[crate::ctb_test(tokio::test)]
329    async fn can_download_source() {
330        let (status, body) = test_get_no_login("/src.zip").await;
331        assert_eq!(status, 200);
332        assert!(body.starts_with("PK"));
333    }
334
335    #[crate::ctb_test(tokio::test)]
336    async fn can_get_404() {
337        let (status, body) = test_get_no_login("/nonexistent").await;
338        assert_eq!(status, 404);
339        assert_string_contains("was not found.</h1>", &body);
340    }
341
342    #[crate::ctb_test(tokio::test)]
343    async fn can_get_404_json() {
344        let (status, body) = test_get_no_login_json("/nonexistent").await;
345        assert_eq!(status, 404);
346        assert_string_contains("was not found.", &body);
347        assert_string_not_contains("was not found.</h1>", &body);
348    }
349}