Files
keep/src/client.rs
Andrew Phillips e672ec751e feat: add JWT auth, configurable username, switch password auth to Basic
Add server-side JWT authentication with permission-based access control
(read/write/delete claims). Password authentication now uses HTTP Basic
auth only (replacing Bearer). Add configurable username for both server
and client (--server-username/--client-username, defaults to "keep").

JWT secret supports file-based loading via --server-jwt-secret-file for
Docker secrets. OPTIONS preflight requests bypass auth. HEAD mapped to
read permission.

Co-Authored-By: opencode <noreply@opencode.ai>
2026-03-13 13:56:35 -03:00

347 lines
11 KiB
Rust

use crate::services::error::CoreError;
use base64::Engine;
use serde::de::DeserializeOwned;
use std::collections::HashMap;
use std::io::Read;
/// Item information returned from the server API.
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
pub struct ItemInfo {
pub id: i64,
pub ts: String,
pub size: Option<i64>,
pub compression: String,
pub tags: Vec<String>,
pub metadata: HashMap<String, String>,
}
pub struct KeepClient {
base_url: String,
agent: ureq::Agent,
username: Option<String>,
password: Option<String>,
jwt: Option<String>,
}
impl KeepClient {
pub fn new(
base_url: &str,
username: Option<String>,
password: Option<String>,
jwt: Option<String>,
) -> Result<Self, CoreError> {
let base_url = base_url.trim_end_matches('/').to_string();
let agent = ureq::Agent::new_with_defaults();
Ok(Self {
base_url,
agent,
username,
password,
jwt,
})
}
pub fn base_url(&self) -> &str {
&self.base_url
}
pub fn username(&self) -> Option<&String> {
self.username.as_ref()
}
pub fn password(&self) -> Option<&String> {
self.password.as_ref()
}
pub fn jwt(&self) -> Option<&String> {
self.jwt.as_ref()
}
fn url(&self, path: &str) -> String {
format!("{}{}", self.base_url, path)
}
/// Get the Authorization header value for the current credentials.
///
/// JWT token is sent as `Bearer <token>`.
/// Password is sent as `Basic base64(username:password)`
/// where username defaults to "keep".
fn auth_header(&self) -> Option<String> {
if let Some(ref jwt) = self.jwt {
Some(format!("Bearer {jwt}"))
} else if let Some(ref password) = self.password {
let username = self.username.as_deref().unwrap_or("keep");
let credentials = format!("{username}:{password}");
let encoded = base64::engine::general_purpose::STANDARD.encode(&credentials);
Some(format!("Basic {encoded}"))
} else {
None
}
}
fn handle_error<T>(&self, result: Result<T, ureq::Error>) -> Result<T, CoreError> {
match result {
Ok(v) => Ok(v),
Err(ureq::Error::StatusCode(code)) => Err(CoreError::Other(anyhow::anyhow!(
"Server returned error: HTTP {}",
code
))),
Err(e) => Err(CoreError::Other(anyhow::anyhow!("Request failed: {}", e))),
}
}
pub fn get_json<T: DeserializeOwned>(&self, path: &str) -> Result<T, CoreError> {
let url = self.url(path);
let mut req = self.agent.get(&url);
if let Some(ref auth) = self.auth_header() {
req = req.header("Authorization", auth);
}
let response = self.handle_error(req.call())?;
let body: T = self.handle_error(response.into_body().read_json())?;
Ok(body)
}
pub fn get_json_with_query<T: DeserializeOwned>(
&self,
path: &str,
params: &[(&str, &str)],
) -> Result<T, CoreError> {
let mut url = self.url(path);
if !params.is_empty() {
url.push('?');
for (i, (key, value)) in params.iter().enumerate() {
if i > 0 {
url.push('&');
}
url.push_str(&format!("{key}={value}"));
}
}
let mut req = self.agent.get(&url);
if let Some(ref auth) = self.auth_header() {
req = req.header("Authorization", auth);
}
let response = self.handle_error(req.call())?;
let body: T = self.handle_error(response.into_body().read_json())?;
Ok(body)
}
pub fn get_bytes(&self, path: &str) -> Result<Vec<u8>, CoreError> {
let url = self.url(path);
let mut req = self.agent.get(&url);
if let Some(ref auth) = self.auth_header() {
req = req.header("Authorization", auth);
}
let response = self.handle_error(req.call())?;
let mut body = response.into_body();
let bytes = body
.read_to_vec()
.map_err(|e| CoreError::Other(anyhow::anyhow!("{}", e)))?;
Ok(bytes)
}
pub fn post_bytes(
&self,
path: &str,
body_bytes: &[u8],
params: &[(&str, &str)],
) -> Result<ItemInfo, CoreError> {
let mut cursor = std::io::Cursor::new(body_bytes);
self.post_stream(path, &mut cursor, params)
}
/// Stream data from a reader to the server using chunked transfer encoding.
///
/// The reader is consumed in chunks and sent to the server without buffering
/// the entire body in memory. This enables true streaming for large payloads.
pub fn post_stream(
&self,
path: &str,
body_reader: &mut dyn Read,
params: &[(&str, &str)],
) -> Result<ItemInfo, CoreError> {
let mut url = self.url(path);
if !params.is_empty() {
url.push('?');
for (i, (key, value)) in params.iter().enumerate() {
if i > 0 {
url.push('&');
}
url.push_str(&format!("{key}={value}"));
}
}
let mut req = self.agent.post(&url);
if let Some(ref auth) = self.auth_header() {
req = req.header("Authorization", auth);
}
req = req.header("Content-Type", "application/octet-stream");
let response = self.handle_error(req.send(ureq::SendBody::from_reader(body_reader)))?;
#[derive(serde::Deserialize)]
struct ApiResponse {
data: Option<ItemInfo>,
error: Option<String>,
}
let api_response: ApiResponse = self.handle_error(response.into_body().read_json())?;
if let Some(error) = api_response.error {
return Err(CoreError::Other(anyhow::anyhow!("Server error: {}", error)));
}
api_response
.data
.ok_or_else(|| CoreError::Other(anyhow::anyhow!("No data in response")))
}
pub fn delete(&self, path: &str) -> Result<(), CoreError> {
let url = self.url(path);
let mut req = self.agent.delete(&url);
if let Some(ref auth) = self.auth_header() {
req = req.header("Authorization", auth);
}
self.handle_error(req.call())?;
Ok(())
}
pub fn get_status(&self) -> Result<serde_json::Value, CoreError> {
self.get_json("/api/status")
}
pub fn get_item_info(&self, id: i64) -> Result<ItemInfo, CoreError> {
#[derive(serde::Deserialize)]
struct ApiResponse {
data: Option<ItemInfo>,
}
let response: ApiResponse = self.get_json(&format!("/api/item/{id}/info"))?;
response
.data
.ok_or_else(|| CoreError::Other(anyhow::anyhow!("Item not found")))
}
pub fn list_items(
&self,
tags: &[String],
order: &str,
start: u64,
count: u64,
) -> Result<Vec<ItemInfo>, CoreError> {
#[derive(serde::Deserialize)]
struct ApiResponse {
data: Option<Vec<ItemInfo>>,
}
let mut params: Vec<(String, String)> = Vec::new();
params.push(("order".to_string(), order.to_string()));
params.push(("start".to_string(), start.to_string()));
params.push(("count".to_string(), count.to_string()));
if !tags.is_empty() {
params.push(("tags".to_string(), tags.join(",")));
}
let param_refs: Vec<(&str, &str)> = params
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
let response: ApiResponse = self.get_json_with_query("/api/item/", &param_refs)?;
Ok(response.data.unwrap_or_default())
}
pub fn save_item(
&self,
content: &[u8],
tags: &[String],
metadata: &HashMap<String, String>,
compress: bool,
meta: bool,
) -> Result<ItemInfo, CoreError> {
let mut params: Vec<(String, String)> = Vec::new();
if !tags.is_empty() {
params.push(("tags".to_string(), tags.join(",")));
}
if !metadata.is_empty() {
let meta_json = serde_json::to_string(metadata).map_err(|e| {
CoreError::Other(anyhow::anyhow!("Failed to serialize metadata: {}", e))
})?;
params.push(("metadata".to_string(), meta_json));
}
params.push(("compress".to_string(), compress.to_string()));
params.push(("meta".to_string(), meta.to_string()));
let param_refs: Vec<(&str, &str)> = params
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
self.post_bytes("/api/item/", content, &param_refs)
}
pub fn delete_item(&self, id: i64) -> Result<(), CoreError> {
self.delete(&format!("/api/item/{id}"))
}
/// Add metadata to an existing item.
pub fn post_metadata(
&self,
id: i64,
metadata: &HashMap<String, String>,
) -> Result<(), CoreError> {
let url = self.url(&format!("/api/item/{id}/meta"));
let mut req = self.agent.post(&url);
if let Some(ref auth) = self.auth_header() {
req = req.header("Authorization", auth);
}
req = req.header("Content-Type", "application/json");
let body = serde_json::to_vec(metadata)
.map_err(|e| CoreError::Other(anyhow::anyhow!("Failed to serialize metadata: {e}")))?;
let mut cursor = std::io::Cursor::new(body);
self.handle_error(req.send(ureq::SendBody::from_reader(&mut cursor)))?;
Ok(())
}
pub fn get_item_content_raw(&self, id: i64) -> Result<(Vec<u8>, String), CoreError> {
let url = format!(
"{}?decompress=false",
self.url(&format!("/api/item/{id}/content"))
);
let mut req = self.agent.get(&url);
if let Some(ref auth) = self.auth_header() {
req = req.header("Authorization", auth);
}
let response = self.handle_error(req.call())?;
let compression = response
.headers()
.get("X-Keep-Compression")
.and_then(|v| v.to_str().ok())
.unwrap_or("none")
.to_string();
let mut body = response.into_body();
let bytes = body
.read_to_vec()
.map_err(|e| CoreError::Other(anyhow::anyhow!("{}", e)))?;
Ok((bytes, compression))
}
pub fn diff_items(&self, id_a: i64, id_b: i64) -> Result<Vec<String>, CoreError> {
#[derive(serde::Deserialize)]
struct ApiResponse {
data: Option<Vec<String>>,
}
let params = [("id_a", id_a.to_string()), ("id_b", id_b.to_string())];
let param_refs: Vec<(&str, &str)> = params.iter().map(|(k, v)| (*k, v.as_str())).collect();
let response: ApiResponse = self.get_json_with_query("/api/diff", &param_refs)?;
Ok(response.data.unwrap_or_default())
}
}