Files
sovereign_browser/src/search.c
T

368 lines
12 KiB
C

/*
* search.c — search engine integration for sovereign_browser
*
* Implements search engine configuration, URL building, async autocomplete
* suggestion fetching via libsoup, and a URL-vs-query heuristic.
*
* The active engine is stored in settings ("search_engine" key) and
* synced via NIP-78 (settings_sync).
*/
#include "search.h"
#include "settings.h"
#include "db.h"
#include <libsoup/soup.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
/* Vendored cJSON for parsing suggestion API responses. */
#include "../nostr_core_lib/cjson/cJSON.h"
/* ── Built-in search engines ────────────────────────────────────────── */
static const search_engine_t g_engines[] = {
{
"duckduckgo", "DuckDuckGo",
"https://duckduckgo.com/?q=%s",
"https://duckduckgo.com/ac/?q=%s&type=list"
},
{
"google", "Google",
"https://www.google.com/search?q=%s",
"https://suggestqueries.google.com/complete/search?client=firefox&q=%s"
},
{
"brave", "Brave Search",
"https://search.brave.com/search?q=%s",
"https://search.brave.com/api/suggest?q=%s"
},
{
"startpage", "Startpage",
"https://www.startpage.com/sp/search?query=%s",
NULL /* no public autocomplete API */
},
{
"searx", "Searx",
"https://searx.be/search?q=%s",
NULL
},
{ NULL, NULL, NULL, NULL } /* sentinel */
};
const search_engine_t *search_engines_get(void) {
return g_engines;
}
int search_engines_count(void) {
int count = 0;
while (g_engines[count].id != NULL) count++;
return count;
}
const search_engine_t *search_engine_by_id(const char *id) {
if (id == NULL || id[0] == '\0') return NULL;
for (int i = 0; g_engines[i].id != NULL; i++) {
if (strcmp(g_engines[i].id, id) == 0) {
return &g_engines[i];
}
}
return NULL;
}
const search_engine_t *search_engine_get_active(void) {
const browser_settings_t *s = settings_get();
const search_engine_t *engine = search_engine_by_id(s->search_engine);
if (engine == NULL) {
/* Fall back to DuckDuckGo if the configured engine is unknown. */
engine = &g_engines[0];
}
return engine;
}
int search_engine_set_active(const char *id) {
if (search_engine_by_id(id) == NULL) {
return -1;
}
browser_settings_t *s = settings_get_mutable();
snprintf(s->search_engine, sizeof(s->search_engine), "%s", id);
settings_save();
return 0;
}
/* ── URL building ──────────────────────────────────────────────────── */
char *search_build_search_url_for(const search_engine_t *engine,
const char *query) {
if (engine == NULL || query == NULL) return NULL;
/* URL-encode the query for use in a URL query parameter. */
char *encoded = g_uri_escape_string(query, NULL, FALSE);
if (encoded == NULL) return NULL;
char *url = g_strdup_printf(engine->search_url, encoded);
g_free(encoded);
return url;
}
char *search_build_search_url(const char *query) {
return search_build_search_url_for(search_engine_get_active(), query);
}
/* ── URL heuristic ─────────────────────────────────────────────────── */
gboolean search_is_url(const char *input) {
if (input == NULL || input[0] == '\0') return FALSE;
/* Has a scheme (e.g. "https://", "http://", "ftp://"). */
if (strstr(input, "://") != NULL) return TRUE;
/* Internal about: pages. */
if (strncmp(input, "about:", 6) == 0) return TRUE;
/* sovereign:// internal pages. */
if (strncmp(input, "sovereign://", 12) == 0) return TRUE;
/* If it contains spaces, it's almost certainly a search query. */
if (strchr(input, ' ') != NULL) return FALSE;
/* "localhost" or "localhost:port". */
if (strncmp(input, "localhost", 9) == 0) return TRUE;
/* Check for a dot — looks like a domain name.
* But require at least one char before and after the dot. */
const char *dot = strchr(input, '.');
if (dot != NULL && dot > input && dot[1] != '\0') {
return TRUE;
}
/* Check for IPv4 address (4 numbers separated by dots). */
{
int a, b, c, d;
if (sscanf(input, "%d.%d.%d.%d", &a, &b, &c, &d) == 4) {
if (a >= 0 && a <= 255 && b >= 0 && b <= 255 &&
c >= 0 && c <= 255 && d >= 0 && d <= 255) {
return TRUE;
}
}
}
return FALSE;
}
/* ── Async suggestion fetch ────────────────────────────────────────── */
/* A pending suggestion request. */
typedef struct {
guint id; /* unique request ID */
SoupSession *session; /* libsoup session */
SoupMessage *msg; /* in-flight HTTP request */
search_suggest_callback callback;
gpointer user_data;
GCancellable *cancellable;
} suggest_request_t;
/* Global counter for request IDs. */
static guint g_next_request_id = 1;
/* Active requests, keyed by ID. We keep a simple list since there's
* typically only one active at a time (the latest keystroke). */
static GList *g_active_requests = NULL;
/*
* Parse a suggestion API response.
*
* DuckDuckGo returns: ["query", ["sugg1", "sugg2", ...]]
* Google (client=firefox) returns: ["query", ["sugg1", "sugg2", ...]]
* Brave returns: ["sugg1", "sugg2", ...] (plain array of strings)
*
* Returns a NULL-terminated array of newly allocated strings, or NULL.
*/
static char **parse_suggestions(const char *body, gsize body_len) {
if (body == NULL || body_len == 0) return NULL;
cJSON *root = cJSON_ParseWithLength(body, body_len);
if (root == NULL) return NULL;
char **results = g_new0(char *, SEARCH_SUGGESTION_MAX + 1);
int count = 0;
if (cJSON_IsArray(root)) {
int arr_size = cJSON_GetArraySize(root);
/* DuckDuckGo / Google format: first element is the query string,
* second element is an array of suggestions. */
if (arr_size >= 2 && cJSON_IsString(cJSON_GetArrayItem(root, 0))) {
cJSON *suggestions = cJSON_GetArrayItem(root, 1);
if (cJSON_IsArray(suggestions)) {
int sugg_size = cJSON_GetArraySize(suggestions);
for (int i = 0; i < sugg_size && count < SEARCH_SUGGESTION_MAX; i++) {
cJSON *item = cJSON_GetArrayItem(suggestions, i);
if (cJSON_IsString(item) && item->valuestring[0] != '\0') {
results[count++] = g_strdup(item->valuestring);
}
}
}
} else {
/* Brave format: plain array of strings. */
for (int i = 0; i < arr_size && count < SEARCH_SUGGESTION_MAX; i++) {
cJSON *item = cJSON_GetArrayItem(root, i);
if (cJSON_IsString(item) && item->valuestring[0] != '\0') {
results[count++] = g_strdup(item->valuestring);
}
}
}
}
cJSON_Delete(root);
if (count == 0) {
g_free(results);
return NULL;
}
return results;
}
/*
* Idle callback to deliver results on the main thread.
*/
typedef struct {
search_suggest_callback callback;
char **suggestions;
gpointer user_data;
} idle_delivery_t;
static gboolean deliver_suggestions_idle(gpointer data) {
idle_delivery_t *delivery = (idle_delivery_t *)data;
delivery->callback(delivery->suggestions, delivery->user_data);
/* Free the suggestions array after the callback has consumed them. */
if (delivery->suggestions) {
for (int i = 0; delivery->suggestions[i] != NULL; i++) {
g_free(delivery->suggestions[i]);
}
g_free(delivery->suggestions);
}
g_free(delivery);
return G_SOURCE_REMOVE;
}
/*
* GAsyncReadyCallback for soup_session_send_and_read_async.
* Called when the full response body has been downloaded.
*/
static void on_suggest_async_ready(GObject *source_object, GAsyncResult *res,
gpointer user_data) {
suggest_request_t *req = (suggest_request_t *)user_data;
SoupSession *session = SOUP_SESSION(source_object);
char **suggestions = NULL;
GBytes *bytes = soup_session_send_and_read_finish(session, res, NULL);
if (bytes != NULL) {
gsize size = 0;
const gchar *data = g_bytes_get_data(bytes, &size);
if (data && size > 0) {
suggestions = parse_suggestions(data, size);
}
g_bytes_unref(bytes);
}
/* Deliver on the main thread via an idle handler. We're already on
* the main thread (libsoup-3.0 async callbacks run on the thread
* that started the request), but the idle handler avoids re-entrancy
* issues with the entry completion widget. */
idle_delivery_t *delivery = g_new(idle_delivery_t, 1);
delivery->callback = req->callback;
delivery->suggestions = suggestions;
delivery->user_data = req->user_data;
g_idle_add(deliver_suggestions_idle, delivery);
/* Clean up. */
g_active_requests = g_list_remove(g_active_requests, req);
g_object_unref(req->msg);
if (req->cancellable) g_object_unref(req->cancellable);
g_object_unref(req->session);
g_free(req);
}
guint search_suggest_fetch_async(const char *query,
search_suggest_callback callback,
gpointer user_data) {
if (query == NULL || query[0] == '\0' || callback == NULL) return 0;
const search_engine_t *engine = search_engine_get_active();
if (engine->suggest_url == NULL) {
/* Engine has no suggestion API — deliver NULL immediately. */
idle_delivery_t *delivery = g_new(idle_delivery_t, 1);
delivery->callback = callback;
delivery->suggestions = NULL;
delivery->user_data = user_data;
g_idle_add(deliver_suggestions_idle, delivery);
return 0;
}
/* Build the suggestion URL. */
char *encoded = g_uri_escape_string(query, NULL, FALSE);
if (encoded == NULL) return 0;
char *url = g_strdup_printf(engine->suggest_url, encoded);
g_free(encoded);
if (url == NULL) return 0;
/* Create the request. */
suggest_request_t *req = g_new0(suggest_request_t, 1);
req->id = g_next_request_id++;
req->callback = callback;
req->user_data = user_data;
req->cancellable = g_cancellable_new();
/* Create a SoupSession per request. Suggestion requests are
* infrequent (one per keystroke with debouncing) and this avoids
* thread-safety concerns with a shared session. */
req->session = soup_session_new();
g_object_set(req->session, "timeout", 5, "idle-timeout", 5, NULL);
req->msg = soup_message_new("GET", url);
g_free(url);
if (req->msg == NULL) {
g_object_unref(req->cancellable);
g_object_unref(req->session);
g_free(req);
return 0;
}
/* Set a User-Agent so the APIs don't block us. */
soup_message_headers_replace(soup_message_get_request_headers(req->msg),
"User-Agent",
"sovereign_browser/1.0");
g_active_requests = g_list_prepend(g_active_requests, req);
/* Send the request asynchronously. on_suggest_async_ready is
* invoked when the full response body has been downloaded. */
soup_session_send_and_read_async(req->session, req->msg,
G_PRIORITY_DEFAULT,
req->cancellable,
on_suggest_async_ready,
req);
return req->id;
}
void search_suggest_cancel(guint request_id) {
if (request_id == 0) return;
GList *l = g_active_requests;
while (l != NULL) {
suggest_request_t *req = (suggest_request_t *)l->data;
if (req->id == request_id) {
g_cancellable_cancel(req->cancellable);
return; /* The async callback will handle cleanup. */
}
l = l->next;
}
}