Files
sovereign_browser/src/bookmarks.c
T

1104 lines
39 KiB
C

/*
* bookmarks.c — NIP-44 encrypted Nostr bookmarks with nested folders
*
* Stores bookmarks as NIP-51 kind 30003 (bookmark sets) events, one per
* folder. Folders may be nested arbitrarily (e.g. "Work/Projects/Secret").
*
* 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)
*
* 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"
#include "db.h"
#include "settings.h"
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include "nostr_core/nostr_core.h"
#include "../nostr_core_lib/cjson/cJSON.h"
/* ── Global state ──────────────────────────────────────────────────── */
static nostr_signer_t *g_signer = NULL;
static char g_pubkey[65] = {0};
static int g_have_signer = 0;
static bookmark_node_t g_root = {0};
/* 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;
}
/* 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];
}
parent->child_count--;
}
/* ── HMAC d-tag derivation ─────────────────────────────────────────── */
/* Compute the opaque d tag for a path using a single-step HMAC keyed by the
* signer's secp256k1 private key:
* d = HMAC-SHA256(privkey, BOOKMARKS_HMAC_KEY_LABEL + ":" + path) → hex
*
* This is computed remotely via nostr_signer_derive_hmac, so the privkey
* never leaves the signer (n_signer or local). The label prefix provides
* domain separation so the same path used by a different application
* produces a different d tag.
*
* Returns a newly allocated 64-char hex string (caller frees), or NULL.
*
* If no signer is available, falls back to the legacy plaintext d tag
* (the path itself) so bookmarks can still be published in read-only mode.
* The loader recognises both formats. */
static char *path_to_d_tag(const char *path) {
if (path == NULL) path = "";
if (!g_have_signer || g_signer == NULL) {
/* No signer — use the plaintext path as the d tag (read-only mode). */
return g_strdup(path);
}
/* Build "LABEL:path" — single-step domain-separated HMAC input. */
char *data = g_strdup_printf("%s:%s", BOOKMARKS_HMAC_KEY_LABEL, path);
if (data == NULL) return NULL;
char digest_hex[65];
int rc = nostr_signer_derive_hmac(g_signer, data, digest_hex);
g_free(data);
if (rc != NOSTR_SUCCESS) {
g_printerr("[bookmarks] derive_hmac failed (rc=%d); falling back to plaintext d tag\n", rc);
return g_strdup(path);
}
return g_strdup(digest_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 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 < node->bookmark_count; i++) {
cJSON *tag = cJSON_CreateArray();
cJSON_AddItemToArray(tag, cJSON_CreateString("bookmark"));
cJSON_AddItemToArray(tag, cJSON_CreateString(node->bookmarks[i].url));
cJSON_AddItemToArray(tag, cJSON_CreateString(node->bookmarks[i].title));
cJSON_AddItemToArray(tag,
cJSON_CreateNumber((double)node->bookmarks[i].added));
cJSON_AddItemToArray(arr, tag);
}
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 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_NODE) count = BOOKMARKS_MAX_PER_NODE;
node->bookmarks = g_new0(bookmark_t, count);
node->bookmark_count = 0;
cJSON *tag;
cJSON_ArrayForEach(tag, arr) {
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;
node->bookmarks[node->bookmark_count].url = g_strdup(t1->valuestring);
node->bookmarks[node->bookmark_count].title = g_strdup(
(t2 && cJSON_IsString(t2)) ? t2->valuestring : "");
node->bookmarks[node->bookmark_count].added =
(t3 && cJSON_IsNumber(t3)) ? (long)t3->valuedouble : 0;
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;
}
/* 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 ────────────────────────────────── */
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);
if (rc != NOSTR_SUCCESS || ciphertext == NULL) {
g_printerr("[bookmarks] NIP-44 encrypt failed: %d\n", rc);
return NULL;
}
return ciphertext;
}
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);
if (rc != NOSTR_SUCCESS || plaintext == NULL) {
g_printerr("[bookmarks] NIP-44 decrypt failed: %d\n", rc);
return NULL;
}
return plaintext;
}
/* ── Relay publish helpers ─────────────────────────────────────────── */
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);
int count = 0;
char *saveptr = NULL;
char *line = strtok_r(*relay_buf, "\n\r", &saveptr);
while (line != NULL && count < max) {
while (*line == ' ' || *line == '\t') line++;
if (line[0] != '\0' &&
(strncmp(line, "wss://", 6) == 0 ||
strncmp(line, "ws://", 5) == 0)) {
urls_out[count++] = line;
}
line = strtok_r(NULL, "\n\r", &saveptr);
}
return count;
}
/* 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;
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;
}
char *content_json = node_to_content_json(node);
if (content_json == NULL) { g_free(d_tag); return -1; }
char *ciphertext = encrypt_content(content_json);
g_free(content_json);
if (ciphertext == NULL) { g_free(d_tag); return -1; }
cJSON *tags = cJSON_CreateArray();
cJSON *d = cJSON_CreateArray();
cJSON_AddItemToArray(d, cJSON_CreateString("d"));
cJSON_AddItemToArray(d, cJSON_CreateString(d_tag));
cJSON_AddItemToArray(tags, d);
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");
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_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");
}
g_free(relay_buf);
cJSON_Delete(event);
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:<pubkey>:<d-tag> */
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) {
g_signer = signer;
g_have_signer = (signer != NULL);
if (pubkey_hex) {
snprintf(g_pubkey, sizeof(g_pubkey), "%s", pubkey_hex);
} else {
g_pubkey[0] = '\0';
}
/* 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') {
cJSON *events = db_get_events(g_pubkey, 30003, 0);
if (events != NULL) {
int n = cJSON_GetArraySize(events);
g_print("[bookmarks] Loading %d cached folder event(s)\n", n);
cJSON *event;
cJSON_ArrayForEach(event, events) {
/* Get the d tag. */
const char *d_value = "";
cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (cJSON_IsArray(tags)) {
cJSON *tag;
cJSON_ArrayForEach(tag, tags) {
if (!cJSON_IsArray(tag)) continue;
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)) d_value = t1->valuestring;
break;
}
}
}
/* 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;
}
/* Migration: re-publish with the current single-step HMAC d
* tag and delete the old event if the d tag doesn't match.
* This covers both legacy plaintext d tags AND old two-step
* HMAC d tags (from before the single-step switch). */
if (g_have_signer && path != NULL && path[0] != '\0') {
char *new_d = path_to_d_tag(path);
if (new_d != NULL && strcmp(new_d, d_value) != 0) {
g_print("[bookmarks] Migrating folder '%s' to current HMAC d tag\n",
path);
if (leaf) publish_node(leaf);
publish_deletion_for_d(d_value);
}
g_free(new_d);
}
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);
}
}
g_print("[bookmarks] Initialized: signer=%s\n",
g_have_signer ? "yes" : "no");
return 0;
}
void bookmarks_cleanup(void) {
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_sub_count = 0;
}
const bookmark_node_t *bookmarks_get_root(void) {
return (g_root.name != NULL) ? &g_root : NULL;
}
const bookmark_node_t *bookmarks_find(const char *path) {
return node_find_path(&g_root, path);
}
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 (path == NULL || path[0] == '\0') path = "General";
if (url == NULL || url[0] == '\0') return -1;
bookmark_node_t *leaf = node_ensure_path(&g_root, path, NULL);
if (leaf == NULL) return -1;
if (find_bookmark_in_node(leaf, url) >= 0) {
g_print("[bookmarks] URL already bookmarked in '%s'\n", path);
return 0;
}
if (leaf->bookmark_count >= BOOKMARKS_MAX_PER_NODE) {
g_printerr("[bookmarks] Folder '%s' is full\n", path);
return -1;
}
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, path);
int rc = publish_node(leaf);
notify_changed();
return rc;
}
int bookmarks_remove(const char *path, const char *url) {
if (!g_have_signer) return -1;
if (path == NULL || path[0] == '\0') path = "General";
bookmark_node_t *leaf = node_find_path(&g_root, path);
if (leaf == NULL) return -1;
int bidx = find_bookmark_in_node(leaf, url);
if (bidx < 0) return -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];
}
leaf->bookmark_count--;
g_print("[bookmarks] Removed '%s' from '%s'\n", url, path);
int rc = publish_node(leaf);
notify_changed();
return rc;
}
int bookmarks_rename(const char *path, const char *url, const char *new_title) {
if (!g_have_signer) {
g_printerr("[bookmarks] No signer, cannot rename\n");
return -1;
}
if (path == NULL || path[0] == '\0') path = "General";
if (url == NULL || url[0] == '\0') return -1;
bookmark_node_t *leaf = node_find_path(&g_root, path);
if (leaf == NULL) return -1;
int bidx = find_bookmark_in_node(leaf, url);
if (bidx < 0) return -1;
/* Replace the title in place. The URL and added timestamp are
* preserved — only the user-editable label changes. */
char *old_title = leaf->bookmarks[bidx].title;
leaf->bookmarks[bidx].title = g_strdup(new_title ? new_title : "");
g_free(old_title);
g_print("[bookmarks] Renamed bookmark '%s' in '%s'\n", url, path);
int rc = publish_node(leaf);
notify_changed();
return rc;
}
int bookmarks_move(const char *from_path, const char *url, const char *to_path) {
if (!g_have_signer) return -1;
if (from_path == NULL || from_path[0] == '\0') from_path = "General";
if (to_path == NULL || to_path[0] == '\0') to_path = "General";
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;
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;
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];
}
from->bookmark_count--;
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 = 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_path, to_path);
publish_node(from);
int rc = publish_node(to);
notify_changed();
return rc;
}
int bookmarks_create_dir(const char *path) {
if (!g_have_signer) return -1;
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;
g_print("[bookmarks] Created folder '%s'\n", path);
int rc = publish_node(leaf);
notify_changed();
return rc;
}
/* 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));
}
/* Update the node's path in memory. */
g_free(node->path);
node->path = g_strdup(new_path->str);
/* 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);
}
g_string_free(old_path, TRUE);
g_string_free(new_path, TRUE);
return 0;
}
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;
db_store_event(event);
if (!g_have_signer) return 0;
/* Get the d tag. */
const char *d_value = "";
cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (cJSON_IsArray(tags)) {
cJSON *tag;
cJSON_ArrayForEach(tag, tags) {
if (!cJSON_IsArray(tag)) continue;
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)) d_value = t1->valuestring;
break;
}
}
}
cJSON *content = cJSON_GetObjectItemCaseSensitive(event, "content");
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;
}
/* Migration: re-publish with the current single-step HMAC d tag and
* delete the old event if the d tag doesn't match. Covers both legacy
* plaintext d tags and old two-step HMAC d tags. */
if (g_have_signer && path != NULL && path[0] != '\0') {
char *new_d = path_to_d_tag(path);
if (new_d != NULL && strcmp(new_d, d_value) != 0) {
if (leaf) publish_node(leaf);
publish_deletion_for_d(d_value);
}
g_free(new_d);
}
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;
}