| 1 |
|
| 2 |
|
| 3 |
use crate::commands::error::ApiError; |
| 4 |
use crate::state::AppState; |
| 5 |
use bb_db::{BookmarkId, CreateBookmark, ItemId, UpdateBookmark}; |
| 6 |
use regex::Regex; |
| 7 |
use serde::{Deserialize, Serialize}; |
| 8 |
use std::sync::{Arc, LazyLock}; |
| 9 |
use tauri::State; |
| 10 |
use tracing::instrument; |
| 11 |
|
| 12 |
|
| 13 |
|
| 14 |
|
| 15 |
#[derive(Debug, Clone, Serialize)] |
| 16 |
#[serde(rename_all = "camelCase")] |
| 17 |
pub struct BookmarkResponse { |
| 18 |
pub id: String, |
| 19 |
pub url: String, |
| 20 |
pub title: String, |
| 21 |
pub description: String, |
| 22 |
pub author: String, |
| 23 |
pub source_name: String, |
| 24 |
pub feed_item_id: Option<String>, |
| 25 |
pub notes: String, |
| 26 |
pub is_pinned: bool, |
| 27 |
pub tags: Vec<String>, |
| 28 |
pub created_at: String, |
| 29 |
pub time_ago: String, |
| 30 |
} |
| 31 |
|
| 32 |
|
| 33 |
#[derive(Debug, Clone, Deserialize)] |
| 34 |
#[serde(rename_all = "camelCase")] |
| 35 |
pub struct CreateBookmarkInput { |
| 36 |
pub url: String, |
| 37 |
pub title: String, |
| 38 |
#[serde(default)] |
| 39 |
pub description: String, |
| 40 |
#[serde(default)] |
| 41 |
pub author: String, |
| 42 |
#[serde(default)] |
| 43 |
pub source_name: String, |
| 44 |
#[serde(default)] |
| 45 |
pub notes: String, |
| 46 |
#[serde(default)] |
| 47 |
pub tags: Vec<String>, |
| 48 |
} |
| 49 |
|
| 50 |
|
| 51 |
#[derive(Debug, Clone, Deserialize)] |
| 52 |
#[serde(rename_all = "camelCase")] |
| 53 |
pub struct UpdateBookmarkInput { |
| 54 |
pub title: Option<String>, |
| 55 |
pub description: Option<String>, |
| 56 |
pub notes: Option<String>, |
| 57 |
pub is_pinned: Option<bool>, |
| 58 |
} |
| 59 |
|
| 60 |
|
| 61 |
|
| 62 |
fn format_time_ago(timestamp: &str) -> String { |
| 63 |
let dt = bb_db::parse_timestamp(timestamp); |
| 64 |
let now = chrono::Utc::now(); |
| 65 |
let diff = now.signed_duration_since(dt); |
| 66 |
|
| 67 |
if diff.num_seconds() < 60 { |
| 68 |
"just now".to_string() |
| 69 |
} else if diff.num_minutes() < 60 { |
| 70 |
format!("{}m ago", diff.num_minutes()) |
| 71 |
} else if diff.num_hours() < 24 { |
| 72 |
format!("{}h ago", diff.num_hours()) |
| 73 |
} else if diff.num_days() < 7 { |
| 74 |
format!("{}d ago", diff.num_days()) |
| 75 |
} else { |
| 76 |
dt.format("%b %d, %Y").to_string() |
| 77 |
} |
| 78 |
} |
| 79 |
|
| 80 |
async fn bookmark_to_response( |
| 81 |
db: &bb_db::Database, |
| 82 |
bookmark: bb_db::DbBookmark, |
| 83 |
) -> Result<BookmarkResponse, ApiError> { |
| 84 |
let tags = db.bookmarks().get_tags(bookmark.id).await?; |
| 85 |
Ok(BookmarkResponse { |
| 86 |
id: bookmark.id.to_string(), |
| 87 |
url: bookmark.url, |
| 88 |
title: bookmark.title, |
| 89 |
description: bookmark.description, |
| 90 |
author: bookmark.author, |
| 91 |
source_name: bookmark.source_name, |
| 92 |
feed_item_id: bookmark.feed_item_id, |
| 93 |
notes: bookmark.notes, |
| 94 |
is_pinned: bookmark.is_pinned, |
| 95 |
tags, |
| 96 |
created_at: bookmark.created_at.clone(), |
| 97 |
time_ago: format_time_ago(&bookmark.created_at), |
| 98 |
}) |
| 99 |
} |
| 100 |
|
| 101 |
|
| 102 |
const BB_TAG_CONFIG: tagtree::TagConfig = tagtree::TagConfig { |
| 103 |
max_depth: 3, |
| 104 |
max_length: 80, |
| 105 |
semantic_depth: 0, |
| 106 |
}; |
| 107 |
|
| 108 |
fn validate_bookmark_tags(tags: &[String]) -> Result<(), ApiError> { |
| 109 |
for tag in tags { |
| 110 |
tagtree::validate_with(tag, &BB_TAG_CONFIG) |
| 111 |
.map_err(|e| ApiError::bad_request(format!("invalid tag: {}", e.0)))?; |
| 112 |
} |
| 113 |
Ok(()) |
| 114 |
} |
| 115 |
|
| 116 |
fn validate_url(url: &str) -> Result<(), ApiError> { |
| 117 |
let url = url.trim(); |
| 118 |
if url.is_empty() { |
| 119 |
return Err(ApiError::bad_request("URL is required")); |
| 120 |
} |
| 121 |
if !url.starts_with("http://") && !url.starts_with("https://") { |
| 122 |
return Err(ApiError::bad_request("URL must start with http:// or https://")); |
| 123 |
} |
| 124 |
Ok(()) |
| 125 |
} |
| 126 |
|
| 127 |
|
| 128 |
|
| 129 |
|
| 130 |
#[tauri::command] |
| 131 |
#[instrument(skip_all)] |
| 132 |
pub async fn list_bookmarks( |
| 133 |
state: State<'_, Arc<AppState>>, |
| 134 |
tag: Option<String>, |
| 135 |
) -> Result<Vec<BookmarkResponse>, ApiError> { |
| 136 |
let db = state.orchestrator.database(); |
| 137 |
let bookmarks = db.bookmarks().list(tag.as_deref()).await?; |
| 138 |
|
| 139 |
let mut responses = Vec::with_capacity(bookmarks.len()); |
| 140 |
for bookmark in bookmarks { |
| 141 |
responses.push(bookmark_to_response(db, bookmark).await?); |
| 142 |
} |
| 143 |
|
| 144 |
Ok(responses) |
| 145 |
} |
| 146 |
|
| 147 |
|
| 148 |
#[tauri::command] |
| 149 |
#[instrument(skip_all)] |
| 150 |
pub async fn create_bookmark( |
| 151 |
state: State<'_, Arc<AppState>>, |
| 152 |
input: CreateBookmarkInput, |
| 153 |
) -> Result<BookmarkResponse, ApiError> { |
| 154 |
validate_url(&input.url)?; |
| 155 |
validate_bookmark_tags(&input.tags)?; |
| 156 |
|
| 157 |
let db = state.orchestrator.database(); |
| 158 |
|
| 159 |
|
| 160 |
if db.bookmarks().get_by_url(input.url.trim()).await?.is_some() { |
| 161 |
return Err(ApiError::bad_request("URL is already bookmarked")); |
| 162 |
} |
| 163 |
|
| 164 |
let bookmark = db |
| 165 |
.bookmarks() |
| 166 |
.create(CreateBookmark { |
| 167 |
url: input.url.trim().to_string(), |
| 168 |
title: input.title.trim().to_string(), |
| 169 |
description: input.description, |
| 170 |
author: input.author, |
| 171 |
source_name: input.source_name, |
| 172 |
feed_item_id: None, |
| 173 |
notes: input.notes, |
| 174 |
tags: input.tags, |
| 175 |
}) |
| 176 |
.await?; |
| 177 |
|
| 178 |
bookmark_to_response(db, bookmark).await |
| 179 |
} |
| 180 |
|
| 181 |
|
| 182 |
#[tauri::command] |
| 183 |
#[instrument(skip_all)] |
| 184 |
pub async fn create_bookmark_from_item( |
| 185 |
state: State<'_, Arc<AppState>>, |
| 186 |
item_id: String, |
| 187 |
tags: Option<Vec<String>>, |
| 188 |
) -> Result<BookmarkResponse, ApiError> { |
| 189 |
if let Some(ref t) = tags { |
| 190 |
validate_bookmark_tags(t)?; |
| 191 |
} |
| 192 |
|
| 193 |
let id: ItemId = item_id |
| 194 |
.parse() |
| 195 |
.map_err(|_| ApiError::bad_request("Invalid item ID"))?; |
| 196 |
|
| 197 |
let db = state.orchestrator.database(); |
| 198 |
|
| 199 |
let item = db |
| 200 |
.items() |
| 201 |
.get(id) |
| 202 |
.await? |
| 203 |
.ok_or_else(|| ApiError::not_found("Feed item not found"))?; |
| 204 |
|
| 205 |
|
| 206 |
if db.bookmarks().get_by_feed_item(&item.id.to_string()).await?.is_some() { |
| 207 |
return Err(ApiError::bad_request("Item is already bookmarked")); |
| 208 |
} |
| 209 |
if let Some(ref url) = item.url { |
| 210 |
if db.bookmarks().get_by_url(url).await?.is_some() { |
| 211 |
return Err(ApiError::bad_request("URL is already bookmarked")); |
| 212 |
} |
| 213 |
} |
| 214 |
|
| 215 |
let url = item.url.clone().unwrap_or_default(); |
| 216 |
if url.is_empty() { |
| 217 |
return Err(ApiError::bad_request("Item has no URL to bookmark")); |
| 218 |
} |
| 219 |
|
| 220 |
let bookmark = db |
| 221 |
.bookmarks() |
| 222 |
.create(CreateBookmark { |
| 223 |
url, |
| 224 |
title: item.title.clone().unwrap_or_else(|| item.bite_text.clone()), |
| 225 |
description: item.body.clone().map(|b| { |
| 226 |
|
| 227 |
let plain = b.chars().take(300).collect::<String>(); |
| 228 |
plain |
| 229 |
}).unwrap_or_default(), |
| 230 |
author: item.bite_author.clone(), |
| 231 |
source_name: item.source_name.clone(), |
| 232 |
feed_item_id: Some(item.id.to_string()), |
| 233 |
notes: String::new(), |
| 234 |
tags: tags.unwrap_or_default(), |
| 235 |
}) |
| 236 |
.await?; |
| 237 |
|
| 238 |
bookmark_to_response(db, bookmark).await |
| 239 |
} |
| 240 |
|
| 241 |
|
| 242 |
#[tauri::command] |
| 243 |
#[instrument(skip_all)] |
| 244 |
pub async fn update_bookmark( |
| 245 |
state: State<'_, Arc<AppState>>, |
| 246 |
id: String, |
| 247 |
input: UpdateBookmarkInput, |
| 248 |
) -> Result<(), ApiError> { |
| 249 |
let bookmark_id: BookmarkId = id |
| 250 |
.parse() |
| 251 |
.map_err(|_| ApiError::bad_request("Invalid bookmark ID"))?; |
| 252 |
|
| 253 |
state |
| 254 |
.orchestrator |
| 255 |
.database() |
| 256 |
.bookmarks() |
| 257 |
.update( |
| 258 |
bookmark_id, |
| 259 |
UpdateBookmark { |
| 260 |
title: input.title, |
| 261 |
description: input.description, |
| 262 |
notes: input.notes, |
| 263 |
is_pinned: input.is_pinned, |
| 264 |
}, |
| 265 |
) |
| 266 |
.await?; |
| 267 |
|
| 268 |
Ok(()) |
| 269 |
} |
| 270 |
|
| 271 |
|
| 272 |
#[tauri::command] |
| 273 |
#[instrument(skip_all)] |
| 274 |
pub async fn delete_bookmark( |
| 275 |
state: State<'_, Arc<AppState>>, |
| 276 |
id: String, |
| 277 |
) -> Result<(), ApiError> { |
| 278 |
let bookmark_id: BookmarkId = id |
| 279 |
.parse() |
| 280 |
.map_err(|_| ApiError::bad_request("Invalid bookmark ID"))?; |
| 281 |
|
| 282 |
state |
| 283 |
.orchestrator |
| 284 |
.database() |
| 285 |
.bookmarks() |
| 286 |
.delete(bookmark_id) |
| 287 |
.await?; |
| 288 |
|
| 289 |
Ok(()) |
| 290 |
} |
| 291 |
|
| 292 |
|
| 293 |
#[tauri::command] |
| 294 |
#[instrument(skip_all)] |
| 295 |
pub async fn set_bookmark_tags( |
| 296 |
state: State<'_, Arc<AppState>>, |
| 297 |
id: String, |
| 298 |
tags: Vec<String>, |
| 299 |
) -> Result<(), ApiError> { |
| 300 |
validate_bookmark_tags(&tags)?; |
| 301 |
|
| 302 |
let bookmark_id: BookmarkId = id |
| 303 |
.parse() |
| 304 |
.map_err(|_| ApiError::bad_request("Invalid bookmark ID"))?; |
| 305 |
|
| 306 |
state |
| 307 |
.orchestrator |
| 308 |
.database() |
| 309 |
.bookmarks() |
| 310 |
.set_tags(bookmark_id, &tags) |
| 311 |
.await?; |
| 312 |
|
| 313 |
Ok(()) |
| 314 |
} |
| 315 |
|
| 316 |
|
| 317 |
#[tauri::command] |
| 318 |
#[instrument(skip_all)] |
| 319 |
pub async fn list_bookmark_tags( |
| 320 |
state: State<'_, Arc<AppState>>, |
| 321 |
) -> Result<Vec<String>, ApiError> { |
| 322 |
Ok(state |
| 323 |
.orchestrator |
| 324 |
.database() |
| 325 |
.bookmarks() |
| 326 |
.list_all_tags() |
| 327 |
.await?) |
| 328 |
} |
| 329 |
|
| 330 |
|
| 331 |
#[tauri::command] |
| 332 |
#[instrument(skip_all)] |
| 333 |
pub async fn is_bookmarked( |
| 334 |
state: State<'_, Arc<AppState>>, |
| 335 |
url: String, |
| 336 |
) -> Result<bool, ApiError> { |
| 337 |
Ok(state |
| 338 |
.orchestrator |
| 339 |
.database() |
| 340 |
.bookmarks() |
| 341 |
.get_by_url(&url) |
| 342 |
.await? |
| 343 |
.is_some()) |
| 344 |
} |
| 345 |
|
| 346 |
|
| 347 |
#[tauri::command] |
| 348 |
#[instrument(skip_all)] |
| 349 |
pub async fn get_bookmark_count( |
| 350 |
state: State<'_, Arc<AppState>>, |
| 351 |
) -> Result<i64, ApiError> { |
| 352 |
Ok(state |
| 353 |
.orchestrator |
| 354 |
.database() |
| 355 |
.bookmarks() |
| 356 |
.count() |
| 357 |
.await?) |
| 358 |
} |
| 359 |
|
| 360 |
|
| 361 |
|
| 362 |
#[tauri::command] |
| 363 |
#[instrument(skip_all)] |
| 364 |
pub async fn export_bookmark_html( |
| 365 |
state: State<'_, Arc<AppState>>, |
| 366 |
id: String, |
| 367 |
) -> Result<String, ApiError> { |
| 368 |
let bookmark_id: BookmarkId = id |
| 369 |
.parse() |
| 370 |
.map_err(|_| ApiError::bad_request("Invalid bookmark ID"))?; |
| 371 |
|
| 372 |
let db = state.orchestrator.database(); |
| 373 |
let bookmark = db |
| 374 |
.bookmarks() |
| 375 |
.get(bookmark_id) |
| 376 |
.await? |
| 377 |
.ok_or_else(|| ApiError::not_found("Bookmark not found"))?; |
| 378 |
|
| 379 |
|
| 380 |
let body = if let Some(ref item_id) = bookmark.feed_item_id { |
| 381 |
if let Ok(id) = item_id.parse::<ItemId>() { |
| 382 |
db.items() |
| 383 |
.get(id) |
| 384 |
.await? |
| 385 |
.and_then(|item| item.body.clone()) |
| 386 |
.unwrap_or_default() |
| 387 |
} else { |
| 388 |
String::new() |
| 389 |
} |
| 390 |
} else { |
| 391 |
String::new() |
| 392 |
}; |
| 393 |
|
| 394 |
let html = format!( |
| 395 |
r#"<!DOCTYPE html> |
| 396 |
<html lang="en"> |
| 397 |
<head> |
| 398 |
<meta charset="UTF-8"> |
| 399 |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| 400 |
<title>{title}</title> |
| 401 |
<style> |
| 402 |
body {{ max-width: 700px; margin: 2rem auto; padding: 0 1rem; font-family: system-ui, sans-serif; line-height: 1.6; color: #333; }} |
| 403 |
h1 {{ font-size: 1.5rem; }} |
| 404 |
.meta {{ color: #666; font-size: 0.9rem; margin-bottom: 1.5rem; }} |
| 405 |
.meta a {{ color: #0066cc; }} |
| 406 |
img {{ max-width: 100%; height: auto; }} |
| 407 |
</style> |
| 408 |
</head> |
| 409 |
<body> |
| 410 |
<h1>{title}</h1> |
| 411 |
<div class="meta"> |
| 412 |
{author_line} |
| 413 |
<a href="{url}">{url}</a><br> |
| 414 |
Saved from Balanced Breakfast |
| 415 |
</div> |
| 416 |
<div class="content">{body}</div> |
| 417 |
</body> |
| 418 |
</html>"#, |
| 419 |
title = html_escape(&bookmark.title), |
| 420 |
url = html_escape(&bookmark.url), |
| 421 |
author_line = if bookmark.author.is_empty() { |
| 422 |
String::new() |
| 423 |
} else { |
| 424 |
format!("By {}<br>", html_escape(&bookmark.author)) |
| 425 |
}, |
| 426 |
|
| 427 |
|
| 428 |
|
| 429 |
body = sanitize_body_for_export(&body), |
| 430 |
); |
| 431 |
|
| 432 |
Ok(html) |
| 433 |
} |
| 434 |
|
| 435 |
|
| 436 |
|
| 437 |
|
| 438 |
fn sanitize_body_for_export(html: &str) -> String { |
| 439 |
static DANGEROUS_TAGS: LazyLock<Regex> = LazyLock::new(|| { |
| 440 |
Regex::new(r#"(?i)<\s*/?\s*(script|iframe|object|embed|form|base|style)\b[^>]*>"#).expect("invalid regex") |
| 441 |
}); |
| 442 |
static ON_HANDLERS: LazyLock<Regex> = LazyLock::new(|| { |
| 443 |
Regex::new(r#"(?i)\bon\w+\s*=\s*["'][^"']*["']"#).expect("invalid regex") |
| 444 |
}); |
| 445 |
let s = DANGEROUS_TAGS.replace_all(html, ""); |
| 446 |
ON_HANDLERS.replace_all(&s, "").into_owned() |
| 447 |
} |
| 448 |
|
| 449 |
fn html_escape(s: &str) -> String { |
| 450 |
s.replace('&', "&") |
| 451 |
.replace('<', "<") |
| 452 |
.replace('>', ">") |
| 453 |
.replace('"', """) |
| 454 |
} |
| 455 |
|
| 456 |
#[cfg(test)] |
| 457 |
mod tests { |
| 458 |
use super::*; |
| 459 |
|
| 460 |
#[test] |
| 461 |
fn validate_url_rejects_empty() { |
| 462 |
assert!(validate_url("").is_err()); |
| 463 |
assert!(validate_url(" ").is_err()); |
| 464 |
} |
| 465 |
|
| 466 |
#[test] |
| 467 |
fn validate_url_rejects_non_http() { |
| 468 |
assert!(validate_url("ftp://example.com").is_err()); |
| 469 |
assert!(validate_url("javascript:alert(1)").is_err()); |
| 470 |
} |
| 471 |
|
| 472 |
#[test] |
| 473 |
fn validate_url_accepts_http() { |
| 474 |
assert!(validate_url("http://example.com").is_ok()); |
| 475 |
assert!(validate_url("https://example.com/path?q=1").is_ok()); |
| 476 |
} |
| 477 |
|
| 478 |
#[test] |
| 479 |
fn html_escape_special_chars() { |
| 480 |
assert_eq!(html_escape("<script>"), "<script>"); |
| 481 |
assert_eq!(html_escape("a&b"), "a&b"); |
| 482 |
assert_eq!(html_escape("\"quoted\""), ""quoted""); |
| 483 |
} |
| 484 |
|
| 485 |
#[test] |
| 486 |
fn time_ago_just_now() { |
| 487 |
let now = chrono::Utc::now().format(bb_db::TIMESTAMP_FMT).to_string(); |
| 488 |
assert_eq!(format_time_ago(&now), "just now"); |
| 489 |
} |
| 490 |
} |
| 491 |
|