use std::sync::Arc;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::{Extension, Json, Router};
use pensieve_catalog::PostgresCatalog;
use pensieve_core::catalog::Catalog;
use object_store::path::Path as ObjPath;
use object_store::ObjectStore;
use serde::Deserialize;
use uuid::Uuid;
use crate::auth::Principal;
const DEFAULT_LIMIT: u64 = 64 * 1024; const MAX_LIMIT: u64 = 4 * 1024 * 1024;
#[derive(Clone)]
pub struct ArtifactsState {
pub catalog: Arc<dyn Catalog>,
pub store: Arc<dyn ObjectStore>,
}
#[derive(Debug, Deserialize)]
struct RangeQuery {
#[serde(default)]
offset: u64,
limit: Option<u64>,
}
async fn get_artifact(
State(state): State<ArtifactsState>,
Extension(principal): Extension<Principal>,
Path(id): Path<Uuid>,
Query(q): Query<RangeQuery>,
) -> Response {
let Some(pg) = state.catalog.as_ref_any().downcast_ref::<PostgresCatalog>() else {
return (
StatusCode::SERVICE_UNAVAILABLE,
"artifact retrieval requires Postgres",
)
.into_response();
};
let rec = match pg.get_artifact_in_tenant(principal.tenant, id).await {
Ok(Some(r)) => r,
Ok(None) => return (StatusCode::NOT_FOUND, "artifact not found").into_response(),
Err(e) => {
return (StatusCode::INTERNAL_SERVER_ERROR, format!("lookup: {e}")).into_response()
}
};
let offset = q.offset as usize;
let limit = q.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize;
let path = ObjPath::from(rec.object_path.as_str());
let (size, nbytes, content) = match read_window(&state.store, &path, offset, limit).await {
Ok(v) => v,
Err(resp) => return resp,
};
Json(serde_json::json!({
"id": id,
"object_path": rec.object_path,
"sha256": rec.sha256,
"artifact_class": rec.artifact_class,
"source": rec.source,
"size_bytes": size,
"offset": offset,
"returned_bytes": nbytes,
"eof": offset.saturating_add(nbytes) >= size,
"content": content,
}))
.into_response()
}
#[derive(Debug, Deserialize)]
struct PathQuery {
path: String,
#[serde(default)]
offset: u64,
limit: Option<u64>,
}
async fn get_artifact_by_path(
State(state): State<ArtifactsState>,
Extension(principal): Extension<Principal>,
Query(q): Query<PathQuery>,
) -> Response {
let prefix = format!("artifacts/{}/", principal.tenant.as_uuid());
if !q.path.starts_with(&prefix) {
return (
StatusCode::FORBIDDEN,
"path is not in your tenant's artifact namespace",
)
.into_response();
}
let offset = q.offset as usize;
let limit = q.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize;
let path = ObjPath::from(q.path.as_str());
let (size, nbytes, content) = match read_window(&state.store, &path, offset, limit).await {
Ok(v) => v,
Err(resp) => return resp,
};
Json(serde_json::json!({
"object_path": q.path,
"size_bytes": size,
"offset": offset,
"returned_bytes": nbytes,
"eof": offset.saturating_add(nbytes) >= size,
"content": content,
}))
.into_response()
}
async fn read_window(
store: &Arc<dyn ObjectStore>,
path: &ObjPath,
offset: usize,
limit: usize,
) -> Result<(usize, usize, String), Response> {
let size = match store.head(path).await {
Ok(meta) => meta.size,
Err(object_store::Error::NotFound { .. }) => {
return Err((StatusCode::NOT_FOUND, "artifact blob missing").into_response())
}
Err(e) => {
return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("head: {e}")).into_response())
}
};
if offset >= size {
return Ok((size, 0, String::new()));
}
let end = offset.saturating_add(limit).min(size);
match store.get_range(path, offset..end).await {
Ok(bytes) => Ok((size, bytes.len(), String::from_utf8_lossy(&bytes).into_owned())),
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, format!("read: {e}")).into_response()),
}
}
pub fn artifacts_router(catalog: Arc<dyn Catalog>, store: Arc<dyn ObjectStore>) -> Router {
Router::new()
.route("/v1/artifacts/by-path", get(get_artifact_by_path))
.route("/v1/artifacts/:id", get(get_artifact))
.with_state(ArtifactsState { catalog, store })
}