chore: add favicons to bookmarks fbec09b0
Steve Simkins · 2026-05-02 11:11 7 file(s) · +152 −21
Cargo.lock +3 −0
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]]
apps/bookmarks/Cargo.toml +3 −0
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"
apps/bookmarks/src/db.rs +46 −12
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> {
apps/bookmarks/src/favicon.rs (added) +39 −0
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 +
}
apps/bookmarks/src/main.rs +49 −7
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))
apps/bookmarks/src/templates/admin.html +6 −1
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>
apps/bookmarks/src/templates/index.html +6 −1
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 %}