1104 lines
39 KiB
C
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;
|
|
}
|