Files
sovereign_browser/src/settings_sync.c
T

347 lines
12 KiB
C

/*
* settings_sync.c — NIP-78 (kind 30078) settings sync for sovereign_browser
*
* Syncs whitelisted, device-independent settings + keyboard shortcut
* bindings across all devices the user logs in on. Uses a single kind
* 30078 addressable event with d-tag "sovereign_browser". The content is
* NIP-44 encrypted (self-to-self) JSON.
*
* The publish path reuses the same pattern as bookmarks.c's
* publish_directory(): serialize → NIP-44 encrypt → sign → store in
* SQLite → publish to bootstrap relays.
*
* settings_sync_publish() is debounced (500ms) so rapid edits coalesce.
*/
#include "settings_sync.h"
#include "settings.h"
#include "shortcuts.h"
#include "db.h"
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <time.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;
/* Debounce: a timeout source id for the deferred publish. */
static guint g_publish_timeout_id = 0;
/* db_kv key for the last-synced timestamp. */
#define SYNC_TS_KEY "settings_sync.nostr_synced_at"
/* ── Whitelist of syncable settings ─────────────────────────────────── */
/* Keys from the settings struct that should be synced. Device-specific
* settings (agent port, allowed origins, session restore, security
* toggles) are intentionally excluded. */
static const char *g_sync_setting_keys[] = {
"new_tab_url",
"tab_bar_position",
"show_tab_close_buttons",
"middle_click_close",
"ctrl_tab_switch", /* master shortcuts toggle */
"tab_drag_reorder",
"max_tabs",
"bootstrap_relays",
"search_engine",
};
static const int g_sync_setting_count =
(int)(sizeof(g_sync_setting_keys) / sizeof(g_sync_setting_keys[0]));
/* ── 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);
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;
}
/* 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("[settings_sync] 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("[settings_sync] NIP-44 decrypt failed: %d\n", rc);
return NULL;
}
return plaintext;
}
/* ── Serialization ──────────────────────────────────────────────────── */
/* Serialize all syncable settings + shortcuts to a JSON object:
* {"settings":{...}, "shortcuts":{...}}
* Returns a newly allocated cJSON object (caller must delete). */
static cJSON *serialize_payload(void) {
cJSON *root = cJSON_CreateObject();
/* Settings whitelist. */
cJSON *settings_obj = cJSON_CreateObject();
const browser_settings_t *s = settings_get();
(void)s; /* read via db_kv_get to get string values uniformly */
for (int i = 0; i < g_sync_setting_count; i++) {
const char *key = g_sync_setting_keys[i];
const char *val = db_kv_get(key);
if (val) {
cJSON_AddStringToObject(settings_obj, key, val);
}
}
cJSON_AddItemToObject(root, "settings", settings_obj);
/* Shortcuts. */
cJSON *shortcuts_obj = shortcuts_serialize();
cJSON_AddItemToObject(root, "shortcuts", shortcuts_obj);
return root;
}
/* ── Publish (debounced) ────────────────────────────────────────────── */
/* The actual publish operation (no debounce). */
static void do_publish(void) {
if (!g_have_signer) return;
/* Serialize. */
cJSON *payload = serialize_payload();
char *json = cJSON_PrintUnformatted(payload);
cJSON_Delete(payload);
if (json == NULL) {
g_printerr("[settings_sync] Failed to serialize payload\n");
return;
}
/* Encrypt. */
char *ciphertext = encrypt_content(json);
free(json);
if (ciphertext == NULL) return;
/* Build tags: [["d", "sovereign_browser"], ["client", "sovereign_browser"]] */
cJSON *tags = cJSON_CreateArray();
cJSON *d_tag = cJSON_CreateArray();
cJSON_AddItemToArray(d_tag, cJSON_CreateString("d"));
cJSON_AddItemToArray(d_tag, cJSON_CreateString(SETTINGS_SYNC_D_TAG));
cJSON_AddItemToArray(tags, d_tag);
cJSON *client_tag = cJSON_CreateArray();
cJSON_AddItemToArray(client_tag, cJSON_CreateString("client"));
cJSON_AddItemToArray(client_tag, cJSON_CreateString("sovereign_browser"));
cJSON_AddItemToArray(tags, client_tag);
/* Create and sign the event. */
cJSON *event = nostr_create_and_sign_event_with_signer(
SETTINGS_SYNC_KIND, ciphertext, tags, g_signer, time(NULL));
g_free(ciphertext);
if (event == NULL) {
g_printerr("[settings_sync] Failed to create/sign event\n");
cJSON_Delete(tags);
return;
}
/* Store in SQLite. */
db_store_event(event);
/* Record the sync timestamp. */
char ts_buf[32];
snprintf(ts_buf, sizeof(ts_buf), "%ld", (long)time(NULL));
db_kv_set(SYNC_TS_KEY, ts_buf);
/* 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("[settings_sync] Published kind %d to %d/%d relays\n",
SETTINGS_SYNC_KIND, success_count, relay_count);
} else {
g_print("[settings_sync] No relays configured, event stored locally\n");
}
g_free(relay_buf);
cJSON_Delete(event);
}
/* GLib timeout callback for the debounced publish. */
static gboolean publish_timeout_cb(gpointer data) {
(void)data;
g_publish_timeout_id = 0;
do_publish();
return G_SOURCE_REMOVE;
}
/* ── Public API ─────────────────────────────────────────────────────── */
void settings_sync_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';
}
}
void settings_sync_set_signer(nostr_signer_t *signer, const char *pubkey_hex) {
settings_sync_init(signer, pubkey_hex);
}
void settings_sync_publish(void) {
if (!g_have_signer) return;
/* Cancel any pending publish and schedule a new one in 500ms. */
if (g_publish_timeout_id > 0) {
g_source_remove(g_publish_timeout_id);
}
g_publish_timeout_id = g_timeout_add(500, publish_timeout_cb, NULL);
}
int settings_sync_merge_from_nostr(const void *event_cjson) {
const cJSON *event = (const cJSON *)event_cjson;
if (event == NULL) return -1;
if (!g_have_signer) return -1;
/* Verify the kind. */
cJSON *kind = cJSON_GetObjectItemCaseSensitive(event, "kind");
if (!cJSON_IsNumber(kind) || (long)kind->valuedouble != SETTINGS_SYNC_KIND) {
return -1;
}
/* Verify the d-tag is "sovereign_browser". */
cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (!cJSON_IsArray(tags)) return -1;
int is_ours = 0;
cJSON *tag;
cJSON_ArrayForEach(tag, tags) {
if (!cJSON_IsArray(tag)) continue;
cJSON *t0 = cJSON_GetArrayItem(tag, 0);
cJSON *t1 = cJSON_GetArrayItem(tag, 1);
if (cJSON_IsString(t0) && strcmp(t0->valuestring, "d") == 0 &&
cJSON_IsString(t1) &&
strcmp(t1->valuestring, SETTINGS_SYNC_D_TAG) == 0) {
is_ours = 1;
break;
}
}
if (!is_ours) return -1;
/* Get the event's created_at. */
cJSON *created_at = cJSON_GetObjectItemCaseSensitive(event, "created_at");
if (!cJSON_IsNumber(created_at)) return -1;
long event_ts = (long)created_at->valuedouble;
/* Compare to our last-synced timestamp. */
const char *ts_str = db_kv_get(SYNC_TS_KEY);
long local_ts = 0;
if (ts_str) local_ts = atol(ts_str);
if (event_ts <= local_ts) {
g_print("[settings_sync] Nostr event (%ld) not newer than local (%ld), "
"skipping merge\n", event_ts, local_ts);
return 0;
}
/* Decrypt the content. */
cJSON *content = cJSON_GetObjectItemCaseSensitive(event, "content");
if (!cJSON_IsString(content)) return -1;
char *plaintext = decrypt_content(content->valuestring);
if (plaintext == NULL) return -1;
/* Parse the JSON payload. */
cJSON *payload = cJSON_Parse(plaintext);
free(plaintext);
if (payload == NULL || !cJSON_IsObject(payload)) {
g_printerr("[settings_sync] Failed to parse decrypted payload\n");
if (payload) cJSON_Delete(payload);
return -1;
}
/* Merge settings. */
cJSON *settings_obj = cJSON_GetObjectItemCaseSensitive(payload, "settings");
if (cJSON_IsObject(settings_obj)) {
cJSON *item;
cJSON_ArrayForEach(item, settings_obj) {
if (!cJSON_IsString(item)) continue;
/* Only merge whitelisted keys. */
for (int i = 0; i < g_sync_setting_count; i++) {
if (strcmp(item->string, g_sync_setting_keys[i]) == 0) {
db_kv_set(item->string, item->valuestring);
break;
}
}
}
/* Reload settings from db_kv into the in-memory singleton. */
settings_load();
}
/* Merge shortcuts. */
cJSON *shortcuts_obj = cJSON_GetObjectItemCaseSensitive(payload, "shortcuts");
if (cJSON_IsObject(shortcuts_obj)) {
shortcuts_merge_from_json(shortcuts_obj);
}
/* Update the sync timestamp. */
char ts_buf[32];
snprintf(ts_buf, sizeof(ts_buf), "%ld", event_ts);
db_kv_set(SYNC_TS_KEY, ts_buf);
g_print("[settings_sync] Merged settings from Nostr event (ts=%ld)\n",
event_ts);
cJSON_Delete(payload);
return 0;
}