Files
sovereign_browser/src/bookmarks.c
T

588 lines
20 KiB
C

/*
* bookmarks.c — NIP-44 encrypted Nostr bookmarks with directories
*
* 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:
*
* [["bookmark", "https://example.com", "Example", 1690000000], ...]
*
* 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.
*/
#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_dir_t *g_dirs = NULL;
static int g_dir_count = 0;
static int g_dir_cap = 0;
/* ── Helpers ───────────────────────────────────────────────────────── */
/* 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;
}
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;
}
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;
}
/* 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);
}
g_free(dir->bookmarks);
dir->bookmarks = NULL;
dir->count = 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;
}
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) {
cJSON *arr = cJSON_CreateArray();
for (int i = 0; i < dir->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_CreateNumber(dir->bookmarks[i].added));
cJSON_AddItemToArray(arr, tag);
}
char *json = cJSON_PrintUnformatted(arr);
cJSON_Delete(arr);
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);
int count = cJSON_GetArraySize(arr);
if (count > BOOKMARKS_MAX_PER_DIR) count = BOOKMARKS_MAX_PER_DIR;
dir->bookmarks = g_new0(bookmark_t, count);
dir->count = 0;
cJSON *tag;
cJSON_ArrayForEach(tag, arr) {
if (dir->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(
(t2 && cJSON_IsString(t2)) ? t2->valuestring : "");
dir->bookmarks[dir->count].added =
(t3 && cJSON_IsNumber(t3)) ? (long)t3->valuedouble : 0;
dir->count++;
}
cJSON_Delete(arr);
return 0;
}
/* ── 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);
if (rc != NOSTR_SUCCESS || ciphertext == NULL) {
g_printerr("[bookmarks] NIP-44 encrypt failed: %d\n", rc);
return NULL;
}
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);
if (rc != NOSTR_SUCCESS || plaintext == NULL) {
g_printerr("[bookmarks] NIP-44 decrypt failed: %d\n", rc);
return NULL;
}
return plaintext;
}
/* ── Publish ───────────────────────────────────────────────────────── */
/* 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);
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 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) {
if (!g_have_signer) {
g_printerr("[bookmarks] No signer, cannot publish\n");
return -1;
}
int idx = find_dir(dir_name);
if (idx < 0) {
g_printerr("[bookmarks] Directory '%s' not found\n", dir_name);
return -1;
}
/* Serialize and encrypt. */
char *json = dir_to_json(&g_dirs[idx]);
if (json == NULL) return -1;
char *ciphertext = encrypt_content(json);
g_free(json);
if (ciphertext == NULL) 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);
/* Create and sign the event. */
cJSON *event = nostr_create_and_sign_event_with_signer(
30003, ciphertext, tags, g_signer, time(NULL));
g_free(ciphertext);
if (event == NULL) {
g_printerr("[bookmarks] Failed to create/sign event\n");
cJSON_Delete(tags);
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);
} else {
g_print("[bookmarks] No relays configured, event stored locally only\n");
}
g_free(relay_buf);
cJSON_Delete(event);
return 0;
}
/* ── 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';
}
/* 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);
cJSON *event;
cJSON_ArrayForEach(event, events) {
/* Get the d tag to find the directory name. */
const char *dir_name = "General";
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)) {
dir_name = 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);
}
}
}
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");
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;
g_signer = NULL;
g_have_signer = 0;
g_pubkey[0] = '\0';
}
const bookmark_dir_t *bookmarks_get_dirs(int *count_out) {
if (count_out) *count_out = g_dir_count;
return g_dirs;
}
const bookmark_dir_t *bookmarks_get_dir(const char *name) {
int idx = find_dir(name);
return (idx >= 0) ? &g_dirs[idx] : NULL;
}
int bookmarks_add(const char *dir, 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 (url == NULL || url[0] == '\0') return -1;
int idx = ensure_dir(dir);
if (idx < 0) 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);
return 0;
}
/* Check capacity. */
if (g_dirs[idx].count >= BOOKMARKS_MAX_PER_DIR) {
g_printerr("[bookmarks] Directory '%s' is full\n", dir);
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++;
g_print("[bookmarks] Added '%s' to '%s'\n", url, dir);
/* Publish. */
return publish_directory(dir);
}
int bookmarks_remove(const char *dir, const char *url) {
if (!g_have_signer) return -1;
if (dir == NULL) dir = "General";
int idx = find_dir(dir);
if (idx < 0) return -1;
int bidx = find_bookmark_in_dir(&g_dirs[idx], 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_dirs[idx].count--;
g_print("[bookmarks] Removed '%s' from '%s'\n", url, dir);
return publish_directory(dir);
}
int bookmarks_move(const char *from_dir, const char *url, const char *to_dir) {
if (!g_have_signer) return -1;
if (from_dir == NULL) from_dir = "General";
if (to_dir == NULL) to_dir = "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);
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;
/* 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_dirs[from_idx].count--;
/* Add to destination. */
int to_idx = ensure_dir(to_dir);
if (to_idx < 0) {
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++;
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);
}
int bookmarks_create_dir(const char *name) {
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);
return -1;
}
int idx = ensure_dir(name);
if (idx < 0) return -1;
g_print("[bookmarks] Created directory '%s'\n", name);
return publish_directory(name);
}
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;
}
int idx = find_dir(name);
if (idx < 0) return -1;
/* 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");
}
}
/* 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. */
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_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";
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)) dir_name = 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);
}
}
return 0;
}