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