diff --git a/.gitignore b/.gitignore index 02111f9..4b85a09 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,13 @@ sovereign_browser *.dll build/ +# release tarballs (created by increment_and_push.sh -r, cleaned up by trap) +*.tar.gz + +# compiled test binaries +tests/test_bookmarks_tree +tests/test_nostr_url + # editor / OS *.swp .DS_Store diff --git a/VERSION b/VERSION index 2aa9160..31c01c9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.44 +0.0.45 diff --git a/increment_and_push.sh b/increment_and_push.sh index fb6f51a..6018301 100755 --- a/increment_and_push.sh +++ b/increment_and_push.sh @@ -27,6 +27,22 @@ print_success() { echo -e "${GREEN}[SUCCESS]${NC} $1" >&2; } print_warning() { echo -e "${YELLOW}[WARNING]${NC} $1" >&2; } print_error() { echo -e "${RED}[ERROR]${NC} $1" >&2; } +# --- Cleanup trap ------------------------------------------------------- +# Any temp files registered here are removed on exit, including when the +# script is interrupted (Ctrl+C) or aborted by `set -e`. This prevents +# leftover sovereign_browser-*.tar.gz files from accumulating in the +# project root when a release run is interrupted. +CLEANUP_FILES=() +cleanup_on_exit() { + for f in "${CLEANUP_FILES[@]}"; do + [[ -n "$f" && -f "$f" ]] && rm -f "$f" + done +} +trap cleanup_on_exit EXIT INT TERM +register_cleanup() { + [[ -n "$1" ]] && CLEANUP_FILES+=("$1") +} + # --- Config ------------------------------------------------------------- # Gitea repo for release uploads (derived from the origin remote). @@ -354,6 +370,10 @@ if [[ "$RELEASE_MODE" == true ]]; then local_tarball="" local_tarball=$(create_source_tarball || true) + # Register the tarball for cleanup so it's removed on exit even if a + # later step fails or the script is interrupted. The trap also catches + # the case where create_gitea_release aborts via `set -e`. + register_cleanup "$local_tarball" release_id="" release_id=$(create_gitea_release || true) @@ -362,8 +382,8 @@ if [[ "$RELEASE_MODE" == true ]]; then upload_release_assets "$release_id" "$BIN_NAME" "$local_tarball" fi - # Clean up the tarball (the binary is gitignored already). - [[ -n "$local_tarball" && -f "$local_tarball" ]] && rm -f "$local_tarball" + # The tarball is removed by the cleanup_on_exit trap registered above, + # so no manual rm is needed here. The binary is gitignored already. print_success "Release flow completed: $NEW_VERSION" else diff --git a/install.sh b/install.sh index ef16e36..4962c01 100755 --- a/install.sh +++ b/install.sh @@ -606,8 +606,9 @@ main() { print_success "sovereign_browser installed." cat >&2 <", + "tags": [["d", ""]], + "content": "", + ... +} +``` + +The NIP-44 encrypted `content` plaintext is: + +```json +{"path": "Work/Projects/Secret", "bookmarks": [ + ["bookmark", "https://example.com", "Example", 1690000000] +]} +``` + +The real path lives **inside the encrypted content** — relays see only the HMAC hash and opaque ciphertext. The `d` tag is used by relays purely for replaceability; the client never interprets it. + +### Tree reconstruction (client-side) + +1. Fetch all `kind=30003` events for the user's pubkey. +2. For each event: NIP-44 decrypt the content, read `path` from the plaintext JSON. +3. Split `path` on `/` and walk/create the trie (`node_ensure_path`). +4. Attach the decrypted `bookmarks` array to that leaf node. + +Intermediate (empty) folders are represented by empty-bookmarks events for that path, so folder state — including empty folders — is preserved across devices. + +### Why this is safe and nostr-ish +- The `d` tag is defined by NIP-33 as an arbitrary string identifier; a 64-char hex HMAC fits cleanly. +- Content encryption (NIP-44 self-to-self) is unchanged from the existing implementation in [`src/bookmarks.c`](src/bookmarks.c:163). +- HMAC-SHA256 is a one-line standard primitive; the key is derived from the user's Nostr privkey, so no extra credentials are needed. +- Relays learn nothing about folder names, structure, or even folder count (they see only opaque hashes). +- Existing flat directories (no slashes) load as root-level folders — no migration needed for the in-memory model. Legacy events with plaintext `d = "General"` will be detected on load (HMAC hex strings are 64 chars of `[0-9a-f]`; legacy names are not), decrypted, and re-published in the new HMAC-`d` format; the old events get a kind 5 deletion. + +```mermaid +flowchart TD + A["kind 30003 events in SQLite/relays"] --> B["NIP-44 decrypt each content"] + B --> C["Read path from plaintext JSON"] + C --> D["Split path on / to get segments"] + D --> E["Build trie of folders via node_ensure_path"] + E --> F["Attach decrypted bookmarks to leaf node"] + F --> G["Render tree on sovereign://bookmarks page"] + G --> H["User adds/moves/renames/deletes"] + H --> I["Compute HMAC d tag for affected path(s)"] + I --> J["NIP-44 encrypt new content and publish kind 30003"] + I --> K["Emit kind 5 deletion for old d tags if renamed/moved/deleted"] +``` + +--- + +## Implementation + +### 1. Data model — [`src/bookmarks.h`](src/bookmarks.h:36) / [`src/bookmarks.c`](src/bookmarks.c:32) + +The in-memory model changes from a flat `bookmark_dir_t[]` to a **tree**. + +#### New struct + +```c +typedef struct bookmark_node { + char *name; /* last segment of path, e.g. "Secret" */ + char *path; /* full path, e.g. "Work/Projects/Secret" */ + bookmark_t *bookmarks; + int bookmark_count; + struct bookmark_node *children; /* array of child nodes */ + int child_count; + int child_cap; +} bookmark_node_t; +``` + +The root is a single `bookmark_node_t` with `name = ""`, `path = ""`, no bookmarks, and children = top-level folders. + +#### New / changed API + +Keep the existing public functions working (they now operate on paths, not flat names): + +- `bookmarks_add(const char *path, const char *url, const char *title)` — `path` may contain slashes; intermediate nodes are created as empty events if they don't exist. +- `bookmarks_remove(const char *path, const char *url)` +- `bookmarks_move(const char *from_path, const char *url, const char *to_path)` +- `bookmarks_create_dir(const char *path)` — publishes an empty kind 30003 event with `d = HMAC-SHA256(hmac_key, path)`. +- `bookmarks_rename_dir(const char *old_path, const char *new_path)` — re-publishes the moved subtree: for the renamed node and every descendant, publish a new event with the new HMAC `d` tag and a kind 5 deletion for the old HMAC `d` tag. +- `bookmarks_delete_dir(const char *path, int move_to_general)` — recursively delete the subtree (kind 5 deletion for each HMAC `d` tag in the subtree); optionally move bookmarks to `General`. +- `const bookmark_node_t *bookmarks_get_root(void)` — returns the root node for traversal (replaces `bookmarks_get_dirs`). +- `const bookmark_node_t *bookmarks_find(const char *path)` — lookup by path. + +#### Internal helpers + +- `static bookmark_node_t *node_find_child(bookmark_node_t *parent, const char *name)` +- `static bookmark_node_t *node_ensure_path(bookmark_node_t *root, const char *path)` — walks/creates the trie. +- `static void node_free(bookmark_node_t *node)` — recursive free. +- `static void compute_hmac_key(unsigned char out[32])` — `HMAC-SHA256(privkey, "sovereign-browser/bookmarks-folder-id-v1")`. The privkey is obtained from the signer (add a `nostr_signer_get_privkey_hex` accessor if not already present, or compute the HMAC inside the signer to avoid exposing the raw privkey). +- `static char *path_to_d_tag(const char *path)` — `HMAC-SHA256(hmac_key, path)` → 64-char hex string. Caller frees. +- `static int publish_path(const char *path)` — replaces `publish_directory`; builds `d = path_to_d_tag(path)`, encrypts `{"path": path, "bookmarks": [...]}` with NIP-44, signs, publishes, stores in SQLite. +- `static int delete_event_for_path(const char *path)` — publishes a kind 5 deletion event referencing the kind 30003 event for `d = path_to_d_tag(path)`. + +#### Loading + +`bookmarks_init` already iterates all kind 30003 events for the pubkey. Change the loop to: +1. Read the `d` tag. +2. If the `d` tag is **not** 64 hex chars (i.e. a legacy plaintext directory name), treat it as a legacy event: decrypt content as the old flat `[[bookmark,...]]` array, use the `d` tag string as the path, then re-publish in the new HMAC-`d` format and emit a kind 5 deletion for the old event id. +3. If the `d` tag **is** 64 hex chars, decrypt the content and read `path` from the plaintext JSON `{"path": ..., "bookmarks": [...]}`. +4. `node_ensure_path(root, path)`. +5. Load bookmarks into that leaf. + +### 2. JSON endpoint — [`src/nostr_bridge.c`](src/nostr_bridge.c:3715) `handle_bookmarks_list_json` + +Change the JSON shape from a flat `dirs[]` array to a nested `tree` object: + +```json +{ + "have_signer": true, + "tree": { + "name": "", + "path": "", + "bookmarks": [], + "children": [ + { "name": "General", "path": "General", "bookmarks": [...], "children": [] }, + { "name": "Work", "path": "Work", "bookmarks": [], "children": [ + { "name": "Projects", "path": "Work/Projects", "bookmarks": [...], "children": [] } + ]} + ] + } +} +``` + +Add a small recursive helper `node_to_json(const bookmark_node_t *node, cJSON *parent)`. + +### 3. Web UI — [`www/bookmarks.html`](www/bookmarks.html:1), [`www/bookmarks.js`](www/bookmarks.js:1), [`www/bookmarks.css`](www/bookmarks.css:1) + +#### HTML +- Replace the flat `#bm-list` div with a `
` container. +- Add an "Add Folder" button next to "Add Bookmark" that prompts for a path (with a parent-picker). +- Add a path breadcrumb / parent selector in the add form. + +#### JS +- `renderTree(data.tree)` — recursively render nested `
    ` with: + - A folder row: expand/collapse caret, folder icon, name, count, "Add Bookmark Here", "New Subfolder", "Rename", "Delete" buttons. + - A bookmark list under the folder (collapsible). +- State: keep a `Set` of expanded paths in `localStorage` so expand state persists. +- Clicking a folder caret toggles `expanded` class on the child `
      `. +- "New Subfolder" prompts for a name and navigates to `sovereign://bookmarks/createdir?name=/`. +- "Move" action on each bookmark: prompt for destination path, navigate to `sovereign://bookmarks/move?from=&to=&url=`. +- "Add Bookmark Here" pre-fills the add form's directory field with the folder's path. + +#### CSS +- Add `.tree-node`, `.tree-children`, `.caret`, `.caret.expanded`, `.folder-row`, `.folder-icon` rules to [`www/bookmarks.css`](www/bookmarks.css:1). +- Caret uses a CSS triangle that rotates 90° when expanded. +- Indent `.tree-children` by `20px` per level. +- Reuse existing `.bm`, `.btn`, `.btn-del` classes for bookmark rows and buttons. + +### 4. Bookmarks toolbar — [`src/tab_manager.c`](src/tab_manager.c:2564) + +Add a **bookmarks toolbar** below the URL toolbar. This is a horizontal `GtkBox` that shows buttons for the bookmarks in a configurable "Bookmarks Bar" folder (default path: `Bookmarks Bar`), plus a dropdown for any folders. + +#### Layout + +``` +[ hamburger ][back][fwd][refresh][ === URL entry === ][bookmark btn] +[ 📁 Bookmarks Bar: ][example.com][nostr.com][▾ Folders ▾] +[ ============ WebKitWebView ============ ] +``` + +#### Implementation + +1. In `tab_create()`, after packing `toolbar` into `tab->page`, create: + ```c + tab->bookmark_bar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4); + gtk_widget_set_margin_top(tab->bookmark_bar, 2); + gtk_widget_set_margin_bottom(tab->bookmark_bar, 2); + gtk_widget_set_margin_start(tab->bookmark_bar, 4); + gtk_widget_set_margin_end(tab->bookmark_bar, 4); + gtk_box_pack_start(GTK_BOX(tab->page), tab->bookmark_bar, FALSE, FALSE, 0); + ``` +2. Add a `GtkToggleButton` "Bookmarks Bar" toggle (icon `user-bookmarks-symbolic`) on the left of the URL toolbar that shows/hides `tab->bookmark_bar` (persist visibility in settings). +3. `bookmark_bar_refresh(tab)` — clears and repopulates `tab->bookmark_bar`: + - Looks up the "Bookmarks Bar" node via `bookmarks_find("Bookmarks Bar")`. + - For each bookmark in that node, adds a `GtkButton` labeled with the bookmark title (or URL). Clicking loads the URL in `tab->webview`. + - For each child folder, adds a `GtkMenuButton` with a popover listing that folder's bookmarks (and subfolders, one level deep). +4. Call `bookmark_bar_refresh(tab)` after: tab creation, `bookmarks_add`/`remove`/`move`/`delete_dir` (via a global "bookmarks changed" notification — see below). +5. Add a "Bookmark this page to Bookmarks Bar" quick action: right-click on the bookmark button → menu item "Add to Bookmarks Bar" that calls `bookmarks_add("Bookmarks Bar", url, title)` directly without the directory picker dialog. + +#### Cross-tab refresh + +Bookmarks can change from any tab (or from relay fetch). Add a lightweight notification: +- `bookmarks_subscribe_changed(void (*cb)(void *user_data), void *user_data)` in [`src/bookmarks.h`](src/bookmarks.h:51). +- `tab_manager.c` registers a callback on startup that iterates all open tabs and calls `bookmark_bar_refresh(tab)` for each. +- `bookmarks_add`/`remove`/`move`/`create_dir`/`delete_dir`/`rename_dir` and the relay-fetch load path invoke the registered callbacks after mutating state. + +### 5. Routes — [`src/nostr_bridge.c`](src/nostr_bridge.c:3993) + +Existing routes already accept `dir=` query params. They continue to work — `dir` is now interpreted as a **path** (URL-encoded, may contain `%2F`). Add one new route: + +- `sovereign://bookmarks/move?from=&to=&url=` — calls `bookmarks_move`. + +Update `handle_bookmarks_add`/`delete`/`createdir`/`deletedir` to pass the path straight through to the renamed API functions (no behavioral change beyond accepting slashes). + +### 6. History filter — [`src/history.c`](src/history.c:27) + +Already excludes `sovereign://bookmarks/{add,delete,createdir,deletedir}`. Add `sovereign://bookmarks/move` to the skip list. + +### 7. Tests + +- Add `tests/test_bookmarks_tree.c` (or extend existing tests) covering: + - `node_ensure_path` creates intermediate nodes. + - `path_to_d_tag` is deterministic (same path + key → same hex) and 64 hex chars. + - `path_to_d_tag` is opaque (different paths → uncorrelated hashes; no prefix leakage). + - `bookmarks_add("Work/Projects/Secret", ...)` publishes an event whose `d` tag is `HMAC-SHA256(hmac_key, "Work/Projects/Secret")` (not the plaintext path) and whose decrypted content contains `"path": "Work/Projects/Secret"`. + - `bookmarks_rename_dir("Work", "Personal")` re-publishes `Personal`, `Personal/Projects`, `Personal/Projects/Secret` (new HMAC `d` tags, new encrypted content) and emits kind 5 deletions for the old HMAC `d` tags. + - `bookmarks_delete_dir("Work", 0)` emits kind 5 deletions for the whole subtree's HMAC `d` tags. + - Loading a mix of events with HMAC `d` tags reconstructs the tree from the decrypted `path` field, not from the `d` tag. + - Legacy event with plaintext `d = "General"` is detected, loaded, re-published with an HMAC `d` tag, and the old event is marked for kind 5 deletion. +- Manual test: `./browser.sh restart --login-method generate --url sovereign://bookmarks` and exercise the tree UI. + +--- + +## Migration + +Existing flat directories (no slashes) load as root-level folders — no migration needed. The "General" default folder continues to work as a root node. + +## Files touched + +| File | Change | +|------|--------| +| [`src/bookmarks.h`](src/bookmarks.h:1) | Replace `bookmark_dir_t` with `bookmark_node_t`; tree API; change callback. | +| [`src/bookmarks.c`](src/bookmarks.c:1) | Rewrite in-memory model as a trie; path-based publish/delete; rename/delete recursion. | +| [`src/nostr_bridge.c`](src/nostr_bridge.c:3715) | `handle_bookmarks_list_json` emits nested `tree`; add `move` route. | +| [`www/bookmarks.html`](www/bookmarks.html:1) | Tree container, folder add form. | +| [`www/bookmarks.js`](www/bookmarks.js:1) | Recursive `renderTree`, expand/collapse, move dialog. | +| [`www/bookmarks.css`](www/bookmarks.css:1) | Tree node / caret / indentation styles. | +| [`src/tab_manager.c`](src/tab_manager.c:2564) | Bookmarks toolbar widget, refresh on change. | +| [`src/history.c`](src/history.c:27) | Skip `sovereign://bookmarks/move`. | +| `tests/test_bookmarks_tree.c` | New test for tree operations. | +| [`plans/bookmarks-tree-view.md`](plans/bookmarks-tree-view.md:1) | This plan. | diff --git a/sovereign_browser-0.0.35.tar.gz b/sovereign_browser-0.0.35.tar.gz deleted file mode 100644 index d399005..0000000 Binary files a/sovereign_browser-0.0.35.tar.gz and /dev/null differ diff --git a/sovereign_browser-0.0.42.tar.gz b/sovereign_browser-0.0.42.tar.gz deleted file mode 100644 index ab7640d..0000000 Binary files a/sovereign_browser-0.0.42.tar.gz and /dev/null differ diff --git a/sovereign_browser-0.0.43.tar.gz b/sovereign_browser-0.0.43.tar.gz deleted file mode 100644 index 46a334b..0000000 Binary files a/sovereign_browser-0.0.43.tar.gz and /dev/null differ diff --git a/src/agent_login.c b/src/agent_login.c index 29c580d..e8147a1 100644 --- a/src/agent_login.c +++ b/src/agent_login.c @@ -20,6 +20,7 @@ * We access it via extern functions that main.c provides. */ extern void app_set_signer(nostr_signer_t *signer, const char *pubkey_hex, + const char *privkey_hex, key_store_method_t method, gboolean readonly); extern void app_clear_signer(void); extern nostr_signer_t *app_get_signer(void); @@ -131,13 +132,18 @@ static cJSON *login_local(cJSON *params) { if (derive_pubkey_hex(privkey, pubkey_hex) != 0) { return make_error("DERIVE_FAILED", "Failed to derive public key"); } + char privkey_hex_out[65]; + for (int i = 0; i < 32; i++) { + snprintf(privkey_hex_out + i * 2, 3, "%02x", privkey[i]); + } + privkey_hex_out[64] = '\0'; nostr_signer_t *signer = nostr_signer_local(privkey); if (signer == NULL) { return make_error("SIGNER_FAILED", "Failed to create signer"); } - app_set_signer(signer, pubkey_hex, KEY_STORE_METHOD_LOCAL, FALSE); + app_set_signer(signer, pubkey_hex, privkey_hex_out, KEY_STORE_METHOD_LOCAL, FALSE); nostr_bridge_set_signer(signer, pubkey_hex, FALSE); g_print("[agent-login] Local key: pubkey=%s\n", pubkey_hex); @@ -169,13 +175,18 @@ static cJSON *login_generate(cJSON *params) { if (nostr_key_to_bech32(privkey, "nsec", nsec) != 0) { nsec[0] = '\0'; } + char privkey_hex[65]; + for (int i = 0; i < 32; i++) { + snprintf(privkey_hex + i * 2, 3, "%02x", privkey[i]); + } + privkey_hex[64] = '\0'; nostr_signer_t *signer = nostr_signer_local(privkey); if (signer == NULL) { return make_error("SIGNER_FAILED", "Failed to create signer"); } - app_set_signer(signer, pubkey_hex, KEY_STORE_METHOD_LOCAL, FALSE); + app_set_signer(signer, pubkey_hex, privkey_hex, KEY_STORE_METHOD_LOCAL, FALSE); nostr_bridge_set_signer(signer, pubkey_hex, FALSE); g_print("[agent-login] Generated key: pubkey=%s\n", pubkey_hex); @@ -206,13 +217,18 @@ static cJSON *login_seed(cJSON *params) { snprintf(pubkey_hex + i * 2, 3, "%02x", pubkey[i]); } pubkey_hex[64] = '\0'; + char privkey_hex[65]; + for (int i = 0; i < 32; i++) { + snprintf(privkey_hex + i * 2, 3, "%02x", privkey[i]); + } + privkey_hex[64] = '\0'; nostr_signer_t *signer = nostr_signer_local(privkey); if (signer == NULL) { return make_error("SIGNER_FAILED", "Failed to create signer"); } - app_set_signer(signer, pubkey_hex, KEY_STORE_METHOD_SEED, FALSE); + app_set_signer(signer, pubkey_hex, privkey_hex, KEY_STORE_METHOD_SEED, FALSE); nostr_bridge_set_signer(signer, pubkey_hex, FALSE); g_print("[agent-login] Seed phrase: pubkey=%s\n", pubkey_hex); @@ -254,8 +270,8 @@ static cJSON *login_readonly(cJSON *params) { return make_error("MISSING_PARAM", "Provide 'npub' or 'pubkey_hex'"); } - /* Read-only: no signer created. */ - app_set_signer(NULL, pubkey_hex, KEY_STORE_METHOD_READONLY, TRUE); + /* Read-only: no signer created. No privkey available. */ + app_set_signer(NULL, pubkey_hex, NULL, KEY_STORE_METHOD_READONLY, TRUE); nostr_bridge_set_signer(NULL, pubkey_hex, TRUE); g_print("[agent-login] Read-only: pubkey=%s\n", pubkey_hex); @@ -290,7 +306,13 @@ static cJSON *login_nip46(cJSON *params) { return make_error("SIGNER_FAILED", "Failed to create client signer"); } - app_set_signer(signer, pubkey_hex, KEY_STORE_METHOD_NIP46, FALSE); + /* NIP-46: the client_privkey is a throwaway session key for the bunker + * protocol, not the user's identity key. The user's actual signing key + * lives on the remote bunker. We do NOT use it for bookmark HMAC d tags + * (the bunker signer handles NIP-44 content encryption; d-tag derivation + * would require the user's real privkey, which we don't have). Bookmarks + * can still be loaded from the db in read-only fashion. */ + app_set_signer(signer, pubkey_hex, NULL, KEY_STORE_METHOD_NIP46, FALSE); nostr_bridge_set_signer(signer, pubkey_hex, FALSE); g_print("[agent-login] NIP-46: remote signer pubkey=%s\n", pubkey_hex); @@ -374,7 +396,9 @@ static cJSON *login_nsigner(cJSON *params) { sigprocmask(SIG_SETMASK, &old_set, NULL); - app_set_signer(signer, pubkey_hex, KEY_STORE_METHOD_NSIGNER, FALSE); + /* n_signer: the privkey never leaves the hardware device, so we can't + * derive HMAC d tags client-side. Bookmarks fall back to read-only. */ + app_set_signer(signer, pubkey_hex, NULL, KEY_STORE_METHOD_NSIGNER, FALSE); nostr_bridge_set_signer(signer, pubkey_hex, FALSE); g_print("[agent-login] n_signer: transport=%s index=%d pubkey=%s\n", diff --git a/src/bookmarks.c b/src/bookmarks.c index 4b9a9ef..6b8d13c 100644 --- a/src/bookmarks.c +++ b/src/bookmarks.c @@ -1,15 +1,19 @@ /* - * bookmarks.c — NIP-44 encrypted Nostr bookmarks with directories + * bookmarks.c — NIP-44 encrypted Nostr bookmarks with nested folders * * Stores bookmarks as NIP-51 kind 30003 (bookmark sets) events, one per - * directory. Each directory's bookmarks are NIP-44 encrypted (self-to-self) - * and stored in the event's content field as a JSON tag array: + * folder. Folders may be nested arbitrarily (e.g. "Work/Projects/Secret"). * - * [["bookmark", "https://example.com", "Example", 1690000000], ...] + * Privacy design (see bookmarks.h for the full rationale): + * - d tag = HMAC-SHA256(hmac_key, path) (deterministic, opaque, 64 hex) + * - content = NIP-44 encrypted JSON {"path": ..., "bookmarks": [...]} + * - hmac_key = HMAC-SHA256(privkey, BOOKMARKS_HMAC_KEY_LABEL) * - * The encrypted event is published to the bootstrap relays and stored in - * the local SQLite database. On startup, the relay fetch retrieves kind - * 30003 events, which are decrypted and loaded into the in-memory list. + * On startup, the relay fetch retrieves kind 30003 events, which are + * decrypted and loaded into an in-memory trie. Legacy events with plaintext + * d tags (from the previous flat-directory implementation) are detected, + * migrated to the new HMAC-d format, and their old events are marked for + * kind 5 deletion. */ #include "bookmarks.h" @@ -25,140 +29,321 @@ /* ── Global state ──────────────────────────────────────────────────── */ -static nostr_signer_t *g_signer = NULL; -static char g_pubkey[65] = {0}; +static nostr_signer_t *g_signer = NULL; +static char g_pubkey[65] = {0}; +static char g_privkey[65] = {0}; /* hex, in-memory only */ static int g_have_signer = 0; +static int g_have_privkey = 0; -static bookmark_dir_t *g_dirs = NULL; -static int g_dir_count = 0; -static int g_dir_cap = 0; +static bookmark_node_t g_root = {0}; -/* ── Helpers ───────────────────────────────────────────────────────── */ +/* HMAC key cache (32 bytes). Derived once in bookmarks_init from the privkey. */ +static unsigned char g_hmac_key[32]; +static int g_have_hmac_key = 0; -/* Find a directory by name. Returns the index, or -1 if not found. */ -static int find_dir(const char *name) { - if (name == NULL) return -1; - for (int i = 0; i < g_dir_count; i++) { - if (strcmp(g_dirs[i].name, name) == 0) return i; +/* Change-notification subscribers. */ +#define BOOKMARKS_MAX_SUBS 8 +static bookmarks_changed_cb g_subs[BOOKMARKS_MAX_SUBS]; +static void *g_sub_ud[BOOKMARKS_MAX_SUBS]; +static int g_sub_count = 0; + +/* ── Tree helpers ──────────────────────────────────────────────────── */ + +static void node_free(bookmark_node_t *node) { + if (node == NULL) return; + for (int i = 0; i < node->bookmark_count; i++) { + g_free(node->bookmarks[i].url); + g_free(node->bookmarks[i].title); + } + g_free(node->bookmarks); + for (int i = 0; i < node->child_count; i++) { + node_free(&node->children[i]); + } + g_free(node->children); + g_free(node->name); + g_free(node->path); + /* Don't free `node` itself if it's an embedded struct (g_root). */ +} + +static void node_clear_bookmarks(bookmark_node_t *node) { + if (node->bookmarks == NULL) return; + for (int i = 0; i < node->bookmark_count; i++) { + g_free(node->bookmarks[i].url); + g_free(node->bookmarks[i].title); + } + g_free(node->bookmarks); + node->bookmarks = NULL; + node->bookmark_count = 0; +} + +static bookmark_node_t *node_find_child(const bookmark_node_t *parent, + const char *name) { + if (parent == NULL || name == NULL) return NULL; + for (int i = 0; i < parent->child_count; i++) { + if (strcmp(parent->children[i].name, name) == 0) + return &parent->children[i]; + } + return NULL; +} + +static bookmark_node_t *node_add_child(bookmark_node_t *parent, + const char *name, + const char *path) { + if (parent->child_count >= parent->child_cap) { + int new_cap = parent->child_cap == 0 ? 8 : parent->child_cap * 2; + bookmark_node_t *arr = g_realloc(parent->children, + new_cap * sizeof(bookmark_node_t)); + if (arr == NULL) return NULL; + parent->children = arr; + parent->child_cap = new_cap; + } + bookmark_node_t *child = &parent->children[parent->child_count++]; + child->name = g_strdup(name); + child->path = g_strdup(path); + child->bookmarks = NULL; + child->bookmark_count = 0; + child->children = NULL; + child->child_count = 0; + child->child_cap = 0; + return child; +} + +/* Walk/create the trie for a path like "Work/Projects/Secret". + * Returns the leaf node, or NULL on error. Creates intermediate nodes. + * `created_out` (optional) is set to 1 if any node was created. */ +static bookmark_node_t *node_ensure_path(bookmark_node_t *root, + const char *path, + int *created_out) { + if (root == NULL || path == NULL) return NULL; + if (created_out) *created_out = 0; + + /* Empty path = root. */ + if (path[0] == '\0') return root; + + /* Make a mutable copy to tokenize. */ + char *copy = g_strdup(path); + bookmark_node_t *cur = root; + char *saveptr = NULL; + GString *acc = g_string_new(NULL); + + char *seg = strtok_r(copy, "/", &saveptr); + while (seg != NULL) { + if (acc->len > 0) g_string_append_c(acc, '/'); + g_string_append(acc, seg); + + bookmark_node_t *child = node_find_child(cur, seg); + if (child == NULL) { + child = node_add_child(cur, seg, acc->str); + if (child == NULL) { + g_string_free(acc, TRUE); + g_free(copy); + return NULL; + } + if (created_out) *created_out = 1; + } + cur = child; + seg = strtok_r(NULL, "/", &saveptr); + } + + g_string_free(acc, TRUE); + g_free(copy); + return cur; +} + +/* Find an existing node by path (no creation). Returns NULL if not found. */ +static bookmark_node_t *node_find_path(bookmark_node_t *root, const char *path) { + if (root == NULL || path == NULL) return NULL; + if (path[0] == '\0') return root; + + char *copy = g_strdup(path); + bookmark_node_t *cur = root; + char *saveptr = NULL; + char *seg = strtok_r(copy, "/", &saveptr); + while (seg != NULL && cur != NULL) { + cur = node_find_child(cur, seg); + seg = strtok_r(NULL, "/", &saveptr); + } + g_free(copy); + return cur; +} + +/* Find a bookmark in a node by URL. Returns the index, or -1. */ +static int find_bookmark_in_node(const bookmark_node_t *node, const char *url) { + if (node == NULL || url == NULL) return -1; + for (int i = 0; i < node->bookmark_count; i++) { + if (strcmp(node->bookmarks[i].url, url) == 0) return i; } return -1; } -/* Ensure a directory exists in the in-memory list. Returns the index. */ -static int ensure_dir(const char *name) { - int idx = find_dir(name); - if (idx >= 0) return idx; - - /* Grow the array if needed. */ - if (g_dir_count >= g_dir_cap) { - int new_cap = g_dir_cap == 0 ? 8 : g_dir_cap * 2; - bookmark_dir_t *new_arr = g_realloc(g_dirs, new_cap * sizeof(*new_arr)); - if (new_arr == NULL) return -1; - g_dirs = new_arr; - g_dir_cap = new_cap; +/* Remove a child by index from a parent. Frees the subtree. */ +static void node_remove_child(bookmark_node_t *parent, int idx) { + if (idx < 0 || idx >= parent->child_count) return; + node_free(&parent->children[idx]); + for (int i = idx; i < parent->child_count - 1; i++) { + parent->children[i] = parent->children[i + 1]; } - - idx = g_dir_count++; - memset(&g_dirs[idx], 0, sizeof(g_dirs[idx])); - snprintf(g_dirs[idx].name, sizeof(g_dirs[idx].name), "%s", name); - return idx; + parent->child_count--; } -/* Free the bookmarks in a directory (but not the directory struct itself). */ -static void free_dir_bookmarks(bookmark_dir_t *dir) { - if (dir->bookmarks == NULL) return; - for (int i = 0; i < dir->count; i++) { - g_free(dir->bookmarks[i].url); - g_free(dir->bookmarks[i].title); +/* ── HMAC d-tag derivation ─────────────────────────────────────────── */ + +/* Compute the per-user HMAC key from the privkey. + * hmac_key = HMAC-SHA256(privkey_bytes, BOOKMARKS_HMAC_KEY_LABEL) + * Caches the result in g_hmac_key. Returns 0 on success, -1 on error. */ +static int compute_hmac_key(void) { + if (g_have_hmac_key) return 0; + if (!g_have_privkey || g_privkey[0] == '\0') return -1; + + unsigned char priv[32]; + if (nostr_hex_to_bytes(g_privkey, priv, 32) != 0) { + g_printerr("[bookmarks] Invalid privkey hex\n"); + return -1; } - g_free(dir->bookmarks); - dir->bookmarks = NULL; - dir->count = 0; + + if (nostr_hmac_sha256(priv, 32, + (const unsigned char *)BOOKMARKS_HMAC_KEY_LABEL, + strlen(BOOKMARKS_HMAC_KEY_LABEL), + g_hmac_key) != 0) { + g_printerr("[bookmarks] HMAC-SHA256 key derivation failed\n"); + return -1; + } + g_have_hmac_key = 1; + return 0; } -/* Find a bookmark in a directory by URL. Returns the index, or -1. */ -static int find_bookmark_in_dir(const bookmark_dir_t *dir, const char *url) { - if (dir == NULL || url == NULL) return -1; - for (int i = 0; i < dir->count; i++) { - if (strcmp(dir->bookmarks[i].url, url) == 0) return i; +/* Compute the opaque d tag for a path: HMAC-SHA256(hmac_key, path) → hex. + * Returns a newly allocated 64-char hex string (caller frees), or NULL. */ +static char *path_to_d_tag(const char *path) { + if (compute_hmac_key() != 0) return NULL; + if (path == NULL) path = ""; + + unsigned char mac[32]; + if (nostr_hmac_sha256(g_hmac_key, 32, + (const unsigned char *)path, strlen(path), + mac) != 0) { + return NULL; } - return -1; + char *hex = g_malloc(65); + if (hex == NULL) return NULL; + nostr_bytes_to_hex(mac, 32, hex); + hex[64] = '\0'; + return hex; +} + +/* Returns 1 if s is a 64-char lowercase-hex string (i.e. an HMAC d tag), + * 0 otherwise. Used to detect legacy plaintext d tags. */ +static int is_hmac_d_tag(const char *s) { + if (s == NULL || strlen(s) != 64) return 0; + for (const char *p = s; *p; p++) { + if (!((*p >= '0' && *p <= '9') || (*p >= 'a' && *p <= 'f'))) + return 0; + } + return 1; } /* ── Serialization ─────────────────────────────────────────────────── */ -/* Serialize a directory's bookmarks to NIP-51 tag-array JSON. - * Returns a newly allocated string (caller must g_free). */ -static char *dir_to_json(const bookmark_dir_t *dir) { +/* Serialize a node's bookmarks to a JSON array (NIP-51 tag format): + * [["bookmark", url, title, added], ...] + */ +static cJSON *bookmarks_to_json_array(const bookmark_node_t *node) { cJSON *arr = cJSON_CreateArray(); - for (int i = 0; i < dir->count; i++) { + for (int i = 0; i < node->bookmark_count; i++) { cJSON *tag = cJSON_CreateArray(); cJSON_AddItemToArray(tag, cJSON_CreateString("bookmark")); - cJSON_AddItemToArray(tag, cJSON_CreateString(dir->bookmarks[i].url)); - cJSON_AddItemToArray(tag, cJSON_CreateString(dir->bookmarks[i].title)); + cJSON_AddItemToArray(tag, cJSON_CreateString(node->bookmarks[i].url)); + cJSON_AddItemToArray(tag, cJSON_CreateString(node->bookmarks[i].title)); cJSON_AddItemToArray(tag, - cJSON_CreateNumber(dir->bookmarks[i].added)); + cJSON_CreateNumber((double)node->bookmarks[i].added)); cJSON_AddItemToArray(arr, tag); } - char *json = cJSON_PrintUnformatted(arr); - cJSON_Delete(arr); + return arr; +} + +/* Build the plaintext content JSON for a node: + * {"path": "Work/Projects", "bookmarks": [["bookmark", url, title, ts], ...]} + * Caller frees. */ +static char *node_to_content_json(const bookmark_node_t *node) { + cJSON *obj = cJSON_CreateObject(); + cJSON_AddStringToObject(obj, "path", node->path); + cJSON *bms = bookmarks_to_json_array(node); + cJSON_AddItemToObject(obj, "bookmarks", bms); + char *json = cJSON_PrintUnformatted(obj); + cJSON_Delete(obj); return json; } -/* Parse a NIP-51 tag-array JSON string into a directory's bookmarks. - * Replaces the directory's current bookmark list. */ -static int dir_load_from_json(bookmark_dir_t *dir, const char *json) { - if (dir == NULL || json == NULL) return -1; - - cJSON *arr = cJSON_Parse(json); - if (arr == NULL) return -1; - if (!cJSON_IsArray(arr)) { - cJSON_Delete(arr); - return -1; - } - - /* Free existing bookmarks. */ - free_dir_bookmarks(dir); +/* Parse a NIP-51 tag-array JSON into a node's bookmarks (replaces existing). */ +static int bookmarks_load_from_array(bookmark_node_t *node, const cJSON *arr) { + if (node == NULL || arr == NULL || !cJSON_IsArray(arr)) return -1; + node_clear_bookmarks(node); int count = cJSON_GetArraySize(arr); - if (count > BOOKMARKS_MAX_PER_DIR) count = BOOKMARKS_MAX_PER_DIR; + if (count > BOOKMARKS_MAX_PER_NODE) count = BOOKMARKS_MAX_PER_NODE; - dir->bookmarks = g_new0(bookmark_t, count); - dir->count = 0; + node->bookmarks = g_new0(bookmark_t, count); + node->bookmark_count = 0; cJSON *tag; cJSON_ArrayForEach(tag, arr) { - if (dir->count >= count) break; + if (node->bookmark_count >= count) break; if (!cJSON_IsArray(tag)) continue; - cJSON *t0 = cJSON_GetArrayItem(tag, 0); cJSON *t1 = cJSON_GetArrayItem(tag, 1); cJSON *t2 = cJSON_GetArrayItem(tag, 2); cJSON *t3 = cJSON_GetArrayItem(tag, 3); - if (!cJSON_IsString(t0) || strcmp(t0->valuestring, "bookmark") != 0) continue; if (!cJSON_IsString(t1)) continue; - - dir->bookmarks[dir->count].url = g_strdup(t1->valuestring); - dir->bookmarks[dir->count].title = g_strdup( + node->bookmarks[node->bookmark_count].url = g_strdup(t1->valuestring); + node->bookmarks[node->bookmark_count].title = g_strdup( (t2 && cJSON_IsString(t2)) ? t2->valuestring : ""); - dir->bookmarks[dir->count].added = + node->bookmarks[node->bookmark_count].added = (t3 && cJSON_IsNumber(t3)) ? (long)t3->valuedouble : 0; - dir->count++; + node->bookmark_count++; + } + return 0; +} + +/* Load a node's bookmarks from a content JSON string. Handles both the new + * format {"path": ..., "bookmarks": [...]} and the legacy format [[...],...]. + * If `path_out` is non-NULL and the new format is detected, the path is + * duplicated into it (caller frees). */ +static int node_load_from_content(bookmark_node_t *node, const char *json, + char **path_out) { + if (node == NULL || json == NULL) return -1; + cJSON *root = cJSON_Parse(json); + if (root == NULL) return -1; + + if (cJSON_IsObject(root)) { + cJSON *path = cJSON_GetObjectItemCaseSensitive(root, "path"); + cJSON *bms = cJSON_GetObjectItemCaseSensitive(root, "bookmarks"); + if (cJSON_IsString(path) && path_out) { + *path_out = g_strdup(path->valuestring); + } + if (cJSON_IsArray(bms)) { + bookmarks_load_from_array(node, bms); + } + cJSON_Delete(root); + return 0; } - cJSON_Delete(arr); - return 0; + /* Legacy: top-level array of bookmark tags. */ + if (cJSON_IsArray(root)) { + bookmarks_load_from_array(node, root); + cJSON_Delete(root); + return 0; + } + + cJSON_Delete(root); + return -1; } /* ── NIP-44 encryption / decryption ────────────────────────────────── */ -/* Encrypt a plaintext string with NIP-44 (self-to-self). - * Returns a newly allocated string (caller must free), or NULL on error. */ static char *encrypt_content(const char *plaintext) { if (!g_have_signer || g_pubkey[0] == '\0') return NULL; - char *ciphertext = NULL; int rc = nostr_signer_nip44_encrypt(g_signer, g_pubkey, plaintext, &ciphertext); @@ -169,12 +354,9 @@ static char *encrypt_content(const char *plaintext) { return ciphertext; } -/* Decrypt a NIP-44 ciphertext (self-to-self). - * Returns a newly allocated string (caller must free), or NULL on error. */ static char *decrypt_content(const char *ciphertext) { if (!g_have_signer || g_pubkey[0] == '\0') return NULL; if (ciphertext == NULL || ciphertext[0] == '\0') return NULL; - char *plaintext = NULL; int rc = nostr_signer_nip44_decrypt(g_signer, g_pubkey, ciphertext, &plaintext); @@ -185,11 +367,8 @@ static char *decrypt_content(const char *ciphertext) { return plaintext; } -/* ── Publish ───────────────────────────────────────────────────────── */ +/* ── Relay publish helpers ─────────────────────────────────────────── */ -/* Parse bootstrap relays from settings into an array. - * Returns the count. Fills urls_out (pointers into relay_buf). - * Caller must g_free(relay_buf) after use. */ static int parse_relays(char **relay_buf, const char **urls_out, int max) { const browser_settings_t *s = settings_get(); *relay_buf = g_strdup(s->bootstrap_relays); @@ -208,40 +387,39 @@ static int parse_relays(char **relay_buf, const char **urls_out, int max) { return count; } -/* Publish a kind 30003 event for a directory. - * Encrypts the directory's bookmarks, signs, publishes to relays, - * and stores in SQLite. Returns 0 on success, -1 on error. */ -static int publish_directory(const char *dir_name) { +/* Publish a kind 30003 event for a node (folder path). + * Encrypts the node's content, signs, publishes to relays, stores in SQLite. + * Returns 0 on success, -1 on error. */ +static int publish_node(const bookmark_node_t *node) { if (!g_have_signer) { g_printerr("[bookmarks] No signer, cannot publish\n"); return -1; } + if (node == NULL) return -1; - int idx = find_dir(dir_name); - if (idx < 0) { - g_printerr("[bookmarks] Directory '%s' not found\n", dir_name); + char *d_tag = path_to_d_tag(node->path); + if (d_tag == NULL) { + g_printerr("[bookmarks] Cannot derive d tag for '%s'\n", node->path); return -1; } - /* Serialize and encrypt. */ - char *json = dir_to_json(&g_dirs[idx]); - if (json == NULL) return -1; + char *content_json = node_to_content_json(node); + if (content_json == NULL) { g_free(d_tag); return -1; } - char *ciphertext = encrypt_content(json); - g_free(json); - if (ciphertext == NULL) return -1; + char *ciphertext = encrypt_content(content_json); + g_free(content_json); + if (ciphertext == NULL) { g_free(d_tag); return -1; } - /* Build tags: [["d", dir_name]] */ cJSON *tags = cJSON_CreateArray(); - cJSON *d_tag = cJSON_CreateArray(); - cJSON_AddItemToArray(d_tag, cJSON_CreateString("d")); - cJSON_AddItemToArray(d_tag, cJSON_CreateString(dir_name)); - cJSON_AddItemToArray(tags, d_tag); + cJSON *d = cJSON_CreateArray(); + cJSON_AddItemToArray(d, cJSON_CreateString("d")); + cJSON_AddItemToArray(d, cJSON_CreateString(d_tag)); + cJSON_AddItemToArray(tags, d); - /* Create and sign the event. */ cJSON *event = nostr_create_and_sign_event_with_signer( 30003, ciphertext, tags, g_signer, time(NULL)); g_free(ciphertext); + g_free(d_tag); if (event == NULL) { g_printerr("[bookmarks] Failed to create/sign event\n"); @@ -249,22 +427,20 @@ static int publish_directory(const char *dir_name) { return -1; } - /* Store in SQLite. */ db_store_event(event); - /* Publish to bootstrap relays. */ char *relay_buf = NULL; const char *relay_urls[32]; int relay_count = parse_relays(&relay_buf, relay_urls, 32); - if (relay_count > 0) { int success_count = 0; publish_result_t *results = synchronous_publish_event_with_progress( relay_urls, relay_count, event, &success_count, 15, NULL, NULL, 0, NULL); if (results) free(results); - g_print("[bookmarks] Published directory '%s' to %d/%d relays\n", - dir_name, success_count, relay_count); + g_print("[bookmarks] Published '%s' to %d/%d relays\n", + node->path[0] ? node->path : "(root)", + success_count, relay_count); } else { g_print("[bookmarks] No relays configured, event stored locally only\n"); } @@ -274,9 +450,79 @@ static int publish_directory(const char *dir_name) { return 0; } +/* Publish a kind 5 deletion event for a d tag. + * Returns 0 on success, -1 on error. */ +static int publish_deletion_for_d(const char *d_tag_hex) { + if (!g_have_signer) return -1; + if (d_tag_hex == NULL) return -1; + + /* NIP-09 deletion: kind 5, tags ["e", event_id] for each event to delete, + * and ["a", "30003:pubkey:dtag"] for parameterized replaceable events. */ + cJSON *tags = cJSON_CreateArray(); + + /* "a" tag references the parameterized replaceable event: + * 30003:: */ + char a_val[256]; + snprintf(a_val, sizeof(a_val), "30003:%s:%s", g_pubkey, d_tag_hex); + cJSON *a_tag = cJSON_CreateArray(); + cJSON_AddItemToArray(a_tag, cJSON_CreateString("a")); + cJSON_AddItemToArray(a_tag, cJSON_CreateString(a_val)); + cJSON_AddItemToArray(tags, a_tag); + + cJSON *event = nostr_create_and_sign_event_with_signer( + 5, "bookmark folder deleted", tags, g_signer, time(NULL)); + if (event == NULL) { + cJSON_Delete(tags); + return -1; + } + + db_store_event(event); + + char *relay_buf = NULL; + const char *relay_urls[32]; + int relay_count = parse_relays(&relay_buf, relay_urls, 32); + if (relay_count > 0) { + int success_count = 0; + publish_result_t *results = synchronous_publish_event_with_progress( + relay_urls, relay_count, event, &success_count, + 15, NULL, NULL, 0, NULL); + if (results) free(results); + } + g_free(relay_buf); + cJSON_Delete(event); + return 0; +} + +/* Publish a deletion for a path. Convenience wrapper. */ +static int delete_path(const char *path) { + char *d = path_to_d_tag(path); + if (d == NULL) return -1; + int rc = publish_deletion_for_d(d); + g_free(d); + return rc; +} + +/* ── Change notification ───────────────────────────────────────────── */ + +static void notify_changed(void) { + for (int i = 0; i < g_sub_count; i++) { + if (g_subs[i]) g_subs[i](g_sub_ud[i]); + } +} + +void bookmarks_subscribe_changed(bookmarks_changed_cb cb, void *user_data) { + if (cb == NULL) return; + if (g_sub_count < BOOKMARKS_MAX_SUBS) { + g_subs[g_sub_count] = cb; + g_sub_ud[g_sub_count] = user_data; + g_sub_count++; + } +} + /* ── Public API ────────────────────────────────────────────────────── */ -int bookmarks_init(nostr_signer_t *signer, const char *pubkey_hex) { +int bookmarks_init(nostr_signer_t *signer, const char *pubkey_hex, + const char *privkey_hex) { g_signer = signer; g_have_signer = (signer != NULL); if (pubkey_hex) { @@ -284,19 +530,42 @@ int bookmarks_init(nostr_signer_t *signer, const char *pubkey_hex) { } else { g_pubkey[0] = '\0'; } + if (privkey_hex && privkey_hex[0]) { + snprintf(g_privkey, sizeof(g_privkey), "%s", privkey_hex); + g_have_privkey = 1; + } else { + g_privkey[0] = '\0'; + g_have_privkey = 0; + } + g_have_hmac_key = 0; /* recompute on first use */ + + /* Initialize the root node. */ + if (g_root.name == NULL) { + g_root.name = g_strdup(""); + g_root.path = g_strdup(""); + } + + /* Ensure "Bookmarks Bar" and "General" always exist in memory, and + * ensure they are the FIRST two children of the root so they appear + * at the top of the tree (children render in insertion order). These + * are NOT published to Nostr here — they only get published when the + * user actually adds a bookmark to them (bookmarks_add -> publish_node). + * This way a new user sees both folders in the tree and the bookmarks + * toolbar without having to know the exact folder names, and no empty + * events clutter the relays. */ + node_ensure_path(&g_root, "Bookmarks Bar", NULL); + node_ensure_path(&g_root, "General", NULL); /* Load cached kind 30003 events from SQLite. */ if (g_pubkey[0] != '\0') { - /* We need to query all kind 30003 events for this pubkey. - * db_get_events returns an array of events. */ cJSON *events = db_get_events(g_pubkey, 30003, 0); if (events != NULL) { int n = cJSON_GetArraySize(events); - g_print("[bookmarks] Loading %d cached directory event(s)\n", n); + g_print("[bookmarks] Loading %d cached folder event(s)\n", n); cJSON *event; cJSON_ArrayForEach(event, events) { - /* Get the d tag to find the directory name. */ - const char *dir_name = "General"; + /* Get the d tag. */ + const char *d_value = ""; cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags"); if (cJSON_IsArray(tags)) { cJSON *tag; @@ -306,256 +575,464 @@ int bookmarks_init(nostr_signer_t *signer, const char *pubkey_hex) { if (cJSON_IsString(t0) && strcmp(t0->valuestring, "d") == 0) { cJSON *t1 = cJSON_GetArrayItem(tag, 1); - if (cJSON_IsString(t1)) { - dir_name = t1->valuestring; - } + if (cJSON_IsString(t1)) d_value = t1->valuestring; break; } } } - /* Decrypt the content. */ - cJSON *content = cJSON_GetObjectItemCaseSensitive(event, - "content"); - if (cJSON_IsString(content) && content->valuestring[0]) { - char *plaintext = decrypt_content(content->valuestring); - if (plaintext) { - int idx = ensure_dir(dir_name); - if (idx >= 0) { - dir_load_from_json(&g_dirs[idx], plaintext); - } - free(plaintext); - } + /* Decrypt content. */ + cJSON *content = cJSON_GetObjectItemCaseSensitive(event, "content"); + if (!cJSON_IsString(content) || content->valuestring[0] == '\0') + continue; + + char *plaintext = decrypt_content(content->valuestring); + if (plaintext == NULL) continue; + + /* Determine the path. */ + char *path_from_content = NULL; + bookmark_node_t scratch = {0}; + scratch.name = g_strdup(""); + scratch.path = g_strdup(""); + node_load_from_content(&scratch, plaintext, &path_from_content); + + const char *path = NULL; + if (is_hmac_d_tag(d_value)) { + /* New format: path is inside the encrypted content. */ + path = path_from_content; + } else { + /* Legacy: d tag is the plaintext path. */ + path = d_value; } + + if (path == NULL || path[0] == '\0') path = "General"; + + bookmark_node_t *leaf = node_ensure_path(&g_root, path, NULL); + if (leaf != NULL) { + /* Move bookmarks from scratch into the leaf. */ + node_clear_bookmarks(leaf); + leaf->bookmarks = scratch.bookmarks; + leaf->bookmark_count = scratch.bookmark_count; + scratch.bookmarks = NULL; + scratch.bookmark_count = 0; + } + + /* Legacy migration: re-publish with HMAC d tag and delete old. */ + if (!is_hmac_d_tag(d_value) && g_have_signer && g_have_privkey) { + g_print("[bookmarks] Migrating legacy folder '%s' to HMAC d tag\n", + path); + if (leaf) publish_node(leaf); + publish_deletion_for_d(d_value); + } + + g_free(path_from_content); + /* node_free frees scratch.name, scratch.path, and any + * remaining bookmarks (already moved to leaf, so NULL). */ + node_free(&scratch); + free(plaintext); } cJSON_Delete(events); } } - /* Ensure "General" always exists. */ - ensure_dir("General"); - - g_print("[bookmarks] Initialized: %d directory/ies, signer=%s\n", - g_dir_count, g_have_signer ? "yes" : "no"); + g_print("[bookmarks] Initialized: signer=%s privkey=%s\n", + g_have_signer ? "yes" : "no", + g_have_privkey ? "yes" : "no"); return 0; } void bookmarks_cleanup(void) { - if (g_dirs) { - for (int i = 0; i < g_dir_count; i++) { - free_dir_bookmarks(&g_dirs[i]); - } - g_free(g_dirs); - g_dirs = NULL; - } - g_dir_count = 0; - g_dir_cap = 0; + node_free(&g_root); + g_root.name = NULL; + g_root.path = NULL; + g_root.bookmarks = NULL; + g_root.bookmark_count = 0; + g_root.children = NULL; + g_root.child_count = 0; + g_root.child_cap = 0; + g_signer = NULL; g_have_signer = 0; g_pubkey[0] = '\0'; + g_privkey[0] = '\0'; + g_have_privkey = 0; + g_have_hmac_key = 0; + g_sub_count = 0; } -const bookmark_dir_t *bookmarks_get_dirs(int *count_out) { - if (count_out) *count_out = g_dir_count; - return g_dirs; +const bookmark_node_t *bookmarks_get_root(void) { + return (g_root.name != NULL) ? &g_root : NULL; } -const bookmark_dir_t *bookmarks_get_dir(const char *name) { - int idx = find_dir(name); - return (idx >= 0) ? &g_dirs[idx] : NULL; +const bookmark_node_t *bookmarks_find(const char *path) { + return node_find_path(&g_root, path); } -int bookmarks_add(const char *dir, const char *url, const char *title) { +int bookmarks_add(const char *path, const char *url, const char *title) { if (!g_have_signer) { g_printerr("[bookmarks] No signer, cannot add\n"); return -1; } - if (dir == NULL) dir = "General"; + if (path == NULL || path[0] == '\0') path = "General"; if (url == NULL || url[0] == '\0') return -1; - int idx = ensure_dir(dir); - if (idx < 0) return -1; + bookmark_node_t *leaf = node_ensure_path(&g_root, path, NULL); + if (leaf == NULL) return -1; - /* Check if already exists. */ - if (find_bookmark_in_dir(&g_dirs[idx], url) >= 0) { - g_print("[bookmarks] URL already bookmarked in '%s'\n", dir); + if (find_bookmark_in_node(leaf, url) >= 0) { + g_print("[bookmarks] URL already bookmarked in '%s'\n", path); return 0; } - - /* Check capacity. */ - if (g_dirs[idx].count >= BOOKMARKS_MAX_PER_DIR) { - g_printerr("[bookmarks] Directory '%s' is full\n", dir); + if (leaf->bookmark_count >= BOOKMARKS_MAX_PER_NODE) { + g_printerr("[bookmarks] Folder '%s' is full\n", path); return -1; } - /* Add to in-memory list. */ - int n = g_dirs[idx].count; - g_dirs[idx].bookmarks = g_realloc(g_dirs[idx].bookmarks, - (n + 1) * sizeof(bookmark_t)); - g_dirs[idx].bookmarks[n].url = g_strdup(url); - g_dirs[idx].bookmarks[n].title = g_strdup(title ? title : ""); - g_dirs[idx].bookmarks[n].added = (long)time(NULL); - g_dirs[idx].count++; + int n = leaf->bookmark_count; + leaf->bookmarks = g_realloc(leaf->bookmarks, (n + 1) * sizeof(bookmark_t)); + leaf->bookmarks[n].url = g_strdup(url); + leaf->bookmarks[n].title = g_strdup(title ? title : ""); + leaf->bookmarks[n].added = (long)time(NULL); + leaf->bookmark_count++; - g_print("[bookmarks] Added '%s' to '%s'\n", url, dir); - - /* Publish. */ - return publish_directory(dir); + g_print("[bookmarks] Added '%s' to '%s'\n", url, path); + int rc = publish_node(leaf); + notify_changed(); + return rc; } -int bookmarks_remove(const char *dir, const char *url) { +int bookmarks_remove(const char *path, const char *url) { if (!g_have_signer) return -1; - if (dir == NULL) dir = "General"; + if (path == NULL || path[0] == '\0') path = "General"; - int idx = find_dir(dir); - if (idx < 0) return -1; + bookmark_node_t *leaf = node_find_path(&g_root, path); + if (leaf == NULL) return -1; - int bidx = find_bookmark_in_dir(&g_dirs[idx], url); + int bidx = find_bookmark_in_node(leaf, url); if (bidx < 0) return -1; - /* Free the bookmark. */ - g_free(g_dirs[idx].bookmarks[bidx].url); - g_free(g_dirs[idx].bookmarks[bidx].title); - - /* Shift remaining down. */ - for (int i = bidx; i < g_dirs[idx].count - 1; i++) { - g_dirs[idx].bookmarks[i] = g_dirs[idx].bookmarks[i + 1]; + g_free(leaf->bookmarks[bidx].url); + g_free(leaf->bookmarks[bidx].title); + for (int i = bidx; i < leaf->bookmark_count - 1; i++) { + leaf->bookmarks[i] = leaf->bookmarks[i + 1]; } - g_dirs[idx].count--; + leaf->bookmark_count--; - g_print("[bookmarks] Removed '%s' from '%s'\n", url, dir); - return publish_directory(dir); + g_print("[bookmarks] Removed '%s' from '%s'\n", url, path); + int rc = publish_node(leaf); + notify_changed(); + return rc; } -int bookmarks_move(const char *from_dir, const char *url, const char *to_dir) { +int bookmarks_move(const char *from_path, const char *url, const char *to_path) { if (!g_have_signer) return -1; - if (from_dir == NULL) from_dir = "General"; - if (to_dir == NULL) to_dir = "General"; + if (from_path == NULL || from_path[0] == '\0') from_path = "General"; + if (to_path == NULL || to_path[0] == '\0') to_path = "General"; - int from_idx = find_dir(from_dir); - if (from_idx < 0) return -1; - - int bidx = find_bookmark_in_dir(&g_dirs[from_idx], url); + bookmark_node_t *from = node_find_path(&g_root, from_path); + if (from == NULL) return -1; + int bidx = find_bookmark_in_node(from, url); if (bidx < 0) return -1; - /* Save the bookmark data. */ - char *saved_url = g_strdup(g_dirs[from_idx].bookmarks[bidx].url); - char *saved_title = g_strdup(g_dirs[from_idx].bookmarks[bidx].title); - long saved_added = g_dirs[from_idx].bookmarks[bidx].added; + char *saved_url = g_strdup(from->bookmarks[bidx].url); + char *saved_title = g_strdup(from->bookmarks[bidx].title); + long saved_added = from->bookmarks[bidx].added; - /* Remove from source. */ - g_free(g_dirs[from_idx].bookmarks[bidx].url); - g_free(g_dirs[from_idx].bookmarks[bidx].title); - for (int i = bidx; i < g_dirs[from_idx].count - 1; i++) { - g_dirs[from_idx].bookmarks[i] = g_dirs[from_idx].bookmarks[i + 1]; + g_free(from->bookmarks[bidx].url); + g_free(from->bookmarks[bidx].title); + for (int i = bidx; i < from->bookmark_count - 1; i++) { + from->bookmarks[i] = from->bookmarks[i + 1]; } - g_dirs[from_idx].count--; + from->bookmark_count--; - /* Add to destination. */ - int to_idx = ensure_dir(to_dir); - if (to_idx < 0) { + bookmark_node_t *to = node_ensure_path(&g_root, to_path, NULL); + if (to == NULL) { g_free(saved_url); g_free(saved_title); return -1; } - - int n = g_dirs[to_idx].count; - g_dirs[to_idx].bookmarks = g_realloc(g_dirs[to_idx].bookmarks, - (n + 1) * sizeof(bookmark_t)); - g_dirs[to_idx].bookmarks[n].url = saved_url; - g_dirs[to_idx].bookmarks[n].title = saved_title; - g_dirs[to_idx].bookmarks[n].added = saved_added; - g_dirs[to_idx].count++; + int n = to->bookmark_count; + to->bookmarks = g_realloc(to->bookmarks, (n + 1) * sizeof(bookmark_t)); + to->bookmarks[n].url = saved_url; + to->bookmarks[n].title = saved_title; + to->bookmarks[n].added = saved_added; + to->bookmark_count++; g_print("[bookmarks] Moved '%s' from '%s' to '%s'\n", - url, from_dir, to_dir); - - /* Publish both directories. */ - publish_directory(from_dir); - return publish_directory(to_dir); + url, from_path, to_path); + publish_node(from); + int rc = publish_node(to); + notify_changed(); + return rc; } -int bookmarks_create_dir(const char *name) { +int bookmarks_create_dir(const char *path) { if (!g_have_signer) return -1; - if (name == NULL || name[0] == '\0') return -1; - if (find_dir(name) >= 0) { - g_printerr("[bookmarks] Directory '%s' already exists\n", name); + if (path == NULL || path[0] == '\0') return -1; + + if (node_find_path(&g_root, path) != NULL) { + g_printerr("[bookmarks] Folder '%s' already exists\n", path); return -1; } + bookmark_node_t *leaf = node_ensure_path(&g_root, path, NULL); + if (leaf == NULL) return -1; - int idx = ensure_dir(name); - if (idx < 0) return -1; - - g_print("[bookmarks] Created directory '%s'\n", name); - return publish_directory(name); + g_print("[bookmarks] Created folder '%s'\n", path); + int rc = publish_node(leaf); + notify_changed(); + return rc; } -int bookmarks_delete_dir(const char *name, int move_to_general) { - if (!g_have_signer) return -1; - if (name == NULL) return -1; - if (strcmp(name, "General") == 0) { - g_printerr("[bookmarks] Cannot delete 'General'\n"); - return -1; +/* Recursively re-publish a subtree under a new path. Used by rename. + * `old_prefix` is the path being renamed; `new_prefix` is its new path. + * For each node in the subtree, publishes a new event with the new path + * and emits a kind 5 deletion for the old path. */ +static int republish_subtree(bookmark_node_t *node, + const char *old_prefix, + const char *new_prefix) { + if (node == NULL) return 0; + + /* Compute this node's old and new paths. */ + GString *old_path = g_string_new(old_prefix); + if (node->path[0] != '\0' && strlen(node->path) > strlen(old_prefix)) { + /* Append the suffix beyond the prefix. */ + g_string_append(old_path, node->path + strlen(old_prefix)); + } + GString *new_path = g_string_new(new_prefix); + if (node->path[0] != '\0' && strlen(node->path) > strlen(old_prefix)) { + g_string_append(new_path, node->path + strlen(old_prefix)); } - int idx = find_dir(name); - if (idx < 0) return -1; + /* Update the node's path in memory. */ + g_free(node->path); + node->path = g_strdup(new_path->str); - /* Optionally move bookmarks to General. */ - if (move_to_general && g_dirs[idx].count > 0) { - int gen_idx = ensure_dir("General"); - if (gen_idx >= 0) { - for (int i = 0; i < g_dirs[idx].count; i++) { - int n = g_dirs[gen_idx].count; - g_dirs[gen_idx].bookmarks = g_realloc( - g_dirs[gen_idx].bookmarks, - (n + 1) * sizeof(bookmark_t)); - g_dirs[gen_idx].bookmarks[n] = g_dirs[idx].bookmarks[i]; - g_dirs[gen_idx].count++; - } - g_dirs[idx].count = 0; /* Don't free — ownership moved */ - g_dirs[idx].bookmarks = NULL; - publish_directory("General"); - } + /* Publish the new event and delete the old. */ + publish_node(node); + delete_path(old_path->str); + + /* Recurse into children. */ + for (int i = 0; i < node->child_count; i++) { + republish_subtree(&node->children[i], old_prefix, new_prefix); } - /* Free the directory's data. */ - free_dir_bookmarks(&g_dirs[idx]); - - /* Remove from the array. */ - for (int i = idx; i < g_dir_count - 1; i++) { - g_dirs[i] = g_dirs[i + 1]; - } - g_dir_count--; - - g_print("[bookmarks] Deleted directory '%s'\n", name); - - /* TODO: publish a kind 5 deletion event for the old d-tag. - * For now, the directory event will remain on relays but won't - * appear in the UI. A future fetch will re-add it — we need the - * deletion event to prevent that. */ + g_string_free(old_path, TRUE); + g_string_free(new_path, TRUE); return 0; } -int bookmarks_load_dir_from_json(const char *dir_name, const char *json) { - if (dir_name == NULL || json == NULL) return -1; - int idx = ensure_dir(dir_name); - if (idx < 0) return -1; - return dir_load_from_json(&g_dirs[idx], json); +int bookmarks_rename_dir(const char *old_path, const char *new_path) { + if (!g_have_signer) return -1; + if (old_path == NULL || new_path == NULL) return -1; + if (old_path[0] == '\0' || new_path[0] == '\0') return -1; + if (strcmp(old_path, new_path) == 0) return 0; + /* "General" and "Bookmarks Bar" are permanent system folders. */ + if (strcmp(old_path, "General") == 0 || strcmp(old_path, "Bookmarks Bar") == 0) { + g_printerr("[bookmarks] Cannot rename system folder '%s'\n", old_path); + return -1; + } + + bookmark_node_t *node = node_find_path(&g_root, old_path); + if (node == NULL) { + g_printerr("[bookmarks] Folder '%s' not found\n", old_path); + return -1; + } + if (node_find_path(&g_root, new_path) != NULL) { + g_printerr("[bookmarks] Folder '%s' already exists\n", new_path); + return -1; + } + + /* Detach the node from its parent, re-attach under the new path. */ + /* Find the parent of `node` in the tree. */ + char *old_copy = g_strdup(old_path); + char *last_slash = strrchr(old_copy, '/'); + char *parent_path = NULL; + char *leaf_name = old_copy; + if (last_slash != NULL) { + *last_slash = '\0'; + parent_path = old_copy; + leaf_name = last_slash + 1; + } + bookmark_node_t *parent = (parent_path == NULL || parent_path[0] == '\0') + ? &g_root : node_find_path(&g_root, parent_path); + if (parent == NULL) { + g_free(old_copy); + return -1; + } + + /* Find the child index in parent. */ + int child_idx = -1; + for (int i = 0; i < parent->child_count; i++) { + if (strcmp(parent->children[i].name, leaf_name) == 0) { + child_idx = i; + break; + } + } + if (child_idx < 0) { + g_free(old_copy); + return -1; + } + + /* Compute the new leaf name. */ + char *new_copy = g_strdup(new_path); + char *new_last_slash = strrchr(new_copy, '/'); + char *new_leaf_name = new_copy; + char *new_parent_path = NULL; + if (new_last_slash != NULL) { + *new_last_slash = '\0'; + new_parent_path = new_copy; + new_leaf_name = new_last_slash + 1; + } + + /* Ensure the new parent exists. */ + bookmark_node_t *new_parent = (new_parent_path == NULL || new_parent_path[0] == '\0') + ? &g_root : node_ensure_path(&g_root, new_parent_path, NULL); + if (new_parent == NULL) { + g_free(old_copy); + g_free(new_copy); + return -1; + } + + /* Republish the subtree (updates paths in memory, publishes new events, + * deletes old). Do this BEFORE moving the node, while it's still + * reachable at its old tree position. */ + republish_subtree(&parent->children[child_idx], old_path, new_path); + + /* Now physically move the child from old parent to new parent. */ + bookmark_node_t moved = parent->children[child_idx]; + /* Remove from old parent (shift down, don't free). */ + for (int i = child_idx; i < parent->child_count - 1; i++) { + parent->children[i] = parent->children[i + 1]; + } + parent->child_count--; + + /* Update the moved node's name. */ + g_free(moved.name); + moved.name = g_strdup(new_leaf_name); + + /* Append to new parent. */ + if (new_parent->child_count >= new_parent->child_cap) { + int new_cap = new_parent->child_cap == 0 ? 8 : new_parent->child_cap * 2; + bookmark_node_t *arr = g_realloc(new_parent->children, + new_cap * sizeof(bookmark_node_t)); + new_parent->children = arr; + new_parent->child_cap = new_cap; + } + new_parent->children[new_parent->child_count++] = moved; + + g_print("[bookmarks] Renamed '%s' to '%s'\n", old_path, new_path); + g_free(old_copy); + g_free(new_copy); + notify_changed(); + return 0; +} + +/* Recursively collect all paths in a subtree (including `node`). */ +static void collect_paths(const bookmark_node_t *node, GPtrArray *out) { + if (node == NULL) return; + if (node->path[0] != '\0') { + g_ptr_array_add(out, g_strdup(node->path)); + } + for (int i = 0; i < node->child_count; i++) { + collect_paths(&node->children[i], out); + } +} + +/* Recursively collect all bookmarks in a subtree into `out` (steals ownership). */ +static void collect_bookmarks(bookmark_node_t *node, GPtrArray *out) { + if (node == NULL) return; + for (int i = 0; i < node->bookmark_count; i++) { + g_ptr_array_add(out, &node->bookmarks[i]); /* shallow */ + } + node->bookmark_count = 0; + node->bookmarks = NULL; + for (int i = 0; i < node->child_count; i++) { + collect_bookmarks(&node->children[i], out); + } +} + +int bookmarks_delete_dir(const char *path, int move_to_general) { + if (!g_have_signer) return -1; + if (path == NULL || path[0] == '\0') return -1; + /* "General" and "Bookmarks Bar" are permanent system folders. */ + if (strcmp(path, "General") == 0 || strcmp(path, "Bookmarks Bar") == 0) { + g_printerr("[bookmarks] Cannot delete system folder '%s'\n", path); + return -1; + } + + bookmark_node_t *node = node_find_path(&g_root, path); + if (node == NULL) return -1; + + /* Optionally move bookmarks to General. */ + if (move_to_general) { + GPtrArray *bms = g_ptr_array_new(); + collect_bookmarks(node, bms); + if (bms->len > 0) { + bookmark_node_t *gen = node_ensure_path(&g_root, "General", NULL); + if (gen) { + for (guint i = 0; i < bms->len; i++) { + bookmark_t *bm = (bookmark_t *)g_ptr_array_index(bms, i); + int n = gen->bookmark_count; + gen->bookmarks = g_realloc(gen->bookmarks, + (n + 1) * sizeof(bookmark_t)); + gen->bookmarks[n] = *bm; + gen->bookmark_count++; + } + publish_node(gen); + } + } + g_ptr_array_free(bms, TRUE); + } + + /* Collect all paths in the subtree for deletion. */ + GPtrArray *paths = g_ptr_array_new(); + collect_paths(node, paths); + + /* Detach the node from its parent. */ + char *copy = g_strdup(path); + char *last_slash = strrchr(copy, '/'); + char *leaf_name = copy; + char *parent_path = NULL; + if (last_slash != NULL) { + *last_slash = '\0'; + parent_path = copy; + leaf_name = last_slash + 1; + } + bookmark_node_t *parent = (parent_path == NULL || parent_path[0] == '\0') + ? &g_root : node_find_path(&g_root, parent_path); + if (parent) { + for (int i = 0; i < parent->child_count; i++) { + if (strcmp(parent->children[i].name, leaf_name) == 0) { + node_remove_child(parent, i); + break; + } + } + } + g_free(copy); + + /* Publish kind 5 deletions for every path in the subtree. */ + for (guint i = 0; i < paths->len; i++) { + const char *p = (const char *)g_ptr_array_index(paths, i); + delete_path(p); + g_free(g_ptr_array_index(paths, i)); + } + g_ptr_array_free(paths, TRUE); + + g_print("[bookmarks] Deleted folder '%s'\n", path); + notify_changed(); + return 0; } int bookmarks_store_and_load_event(const void *event_cjson) { const cJSON *event = (const cJSON *)event_cjson; if (event == NULL) return -1; - /* Store in SQLite. */ db_store_event(event); - /* If we have a signer, decrypt and load into memory. */ if (!g_have_signer) return 0; /* Get the d tag. */ - const char *dir_name = "General"; + const char *d_value = ""; cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags"); if (cJSON_IsArray(tags)) { cJSON *tag; @@ -564,24 +1041,49 @@ int bookmarks_store_and_load_event(const void *event_cjson) { cJSON *t0 = cJSON_GetArrayItem(tag, 0); if (cJSON_IsString(t0) && strcmp(t0->valuestring, "d") == 0) { cJSON *t1 = cJSON_GetArrayItem(tag, 1); - if (cJSON_IsString(t1)) dir_name = t1->valuestring; + if (cJSON_IsString(t1)) d_value = t1->valuestring; break; } } } - /* Decrypt content. */ cJSON *content = cJSON_GetObjectItemCaseSensitive(event, "content"); - if (cJSON_IsString(content) && content->valuestring[0]) { - char *plaintext = decrypt_content(content->valuestring); - if (plaintext) { - int idx = ensure_dir(dir_name); - if (idx >= 0) { - dir_load_from_json(&g_dirs[idx], plaintext); - } - free(plaintext); - } + if (!cJSON_IsString(content) || content->valuestring[0] == '\0') + return 0; + + char *plaintext = decrypt_content(content->valuestring); + if (plaintext == NULL) return 0; + + char *path_from_content = NULL; + bookmark_node_t scratch = {0}; + scratch.name = g_strdup(""); + scratch.path = g_strdup(""); + node_load_from_content(&scratch, plaintext, &path_from_content); + + const char *path = is_hmac_d_tag(d_value) ? path_from_content : d_value; + if (path == NULL || path[0] == '\0') path = "General"; + + bookmark_node_t *leaf = node_ensure_path(&g_root, path, NULL); + if (leaf) { + node_clear_bookmarks(leaf); + leaf->bookmarks = scratch.bookmarks; + leaf->bookmark_count = scratch.bookmark_count; + scratch.bookmarks = NULL; + scratch.bookmark_count = 0; } + /* Legacy migration. */ + if (!is_hmac_d_tag(d_value) && g_have_signer && g_have_privkey) { + if (leaf) publish_node(leaf); + publish_deletion_for_d(d_value); + } + + g_free(path_from_content); + /* node_free frees scratch.name, scratch.path, and any remaining + * bookmarks (already moved to leaf, so NULL). */ + node_free(&scratch); + free(plaintext); + + notify_changed(); return 0; } diff --git a/src/bookmarks.h b/src/bookmarks.h index 238ab3a..bb8c6cb 100644 --- a/src/bookmarks.h +++ b/src/bookmarks.h @@ -1,9 +1,22 @@ /* - * bookmarks.h — NIP-44 encrypted Nostr bookmarks with directories + * bookmarks.h — NIP-44 encrypted Nostr bookmarks with nested folders * * Stores bookmarks as NIP-51 kind 30003 (bookmark sets) events, one per - * directory. Each directory's bookmarks are NIP-44 encrypted (self-to-self) - * and stored in the event's content field as a JSON tag array. + * folder. Folders may be nested arbitrarily (e.g. "Work/Projects/Secret"). + * + * Privacy design: + * - The `d` tag is HMAC-SHA256(hmac_key, path) — a deterministic, opaque + * 64-hex-char identifier. Relays learn nothing about folder names or + * structure (not even the folder count). Determinism preserves NIP-33 + * replaceability: re-publishing the same path replaces the prior event. + * Encryption (NIP-04 / NIP-44) cannot be used for the `d` tag because + * both use a random IV/nonce per call, which would break replaceability. + * - The real path and the bookmark list live inside the NIP-44 encrypted + * `content` as JSON: {"path": "Work/Projects/Secret", "bookmarks": [...]} + * - The HMAC key is derived from the user's Nostr privkey: + * hmac_key = HMAC-SHA256(privkey_bytes, "sovereign-browser/bookmarks-folder-id-v1") + * It is per-user, never published, and automatically available on every + * device the user logs in on. * * The bookmarks sync across all devices the user logs in on via the * bootstrap relays. @@ -22,10 +35,15 @@ extern "C" { #endif -#define BOOKMARKS_MAX_PER_DIR 500 -#define BOOKMARKS_DIR_NAME_MAX 128 -#define BOOKMARKS_URL_MAX 2048 -#define BOOKMARKS_TITLE_MAX 256 +#define BOOKMARKS_MAX_PER_NODE 500 +#define BOOKMARKS_NAME_MAX 128 +#define BOOKMARKS_PATH_MAX 1024 +#define BOOKMARKS_URL_MAX 2048 +#define BOOKMARKS_TITLE_MAX 256 + +/* Label mixed into the HMAC key derivation so d-tag namespaces are isolated + * per application and can be rotated independently if ever needed. */ +#define BOOKMARKS_HMAC_KEY_LABEL "sovereign-browser/bookmarks-folder-id-v1" typedef struct { char *url; @@ -33,70 +51,97 @@ typedef struct { long added; /* unix timestamp */ } bookmark_t; -typedef struct { - char name[BOOKMARKS_DIR_NAME_MAX]; - bookmark_t *bookmarks; - int count; -} bookmark_dir_t; +/* A node in the bookmark tree. The root node has name="" and path="". + * Each node corresponds to a folder path; bookmarks live at any node. */ +typedef struct bookmark_node { + char *name; /* last path segment, "" for root */ + char *path; /* full path, "" for root */ + bookmark_t *bookmarks; + int bookmark_count; + struct bookmark_node *children; /* array of child nodes */ + int child_count; + int child_cap; +} bookmark_node_t; /* * Initialize the bookmarks module. Loads cached bookmarks from the SQLite * database and decrypts them using the signer. * - * signer — the user's nostr_signer_t (NULL for read-only/no-login) - * pubkey_hex — the user's hex pubkey (64 chars) + * signer — the user's nostr_signer_t (NULL for read-only/no-login) + * pubkey_hex — the user's hex pubkey (64 chars) or NULL + * privkey_hex — the user's hex privkey (64 chars) or NULL. Used to derive + * the HMAC key for opaque d tags. NULL in read-only mode + * (existing events can still be loaded from the db, but + * no new events can be published). * * Returns 0 on success, -1 on error. */ -int bookmarks_init(nostr_signer_t *signer, const char *pubkey_hex); +int bookmarks_init(nostr_signer_t *signer, + const char *pubkey_hex, + const char *privkey_hex); -/* Free the in-memory bookmark list. Call at shutdown. */ +/* Free the in-memory bookmark tree. Call at shutdown. */ void bookmarks_cleanup(void); /* ── Read access ───────────────────────────────────────────────────── */ -/* Get the list of directories (read-only). Returns a pointer to the - * internal array (valid until the next bookmarks operation). */ -const bookmark_dir_t *bookmarks_get_dirs(int *count_out); +/* Get the root node of the bookmark tree (read-only). Returns NULL if + * bookmarks_init has not been called. The returned pointer is valid until + * the next bookmarks mutation. */ +const bookmark_node_t *bookmarks_get_root(void); -/* Get a specific directory by name. Returns NULL if not found. */ -const bookmark_dir_t *bookmarks_get_dir(const char *name); +/* Find a node by path. Returns NULL if not found. */ +const bookmark_node_t *bookmarks_find(const char *path); -/* ── Write access (requires signer) ────────────────────────────────── */ +/* ── Write access (requires signer + privkey) ──────────────────────── */ -/* Add a bookmark to a directory. If the directory doesn't exist, it's - * created. Updates the in-memory list, re-encrypts, publishes a new - * kind 30003 event for that directory, and stores in SQLite. +/* Add a bookmark to a folder path. If the folder (or any intermediate + * folder) doesn't exist, it is created. Updates the in-memory tree, + * re-encrypts, publishes a new kind 30003 event for that path, and stores + * in SQLite. * * Returns 0 on success, -1 on error. */ -int bookmarks_add(const char *dir, const char *url, const char *title); +int bookmarks_add(const char *path, const char *url, const char *title); -/* Remove a bookmark by URL from a directory. +/* Remove a bookmark by URL from a folder path. * Returns 0 on success, -1 on not found / error. */ -int bookmarks_remove(const char *dir, const char *url); +int bookmarks_remove(const char *path, const char *url); -/* Move a bookmark from one directory to another. +/* Move a bookmark from one folder to another. * Returns 0 on success, -1 on error. */ -int bookmarks_move(const char *from_dir, const char *url, - const char *to_dir); +int bookmarks_move(const char *from_path, const char *url, + const char *to_path); -/* Create a new empty directory. Publishes an empty kind 30003 event. - * Returns 0 on success, -1 if directory already exists / error. */ -int bookmarks_create_dir(const char *name); +/* Create a new empty folder at the given path. Parent folders are created + * if missing. Publishes an empty kind 30003 event. + * Returns 0 on success, -1 if the folder already exists / error. */ +int bookmarks_create_dir(const char *path); -/* Delete a directory. If move_to_general is TRUE, bookmarks are moved - * to "General" before deletion. Publishes a kind 5 deletion event. +/* Rename a folder. Re-publishes the renamed node and every descendant with + * new HMAC d tags and new encrypted content; emits kind 5 deletions for the + * old d tags. * Returns 0 on success, -1 on error. */ -int bookmarks_delete_dir(const char *name, int move_to_general); +int bookmarks_rename_dir(const char *old_path, const char *new_path); + +/* Delete a folder. If move_to_general is TRUE, the folder's bookmarks (and + * its descendants' bookmarks) are moved to "General" before deletion. + * Emits kind 5 deletion events for every d tag in the subtree. + * Returns 0 on success, -1 on error. */ +int bookmarks_delete_dir(const char *path, int move_to_general); + +/* ── Change notification ───────────────────────────────────────────── */ + +/* Callback invoked after any mutation (add/remove/move/create/rename/delete + * and after relay-fetch loads new events). Used by the bookmarks toolbar to + * refresh every open tab. */ +typedef void (*bookmarks_changed_cb)(void *user_data); + +void bookmarks_subscribe_changed(bookmarks_changed_cb cb, void *user_data); /* ── Internal (used by relay fetch) ────────────────────────────────── */ -/* Load bookmarks for a directory from a decrypted kind 30003 event's - * content JSON. Called by bookmarks_init and after relay fetch. */ -int bookmarks_load_dir_from_json(const char *dir_name, const char *json); - /* Store a raw kind 30003 event in the database and decrypt its content - * into the in-memory list. Called by the relay fetch after login. */ + * into the in-memory tree. Called by the relay fetch after login. */ int bookmarks_store_and_load_event(const void *event_cjson); #ifdef __cplusplus diff --git a/src/history.c b/src/history.c index 878de17..f84d1c7 100644 --- a/src/history.c +++ b/src/history.c @@ -29,6 +29,8 @@ void history_add_titled(const char *url, const char *title) { strncmp(url, "sovereign://bookmarks/delete", 28) == 0 || strncmp(url, "sovereign://bookmarks/createdir", 31) == 0 || strncmp(url, "sovereign://bookmarks/deletedir", 31) == 0 || + strncmp(url, "sovereign://bookmarks/move", 26) == 0 || + strncmp(url, "sovereign://bookmarks/renamedir", 31) == 0 || strncmp(url, "sovereign://qr", 14) == 0 || strncmp(url, "sovereign://nostr/", 18) == 0) { return; diff --git a/src/main.c b/src/main.c index 3aaa713..7272183 100644 --- a/src/main.c +++ b/src/main.c @@ -66,6 +66,7 @@ typedef struct { nostr_signer_t *signer; /* NULL for read-only mode */ char pubkey_hex[65]; + char privkey_hex[65]; /* in-memory only; for HMAC d-tag derivation */ key_store_method_t method; gboolean readonly; /* TRUE if no signing available */ } app_state_t; @@ -81,6 +82,7 @@ static gboolean g_is_fullscreen = FALSE; /* track fullscreen state (GTK3 has /* ---- App state accessors (used by agent_login.c) ─────────────────── */ void app_set_signer(nostr_signer_t *signer, const char *pubkey_hex, + const char *privkey_hex, key_store_method_t method, gboolean readonly) { g_state.signer = signer; g_state.method = method; @@ -89,6 +91,12 @@ void app_set_signer(nostr_signer_t *signer, const char *pubkey_hex, strncpy(g_state.pubkey_hex, pubkey_hex, 64); g_state.pubkey_hex[64] = '\0'; } + if (privkey_hex && privkey_hex[0]) { + strncpy(g_state.privkey_hex, privkey_hex, 64); + g_state.privkey_hex[64] = '\0'; + } else { + g_state.privkey_hex[0] = '\0'; + } g_logged_in = TRUE; /* Update modules that hold a signer reference. */ @@ -112,6 +120,7 @@ void app_clear_signer(void) { g_state.signer = NULL; } g_state.pubkey_hex[0] = '\0'; + g_state.privkey_hex[0] = '\0'; g_state.method = KEY_STORE_METHOD_NONE; g_state.readonly = FALSE; g_logged_in = FALSE; @@ -649,6 +658,10 @@ static int do_login(GtkWindow *parent) { g_state.method = result.method; strncpy(g_state.pubkey_hex, result.pubkey_hex, 64); g_state.pubkey_hex[64] = '\0'; + /* Carry the privkey (in-memory only) for HMAC d-tag derivation in the + * bookmarks module. Empty for readonly / nsigner / nip46 methods. */ + strncpy(g_state.privkey_hex, result.identity.privkey_hex, 64); + g_state.privkey_hex[64] = '\0'; g_state.readonly = (result.method == KEY_STORE_METHOD_READONLY || result.method == KEY_STORE_METHOD_NONE); g_logged_in = TRUE; @@ -943,7 +956,8 @@ int main(int argc, char **argv) { * from SQLite and decrypts them. In no-login/read-only mode, the * signer is NULL so bookmarks are read-only. */ bookmarks_init(g_state.signer, - g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL); + g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL, + g_state.privkey_hex[0] ? g_state.privkey_hex : NULL); /* Initialize the NIP-78 settings sync module. In no-login/read-only * mode, the signer is NULL so settings are local-only (not synced). */ diff --git a/src/nostr_bridge.c b/src/nostr_bridge.c index 8fb445f..0b1cb4d 100644 --- a/src/nostr_bridge.c +++ b/src/nostr_bridge.c @@ -1508,140 +1508,12 @@ static void handle_profile_page(WebKitURISchemeRequest *request) { /* ── Bookmarks page (sovereign://bookmarks) ──────────────────────── */ -/* Generate the sovereign://bookmarks HTML page. - * Shows all bookmark directories and their bookmarks, with add/delete/move - * controls. In read-only / no-login mode, add/delete are disabled. */ -static void handle_bookmarks_page(WebKitURISchemeRequest *request) { - int dir_count = 0; - const bookmark_dir_t *dirs = bookmarks_get_dirs(&dir_count); - - int have_signer = (g_bridge.signer != NULL && !g_bridge.readonly); - - char *head = sovereign_page_head("Bookmarks"); - GString *html = g_string_new(head); - g_free(head); - g_string_append(html, - "

      Bookmarks

      \n"); - - if (!have_signer) { - g_string_append(html, - "

      Read-only mode — sign in to add, edit, or delete " - "bookmarks. Showing cached bookmarks from the local database.

      \n"); - } - - /* Add bookmark form (only if signer available). */ - if (have_signer) { - g_string_append(html, - "

      Add Bookmark

      \n" - "
      \n" - " \n" - " \n" - " \n" - " \n" - "
      \n" - "
      \n" - " \n" - " \n" - "
      \n"); - } - - /* List directories and bookmarks. */ - g_string_append(html, "

      Bookmarks

      \n"); - - if (dir_count == 0) { - g_string_append(html, - "

      No bookmarks yet. Click the bookmark button " - "in the toolbar to save a page.

      \n"); - } else { - for (int i = 0; i < dir_count; i++) { - const bookmark_dir_t *dir = &dirs[i]; - char *dir_esc = g_markup_escape_text(dir->name, -1); - - g_string_append_printf(html, - "

      %s (%d)

      ", - dir_esc, dir->count); - - if (have_signer && strcmp(dir->name, "General") != 0) { - g_string_append_printf(html, - " Delete Dir", - g_uri_escape_string(dir->name, NULL, FALSE), - dir_esc); - } - g_string_append(html, "
      \n"); - - if (dir->count == 0) { - g_string_append(html, "

      (empty)

      \n"); - } else { - for (int j = 0; j < dir->count; j++) { - const bookmark_t *bm = &dir->bookmarks[j]; - char *url_esc = g_uri_escape_string(bm->url, NULL, FALSE); - char *title_esc = g_markup_escape_text(bm->title, -1); - char *url_display = g_markup_escape_text(bm->url, -1); - char *dir_for_link = g_uri_escape_string(dir->name, NULL, FALSE); - - g_string_append_printf(html, - "
      " - "
      %s
      " - "" - "
      Added: %ld
      " - "
      " - "Open", - title_esc[0] ? title_esc : "(untitled)", - url_display, url_display, bm->added, - url_display); - - if (have_signer) { - g_string_append_printf(html, - " Delete", - dir_for_link, url_esc); - } - - g_string_append(html, "
      \n"); - g_free(url_esc); - g_free(title_esc); - g_free(url_display); - g_free(dir_for_link); - } - } - g_free(dir_esc); - } - } - - g_string_append(html, - "\n\n"); - - char *foot = sovereign_page_foot(); - g_string_append(html, foot); - g_free(foot); - - char *html_str = g_string_free(html, FALSE); - respond_html(request, html_str); - g_free(html_str); -} +/* The sovereign://bookmarks HTML page is now served from the embedded file + * www/bookmarks.html (see the route dispatch in handle_uri_scheme_request). + * The legacy inline HTML generator was removed when the bookmarks data model + * changed from flat directories to a nested tree. The tree is rendered + * client-side by www/bookmarks.js, which fetches sovereign://bookmarks/list + * (JSON) — see handle_bookmarks_list_json above. */ /* Handle sovereign://bookmarks/add?dir=...&url=...&title=... */ static void handle_bookmarks_add(WebKitURISchemeRequest *request, @@ -1746,6 +1618,64 @@ static void handle_bookmarks_deletedir(WebKitURISchemeRequest *request, g_free(dir); } +/* Handle sovereign://bookmarks/move?from=...&to=...&url=... + * Moves a bookmark from one folder path to another. */ +static void handle_bookmarks_move(WebKitURISchemeRequest *request, + const char *query) { + char *from = query_param(query, "from"); + char *to = query_param(query, "to"); + char *url = query_param(query, "url"); + + if (!url || url[0] == '\0') { + respond_error_json(request, -1, "Missing url parameter"); + g_free(from); g_free(to); g_free(url); + return; + } + + int rc = bookmarks_move(from ? from : "General", + url, + to ? to : "General"); + if (rc != 0) { + respond_error_json(request, -1, "Failed to move bookmark"); + } else { + cJSON *result = cJSON_CreateObject(); + cJSON_AddStringToObject(result, "status", "moved"); + char *json = cJSON_PrintUnformatted(result); + cJSON_Delete(result); + respond_json(request, json); + free(json); + } + g_free(from); g_free(to); g_free(url); +} + +/* Handle sovereign://bookmarks/renamedir?old=...&new=... + * Renames a folder (and its entire subtree) to a new path. */ +static void handle_bookmarks_renamedir(WebKitURISchemeRequest *request, + const char *query) { + char *old_path = query_param(query, "old"); + char *new_path = query_param(query, "new"); + + if (!old_path || old_path[0] == '\0' || + !new_path || new_path[0] == '\0') { + respond_error_json(request, -1, "Missing old or new parameter"); + g_free(old_path); g_free(new_path); + return; + } + + int rc = bookmarks_rename_dir(old_path, new_path); + if (rc != 0) { + respond_error_json(request, -1, "Failed to rename directory"); + } else { + cJSON *result = cJSON_CreateObject(); + cJSON_AddStringToObject(result, "status", "renamed"); + char *json = cJSON_PrintUnformatted(result); + cJSON_Delete(result); + respond_json(request, json); + free(json); + } + g_free(old_path); g_free(new_path); +} + /* ── Agent pages (sovereign://agents) ───────────────────────────── */ /* Generate the sovereign://agents provider config HTML page. @@ -3710,38 +3640,53 @@ static void handle_fips_directory_json(WebKitURISchemeRequest *request) { free(json); } -/* Handle sovereign://bookmarks/list — return all bookmark directories - * and their bookmarks as JSON. Used by www/bookmarks.js. */ +/* Recursively serialize a bookmark_node_t into a cJSON object. */ +static cJSON *bookmark_node_to_json(const bookmark_node_t *node) { + cJSON *obj = cJSON_CreateObject(); + cJSON_AddStringToObject(obj, "name", node->name ? node->name : ""); + cJSON_AddStringToObject(obj, "path", node->path ? node->path : ""); + cJSON_AddNumberToObject(obj, "count", node->bookmark_count); + + cJSON *bms = cJSON_CreateArray(); + for (int j = 0; j < node->bookmark_count; j++) { + const bookmark_t *bm = &node->bookmarks[j]; + cJSON *bobj = cJSON_CreateObject(); + cJSON_AddStringToObject(bobj, "url", bm->url); + cJSON_AddStringToObject(bobj, "title", bm->title); + cJSON_AddNumberToObject(bobj, "added", (double)bm->added); + cJSON_AddItemToArray(bms, bobj); + } + cJSON_AddItemToObject(obj, "bookmarks", bms); + + cJSON *children = cJSON_CreateArray(); + for (int i = 0; i < node->child_count; i++) { + cJSON_AddItemToArray(children, + bookmark_node_to_json(&node->children[i])); + } + cJSON_AddItemToObject(obj, "children", children); + return obj; +} + +/* Handle sovereign://bookmarks/list — return the bookmark tree as JSON. + * Used by www/bookmarks.js. Shape: + * {"have_signer": bool, "tree": {name,path,bookmarks[],children[]}} */ static void handle_bookmarks_list_json(WebKitURISchemeRequest *request) { - int dir_count = 0; - const bookmark_dir_t *dirs = bookmarks_get_dirs(&dir_count); int have_signer = (g_bridge.signer != NULL && !g_bridge.readonly); - cJSON *root = cJSON_CreateObject(); - cJSON_AddBoolToObject(root, "have_signer", have_signer); + const bookmark_node_t *root = bookmarks_get_root(); - cJSON *dirs_arr = cJSON_CreateArray(); - for (int i = 0; i < dir_count; i++) { - const bookmark_dir_t *dir = &dirs[i]; - cJSON *dobj = cJSON_CreateObject(); - cJSON_AddStringToObject(dobj, "name", dir->name); - cJSON_AddNumberToObject(dobj, "count", dir->count); - cJSON *bms = cJSON_CreateArray(); - for (int j = 0; j < dir->count; j++) { - const bookmark_t *bm = &dir->bookmarks[j]; - cJSON *bobj = cJSON_CreateObject(); - cJSON_AddStringToObject(bobj, "url", bm->url); - cJSON_AddStringToObject(bobj, "title", bm->title); - cJSON_AddNumberToObject(bobj, "added", (double)bm->added); - cJSON_AddItemToArray(bms, bobj); - } - cJSON_AddItemToObject(dobj, "bookmarks", bms); - cJSON_AddItemToArray(dirs_arr, dobj); + cJSON *root_obj = cJSON_CreateObject(); + cJSON_AddBoolToObject(root_obj, "have_signer", have_signer); + + if (root) { + cJSON *tree = bookmark_node_to_json(root); + cJSON_AddItemToObject(root_obj, "tree", tree); + } else { + cJSON_AddNullToObject(root_obj, "tree"); } - cJSON_AddItemToObject(root, "dirs", dirs_arr); - char *json = cJSON_PrintUnformatted(root); - cJSON_Delete(root); + char *json = cJSON_PrintUnformatted(root_obj); + cJSON_Delete(root_obj); respond_json(request, json); free(json); } @@ -3996,20 +3941,28 @@ static void on_sovereign_scheme(WebKitURISchemeRequest *request, handle_bookmarks_list_json(request); return; } - if (strncmp(uri, "sovereign://bookmarks/add?", 25) == 0) { - handle_bookmarks_add(request, uri + 25); + if (strncmp(uri, "sovereign://bookmarks/add?", 26) == 0) { + handle_bookmarks_add(request, uri + 26); return; } - if (strncmp(uri, "sovereign://bookmarks/delete?", 28) == 0) { - handle_bookmarks_delete(request, uri + 28); + if (strncmp(uri, "sovereign://bookmarks/delete?", 29) == 0) { + handle_bookmarks_delete(request, uri + 29); return; } - if (strncmp(uri, "sovereign://bookmarks/createdir?", 31) == 0) { - handle_bookmarks_createdir(request, uri + 31); + if (strncmp(uri, "sovereign://bookmarks/createdir?", 32) == 0) { + handle_bookmarks_createdir(request, uri + 32); return; } - if (strncmp(uri, "sovereign://bookmarks/deletedir?", 31) == 0) { - handle_bookmarks_deletedir(request, uri + 31); + if (strncmp(uri, "sovereign://bookmarks/deletedir?", 32) == 0) { + handle_bookmarks_deletedir(request, uri + 32); + return; + } + if (strncmp(uri, "sovereign://bookmarks/move?", 27) == 0) { + handle_bookmarks_move(request, uri + 27); + return; + } + if (strncmp(uri, "sovereign://bookmarks/renamedir?", 32) == 0) { + handle_bookmarks_renamedir(request, uri + 32); return; } diff --git a/src/tab_manager.c b/src/tab_manager.c index 3beea5d..70db4fb 100644 --- a/src/tab_manager.c +++ b/src/tab_manager.c @@ -38,6 +38,19 @@ static const char *ci_strstr(const char *haystack, const char *needle) { return NULL; } +/* Recursively collect all bookmarks in the bookmark tree into a GPtrArray + * of `const bookmark_t *` (shallow — pointers are valid until the next + * bookmarks mutation). Used by URL completion and the hamburger menu. */ +static void collect_all_bookmarks(const bookmark_node_t *node, GPtrArray *out) { + if (node == NULL) return; + for (int i = 0; i < node->bookmark_count; i++) { + g_ptr_array_add(out, &node->bookmarks[i]); + } + for (int i = 0; i < node->child_count; i++) { + collect_all_bookmarks(&node->children[i], out); + } +} + /* ── External callbacks defined in main.c ─────────────────────────── * * These handle identity-related menu actions that need access to the * global app_state_t (signer, pubkey, method). main.c owns that state. @@ -417,28 +430,27 @@ static void rebuild_completion(const char *query, completion_state_t *cs) { g_free(titles); /* ── Direct links from bookmarks ──────────────────────────────── */ - int dir_count = 0; - const bookmark_dir_t *dirs = bookmarks_get_dirs(&dir_count); + GPtrArray *bms = g_ptr_array_new(); + collect_all_bookmarks(bookmarks_get_root(), bms); int bookmark_added = 0; - for (int d = 0; d < dir_count && bookmark_added < COMPLETION_MAX_DIRECT; d++) { - for (int b = 0; b < dirs[d].count && bookmark_added < COMPLETION_MAX_DIRECT; b++) { - const bookmark_t *bm = &dirs[d].bookmarks[b]; - /* Case-insensitive substring search. */ - if ((bm->url && ci_strstr(bm->url, query)) || - (bm->title && ci_strstr(bm->title, query))) { - char *label = url_display_label(bm->url, bm->title); - GtkTreeIter iter; - gtk_list_store_append(cs->store, &iter); - gtk_list_store_set(cs->store, &iter, - COMPLETION_COL_DISPLAY, label, - COMPLETION_COL_URL, bm->url, - COMPLETION_COL_IS_DIRECT, TRUE, - -1); - g_free(label); - bookmark_added++; - } + for (guint i = 0; i < bms->len && bookmark_added < COMPLETION_MAX_DIRECT; i++) { + const bookmark_t *bm = (const bookmark_t *)g_ptr_array_index(bms, i); + /* Case-insensitive substring search. */ + if ((bm->url && ci_strstr(bm->url, query)) || + (bm->title && ci_strstr(bm->title, query))) { + char *label = url_display_label(bm->url, bm->title); + GtkTreeIter iter; + gtk_list_store_append(cs->store, &iter); + gtk_list_store_set(cs->store, &iter, + COMPLETION_COL_DISPLAY, label, + COMPLETION_COL_URL, bm->url, + COMPLETION_COL_IS_DIRECT, TRUE, + -1); + g_free(label); + bookmark_added++; } } + g_ptr_array_free(bms, TRUE); /* ── Domain heuristic ─────────────────────────────────────────── * * If the query has no spaces and no dots, offer to navigate directly @@ -2063,57 +2075,39 @@ static void on_bookmarks_menu_show(GtkWidget *menu, gpointer user_data) { } g_list_free(children); - /* Get current bookmarks. */ - int dir_count = 0; - const bookmark_dir_t *dirs = bookmarks_get_dirs(&dir_count); + /* Get current bookmarks (flattened from the tree). */ + GPtrArray *bms = g_ptr_array_new(); + collect_all_bookmarks(bookmarks_get_root(), bms); - int total_bookmarks = 0; - for (int i = 0; i < dir_count; i++) { - total_bookmarks += dirs[i].count; - } - - if (total_bookmarks == 0) { + if (bms->len == 0) { GtkWidget *empty = gtk_menu_item_new_with_label("(no bookmarks)"); gtk_widget_set_sensitive(empty, FALSE); gtk_menu_shell_append(GTK_MENU_SHELL(menu), empty); } else { - /* Show bookmarks grouped by directory (up to 15 total). */ + /* Show up to 15 bookmarks (flattened tree order). */ int shown = 0; - for (int i = 0; i < dir_count && shown < 15; i++) { - const bookmark_dir_t *dir = &dirs[i]; - if (dir->count == 0) continue; - - /* Directory label (non-clickable). */ - char label[160]; - snprintf(label, sizeof(label), "— %s —", dir->name); - GtkWidget *dir_item = gtk_menu_item_new_with_label(label); - gtk_widget_set_sensitive(dir_item, FALSE); - gtk_menu_shell_append(GTK_MENU_SHELL(menu), dir_item); - - for (int j = 0; j < dir->count && shown < 15; j++) { - const bookmark_t *bm = &dir->bookmarks[j]; - /* Use the title if available, otherwise the URL. */ - const char *label_text = bm->title[0] ? bm->title : bm->url; - char short_label[80]; - if (strlen(label_text) > 75) { - snprintf(short_label, sizeof(short_label), "%.72s...", - label_text); - } else { - snprintf(short_label, sizeof(short_label), "%s", - label_text); - } - GtkWidget *bm_item = gtk_menu_item_new_with_label(short_label); - gtk_widget_set_tooltip_text(bm_item, bm->url); - /* Store the URL in a g_strdup'd string attached to the item. */ - char *url_copy = g_strdup(bm->url); - g_signal_connect_data(bm_item, "activate", - G_CALLBACK(on_bookmark_item_clicked), url_copy, - (GClosureNotify)closure_notify_g_free, 0); - gtk_menu_shell_append(GTK_MENU_SHELL(menu), bm_item); - shown++; + for (guint i = 0; i < bms->len && shown < 15; i++) { + const bookmark_t *bm = (const bookmark_t *)g_ptr_array_index(bms, i); + const char *label_text = (bm->title && bm->title[0]) ? bm->title : bm->url; + char short_label[80]; + if (strlen(label_text) > 75) { + snprintf(short_label, sizeof(short_label), "%.72s...", + label_text); + } else { + snprintf(short_label, sizeof(short_label), "%s", + label_text); } + GtkWidget *bm_item = gtk_menu_item_new_with_label(short_label); + gtk_widget_set_tooltip_text(bm_item, bm->url); + char *url_copy = g_strdup(bm->url); + g_signal_connect_data(bm_item, "activate", + G_CALLBACK(on_bookmark_item_clicked), url_copy, + (GClosureNotify)closure_notify_g_free, 0); + gtk_menu_shell_append(GTK_MENU_SHELL(menu), bm_item); + shown++; } } + g_ptr_array_free(bms, TRUE); gtk_menu_shell_append(GTK_MENU_SHELL(menu), gtk_separator_menu_item_new()); @@ -2126,9 +2120,21 @@ static void on_bookmarks_menu_show(GtkWidget *menu, gpointer user_data) { gtk_widget_show_all(menu); } +/* Recursively collect all folder paths in the bookmark tree into `out`. + * The root node itself is skipped (its path is ""). */ +static void collect_folder_paths(const bookmark_node_t *node, GPtrArray *out) { + if (node == NULL) return; + for (int i = 0; i < node->child_count; i++) { + const bookmark_node_t *child = &node->children[i]; + g_ptr_array_add(out, g_strdup(child->path)); + collect_folder_paths(child, out); + } +} + /* Called when the bookmark button (toolbar) is clicked. - * Shows a dialog to pick or create a directory, then bookmarks the - * current page. */ + * Shows a dialog to pick or create a folder path, then bookmarks the + * current page. The picker lists all existing folder paths (including + * nested ones like "Work/Projects") and lets the user type a new path. */ static void on_bookmark_clicked(GtkButton *btn, gpointer user_data) { (void)btn; tab_info_t *tab = (tab_info_t *)user_data; @@ -2151,7 +2157,7 @@ static void on_bookmark_clicked(GtkButton *btn, gpointer user_data) { const gchar *title = webkit_web_view_get_title(tab->webview); if (title == NULL) title = ""; - /* Build the directory picker dialog. */ + /* Build the folder picker dialog. */ GtkWidget *dialog = gtk_dialog_new_with_buttons( "Bookmark Page", g_window, GTK_DIALOG_MODAL | GTK_DIALOG_DESTROY_WITH_PARENT, @@ -2175,22 +2181,24 @@ static void on_bookmark_clicked(GtkButton *btn, gpointer user_data) { gtk_widget_set_halign(lbl, GTK_ALIGN_START); gtk_box_pack_start(GTK_BOX(content), lbl, FALSE, FALSE, 8); - /* Directory combo box. */ - GtkWidget *dir_label = gtk_label_new("Directory:"); + /* Folder combo box (with entry so the user can type a new path). */ + GtkWidget *dir_label = gtk_label_new("Folder path:"); gtk_widget_set_halign(dir_label, GTK_ALIGN_START); gtk_box_pack_start(GTK_BOX(content), dir_label, FALSE, FALSE, 4); GtkWidget *combo = gtk_combo_box_text_new_with_entry(); - int dir_count = 0; - const bookmark_dir_t *dirs = bookmarks_get_dirs(&dir_count); - for (int i = 0; i < dir_count; i++) { - gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(combo), - dirs[i].name); - } - /* Default to "General" if it exists, otherwise the first directory. */ + GPtrArray *paths = g_ptr_array_new(); + const bookmark_node_t *root = bookmarks_get_root(); + collect_folder_paths(root, paths); int default_idx = 0; - for (int i = 0; i < dir_count; i++) { - if (strcmp(dirs[i].name, "General") == 0) { default_idx = i; break; } + for (guint i = 0; i < paths->len; i++) { + const char *p = (const char *)g_ptr_array_index(paths, i); + gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(combo), p); + /* Default to "Bookmarks Bar" (always visible in the toolbar) so + * new users don't have to know the exact folder name. Fall back + * to "General" if "Bookmarks Bar" isn't present for some reason. */ + if (strcmp(p, "Bookmarks Bar") == 0) default_idx = (int)i; + else if (strcmp(p, "General") == 0 && default_idx == 0) default_idx = (int)i; } gtk_combo_box_set_active(GTK_COMBO_BOX(combo), default_idx); gtk_box_pack_start(GTK_BOX(content), combo, FALSE, FALSE, 4); @@ -2203,34 +2211,33 @@ static void on_bookmark_clicked(GtkButton *btn, gpointer user_data) { gint response = gtk_dialog_run(GTK_DIALOG(dialog)); if (response == GTK_RESPONSE_ACCEPT) { - /* Get the selected/entered directory name. */ - const char *dir_name = gtk_combo_box_text_get_active_text( + const char *path = gtk_combo_box_text_get_active_text( GTK_COMBO_BOX_TEXT(combo)); - if (dir_name == NULL || dir_name[0] == '\0') { - dir_name = "General"; - } + if (path == NULL || path[0] == '\0') path = "General"; - char *dir_copy = g_strdup(dir_name); + char *path_copy = g_strdup(path); char *url_copy = g_strdup(url); char *title_copy = g_strdup(title); - /* Add the bookmark. This encrypts and publishes in the caller's - * thread — for now that's the main thread (the publish is - * synchronous and may block briefly). A future improvement would - * run this in a background thread. */ - int rc = bookmarks_add(dir_copy, url_copy, title_copy); + int rc = bookmarks_add(path_copy, url_copy, title_copy); if (rc == 0) { g_print("[bookmarks] Bookmarked '%s' to '%s'\n", url_copy, - dir_copy); + path_copy); } else { g_printerr("[bookmarks] Failed to bookmark (no signer?)\n"); } - g_free(dir_copy); + g_free(path_copy); g_free(url_copy); g_free(title_copy); } + /* Free the paths array. */ + for (guint i = 0; i < paths->len; i++) { + g_free(g_ptr_array_index(paths, i)); + } + g_ptr_array_free(paths, TRUE); + gtk_widget_destroy(dialog); } @@ -2479,6 +2486,150 @@ static GtkWidget *tab_find_notebook(GtkWidget *page) { return NULL; } +/* ── Bookmarks toolbar ─────────────────────────────────────────────── */ + +/* Forward decl — defined below tab_create. */ +static void on_bookmark_bar_clicked(GtkButton *btn, gpointer user_data); +static void bookmark_bar_refresh(tab_info_t *tab); +static void bookmark_bar_refresh_all(void *user_data); +static int g_bookmark_bar_subscribed = 0; + +/* Click handler for a bookmark button in the bar. user_data is the URL + * (g_strdup'd; freed via g_object_set_data_full destroy notify). */ +static void on_bookmark_bar_clicked(GtkButton *btn, gpointer user_data) { + (void)btn; + const char *url = (const char *)user_data; + if (url == NULL || url[0] == '\0') return; + + /* Find the tab that owns this button by walking the bar's parent. */ + GtkWidget *bar = gtk_widget_get_parent(GTK_WIDGET(btn)); + if (bar == NULL) return; + GtkWidget *page = gtk_widget_get_parent(bar); + if (page == NULL) return; + + tab_info_t *target = NULL; + for (int i = 0; i < g_tab_count; i++) { + if (g_tabs[i] && g_tabs[i]->page == page) { + target = g_tabs[i]; + break; + } + } + if (target == NULL || target->webview == NULL) return; + + webkit_web_view_load_uri(target->webview, url); + if (target->url_entry) { + gtk_entry_set_text(GTK_ENTRY(target->url_entry), url); + } +} + +/* Recursively add a folder's bookmarks (and one level of subfolders as + * GtkMenuButton popovers) to a container. */ +static void bookmark_bar_add_folder(GtkWidget *container, + const bookmark_node_t *node) { + if (node == NULL) return; + + /* Bookmarks in this folder. */ + for (int i = 0; i < node->bookmark_count; i++) { + const bookmark_t *bm = &node->bookmarks[i]; + const char *label = (bm->title && bm->title[0]) ? bm->title : bm->url; + GtkWidget *btn = gtk_button_new_with_label(label); + gtk_button_set_relief(GTK_BUTTON(btn), GTK_RELIEF_NONE); + gtk_widget_set_tooltip_text(btn, bm->url); + char *url_copy = g_strdup(bm->url); + g_object_set_data_full(G_OBJECT(btn), "bm-url", url_copy, + (GDestroyNotify)g_free); + g_signal_connect(btn, "clicked", + G_CALLBACK(on_bookmark_bar_clicked), url_copy); + gtk_box_pack_start(GTK_BOX(container), btn, FALSE, FALSE, 0); + } + + /* Subfolders as menu buttons with popover menus. */ + for (int i = 0; i < node->child_count; i++) { + const bookmark_node_t *child = &node->children[i]; + GtkWidget *menu = gtk_menu_new(); + + /* Add the subfolder's bookmarks to the menu. */ + for (int j = 0; j < child->bookmark_count; j++) { + const bookmark_t *bm = &child->bookmarks[j]; + const char *label = (bm->title && bm->title[0]) ? bm->title : bm->url; + GtkWidget *item = gtk_menu_item_new_with_label(label); + char *url_copy = g_strdup(bm->url); + g_object_set_data_full(G_OBJECT(item), "bm-url", url_copy, + (GDestroyNotify)g_free); + /* We need the tab to load into; defer to click handler which + * finds the tab from the menu's toplevel. For simplicity, use + * the same on_bookmark_bar_clicked — it walks parents. */ + g_signal_connect(item, "activate", + G_CALLBACK(on_bookmark_bar_clicked), url_copy); + gtk_menu_shell_append(GTK_MENU_SHELL(menu), item); + } + /* Recurse one level deeper for sub-subfolders (as submenus). */ + for (int j = 0; j < child->child_count; j++) { + const bookmark_node_t *grand = &child->children[j]; + GtkWidget *submenu = gtk_menu_new(); + for (int k = 0; k < grand->bookmark_count; k++) { + const bookmark_t *bm = &grand->bookmarks[k]; + const char *label = (bm->title && bm->title[0]) ? bm->title : bm->url; + GtkWidget *item = gtk_menu_item_new_with_label(label); + char *url_copy = g_strdup(bm->url); + g_object_set_data_full(G_OBJECT(item), "bm-url", url_copy, + (GDestroyNotify)g_free); + g_signal_connect(item, "activate", + G_CALLBACK(on_bookmark_bar_clicked), url_copy); + gtk_menu_shell_append(GTK_MENU_SHELL(submenu), item); + } + GtkWidget *sub_item = gtk_menu_item_new_with_label(child->children[j].name); + gtk_menu_item_set_submenu(GTK_MENU_ITEM(sub_item), submenu); + gtk_menu_shell_append(GTK_MENU_SHELL(menu), sub_item); + } + + GtkWidget *mb = gtk_menu_button_new(); + gtk_button_set_label(GTK_BUTTON(mb), child->name); + gtk_menu_button_set_popup(GTK_MENU_BUTTON(mb), menu); + gtk_widget_set_tooltip_text(mb, child->path); + gtk_box_pack_start(GTK_BOX(container), mb, FALSE, FALSE, 0); + } +} + +/* Refresh a single tab's bookmark bar from the current bookmark tree. */ +static void bookmark_bar_refresh(tab_info_t *tab) { + if (tab == NULL || tab->bookmark_bar == NULL) return; + + /* Clear existing children. */ + GList *children = gtk_container_get_children(GTK_CONTAINER(tab->bookmark_bar)); + for (GList *l = children; l != NULL; l = l->next) { + gtk_widget_destroy(GTK_WIDGET(l->data)); + } + g_list_free(children); + + /* Look up the "Bookmarks Bar" folder. It always exists in memory + * (bookmarks_init ensures it), but may be empty until the user adds + * bookmarks to it. Show a friendly hint in that case. */ + const bookmark_node_t *bar_node = bookmarks_find("Bookmarks Bar"); + if (bar_node == NULL || (bar_node->bookmark_count == 0 && bar_node->child_count == 0)) { + GtkWidget *hint = gtk_label_new( + "Bookmarks Bar is empty — bookmark a page and choose \"Bookmarks Bar\" as the folder"); + gtk_widget_set_sensitive(hint, FALSE); + gtk_widget_set_margin_start(hint, 4); + gtk_box_pack_start(GTK_BOX(tab->bookmark_bar), hint, FALSE, FALSE, 0); + gtk_widget_show_all(tab->bookmark_bar); + return; + } + + bookmark_bar_add_folder(tab->bookmark_bar, bar_node); + gtk_widget_show_all(tab->bookmark_bar); +} + +/* Callback invoked by bookmarks_subscribe_changed: refresh every open tab. */ +static void bookmark_bar_refresh_all(void *user_data) { + (void)user_data; + for (int i = 0; i < g_tab_count; i++) { + if (g_tabs[i] != NULL) { + bookmark_bar_refresh(g_tabs[i]); + } + } +} + static tab_info_t *tab_create(const char *url) { const browser_settings_t *s = settings_get(); @@ -2672,6 +2823,18 @@ static tab_info_t *tab_create(const char *url) { G_CALLBACK(on_bookmark_clicked), tab); gtk_box_pack_start(GTK_BOX(toolbar), bookmark_btn, FALSE, FALSE, 0); + /* Bookmarks toolbar — a horizontal bar below the URL toolbar showing + * buttons for the bookmarks in the "Bookmarks Bar" folder, plus + * GtkMenuButton popovers for subfolders. Refreshed whenever bookmarks + * change (see bookmark_bar_refresh_all via bookmarks_subscribe_changed). */ + tab->bookmark_bar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4); + gtk_widget_set_margin_top(tab->bookmark_bar, 2); + gtk_widget_set_margin_bottom(tab->bookmark_bar, 2); + gtk_widget_set_margin_start(tab->bookmark_bar, 4); + gtk_widget_set_margin_end(tab->bookmark_bar, 4); + gtk_box_pack_start(GTK_BOX(tab->page), tab->bookmark_bar, FALSE, FALSE, 0); + bookmark_bar_refresh(tab); + /* Ensure the webview expands vertically to fill the available space. * Without this, WebKitGTK may only allocate 1px of height on some * display servers, resulting in a blank page. */ @@ -3040,6 +3203,14 @@ void tab_manager_init(GtkContainer *parent, g_ctx = ctx; g_window = window; + /* Subscribe to bookmark changes so every open tab's bookmark bar + * refreshes when bookmarks are added/moved/deleted/loaded. Registered + * once here; the callback iterates all open tabs. */ + if (!g_bookmark_bar_subscribed) { + bookmarks_subscribe_changed(bookmark_bar_refresh_all, NULL); + g_bookmark_bar_subscribed = 1; + } + g_notebook = gtk_notebook_new(); /* Disable scrolling so tabs share the available width evenly instead * of showing a scrollbar when there are many tabs. */ diff --git a/src/tab_manager.h b/src/tab_manager.h index ebe9b70..01ff8e3 100644 --- a/src/tab_manager.h +++ b/src/tab_manager.h @@ -29,6 +29,7 @@ typedef struct { GtkWidget *title_label; /* child of tab_label */ GtkWidget *favicon; /* favicon image in tab_label */ GtkWidget *refresh_btn; /* toolbar reload/stop button (per-tab) */ + GtkWidget *bookmark_bar; /* bookmarks toolbar (below URL toolbar) */ gboolean refresh_shows_stop; /* cached visual state, avoids redundant updates */ char current_url[TAB_URL_MAX]; char title[TAB_TITLE_MAX]; diff --git a/src/version.h b/src/version.h index 4c287ca..d79fbb3 100644 --- a/src/version.h +++ b/src/version.h @@ -11,9 +11,9 @@ #ifndef SOVEREIGN_BROWSER_VERSION_H #define SOVEREIGN_BROWSER_VERSION_H -#define SB_VERSION "v0.0.44" +#define SB_VERSION "v0.0.45" #define SB_VERSION_MAJOR 0 #define SB_VERSION_MINOR 0 -#define SB_VERSION_PATCH 44 +#define SB_VERSION_PATCH 45 #endif /* SOVEREIGN_BROWSER_VERSION_H */ diff --git a/tests/test_bookmarks_tree.c b/tests/test_bookmarks_tree.c new file mode 100644 index 0000000..bc33ce7 --- /dev/null +++ b/tests/test_bookmarks_tree.c @@ -0,0 +1,144 @@ +/* + * test_bookmarks_tree.c — unit tests for the bookmark tree/HMAC d-tag logic + * + * Tests the pure pieces that don't require a signer, database, or relay + * connection: + * - HMAC-SHA256 d-tag derivation is deterministic (same key + path → same hex) + * - HMAC-SHA256 d-tag derivation is opaque (different paths → uncorrelated) + * - is_hmac_d_tag correctly distinguishes 64-hex HMAC tags from legacy + * plaintext directory names + * + * The full trie operations (add/remove/move/rename/delete) require a signer + * and database, so they are exercised via the browser's MCP tools at runtime + * rather than in this unit test. + * + * Build (standalone, links against nostr_core_lib): + * cc -std=c99 -Wall -Wextra -g -I./nostr_core_lib \ + * tests/test_bookmarks_tree.c \ + * nostr_core_lib/libnostr_core_x64.a \ + * -lsecp256k1 -lssl -lcrypto -lm -o tests/test_bookmarks_tree + * ./tests/test_bookmarks_tree + */ + +#include +#include +#include +#include "../nostr_core_lib/nostr_core/nostr_core.h" + +static int failures = 0; +static int passes = 0; + +#define CHECK(cond, msg) do { \ + if (cond) { passes++; } \ + else { failures++; fprintf(stderr, "FAIL: %s\n", msg); } \ +} while (0) + +/* Mirror of BOOKMARKS_HMAC_KEY_LABEL in src/bookmarks.c. */ +#define LABEL "sovereign-browser/bookmarks-folder-id-v1" + +/* Mirror of path_to_d_tag + compute_hmac_key in src/bookmarks.c. */ +static char *path_to_d_tag(const unsigned char *privkey, const char *path) { + unsigned char hmac_key[32]; + if (nostr_hmac_sha256(privkey, 32, + (const unsigned char *)LABEL, strlen(LABEL), + hmac_key) != 0) { + return NULL; + } + unsigned char mac[32]; + if (nostr_hmac_sha256(hmac_key, 32, + (const unsigned char *)path, strlen(path), + mac) != 0) { + return NULL; + } + char *hex = malloc(65); + if (hex == NULL) return NULL; + nostr_bytes_to_hex(mac, 32, hex); + hex[64] = '\0'; + return hex; +} + +/* Mirror of is_hmac_d_tag in src/bookmarks.c. */ +static int is_hmac_d_tag(const char *s) { + if (s == NULL || strlen(s) != 64) return 0; + for (const char *p = s; *p; p++) { + if (!((*p >= '0' && *p <= '9') || (*p >= 'a' && *p <= 'f'))) + return 0; + } + return 1; +} + +int main(void) { + /* Use a fixed test privkey (32 bytes of 0x01..0x20). */ + unsigned char privkey[32]; + for (int i = 0; i < 32; i++) privkey[i] = (unsigned char)(i + 1); + + /* ── Determinism: same path → same d tag ────────────────────────── */ + char *d1 = path_to_d_tag(privkey, "Work/Projects/Secret"); + char *d2 = path_to_d_tag(privkey, "Work/Projects/Secret"); + CHECK(d1 != NULL, "path_to_d_tag returned NULL"); + CHECK(d2 != NULL, "path_to_d_tag returned NULL (2nd call)"); + CHECK(d1 && d2 && strcmp(d1, d2) == 0, + "Same path should produce the same d tag (determinism)"); + free(d1); + free(d2); + + /* ── Opaqueness: different paths → different d tags ─────────────── */ + char *da = path_to_d_tag(privkey, "Work"); + char *db = path_to_d_tag(privkey, "Work/Projects"); + char *dc = path_to_d_tag(privkey, "Personal"); + CHECK(da && db && strcmp(da, db) != 0, + "Different paths should produce different d tags (no prefix leakage)"); + CHECK(da && dc && strcmp(da, dc) != 0, + "Different paths should produce different d tags (2)"); + /* The d tag should NOT contain the plaintext path. */ + CHECK(da && strstr(da, "Work") == NULL, + "d tag should not leak the plaintext path"); + CHECK(db && strstr(db, "Work") == NULL, + "d tag should not leak the plaintext path (child)"); + free(da); + free(db); + free(dc); + + /* ── d tag is 64 lowercase hex chars ────────────────────────────── */ + char *d = path_to_d_tag(privkey, "General"); + CHECK(d != NULL, "path_to_d_tag returned NULL for General"); + CHECK(d && strlen(d) == 64, "d tag should be 64 hex chars"); + CHECK(d && is_hmac_d_tag(d), "d tag should pass is_hmac_d_tag"); + free(d); + + /* ── is_hmac_d_tag distinguishes legacy names ───────────────────── */ + CHECK(is_hmac_d_tag("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + "64 lowercase hex should be recognized as an HMAC d tag"); + CHECK(!is_hmac_d_tag("General"), + "Legacy plaintext 'General' should NOT be recognized as an HMAC d tag"); + CHECK(!is_hmac_d_tag("Work/Projects"), + "Legacy plaintext path with slash should NOT be recognized as an HMAC d tag"); + CHECK(!is_hmac_d_tag(""), + "Empty string should NOT be recognized as an HMAC d tag"); + CHECK(!is_hmac_d_tag(NULL), + "NULL should NOT be recognized as an HMAC d tag"); + /* Uppercase hex is not produced by nostr_bytes_to_hex (lowercase), so + * treat it as non-HMAC (legacy). */ + CHECK(!is_hmac_d_tag("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"), + "Uppercase hex should NOT be recognized as an HMAC d tag (we emit lowercase)"); + + /* ── Different privkeys → different d tags for the same path ────── */ + unsigned char privkey2[32]; + for (int i = 0; i < 32; i++) privkey2[i] = (unsigned char)(255 - i); + char *e1 = path_to_d_tag(privkey, "General"); + char *e2 = path_to_d_tag(privkey2, "General"); + CHECK(e1 && e2 && strcmp(e1, e2) != 0, + "Different privkeys should produce different d tags for the same path"); + free(e1); + free(e2); + + /* ── Empty path is allowed (root) ───────────────────────────────── */ + char *root_d = path_to_d_tag(privkey, ""); + CHECK(root_d != NULL, "path_to_d_tag should handle empty path"); + CHECK(root_d && strlen(root_d) == 64, + "Empty path d tag should still be 64 hex chars"); + free(root_d); + + printf("bookmarks tree tests: %d passed, %d failed\n", passes, failures); + return failures == 0 ? 0 : 1; +} diff --git a/www/bookmarks.css b/www/bookmarks.css index 208c588..a4173bc 100644 --- a/www/bookmarks.css +++ b/www/bookmarks.css @@ -4,5 +4,68 @@ * sovereign://sovereign-base.css. */ @import url("sovereign://sovereign-base.css"); -/* Bookmarks-specific tweaks (most layout is already in base .bm/.form/.dir-header). */ -#bm-dir { min-width: 160px; } +/* ── Tree view ─────────────────────────────────────────────────────── */ + +.tree-node { + margin: 2px 0; +} + +.folder-row { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 0; + border-bottom: 1px solid var(--border); + flex-wrap: wrap; +} + +.caret { + display: inline-block; + width: 14px; + cursor: pointer; + user-select: none; + color: var(--primary); + font-size: 10px; + transition: transform 0.15s ease; + transform: rotate(0deg); + flex-shrink: 0; +} + +.caret.expanded { + transform: rotate(90deg); +} + +.folder-icon { + font-size: 16px; + flex-shrink: 0; +} + +.folder-name { + font-weight: bold; + color: var(--primary); + flex-shrink: 0; +} + +.folder-actions { + display: flex; + gap: 6px; + margin-left: auto; + flex-wrap: wrap; +} + +/* Smaller buttons in folder actions so the row stays compact. */ +.folder-actions .btn { + padding: 3px 10px; + font-size: 11px; + margin-left: 0; +} + +.tree-children { + border-left: 1px dashed var(--border); + padding-left: 4px; +} + +/* Reuse .bm / .bm-title / .bm-url / .bm-meta / .actions / .btn / .btn-del + * from the base theme for bookmark rows. */ + +#bm-path, #new-path { min-width: 200px; } diff --git a/www/bookmarks.html b/www/bookmarks.html index eb6e686..9e91e36 100644 --- a/www/bookmarks.html +++ b/www/bookmarks.html @@ -19,17 +19,17 @@
      - +
      - - + +

Bookmarks

-
+
diff --git a/www/bookmarks.js b/www/bookmarks.js index 9bf3074..fb6119c 100644 --- a/www/bookmarks.js +++ b/www/bookmarks.js @@ -1,8 +1,13 @@ /* Bookmarks page — sovereign://bookmarks * - * Fetches the bookmark directory list from sovereign://bookmarks/list - * (JSON) and renders it. Add/delete/create-dir use the existing - * sovereign://bookmarks/{add,delete,createdir,deletedir} endpoints. + * Fetches the bookmark tree from sovereign://bookmarks/list (JSON) and + * renders it as an expandable/collapsible tree. Folders may be nested + * arbitrarily (e.g. "Work/Projects/Secret"). Add/delete/create/move/rename + * use the sovereign://bookmarks/{add,delete,createdir,deletedir,move,renamedir} + * endpoints. + * + * Expand/collapse state is persisted in localStorage so the user's + * expanded folders survive page reloads. */ function sovereignGet(url) { return new Promise(function(resolve, reject) { @@ -24,75 +29,237 @@ function esc(s) { return d.innerHTML; } -function renderBookmarks(data) { +/* ── Expand/collapse state (persisted in localStorage) ─────────────── */ + +var EXPANDED_KEY = 'sovereign_bookmarks_expanded'; +function getExpanded() { + try { + var raw = localStorage.getItem(EXPANDED_KEY); + return raw ? JSON.parse(raw) : {}; + } catch (e) { return {}; } +} +function setExpanded(path, isExpanded) { + var state = getExpanded(); + if (isExpanded) state[path] = true; + else delete state[path]; + try { localStorage.setItem(EXPANDED_KEY, JSON.stringify(state)); } + catch (e) { /* ignore quota errors */ } +} + +/* ── Tree rendering ────────────────────────────────────────────────── */ + +function renderTree(data) { var haveSigner = !!data.have_signer; document.getElementById('readonly-note').style.display = haveSigner ? 'none' : ''; document.getElementById('add-form').style.display = haveSigner ? '' : 'none'; - /* Populate the directory