diff --git a/src/modes/server/api.rs b/src/modes/server/api.rs index e69de29..2eef426 100644 --- a/src/modes/server/api.rs +++ b/src/modes/server/api.rs @@ -0,0 +1,27 @@ +use axum::{ + routing::{get, post, delete}, + Router, +}; + +use crate::modes::server::common::AppState; + +pub mod item; +pub mod status; +pub mod docs; + +pub fn api_routes() -> Router { + Router::new() + // Item endpoints + .route("/api/item/", get(item::handle_list_items).post(item::handle_post_item)) + .route("/api/item/latest", get(item::handle_get_item_latest)) + .route("/api/item/latest/meta", get(item::handle_get_item_latest_meta)) + .route("/api/item/latest/content", get(item::handle_get_content_latest_raw)) + .route("/api/item/:id", get(item::handle_get_item).delete(item::handle_delete_item)) + .route("/api/item/:id/meta", get(item::handle_get_item_meta)) + .route("/api/item/:id/content", get(item::handle_get_content_raw)) + // Status endpoint + .route("/api/status", get(status::handle_status)) + // Documentation endpoints + .route("/api/openapi.json", get(docs::handle_openapi)) + .route("/api/docs", get(docs::handle_swagger_ui)) +} diff --git a/src/modes/server/api/item.rs b/src/modes/server/api/item.rs index e69de29..88dd1f7 100644 --- a/src/modes/server/api/item.rs +++ b/src/modes/server/api/item.rs @@ -0,0 +1,634 @@ +use axum::{ + extract::{ConnectInfo, Path, Query, State}, + http::{HeaderMap, StatusCode}, + response::{Json, Response, IntoResponse}, + http::header, +}; +use log::warn; +use serde_json::json; +use std::collections::HashMap; +use std::net::SocketAddr; +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, TagsQuery, check_auth, ListItemsQuery}; + +pub async fn handle_list_items( + 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/ from {}", addr); + return Err(StatusCode::UNAUTHORIZED); + } + + 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)) +} + +pub async fn handle_post_item( + State(state): State, + headers: HeaderMap, + ConnectInfo(addr): ConnectInfo, +) -> Result>, StatusCode> { + if !check_auth(&headers, &state.password) { + warn!("Unauthorized request to POST /api/item/ from {}", addr); + return Err(StatusCode::UNAUTHORIZED); + } + + // 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)) +} + +pub async fn handle_delete_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 DELETE /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 {} for deletion: {}", id, e); + StatusCode::INTERNAL_SERVER_ERROR + })? { + db::delete_item(&mut *conn, item).map_err(|e| { + warn!("Failed to delete item {}: {}", id, e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + let response = ApiResponse::<()> { + success: true, + data: None, + error: None, + }; + Ok(Json(response)) + } else { + Err(StatusCode::NOT_FOUND) + } + } else { + Err(StatusCode::BAD_REQUEST) + } +} + +pub async fn handle_get_content_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/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_content(&item, &state.data_dir).await { + Ok(content) => { + let response = ApiResponse { + success: true, + data: Some(content), + 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) + } +} + +pub async fn handle_get_content( + 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/{}/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_content(&item, &state.data_dir).await { + Ok(content) => { + let response = ApiResponse { + success: true, + data: Some(content), + error: None, + }; + Ok(Json(response)) + } + Err(e) => { + warn!("Failed to get content for 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) + } + } else { + Err(StatusCode::BAD_REQUEST) + } +} + +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"))?; + + // 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_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) + } +} diff --git a/src/modes/server/api/status.rs b/src/modes/server/api/status.rs index e69de29..a158bd5 100644 --- a/src/modes/server/api/status.rs +++ b/src/modes/server/api/status.rs @@ -0,0 +1,56 @@ +use axum::{ + extract::{ConnectInfo, State}, + http::{HeaderMap, StatusCode}, + response::Json, +}; +use clap::Command; +use log::warn; +use serde_json::json; + +use crate::meta_plugin::MetaPluginType; +use crate::modes::status::{StatusInfo, generate_status_info}; +use crate::modes::server::common::{AppState, ApiResponse, check_auth}; + +pub async fn handle_status( + State(state): State, + headers: HeaderMap, + ConnectInfo(addr): ConnectInfo, +) -> Result>, StatusCode> { + if !check_auth(&headers, &state.password) { + warn!("Unauthorized request from {}", addr); + return Err(StatusCode::UNAUTHORIZED); + } + + // Use the actual args that the server was started with + let args = &state.args; + + // Determine which meta plugins would be enabled for a save operation + let mut meta_plugin_types: Vec = crate::modes::common::cmd_args_meta_plugin_types(&mut Command::new("keep"), args); + + // Add digest type if specified + let digest_type = crate::modes::common::cmd_args_digest_type(&mut Command::new("keep"), args); + let digest_meta_plugin_type = match digest_type { + crate::meta_plugin::MetaPluginType::DigestSha256 => Some(MetaPluginType::DigestSha256), + crate::meta_plugin::MetaPluginType::DigestMd5 => Some(MetaPluginType::DigestMd5), + _ => None, + }; + + if let Some(digest_plugin_type) = digest_meta_plugin_type { + if !meta_plugin_types.contains(&digest_plugin_type) { + meta_plugin_types.push(digest_plugin_type); + } + } + + let mut db_path = state.data_dir.clone(); + db_path.push("keep-1.db"); + + let status_info = generate_status_info(state.data_dir.clone(), db_path, &meta_plugin_types); + + let response = ApiResponse { + success: true, + data: Some(status_info), + error: None, + }; + + Ok(Json(response)) +} diff --git a/src/modes/server/common.rs b/src/modes/server/common.rs index 052b5de..19eb2cc 100644 --- a/src/modes/server/common.rs +++ b/src/modes/server/common.rs @@ -58,6 +58,14 @@ pub struct TagsQuery { pub tags: Option, } +#[derive(Debug, Deserialize)] +pub struct ListItemsQuery { + pub tags: Option, + pub order: Option, + pub start: Option, + pub count: Option, +} + pub fn check_auth(headers: &HeaderMap, password: &Option) -> bool { if let Some(expected_password) = password { if let Some(auth_header) = headers.get("authorization") {