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::HookOutputResponse>,
109 post_run_hooks: Vec<models::HookOutputResponse>,
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_index, hook_id, config, output, success, error
380 FROM run_outputs
381 WHERE run_id = $1
382 ORDER BY phase, hook_index
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::HookOutputResponse {
396 meta: models::HookMetaResponse {
397 id: row.hook_id,
398 config: row.config,
399 success: row.success,
400 error: row.error,
401 hook_index: row.hook_index,
402 },
403 output: row.output,
404 };
405
406 if row.phase == "pre" {
407 pre_hooks.push(hook_output);
408 } else if row.phase == "post" {
409 post_hooks.push(hook_output);
410 } else {
411 warn!("Unknown hook phase: {}", row.phase);
412 }
413 }
414
415 (pre_hooks, post_hooks)
416 }
417 Err(e) => {
418 error!("Failed to fetch hook outputs: {}", e);
419 (Vec::new(), Vec::new())
420 }
421 };
422
423 let files_result = sqlx::query_as!(
425 models::CapturedFile,
426 r#"
427 SELECT path, size, hash, storage_path, content_type
428 FROM captured_files
429 WHERE run_id = $1
430 ORDER BY path
431 "#,
432 id
433 )
434 .fetch_all(&state.pool)
435 .await;
436
437 let files = match files_result {
438 Ok(files) => files,
439 Err(e) => {
440 error!("Failed to fetch captured files: {}", e);
441 Vec::new()
442 }
443 };
444
445 Ok(RunDetailTemplate {
446 run,
447 pre_run_hooks,
448 post_run_hooks,
449 files,
450 })
451}
452
453async fn health_check(State(state): State<AppState>) -> impl IntoResponse {
454 match sqlx::query("SELECT 1").fetch_one(&state.pool).await {
455 Ok(_) => Json(json!({
456 "status": "ok",
457 "database": "connected"
458 })),
459 Err(e) => Json(json!({
460 "status": "error",
461 "database": "disconnected",
462 "error": e.to_string()
463 })),
464 }
465}
466
467async fn list_vaults(State(state): State<AppState>) -> impl IntoResponse {
468 info!("Listing all vaults");
469
470 match fetch_vault_list(&state.pool).await {
471 Ok(vaults) => {
472 info!("Found {} vaults", vaults.len());
473 json_response(capsula_api_types::VaultsResponse {
474 status: "ok".to_string(),
475 vaults,
476 })
477 }
478 Err(e) => {
479 error!("Failed to list vaults: {}", e);
480 json_response(capsula_api_types::ErrorResponse {
481 status: "error".to_string(),
482 error: e.to_string(),
483 })
484 }
485 }
486}
487
488async fn get_vault_info(
489 State(state): State<AppState>,
490 Path(name): Path<String>,
491) -> impl IntoResponse {
492 info!("Getting vault info: {}", name);
493
494 let result = sqlx::query!(
495 r#"
496 SELECT vault as name, COUNT(*) as "run_count!"
497 FROM runs
498 WHERE vault = $1
499 GROUP BY vault
500 "#,
501 name
502 )
503 .fetch_optional(&state.pool)
504 .await;
505
506 match result {
507 Ok(Some(row)) => {
508 let vault = VaultInfo {
509 name: row.name,
510 run_count: row.run_count,
511 };
512 info!("Found vault: {} with {} runs", vault.name, vault.run_count);
513 let response = capsula_api_types::VaultExistsResponse {
514 status: "ok".to_string(),
515 exists: true,
516 vault: Some(vault),
517 };
518 json_response(response)
519 }
520 Ok(None) => {
521 info!("Vault not found: {}", name);
522 let response = capsula_api_types::VaultExistsResponse {
523 status: "ok".to_string(),
524 exists: false,
525 vault: None,
526 };
527 json_response(response)
528 }
529 Err(e) => {
530 error!("Failed to get vault info: {}", e);
531 let response = capsula_api_types::ErrorResponse {
532 status: "error".to_string(),
533 error: e.to_string(),
534 };
535 json_response(response)
536 }
537 }
538}
539
540async fn list_runs(
541 State(state): State<AppState>,
542 Query(params): Query<models::ListRunsQuery>,
543) -> impl IntoResponse {
544 let limit = params.limit.unwrap_or(100);
545 let offset = params.offset.unwrap_or(0);
546
547 if let Some(ref vault) = params.vault {
548 info!(
549 "Listing runs for vault: {} (limit={}, offset={})",
550 vault, limit, offset
551 );
552 } else {
553 info!("Listing all runs (limit={}, offset={})", limit, offset);
554 }
555
556 let result = fetch_runs(&state.pool, params.vault.as_deref(), limit, offset).await;
557
558 match result {
559 Ok(runs) => {
560 info!("Found {} runs", runs.len());
561 Json(json!({
562 "status": "ok",
563 "runs": runs,
564 "limit": limit,
565 "offset": offset
566 }))
567 }
568 Err(e) => {
569 error!("Failed to list runs: {}", e);
570 Json(json!({
571 "status": "error",
572 "error": e.to_string()
573 }))
574 }
575 }
576}
577
578#[expect(clippy::too_many_lines, reason = "TODO: Refactor later")]
580async fn search_runs(
581 State(state): State<AppState>,
582 Json(request): Json<models::SearchRunsRequest>,
583) -> impl IntoResponse {
584 info!(
585 "Searching runs: vault={:?}, hook_filters={}",
586 request.vault,
587 request.hook_filters.len()
588 );
589
590 let builder = match query::RunQueryBuilder::from_request(&request) {
592 Ok(b) => b,
593 Err(e) => {
594 error!("Failed to build query: {}", e);
595 return Json(json!({
596 "status": "error",
597 "error": e.to_string()
598 }));
599 }
600 };
601
602 let query_sql = builder.build_query();
603 let count_sql = builder.build_count_query();
604 let bind_values = builder.bind_values();
605 info!("Executing search query: {}", query_sql);
610
611 let total: i64 = {
613 let mut query = sqlx::query_scalar::<_, i64>(sqlx::AssertSqlSafe(count_sql));
614 for value in bind_values {
615 query = match value {
616 query::BindValue::String(s) => query.bind(s.clone()),
617 query::BindValue::I32(i) => query.bind(*i),
618 query::BindValue::I64(i) => query.bind(*i),
619 query::BindValue::DateTime(dt) => query.bind(*dt),
620 query::BindValue::Bool(b) => query.bind(*b),
621 };
622 }
623 match query.fetch_one(&state.pool).await {
624 Ok(count) => count,
625 Err(e) => {
626 error!("Failed to execute count query: {}", e);
627 return Json(json!({
628 "status": "error",
629 "error": "Query execution failed"
630 }));
631 }
632 }
633 };
634
635 let runs: Vec<models::Run> = {
637 let mut query = sqlx::query_as::<_, models::Run>(sqlx::AssertSqlSafe(query_sql));
638 for value in bind_values {
639 query = match value {
640 query::BindValue::String(s) => query.bind(s.clone()),
641 query::BindValue::I32(i) => query.bind(*i),
642 query::BindValue::I64(i) => query.bind(*i),
643 query::BindValue::DateTime(dt) => query.bind(*dt),
644 query::BindValue::Bool(b) => query.bind(*b),
645 };
646 }
647 match query.fetch_all(&state.pool).await {
648 Ok(runs) => runs,
649 Err(e) => {
650 error!("Failed to execute search query: {}", e);
651 return Json(json!({
652 "status": "error",
653 "error": "Query execution failed"
654 }));
655 }
656 }
657 };
658
659 info!("Found {} runs (total: {})", runs.len(), total);
660
661 let include_files = request.include.contains(&models::IncludeField::Files);
663 let include_stdout = request.include.contains(&models::IncludeField::Stdout);
664 let include_stderr = request.include.contains(&models::IncludeField::Stderr);
665 let include_hooks = request.include.contains(&models::IncludeField::Hooks);
666
667 let mut results = Vec::with_capacity(runs.len());
669 for run in runs {
670 let files = if include_files {
671 match sqlx::query_as::<_, models::CapturedFile>(
672 "SELECT path, size, hash, storage_path, content_type \
673 FROM captured_files WHERE run_id = $1 ORDER BY path",
674 )
675 .bind(&run.id)
676 .fetch_all(&state.pool)
677 .await
678 {
679 Ok(files) => Some(
680 files
681 .into_iter()
682 .map(|f| models::FileInfo {
683 path: f.path.clone(),
684 size: f.size,
685 hash: f.hash,
686 url: format!("/api/v1/runs/{}/files/{}", run.id, f.path),
687 })
688 .collect(),
689 ),
690 Err(e) => {
691 warn!("Failed to fetch files for run {}: {}", run.id, e);
692 None
693 }
694 }
695 } else {
696 None
697 };
698
699 let (pre_run_hooks, post_run_hooks) = if include_hooks {
700 match sqlx::query_as::<_, models::RunOutputRow>(
701 "SELECT phase, hook_index, hook_id, config, output, success, error \
702 FROM run_outputs WHERE run_id = $1 ORDER BY phase, hook_index",
703 )
704 .bind(&run.id)
705 .fetch_all(&state.pool)
706 .await
707 {
708 Ok(rows) => {
709 let mut pre_hooks = Vec::new();
710 let mut post_hooks = Vec::new();
711 for row in rows {
712 let hook_output = models::HookOutputResponse {
713 meta: models::HookMetaResponse {
714 id: row.hook_id,
715 config: row.config,
716 success: row.success,
717 error: row.error,
718 hook_index: row.hook_index,
719 },
720 output: row.output,
721 };
722 if row.phase == "pre" {
723 pre_hooks.push(hook_output);
724 } else if row.phase == "post" {
725 post_hooks.push(hook_output);
726 } else {
727 }
729 }
730 (Some(pre_hooks), Some(post_hooks))
731 }
732 Err(e) => {
733 warn!("Failed to fetch hooks for run {}: {}", run.id, e);
734 (None, None)
735 }
736 }
737 } else {
738 (None, None)
739 };
740
741 results.push(models::SearchRunResult {
742 id: run.id,
743 name: run.name,
744 timestamp: run.timestamp,
745 vault: run.vault,
746 command: run.command,
747 project_root: run.project_root,
748 exit_code: run.exit_code,
749 duration_ms: run.duration_ms,
750 stdout: if include_stdout { run.stdout } else { None },
751 stderr: if include_stderr { run.stderr } else { None },
752 files,
753 pre_run_hooks,
754 post_run_hooks,
755 });
756 }
757
758 let response = models::SearchRunsResponse {
759 status: "ok".to_string(),
760 total,
761 runs: results,
762 };
763
764 json_response(response)
765}
766
767async fn create_run(
768 State(state): State<AppState>,
769 Json(request): Json<models::CreateRunRequest>,
770) -> impl IntoResponse {
771 info!("Creating run: {}", request.id);
772
773 let result = sqlx::query!(
774 r#"
775 INSERT INTO runs (
776 id, name, timestamp, command, vault, project_root,
777 exit_code, duration_ms, stdout, stderr
778 ) VALUES (
779 $1, $2, $3, $4, $5, $6,
780 $7, $8, $9, $10
781 )
782 "#,
783 request.id,
784 request.name,
785 request.timestamp,
786 request.command,
787 request.vault,
788 request.project_root,
789 request.exit_code,
790 request.duration_ms,
791 request.stdout,
792 request.stderr
793 )
794 .execute(&state.pool)
795 .await;
796
797 match result {
798 Ok(_) => {
799 info!("Run created successfully");
800 (
801 StatusCode::CREATED,
802 Json(json!({
803 "status": "created",
804 "run": request
805 })),
806 )
807 }
808 Err(e) => {
809 error!("Failed to insert run: {}", e);
810 let status = if e.to_string().contains("duplicate key") {
811 StatusCode::CONFLICT
812 } else {
813 StatusCode::INTERNAL_SERVER_ERROR
814 };
815 (
816 status,
817 Json(json!({
818 "status": "error",
819 "error": e.to_string()
820 })),
821 )
822 }
823 }
824}
825
826async fn get_run(State(state): State<AppState>, Path(id): Path<String>) -> impl IntoResponse {
827 info!("Getting run: {}", id);
828
829 let result = fetch_run_by_id(&state.pool, &id).await;
830
831 match result {
832 Ok(Some(run)) => {
833 info!("Found run: {}", run.id);
834
835 let hook_outputs_result = sqlx::query_as!(
837 models::RunOutputRow,
838 r#"
839 SELECT phase, hook_index, hook_id, config, output, success, error
840 FROM run_outputs
841 WHERE run_id = $1
842 ORDER BY phase, hook_index
843 "#,
844 id
845 )
846 .fetch_all(&state.pool)
847 .await;
848
849 let (pre_run_hooks, post_run_hooks) = match hook_outputs_result {
850 Ok(rows) => {
851 let mut pre_hooks = Vec::new();
852 let mut post_hooks = Vec::new();
853
854 for row in rows {
855 let hook_output = models::HookOutputResponse {
856 meta: models::HookMetaResponse {
857 id: row.hook_id,
858 config: row.config,
859 success: row.success,
860 error: row.error,
861 hook_index: row.hook_index,
862 },
863 output: row.output,
864 };
865
866 if row.phase == "pre" {
867 pre_hooks.push(hook_output);
868 } else if row.phase == "post" {
869 post_hooks.push(hook_output);
870 } else {
871 warn!("Unknown hook phase: {}", row.phase);
872 }
873 }
874
875 info!(
876 "Found {} pre-run hooks and {} post-run hooks",
877 pre_hooks.len(),
878 post_hooks.len()
879 );
880 (pre_hooks, post_hooks)
881 }
882 Err(e) => {
883 error!("Failed to fetch hook outputs: {}", e);
884 (Vec::new(), Vec::new())
885 }
886 };
887
888 Json(json!({
889 "status": "ok",
890 "run": run,
891 "pre_run_hooks": pre_run_hooks,
892 "post_run_hooks": post_run_hooks
893 }))
894 }
895 Ok(None) => {
896 info!("Run not found: {}", id);
897 Json(json!({
898 "status": "not_found",
899 "error": format!("Run with id {} not found", id)
900 }))
901 }
902 Err(e) => {
903 error!("Failed to retrieve run: {}", e);
904 Json(json!({
905 "status": "error",
906 "error": e.to_string()
907 }))
908 }
909 }
910}
911
912#[expect(clippy::too_many_lines, reason = "TODO: Refactor later")]
913#[expect(
914 clippy::else_if_without_else,
915 reason = "There is `continue` or `return` in each branch, so `else` is redundant"
916)]
917async fn upload_files(
918 State(state): State<AppState>,
919 mut multipart: Multipart,
920) -> impl IntoResponse {
921 let storage_path = &state.storage_path;
922 info!("Received file upload request");
923
924 if let Err(e) = tokio::fs::create_dir_all(&storage_path).await {
925 error!(
926 "Failed to create storage directory at {}: {}",
927 storage_path.display(),
928 e
929 );
930 return Json(json!({
931 "status": "error",
932 "error": format!("Failed to create storage directory: {}", e)
933 }));
934 }
935
936 let mut files_processed = 0;
937 let mut total_bytes = 0u64;
938 let mut run_id: Option<String> = None;
939 let mut pending_paths: VecDeque<String> = VecDeque::new();
940 let mut pre_run_hooks: Option<Vec<models::HookOutputUpload>> = None;
941 let mut post_run_hooks: Option<Vec<models::HookOutputUpload>> = None;
942
943 while let Ok(Some(field)) = multipart.next_field().await {
944 let field_name = field.name().unwrap_or("unknown").to_string();
945
946 if field_name == "run_id" {
947 match field.text().await {
948 Ok(text) => {
949 run_id = Some(text);
950 continue;
951 }
952 Err(e) => {
953 error!("Failed to read run_id field: {}", e);
954 return Json(json!({
955 "status": "error",
956 "error": format!("Failed to read run_id: {}", e)
957 }));
958 }
959 }
960 } else if field_name == "path" {
961 match field.text().await {
962 Ok(text) => {
963 pending_paths.push_back(text);
964 continue;
965 }
966 Err(e) => {
967 error!("Failed to read path field: {}", e);
968 return Json(json!({
969 "status": "error",
970 "error": format!("Failed to read path: {}", e)
971 }));
972 }
973 }
974 } else if field_name == "pre_run" {
975 match field.text().await {
976 Ok(text) => match serde_json::from_str::<Vec<models::HookOutputUpload>>(&text) {
977 Ok(hooks) => {
978 info!("Parsed {} pre-run hooks", hooks.len());
979 pre_run_hooks = Some(hooks);
980 continue;
981 }
982 Err(e) => {
983 error!("Failed to parse pre_run JSON: {}", e);
984 return Json(json!({
985 "status": "error",
986 "error": format!("Failed to parse pre_run JSON: {}", e)
987 }));
988 }
989 },
990 Err(e) => {
991 error!("Failed to read pre_run field: {}", e);
992 return Json(json!({
993 "status": "error",
994 "error": format!("Failed to read pre_run: {}", e)
995 }));
996 }
997 }
998 } else if field_name == "post_run" {
999 match field.text().await {
1000 Ok(text) => match serde_json::from_str::<Vec<models::HookOutputUpload>>(&text) {
1001 Ok(hooks) => {
1002 info!("Parsed {} post-run hooks", hooks.len());
1003 post_run_hooks = Some(hooks);
1004 continue;
1005 }
1006 Err(e) => {
1007 error!("Failed to parse post_run JSON: {}", e);
1008 return Json(json!({
1009 "status": "error",
1010 "error": format!("Failed to parse post_run JSON: {}", e)
1011 }));
1012 }
1013 },
1014 Err(e) => {
1015 error!("Failed to read post_run field: {}", e);
1016 return Json(json!({
1017 "status": "error",
1018 "error": format!("Failed to read post_run: {}", e)
1019 }));
1020 }
1021 }
1022 }
1023
1024 let file_name = field.file_name().unwrap_or("unknown").to_string();
1025 let content_type = field
1026 .content_type()
1027 .unwrap_or("application/octet-stream")
1028 .to_string();
1029
1030 info!(
1031 "Processing file: field_name={}, file_name={}, content_type={}",
1032 field_name, file_name, content_type
1033 );
1034
1035 match field.bytes().await {
1036 Ok(data) => {
1037 let size = data.len();
1038 total_bytes += size as u64;
1039 let Ok(size_i64) = i64::try_from(size) else {
1040 error!("File too large to store size in database: {} bytes", size);
1041 return Json(json!({
1042 "status": "error",
1043 "error": "File too large to store size in database"
1044 }));
1045 };
1046
1047 let mut hasher = Sha256::new();
1048 hasher.update(&data);
1049 let hash = hex_encode(hasher.finalize().as_ref());
1050
1051 info!("File hash: {}, size: {} bytes", hash, size);
1052
1053 let hash_dir = &hash[0..2];
1054 let file_storage_dir = storage_path.join(hash_dir);
1055 if let Err(e) = tokio::fs::create_dir_all(&file_storage_dir).await {
1056 error!(
1057 "Failed to create hash directory at {}: {}",
1058 file_storage_dir.display(),
1059 e
1060 );
1061 return Json(json!({
1062 "status": "error",
1063 "error": format!("Failed to create storage directory: {}", e)
1064 }));
1065 }
1066
1067 let file_storage_path = file_storage_dir.join(&hash);
1068 let storage_path_str = file_storage_path.to_string_lossy().to_string();
1069
1070 if file_storage_path.exists() {
1071 info!("File already exists (deduplicated): {}", storage_path_str);
1072 } else {
1073 if let Err(e) = tokio::fs::write(&file_storage_path, &data).await {
1074 error!("Failed to write file: {}", e);
1075 return Json(json!({
1076 "status": "error",
1077 "error": format!("Failed to write file: {}", e)
1078 }));
1079 }
1080 info!("Saved new file: {}", storage_path_str);
1081 }
1082
1083 if let Some(ref rid) = run_id {
1084 let candidate_path = pending_paths
1085 .pop_front()
1086 .or_else(|| {
1087 if file_name == "unknown" {
1088 None
1089 } else {
1090 Some(file_name.clone())
1091 }
1092 })
1093 .unwrap_or_else(|| {
1094 if field_name != "unknown" && field_name != "file" {
1095 field_name.clone()
1096 } else {
1097 format!("file-{}", files_processed + 1)
1098 }
1099 });
1100
1101 let relative_path = match sanitize_relative_path(&candidate_path) {
1102 Ok(p) => p,
1103 Err(reason) => {
1104 warn!(
1105 "Rejected captured-file path '{}': {}",
1106 candidate_path, reason
1107 );
1108 return Json(json!({
1109 "status": "error",
1110 "error": format!("Invalid file path '{}': {}", candidate_path, reason)
1111 }));
1112 }
1113 };
1114
1115 let result = sqlx::query!(
1116 r#"
1117 INSERT INTO captured_files (run_id, path, size, hash, storage_path, content_type)
1118 VALUES ($1, $2, $3, $4, $5, $6)
1119 ON CONFLICT (run_id, path) DO UPDATE
1120 SET size = EXCLUDED.size,
1121 hash = EXCLUDED.hash,
1122 storage_path = EXCLUDED.storage_path,
1123 content_type = EXCLUDED.content_type
1124 "#,
1125 rid,
1126 relative_path,
1127 size_i64,
1128 hash,
1129 storage_path_str,
1130 content_type
1131 )
1132 .execute(&state.pool)
1133 .await;
1134
1135 match result {
1136 Ok(_) => {
1137 info!("Stored file metadata in database");
1138 }
1139 Err(e) => {
1140 error!("Failed to store file metadata: {}", e);
1141 return Json(json!({
1142 "status": "error",
1143 "error": format!("Failed to store file metadata: {}", e)
1144 }));
1145 }
1146 }
1147 }
1148
1149 files_processed += 1;
1150 info!("Successfully processed file: {} bytes", size);
1151 }
1152 Err(e) => {
1153 error!("Failed to read file field: {}", e);
1154 return Json(json!({
1155 "status": "error",
1156 "error": format!("Failed to read file: {}", e)
1157 }));
1158 }
1159 }
1160 }
1161
1162 info!(
1163 "Upload complete: {} files, {} bytes total",
1164 files_processed, total_bytes
1165 );
1166
1167 let mut pre_run_count = 0;
1169 let mut post_run_count = 0;
1170
1171 if let Some(ref rid) = run_id {
1172 if let Some(hooks) = pre_run_hooks {
1173 for (idx, hook) in hooks.iter().enumerate() {
1181 let hook_index = i32::try_from(idx).unwrap_or(i32::MAX);
1182
1183 let result = sqlx::query!(
1184 r#"
1185 INSERT INTO run_outputs (run_id, phase, hook_id, config, output, success, error, hook_index)
1186 VALUES ($1, 'pre', $2, $3, $4, $5, $6, $7)
1187 ON CONFLICT (run_id, phase, hook_index) DO UPDATE
1188 SET hook_id = EXCLUDED.hook_id,
1189 config = EXCLUDED.config,
1190 output = EXCLUDED.output,
1191 success = EXCLUDED.success,
1192 error = EXCLUDED.error
1193 "#,
1194 rid,
1195 hook.meta.id,
1196 hook.meta.config,
1197 hook.output,
1198 hook.meta.success,
1199 hook.meta.error,
1200 hook_index
1201 )
1202 .execute(&state.pool)
1203 .await;
1204
1205 match result {
1206 Ok(_) => {
1207 pre_run_count += 1;
1208 info!("Stored pre-run hook: {}", hook.meta.id);
1209 }
1210 Err(e) => {
1211 error!("Failed to store pre-run hook {}: {}", hook.meta.id, e);
1212 return Json(json!({
1213 "status": "error",
1214 "error": format!("Failed to store pre-run hook {}: {}", hook.meta.id, e)
1215 }));
1216 }
1217 }
1218 }
1219 }
1220
1221 if let Some(hooks) = post_run_hooks {
1222 for (idx, hook) in hooks.iter().enumerate() {
1223 let hook_index = i32::try_from(idx).unwrap_or(i32::MAX);
1224
1225 let result = sqlx::query!(
1226 r#"
1227 INSERT INTO run_outputs (run_id, phase, hook_id, config, output, success, error, hook_index)
1228 VALUES ($1, 'post', $2, $3, $4, $5, $6, $7)
1229 ON CONFLICT (run_id, phase, hook_index) DO UPDATE
1230 SET hook_id = EXCLUDED.hook_id,
1231 config = EXCLUDED.config,
1232 output = EXCLUDED.output,
1233 success = EXCLUDED.success,
1234 error = EXCLUDED.error
1235 "#,
1236 rid,
1237 hook.meta.id,
1238 hook.meta.config,
1239 hook.output,
1240 hook.meta.success,
1241 hook.meta.error,
1242 hook_index
1243 )
1244 .execute(&state.pool)
1245 .await;
1246
1247 match result {
1248 Ok(_) => {
1249 post_run_count += 1;
1250 info!("Stored post-run hook: {}", hook.meta.id);
1251 }
1252 Err(e) => {
1253 error!("Failed to store post-run hook {}: {}", hook.meta.id, e);
1254 return Json(json!({
1255 "status": "error",
1256 "error": format!("Failed to store post-run hook {}: {}", hook.meta.id, e)
1257 }));
1258 }
1259 }
1260 }
1261 }
1262 }
1263
1264 info!(
1265 "Hook outputs stored: {} pre-run, {} post-run",
1266 pre_run_count, post_run_count
1267 );
1268
1269 let response = capsula_api_types::UploadResponse {
1270 status: "ok".to_string(),
1271 files_processed,
1272 total_bytes,
1273 pre_run_hooks: pre_run_count,
1274 post_run_hooks: post_run_count,
1275 };
1276 json_response(response)
1277}
1278
1279async fn download_file(
1280 State(state): State<AppState>,
1281 Path((run_id, file_path)): Path<(String, String)>,
1282) -> Result<Response, StatusCode> {
1283 info!("Downloading file: run_id={}, path={}", run_id, file_path);
1284
1285 let file_record = sqlx::query!(
1287 r#"
1288 SELECT storage_path, content_type, path as file_path
1289 FROM captured_files
1290 WHERE run_id = $1 AND path = $2
1291 "#,
1292 run_id,
1293 file_path
1294 )
1295 .fetch_optional(&state.pool)
1296 .await
1297 .map_err(|e| {
1298 error!("Database error while fetching file metadata: {}", e);
1299 StatusCode::INTERNAL_SERVER_ERROR
1300 })?;
1301
1302 let Some(record) = file_record else {
1303 info!("File not found: run_id={}, path={}", run_id, file_path);
1304 return Err(StatusCode::NOT_FOUND);
1305 };
1306
1307 let file_data = tokio::fs::read(&record.storage_path).await.map_err(|e| {
1309 error!(
1310 "Failed to read file from storage {}: {}",
1311 record.storage_path, e
1312 );
1313 StatusCode::INTERNAL_SERVER_ERROR
1314 })?;
1315
1316 info!(
1317 "Successfully read file: {} bytes from {}",
1318 file_data.len(),
1319 record.storage_path
1320 );
1321
1322 let content_type = record.content_type.unwrap_or_else(|| {
1324 mime_guess::from_path(&record.file_path)
1325 .first_or_octet_stream()
1326 .to_string()
1327 });
1328
1329 let filename = std::path::Path::new(&record.file_path)
1331 .file_name()
1332 .and_then(|n| n.to_str())
1333 .unwrap_or("download");
1334
1335 Response::builder()
1337 .status(StatusCode::OK)
1338 .header(header::CONTENT_TYPE, content_type)
1339 .header(
1340 header::CONTENT_DISPOSITION,
1341 content_disposition_inline(filename),
1342 )
1343 .body(Body::from(file_data))
1344 .map_err(|e| {
1345 error!("Failed to build response: {}", e);
1346 StatusCode::INTERNAL_SERVER_ERROR
1347 })
1348}
1349
1350fn content_disposition_inline(filename: &str) -> String {
1355 let ascii_fallback: String = filename
1356 .chars()
1357 .map(|c| {
1358 if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
1359 c
1360 } else {
1361 '_'
1362 }
1363 })
1364 .collect();
1365 let ascii_fallback = if ascii_fallback.is_empty() {
1366 "download".to_owned()
1367 } else {
1368 ascii_fallback
1369 };
1370 let encoded = utf8_percent_encode(filename, RFC5987_ATTR_CHAR);
1371 format!("inline; filename=\"{ascii_fallback}\"; filename*=UTF-8''{encoded}")
1372}
1373
1374fn sanitize_relative_path(candidate: &str) -> Result<String, &'static str> {
1379 if candidate.is_empty() {
1380 return Err("path is empty");
1381 }
1382 if candidate.contains('\0') {
1383 return Err("path contains NUL byte");
1384 }
1385
1386 let normalized = candidate.replace('\\', "/");
1391 let first_segment = normalized.split('/').next().unwrap_or("");
1392 let mut first_chars = first_segment.chars();
1393 if first_chars.next().is_some_and(|c| c.is_ascii_alphabetic())
1394 && first_chars.next() == Some(':')
1395 {
1396 return Err("path must be relative");
1397 }
1398
1399 let mut out = String::with_capacity(normalized.len());
1400 for component in StdPath::new(&normalized).components() {
1401 match component {
1402 Component::Normal(seg) => {
1403 let seg = seg.to_str().ok_or("path contains invalid UTF-8")?;
1404 if !out.is_empty() {
1405 out.push('/');
1406 }
1407 out.push_str(seg);
1408 }
1409 Component::CurDir => {}
1410 Component::ParentDir => return Err("path contains '..' segment"),
1411 Component::RootDir | Component::Prefix(_) => return Err("path must be relative"),
1412 }
1413 }
1414
1415 if out.is_empty() {
1416 return Err("path is empty");
1417 }
1418 Ok(out)
1419}
1420
1421#[cfg(test)]
1422mod tests {
1423 use super::{content_disposition_inline, sanitize_relative_path};
1424
1425 #[test]
1426 fn sanitize_rejects_absolute_paths() {
1427 assert!(sanitize_relative_path("/etc/passwd").is_err());
1428 assert!(sanitize_relative_path("\\windows\\system32").is_err());
1429 }
1430
1431 #[test]
1432 fn sanitize_rejects_parent_traversal() {
1433 assert!(sanitize_relative_path("../etc/passwd").is_err());
1434 assert!(sanitize_relative_path("a/../b").is_err());
1435 assert!(sanitize_relative_path("a\\..\\b").is_err());
1436 }
1437
1438 #[test]
1439 fn sanitize_rejects_drive_prefix_and_nul() {
1440 assert!(sanitize_relative_path("C:\\Users").is_err());
1441 assert!(sanitize_relative_path("d:/foo").is_err());
1442 assert!(sanitize_relative_path("foo\0bar").is_err());
1443 assert!(sanitize_relative_path("").is_err());
1444 }
1445
1446 #[test]
1447 fn sanitize_accepts_normal_paths() {
1448 assert_eq!(sanitize_relative_path("foo.txt").unwrap(), "foo.txt");
1449 assert_eq!(
1450 sanitize_relative_path("dir/sub/file.log").unwrap(),
1451 "dir/sub/file.log"
1452 );
1453 assert_eq!(
1455 sanitize_relative_path("dir\\sub\\file.log").unwrap(),
1456 "dir/sub/file.log"
1457 );
1458 }
1459
1460 #[test]
1461 fn sanitize_normalizes_redundant_segments() {
1462 assert_eq!(sanitize_relative_path("a//b").unwrap(), "a/b");
1464 assert_eq!(sanitize_relative_path("./a/b").unwrap(), "a/b");
1465 assert_eq!(sanitize_relative_path("a/./b").unwrap(), "a/b");
1466 assert_eq!(sanitize_relative_path("a/b/").unwrap(), "a/b");
1467 }
1468
1469 #[test]
1470 fn content_disposition_strips_dangerous_chars_in_fallback() {
1471 let header = content_disposition_inline("a\"b\r\nc.txt");
1472 assert!(header.starts_with("inline; filename=\"a_b__c.txt\";"));
1473 assert!(!header.contains('\r'));
1475 assert!(!header.contains('\n'));
1476 }
1477
1478 #[test]
1479 fn content_disposition_encodes_unicode_in_filename_star() {
1480 let header = content_disposition_inline("日本語.txt");
1481 assert!(header.contains("filename*=UTF-8''"));
1482 assert!(header.contains("%E6%97%A5"));
1484 }
1485
1486 #[test]
1487 fn content_disposition_percent_encodes_attr_char_boundary() {
1488 let header = content_disposition_inline("abc.txt");
1490 assert!(header.contains("filename*=UTF-8''abc.txt"));
1491 let header = content_disposition_inline("a b\"c");
1492 assert!(header.contains("filename*=UTF-8''a%20b%22c"));
1493 }
1494}