Skip to main content

bookmarks_webapp/
lib.rs

1//! Embedded webapp for bookmarks.
2
3use axum::extract::{Path, Query, State};
4use axum::http::StatusCode;
5use axum::response::Html;
6use axum::routing::{get, post};
7use axum::{Json, Router};
8use std::net::{SocketAddr, TcpStream};
9use std::sync::{Arc, Mutex};
10use std::thread;
11use std::time::Duration;
12use tokio::sync::oneshot;
13
14use bookmarks_core::config::{Config, UrlEntry};
15use bookmarks_core::storage::Storage;
16use bookmarks_core::strings;
17
18const DEFAULT_WEBAPP_PORT: u16 = 1414;
19
20fn default_webapp_addr() -> SocketAddr {
21    SocketAddr::from(([127, 0, 0, 1], DEFAULT_WEBAPP_PORT))
22}
23
24struct AppState {
25    storage: Mutex<Box<dyn Storage>>,
26}
27
28impl AppState {
29    fn lock_storage(&self) -> std::sync::MutexGuard<'_, Box<dyn Storage>> {
30        self.storage.lock().unwrap_or_else(|e| e.into_inner())
31    }
32
33    fn load_config(&self) -> Result<Config, String> {
34        self.lock_storage().load().map_err(|e| e.to_string())
35    }
36
37    fn storage_metadata(&self) -> (String, Option<String>) {
38        let storage = self.lock_storage();
39        (
40            storage.backend_name().to_string(),
41            storage
42                .path()
43                .map(|path| path.to_string_lossy().into_owned()),
44        )
45    }
46
47    /// Hold the lock across the entire load-modify-save cycle to prevent
48    /// TOCTOU races between concurrent requests.
49    fn modify_config<F>(&self, f: F) -> Result<(), String>
50    where
51        F: FnOnce(&mut Config) -> Result<(), String>,
52    {
53        let storage = self.lock_storage();
54        let mut config = storage.load().map_err(|e| e.to_string())?;
55        f(&mut config)?;
56        storage.save(&config).map_err(|e| e.to_string())
57    }
58}
59
60fn escape(s: &str) -> String {
61    s.replace('&', "&amp;")
62        .replace('<', "&lt;")
63        .replace('>', "&gt;")
64        .replace('"', "&quot;")
65}
66
67fn js_attr_string(s: &str) -> String {
68    let literal = serde_json::to_string(s).unwrap_or_else(|_| "\"\"".to_string());
69    escape(&literal)
70}
71
72// -- HTML rendering ----------------------------------------------------------
73
74const STYLE: &str = r#"
75* { margin: 0; padding: 0; box-sizing: border-box; }
76html { background: #1a1a29; }
77body { font-family: system-ui, -apple-system, sans-serif; background: #1a1a29; color: #8c8ca6; max-width: 720px; margin: 0 auto; padding: 32px 24px; }
78h1 { font-size: 1.4rem; color: #8c8ca6; margin-bottom: 8px; font-weight: 500; }
79.subtitle { font-size: 0.85rem; color: #8c8ca6; margin-bottom: 24px; }
80.subtitle a { color: #bf4dff; text-decoration: none; }
81.subtitle a:hover { text-decoration: underline; }
82h2 { font-size: 1rem; color: #8c8ca6; margin-bottom: 12px; text-transform: lowercase; }
83.section { margin-bottom: 28px; }
84table { width: 100%; border-collapse: collapse; table-layout: fixed; }
85col.col-check { width: 28px; }
86col.col-name { width: 130px; }
87col.col-actions { width: 70px; }
88th { text-align: left; font-size: 0.75rem; color: #666680; text-transform: uppercase; letter-spacing: 0.05em; padding: 6px 8px; border-bottom: 1px solid #2e2e47; }
89th.sortable { cursor: pointer; user-select: none; }
90th.sortable:hover { color: #8c8ca6; }
91th.active { color: #bf4dff; }
92td { padding: 6px 8px; border-bottom: 1px solid #242438; font-size: 0.85rem; vertical-align: top; overflow: hidden; text-overflow: ellipsis; }
93td.check, th.check { text-align: center; overflow: visible; }
94td.check input, th.check input { cursor: pointer; accent-color: #bf4dff; }
95td.name { color: #bf4dff; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
96td.name a { color: #bf4dff; text-decoration: none; }
97td.name a:hover { text-decoration: underline; }
98td.url a { color: #22d3ee; text-decoration: none; word-break: break-all; }
99td.url a:hover { text-decoration: underline; color: #67e8f9; }
100td.aliases { color: #a640f2; font-size: 0.8rem; }
101td.entries a { color: #a640f2; text-decoration: none; }
102td.entries a:hover { text-decoration: underline; color: #bf4dff; }
103.actions { text-align: right; white-space: nowrap; }
104.btn { background: none; border: 1px solid #2e2e47; color: #8c8ca6; padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 0.75rem; }
105.btn:hover { border-color: #666680; color: #edeedf; }
106.btn-danger { border-color: #5c2a2a; color: #ff7373; }
107.btn-danger:hover { border-color: #ff7373; color: #ffa0a0; }
108.btn-add { background: #242438; border-color: #2e2e47; color: #bf4dff; white-space: nowrap; width: 72px; text-align: center; flex-shrink: 0; }
109.btn-add:hover { background: #2e2e47; border-color: #666680; }
110.bulk-bar { display: none; align-items: center; gap: 8px; margin-bottom: 12px; padding: 8px 12px; background: #242438; border: 1px solid #2e2e47; border-radius: 6px; }
111.bulk-bar.visible { display: flex; }
112.bulk-bar .bulk-count { font-size: 0.8rem; color: #bf4dff; }
113form.inline { display: flex; gap: 6px; align-items: center; margin-top: 6px; }
114form.inline input { background: #242438; border: 1px solid #2e2e47; color: #edeedf; padding: 5px 8px; border-radius: 4px; font-size: 0.8rem; min-width: 0; }
115form.inline input:first-of-type { flex: 2; }
116form.inline input:nth-of-type(2) { flex: 3; }
117form.inline input::placeholder { color: #666680; }
118form.inline input:focus { outline: none; border-color: #bf4dff; }
119.copy-btn { background: none; border: none; color: #666680; cursor: pointer; padding: 0; line-height: 1; flex-shrink: 0; vertical-align: middle; }
120.copy-btn:hover { color: #8c8ca6; }
121.copy-btn.copied { color: #4ade80; }
122td.url .url-cell { display: flex; align-items: center; gap: 6px; }
123.error-banner { background: #3a1a2a; border: 1px solid #5c2a2a; color: #ff7373; padding: 8px 12px; border-radius: 6px; margin-bottom: 12px; font-size: 0.8rem; cursor: pointer; }
124.editable { cursor: pointer; }
125.editable:hover { background: #2e2e47; border-radius: 3px; }
126.edit-input { background: #242438; border: 1px solid #bf4dff; color: #edeedf; padding: 3px 6px; border-radius: 3px; font-size: 0.8rem; width: 100%; font-family: inherit; }
127.edit-input:focus { outline: none; }
128.empty { color: #666680; font-style: italic; font-size: 0.85rem; padding: 12px 0; }
129.toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 16px; }
130.toolbar input { background: #242438; border: 1px solid #2e2e47; color: #edeedf; padding: 5px 8px; border-radius: 4px; font-size: 0.8rem; width: 200px; }
131.toolbar input::placeholder { color: #666680; }
132.toolbar input:focus { outline: none; border-color: #bf4dff; }
133.tabs { display: flex; gap: 4px; flex-shrink: 0; }
134.tab { background: none; border: 1px solid #2e2e47; color: #8c8ca6; padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 0.75rem; }
135.tab:hover { color: #edeedf; border-color: #666680; }
136.tab.active { color: #bf4dff; border-color: #bf4dff; background: #382952; }
137.counts { font-size: 0.7rem; color: #666680; margin-left: 3px; }
138.modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 100; align-items: center; justify-content: center; }
139.modal-overlay.visible { display: flex; }
140.modal { background: #141421; border: 1px solid #2e2e47; border-radius: 8px; padding: 24px; max-width: 400px; width: 90%; }
141.modal h3 { color: #edeedf; font-size: 1rem; margin-bottom: 8px; }
142.modal p { color: #8c8ca6; font-size: 0.85rem; margin-bottom: 16px; line-height: 1.4; }
143.modal .modal-actions { display: flex; gap: 8px; justify-content: flex-end; }
144.modal .btn-cancel { border-color: #2e2e47; color: #8c8ca6; padding: 6px 16px; font-size: 0.8rem; }
145.modal .btn-confirm { background: #3a1a2a; border-color: #ff7373; color: #ff7373; padding: 6px 16px; font-size: 0.8rem; }
146@media (max-width: 680px) {
147  body { width: auto; padding: 24px 16px; }
148  .toolbar { flex-wrap: wrap; }
149  .toolbar input { width: 100%; }
150  .tabs { width: 100%; }
151  .tab { flex: 1; text-align: center; }
152  form.inline { flex-wrap: wrap; }
153  form.inline input:first-of-type { flex: 1 1 100%; }
154  form.inline input:nth-of-type(2) { flex: 1 1 auto; }
155  .btn-add { flex-shrink: 0; }
156  col.col-name { width: 100px; }
157  col.col-actions { width: 60px; }
158}
159"#;
160
161const SCRIPT: &str = r#"
162var pendingAction = null;
163
164function contentEl() {
165  return document.getElementById('content');
166}
167
168function afterSwap() {
169  updateBulkBar();
170  document.querySelectorAll('.select-all').forEach(function(cb) { cb.checked = false; });
171}
172
173function swapContent(html) {
174  contentEl().innerHTML = html;
175  afterSwap();
176}
177
178function fetchContent(url) {
179  fetch(url, { headers: { 'X-Requested-With': 'fetch' } })
180    .then(function(r) { if (!r.ok) throw new Error(r.statusText); return r.text(); })
181    .then(swapContent);
182}
183
184function postAndSwap(url, body) {
185  fetch(url, {
186    method: 'POST',
187    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
188    body: body || ''
189  })
190    .then(function(r) { if (!r.ok) throw new Error(r.statusText); return r.text(); })
191    .then(swapContent);
192}
193
194document.addEventListener('submit', function(e) {
195  var form = e.target.closest('form[data-post]');
196  if (!form) return;
197  e.preventDefault();
198  postAndSwap(form.dataset.post, new URLSearchParams(new FormData(form)).toString());
199});
200
201document.addEventListener('click', function(e) {
202  var trigger = e.target.closest('[data-fetch]');
203  if (!trigger) return;
204  e.preventDefault();
205  fetchContent(trigger.dataset.fetch);
206});
207
208function confirmDelete(title, message, action) {
209  document.getElementById('confirm-title').textContent = title;
210  document.getElementById('confirm-message').textContent = message;
211  document.getElementById('confirm-modal').classList.add('visible');
212  pendingAction = action;
213  document.getElementById('confirm-btn').onclick = function() {
214    var action = pendingAction;
215    closeModal();
216    if (action) action();
217  };
218}
219
220function closeModal() {
221  document.getElementById('confirm-modal').classList.remove('visible');
222  pendingAction = null;
223}
224
225document.addEventListener('DOMContentLoaded', function() {
226  document.getElementById('confirm-modal').addEventListener('click', function(e) {
227    if (e.target === this) closeModal();
228  });
229  document.addEventListener('keydown', function(e) {
230    if (e.key === 'Escape') closeModal();
231  });
232});
233
234function openGroup(urls) {
235  urls.forEach(function(u) { window.open(u, '_blank', 'noopener'); });
236}
237
238function copyUrl(btn, text) {
239  navigator.clipboard.writeText(text).then(function() {
240    btn.classList.add('copied');
241    btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
242    setTimeout(function() {
243      btn.classList.remove('copied');
244      btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
245    }, 1500);
246  });
247}
248
249function startEdit(event, type, name, field, currentValue) {
250  var cell = event.target.closest('td');
251  if (cell.querySelector('.edit-input')) return;
252  var original = cell.innerHTML;
253  var done = false;
254  var input = document.createElement('input');
255  input.className = 'edit-input';
256  input.value = currentValue;
257  function finish(save) {
258    if (done) return;
259    done = true;
260    if (save && input.value.trim() && input.value !== currentValue) {
261      submitEdit(type, name, field, input.value.trim(), cell, original);
262    } else {
263      cell.innerHTML = original;
264    }
265  }
266  input.addEventListener('keydown', function(e) {
267    if (e.key === 'Enter') { e.preventDefault(); finish(true); }
268    if (e.key === 'Escape') { finish(false); }
269  });
270  input.addEventListener('blur', function() { finish(true); });
271  cell.innerHTML = '';
272  cell.appendChild(input);
273  input.focus();
274  input.select();
275}
276
277function submitEdit(type, name, field, value, cell, original) {
278  var params = new URLSearchParams();
279  if (field === 'name') params.append('new_name', value);
280  if (field === 'url') params.append('new_url', value);
281  if (field === 'aliases') params.append('new_aliases', value);
282  if (field === 'entries') params.append('new_entries', value);
283  fetch('/edit/' + type + '/' + encodeURIComponent(name), {
284    method: 'POST',
285    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
286    body: params.toString()
287  })
288    .then(function(r) { if (!r.ok) throw new Error(r.statusText); return r.text(); })
289    .then(swapContent)
290    .catch(function() { cell.innerHTML = original; });
291}
292
293function deleteSingle(type, name) {
294  confirmDelete(
295    'delete ' + type,
296    'are you sure you want to delete ' + type + ' "' + name + '"? this cannot be undone.',
297    function() { postAndSwap('/delete/' + type + '/' + encodeURIComponent(name)); }
298  );
299}
300
301function updateBulkBar() {
302  var checked = document.querySelectorAll('input.row-check:checked');
303  var bar = document.getElementById('bulk-bar');
304  var count = document.getElementById('bulk-count');
305  if (!bar || !count) return;
306  if (checked.length > 0) {
307    bar.classList.add('visible');
308    count.textContent = checked.length + ' selected';
309  } else {
310    bar.classList.remove('visible');
311  }
312}
313
314function toggleAll(src) {
315  document.querySelectorAll('input.row-check').forEach(function(cb) {
316    if (cb.closest('tr').style.display !== 'none') cb.checked = src.checked;
317  });
318  updateBulkBar();
319}
320
321function clearSelected() {
322  document.querySelectorAll('input.row-check').forEach(function(cb) { cb.checked = false; });
323  updateBulkBar();
324}
325
326function deleteSelected() {
327  var checked = document.querySelectorAll('input.row-check:checked');
328  if (checked.length === 0) return;
329  var items = [];
330  checked.forEach(function(cb) { items.push(cb.dataset.type + ' "' + cb.dataset.name + '"'); });
331  confirmDelete(
332    'delete ' + checked.length + ' item' + (checked.length > 1 ? 's' : ''),
333    'are you sure you want to delete: ' + items.join(', ') + '? this cannot be undone.',
334    function() {
335      var toDelete = [];
336      checked.forEach(function(cb) {
337        toDelete.push({ type: cb.dataset.type, name: cb.dataset.name });
338      });
339      var i = 0;
340      function next() {
341        if (i >= toDelete.length) {
342          fetchContent('/content');
343          return;
344        }
345        var item = toDelete[i++];
346        fetch('/delete/' + item.type + '/' + encodeURIComponent(item.name), { method: 'POST' }).then(next);
347      }
348      next();
349    }
350  );
351}
352
353function filterRows() {
354  var q = document.getElementById('search').value.toLowerCase();
355  document.querySelectorAll('table tr[data-filter]').forEach(function(row) {
356    row.style.display = row.getAttribute('data-filter').toLowerCase().includes(q) ? '' : 'none';
357  });
358}
359
360function showTab(tab) {
361  ['urls','groups'].forEach(function(t) {
362    var el = document.getElementById('section-' + t);
363    var btn = document.getElementById('tab-' + t);
364    if (el) el.style.display = (t === tab || tab === 'all') ? '' : 'none';
365    if (btn) btn.classList.toggle('active', t === tab);
366  });
367  var allBtn = document.getElementById('tab-all');
368  if (allBtn) allBtn.classList.toggle('active', tab === 'all');
369  filterRows();
370}
371"#;
372
373fn page(body: &str) -> String {
374    let project_url = strings::PROJECT_URL;
375    format!(
376        r##"<!DOCTYPE html>
377<html lang="en">
378<head>
379  <meta charset="utf-8">
380  <meta name="viewport" content="width=device-width, initial-scale=1">
381  <title>bookmarks</title>
382  <style>{STYLE}</style>
383</head>
384<body>
385  <h1>Bookmarks</h1>
386  <p class="subtitle"><a href="{project_url}" target="_blank" rel="noopener">bookmarks</a> in your filesystem</p>
387  <div id="content">
388    {body}
389  </div>
390  <div class="modal-overlay" id="confirm-modal">
391    <div class="modal">
392      <h3 id="confirm-title">confirm delete</h3>
393      <p id="confirm-message"></p>
394      <div class="modal-actions">
395        <button class="btn btn-cancel" onclick="closeModal()">cancel</button>
396        <button class="btn btn-confirm" id="confirm-btn">delete</button>
397      </div>
398    </div>
399  </div>
400  <script>{SCRIPT}</script>
401</body>
402</html>"##
403    )
404}
405
406fn resolve_url<'a>(name: &str, config: &'a Config) -> Option<&'a str> {
407    bookmarks_core::open::resolve_uri(name, config).ok()
408}
409
410fn linked_name(name: &str, url: &str) -> String {
411    let n = escape(name);
412    let u = escape(url);
413    format!(r##"<a href="{u}" target="_blank" rel="noopener" title="{u}">{n}</a>"##)
414}
415
416fn copy_btn(url: &str) -> String {
417    let uj = js_attr_string(url);
418    format!(
419        r##"<button class="copy-btn" onclick="copyUrl(this,{uj})" title="copy to clipboard"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>"##
420    )
421}
422
423fn url_row(name: &str, entry: &UrlEntry) -> String {
424    let n = escape(name);
425    let nj = js_attr_string(name);
426    let url = entry.url();
427    let u = escape(url);
428    let uj = js_attr_string(url);
429    let name_link = linked_name(name, url);
430    let copy = copy_btn(url);
431    let aliases = entry.aliases();
432    let aliases_raw = aliases.join(", ");
433    let aliases_raw_js = js_attr_string(&aliases_raw);
434    let aliases_html = if aliases.is_empty() {
435        r#"<span style="color:#666680;font-size:0.8rem;font-style:italic">+ aliases</span>"#
436            .to_string()
437    } else {
438        let escaped: Vec<String> = aliases.iter().map(|a| escape(a)).collect();
439        format!(
440            r#"<span style="color:#a640f2;font-size:0.8rem">{}</span>"#,
441            escaped.join(", ")
442        )
443    };
444    format!(
445        r##"<tr data-filter="{n} {u} {aliases_filter}">
446  <td class="check"><input type="checkbox" class="row-check" data-type="url" data-name="{n}" onchange="updateBulkBar()"></td>
447  <td class="name editable" ondblclick="startEdit(event,'url',{nj},'name',{nj})">{name_link}</td>
448  <td class="url editable" ondblclick="startEdit(event,'url',{nj},'url',{uj})"><span class="url-cell">{copy}<a href="{u}" target="_blank" rel="noopener">{u}</a></span></td>
449  <td class="aliases editable" ondblclick="startEdit(event,'url',{nj},'aliases',{aliases_raw_js})">{aliases_html}</td>
450  <td class="actions">
451    <button class="btn btn-danger" onclick="deleteSingle('url',{nj})">delete</button>
452  </td>
453</tr>"##,
454        aliases_filter = escape(&aliases.join(" ")),
455    )
456}
457
458fn group_row(name: &str, entries: &[String], config: &Config) -> String {
459    let n = escape(name);
460    let nj = js_attr_string(name);
461    let urls: Vec<String> = entries
462        .iter()
463        .filter_map(|entry| resolve_url(entry, config).map(js_attr_string))
464        .collect();
465    let urls_arr = urls.join(",");
466    let entry_links: Vec<String> = entries
467        .iter()
468        .map(|entry| {
469            let e = escape(entry);
470            if let Some(url) = resolve_url(entry, config) {
471                let u = escape(url);
472                format!(r##"<a href="{u}" target="_blank" rel="noopener" title="{u}">{e}</a>"##)
473            } else {
474                e
475            }
476        })
477        .collect();
478    let entries_html = entry_links.join(", ");
479    let filter_str = entries
480        .iter()
481        .map(|e| escape(e))
482        .collect::<Vec<_>>()
483        .join(", ");
484    let name_cell = if urls.is_empty() {
485        n.clone()
486    } else {
487        format!(
488            r##"<a href="#" onclick="openGroup([{urls_arr}]);return false;" title="open all {count} urls">{n}</a>"##,
489            count = urls.len()
490        )
491    };
492    let entries_raw_js = js_attr_string(&entries.join(", "));
493    format!(
494        r##"<tr data-filter="{n} {filter_str}">
495  <td class="check"><input type="checkbox" class="row-check" data-type="group" data-name="{n}" onchange="updateBulkBar()"></td>
496  <td class="name editable" ondblclick="startEdit(event,'group',{nj},'name',{nj})">{name_cell}</td>
497  <td class="entries editable" ondblclick="startEdit(event,'group',{nj},'entries',{entries_raw_js})">{entries_html}</td>
498  <td class="actions">
499    <button class="btn btn-danger" onclick="deleteSingle('group',{nj})">delete</button>
500  </td>
501</tr>"##
502    )
503}
504
505#[derive(Debug, Clone, Copy, PartialEq)]
506enum SortField {
507    Name,
508    Url,
509}
510
511fn render_content(config: &Config, sort: SortField, error: Option<&str>) -> String {
512    let mut urls: Vec<_> = config.urls.iter().collect();
513    let mut groups: Vec<_> = config.groups.iter().collect();
514
515    match sort {
516        SortField::Name => {
517            urls.sort_by_key(|(k, _)| k.as_str());
518            groups.sort_by_key(|(k, _)| k.as_str());
519        }
520        SortField::Url => {
521            urls.sort_by_key(|(_, v)| v.url());
522            groups.sort_by_key(|(k, _)| k.as_str());
523        }
524    }
525
526    let name_cls = if sort == SortField::Name {
527        " active"
528    } else {
529        ""
530    };
531    let url_cls = if sort == SortField::Url {
532        " active"
533    } else {
534        ""
535    };
536
537    let mut html = String::new();
538
539    html.push_str(&format!(
540        r##"<div class="toolbar">
541  <input id="search" type="text" placeholder="{ph_filter}" oninput="filterRows()" autocomplete="off">
542  <div class="tabs">
543    <button id="tab-all" class="tab active" onclick="showTab('all')">all</button>
544    <button id="tab-urls" class="tab" onclick="showTab('urls')">urls<span class="counts">{uc}</span></button>
545    <button id="tab-groups" class="tab" onclick="showTab('groups')">groups<span class="counts">{gc}</span></button>
546  </div>
547</div>"##,
548        ph_filter = strings::PH_FILTER,
549        uc = urls.len(),
550        gc = groups.len(),
551    ));
552
553    if let Some(msg) = error {
554        let m = escape(msg);
555        html.push_str(&format!(
556            r##"<div class="error-banner" onclick="this.remove()">{m} <span style="margin-left:8px;cursor:pointer;opacity:0.6">x</span></div>"##
557        ));
558    }
559
560    html.push_str(
561        r##"<div class="bulk-bar" id="bulk-bar">
562  <span class="bulk-count" id="bulk-count">0 selected</span>
563  <button class="btn btn-danger" onclick="deleteSelected()">delete selected</button>
564  <button class="btn" onclick="clearSelected()">clear</button>
565</div>"##,
566    );
567
568    html.push_str(&format!(
569        r##"<div class="section">
570<form class="inline" data-post="/add/url" action="/add/url" method="post">
571  <input name="name" placeholder="{ph_url_name}" required>
572  <input name="url" placeholder="{ph_url}" required>
573  <button class="btn btn-add" type="submit">+ url</button>
574</form>
575<form class="inline" data-post="/add/group" action="/add/group" method="post">
576  <input name="name" placeholder="{ph_group_name}" required>
577  <input name="entries" placeholder="{ph_group_entries}" required>
578  <button class="btn btn-add" type="submit">+ group</button>
579</form>
580</div>"##,
581        ph_url_name = strings::PH_URL_NAME,
582        ph_url = strings::PH_URL,
583        ph_group_name = strings::PH_GROUP_NAME,
584        ph_group_entries = strings::PH_GROUP_ENTRIES,
585    ));
586
587    html.push_str(r##"<div class="section" id="section-urls"><h2>urls</h2>"##);
588    if urls.is_empty() {
589        html.push_str(r#"<p class="empty">no urls yet</p>"#);
590    } else {
591        html.push_str(&format!(
592            r##"<table><colgroup><col class="col-check"><col class="col-name"><col class="col-value"><col style="width:120px"><col class="col-actions"></colgroup><tr><th class="check"><input type="checkbox" class="select-all" onchange="toggleAll(this)"></th><th class="sortable{name_cls}" data-fetch="/content?sort=name">name</th><th class="sortable{url_cls}" data-fetch="/content?sort=url">url</th><th>aliases</th><th></th></tr>"##,
593        ));
594        for (name, entry) in &urls {
595            html.push_str(&url_row(name, entry));
596        }
597        html.push_str("</table>");
598    }
599    html.push_str("</div>");
600
601    html.push_str(r##"<div class="section" id="section-groups"><h2>groups</h2>"##);
602    if groups.is_empty() {
603        html.push_str(r#"<p class="empty">no groups yet</p>"#);
604    } else {
605        html.push_str(&format!(
606            r##"<table><colgroup><col class="col-check"><col class="col-name"><col class="col-value"><col class="col-actions"></colgroup><tr><th class="check"><input type="checkbox" class="select-all" onchange="toggleAll(this)"></th><th class="sortable{name_cls}" data-fetch="/content?sort=name">group</th><th>entries</th><th></th></tr>"##,
607        ));
608        for (name, entries) in &groups {
609            html.push_str(&group_row(name, entries, config));
610        }
611        html.push_str("</table>");
612    }
613    html.push_str("</div>");
614
615    html
616}
617
618// -- Handlers ----------------------------------------------------------------
619
620type S = State<Arc<AppState>>;
621type Form = axum::extract::Form<std::collections::HashMap<String, String>>;
622
623#[derive(Debug, serde::Deserialize, Default)]
624struct ContentQuery {
625    #[serde(default)]
626    sort: Option<String>,
627}
628
629#[derive(Debug, serde::Serialize)]
630struct HealthResponse {
631    status: &'static str,
632    app: &'static str,
633    backend: String,
634    path: Option<String>,
635}
636
637fn parse_sort(q: &ContentQuery) -> SortField {
638    match q.sort.as_deref() {
639        Some("url") => SortField::Url,
640        _ => SortField::Name,
641    }
642}
643
644async fn health(State(state): S) -> Json<HealthResponse> {
645    let (backend, path) = state.storage_metadata();
646    Json(HealthResponse {
647        status: "ok",
648        app: "bookmarks-webapp",
649        backend,
650        path,
651    })
652}
653
654async fn favicon() -> StatusCode {
655    StatusCode::NO_CONTENT
656}
657
658async fn index(State(state): S, q: Query<ContentQuery>) -> Html<String> {
659    Html(page(&render_state_content(&state, parse_sort(&q), None)))
660}
661
662async fn content(State(state): S, q: Query<ContentQuery>) -> Html<String> {
663    Html(render_state_content(&state, parse_sort(&q), None))
664}
665
666fn content_ok(state: &Arc<AppState>) -> Html<String> {
667    Html(render_state_content(state, SortField::Name, None))
668}
669
670fn content_err(state: &Arc<AppState>, msg: &str) -> Html<String> {
671    Html(render_state_content(state, SortField::Name, Some(msg)))
672}
673
674fn render_state_content(state: &Arc<AppState>, sort: SortField, error: Option<&str>) -> String {
675    match state.load_config() {
676        Ok(config) => render_content(&config, sort, error),
677        Err(load_err) => {
678            let message = match error {
679                Some(error) => format!("{error}; failed to load config: {load_err}"),
680                None => format!("failed to load config: {load_err}"),
681            };
682            render_content(&Config::default(), sort, Some(&message))
683        }
684    }
685}
686
687fn modify_or_err(
688    state: &Arc<AppState>,
689    f: impl FnOnce(&mut Config) -> Result<(), String>,
690) -> Html<String> {
691    match state.modify_config(f) {
692        Ok(()) => content_ok(state),
693        Err(e) => content_err(state, &e),
694    }
695}
696
697async fn add_url(State(state): S, axum::extract::Form(form): Form) -> Html<String> {
698    let name = form.get("name").cloned().unwrap_or_default();
699    let url = form.get("url").cloned().unwrap_or_default();
700    if !name.is_empty() && !url.is_empty() {
701        return modify_or_err(&state, |config| {
702            config.upsert_url(name, url);
703            Ok(())
704        });
705    }
706    content_ok(&state)
707}
708
709async fn add_group(State(state): S, axum::extract::Form(form): Form) -> Html<String> {
710    let name = form.get("name").cloned().unwrap_or_default();
711    let entries_raw = form.get("entries").cloned().unwrap_or_default();
712    if !name.is_empty() && !entries_raw.is_empty() {
713        let entries = Config::parse_list(&entries_raw);
714        if !entries.is_empty() {
715            return modify_or_err(&state, |config| {
716                config
717                    .upsert_group(name, entries)
718                    .map_err(|e| e.to_string())
719            });
720        }
721    }
722    content_ok(&state)
723}
724
725async fn delete_url(State(state): S, Path(name): Path<String>) -> Html<String> {
726    modify_or_err(&state, |config| {
727        config.delete_url(&name).map_err(|e| e.to_string())
728    })
729}
730
731async fn delete_group(State(state): S, Path(name): Path<String>) -> Html<String> {
732    modify_or_err(&state, |config| {
733        config.delete_group(&name).map_err(|e| e.to_string())
734    })
735}
736
737async fn edit_url(
738    State(state): S,
739    Path(name): Path<String>,
740    axum::extract::Form(form): Form,
741) -> Html<String> {
742    let new_name = form.get("new_name").filter(|s| !s.is_empty()).cloned();
743    let new_url = form.get("new_url").filter(|s| !s.is_empty()).cloned();
744    let new_aliases = form.get("new_aliases").cloned();
745
746    modify_or_err(&state, |config| {
747        let key = if let Some(ref new_name) = new_name
748            && new_name != &name
749        {
750            config
751                .rename_url(&name, new_name)
752                .map_err(|e| e.to_string())?;
753            new_name.clone()
754        } else {
755            name
756        };
757
758        if let Some(new_url) = new_url {
759            config
760                .set_url_value(&key, new_url)
761                .map_err(|e| e.to_string())?;
762        }
763
764        if let Some(new_aliases) = new_aliases {
765            config
766                .set_url_aliases(&key, Config::parse_list(&new_aliases))
767                .map_err(|e| e.to_string())?;
768        }
769
770        Ok(())
771    })
772}
773
774async fn edit_group(
775    State(state): S,
776    Path(name): Path<String>,
777    axum::extract::Form(form): Form,
778) -> Html<String> {
779    let new_name = form.get("new_name").filter(|s| !s.is_empty()).cloned();
780    let new_entries = form.get("new_entries").filter(|s| !s.is_empty()).cloned();
781
782    modify_or_err(&state, |config| {
783        let parsed_entries = new_entries
784            .as_ref()
785            .map(|raw| {
786                let entries = Config::parse_list(raw);
787                config
788                    .validate_group_entries(&entries)
789                    .map_err(|e| e.to_string())?;
790                Ok::<_, String>(entries)
791            })
792            .transpose()?;
793
794        let key = if let Some(ref new_name) = new_name
795            && new_name != &name
796        {
797            config
798                .rename_group(&name, new_name)
799                .map_err(|e| e.to_string())?;
800            new_name.clone()
801        } else {
802            name
803        };
804
805        if let Some(entries) = parsed_entries {
806            config
807                .set_group_entries(&key, entries)
808                .map_err(|e| e.to_string())?;
809        }
810
811        Ok(())
812    })
813}
814
815// -- Server ------------------------------------------------------------------
816
817pub fn router(storage: Box<dyn Storage>) -> Router {
818    let state = Arc::new(AppState {
819        storage: Mutex::new(storage),
820    });
821
822    Router::new()
823        .route("/", get(index))
824        .route("/favicon.ico", get(favicon))
825        .route("/api/health", get(health))
826        .route("/content", get(content))
827        .route("/add/url", post(add_url))
828        .route("/add/group", post(add_group))
829        .route("/delete/url/{name}", post(delete_url))
830        .route("/delete/group/{name}", post(delete_group))
831        .route("/edit/url/{name}", post(edit_url))
832        .route("/edit/group/{name}", post(edit_group))
833        .with_state(state)
834}
835
836pub struct BackgroundServer {
837    addr: SocketAddr,
838    shutdown: Option<oneshot::Sender<()>>,
839    handle: Option<thread::JoinHandle<anyhow::Result<()>>>,
840}
841
842impl BackgroundServer {
843    pub fn addr(&self) -> SocketAddr {
844        self.addr
845    }
846
847    pub fn url(&self) -> String {
848        format!("http://{}", self.addr)
849    }
850}
851
852impl Drop for BackgroundServer {
853    fn drop(&mut self) {
854        if let Some(shutdown) = self.shutdown.take() {
855            let _ = shutdown.send(());
856        }
857        if let Some(handle) = self.handle.take() {
858            let _ = handle.join();
859        }
860    }
861}
862
863pub fn spawn_loopback(storage: Box<dyn Storage>) -> anyhow::Result<BackgroundServer> {
864    let listener = std::net::TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0)))?;
865    let addr = listener.local_addr()?;
866    listener.set_nonblocking(true)?;
867
868    let app = router(storage);
869    let (shutdown_tx, shutdown_rx) = oneshot::channel();
870    let handle = thread::spawn(move || {
871        let rt = tokio::runtime::Runtime::new()?;
872        rt.block_on(async move {
873            let listener = tokio::net::TcpListener::from_std(listener)?;
874            axum::serve(listener, app)
875                .with_graceful_shutdown(async {
876                    let _ = shutdown_rx.await;
877                })
878                .await?;
879            Ok(())
880        })
881    });
882
883    Ok(BackgroundServer {
884        addr,
885        shutdown: Some(shutdown_tx),
886        handle: Some(handle),
887    })
888}
889
890pub fn serve_addr(storage: Box<dyn Storage>, addr: SocketAddr) -> anyhow::Result<()> {
891    let rt = tokio::runtime::Runtime::new()?;
892    rt.block_on(async {
893        let listener = tokio::net::TcpListener::bind(addr).await?;
894        axum::serve(listener, router(storage))
895            .with_graceful_shutdown(async {
896                tokio::signal::ctrl_c()
897                    .await
898                    .expect("failed to listen for ctrl+c");
899                println!("\nshutting down...");
900            })
901            .await?;
902        Ok(())
903    })
904}
905
906pub fn run_webapp(storage: Box<dyn Storage>) -> anyhow::Result<()> {
907    let addr = default_webapp_addr();
908    println!("bookmarks webapp: http://localhost:{}", addr.port());
909    let _ = open::that(format!("http://localhost:{}", addr.port()));
910    serve_addr(storage, addr)
911}
912
913pub fn wait_for_health(addr: SocketAddr) -> anyhow::Result<()> {
914    for _ in 0..50 {
915        if health_check(addr).is_ok_and(|body| body.contains("\"status\":\"ok\"")) {
916            return Ok(());
917        }
918        thread::sleep(Duration::from_millis(100));
919    }
920    anyhow::bail!("bookmarks webapp did not become ready at {addr}")
921}
922
923fn health_check(addr: SocketAddr) -> std::io::Result<String> {
924    use std::io::{Read, Write};
925
926    let mut stream = TcpStream::connect(addr)?;
927    stream.set_read_timeout(Some(Duration::from_millis(500)))?;
928    stream
929        .write_all(b"GET /api/health HTTP/1.1\r\nHost: bookmarks\r\nConnection: close\r\n\r\n")?;
930    let mut body = String::new();
931    stream.read_to_string(&mut body)?;
932    Ok(body)
933}
934
935#[cfg(test)]
936mod tests {
937    use super::*;
938    use axum::body::Body;
939    use http_body_util::BodyExt;
940    use tower::ServiceExt;
941
942    struct MemStorage {
943        config: Mutex<Config>,
944    }
945
946    struct LoadErrorStorage;
947
948    impl MemStorage {
949        fn new() -> Self {
950            Self {
951                config: Mutex::new(Config::default()),
952            }
953        }
954    }
955
956    impl Storage for MemStorage {
957        fn load(&self) -> anyhow::Result<Config> {
958            Ok(self.config.lock().unwrap().clone())
959        }
960
961        fn save(&self, config: &Config) -> anyhow::Result<()> {
962            *self.config.lock().unwrap() = config.clone();
963            Ok(())
964        }
965
966        fn init(&self) -> anyhow::Result<()> {
967            Ok(())
968        }
969
970        fn backend_name(&self) -> &str {
971            "memory"
972        }
973    }
974
975    impl Storage for LoadErrorStorage {
976        fn load(&self) -> anyhow::Result<Config> {
977            anyhow::bail!("bad bookmarks.toml")
978        }
979
980        fn save(&self, _config: &Config) -> anyhow::Result<()> {
981            panic!("save should not be called when load fails")
982        }
983
984        fn init(&self) -> anyhow::Result<()> {
985            Ok(())
986        }
987
988        fn backend_name(&self) -> &str {
989            "broken"
990        }
991    }
992
993    fn test_app() -> Router {
994        router(Box::new(MemStorage::new()))
995    }
996
997    fn load_error_app() -> Router {
998        router(Box::new(LoadErrorStorage))
999    }
1000
1001    async fn response_status(
1002        app: Router,
1003        method: &str,
1004        uri: &str,
1005        body: Option<&str>,
1006    ) -> (axum::http::StatusCode, String) {
1007        let req = axum::http::Request::builder().method(method).uri(uri);
1008
1009        let req = if let Some(b) = body {
1010            req.header("content-type", "application/x-www-form-urlencoded")
1011                .body(Body::from(b.to_string()))
1012                .unwrap()
1013        } else {
1014            req.body(Body::empty()).unwrap()
1015        };
1016
1017        let resp = app.oneshot(req).await.unwrap();
1018        let status = resp.status();
1019        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1020        let text = String::from_utf8_lossy(&bytes).to_string();
1021        (status, text)
1022    }
1023
1024    #[tokio::test]
1025    async fn get_index_returns_200_without_external_assets() {
1026        let (status, body) = response_status(test_app(), "GET", "/", None).await;
1027        assert_eq!(status, 200);
1028        assert!(body.contains("Bookmarks"));
1029        assert!(!body.contains("https://unpkg"));
1030        assert!(!body.contains("script src=\"http"));
1031    }
1032
1033    #[tokio::test]
1034    async fn load_errors_render_without_falling_back_to_empty_config() {
1035        let (status, body) = response_status(load_error_app(), "GET", "/", None).await;
1036        assert_eq!(status, 200);
1037        assert!(body.contains("error-banner"));
1038        assert!(body.contains("failed to load config: bad bookmarks.toml"));
1039    }
1040
1041    #[tokio::test]
1042    async fn mutations_do_not_save_when_config_load_fails() {
1043        let (status, body) = response_status(
1044            load_error_app(),
1045            "POST",
1046            "/add/url",
1047            Some("name=rust&url=https%3A%2F%2Frust-lang.org"),
1048        )
1049        .await;
1050        assert_eq!(status, 200);
1051        assert!(body.contains("failed to load config: bad bookmarks.toml"));
1052    }
1053
1054    #[test]
1055    fn js_attribute_strings_are_json_literals_and_html_escaped() {
1056        let literal = js_attr_string("a&b\"c'd<e>");
1057        assert!(literal.starts_with("&quot;"));
1058        assert!(literal.ends_with("&quot;"));
1059        assert!(literal.contains("&amp;"));
1060        assert!(literal.contains("\\&quot;"));
1061        assert!(!literal.contains('"'));
1062        assert!(!literal.contains('<'));
1063    }
1064
1065    #[tokio::test]
1066    async fn health_returns_metadata() {
1067        let (status, body) = response_status(test_app(), "GET", "/api/health", None).await;
1068        assert_eq!(status, 200);
1069        assert!(body.contains(r#""status":"ok""#));
1070        assert!(body.contains(r#""backend":"memory""#));
1071    }
1072
1073    #[tokio::test]
1074    async fn get_content_returns_200() {
1075        let (status, _) = response_status(test_app(), "GET", "/content", None).await;
1076        assert_eq!(status, 200);
1077    }
1078
1079    #[tokio::test]
1080    async fn add_edit_and_delete_url() {
1081        let app = test_app();
1082
1083        let (status, body) = response_status(
1084            app.clone(),
1085            "POST",
1086            "/add/url",
1087            Some("name=rust&url=https%3A%2F%2Frust-lang.org"),
1088        )
1089        .await;
1090        assert_eq!(status, 200);
1091        assert!(body.contains("rust"));
1092
1093        let (status, body) = response_status(
1094            app.clone(),
1095            "POST",
1096            "/edit/url/rust",
1097            Some("new_name=ferris&new_aliases=rs"),
1098        )
1099        .await;
1100        assert_eq!(status, 200);
1101        assert!(body.contains("ferris"));
1102        assert!(body.contains("rs"));
1103
1104        let (status, body) = response_status(app.clone(), "POST", "/delete/url/ferris", None).await;
1105        assert_eq!(status, 200);
1106        assert!(!body.contains("rust-lang.org"));
1107    }
1108
1109    #[tokio::test]
1110    async fn add_url_empty_fields_is_noop() {
1111        let app = test_app();
1112        let (status, body) =
1113            response_status(app.clone(), "POST", "/add/url", Some("name=&url=")).await;
1114        assert_eq!(status, 200);
1115        assert!(body.contains("no urls yet"));
1116    }
1117
1118    #[tokio::test]
1119    async fn add_edit_and_delete_group() {
1120        let app = test_app();
1121
1122        let _ = response_status(
1123            app.clone(),
1124            "POST",
1125            "/add/url",
1126            Some("name=gh&url=https%3A%2F%2Fgithub.com"),
1127        )
1128        .await;
1129        let _ = response_status(
1130            app.clone(),
1131            "POST",
1132            "/add/url",
1133            Some("name=rs&url=https%3A%2F%2Frust-lang.org"),
1134        )
1135        .await;
1136
1137        let (status, body) = response_status(
1138            app.clone(),
1139            "POST",
1140            "/add/group",
1141            Some("name=dev&entries=gh"),
1142        )
1143        .await;
1144        assert_eq!(status, 200);
1145        assert!(body.contains("dev"));
1146
1147        let (status, body) = response_status(
1148            app.clone(),
1149            "POST",
1150            "/edit/group/dev",
1151            Some("new_name=code&new_entries=gh%2Crs"),
1152        )
1153        .await;
1154        assert_eq!(status, 200);
1155        assert!(body.contains("code"));
1156        assert!(body.contains("rs"));
1157
1158        let (status, body) = response_status(app.clone(), "POST", "/delete/group/code", None).await;
1159        assert_eq!(status, 200);
1160        assert!(!body.contains(">code<"));
1161    }
1162
1163    #[tokio::test]
1164    async fn add_group_with_missing_entries_shows_error() {
1165        let app = test_app();
1166
1167        let (status, body) = response_status(
1168            app.clone(),
1169            "POST",
1170            "/add/group",
1171            Some("name=bad&entries=nonexistent"),
1172        )
1173        .await;
1174        assert_eq!(status, 200);
1175        assert!(body.contains("error-banner"));
1176    }
1177
1178    #[tokio::test]
1179    async fn delete_nonexistent_url_shows_error() {
1180        let app = test_app();
1181        let (status, body) = response_status(app.clone(), "POST", "/delete/url/nope", None).await;
1182        assert_eq!(status, 200);
1183        assert!(body.contains("error-banner"));
1184    }
1185
1186    #[tokio::test]
1187    async fn sort_by_url() {
1188        let app = test_app();
1189        let (status, _) = response_status(app.clone(), "GET", "/content?sort=url", None).await;
1190        assert_eq!(status, 200);
1191    }
1192}