1use 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 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('&', "&")
62 .replace('<', "<")
63 .replace('>', ">")
64 .replace('"', """)
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
72const 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
618type 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
815pub 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("""));
1058 assert!(literal.ends_with("""));
1059 assert!(literal.contains("&"));
1060 assert!(literal.contains("\\""));
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}