413 lines
16 KiB
C
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);
|
|
}
|