use axum::{ extract::{Path, Query, State}, http::{StatusCode}, response::{Json, Response, IntoResponse}, http::header, }; use log::warn; use std::collections::HashMap; use std::path::PathBuf; use std::str::FromStr; use std::io::Read; use anyhow::{Result, anyhow}; use crate::compression_engine::{CompressionType, get_compression_engine}; use crate::db; use crate::modes::server::common::{AppState, ApiResponse, ItemInfo, ItemContentInfo, TagsQuery, ListItemsQuery, ItemQuery, ItemInfoListResponse, ItemInfoResponse, ItemContentInfoResponse, MetadataResponse}; use crate::common::is_binary::is_binary; #[utoipa::path( get, path = "/api/item/", operation_id = "list_items", summary = "List stored items", description = "Retrieve a paginated list of stored items with their metadata and tags. Items can be filtered by tags and sorted by creation time. Each item includes comprehensive metadata extracted during storage such as file type, encoding, size, and custom tags for organization.", responses( (status = 200, description = "Successfully retrieved paginated list of items with metadata and tags", body = ItemInfoListResponse), (status = 401, description = "Unauthorized - Invalid or missing authentication credentials"), (status = 500, description = "Internal server error - Failed to retrieve items from database") ), params( ("tags" = Option, Query, description = "Comma-separated list of tags to filter by (e.g., 'important,work'). Only items that have ALL specified tags will be returned."), ("order" = Option, Query, description = "Sort order for results: 'newest' (default, most recent first) or 'oldest' (oldest first)"), ("start" = Option, Query, description = "Starting index for pagination (default: 0). Use this to skip items for pagination."), ("count" = Option, Query, description = "Maximum number of items to return in this request (default: 100, maximum: 1000)") ), security( ("bearerAuth" = []) ), tag = "item" )] pub async fn handle_list_items( State(state): State, Query(params): Query, ) -> Result>>, StatusCode> { let mut conn = state.db.lock().await; let tags: Vec = params.tags .as_ref() .map(|s| s.split(',').map(|t| t.trim().to_string()).collect()) .unwrap_or_default(); let items = if tags.is_empty() { db::get_items(&mut *conn).map_err(|e| { warn!("Failed to get items: {}", e); StatusCode::INTERNAL_SERVER_ERROR })? } else { db::get_items_matching(&mut *conn, &tags, &HashMap::new()) .map_err(|e| { warn!("Failed to get items matching tags {:?}: {}", tags, e); StatusCode::INTERNAL_SERVER_ERROR })? }; // Apply ordering (default is newest first) let mut items = items; match params.order.as_deref().unwrap_or("newest") { "newest" => items.sort_by(|a, b| b.ts.cmp(&a.ts)), "oldest" => items.sort_by(|a, b| a.ts.cmp(&b.ts)), _ => items.sort_by(|a, b| b.ts.cmp(&a.ts)), // default to newest } // Apply pagination let start = params.start.unwrap_or(0) as usize; let count = params.count.unwrap_or(100) as usize; let items: Vec<_> = items.into_iter().skip(start).take(count).collect(); // Get item IDs for batch queries let item_ids: Vec = items.iter().filter_map(|item| item.id).collect(); // Get tags and metadata for all items let tags_map = db::get_tags_for_items(&mut *conn, &item_ids) .map_err(|e| { warn!("Failed to get tags for items: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; let meta_map = db::get_meta_for_items(&mut *conn, &item_ids) .map_err(|e| { warn!("Failed to get metadata for items: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; let item_infos: Vec = items .into_iter() .map(|item| { let item_id = item.id.unwrap_or(0); let item_tags = tags_map.get(&item_id) .map(|tags| tags.iter().map(|t| t.name.clone()).collect()) .unwrap_or_default(); let item_meta = meta_map.get(&item_id) .cloned() .unwrap_or_default(); ItemInfo { id: item_id, ts: item.ts.to_rfc3339(), size: item.size, compression: item.compression, tags: item_tags, metadata: item_meta, } }) .collect(); let response = ApiResponse { success: true, data: Some(item_infos), error: None, }; Ok(Json(response)) } #[utoipa::path( post, path = "/api/item/", operation_id = "post_item", summary = "Store new item", description = "Create a new item by uploading content. The content will be automatically compressed, analyzed for metadata (file type, encoding, etc.), and stored with a unique identifier. Binary detection is performed automatically, and various metadata plugins extract information like line counts, file types, and system information.", responses( (status = 201, description = "Successfully created new item with generated metadata and unique ID", body = ItemInfoResponse), (status = 400, description = "Bad request - Invalid input data or malformed content"), (status = 401, description = "Unauthorized - Invalid or missing authentication credentials"), (status = 500, description = "Internal server error - Failed to create item due to storage or processing error") ), request_body( content = String, description = "Raw content to store as a new item. Can be text or binary data.", content_type = "application/octet-stream" ), security( ("bearerAuth" = []) ), tag = "item" )] pub async fn handle_post_item( State(_state): State, ) -> Result>, StatusCode> { // This is a simplified implementation // In a real implementation, you'd need to properly parse multipart/form-data // or JSON payload with the item data let response = ApiResponse:: { success: false, data: None, error: Some("POST /api/item/ not yet implemented".to_string()), }; Ok(Json(response)) } #[utoipa::path( get, path = "/api/item/latest", operation_id = "get_item_latest", summary = "Get latest item with content", description = "Retrieve the most recently stored item including its content and metadata. If tags are specified, returns the latest item that matches ALL the given tags. For text content, the actual content is included in the response. For binary content, only metadata is returned unless allow_binary is true.", responses( (status = 200, description = "Successfully retrieved latest item with content and metadata. Content is included if item is text-based or allow_binary is true.", body = ItemContentInfoResponse), (status = 401, description = "Unauthorized - Invalid or missing authentication credentials"), (status = 404, description = "Item not found - No items exist in the database or no items match the specified tag criteria"), (status = 500, description = "Internal server error - Failed to retrieve item content due to decompression or database error") ), params( ("tags" = Option, Query, description = "Comma-separated list of tags to filter by (e.g., 'important,work'). If specified, returns the latest item that has ALL the specified tags."), ("allow_binary" = Option, Query, description = "Whether to include raw content for binary files (default: false). When false, binary files return only metadata. When true, binary content is included as a string.") ), security( ("bearerAuth" = []) ), tag = "item" )] pub async fn handle_get_item_latest( State(state): State, Query(params): Query, ) -> Result>, StatusCode> { 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_content_info(&item, &state.data_dir, &mut *conn, params.allow_binary).await { Ok(content_info) => { let response = ApiResponse { success: true, data: Some(content_info), error: None, }; Ok(Json(response)) } Err(e) => { warn!("Failed to get content for item {}: {}", item.id.unwrap_or(0), e); let response = ApiResponse:: { success: false, data: None, error: Some(format!("Failed to retrieve content: {}", e)), }; Ok(Json(response)) } } } else { Err(StatusCode::NOT_FOUND) } } #[utoipa::path( get, path = "/api/item/{item_id}", operation_id = "get_item", summary = "Get item with content", description = "Retrieve a specific item by its ID including both content and metadata. The content is automatically decompressed and returned as a string for text files. Binary files return only metadata unless allow_binary is explicitly set to true.", responses( (status = 200, description = "Successfully retrieved item with content and metadata. Content is included if item is text-based or allow_binary is true.", body = ItemContentInfoResponse), (status = 400, description = "Bad request - Invalid item ID (must be a positive integer)"), (status = 401, description = "Unauthorized - Invalid or missing authentication credentials"), (status = 404, description = "Item not found - No item exists with the specified ID"), (status = 500, description = "Internal server error - Failed to retrieve item content due to decompression or database error") ), params( ("item_id" = i64, Path, description = "Unique identifier of the item to retrieve (must be a positive integer)"), ("allow_binary" = Option, Query, description = "Whether to include raw content for binary files (default: false). When false, binary files return only metadata. When true, binary content is included as a string.") ), security( ("bearerAuth" = []) ), tag = "item" )] pub async fn handle_get_item( State(state): State, Path(item_id): Path, Query(params): Query, ) -> Result>, StatusCode> { // Validate that item ID is positive to prevent path traversal issues if item_id <= 0 { return Err(StatusCode::BAD_REQUEST); } let mut conn = state.db.lock().await; if let Some(item) = db::get_item(&mut *conn, item_id).map_err(|e| { warn!("Failed to get item {} for content: {}", item_id, e); StatusCode::INTERNAL_SERVER_ERROR })? { match get_item_content_info(&item, &state.data_dir, &mut *conn, params.allow_binary).await { Ok(content_info) => { let response = ApiResponse { success: true, data: Some(content_info), error: None, }; Ok(Json(response)) } Err(e) => { warn!("Failed to get content for item {}: {}", item_id, e); let response = ApiResponse:: { success: false, data: None, error: Some(format!("Failed to retrieve content: {}", e)), }; Ok(Json(response)) } } } else { Err(StatusCode::NOT_FOUND) } } #[utoipa::path( get, path = "/api/item/latest/content", operation_id = "get_item_latest_content", summary = "Download latest item content", description = "Download the raw content of the most recently stored item. The content is automatically decompressed and returned with the appropriate MIME type header for proper browser handling. If tags are specified, returns the latest item matching ALL the given tags.", responses( (status = 200, description = "Successfully retrieved latest item raw content with appropriate Content-Type header set based on detected MIME type"), (status = 401, description = "Unauthorized - Invalid or missing authentication credentials"), (status = 404, description = "Item not found - No items exist in the database or no items match the specified tag criteria"), (status = 500, description = "Internal server error - Failed to retrieve item content due to decompression or filesystem error") ), params( ("tags" = Option, Query, description = "Comma-separated list of tags to filter by (e.g., 'important,work'). If specified, returns the latest item that has ALL the specified tags.") ), security( ("bearerAuth" = []) ), tag = "item" )] pub async fn handle_get_item_latest_content( State(state): State, Query(params): Query, ) -> Result { 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) } } #[utoipa::path( get, path = "/api/item/{item_id}/content", operation_id = "get_item_content", summary = "Download item content", description = "Download the raw content of a specific item by its ID. The content is automatically decompressed and returned with the appropriate MIME type header for proper browser handling. This endpoint is ideal for downloading files or viewing content directly in the browser.", responses( (status = 200, description = "Successfully retrieved item raw content with appropriate Content-Type header set based on detected MIME type"), (status = 400, description = "Bad request - Invalid item ID (must be a positive integer)"), (status = 401, description = "Unauthorized - Invalid or missing authentication credentials"), (status = 404, description = "Item not found - No item exists with the specified ID"), (status = 500, description = "Internal server error - Failed to retrieve item content due to decompression or filesystem error") ), params( ("item_id" = i64, Path, description = "Unique identifier of the item to retrieve content for (must be a positive integer)") ), security( ("bearerAuth" = []) ), tag = "item" )] pub async fn handle_get_item_content( State(state): State, Path(item_id): Path, ) -> Result { // Validate that item ID is positive to prevent path traversal issues if item_id <= 0 { return Err(StatusCode::BAD_REQUEST); } let mut conn = state.db.lock().await; if let Some(item) = db::get_item(&mut *conn, item_id).map_err(|e| { warn!("Failed to get item {} for content: {}", item_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 {}: {}", item_id, e); Err(StatusCode::INTERNAL_SERVER_ERROR) } } } else { Err(StatusCode::NOT_FOUND) } } async fn get_item_content(item: &db::Item, data_dir: &PathBuf) -> Result { 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 content using the compression engine let mut reader = compression_engine.open(item_path)?; let mut content = String::new(); reader.read_to_string(&mut content)?; Ok(content) } async fn get_item_content_info(item: &db::Item, data_dir: &PathBuf, conn: &mut rusqlite::Connection, allow_binary: bool) -> Result { 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)); } // Get metadata let meta_entries = db::get_item_meta(conn, item) .map_err(|e| anyhow!("Failed to get metadata: {}", e))?; let metadata: HashMap = meta_entries .iter() .map(|m| (m.name.clone(), m.value.clone())) .collect(); // Determine if content is binary let is_binary = if let Some(binary_meta) = metadata.get("binary") { binary_meta == "true" } else { // Fall back to checking the actual content 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)?; let mut reader = compression_engine.open(item_path)?; let mut buffer = [0u8; 8192]; // Read first 8KB to check let bytes_read = reader.read(&mut buffer)?; is_binary(&buffer[..bytes_read]) }; // Get content if not binary or if binary is allowed let content = if is_binary && !allow_binary { None } else { match get_item_content(item, data_dir).await { Ok(content_str) => Some(content_str), Err(_) => None, // If we can't read as string, treat as binary } }; Ok(ItemContentInfo { metadata, content, binary: is_binary, }) } 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)) } #[utoipa::path( get, path = "/api/item/latest/meta", operation_id = "get_item_latest_meta", summary = "Get latest item metadata", description = "Retrieve comprehensive metadata for the most recently stored item. Metadata includes automatically extracted information such as file type, MIME type, encoding, line counts, file size, system information (user, hostname, etc.), and cryptographic hashes. If tags are specified, returns metadata for the latest item matching ALL the given tags.", responses( (status = 200, description = "Successfully retrieved latest item metadata as key-value pairs including file type, encoding, size, and system information", body = MetadataResponse), (status = 401, description = "Unauthorized - Invalid or missing authentication credentials"), (status = 404, description = "Item not found - No items exist in the database or no items match the specified tag criteria"), (status = 500, description = "Internal server error - Failed to retrieve item metadata from database") ), params( ("tags" = Option, Query, description = "Comma-separated list of tags to filter by (e.g., 'important,work'). If specified, returns metadata for the latest item that has ALL the specified tags.") ), security( ("bearerAuth" = []) ), tag = "item" )] pub async fn handle_get_item_latest_meta( State(state): State, Query(params): Query, ) -> Result>>, StatusCode> { 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) } } #[utoipa::path( get, path = "/api/item/{item_id}/meta", operation_id = "get_item_meta", summary = "Get item metadata", description = "Retrieve comprehensive metadata for a specific item by its ID. Metadata includes automatically extracted information such as file type, MIME type, encoding, line counts, file size, system information (user, hostname, process ID, etc.), cryptographic hashes (SHA256, MD5), and performance metrics (read time, read rate).", responses( (status = 200, description = "Successfully retrieved item metadata as key-value pairs including file type, encoding, size, and system information", body = MetadataResponse), (status = 400, description = "Bad request - Invalid item ID (must be a positive integer)"), (status = 401, description = "Unauthorized - Invalid or missing authentication credentials"), (status = 404, description = "Item not found - No item exists with the specified ID"), (status = 500, description = "Internal server error - Failed to retrieve item metadata from database") ), params( ("item_id" = i64, Path, description = "Unique identifier of the item to retrieve metadata for (must be a positive integer)") ), security( ("bearerAuth" = []) ), tag = "item" )] pub async fn handle_get_item_meta( State(state): State, Path(item_id): Path, ) -> Result>>, StatusCode> { let mut conn = state.db.lock().await; if let Some(item) = db::get_item(&mut *conn, item_id).map_err(|e| { warn!("Failed to get item {} for meta: {}", item_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 {}: {}", 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) } }