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