chore: add favicons to bookmarks
fbec09b0
7 file(s) · +152 −21
| 631 | 631 | "mime_guess", |
|
| 632 | 632 | "nanoid", |
|
| 633 | 633 | "rand 0.8.5", |
|
| 634 | + | "reqwest 0.12.28", |
|
| 634 | 635 | "rusqlite", |
|
| 635 | 636 | "rust-embed", |
|
| 637 | + | "scraper", |
|
| 636 | 638 | "serde", |
|
| 637 | 639 | "serde_json", |
|
| 638 | 640 | "subtle", |
|
| 639 | 641 | "tokio", |
|
| 640 | 642 | "tracing", |
|
| 641 | 643 | "tracing-subscriber", |
|
| 644 | + | "url", |
|
| 642 | 645 | ] |
|
| 643 | 646 | ||
| 644 | 647 | [[package]] |
| 26 | 26 | andromeda-darkmatter-css = { workspace = true } |
|
| 27 | 27 | askama = "0.13" |
|
| 28 | 28 | chrono = "0.4" |
|
| 29 | + | reqwest = { version = "0.12" } |
|
| 30 | + | scraper = "0.22" |
|
| 31 | + | url = "2" |
| 13 | 13 | ); |
|
| 14 | 14 | ||
| 15 | 15 | CREATE TABLE IF NOT EXISTS links ( |
|
| 16 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 17 | - | short_id TEXT NOT NULL UNIQUE, |
|
| 18 | - | title TEXT NOT NULL, |
|
| 19 | - | url TEXT NOT NULL, |
|
| 20 | - | category_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE CASCADE, |
|
| 21 | - | created_at INTEGER NOT NULL |
|
| 16 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 17 | + | short_id TEXT NOT NULL UNIQUE, |
|
| 18 | + | title TEXT NOT NULL, |
|
| 19 | + | url TEXT NOT NULL, |
|
| 20 | + | favicon_url TEXT, |
|
| 21 | + | category_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE CASCADE, |
|
| 22 | + | created_at INTEGER NOT NULL |
|
| 22 | 23 | ); |
|
| 23 | 24 | ||
| 24 | 25 | CREATE INDEX IF NOT EXISTS idx_links_category ON links(category_id, created_at DESC); |
|
| 38 | 39 | pub short_id: String, |
|
| 39 | 40 | pub title: String, |
|
| 40 | 41 | pub url: String, |
|
| 42 | + | pub favicon_url: Option<String>, |
|
| 41 | 43 | pub category_id: i64, |
|
| 42 | 44 | pub created_at: i64, |
|
| 43 | 45 | } |
|
| 56 | 58 | "UPDATE categories SET position = id WHERE position = 0", |
|
| 57 | 59 | [], |
|
| 58 | 60 | )?; |
|
| 61 | + | } |
|
| 62 | + | let has_favicon: bool = conn |
|
| 63 | + | .prepare("SELECT 1 FROM pragma_table_info('links') WHERE name = 'favicon_url'")? |
|
| 64 | + | .exists([])?; |
|
| 65 | + | if !has_favicon { |
|
| 66 | + | conn.execute("ALTER TABLE links ADD COLUMN favicon_url TEXT", [])?; |
|
| 59 | 67 | } |
|
| 60 | 68 | Ok(()) |
|
| 61 | 69 | } |
|
| 165 | 173 | pub fn list_links(db: &Db) -> Result<Vec<Link>, DbError> { |
|
| 166 | 174 | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 167 | 175 | let mut stmt = conn.prepare( |
|
| 168 | - | "SELECT id, short_id, title, url, category_id, created_at FROM links ORDER BY created_at DESC", |
|
| 176 | + | "SELECT id, short_id, title, url, favicon_url, category_id, created_at FROM links ORDER BY created_at DESC", |
|
| 169 | 177 | )?; |
|
| 170 | 178 | let rows = stmt.query_map([], |row| { |
|
| 171 | 179 | Ok(Link { |
|
| 173 | 181 | short_id: row.get(1)?, |
|
| 174 | 182 | title: row.get(2)?, |
|
| 175 | 183 | url: row.get(3)?, |
|
| 176 | - | category_id: row.get(4)?, |
|
| 177 | - | created_at: row.get(5)?, |
|
| 184 | + | favicon_url: row.get(4)?, |
|
| 185 | + | category_id: row.get(5)?, |
|
| 186 | + | created_at: row.get(6)?, |
|
| 178 | 187 | }) |
|
| 179 | 188 | })?; |
|
| 180 | 189 | Ok(rows.collect::<Result<Vec<_>, _>>()?) |
|
| 181 | 190 | } |
|
| 182 | 191 | ||
| 183 | - | pub fn create_link(db: &Db, title: &str, url: &str, category_id: i64) -> Result<Link, DbError> { |
|
| 192 | + | pub fn create_link( |
|
| 193 | + | db: &Db, |
|
| 194 | + | title: &str, |
|
| 195 | + | url: &str, |
|
| 196 | + | favicon_url: Option<&str>, |
|
| 197 | + | category_id: i64, |
|
| 198 | + | ) -> Result<Link, DbError> { |
|
| 184 | 199 | let now = chrono::Utc::now().timestamp(); |
|
| 185 | 200 | let short_id = nanoid!(10); |
|
| 186 | 201 | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 187 | 202 | conn.execute( |
|
| 188 | - | "INSERT INTO links (short_id, title, url, category_id, created_at) VALUES (?1, ?2, ?3, ?4, ?5)", |
|
| 189 | - | params![short_id, title, url, category_id, now], |
|
| 203 | + | "INSERT INTO links (short_id, title, url, favicon_url, category_id, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", |
|
| 204 | + | params![short_id, title, url, favicon_url, category_id, now], |
|
| 190 | 205 | )?; |
|
| 191 | 206 | Ok(Link { |
|
| 192 | 207 | id: conn.last_insert_rowid(), |
|
| 193 | 208 | short_id, |
|
| 194 | 209 | title: title.to_string(), |
|
| 195 | 210 | url: url.to_string(), |
|
| 211 | + | favicon_url: favicon_url.map(|s| s.to_string()), |
|
| 196 | 212 | category_id, |
|
| 197 | 213 | created_at: now, |
|
| 198 | 214 | }) |
|
| 215 | + | } |
|
| 216 | + | ||
| 217 | + | pub fn list_links_missing_favicon(db: &Db) -> Result<Vec<(i64, String)>, DbError> { |
|
| 218 | + | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 219 | + | let mut stmt = conn.prepare( |
|
| 220 | + | "SELECT id, url FROM links WHERE favicon_url IS NULL OR favicon_url = ''", |
|
| 221 | + | )?; |
|
| 222 | + | let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?; |
|
| 223 | + | Ok(rows.collect::<Result<Vec<_>, _>>()?) |
|
| 224 | + | } |
|
| 225 | + | ||
| 226 | + | pub fn update_link_favicon(db: &Db, id: i64, favicon_url: Option<&str>) -> Result<(), DbError> { |
|
| 227 | + | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 228 | + | conn.execute( |
|
| 229 | + | "UPDATE links SET favicon_url = ?1 WHERE id = ?2", |
|
| 230 | + | params![favicon_url, id], |
|
| 231 | + | )?; |
|
| 232 | + | Ok(()) |
|
| 199 | 233 | } |
|
| 200 | 234 | ||
| 201 | 235 | pub fn delete_link_by_short_id(db: &Db, short_id: &str) -> Result<bool, DbError> { |
|
| 1 | + | use scraper::{Html, Selector}; |
|
| 2 | + | use std::time::Duration; |
|
| 3 | + | use url::Url; |
|
| 4 | + | ||
| 5 | + | fn build_client() -> reqwest::Client { |
|
| 6 | + | reqwest::Client::builder() |
|
| 7 | + | .timeout(Duration::from_secs(15)) |
|
| 8 | + | .user_agent("andromeda-bookmarks/0.1 (+https://github.com/stevedylandev/andromeda)") |
|
| 9 | + | .build() |
|
| 10 | + | .expect("Failed to build HTTP client") |
|
| 11 | + | } |
|
| 12 | + | ||
| 13 | + | /// Best-effort favicon URL for a page. Parses `<link rel="icon">` from the |
|
| 14 | + | /// HTML, falls back to `/favicon.ico` at the site root. Returns None if |
|
| 15 | + | /// the URL is invalid. |
|
| 16 | + | pub async fn discover_favicon(page_url: &str) -> Option<String> { |
|
| 17 | + | let parsed = Url::parse(page_url).ok()?; |
|
| 18 | + | let client = build_client(); |
|
| 19 | + | ||
| 20 | + | if let Ok(resp) = client.get(page_url).send().await { |
|
| 21 | + | if let Ok(body) = resp.text().await { |
|
| 22 | + | let document = Html::parse_document(&body); |
|
| 23 | + | let selector = Selector::parse( |
|
| 24 | + | r#"link[rel="icon"], link[rel="shortcut icon"], link[rel="apple-touch-icon"]"#, |
|
| 25 | + | ) |
|
| 26 | + | .ok()?; |
|
| 27 | + | if let Some(href) = document |
|
| 28 | + | .select(&selector) |
|
| 29 | + | .find_map(|el| el.attr("href")) |
|
| 30 | + | { |
|
| 31 | + | if let Ok(resolved) = parsed.join(href) { |
|
| 32 | + | return Some(resolved.to_string()); |
|
| 33 | + | } |
|
| 34 | + | } |
|
| 35 | + | } |
|
| 36 | + | } |
|
| 37 | + | ||
| 38 | + | parsed.join("/favicon.ico").ok().map(|u| u.to_string()) |
|
| 39 | + | } |
| 1 | 1 | mod auth; |
|
| 2 | 2 | mod db; |
|
| 3 | + | mod favicon; |
|
| 3 | 4 | ||
| 4 | 5 | use std::sync::{Arc, Mutex}; |
|
| 5 | 6 | ||
| 75 | 76 | short_id: String, |
|
| 76 | 77 | title: String, |
|
| 77 | 78 | url: String, |
|
| 79 | + | favicon_url: Option<String>, |
|
| 78 | 80 | category: String, |
|
| 79 | 81 | } |
|
| 80 | 82 | ||
| 176 | 178 | short_id: l.short_id, |
|
| 177 | 179 | title: l.title, |
|
| 178 | 180 | url: l.url, |
|
| 181 | + | favicon_url: l.favicon_url, |
|
| 179 | 182 | category: cat, |
|
| 180 | 183 | } |
|
| 181 | 184 | }) |
|
| 264 | 267 | return Redirect::to("/admin?error=Server+error").into_response(); |
|
| 265 | 268 | } |
|
| 266 | 269 | }; |
|
| 267 | - | match db::create_link(&state.db, title, url, cat.id) { |
|
| 268 | - | Ok(_) => Redirect::to("/admin?success=Link+added").into_response(), |
|
| 270 | + | let link = match db::create_link(&state.db, title, url, None, cat.id) { |
|
| 271 | + | Ok(l) => l, |
|
| 269 | 272 | Err(e) => { |
|
| 270 | 273 | tracing::error!("create link: {e}"); |
|
| 271 | - | Redirect::to("/admin?error=Failed+to+add+link").into_response() |
|
| 274 | + | return Redirect::to("/admin?error=Failed+to+add+link").into_response(); |
|
| 272 | 275 | } |
|
| 273 | - | } |
|
| 276 | + | }; |
|
| 277 | + | let db = state.db.clone(); |
|
| 278 | + | let url_owned = url.to_string(); |
|
| 279 | + | tokio::spawn(async move { |
|
| 280 | + | if let Some(fav) = favicon::discover_favicon(&url_owned).await { |
|
| 281 | + | let _ = db::update_link_favicon(&db, link.id, Some(&fav)); |
|
| 282 | + | } |
|
| 283 | + | }); |
|
| 284 | + | Redirect::to("/admin?success=Link+added").into_response() |
|
| 274 | 285 | } |
|
| 275 | 286 | ||
| 276 | 287 | async fn admin_delete_link( |
|
| 370 | 381 | return StatusCode::INTERNAL_SERVER_ERROR.into_response(); |
|
| 371 | 382 | } |
|
| 372 | 383 | }; |
|
| 373 | - | match db::create_link(&state.db, title, url, cat.id) { |
|
| 374 | - | Ok(link) => (StatusCode::CREATED, Json(link)).into_response(), |
|
| 384 | + | let mut link = match db::create_link(&state.db, title, url, None, cat.id) { |
|
| 385 | + | Ok(l) => l, |
|
| 375 | 386 | Err(e) => { |
|
| 376 | 387 | tracing::error!("create link: {e}"); |
|
| 377 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() |
|
| 388 | + | return StatusCode::INTERNAL_SERVER_ERROR.into_response(); |
|
| 378 | 389 | } |
|
| 390 | + | }; |
|
| 391 | + | if let Some(fav) = favicon::discover_favicon(url).await { |
|
| 392 | + | let _ = db::update_link_favicon(&state.db, link.id, Some(&fav)); |
|
| 393 | + | link.favicon_url = Some(fav); |
|
| 379 | 394 | } |
|
| 395 | + | (StatusCode::CREATED, Json(link)).into_response() |
|
| 380 | 396 | } |
|
| 381 | 397 | ||
| 382 | 398 | async fn require_api_key( |
|
| 433 | 449 | api_key: std::env::var("BOOKMARKS_API_KEY").ok().filter(|s| !s.is_empty()), |
|
| 434 | 450 | cookie_secure, |
|
| 435 | 451 | }); |
|
| 452 | + | ||
| 453 | + | { |
|
| 454 | + | let db = state.db.clone(); |
|
| 455 | + | tokio::spawn(async move { |
|
| 456 | + | let pending = match db::list_links_missing_favicon(&db) { |
|
| 457 | + | Ok(rows) => rows, |
|
| 458 | + | Err(e) => { |
|
| 459 | + | tracing::error!("favicon backfill query: {e}"); |
|
| 460 | + | return; |
|
| 461 | + | } |
|
| 462 | + | }; |
|
| 463 | + | if pending.is_empty() { |
|
| 464 | + | return; |
|
| 465 | + | } |
|
| 466 | + | tracing::info!("favicon backfill: {} link(s)", pending.len()); |
|
| 467 | + | for (id, url) in pending { |
|
| 468 | + | if let Some(fav) = favicon::discover_favicon(&url).await { |
|
| 469 | + | if let Err(e) = db::update_link_favicon(&db, id, Some(&fav)) { |
|
| 470 | + | tracing::error!("favicon backfill update {id}: {e}"); |
|
| 471 | + | } |
|
| 472 | + | } |
|
| 473 | + | tokio::time::sleep(std::time::Duration::from_millis(250)).await; |
|
| 474 | + | } |
|
| 475 | + | tracing::info!("favicon backfill: done"); |
|
| 476 | + | }); |
|
| 477 | + | } |
|
| 436 | 478 | ||
| 437 | 479 | let api_authed = Router::new() |
|
| 438 | 480 | .route("/api/links", post(api_create_link)) |
|
| 110 | 110 | {% for link in links %} |
|
| 111 | 111 | <li class="admin-list-item"> |
|
| 112 | 112 | <div class="admin-list-info"> |
|
| 113 | - | <a class="admin-list-title" href="{{ link.url }}" target="_blank" rel="noopener noreferrer">{{ link.title }}</a> |
|
| 113 | + | <a class="admin-list-title" href="{{ link.url }}" target="_blank" rel="noopener noreferrer"> |
|
| 114 | + | {% if let Some(fav) = link.favicon_url %} |
|
| 115 | + | <img class="favicon" src="{{ fav }}" alt="" width="16" height="16" loading="lazy" /> |
|
| 116 | + | {% endif %} |
|
| 117 | + | {{ link.title }} |
|
| 118 | + | </a> |
|
| 114 | 119 | <div class="admin-list-meta"> |
|
| 115 | 120 | <span class="tag">{{ link.category }}</span> |
|
| 116 | 121 | </div> |
| 41 | 41 | <ul class="item-list"> |
|
| 42 | 42 | {% for link in group.links %} |
|
| 43 | 43 | <li class="item"> |
|
| 44 | - | <a class="item-title" href="{{ link.url }}" target="_blank" rel="noopener noreferrer">{{ link.title }}</a> |
|
| 44 | + | <a class="item-title" href="{{ link.url }}" target="_blank" rel="noopener noreferrer"> |
|
| 45 | + | {% if let Some(fav) = link.favicon_url %} |
|
| 46 | + | <img class="favicon" src="{{ fav }}" alt="" width="16" height="16" loading="lazy" /> |
|
| 47 | + | {% endif %} |
|
| 48 | + | {{ link.title }} |
|
| 49 | + | </a> |
|
| 45 | 50 | <div class="item-meta">{{ link.url }}</div> |
|
| 46 | 51 | </li> |
|
| 47 | 52 | {% endfor %} |