Files
sovereign_browser/src/nostr_scheme.c
T

413 lines
16 KiB
C

/*
* nostr_scheme.c — asynchronous relay-backed WebKitGTK handler for nostr://
*/
#include "nostr_scheme.h"
#include "db.h"
#include "nostr_url.h"
#include "relay_fetch.h"
#include "settings.h"
#include "nostr_core/nostr_core.h"
#include "../nostr_core_lib/cjson/cJSON.h"
#include <curl/curl.h>
#include <stdlib.h>
#include <string.h>
#define MAX_FETCH_RELAYS 8
#define FETCH_TIMEOUT_SECONDS 8
#define FETCH_EVENT_LIMIT 20
#define NIP11_MAX_BYTES (1024 * 1024)
extern const char *app_get_pubkey_hex(void);
typedef struct {
WebKitURISchemeRequest *request;
char *entity;
char *bootstrap_relays;
char *user_pubkey;
nostr_decoded_entity_t decoded;
GPtrArray *entity_relays;
GPtrArray *user_relays;
GPtrArray *default_relays;
GPtrArray *attempted_relays;
cJSON *events;
cJSON *errors;
cJSON *nip11;
gboolean timed_out;
gint64 started_us;
char *response_json;
} scheme_job_t;
typedef struct {
GString *body;
gboolean overflow;
} curl_body_t;
static gboolean valid_relay_url(const char *url) {
return url && (g_str_has_prefix(url, "wss://") ||
g_str_has_prefix(url, "ws://"));
}
static gboolean array_contains(GPtrArray *array, const char *url) {
for (guint i = 0; array && i < array->len; i++)
if (!g_ascii_strcasecmp(g_ptr_array_index(array, i), url)) return TRUE;
return FALSE;
}
static void add_unique_relay(GPtrArray *array, const char *url) {
if (!array || !valid_relay_url(url) || array_contains(array, url)) return;
char *copy = g_strdup(url);
g_strstrip(copy);
gsize len = strlen(copy);
while (len > 0 && copy[len - 1] == '/') copy[--len] = '\0';
if (*copy && !array_contains(array, copy)) g_ptr_array_add(array, copy);
else g_free(copy);
}
static void parse_bootstrap_relays(scheme_job_t *job) {
gchar **lines = g_strsplit_set(job->bootstrap_relays ? job->bootstrap_relays : "",
"\r\n", -1);
for (gchar **line = lines; line && *line; line++) {
g_strstrip(*line);
add_unique_relay(job->default_relays, *line);
}
g_strfreev(lines);
}
static void parse_user_relays(scheme_job_t *job) {
if (!job->user_pubkey || !*job->user_pubkey) return;
cJSON *event = db_get_latest_event(job->user_pubkey, 10002);
if (!event) return;
cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
cJSON *tag = NULL;
cJSON_ArrayForEach(tag, tags) {
if (!cJSON_IsArray(tag)) continue;
cJSON *name = cJSON_GetArrayItem(tag, 0);
cJSON *url = cJSON_GetArrayItem(tag, 1);
cJSON *marker = cJSON_GetArrayItem(tag, 2);
if (!cJSON_IsString(name) || strcmp(name->valuestring, "r") ||
!cJSON_IsString(url)) continue;
/* NIP-65: no marker means both; "read" is readable; "write" is not. */
if (cJSON_IsString(marker) && strcmp(marker->valuestring, "read")) continue;
add_unique_relay(job->user_relays, url->valuestring);
}
cJSON_Delete(event);
}
static GPtrArray *fallback_relays(scheme_job_t *job) {
return job->user_relays->len ? job->user_relays : job->default_relays;
}
static void record_attempted(scheme_job_t *job, GPtrArray *relays) {
for (guint i = 0; i < relays->len && job->attempted_relays->len < MAX_FETCH_RELAYS; i++)
add_unique_relay(job->attempted_relays, g_ptr_array_index(relays, i));
}
static cJSON *build_filter(const scheme_job_t *job) {
cJSON *filter = cJSON_CreateObject();
if (job->decoded.type == NOSTR_ENTITY_NPUB ||
job->decoded.type == NOSTR_ENTITY_NPROFILE) {
char *pubkey = nostr_hex_encode32(job->decoded.pubkey);
cJSON *authors = cJSON_AddArrayToObject(filter, "authors");
cJSON_AddItemToArray(authors, cJSON_CreateString(pubkey));
cJSON *kinds = cJSON_AddArrayToObject(filter, "kinds");
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(0));
cJSON_AddNumberToObject(filter, "limit", 1);
g_free(pubkey);
} else if (job->decoded.type == NOSTR_ENTITY_NOTE ||
job->decoded.type == NOSTR_ENTITY_NEVENT) {
char *id = nostr_hex_encode32(job->decoded.event_id);
cJSON *ids = cJSON_AddArrayToObject(filter, "ids");
cJSON_AddItemToArray(ids, cJSON_CreateString(id));
cJSON_AddNumberToObject(filter, "limit", 1);
g_free(id);
} else if (job->decoded.type == NOSTR_ENTITY_NADDR) {
char *pubkey = nostr_hex_encode32(job->decoded.pubkey);
cJSON *authors = cJSON_AddArrayToObject(filter, "authors");
cJSON_AddItemToArray(authors, cJSON_CreateString(pubkey));
cJSON *kinds = cJSON_AddArrayToObject(filter, "kinds");
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(job->decoded.kind));
cJSON *d_tags = cJSON_AddArrayToObject(filter, "#d");
cJSON_AddItemToArray(d_tags, cJSON_CreateString(job->decoded.identifier));
cJSON_AddNumberToObject(filter, "limit", 1);
g_free(pubkey);
}
return filter;
}
static int query_relay_stage(scheme_job_t *job, GPtrArray *relays) {
if (!relays || !relays->len || job->attempted_relays->len >= MAX_FETCH_RELAYS) return 0;
const char *urls[MAX_FETCH_RELAYS];
int count = 0;
int remaining = MAX_FETCH_RELAYS - (int)job->attempted_relays->len;
for (guint i = 0; i < relays->len && count < remaining; i++) {
const char *url = g_ptr_array_index(relays, i);
if (!array_contains(job->attempted_relays, url)) urls[count++] = url;
}
if (!count) return 0;
record_attempted(job, relays);
cJSON *filter = build_filter(job);
int result_count = 0;
cJSON **results = relay_query_synchronized(
urls, count, filter, RELAY_QUERY_ALL_RESULTS, &result_count,
FETCH_TIMEOUT_SECONDS, NULL, NULL, 0, NULL);
cJSON_Delete(filter);
if (!results || result_count <= 0) {
if (results) free(results);
cJSON *diag = cJSON_CreateObject();
cJSON_AddStringToObject(diag, "stage", relays == job->entity_relays
? "entity_hints" : "fallback");
cJSON_AddStringToObject(diag, "message",
"No events returned before relay query completed");
cJSON_AddItemToArray(job->errors, diag);
return 0;
}
int added = 0;
for (int i = 0; i < result_count && added < FETCH_EVENT_LIMIT; i++) {
if (!results[i]) continue;
cJSON_AddItemToArray(job->events, results[i]);
results[i] = NULL;
added++;
}
for (int i = 0; i < result_count; i++) if (results[i]) cJSON_Delete(results[i]);
free(results);
return added;
}
static size_t curl_write_bounded(void *data, size_t size, size_t nmemb, void *user) {
curl_body_t *body = user;
size_t bytes = size * nmemb;
if (body->body->len + bytes > NIP11_MAX_BYTES) {
body->overflow = TRUE;
return 0;
}
g_string_append_len(body->body, data, bytes);
return bytes;
}
static void fetch_nip11(scheme_job_t *job) {
char *http_url = NULL;
if (g_str_has_prefix(job->decoded.relay_url, "wss://"))
http_url = g_strdup_printf("https://%s", job->decoded.relay_url + 6);
else if (g_str_has_prefix(job->decoded.relay_url, "ws://"))
http_url = g_strdup_printf("http://%s", job->decoded.relay_url + 5);
if (!http_url) return;
CURL *curl = curl_easy_init();
curl_body_t body = { g_string_new(NULL), FALSE };
struct curl_slist *headers = curl_slist_append(NULL, "Accept: application/nostr+json");
curl_easy_setopt(curl, CURLOPT_URL, http_url);
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, curl_write_bounded);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &body);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, (long)FETCH_TIMEOUT_SECONDS);
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 4L);
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 3L);
curl_easy_setopt(curl, CURLOPT_USERAGENT, "sovereign_browser/nostr-uri");
CURLcode result = curl_easy_perform(curl);
long status = 0;
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status);
if (result == CURLE_OK && status >= 200 && status < 300)
job->nip11 = cJSON_ParseWithLength(body.body->str, body.body->len);
if (!job->nip11) {
cJSON *diag = cJSON_CreateObject();
cJSON_AddStringToObject(diag, "stage", "nip11");
cJSON_AddStringToObject(diag, "relay", job->decoded.relay_url);
cJSON_AddNumberToObject(diag, "http_status", status);
cJSON_AddStringToObject(diag, "message", body.overflow
? "NIP-11 response exceeded size limit"
: (result == CURLE_OK ? "NIP-11 response was not valid JSON"
: curl_easy_strerror(result)));
cJSON_AddItemToArray(job->errors, diag);
if (result == CURLE_OPERATION_TIMEDOUT) job->timed_out = TRUE;
}
curl_slist_free_all(headers);
curl_easy_cleanup(curl);
g_string_free(body.body, TRUE);
g_free(http_url);
}
static void build_response(scheme_job_t *job) {
cJSON *response = NULL;
int event_count = cJSON_GetArraySize(job->events);
if (event_count == 1) {
response = cJSON_DetachItemFromArray(job->events, 0);
} else if (event_count > 1) {
response = job->events;
job->events = NULL;
} else if (job->decoded.type == NOSTR_ENTITY_NRELAY && job->nip11) {
response = job->nip11;
job->nip11 = NULL;
} else {
response = cJSON_CreateObject();
cJSON_AddStringToObject(response, "error", "No events found");
cJSON_AddStringToObject(response, "entity", job->entity);
}
job->response_json = cJSON_PrintUnformatted(response);
cJSON_Delete(response);
}
static gpointer scheme_worker(gpointer data) {
scheme_job_t *job = data;
parse_bootstrap_relays(job);
parse_user_relays(job);
for (guint i = 0; i < job->decoded.relay_hints->len; i++)
add_unique_relay(job->entity_relays,
g_ptr_array_index(job->decoded.relay_hints, i));
if (job->decoded.type == NOSTR_ENTITY_NRELAY) {
fetch_nip11(job);
} else {
int found = 0;
if (job->entity_relays->len) found = query_relay_stage(job, job->entity_relays);
if (!found) query_relay_stage(job, fallback_relays(job));
if (!job->attempted_relays->len) {
cJSON *diag = cJSON_CreateObject();
cJSON_AddStringToObject(diag, "stage", "relay_selection");
cJSON_AddStringToObject(diag, "message", "No usable relay URLs available");
cJSON_AddItemToArray(job->errors, diag);
}
}
build_response(job);
return job;
}
static void scheme_job_free(scheme_job_t *job) {
if (!job) return;
g_clear_object(&job->request);
g_free(job->entity);
g_free(job->bootstrap_relays);
g_free(job->user_pubkey);
g_free(job->response_json);
nostr_decoded_entity_clear(&job->decoded);
g_ptr_array_unref(job->entity_relays);
g_ptr_array_unref(job->user_relays);
g_ptr_array_unref(job->default_relays);
g_ptr_array_unref(job->attempted_relays);
if (job->events) cJSON_Delete(job->events);
if (job->errors) cJSON_Delete(job->errors);
if (job->nip11) cJSON_Delete(job->nip11);
g_free(job);
}
static void scheme_worker_done(GObject *source, GAsyncResult *result, gpointer data) {
(void)source; (void)data;
GTask *task = G_TASK(result);
scheme_job_t *job = g_task_propagate_pointer(task, NULL);
gsize len = strlen(job->response_json);
GInputStream *stream = g_memory_input_stream_new_from_data(
g_memdup2(job->response_json, len), len, g_free);
webkit_uri_scheme_request_finish(job->request, stream, len, "application/json");
g_object_unref(stream);
scheme_job_free(job);
}
static void task_thread(GTask *task, gpointer source, gpointer task_data,
GCancellable *cancel) {
(void)source; (void)cancel;
g_task_return_pointer(task, scheme_worker(task_data), NULL);
}
static char *extract_entity(const char *uri) {
const char *start = nostr_url_entity(uri);
if (!start) return NULL;
while (*start == '/') start++;
const char *end = start;
while (*end && *end != '/' && *end != '?' && *end != '#') end++;
return end == start ? NULL : g_strndup(start, end - start);
}
static void respond_json(WebKitURISchemeRequest *request, char *json) {
gsize len = strlen(json);
GInputStream *stream = g_memory_input_stream_new_from_data(json, len, free);
webkit_uri_scheme_request_finish(request, stream, len, "application/json");
g_object_unref(stream);
}
static void respond_error(WebKitURISchemeRequest *request, const char *entity,
const char *message) {
cJSON *root = cJSON_CreateObject();
cJSON_AddStringToObject(root, "error", message);
if (entity) cJSON_AddStringToObject(root, "entity", entity);
char *json = cJSON_PrintUnformatted(root);
cJSON_Delete(root);
respond_json(request, json);
}
static void respond_helper_apps(WebKitURISchemeRequest *request, const char *config) {
cJSON *apps = cJSON_CreateArray();
gchar **configured = g_strsplit(config ? config : "", ",", -1);
for (gchar **item = configured; item && *item; item++) {
gchar **parts = g_strsplit(*item, "|", 2);
if (parts[0] && *parts[0] && parts[1] && *parts[1]) {
cJSON *app = cJSON_CreateObject();
cJSON_AddStringToObject(app, "name", parts[0]);
cJSON_AddStringToObject(app, "url_template", parts[1]);
cJSON_AddItemToArray(apps, app);
}
g_strfreev(parts);
}
g_strfreev(configured);
char *json = cJSON_PrintUnformatted(apps);
cJSON_Delete(apps);
respond_json(request, json);
}
static void on_nostr_scheme(WebKitURISchemeRequest *request, gpointer user_data) {
(void)user_data;
const char *uri = webkit_uri_scheme_request_get_uri(request);
char *entity = extract_entity(uri);
const browser_settings_t *settings = settings_get();
const char *path = entity ? strstr(uri + strlen("nostr://"), "/") : NULL;
if ((entity && strcmp(entity, "helper-apps") == 0) ||
(path && g_str_has_prefix(path, "/helper-apps") &&
(path[12] == '\0' || path[12] == '?' || path[12] == '#'))) {
respond_helper_apps(request, settings->nostr_helper_apps);
g_free(entity);
return;
}
nostr_decoded_entity_t decoded;
GError *error = NULL;
if (!entity || !nostr_url_decode(entity, &decoded, &error)) {
respond_error(request, entity, error ? error->message :
"Invalid or unsupported Nostr entity");
g_clear_error(&error);
g_free(entity);
return;
}
scheme_job_t *job = g_new0(scheme_job_t, 1);
job->request = g_object_ref(request);
job->entity = entity;
job->bootstrap_relays = g_strdup(settings->bootstrap_relays);
const char *pubkey = app_get_pubkey_hex();
job->user_pubkey = pubkey && *pubkey ? g_strdup(pubkey) : NULL;
job->decoded = decoded;
job->entity_relays = g_ptr_array_new_with_free_func(g_free);
job->user_relays = g_ptr_array_new_with_free_func(g_free);
job->default_relays = g_ptr_array_new_with_free_func(g_free);
job->attempted_relays = g_ptr_array_new_with_free_func(g_free);
job->events = cJSON_CreateArray();
job->errors = cJSON_CreateArray();
job->started_us = g_get_monotonic_time();
GTask *task = g_task_new(NULL, NULL, scheme_worker_done, NULL);
g_task_set_task_data(task, job, NULL);
g_task_run_in_thread(task, task_thread);
g_object_unref(task);
}
void nostr_scheme_register(WebKitWebContext *ctx) {
g_return_if_fail(WEBKIT_IS_WEB_CONTEXT(ctx));
webkit_web_context_register_uri_scheme(ctx, "nostr", on_nostr_scheme, NULL, NULL);
}