diff --git a/src/modes/server/content.rs b/src/modes/server/content.rs index 5849cca..04d45d4 100644 --- a/src/modes/server/content.rs +++ b/src/modes/server/content.rs @@ -1,8 +1,8 @@ use anyhow::{Result, anyhow}; use axum::{ extract::{ConnectInfo, Path, Query, State}, - http::{HeaderMap, StatusCode}, - response::Json, + http::{HeaderMap, StatusCode, header}, + response::{Json, Response, IntoResponse}, }; use log::warn; use serde_json::json; @@ -14,7 +14,7 @@ use std::str::FromStr; use crate::compression_engine::{CompressionType, get_compression_engine}; use crate::db; -use super::common::{AppState, ApiResponse, TagsQuery, check_auth}; +use super::common::{AppState, ApiResponse, TagsQuery, check_auth, ItemInfo}; pub async fn handle_get_content_latest( State(state): State, @@ -23,7 +23,7 @@ pub async fn handle_get_content_latest( ConnectInfo(addr): ConnectInfo, ) -> Result>, StatusCode> { if !check_auth(&headers, &state.password) { - warn!("Unauthorized request to /content from {}", addr); + warn!("Unauthorized request to /api/item/latest/content from {}", addr); return Err(StatusCode::UNAUTHORIZED); } @@ -75,7 +75,7 @@ pub async fn handle_get_content( ConnectInfo(addr): ConnectInfo, ) -> Result>, StatusCode> { if !check_auth(&headers, &state.password) { - warn!("Unauthorized request to /content/{} from {}", item_id, addr); + warn!("Unauthorized request to /api/item/{}/content from {}", item_id, addr); return Err(StatusCode::UNAUTHORIZED); } @@ -119,6 +119,99 @@ pub async fn handle_get_content( } } +pub async fn handle_get_content_latest_raw( + State(state): State, + Query(params): Query, + headers: HeaderMap, + ConnectInfo(addr): ConnectInfo, +) -> Result { + if !check_auth(&headers, &state.password) { + warn!("Unauthorized request to /api/item/latest/content from {}", addr); + return Err(StatusCode::UNAUTHORIZED); + } + + let mut conn = state.db.lock().await; + + let item = if let Some(tags_str) = params.tags { + let tags: Vec = tags_str.split(',').map(|t| t.trim().to_string()).collect(); + db::get_item_matching(&mut *conn, &tags, &HashMap::new()) + .map_err(|e| { + warn!("Failed to get item matching tags {:?} for content: {}", tags, e); + StatusCode::INTERNAL_SERVER_ERROR + })? + } else { + db::get_item_last(&mut *conn).map_err(|e| { + warn!("Failed to get last item for content: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })? + }; + + if let Some(item) = item { + match get_item_raw_content(&item, &state.data_dir, &mut *conn).await { + Ok((content, mime_type)) => { + let mut response = content.into_response(); + response.headers_mut().insert( + header::CONTENT_TYPE, + mime_type.parse().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + ); + Ok(response) + } + Err(e) => { + warn!("Failed to get raw content for item {}: {}", item.id.unwrap_or(0), e); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } + } else { + Err(StatusCode::NOT_FOUND) + } +} + +pub async fn handle_get_content_raw( + State(state): State, + Path(item_id): Path, + headers: HeaderMap, + ConnectInfo(addr): ConnectInfo, +) -> Result { + if !check_auth(&headers, &state.password) { + warn!("Unauthorized request to /api/item/{}/content from {}", item_id, addr); + return Err(StatusCode::UNAUTHORIZED); + } + + if let Ok(id) = item_id.parse::() { + // Validate that item ID is positive to prevent path traversal issues + if id <= 0 { + warn!("Invalid item ID {} from {}", id, addr); + return Err(StatusCode::BAD_REQUEST); + } + + let mut conn = state.db.lock().await; + + if let Some(item) = db::get_item(&mut *conn, id).map_err(|e| { + warn!("Failed to get item {} for content: {}", id, e); + StatusCode::INTERNAL_SERVER_ERROR + })? { + match get_item_raw_content(&item, &state.data_dir, &mut *conn).await { + Ok((content, mime_type)) => { + let mut response = content.into_response(); + response.headers_mut().insert( + header::CONTENT_TYPE, + mime_type.parse().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + ); + Ok(response) + } + Err(e) => { + warn!("Failed to get raw content for item {}: {}", id, e); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } + } else { + Err(StatusCode::NOT_FOUND) + } + } else { + Err(StatusCode::BAD_REQUEST) + } +} + async fn get_item_content(item: &db::Item, data_dir: &PathBuf) -> Result { let item_id = item.id.ok_or_else(|| anyhow!("Item missing ID"))?; @@ -141,11 +234,259 @@ async fn get_item_content(item: &db::Item, data_dir: &PathBuf) -> Result Ok(content) } +async fn get_item_raw_content(item: &db::Item, data_dir: &PathBuf, conn: &mut rusqlite::Connection) -> Result<(Vec, String)> { + let item_id = item.id.ok_or_else(|| anyhow!("Item missing ID"))?; + + // Validate that item ID is positive to prevent path traversal issues + if item_id <= 0 { + return Err(anyhow!("Invalid item ID: {}", item_id)); + } + + let mut item_path = data_dir.clone(); + item_path.push(item_id.to_string()); + + let compression_type = CompressionType::from_str(&item.compression)?; + let compression_engine = get_compression_engine(compression_type)?; + + // Read the raw content using the compression engine + let mut reader = compression_engine.open(item_path)?; + let mut content = Vec::new(); + reader.read_to_end(&mut content)?; + + // Get MIME type from metadata + let meta_entries = db::get_item_meta(conn, item) + .map_err(|e| anyhow!("Failed to get metadata: {}", e))?; + + let mime_type = meta_entries + .iter() + .find(|m| m.name == "file.mime") + .map(|m| m.value.clone()) + .unwrap_or_else(|| "application/octet-stream".to_string()); + + Ok((content, mime_type)) +} + +pub async fn handle_get_item_latest_meta( + State(state): State, + Query(params): Query, + headers: HeaderMap, + ConnectInfo(addr): ConnectInfo, +) -> Result>>, StatusCode> { + if !check_auth(&headers, &state.password) { + warn!("Unauthorized request to /api/item/latest/meta from {}", addr); + return Err(StatusCode::UNAUTHORIZED); + } + + let mut conn = state.db.lock().await; + + let item = if let Some(tags_str) = params.tags { + let tags: Vec = tags_str.split(',').map(|t| t.trim().to_string()).collect(); + db::get_item_matching(&mut *conn, &tags, &HashMap::new()) + .map_err(|e| { + warn!("Failed to get item matching tags {:?} for meta: {}", tags, e); + StatusCode::INTERNAL_SERVER_ERROR + })? + } else { + db::get_item_last(&mut *conn).map_err(|e| { + warn!("Failed to get last item for meta: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })? + }; + + if let Some(item) = item { + let item_meta = db::get_item_meta(&mut *conn, &item) + .map_err(|e| { + warn!("Failed to get metadata for item {}: {}", item.id.unwrap_or(0), e); + StatusCode::INTERNAL_SERVER_ERROR + })? + .into_iter() + .map(|m| (m.name, m.value)) + .collect(); + + let response = ApiResponse { + success: true, + data: Some(item_meta), + error: None, + }; + + Ok(Json(response)) + } else { + Err(StatusCode::NOT_FOUND) + } +} + +pub async fn handle_get_item_meta( + State(state): State, + Path(item_id): Path, + headers: HeaderMap, + ConnectInfo(addr): ConnectInfo, +) -> Result>>, StatusCode> { + if !check_auth(&headers, &state.password) { + warn!("Unauthorized request to /api/item/{}/meta from {}", item_id, addr); + return Err(StatusCode::UNAUTHORIZED); + } + + if let Ok(id) = item_id.parse::() { + let mut conn = state.db.lock().await; + + if let Some(item) = db::get_item(&mut *conn, id).map_err(|e| { + warn!("Failed to get item {} for meta: {}", id, e); + StatusCode::INTERNAL_SERVER_ERROR + })? { + let item_meta = db::get_item_meta(&mut *conn, &item) + .map_err(|e| { + warn!("Failed to get metadata for item {}: {}", id, e); + StatusCode::INTERNAL_SERVER_ERROR + })? + .into_iter() + .map(|m| (m.name, m.value)) + .collect(); + + let response = ApiResponse { + success: true, + data: Some(item_meta), + error: None, + }; + + Ok(Json(response)) + } else { + Err(StatusCode::NOT_FOUND) + } + } else { + Err(StatusCode::BAD_REQUEST) + } +} + +pub async fn handle_get_item_latest( + State(state): State, + Query(params): Query, + headers: HeaderMap, + ConnectInfo(addr): ConnectInfo, +) -> Result>, StatusCode> { + if !check_auth(&headers, &state.password) { + warn!("Unauthorized request to /api/item/latest from {}", addr); + return Err(StatusCode::UNAUTHORIZED); + } + + let mut conn = state.db.lock().await; + + let item = if let Some(tags_str) = params.tags { + let tags: Vec = tags_str.split(',').map(|t| t.trim().to_string()).collect(); + db::get_item_matching(&mut *conn, &tags, &HashMap::new()) + .map_err(|e| { + warn!("Failed to get item matching tags {:?}: {}", tags, e); + StatusCode::INTERNAL_SERVER_ERROR + })? + } else { + db::get_item_last(&mut *conn).map_err(|e| { + warn!("Failed to get last item: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })? + }; + + if let Some(item) = item { + let item_tags = db::get_item_tags(&mut *conn, &item) + .map_err(|e| { + warn!("Failed to get tags for item {}: {}", item.id.unwrap_or(0), e); + StatusCode::INTERNAL_SERVER_ERROR + })? + .into_iter() + .map(|t| t.name) + .collect(); + let item_meta = db::get_item_meta(&mut *conn, &item) + .map_err(|e| { + warn!("Failed to get metadata for item {}: {}", item.id.unwrap_or(0), e); + StatusCode::INTERNAL_SERVER_ERROR + })? + .into_iter() + .map(|m| (m.name, m.value)) + .collect(); + + let item_info = ItemInfo { + id: item.id.unwrap_or(0), + ts: item.ts.to_rfc3339(), + size: item.size, + compression: item.compression, + tags: item_tags, + metadata: item_meta, + }; + + let response = ApiResponse { + success: true, + data: Some(item_info), + error: None, + }; + + Ok(Json(response)) + } else { + Err(StatusCode::NOT_FOUND) + } +} + +pub async fn handle_get_item( + State(state): State, + Path(item_id): Path, + headers: HeaderMap, + ConnectInfo(addr): ConnectInfo, +) -> Result>, StatusCode> { + if !check_auth(&headers, &state.password) { + warn!("Unauthorized request to /api/item/{} from {}", item_id, addr); + return Err(StatusCode::UNAUTHORIZED); + } + + if let Ok(id) = item_id.parse::() { + let mut conn = state.db.lock().await; + + if let Some(item) = db::get_item(&mut *conn, id).map_err(|e| { + warn!("Failed to get item {}: {}", id, e); + StatusCode::INTERNAL_SERVER_ERROR + })? { + let item_tags = db::get_item_tags(&mut *conn, &item) + .map_err(|e| { + warn!("Failed to get tags for item {}: {}", id, e); + StatusCode::INTERNAL_SERVER_ERROR + })? + .into_iter() + .map(|t| t.name) + .collect(); + let item_meta = db::get_item_meta(&mut *conn, &item) + .map_err(|e| { + warn!("Failed to get metadata for item {}: {}", id, e); + StatusCode::INTERNAL_SERVER_ERROR + })? + .into_iter() + .map(|m| (m.name, m.value)) + .collect(); + + let item_info = ItemInfo { + id, + ts: item.ts.to_rfc3339(), + size: item.size, + compression: item.compression, + tags: item_tags, + metadata: item_meta, + }; + + let response = ApiResponse { + success: true, + data: Some(item_info), + error: None, + }; + + Ok(Json(response)) + } else { + Err(StatusCode::NOT_FOUND) + } + } else { + Err(StatusCode::BAD_REQUEST) + } +} + pub fn get_content_openapi_spec() -> serde_json::Value { json!({ - "/content": { + "/api/item/latest": { "get": { - "summary": "Get content of latest item", + "summary": "Get latest item with metadata and content", "parameters": [ { "name": "tags", @@ -155,14 +496,65 @@ pub fn get_content_openapi_spec() -> serde_json::Value { } ], "responses": { - "200": {"description": "Item content"}, + "200": { + "description": "Item information", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/ItemInfo"} + } + } + }, "404": {"description": "No items found"} } } }, - "/content/{id}": { + "/api/item/latest/meta": { "get": { - "summary": "Get content by item ID", + "summary": "Get metadata of latest item", + "parameters": [ + { + "name": "tags", + "in": "query", + "schema": {"type": "string"}, + "description": "Comma-separated list of tags to filter by" + } + ], + "responses": { + "200": { + "description": "Item metadata", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {"type": "string"} + } + } + } + }, + "404": {"description": "No items found"} + } + } + }, + "/api/item/latest/content": { + "get": { + "summary": "Get raw content of latest item", + "parameters": [ + { + "name": "tags", + "in": "query", + "schema": {"type": "string"}, + "description": "Comma-separated list of tags to filter by" + } + ], + "responses": { + "200": {"description": "Raw item content"}, + "404": {"description": "No items found"} + } + } + }, + "/api/item/{id}": { + "get": { + "summary": "Get item by ID with metadata and content", "parameters": [ { "name": "id", @@ -172,7 +564,58 @@ pub fn get_content_openapi_spec() -> serde_json::Value { } ], "responses": { - "200": {"description": "Item content"}, + "200": { + "description": "Item information", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/ItemInfo"} + } + } + }, + "404": {"description": "Item not found"} + } + } + }, + "/api/item/{id}/meta": { + "get": { + "summary": "Get metadata by item ID", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": {"type": "integer"} + } + ], + "responses": { + "200": { + "description": "Item metadata", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {"type": "string"} + } + } + } + }, + "404": {"description": "Item not found"} + } + } + }, + "/api/item/{id}/content": { + "get": { + "summary": "Get raw content by item ID", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": {"type": "integer"} + } + ], + "responses": { + "200": {"description": "Raw item content"}, "404": {"description": "Item not found"} } } diff --git a/src/modes/server/docs.rs b/src/modes/server/docs.rs index fd70bb9..e489d62 100644 --- a/src/modes/server/docs.rs +++ b/src/modes/server/docs.rs @@ -95,7 +95,7 @@ pub async fn handle_swagger_ui() -> Html<&'static str> {