Files
keep/src/modes/server/pages.rs
Andrew Phillips 17be6abaab refactor: streaming, security hardening, and MCP removal
Major overhaul of server architecture and security posture:

- Streaming: Unified all I/O through PIPESIZE (8192-byte) buffers.
  POST bodies stream via MpscReader through the save pipeline. GET
  content streams from disk via decompression to client. Removed
  save_item_with_reader, get_item_content_info, ChannelReader.
  413 responses keep partial items (nonfatal by design).

- Security: XSS protection in all HTML pages via html_escape crate.
  Security headers middleware (nosniff, frame deny, referrer policy).
  CORS tightened to explicit headers. Input validation for tags
  (256 chars), metadata (128/4096), pagination (10k cap). Config
  file reads use from_utf8_lossy. Generic error messages in HTML.
  Diff endpoint has 10 MB per-item cap. max_body_size config option.

- Panics eliminated: Path unwraps → proper error propagation.
  Mutex unwraps → map_err (registries) / expect with message (local).

- MCP removed: Deleted all MCP code, rmcp dependency, mcp feature.

- Docs: Updated README, DESIGN, AGENTS to reflect all changes.
2026-03-14 00:03:42 -03:00

478 lines
14 KiB
Rust

use crate::config::ColumnConfig;
use crate::db;
use crate::modes::server::AppState;
use anyhow::Result;
use axum::{
extract::{Path, Query, State},
response::{Html, Response},
};
use html_escape::{encode_double_quoted_attribute, encode_text};
use log::debug;
use rusqlite::Connection;
use serde::Deserialize;
use std::collections::HashMap;
/// Escape text content for safe HTML insertion.
fn esc(s: &str) -> String {
encode_text(s).to_string()
}
/// Escape attribute values for safe HTML attribute insertion.
fn esc_attr(s: &str) -> String {
encode_double_quoted_attribute(s).to_string()
}
#[derive(Deserialize)]
/// Query parameters for the item list endpoint.
///
/// This struct defines the query parameters used to filter, sort, and paginate
/// the list of items displayed on the main page.
///
/// # Fields
///
/// * `sort` - Sorting order, defaults to "newest".
/// * `tags` - Optional comma-separated list of tags to filter by.
/// * `count` - Number of items per page, defaults to 1000.
/// * `start` - Starting index for pagination, defaults to 0.
pub struct ListQueryParams {
#[serde(default = "default_sort")]
sort: String,
#[serde(default)]
tags: Option<String>,
#[serde(default = "default_count")]
count: usize,
#[serde(default)]
start: usize,
}
fn default_sort() -> String {
"newest".to_string()
}
/// Provides the default sorting order for item lists.
///
/// # Returns
///
/// A string representing the default sort order: "newest".
fn default_count() -> usize {
1000
}
/// Adds the web page routes to the Axum router.
///
/// This function configures the routes for the web interface, including the
/// main item list, individual item details, and static CSS styles.
///
/// # Arguments
///
/// * `app` - The existing Axum router with application state.
///
/// # Returns
///
/// The updated router with web routes added.
///
/// # Examples
///
/// ```ignore
/// let app = pages::add_routes(axum::Router::new());
/// ```
pub fn add_routes(app: axum::Router<AppState>) -> axum::Router<AppState> {
app.route("/", axum::routing::get(list_items))
.route("/item/{item_id}", axum::routing::get(show_item))
.route("/style.css", axum::routing::get(style_css))
}
async fn list_items(
State(state): State<AppState>,
Query(params): Query<ListQueryParams>,
) -> Result<Response, Html<String>> {
let conn = state.db.lock().await;
let settings = &state.settings;
let result = build_item_list(&conn, &params, &settings.list_format);
match result {
Ok(html) => {
// Build response with explicit Content-Length
let response = Response::builder()
.header("content-type", "text/html")
.header("content-length", html.len().to_string())
.body(axum::body::Body::from(html))
.map_err(|_| Html("<html><body>Internal Server Error</body></html>".to_string()))?;
Ok(response)
}
Err(_e) => Err(Html(
"<html><body>An internal error occurred</body></html>".to_string(),
)),
}
}
fn build_item_list(
conn: &Connection,
params: &ListQueryParams,
columns: &[ColumnConfig],
) -> Result<String> {
let tags: Vec<String> = params
.tags
.as_ref()
.map(|t| t.split(',').map(|s| s.trim().to_string()).collect())
.unwrap_or_default();
let items = if tags.is_empty() {
db::query_all_items(conn)?
} else {
db::query_tagged_items(conn, &tags)?
};
// Sort items
let mut sorted_items = items;
if params.sort == "newest" {
sorted_items.sort_by(|a, b| b.id.cmp(&a.id));
} else {
sorted_items.sort_by(|a, b| a.id.cmp(&b.id));
}
// Apply pagination
let start = params.start;
let count = params.count.min(10000);
let end = std::cmp::min(start + count, sorted_items.len());
let page_items = if start < sorted_items.len() {
sorted_items[start..std::cmp::min(end, sorted_items.len())].to_vec()
} else {
vec![]
};
// Get tags and meta for all items in the page
let item_ids: Vec<i64> = page_items.iter().filter_map(|item| item.id).collect();
let tags_map = db::get_tags_for_items(conn, &item_ids)?;
let meta_map = db::get_meta_for_items(conn, &item_ids)?;
// Debug: print number of tags per item
for item_id in &item_ids {
if let Some(tags) = tags_map.get(item_id) {
debug!("Item {} has {} tags: {:?}", item_id, tags.len(), tags);
}
}
let mut html = String::new();
html.push_str("<html><head><title>Keep - Items</title>");
html.push_str("<link rel=\"stylesheet\" href=\"/style.css\">");
html.push_str("</head><body>");
html.push_str("<h1>Items</h1>");
html.push_str("<p><a href=\"/swagger\">API Documentation</a></p>");
// Add recent tags section using the items we already have
html.push_str("<h2>Recent Tags</h2>");
// Collect all tags from all items, keeping track of their timestamps
let mut all_tags_with_time: Vec<(String, chrono::DateTime<chrono::Utc>)> = Vec::new();
for item in &sorted_items {
if let Some(item_id) = item.id
&& let Some(tags) = tags_map.get(&item_id)
{
for tag in tags {
all_tags_with_time.push((tag.name.clone(), item.ts));
}
}
}
// Sort by timestamp descending (most recent first)
all_tags_with_time.sort_by(|a, b| b.1.cmp(&a.1));
// Get unique tags in order of most recent appearance
let mut seen = std::collections::HashSet::new();
let mut recent_tags = Vec::new();
for (tag, _) in all_tags_with_time {
if !seen.contains(&tag) {
seen.insert(tag.clone());
recent_tags.push(tag);
if recent_tags.len() >= 20 {
break;
}
}
}
if recent_tags.is_empty() {
html.push_str("<p>No tags found</p>");
} else {
html.push_str("<p>");
for tag in recent_tags {
html.push_str(&format!(
"<a href=\"/?tags={}\" style=\"margin-right: 8px;\">{}</a>",
esc_attr(&tag),
esc(&tag)
));
}
html.push_str("</p>");
}
// Start table
html.push_str("<table>");
// Table headers
html.push_str("<tr>");
for column in columns {
html.push_str(&format!("<th>{}</th>", esc(&column.label)));
}
html.push_str("<th>Actions</th>");
html.push_str("</tr>");
// Table rows
for item in page_items {
let item_id = item.id.unwrap_or(0);
let tags = tags_map.get(&item_id).cloned().unwrap_or_default();
let meta: HashMap<String, String> = meta_map
.get(&item_id)
.map(|metas| {
metas
.iter()
.map(|(name, value)| (name.clone(), value.clone()))
.collect()
})
.unwrap_or_default();
html.push_str("<tr>");
for column in columns {
let value = match column.name.as_str() {
"id" => {
let id_value = item.id.map(|id| id.to_string()).unwrap_or_default();
// Make the ID a link to the item details page
format!("<a href=\"/item/{item_id}\">{id_value}</a>")
}
"time" => item.ts.format("%Y-%m-%d %H:%M:%S").to_string(),
"size" => item.size.map(|s| s.to_string()).unwrap_or_default(),
"tags" => {
// Make sure we're using all tags for the item
let tag_links: Vec<String> = tags
.iter()
.map(|t| {
format!(
"<a href=\"/?tags={}\">{}</a>",
esc_attr(&t.name),
esc(&t.name)
)
})
.collect();
tag_links.join(", ")
}
_ => {
if column.name.starts_with("meta:") {
let meta_key = &column.name[5..];
meta.get(meta_key).cloned().unwrap_or_default()
} else {
String::new()
}
}
};
// Apply max_len if specified, but skip for tags column to avoid truncating HTML
let display_value = if column.name == "tags" {
value
} else if let Some(max_len_str) = &column.max_len {
if let Ok(max_len) = max_len_str.parse::<usize>() {
if value.chars().count() > max_len {
let truncated: String = value.chars().take(max_len).collect();
format!("{truncated}...")
} else {
value
}
} else {
value
}
} else {
value
};
// Apply alignment
let align_style = match column.align {
crate::config::ColumnAlignment::Left => "text-align: left;",
crate::config::ColumnAlignment::Right => "text-align: right;",
crate::config::ColumnAlignment::Center => "text-align: center;",
};
let rendered_value = if column.name == "tags" {
display_value // Already contains escaped HTML links
} else {
esc(&display_value)
};
html.push_str(&format!(
"<td style=\"{align_style}\">{rendered_value}</td>"
));
}
// Actions column
html.push_str(&format!(
"<td><a href=\"/item/{item_id}\">View</a> | <a href=\"/api/item/{item_id}/content\">Download</a></td>"
));
html.push_str("</tr>");
}
html.push_str("</table>");
// Add pagination info
html.push_str(&format!(
"<p>Showing {} to {} of {} items</p>",
start + 1,
std::cmp::min(end, sorted_items.len()),
sorted_items.len()
));
html.push_str("</body></html>");
Ok(html)
}
async fn style_css() -> &'static str {
r#"
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 20px;
background-color: #f5f5f5;
}
h1, h2 {
color: #333;
}
table {
border-collapse: collapse;
width: 100%;
background-color: white;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
th, td {
border: 1px solid #ddd;
padding: 12px;
text-align: left;
}
th {
background-color: #f2f2f2;
position: sticky;
top: 0;
}
tr:nth-child(even) {
background-color: #f9f9f9;
}
tr:hover {
background-color: #f1f1f1;
}
a {
color: #0066cc;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.pagination {
margin: 20px 0;
}
.actions {
white-space: nowrap;
}
"#
}
async fn show_item(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Response, Html<String>> {
let conn = state.db.lock().await;
let result = build_item_details(&conn, id);
match result {
Ok(html) => {
// Build response with explicit Content-Length
let response = Response::builder()
.header("content-type", "text/html")
.header("content-length", html.len().to_string())
.body(axum::body::Body::from(html))
.map_err(|_| Html("<html><body>Internal Server Error</body></html>".to_string()))?;
Ok(response)
}
Err(_e) => Err(Html(
"<html><body>An internal error occurred</body></html>".to_string(),
)),
}
}
fn build_item_details(conn: &Connection, id: i64) -> Result<String> {
let item = match db::get_item(conn, id)? {
Some(item) => item,
None => return Err(anyhow::anyhow!("Item not found")),
};
let tags = db::get_item_tags(conn, &item)?;
let metas = db::get_item_meta(conn, &item)?;
let mut html = String::new();
html.push_str(&format!("<html><head><title>Keep - Item #{id}</title>"));
html.push_str("<link rel=\"stylesheet\" href=\"/style.css\">");
html.push_str("</head><body>");
html.push_str(&format!("<h1>Item #{id}</h1>"));
// Single table for all details
html.push_str("<table>");
html.push_str(&format!(
"<tr><th>ID</th><td>{}</td></tr>",
item.id.unwrap_or(0)
));
html.push_str(&format!(
"<tr><th>Timestamp</th><td>{}</td></tr>",
item.ts.format("%Y-%m-%d %H:%M:%S")
));
html.push_str(&format!(
"<tr><th>Size</th><td>{}</td></tr>",
item.size.unwrap_or(0)
));
html.push_str(&format!(
"<tr><th>Compression</th><td>{}</td></tr>",
esc(&item.compression)
));
// Tags row
html.push_str("<tr><th>Tags</th><td>");
if tags.is_empty() {
html.push_str("No tags");
} else {
let tag_links: Vec<String> = tags
.iter()
.map(|t| {
format!(
"<a href=\"/?tags={}\">{}</a>",
esc_attr(&t.name),
esc(&t.name)
)
})
.collect();
html.push_str(&tag_links.join(", "));
}
html.push_str("</td></tr>");
// Metadata rows
if metas.is_empty() {
html.push_str("<tr><th>Metadata</th><td>No metadata</td></tr>");
} else {
for meta in metas {
html.push_str(&format!(
"<tr><th>{}</th><td>{}</td></tr>",
esc(&meta.name),
esc(&meta.value)
));
}
}
html.push_str("</table>");
// Links
html.push_str("<h2>Actions</h2>");
html.push_str(&format!(
"<p><a href=\"/api/item/{id}/content\">Download Content</a></p>"
));
html.push_str("<p><a href=\"/\">Back to list</a></p>");
html.push_str("</body></html>");
Ok(html)
}