//! `/api/v1/admin/apps/{id}/files*` — minimal files admin endpoints //! backing the dashboard's files view (v1.1.5). //! //! Two operations only, both operator-facing: //! * `GET /apps/{id}/files?collection=&cursor=&limit=` — list file //! metadata for a collection (cursor-paginated). //! * `DELETE /apps/{id}/files/{collection}/{file_id}` — remove a file. //! //! These talk to the `FilesRepo` directly (like `triggers_api` talks to //! `TriggerRepo`), guarded by the same capability model as the SDK //! (`AppFilesRead` / `AppFilesWrite`). **Admin deletes do NOT emit a //! `files:delete` trigger event** — they're operator cleanup actions, //! not script mutations (see HANDBACK §7). The capability binds to the //! resource's `app_id` after the app is loaded. use std::sync::Arc; use axum::body::Body; use axum::extract::{Path, Query, State}; use axum::http::header::{CONTENT_DISPOSITION, CONTENT_LENGTH, CONTENT_TYPE}; use axum::http::StatusCode; use axum::response::{IntoResponse, Json, Response}; use axum::routing::get; use axum::{Extension, Router}; use picloud_shared::{validate_files_collection, AppId, Principal}; use serde::{Deserialize, Serialize}; use serde_json::json; use uuid::Uuid; use crate::app_repo::AppRepository; use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability}; use crate::files_repo::{FilesRepo, FilesRepoError}; #[derive(Clone)] pub struct FilesAdminState { pub files: Arc, pub apps: Arc, pub authz: Arc, } pub fn files_admin_router(state: FilesAdminState) -> Router { Router::new() .route("/apps/{app_id}/files", get(list_files)) .route( "/apps/{app_id}/files/{collection}/{file_id}", get(get_file).delete(delete_file), ) .with_state(state) } #[derive(Debug, Deserialize)] pub struct ListFilesQuery { pub collection: String, #[serde(default)] pub cursor: Option, #[serde(default)] pub limit: Option, } #[derive(Debug, Serialize)] struct FileMetaDto { id: String, collection: String, name: String, content_type: String, size: u64, checksum: String, created_at: String, updated_at: String, } #[derive(Debug, Serialize)] struct ListFilesResponse { files: Vec, next_cursor: Option, } async fn list_files( State(s): State, Extension(principal): Extension, Path(id_or_slug): Path, Query(q): Query, ) -> Result, FilesApiError> { let app_id = resolve_app(&*s.apps, &id_or_slug).await?; require( s.authz.as_ref(), &principal, Capability::AppFilesRead(app_id), ) .await?; // Audit 2026-06-11 (F-FS-002) — the admin endpoints hit the repo // directly (not via FilesServiceImpl, which validates), so a // traversal-shaped collection would otherwise only be caught by the // repo's `guard_collection` as an opaque 500. Validate here for a // clean 422 and as the outer layer of the belt-and-suspenders guard. validate_files_collection(&q.collection).map_err(|e| FilesApiError::Invalid(e.to_string()))?; let page = s .files .list( app_id, &q.collection, q.cursor.as_deref(), q.limit.unwrap_or(0), ) .await?; let files = page .files .into_iter() .map(|m| FileMetaDto { id: m.id.to_string(), collection: m.collection, name: m.name, content_type: m.content_type, size: m.size, checksum: m.checksum, created_at: m.created_at.to_rfc3339(), updated_at: m.updated_at.to_rfc3339(), }) .collect(); Ok(Json(ListFilesResponse { files, next_cursor: page.next_cursor, })) } /// `GET /apps/{id}/files/{collection}/{file_id}` — stream the file's /// bytes as a download. The dashboard's Download button hits this; /// metadata is read via `FilesRepo::head`, bytes via `FilesRepo::get` /// (which checksum-verifies on read). /// /// Audit 2026-06-11 C-2 — response hardening. The previous handler /// served `Content-Disposition: inline` with the user-supplied /// `Content-Type` and no `nosniff` / CSP, so an SVG or HTML upload /// rendered same-origin and hijacked the admin session. Now: /// * `Content-Disposition: attachment` always. /// * `Content-Type` re-sanitized to the storage allowlist as /// belt-and-suspenders (`files_service::create` / `update` already /// coerce at write, but older rows may pre-date that change). /// * `X-Content-Type-Options: nosniff` blocks MIME-sniffing fallbacks. /// * Restrictive CSP scopes any residual rendering attempt. async fn get_file( State(s): State, Extension(principal): Extension, Path((id_or_slug, collection, file_id)): Path<(String, String, String)>, ) -> Result { let app_id = resolve_app(&*s.apps, &id_or_slug).await?; require( s.authz.as_ref(), &principal, Capability::AppFilesRead(app_id), ) .await?; validate_files_collection(&collection).map_err(|e| FilesApiError::Invalid(e.to_string()))?; let id = Uuid::parse_str(&file_id).map_err(|_| FilesApiError::NotFound)?; let meta = s .files .head(app_id, &collection, id) .await? .ok_or(FilesApiError::NotFound)?; let bytes = s .files .get(app_id, &collection, id) .await? .ok_or(FilesApiError::NotFound)?; let safe_ct = picloud_shared::sanitize_stored_content_type(&meta.content_type); // The stored name is upload-supplied; sanitize to header-safe ASCII so the // response builder can never error on a control byte (which `.expect()` // below would turn into a per-request panic). let disposition = format!( "attachment; filename=\"{}\"", picloud_shared::sanitize_stored_filename(&meta.name) ); let len = bytes.len(); Ok(Response::builder() .status(StatusCode::OK) .header(CONTENT_TYPE, safe_ct) .header(CONTENT_DISPOSITION, disposition) .header(CONTENT_LENGTH, len) .header("X-Content-Type-Options", "nosniff") .header( "Content-Security-Policy", "default-src 'none'; sandbox; frame-ancestors 'none'", ) .header("Referrer-Policy", "no-referrer") .body(Body::from(bytes)) .expect("build files download response")) } async fn delete_file( State(s): State, Extension(principal): Extension, Path((id_or_slug, collection, file_id)): Path<(String, String, String)>, ) -> Result { let app_id = resolve_app(&*s.apps, &id_or_slug).await?; require( s.authz.as_ref(), &principal, Capability::AppFilesWrite(app_id), ) .await?; validate_files_collection(&collection).map_err(|e| FilesApiError::Invalid(e.to_string()))?; let id = Uuid::parse_str(&file_id).map_err(|_| FilesApiError::NotFound)?; if s.files.delete(app_id, &collection, id).await?.is_none() { return Err(FilesApiError::NotFound); } Ok(StatusCode::NO_CONTENT) } async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result { crate::app_repo::resolve_app(apps, ident) .await .map_err(|e| FilesApiError::Backend(e.to_string()))? .map(|l| l.app.id) .ok_or(FilesApiError::AppNotFound) } #[derive(Debug, thiserror::Error)] pub enum FilesApiError { #[error("app not found")] AppNotFound, #[error("file not found")] NotFound, #[error("invalid request: {0}")] Invalid(String), #[error("forbidden")] Forbidden, #[error("authorization repo error: {0}")] AuthzRepo(String), #[error("files backend: {0}")] Backend(String), } impl From for FilesApiError { fn from(d: AuthzDenied) -> Self { match d { AuthzDenied::Denied => Self::Forbidden, AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()), } } } impl From for FilesApiError { fn from(e: AuthzError) -> Self { Self::AuthzRepo(e.to_string()) } } impl From for FilesApiError { fn from(e: FilesRepoError) -> Self { Self::Backend(e.to_string()) } } impl IntoResponse for FilesApiError { fn into_response(self) -> Response { let (status, body) = match &self { Self::AppNotFound | Self::NotFound => { (StatusCode::NOT_FOUND, json!({ "error": self.to_string() })) } Self::Invalid(_) => ( StatusCode::UNPROCESSABLE_ENTITY, json!({ "error": self.to_string() }), ), Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })), Self::AuthzRepo(e) => { tracing::error!(error = %e, "files admin authz repo error"); ( StatusCode::INTERNAL_SERVER_ERROR, json!({ "error": "internal error" }), ) } Self::Backend(e) => { tracing::error!(error = %e, "files admin backend error"); ( StatusCode::INTERNAL_SERVER_ERROR, json!({ "error": "internal error" }), ) } }; (status, Json(body)).into_response() } }