1use askama::Template;
2use askama_web::WebTemplate;
3
4#[expect(
5 clippy::unnecessary_wraps,
6 reason = "Askama filter_fn macro generates code that triggers this lint, but Result return type is required by the Askama filter API"
7)]
8#[expect(
9 clippy::inline_always,
10 clippy::unused_self,
11 reason = "Askama's #[filter_fn] macro generates builder pattern code with #[inline(always)] that triggers these lints"
12)]
13mod filters {
14 use askama::filter_fn;
15
16 #[filter_fn]
17 pub fn format_command(s: &str, _env: &dyn askama::Values) -> ::askama::Result<String> {
18 Ok(serde_json::from_str::<Vec<String>>(s).map_or_else(
20 |_| s.to_string(),
21 |cmd_array| {
22 shlex::try_join(cmd_array.iter().map(String::as_str))
23 .unwrap_or_else(|_| cmd_array.join(" "))
24 },
25 ))
26 }
27
28 #[filter_fn]
29 pub fn pretty_json<T: std::fmt::Display>(
30 s: T,
31 _env: &dyn askama::Values,
32 ) -> ::askama::Result<String> {
33 let s_str = s.to_string();
34 Ok(serde_json::from_str::<serde_json::Value>(&s_str)
36 .ok()
37 .and_then(|value| serde_json::to_string_pretty(&value).ok())
38 .unwrap_or(s_str))
39 }
40}
41
42use axum::{
43 Router,
44 body::Body,
45 extract::{DefaultBodyLimit, Multipart, Path, Query, State},
46 http::{StatusCode, header},
47 response::{IntoResponse, Json, Response},
48 routing::{get, post},
49};
50use capsula_api_types::VaultInfo;
51use capsula_core::util::hex_encode;
52use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode};
53use serde_json::json;
54use sha2::{Digest, Sha256};
55use sqlx::PgPool;
56use std::collections::VecDeque;
57use std::path::{Component, Path as StdPath, PathBuf};
58use tracing::{error, info, warn};
59
60const RFC5987_ATTR_CHAR: &AsciiSet = &NON_ALPHANUMERIC
63 .remove(b'!')
64 .remove(b'#')
65 .remove(b'$')
66 .remove(b'&')
67 .remove(b'+')
68 .remove(b'-')
69 .remove(b'.')
70 .remove(b'^')
71 .remove(b'_')
72 .remove(b'`')
73 .remove(b'|')
74 .remove(b'~');
75
76#[derive(Clone)]
77pub struct AppState {
78 pub pool: PgPool,
79 pub storage_path: PathBuf,
80}
81
82mod models;
83mod query;
84
85#[derive(Template, WebTemplate)]
86#[template(path = "index.html")]
87struct IndexTemplate;
88
89#[derive(Template, WebTemplate)]
90#[template(path = "vaults.html")]
91struct VaultsTemplate {
92 vaults: Vec<VaultInfo>,
93}
94
95#[derive(Template, WebTemplate)]
96#[template(path = "runs.html")]
97struct RunsTemplate {
98 runs: Vec<models::Run>,
99 vault: Option<String>,
100 page: i64,
101 total_pages: i64,
102}
103
104#[derive(Template, WebTemplate)]
105#[template(path = "run_detail.html")]
106struct RunDetailTemplate {
107 run: models::Run,
108 pre_run_hooks: Vec<models::HookOutput>,
109 post_run_hooks: Vec<models::HookOutput>,
110 files: Vec<models::CapturedFile>,
111}
112
113#[derive(Template, WebTemplate)]
114#[template(path = "error.html")]
115struct ErrorTemplate {
116 status_code: u16,
117 title: String,
118 message: String,
119}
120
121pub async fn create_pool(database_url: &str, max_connections: u32) -> Result<PgPool, sqlx::Error> {
122 sqlx::postgres::PgPoolOptions::new()
123 .max_connections(max_connections)
124 .acquire_timeout(std::time::Duration::from_secs(3))
125 .connect(database_url)
126 .await
127}
128
129pub fn build_app(pool: PgPool, storage_path: PathBuf, max_body_size: usize) -> Router {
130 let state = AppState { pool, storage_path };
131
132 Router::new()
133 .route("/", get(index))
134 .route("/vaults", get(vaults_page))
135 .route("/runs", get(runs_page))
136 .route("/runs/{id}", get(run_detail_page))
137 .route("/health", get(health_check))
138 .route("/api/v1/vaults", get(list_vaults))
139 .route("/api/v1/vaults/{name}", get(get_vault_info))
140 .route("/api/v1/runs", post(create_run).get(list_runs))
141 .route("/api/v1/runs/search", post(search_runs))
142 .route("/api/v1/runs/{id}", get(get_run))
143 .route("/api/v1/runs/{id}/files/{*path}", get(download_file))
144 .route(
145 "/api/v1/upload",
146 post(upload_files).layer(DefaultBodyLimit::max(max_body_size)),
147 )
148 .route("/static/style.css", get(serve_style_css))
149 .fallback(not_found)
150 .with_state(state)
151}
152
153async fn serve_style_css() -> impl IntoResponse {
154 (
155 [(header::CONTENT_TYPE, mime_guess::mime::TEXT_CSS.as_ref())],
156 include_str!("../static/style.css"),
157 )
158}
159
160fn json_response<T: serde::Serialize>(value: T) -> Json<serde_json::Value> {
168 Json(serde_json::to_value(value).unwrap_or_else(|e| {
169 error!("Failed to serialize response body: {e}");
170 json!({
171 "status": "error",
172 "error": "response serialization failed"
173 })
174 }))
175}
176
177async fn fetch_vault_list(pool: &PgPool) -> Result<Vec<VaultInfo>, sqlx::Error> {
181 let rows = sqlx::query!(
182 r#"
183 SELECT vault as name, COUNT(*) as "run_count!"
184 FROM runs
185 GROUP BY vault
186 ORDER BY vault
187 "#
188 )
189 .fetch_all(pool)
190 .await?;
191
192 Ok(rows
193 .into_iter()
194 .map(|row| VaultInfo {
195 name: row.name,
196 run_count: row.run_count,
197 })
198 .collect())
199}
200
201async fn fetch_runs(
205 pool: &PgPool,
206 vault: Option<&str>,
207 limit: i64,
208 offset: i64,
209) -> Result<Vec<models::Run>, sqlx::Error> {
210 if let Some(vault) = vault {
211 sqlx::query_as!(
212 models::Run,
213 r#"
214 SELECT id, name, timestamp, command, vault, project_root,
215 exit_code, duration_ms, stdout, stderr,
216 created_at, updated_at
217 FROM runs
218 WHERE vault = $1
219 ORDER BY timestamp DESC
220 LIMIT $2 OFFSET $3
221 "#,
222 vault,
223 limit,
224 offset
225 )
226 .fetch_all(pool)
227 .await
228 } else {
229 sqlx::query_as!(
230 models::Run,
231 r#"
232 SELECT id, name, timestamp, command, vault, project_root,
233 exit_code, duration_ms, stdout, stderr,
234 created_at, updated_at
235 FROM runs
236 ORDER BY timestamp DESC
237 LIMIT $1 OFFSET $2
238 "#,
239 limit,
240 offset
241 )
242 .fetch_all(pool)
243 .await
244 }
245}
246
247async fn count_runs(pool: &PgPool, vault: Option<&str>) -> Result<i64, sqlx::Error> {
249 if let Some(vault) = vault {
250 sqlx::query_scalar!(
251 r#"
252 SELECT COUNT(*)::bigint
253 FROM runs
254 WHERE vault = $1
255 "#,
256 vault
257 )
258 .fetch_one(pool)
259 .await
260 .map(|v| v.unwrap_or(0))
261 } else {
262 sqlx::query_scalar!(
263 r#"
264 SELECT COUNT(*)::bigint
265 FROM runs
266 "#
267 )
268 .fetch_one(pool)
269 .await
270 .map(|v| v.unwrap_or(0))
271 }
272}
273
274async fn fetch_run_by_id(pool: &PgPool, id: &str) -> Result<Option<models::Run>, sqlx::Error> {
278 sqlx::query_as!(
279 models::Run,
280 r#"
281 SELECT id, name, timestamp, command, vault, project_root,
282 exit_code, duration_ms, stdout, stderr,
283 created_at, updated_at
284 FROM runs
285 WHERE id = $1
286 "#,
287 id
288 )
289 .fetch_optional(pool)
290 .await
291}
292
293async fn index() -> impl IntoResponse {
294 IndexTemplate
295}
296
297async fn not_found() -> impl IntoResponse {
298 (
299 StatusCode::NOT_FOUND,
300 ErrorTemplate {
301 status_code: 404,
302 title: "Page Not Found".to_string(),
303 message: "The page you are looking for does not exist.".to_string(),
304 },
305 )
306}
307
308async fn vaults_page(State(state): State<AppState>) -> impl IntoResponse {
309 info!("Rendering vaults page");
310
311 let vaults = fetch_vault_list(&state.pool).await.unwrap_or_else(|e| {
312 error!("Failed to fetch vaults: {}", e);
313 Vec::new()
314 });
315 VaultsTemplate { vaults }
316}
317
318async fn runs_page(
319 State(state): State<AppState>,
320 Query(params): Query<models::ListRunsQuery>,
321) -> impl IntoResponse {
322 let page = params.offset.unwrap_or(0) / params.limit.unwrap_or(50) + 1;
323 let limit = params.limit.unwrap_or(50);
324 let offset = (page - 1) * limit;
325
326 info!(
327 "Rendering runs page: page={}, vault={:?}",
328 page, params.vault
329 );
330
331 let total_count = count_runs(&state.pool, params.vault.as_deref())
332 .await
333 .unwrap_or(0);
334
335 let total_pages = (total_count + limit - 1) / limit;
336
337 let runs_result = fetch_runs(&state.pool, params.vault.as_deref(), limit, offset).await;
338
339 match runs_result {
340 Ok(runs) => RunsTemplate {
341 runs,
342 vault: params.vault,
343 page,
344 total_pages,
345 },
346 Err(e) => {
347 error!("Failed to fetch runs: {}", e);
348 RunsTemplate {
349 runs: Vec::new(),
350 vault: params.vault,
351 page: 1,
352 total_pages: 1,
353 }
354 }
355 }
356}
357
358async fn run_detail_page(
359 State(state): State<AppState>,
360 Path(id): Path<String>,
361) -> Result<RunDetailTemplate, StatusCode> {
362 info!("Rendering run detail page for: {}", id);
363
364 let run = fetch_run_by_id(&state.pool, &id)
365 .await
366 .map_err(|e| {
367 error!("Database error while fetching run: {}", e);
368 StatusCode::INTERNAL_SERVER_ERROR
369 })?
370 .ok_or_else(|| {
371 info!("Run not found: {}", id);
372 StatusCode::NOT_FOUND
373 })?;
374
375 let hook_outputs_result = sqlx::query_as!(
377 models::RunOutputRow,
378 r#"
379 SELECT phase, hook_id, config, output, success, error
380 FROM run_outputs
381 WHERE run_id = $1
382 ORDER BY id
383 "#,
384 id
385 )
386 .fetch_all(&state.pool)
387 .await;
388
389 let (pre_run_hooks, post_run_hooks) = match hook_outputs_result {
390 Ok(rows) => {
391 let mut pre_hooks = Vec::new();
392 let mut post_hooks = Vec::new();
393
394 for row in rows {
395 let hook_output = models::HookOutput {
396 meta: models::HookMeta {
397 id: row.hook_id,
398 config: row.config,
399 success: row.success,
400 error: row.error,
401 },
402 output: row.output,
403 };
404
405 if row.phase == "pre" {
406 pre_hooks.push(hook_output);
407 } else if row.phase == "post" {
408 post_hooks.push(hook_output);
409 } else {
410 warn!("Unknown hook phase: {}", row.phase);
411 }
412 }
413
414 (pre_hooks, post_hooks)
415 }
416 Err(e) => {
417 error!("Failed to fetch hook outputs: {}", e);
418 (Vec::new(), Vec::new())
419 }
420 };
421
422 let files_result = sqlx::query_as!(
424 models::CapturedFile,
425 r#"
426 SELECT path, size, hash, storage_path, content_type
427 FROM captured_files
428 WHERE run_id = $1
429 ORDER BY path
430 "#,
431 id
432 )
433 .fetch_all(&state.pool)
434 .await;
435
436 let files = match files_result {
437 Ok(files) => files,
438 Err(e) => {
439 error!("Failed to fetch captured files: {}", e);
440 Vec::new()
441 }
442 };
443
444 Ok(RunDetailTemplate {
445 run,
446 pre_run_hooks,
447 post_run_hooks,
448 files,
449 })
450}
451
452async fn health_check(State(state): State<AppState>) -> impl IntoResponse {
453 match sqlx::query("SELECT 1").fetch_one(&state.pool).await {
454 Ok(_) => Json(json!({
455 "status": "ok",
456 "database": "connected"
457 })),
458 Err(e) => Json(json!({
459 "status": "error",
460 "database": "disconnected",
461 "error": e.to_string()
462 })),
463 }
464}
465
466async fn list_vaults(State(state): State<AppState>) -> impl IntoResponse {
467 info!("Listing all vaults");
468
469 match fetch_vault_list(&state.pool).await {
470 Ok(vaults) => {
471 info!("Found {} vaults", vaults.len());
472 json_response(capsula_api_types::VaultsResponse {
473 status: "ok".to_string(),
474 vaults,
475 })
476 }
477 Err(e) => {
478 error!("Failed to list vaults: {}", e);
479 json_response(capsula_api_types::ErrorResponse {
480 status: "error".to_string(),
481 error: e.to_string(),
482 })
483 }
484 }
485}
486
487async fn get_vault_info(
488 State(state): State<AppState>,
489 Path(name): Path<String>,
490) -> impl IntoResponse {
491 info!("Getting vault info: {}", name);
492
493 let result = sqlx::query!(
494 r#"
495 SELECT vault as name, COUNT(*) as "run_count!"
496 FROM runs
497 WHERE vault = $1
498 GROUP BY vault
499 "#,
500 name
501 )
502 .fetch_optional(&state.pool)
503 .await;
504
505 match result {
506 Ok(Some(row)) => {
507 let vault = VaultInfo {
508 name: row.name,
509 run_count: row.run_count,
510 };
511 info!("Found vault: {} with {} runs", vault.name, vault.run_count);
512 let response = capsula_api_types::VaultExistsResponse {
513 status: "ok".to_string(),
514 exists: true,
515 vault: Some(vault),
516 };
517 json_response(response)
518 }
519 Ok(None) => {
520 info!("Vault not found: {}", name);
521 let response = capsula_api_types::VaultExistsResponse {
522 status: "ok".to_string(),
523 exists: false,
524 vault: None,
525 };
526 json_response(response)
527 }
528 Err(e) => {
529 error!("Failed to get vault info: {}", e);
530 let response = capsula_api_types::ErrorResponse {
531 status: "error".to_string(),
532 error: e.to_string(),
533 };
534 json_response(response)
535 }
536 }
537}
538
539async fn list_runs(
540 State(state): State<AppState>,
541 Query(params): Query<models::ListRunsQuery>,
542) -> impl IntoResponse {
543 let limit = params.limit.unwrap_or(100);
544 let offset = params.offset.unwrap_or(0);
545
546 if let Some(ref vault) = params.vault {
547 info!(
548 "Listing runs for vault: {} (limit={}, offset={})",
549 vault, limit, offset
550 );
551 } else {
552 info!("Listing all runs (limit={}, offset={})", limit, offset);
553 }
554
555 let result = fetch_runs(&state.pool, params.vault.as_deref(), limit, offset).await;
556
557 match result {
558 Ok(runs) => {
559 info!("Found {} runs", runs.len());
560 Json(json!({
561 "status": "ok",
562 "runs": runs,
563 "limit": limit,
564 "offset": offset
565 }))
566 }
567 Err(e) => {
568 error!("Failed to list runs: {}", e);
569 Json(json!({
570 "status": "error",
571 "error": e.to_string()
572 }))
573 }
574 }
575}
576
577#[expect(clippy::too_many_lines, reason = "TODO: Refactor later")]
579async fn search_runs(
580 State(state): State<AppState>,
581 Json(request): Json<models::SearchRunsRequest>,
582) -> impl IntoResponse {
583 info!(
584 "Searching runs: vault={:?}, hook_filters={}",
585 request.vault,
586 request.hook_filters.len()
587 );
588
589 let builder = match query::RunQueryBuilder::from_request(&request) {
591 Ok(b) => b,
592 Err(e) => {
593 error!("Failed to build query: {}", e);
594 return Json(json!({
595 "status": "error",
596 "error": e.to_string()
597 }));
598 }
599 };
600
601 let query_sql = builder.build_query();
602 let count_sql = builder.build_count_query();
603 let bind_values = builder.bind_values();
604
605 info!("Executing search query: {}", query_sql);
606
607 let total: i64 = {
609 let mut query = sqlx::query_scalar::<_, i64>(&count_sql);
610 for value in bind_values {
611 query = match value {
612 query::BindValue::String(s) => query.bind(s.clone()),
613 query::BindValue::I32(i) => query.bind(*i),
614 query::BindValue::I64(i) => query.bind(*i),
615 query::BindValue::DateTime(dt) => query.bind(*dt),
616 query::BindValue::Bool(b) => query.bind(*b),
617 };
618 }
619 match query.fetch_one(&state.pool).await {
620 Ok(count) => count,
621 Err(e) => {
622 error!("Failed to execute count query: {}", e);
623 return Json(json!({
624 "status": "error",
625 "error": "Query execution failed"
626 }));
627 }
628 }
629 };
630
631 let runs: Vec<models::Run> = {
633 let mut query = sqlx::query_as::<_, models::Run>(&query_sql);
634 for value in bind_values {
635 query = match value {
636 query::BindValue::String(s) => query.bind(s.clone()),
637 query::BindValue::I32(i) => query.bind(*i),
638 query::BindValue::I64(i) => query.bind(*i),
639 query::BindValue::DateTime(dt) => query.bind(*dt),
640 query::BindValue::Bool(b) => query.bind(*b),
641 };
642 }
643 match query.fetch_all(&state.pool).await {
644 Ok(runs) => runs,
645 Err(e) => {
646 error!("Failed to execute search query: {}", e);
647 return Json(json!({
648 "status": "error",
649 "error": "Query execution failed"
650 }));
651 }
652 }
653 };
654
655 info!("Found {} runs (total: {})", runs.len(), total);
656
657 let include_files = request.include.contains(&models::IncludeField::Files);
659 let include_stdout = request.include.contains(&models::IncludeField::Stdout);
660 let include_stderr = request.include.contains(&models::IncludeField::Stderr);
661 let include_hooks = request.include.contains(&models::IncludeField::Hooks);
662
663 let mut results = Vec::with_capacity(runs.len());
665 for run in runs {
666 let files = if include_files {
667 match sqlx::query_as::<_, models::CapturedFile>(
668 "SELECT path, size, hash, storage_path, content_type \
669 FROM captured_files WHERE run_id = $1 ORDER BY path",
670 )
671 .bind(&run.id)
672 .fetch_all(&state.pool)
673 .await
674 {
675 Ok(files) => Some(
676 files
677 .into_iter()
678 .map(|f| models::FileInfo {
679 path: f.path.clone(),
680 size: f.size,
681 hash: f.hash,
682 url: format!("/api/v1/runs/{}/files/{}", run.id, f.path),
683 })
684 .collect(),
685 ),
686 Err(e) => {
687 warn!("Failed to fetch files for run {}: {}", run.id, e);
688 None
689 }
690 }
691 } else {
692 None
693 };
694
695 let (pre_run_hooks, post_run_hooks) = if include_hooks {
696 match sqlx::query_as::<_, models::RunOutputRow>(
697 "SELECT phase, hook_id, config, output, success, error \
698 FROM run_outputs WHERE run_id = $1 ORDER BY id",
699 )
700 .bind(&run.id)
701 .fetch_all(&state.pool)
702 .await
703 {
704 Ok(rows) => {
705 let mut pre_hooks = Vec::new();
706 let mut post_hooks = Vec::new();
707 for row in rows {
708 let hook_output = models::HookOutput {
709 meta: models::HookMeta {
710 id: row.hook_id,
711 config: row.config,
712 success: row.success,
713 error: row.error,
714 },
715 output: row.output,
716 };
717 if row.phase == "pre" {
718 pre_hooks.push(hook_output);
719 } else if row.phase == "post" {
720 post_hooks.push(hook_output);
721 } else {
722 }
724 }
725 (Some(pre_hooks), Some(post_hooks))
726 }
727 Err(e) => {
728 warn!("Failed to fetch hooks for run {}: {}", run.id, e);
729 (None, None)
730 }
731 }
732 } else {
733 (None, None)
734 };
735
736 results.push(models::SearchRunResult {
737 id: run.id,
738 name: run.name,
739 timestamp: run.timestamp,
740 vault: run.vault,
741 command: run.command,
742 project_root: run.project_root,
743 exit_code: run.exit_code,
744 duration_ms: run.duration_ms,
745 stdout: if include_stdout { run.stdout } else { None },
746 stderr: if include_stderr { run.stderr } else { None },
747 files,
748 pre_run_hooks,
749 post_run_hooks,
750 });
751 }
752
753 let response = models::SearchRunsResponse {
754 status: "ok".to_string(),
755 total,
756 runs: results,
757 };
758
759 json_response(response)
760}
761
762async fn create_run(
763 State(state): State<AppState>,
764 Json(request): Json<models::CreateRunRequest>,
765) -> impl IntoResponse {
766 info!("Creating run: {}", request.id);
767
768 let result = sqlx::query!(
769 r#"
770 INSERT INTO runs (
771 id, name, timestamp, command, vault, project_root,
772 exit_code, duration_ms, stdout, stderr
773 ) VALUES (
774 $1, $2, $3, $4, $5, $6,
775 $7, $8, $9, $10
776 )
777 "#,
778 request.id,
779 request.name,
780 request.timestamp,
781 request.command,
782 request.vault,
783 request.project_root,
784 request.exit_code,
785 request.duration_ms,
786 request.stdout,
787 request.stderr
788 )
789 .execute(&state.pool)
790 .await;
791
792 match result {
793 Ok(_) => {
794 info!("Run created successfully");
795 (
796 StatusCode::CREATED,
797 Json(json!({
798 "status": "created",
799 "run": request
800 })),
801 )
802 }
803 Err(e) => {
804 error!("Failed to insert run: {}", e);
805 let status = if e.to_string().contains("duplicate key") {
806 StatusCode::CONFLICT
807 } else {
808 StatusCode::INTERNAL_SERVER_ERROR
809 };
810 (
811 status,
812 Json(json!({
813 "status": "error",
814 "error": e.to_string()
815 })),
816 )
817 }
818 }
819}
820
821async fn get_run(State(state): State<AppState>, Path(id): Path<String>) -> impl IntoResponse {
822 info!("Getting run: {}", id);
823
824 let result = fetch_run_by_id(&state.pool, &id).await;
825
826 match result {
827 Ok(Some(run)) => {
828 info!("Found run: {}", run.id);
829
830 let hook_outputs_result = sqlx::query_as!(
832 models::RunOutputRow,
833 r#"
834 SELECT phase, hook_id, config, output, success, error
835 FROM run_outputs
836 WHERE run_id = $1
837 ORDER BY id
838 "#,
839 id
840 )
841 .fetch_all(&state.pool)
842 .await;
843
844 let (pre_run_hooks, post_run_hooks) = match hook_outputs_result {
845 Ok(rows) => {
846 let mut pre_hooks = Vec::new();
847 let mut post_hooks = Vec::new();
848
849 for row in rows {
850 let hook_output = models::HookOutput {
851 meta: models::HookMeta {
852 id: row.hook_id,
853 config: row.config,
854 success: row.success,
855 error: row.error,
856 },
857 output: row.output,
858 };
859
860 if row.phase == "pre" {
861 pre_hooks.push(hook_output);
862 } else if row.phase == "post" {
863 post_hooks.push(hook_output);
864 } else {
865 warn!("Unknown hook phase: {}", row.phase);
866 }
867 }
868
869 info!(
870 "Found {} pre-run hooks and {} post-run hooks",
871 pre_hooks.len(),
872 post_hooks.len()
873 );
874 (pre_hooks, post_hooks)
875 }
876 Err(e) => {
877 error!("Failed to fetch hook outputs: {}", e);
878 (Vec::new(), Vec::new())
879 }
880 };
881
882 Json(json!({
883 "status": "ok",
884 "run": run,
885 "pre_run_hooks": pre_run_hooks,
886 "post_run_hooks": post_run_hooks
887 }))
888 }
889 Ok(None) => {
890 info!("Run not found: {}", id);
891 Json(json!({
892 "status": "not_found",
893 "error": format!("Run with id {} not found", id)
894 }))
895 }
896 Err(e) => {
897 error!("Failed to retrieve run: {}", e);
898 Json(json!({
899 "status": "error",
900 "error": e.to_string()
901 }))
902 }
903 }
904}
905
906#[expect(clippy::too_many_lines, reason = "TODO: Refactor later")]
907#[expect(
908 clippy::else_if_without_else,
909 reason = "There is `continue` or `return` in each branch, so `else` is redundant"
910)]
911async fn upload_files(
912 State(state): State<AppState>,
913 mut multipart: Multipart,
914) -> impl IntoResponse {
915 let storage_path = &state.storage_path;
916 info!("Received file upload request");
917
918 if let Err(e) = tokio::fs::create_dir_all(&storage_path).await {
919 error!(
920 "Failed to create storage directory at {}: {}",
921 storage_path.display(),
922 e
923 );
924 return Json(json!({
925 "status": "error",
926 "error": format!("Failed to create storage directory: {}", e)
927 }));
928 }
929
930 let mut files_processed = 0;
931 let mut total_bytes = 0u64;
932 let mut run_id: Option<String> = None;
933 let mut pending_paths: VecDeque<String> = VecDeque::new();
934 let mut pre_run_hooks: Option<Vec<models::HookOutput>> = None;
935 let mut post_run_hooks: Option<Vec<models::HookOutput>> = None;
936
937 while let Ok(Some(field)) = multipart.next_field().await {
938 let field_name = field.name().unwrap_or("unknown").to_string();
939
940 if field_name == "run_id" {
941 match field.text().await {
942 Ok(text) => {
943 run_id = Some(text);
944 continue;
945 }
946 Err(e) => {
947 error!("Failed to read run_id field: {}", e);
948 return Json(json!({
949 "status": "error",
950 "error": format!("Failed to read run_id: {}", e)
951 }));
952 }
953 }
954 } else if field_name == "path" {
955 match field.text().await {
956 Ok(text) => {
957 pending_paths.push_back(text);
958 continue;
959 }
960 Err(e) => {
961 error!("Failed to read path field: {}", e);
962 return Json(json!({
963 "status": "error",
964 "error": format!("Failed to read path: {}", e)
965 }));
966 }
967 }
968 } else if field_name == "pre_run" {
969 match field.text().await {
970 Ok(text) => match serde_json::from_str::<Vec<models::HookOutput>>(&text) {
971 Ok(hooks) => {
972 info!("Parsed {} pre-run hooks", hooks.len());
973 pre_run_hooks = Some(hooks);
974 continue;
975 }
976 Err(e) => {
977 error!("Failed to parse pre_run JSON: {}", e);
978 return Json(json!({
979 "status": "error",
980 "error": format!("Failed to parse pre_run JSON: {}", e)
981 }));
982 }
983 },
984 Err(e) => {
985 error!("Failed to read pre_run field: {}", e);
986 return Json(json!({
987 "status": "error",
988 "error": format!("Failed to read pre_run: {}", e)
989 }));
990 }
991 }
992 } else if field_name == "post_run" {
993 match field.text().await {
994 Ok(text) => match serde_json::from_str::<Vec<models::HookOutput>>(&text) {
995 Ok(hooks) => {
996 info!("Parsed {} post-run hooks", hooks.len());
997 post_run_hooks = Some(hooks);
998 continue;
999 }
1000 Err(e) => {
1001 error!("Failed to parse post_run JSON: {}", e);
1002 return Json(json!({
1003 "status": "error",
1004 "error": format!("Failed to parse post_run JSON: {}", e)
1005 }));
1006 }
1007 },
1008 Err(e) => {
1009 error!("Failed to read post_run field: {}", e);
1010 return Json(json!({
1011 "status": "error",
1012 "error": format!("Failed to read post_run: {}", e)
1013 }));
1014 }
1015 }
1016 }
1017
1018 let file_name = field.file_name().unwrap_or("unknown").to_string();
1019 let content_type = field
1020 .content_type()
1021 .unwrap_or("application/octet-stream")
1022 .to_string();
1023
1024 info!(
1025 "Processing file: field_name={}, file_name={}, content_type={}",
1026 field_name, file_name, content_type
1027 );
1028
1029 match field.bytes().await {
1030 Ok(data) => {
1031 let size = data.len();
1032 total_bytes += size as u64;
1033 let Ok(size_i64) = i64::try_from(size) else {
1034 error!("File too large to store size in database: {} bytes", size);
1035 return Json(json!({
1036 "status": "error",
1037 "error": "File too large to store size in database"
1038 }));
1039 };
1040
1041 let mut hasher = Sha256::new();
1042 hasher.update(&data);
1043 let hash = hex_encode(hasher.finalize().as_ref());
1044
1045 info!("File hash: {}, size: {} bytes", hash, size);
1046
1047 let hash_dir = &hash[0..2];
1048 let file_storage_dir = storage_path.join(hash_dir);
1049 if let Err(e) = tokio::fs::create_dir_all(&file_storage_dir).await {
1050 error!(
1051 "Failed to create hash directory at {}: {}",
1052 file_storage_dir.display(),
1053 e
1054 );
1055 return Json(json!({
1056 "status": "error",
1057 "error": format!("Failed to create storage directory: {}", e)
1058 }));
1059 }
1060
1061 let file_storage_path = file_storage_dir.join(&hash);
1062 let storage_path_str = file_storage_path.to_string_lossy().to_string();
1063
1064 if file_storage_path.exists() {
1065 info!("File already exists (deduplicated): {}", storage_path_str);
1066 } else {
1067 if let Err(e) = tokio::fs::write(&file_storage_path, &data).await {
1068 error!("Failed to write file: {}", e);
1069 return Json(json!({
1070 "status": "error",
1071 "error": format!("Failed to write file: {}", e)
1072 }));
1073 }
1074 info!("Saved new file: {}", storage_path_str);
1075 }
1076
1077 if let Some(ref rid) = run_id {
1078 let candidate_path = pending_paths
1079 .pop_front()
1080 .or_else(|| {
1081 if file_name == "unknown" {
1082 None
1083 } else {
1084 Some(file_name.clone())
1085 }
1086 })
1087 .unwrap_or_else(|| {
1088 if field_name != "unknown" && field_name != "file" {
1089 field_name.clone()
1090 } else {
1091 format!("file-{}", files_processed + 1)
1092 }
1093 });
1094
1095 let relative_path = match sanitize_relative_path(&candidate_path) {
1096 Ok(p) => p,
1097 Err(reason) => {
1098 warn!(
1099 "Rejected captured-file path '{}': {}",
1100 candidate_path, reason
1101 );
1102 return Json(json!({
1103 "status": "error",
1104 "error": format!("Invalid file path '{}': {}", candidate_path, reason)
1105 }));
1106 }
1107 };
1108
1109 let result = sqlx::query!(
1110 r#"
1111 INSERT INTO captured_files (run_id, path, size, hash, storage_path, content_type)
1112 VALUES ($1, $2, $3, $4, $5, $6)
1113 ON CONFLICT (run_id, path) DO UPDATE
1114 SET size = EXCLUDED.size,
1115 hash = EXCLUDED.hash,
1116 storage_path = EXCLUDED.storage_path,
1117 content_type = EXCLUDED.content_type
1118 "#,
1119 rid,
1120 relative_path,
1121 size_i64,
1122 hash,
1123 storage_path_str,
1124 content_type
1125 )
1126 .execute(&state.pool)
1127 .await;
1128
1129 match result {
1130 Ok(_) => {
1131 info!("Stored file metadata in database");
1132 }
1133 Err(e) => {
1134 error!("Failed to store file metadata: {}", e);
1135 return Json(json!({
1136 "status": "error",
1137 "error": format!("Failed to store file metadata: {}", e)
1138 }));
1139 }
1140 }
1141 }
1142
1143 files_processed += 1;
1144 info!("Successfully processed file: {} bytes", size);
1145 }
1146 Err(e) => {
1147 error!("Failed to read file field: {}", e);
1148 return Json(json!({
1149 "status": "error",
1150 "error": format!("Failed to read file: {}", e)
1151 }));
1152 }
1153 }
1154 }
1155
1156 info!(
1157 "Upload complete: {} files, {} bytes total",
1158 files_processed, total_bytes
1159 );
1160
1161 let mut pre_run_count = 0;
1163 let mut post_run_count = 0;
1164
1165 if let Some(ref rid) = run_id {
1166 if let Some(hooks) = pre_run_hooks {
1167 for hook in hooks {
1168 let result = sqlx::query!(
1169 r#"
1170 INSERT INTO run_outputs (run_id, phase, hook_id, config, output, success, error)
1171 VALUES ($1, 'pre', $2, $3, $4, $5, $6)
1172 ON CONFLICT (run_id, phase, hook_id) DO UPDATE
1173 SET config = EXCLUDED.config,
1174 output = EXCLUDED.output,
1175 success = EXCLUDED.success,
1176 error = EXCLUDED.error
1177 "#,
1178 rid,
1179 hook.meta.id,
1180 hook.meta.config,
1181 hook.output,
1182 hook.meta.success,
1183 hook.meta.error
1184 )
1185 .execute(&state.pool)
1186 .await;
1187
1188 match result {
1189 Ok(_) => {
1190 pre_run_count += 1;
1191 info!("Stored pre-run hook: {}", hook.meta.id);
1192 }
1193 Err(e) => {
1194 error!("Failed to store pre-run hook {}: {}", hook.meta.id, e);
1195 return Json(json!({
1196 "status": "error",
1197 "error": format!("Failed to store pre-run hook {}: {}", hook.meta.id, e)
1198 }));
1199 }
1200 }
1201 }
1202 }
1203
1204 if let Some(hooks) = post_run_hooks {
1205 for hook in hooks {
1206 let result = sqlx::query!(
1207 r#"
1208 INSERT INTO run_outputs (run_id, phase, hook_id, config, output, success, error)
1209 VALUES ($1, 'post', $2, $3, $4, $5, $6)
1210 ON CONFLICT (run_id, phase, hook_id) DO UPDATE
1211 SET config = EXCLUDED.config,
1212 output = EXCLUDED.output,
1213 success = EXCLUDED.success,
1214 error = EXCLUDED.error
1215 "#,
1216 rid,
1217 hook.meta.id,
1218 hook.meta.config,
1219 hook.output,
1220 hook.meta.success,
1221 hook.meta.error
1222 )
1223 .execute(&state.pool)
1224 .await;
1225
1226 match result {
1227 Ok(_) => {
1228 post_run_count += 1;
1229 info!("Stored post-run hook: {}", hook.meta.id);
1230 }
1231 Err(e) => {
1232 error!("Failed to store post-run hook {}: {}", hook.meta.id, e);
1233 return Json(json!({
1234 "status": "error",
1235 "error": format!("Failed to store post-run hook {}: {}", hook.meta.id, e)
1236 }));
1237 }
1238 }
1239 }
1240 }
1241 }
1242
1243 info!(
1244 "Hook outputs stored: {} pre-run, {} post-run",
1245 pre_run_count, post_run_count
1246 );
1247
1248 let response = capsula_api_types::UploadResponse {
1249 status: "ok".to_string(),
1250 files_processed,
1251 total_bytes,
1252 pre_run_hooks: pre_run_count,
1253 post_run_hooks: post_run_count,
1254 };
1255 json_response(response)
1256}
1257
1258async fn download_file(
1259 State(state): State<AppState>,
1260 Path((run_id, file_path)): Path<(String, String)>,
1261) -> Result<Response, StatusCode> {
1262 info!("Downloading file: run_id={}, path={}", run_id, file_path);
1263
1264 let file_record = sqlx::query!(
1266 r#"
1267 SELECT storage_path, content_type, path as file_path
1268 FROM captured_files
1269 WHERE run_id = $1 AND path = $2
1270 "#,
1271 run_id,
1272 file_path
1273 )
1274 .fetch_optional(&state.pool)
1275 .await
1276 .map_err(|e| {
1277 error!("Database error while fetching file metadata: {}", e);
1278 StatusCode::INTERNAL_SERVER_ERROR
1279 })?;
1280
1281 let Some(record) = file_record else {
1282 info!("File not found: run_id={}, path={}", run_id, file_path);
1283 return Err(StatusCode::NOT_FOUND);
1284 };
1285
1286 let file_data = tokio::fs::read(&record.storage_path).await.map_err(|e| {
1288 error!(
1289 "Failed to read file from storage {}: {}",
1290 record.storage_path, e
1291 );
1292 StatusCode::INTERNAL_SERVER_ERROR
1293 })?;
1294
1295 info!(
1296 "Successfully read file: {} bytes from {}",
1297 file_data.len(),
1298 record.storage_path
1299 );
1300
1301 let content_type = record.content_type.unwrap_or_else(|| {
1303 mime_guess::from_path(&record.file_path)
1304 .first_or_octet_stream()
1305 .to_string()
1306 });
1307
1308 let filename = std::path::Path::new(&record.file_path)
1310 .file_name()
1311 .and_then(|n| n.to_str())
1312 .unwrap_or("download");
1313
1314 Response::builder()
1316 .status(StatusCode::OK)
1317 .header(header::CONTENT_TYPE, content_type)
1318 .header(
1319 header::CONTENT_DISPOSITION,
1320 content_disposition_inline(filename),
1321 )
1322 .body(Body::from(file_data))
1323 .map_err(|e| {
1324 error!("Failed to build response: {}", e);
1325 StatusCode::INTERNAL_SERVER_ERROR
1326 })
1327}
1328
1329fn content_disposition_inline(filename: &str) -> String {
1334 let ascii_fallback: String = filename
1335 .chars()
1336 .map(|c| {
1337 if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
1338 c
1339 } else {
1340 '_'
1341 }
1342 })
1343 .collect();
1344 let ascii_fallback = if ascii_fallback.is_empty() {
1345 "download".to_owned()
1346 } else {
1347 ascii_fallback
1348 };
1349 let encoded = utf8_percent_encode(filename, RFC5987_ATTR_CHAR);
1350 format!("inline; filename=\"{ascii_fallback}\"; filename*=UTF-8''{encoded}")
1351}
1352
1353fn sanitize_relative_path(candidate: &str) -> Result<String, &'static str> {
1358 if candidate.is_empty() {
1359 return Err("path is empty");
1360 }
1361 if candidate.contains('\0') {
1362 return Err("path contains NUL byte");
1363 }
1364
1365 let normalized = candidate.replace('\\', "/");
1370 let first_segment = normalized.split('/').next().unwrap_or("");
1371 let mut first_chars = first_segment.chars();
1372 if first_chars.next().is_some_and(|c| c.is_ascii_alphabetic())
1373 && first_chars.next() == Some(':')
1374 {
1375 return Err("path must be relative");
1376 }
1377
1378 let mut out = String::with_capacity(normalized.len());
1379 for component in StdPath::new(&normalized).components() {
1380 match component {
1381 Component::Normal(seg) => {
1382 let seg = seg.to_str().ok_or("path contains invalid UTF-8")?;
1383 if !out.is_empty() {
1384 out.push('/');
1385 }
1386 out.push_str(seg);
1387 }
1388 Component::CurDir => {}
1389 Component::ParentDir => return Err("path contains '..' segment"),
1390 Component::RootDir | Component::Prefix(_) => return Err("path must be relative"),
1391 }
1392 }
1393
1394 if out.is_empty() {
1395 return Err("path is empty");
1396 }
1397 Ok(out)
1398}
1399
1400#[cfg(test)]
1401#[expect(clippy::unwrap_used, reason = "tests may use unwrap for brevity")]
1402mod tests {
1403 use super::{content_disposition_inline, sanitize_relative_path};
1404
1405 #[test]
1406 fn sanitize_rejects_absolute_paths() {
1407 assert!(sanitize_relative_path("/etc/passwd").is_err());
1408 assert!(sanitize_relative_path("\\windows\\system32").is_err());
1409 }
1410
1411 #[test]
1412 fn sanitize_rejects_parent_traversal() {
1413 assert!(sanitize_relative_path("../etc/passwd").is_err());
1414 assert!(sanitize_relative_path("a/../b").is_err());
1415 assert!(sanitize_relative_path("a\\..\\b").is_err());
1416 }
1417
1418 #[test]
1419 fn sanitize_rejects_drive_prefix_and_nul() {
1420 assert!(sanitize_relative_path("C:\\Users").is_err());
1421 assert!(sanitize_relative_path("d:/foo").is_err());
1422 assert!(sanitize_relative_path("foo\0bar").is_err());
1423 assert!(sanitize_relative_path("").is_err());
1424 }
1425
1426 #[test]
1427 fn sanitize_accepts_normal_paths() {
1428 assert_eq!(sanitize_relative_path("foo.txt").unwrap(), "foo.txt");
1429 assert_eq!(
1430 sanitize_relative_path("dir/sub/file.log").unwrap(),
1431 "dir/sub/file.log"
1432 );
1433 assert_eq!(
1435 sanitize_relative_path("dir\\sub\\file.log").unwrap(),
1436 "dir/sub/file.log"
1437 );
1438 }
1439
1440 #[test]
1441 fn sanitize_normalizes_redundant_segments() {
1442 assert_eq!(sanitize_relative_path("a//b").unwrap(), "a/b");
1444 assert_eq!(sanitize_relative_path("./a/b").unwrap(), "a/b");
1445 assert_eq!(sanitize_relative_path("a/./b").unwrap(), "a/b");
1446 assert_eq!(sanitize_relative_path("a/b/").unwrap(), "a/b");
1447 }
1448
1449 #[test]
1450 fn content_disposition_strips_dangerous_chars_in_fallback() {
1451 let header = content_disposition_inline("a\"b\r\nc.txt");
1452 assert!(header.starts_with("inline; filename=\"a_b__c.txt\";"));
1453 assert!(!header.contains('\r'));
1455 assert!(!header.contains('\n'));
1456 }
1457
1458 #[test]
1459 fn content_disposition_encodes_unicode_in_filename_star() {
1460 let header = content_disposition_inline("日本語.txt");
1461 assert!(header.contains("filename*=UTF-8''"));
1462 assert!(header.contains("%E6%97%A5"));
1464 }
1465
1466 #[test]
1467 fn content_disposition_percent_encodes_attr_char_boundary() {
1468 let header = content_disposition_inline("abc.txt");
1470 assert!(header.contains("filename*=UTF-8''abc.txt"));
1471 let header = content_disposition_inline("a b\"c");
1472 assert!(header.contains("filename*=UTF-8''a%20b%22c"));
1473 }
1474}