347 lines
12 KiB
C
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;
|
|
}
|