diff --git a/build.sh b/build.sh index ff7bad20..03ed024e 100755 --- a/build.sh +++ b/build.sh @@ -507,6 +507,7 @@ SOURCES="$SOURCES nostr_core/core_relay_pool.c" SOURCES="$SOURCES nostr_core/nostr_log.c" SOURCES="$SOURCES nostr_websocket/nostr_websocket_openssl.c" SOURCES="$SOURCES nostr_core/request_validator.c" +SOURCES="$SOURCES nostr_core/nostr_http.c" NIP_DESCRIPTIONS="" @@ -540,6 +541,8 @@ if echo "$NEEDED_NIPS" | grep -Eq '(^| )060( |$)|(^| )061( |$)'; then SOURCES="$SOURCES nostr_core/cashu_mint.c" fi +SOURCES="$SOURCES nostr_core/blossom_client.c" + # Build flags CFLAGS="-Wall -Wextra -std=c99 -fPIC -O2" CFLAGS="$CFLAGS -DENABLE_FILE_LOGGING -DENABLE_WEBSOCKET_LOGGING -DENABLE_DEBUG_LOGGING" diff --git a/nostr_core/blossom_client.c b/nostr_core/blossom_client.c new file mode 100644 index 00000000..ca76180d --- /dev/null +++ b/nostr_core/blossom_client.c @@ -0,0 +1,513 @@ +/* + * Blossom HTTP Client + */ + +#include "blossom_client.h" + +#include "../cjson/cJSON.h" +#include "nip001.h" +#include "nostr_http.h" +#include "utils.h" + +#include +#include +#include +#include + +static int is_hex64_local(const char* s) { + if (!s || strlen(s) != 64) return 0; + for (int i = 0; i < 64; i++) { + char c = s[i]; + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'))) { + return 0; + } + } + return 1; +} + +static void trim_trailing_slash_local(const char* in, char* out, size_t out_sz) { + if (!in || !out || out_sz == 0) return; + snprintf(out, out_sz, "%s", in); + size_t n = strlen(out); + while (n > 0 && out[n - 1] == '/') { + out[n - 1] = '\0'; + n--; + } +} + +static int build_blob_url_local(const char* server_url, const char* sha256_hex, char* out, size_t out_sz) { + if (!server_url || !sha256_hex || !out || out_sz == 0) return -1; + char base[512]; + trim_trailing_slash_local(server_url, base, sizeof(base)); + int n = snprintf(out, out_sz, "%s/%s", base, sha256_hex); + if (n < 0 || (size_t)n >= out_sz) return -1; + return 0; +} + +static int parse_descriptor_local(const char* json_text, blossom_blob_descriptor_t* out) { + if (!json_text || !out) return NOSTR_ERROR_INVALID_INPUT; + cJSON* root = cJSON_Parse(json_text); + if (!root) return NOSTR_ERROR_IO_FAILED; + + cJSON* sha = cJSON_GetObjectItemCaseSensitive(root, "sha256"); + if (!sha) sha = cJSON_GetObjectItemCaseSensitive(root, "x"); + cJSON* url = cJSON_GetObjectItemCaseSensitive(root, "url"); + cJSON* size = cJSON_GetObjectItemCaseSensitive(root, "size"); + cJSON* ct = cJSON_GetObjectItemCaseSensitive(root, "content_type"); + if (!ct) ct = cJSON_GetObjectItemCaseSensitive(root, "m"); + cJSON* created = cJSON_GetObjectItemCaseSensitive(root, "created"); + if (!created) created = cJSON_GetObjectItemCaseSensitive(root, "created_at"); + + memset(out, 0, sizeof(*out)); + if (sha && cJSON_IsString(sha) && sha->valuestring) { + snprintf(out->sha256, sizeof(out->sha256), "%s", sha->valuestring); + } + if (url && cJSON_IsString(url) && url->valuestring) { + snprintf(out->url, sizeof(out->url), "%s", url->valuestring); + } + if (size && cJSON_IsNumber(size)) out->size = (long)cJSON_GetNumberValue(size); + if (ct && cJSON_IsString(ct) && ct->valuestring) { + snprintf(out->content_type, sizeof(out->content_type), "%s", ct->valuestring); + } + if (created && cJSON_IsNumber(created)) out->created = (long)cJSON_GetNumberValue(created); + + cJSON_Delete(root); + return NOSTR_SUCCESS; +} + +void blossom_set_ca_bundle(const char* ca_bundle_path) { + nostr_http_set_ca_bundle(ca_bundle_path); +} + +char* blossom_create_auth_header(const unsigned char* private_key, + const char* operation, + const char* sha256_hex, + int expiration_seconds) { + if (!private_key || !operation || operation[0] == '\0') return NULL; + if (sha256_hex && !is_hex64_local(sha256_hex)) return NULL; + + cJSON* tags = cJSON_CreateArray(); + if (!tags) return NULL; + + cJSON* t_tag = cJSON_CreateArray(); + cJSON_AddItemToArray(t_tag, cJSON_CreateString("t")); + cJSON_AddItemToArray(t_tag, cJSON_CreateString(operation)); + cJSON_AddItemToArray(tags, t_tag); + + if (sha256_hex) { + cJSON* x_tag = cJSON_CreateArray(); + cJSON_AddItemToArray(x_tag, cJSON_CreateString("x")); + cJSON_AddItemToArray(x_tag, cJSON_CreateString(sha256_hex)); + cJSON_AddItemToArray(tags, x_tag); + } + + int exp = expiration_seconds > 0 ? expiration_seconds : 300; + long until = (long)time(NULL) + exp; + char exp_buf[32]; + snprintf(exp_buf, sizeof(exp_buf), "%ld", until); + cJSON* e_tag = cJSON_CreateArray(); + cJSON_AddItemToArray(e_tag, cJSON_CreateString("expiration")); + cJSON_AddItemToArray(e_tag, cJSON_CreateString(exp_buf)); + cJSON_AddItemToArray(tags, e_tag); + + cJSON* evt = nostr_create_and_sign_event(24242, "", tags, private_key, time(NULL)); + cJSON_Delete(tags); + if (!evt) return NULL; + + char* evt_json = cJSON_PrintUnformatted(evt); + cJSON_Delete(evt); + if (!evt_json) return NULL; + + size_t evt_len = strlen(evt_json); + size_t b64_cap = ((evt_len + 2U) / 3U) * 4U + 8U; + char* b64 = (char*)malloc(b64_cap); + if (!b64) { + free(evt_json); + return NULL; + } + + size_t n = base64_encode((const unsigned char*)evt_json, evt_len, b64, b64_cap); + free(evt_json); + if (n == 0) { + free(b64); + return NULL; + } + + size_t hdr_cap = n + 16U; + char* header = (char*)malloc(hdr_cap); + if (!header) { + free(b64); + return NULL; + } + snprintf(header, hdr_cap, "Nostr %s", b64); + free(b64); + return header; +} + +int blossom_upload(const char* server_url, + const unsigned char* data, + size_t data_len, + const char* content_type, + const unsigned char* private_key, + const char* sha256_hex, + int timeout_seconds, + blossom_blob_descriptor_t* descriptor_out) { + if (!server_url || !data || data_len == 0 || !descriptor_out) return NOSTR_ERROR_INVALID_INPUT; + + char hash_hex[65] = {0}; + if (sha256_hex) { + if (!is_hex64_local(sha256_hex)) return NOSTR_ERROR_INVALID_INPUT; + snprintf(hash_hex, sizeof(hash_hex), "%s", sha256_hex); + } else { + unsigned char hash[32]; + if (nostr_sha256(data, data_len, hash) != 0) return NOSTR_ERROR_CRYPTO_FAILED; + nostr_bytes_to_hex(hash, 32, hash_hex); + } + + char url[1024]; + char base[512]; + trim_trailing_slash_local(server_url, base, sizeof(base)); + snprintf(url, sizeof(url), "%s/upload", base); + + char auth_buf[1024] = {0}; + char ctype_buf[256] = {0}; + const char* headers[4] = {"Accept: application/json", NULL, NULL, NULL}; + int h = 1; + + if (content_type && content_type[0] != '\0') { + snprintf(ctype_buf, sizeof(ctype_buf), "Content-Type: %s", content_type); + headers[h++] = ctype_buf; + } + + char* auth = NULL; + if (private_key) { + auth = blossom_create_auth_header(private_key, "upload", hash_hex, 300); + if (auth) { + snprintf(auth_buf, sizeof(auth_buf), "Authorization: %s", auth); + headers[h++] = auth_buf; + } + } + headers[h] = NULL; + + nostr_http_request_t req; + memset(&req, 0, sizeof(req)); + req.method = "PUT"; + req.url = url; + req.headers = headers; + req.body = data; + req.body_len = data_len; + req.timeout_seconds = timeout_seconds > 0 ? timeout_seconds : 20; + req.follow_redirects = 1; + req.max_redirects = 3; + + nostr_http_response_t resp; + int rc = nostr_http_request(&req, &resp); + free(auth); + if (rc != NOSTR_SUCCESS) return rc; + + if (resp.status_code < 200 || resp.status_code >= 300) { + nostr_http_response_free(&resp); + return NOSTR_ERROR_NETWORK_FAILED; + } + + int prc = parse_descriptor_local(resp.body ? resp.body : "{}", descriptor_out); + if (prc != NOSTR_SUCCESS) { + memset(descriptor_out, 0, sizeof(*descriptor_out)); + snprintf(descriptor_out->sha256, sizeof(descriptor_out->sha256), "%s", hash_hex); + descriptor_out->size = (long)data_len; + if (content_type && content_type[0] != '\0') { + snprintf(descriptor_out->content_type, sizeof(descriptor_out->content_type), "%s", content_type); + } + } + + nostr_http_response_free(&resp); + return NOSTR_SUCCESS; +} + +int blossom_upload_file(const char* server_url, + const char* file_path, + const char* content_type, + const unsigned char* private_key, + int timeout_seconds, + blossom_blob_descriptor_t* descriptor_out) { + if (!server_url || !file_path || !descriptor_out) return NOSTR_ERROR_INVALID_INPUT; + + FILE* fp = fopen(file_path, "rb"); + if (!fp) return NOSTR_ERROR_IO_FAILED; + + if (fseek(fp, 0, SEEK_END) != 0) { + fclose(fp); + return NOSTR_ERROR_IO_FAILED; + } + long sz = ftell(fp); + if (sz < 0) { + fclose(fp); + return NOSTR_ERROR_IO_FAILED; + } + if (fseek(fp, 0, SEEK_SET) != 0) { + fclose(fp); + return NOSTR_ERROR_IO_FAILED; + } + + unsigned char* buf = (unsigned char*)malloc((size_t)sz); + if (!buf) { + fclose(fp); + return NOSTR_ERROR_MEMORY_FAILED; + } + size_t n = fread(buf, 1, (size_t)sz, fp); + fclose(fp); + if (n != (size_t)sz) { + free(buf); + return NOSTR_ERROR_IO_FAILED; + } + + int rc = blossom_upload(server_url, buf, n, content_type, private_key, NULL, timeout_seconds, descriptor_out); + free(buf); + return rc; +} + +int blossom_download(const char* server_url, + const char* sha256_hex, + int timeout_seconds, + size_t max_bytes, + unsigned char** body_out, + size_t* body_len_out, + char* content_type_out, + size_t content_type_out_size) { + if (!server_url || !sha256_hex || !body_out || !body_len_out || !is_hex64_local(sha256_hex)) { + return NOSTR_ERROR_INVALID_INPUT; + } + + *body_out = NULL; + *body_len_out = 0; + if (content_type_out && content_type_out_size > 0) content_type_out[0] = '\0'; + + char url[1024]; + if (build_blob_url_local(server_url, sha256_hex, url, sizeof(url)) != 0) return NOSTR_ERROR_INVALID_INPUT; + + nostr_http_request_t req; + memset(&req, 0, sizeof(req)); + req.method = "GET"; + req.url = url; + req.timeout_seconds = timeout_seconds > 0 ? timeout_seconds : 20; + req.max_response_bytes = max_bytes; + req.follow_redirects = 1; + req.max_redirects = 3; + + nostr_http_response_t resp; + int rc = nostr_http_request(&req, &resp); + if (rc != NOSTR_SUCCESS) return rc; + + if (resp.status_code < 200 || resp.status_code >= 300) { + nostr_http_response_free(&resp); + return NOSTR_ERROR_NETWORK_FAILED; + } + + unsigned char* out = (unsigned char*)malloc(resp.body_len > 0 ? resp.body_len : 1); + if (!out) { + nostr_http_response_free(&resp); + return NOSTR_ERROR_MEMORY_FAILED; + } + if (resp.body_len > 0 && resp.body) memcpy(out, resp.body, resp.body_len); + + *body_out = out; + *body_len_out = resp.body_len; + if (content_type_out && content_type_out_size > 0 && resp.content_type && resp.content_type[0] != '\0') { + snprintf(content_type_out, content_type_out_size, "%s", resp.content_type); + } + + nostr_http_response_free(&resp); + return NOSTR_SUCCESS; +} + +int blossom_download_to_file(const char* server_url, + const char* sha256_hex, + const char* output_path, + int timeout_seconds, + size_t max_bytes, + blossom_blob_descriptor_t* descriptor_out) { + if (!server_url || !sha256_hex || !output_path || !descriptor_out) return NOSTR_ERROR_INVALID_INPUT; + + unsigned char* body = NULL; + size_t body_len = 0; + char content_type[128] = {0}; + int rc = blossom_download(server_url, sha256_hex, timeout_seconds, max_bytes, &body, &body_len, content_type, sizeof(content_type)); + if (rc != NOSTR_SUCCESS) return rc; + + FILE* fp = fopen(output_path, "wb"); + if (!fp) { + free(body); + return NOSTR_ERROR_IO_FAILED; + } + size_t wn = fwrite(body, 1, body_len, fp); + fclose(fp); + if (wn != body_len) { + free(body); + return NOSTR_ERROR_IO_FAILED; + } + + unsigned char hash[32]; + if (nostr_sha256(body, body_len, hash) != 0) { + free(body); + return NOSTR_ERROR_CRYPTO_FAILED; + } + free(body); + + char hash_hex[65]; + nostr_bytes_to_hex(hash, 32, hash_hex); + + memset(descriptor_out, 0, sizeof(*descriptor_out)); + snprintf(descriptor_out->sha256, sizeof(descriptor_out->sha256), "%s", hash_hex); + descriptor_out->size = (long)body_len; + if (content_type[0] != '\0') snprintf(descriptor_out->content_type, sizeof(descriptor_out->content_type), "%s", content_type); + build_blob_url_local(server_url, sha256_hex, descriptor_out->url, sizeof(descriptor_out->url)); + return NOSTR_SUCCESS; +} + +int blossom_head(const char* server_url, + const char* sha256_hex, + int timeout_seconds, + blossom_blob_descriptor_t* descriptor_out) { + if (!server_url || !sha256_hex || !descriptor_out || !is_hex64_local(sha256_hex)) { + return NOSTR_ERROR_INVALID_INPUT; + } + + char url[1024]; + if (build_blob_url_local(server_url, sha256_hex, url, sizeof(url)) != 0) return NOSTR_ERROR_INVALID_INPUT; + + nostr_http_request_t req; + memset(&req, 0, sizeof(req)); + req.method = "HEAD"; + req.url = url; + req.timeout_seconds = timeout_seconds > 0 ? timeout_seconds : 10; + req.capture_headers = 1; + + nostr_http_response_t resp; + int rc = nostr_http_request(&req, &resp); + if (rc != NOSTR_SUCCESS) return rc; + + if (resp.status_code < 200 || resp.status_code >= 300) { + nostr_http_response_free(&resp); + return NOSTR_ERROR_NETWORK_FAILED; + } + + memset(descriptor_out, 0, sizeof(*descriptor_out)); + snprintf(descriptor_out->sha256, sizeof(descriptor_out->sha256), "%s", sha256_hex); + if (build_blob_url_local(server_url, sha256_hex, descriptor_out->url, sizeof(descriptor_out->url)) != 0) { + nostr_http_response_free(&resp); + return NOSTR_ERROR_INVALID_INPUT; + } + if (resp.content_type && resp.content_type[0] != '\0') { + snprintf(descriptor_out->content_type, sizeof(descriptor_out->content_type), "%s", resp.content_type); + } + + if (resp.headers_raw) { + const char* key = "Content-Length:"; + char* p = strstr(resp.headers_raw, key); + if (p) { + p += strlen(key); + while (*p == ' ' || *p == '\t') p++; + descriptor_out->size = strtol(p, NULL, 10); + } + } + + nostr_http_response_free(&resp); + return NOSTR_SUCCESS; +} + +int blossom_delete(const char* server_url, + const char* sha256_hex, + const unsigned char* private_key, + int timeout_seconds) { + if (!server_url || !sha256_hex || !private_key || !is_hex64_local(sha256_hex)) { + return NOSTR_ERROR_INVALID_INPUT; + } + + char url[1024]; + if (build_blob_url_local(server_url, sha256_hex, url, sizeof(url)) != 0) return NOSTR_ERROR_INVALID_INPUT; + + char* auth = blossom_create_auth_header(private_key, "delete", sha256_hex, 300); + if (!auth) return NOSTR_ERROR_CRYPTO_FAILED; + + char auth_header[1200]; + snprintf(auth_header, sizeof(auth_header), "Authorization: %s", auth); + free(auth); + + const char* headers[] = {"Accept: application/json", auth_header, NULL}; + + nostr_http_request_t req; + memset(&req, 0, sizeof(req)); + req.method = "DELETE"; + req.url = url; + req.headers = headers; + req.timeout_seconds = timeout_seconds > 0 ? timeout_seconds : 20; + + nostr_http_response_t resp; + int rc = nostr_http_request(&req, &resp); + if (rc != NOSTR_SUCCESS) return rc; + int ok = (resp.status_code >= 200 && resp.status_code < 300) ? NOSTR_SUCCESS : NOSTR_ERROR_NETWORK_FAILED; + nostr_http_response_free(&resp); + return ok; +} + +int blossom_list(const char* server_url, + const char* pubkey_hex, + int timeout_seconds, + blossom_blob_descriptor_t** descriptors_out, + int* count_out) { + if (!server_url || !pubkey_hex || !descriptors_out || !count_out) return NOSTR_ERROR_INVALID_INPUT; + + *descriptors_out = NULL; + *count_out = 0; + + char base[512]; + trim_trailing_slash_local(server_url, base, sizeof(base)); + char url[1024]; + snprintf(url, sizeof(url), "%s/list/%s", base, pubkey_hex); + + nostr_http_request_t req; + memset(&req, 0, sizeof(req)); + req.method = "GET"; + req.url = url; + req.timeout_seconds = timeout_seconds > 0 ? timeout_seconds : 20; + + nostr_http_response_t resp; + int rc = nostr_http_request(&req, &resp); + if (rc != NOSTR_SUCCESS) return rc; + + if (resp.status_code < 200 || resp.status_code >= 300) { + nostr_http_response_free(&resp); + return NOSTR_ERROR_NETWORK_FAILED; + } + + cJSON* arr = cJSON_Parse(resp.body ? resp.body : "[]"); + if (!arr || !cJSON_IsArray(arr)) { + cJSON_Delete(arr); + nostr_http_response_free(&resp); + return NOSTR_ERROR_IO_FAILED; + } + + int n = cJSON_GetArraySize(arr); + blossom_blob_descriptor_t* out = (blossom_blob_descriptor_t*)calloc((size_t)n, sizeof(blossom_blob_descriptor_t)); + if (!out && n > 0) { + cJSON_Delete(arr); + nostr_http_response_free(&resp); + return NOSTR_ERROR_MEMORY_FAILED; + } + + for (int i = 0; i < n; i++) { + cJSON* it = cJSON_GetArrayItem(arr, i); + char* tmp = cJSON_PrintUnformatted(it); + if (tmp) { + (void)parse_descriptor_local(tmp, &out[i]); + free(tmp); + } + } + + *descriptors_out = out; + *count_out = n; + + cJSON_Delete(arr); + nostr_http_response_free(&resp); + return NOSTR_SUCCESS; +} diff --git a/nostr_core/blossom_client.h b/nostr_core/blossom_client.h new file mode 100644 index 00000000..8f6d97a7 --- /dev/null +++ b/nostr_core/blossom_client.h @@ -0,0 +1,82 @@ +/* + * Blossom HTTP Client + */ + +#ifndef NOSTR_BLOSSOM_CLIENT_H +#define NOSTR_BLOSSOM_CLIENT_H + +#include "nostr_common.h" +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct { + char sha256[65]; + char url[512]; + long size; + char content_type[128]; + long created; +} blossom_blob_descriptor_t; + +void blossom_set_ca_bundle(const char* ca_bundle_path); + +char* blossom_create_auth_header(const unsigned char* private_key, + const char* operation, + const char* sha256_hex, + int expiration_seconds); + +int blossom_upload(const char* server_url, + const unsigned char* data, + size_t data_len, + const char* content_type, + const unsigned char* private_key, + const char* sha256_hex, + int timeout_seconds, + blossom_blob_descriptor_t* descriptor_out); + +int blossom_upload_file(const char* server_url, + const char* file_path, + const char* content_type, + const unsigned char* private_key, + int timeout_seconds, + blossom_blob_descriptor_t* descriptor_out); + +int blossom_download(const char* server_url, + const char* sha256_hex, + int timeout_seconds, + size_t max_bytes, + unsigned char** body_out, + size_t* body_len_out, + char* content_type_out, + size_t content_type_out_size); + +int blossom_download_to_file(const char* server_url, + const char* sha256_hex, + const char* output_path, + int timeout_seconds, + size_t max_bytes, + blossom_blob_descriptor_t* descriptor_out); + +int blossom_head(const char* server_url, + const char* sha256_hex, + int timeout_seconds, + blossom_blob_descriptor_t* descriptor_out); + +int blossom_delete(const char* server_url, + const char* sha256_hex, + const unsigned char* private_key, + int timeout_seconds); + +int blossom_list(const char* server_url, + const char* pubkey_hex, + int timeout_seconds, + blossom_blob_descriptor_t** descriptors_out, + int* count_out); + +#ifdef __cplusplus +} +#endif + +#endif /* NOSTR_BLOSSOM_CLIENT_H */ diff --git a/nostr_core/cashu_mint.c b/nostr_core/cashu_mint.c index e8bdf161..65ea1a76 100644 --- a/nostr_core/cashu_mint.c +++ b/nostr_core/cashu_mint.c @@ -5,6 +5,7 @@ #include "cashu_mint.h" #include "../cjson/cJSON.h" #include "utils.h" +#include "nostr_http.h" #include #include #include @@ -26,22 +27,8 @@ int nostr_secp256k1_ec_pubkey_combine(nostr_secp256k1_pubkey* out, const nostr_secp256k1_pubkey* const* ins, size_t n); -#include - -typedef struct { - char* data; - size_t size; - size_t capacity; -} cashu_http_response_t; - -static char g_cashu_ca_bundle_path[512] = {0}; - void cashu_mint_set_ca_bundle(const char* ca_bundle_path) { - if (!ca_bundle_path || ca_bundle_path[0] == '\0') { - g_cashu_ca_bundle_path[0] = '\0'; - return; - } - snprintf(g_cashu_ca_bundle_path, sizeof(g_cashu_ca_bundle_path), "%s", ca_bundle_path); + nostr_http_set_ca_bundle(ca_bundle_path); } static char* cashu_strdup(const char* s) { @@ -61,30 +48,6 @@ static int cashu_is_hex_string_len(const char* s, size_t expected_len) { return 1; } -static size_t cashu_write_callback(void* contents, size_t size, size_t nmemb, void* userp) { - cashu_http_response_t* response = (cashu_http_response_t*)userp; - size_t total_size = size * nmemb; - - if (response->size + total_size + 1 > response->capacity) { - size_t new_capacity = response->capacity ? response->capacity * 2 : 1024; - while (new_capacity < response->size + total_size + 1) { - new_capacity *= 2; - } - - char* new_data = (char*)realloc(response->data, new_capacity); - if (!new_data) return 0; - - response->data = new_data; - response->capacity = new_capacity; - } - - memcpy(response->data + response->size, contents, total_size); - response->size += total_size; - response->data[response->size] = '\0'; - - return total_size; -} - static int cashu_http_json_request(const char* method, const char* url, const char* body, @@ -98,58 +61,32 @@ static int cashu_http_json_request(const char* method, *response_out = NULL; if (status_out) *status_out = 0; - CURL* curl = curl_easy_init(); - if (!curl) { - return NOSTR_ERROR_NETWORK_FAILED; - } - - cashu_http_response_t response = {0}; - response.capacity = 1024; - response.data = (char*)malloc(response.capacity); - if (!response.data) { - curl_easy_cleanup(curl); - return NOSTR_ERROR_MEMORY_FAILED; - } - response.data[0] = '\0'; - - struct curl_slist* headers = NULL; - headers = curl_slist_append(headers, "Accept: application/json"); - headers = curl_slist_append(headers, "Content-Type: application/json"); - - curl_easy_setopt(curl, CURLOPT_URL, url); - curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); - curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, (curl_write_callback)cashu_write_callback); - curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); - curl_easy_setopt(curl, CURLOPT_TIMEOUT, (long)(timeout_seconds > 0 ? timeout_seconds : 10)); - curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); - curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 3L); - curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L); - curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L); - curl_easy_setopt(curl, CURLOPT_USERAGENT, "nostr-core/1.0"); - if (g_cashu_ca_bundle_path[0] != '\0') { - curl_easy_setopt(curl, CURLOPT_CAINFO, g_cashu_ca_bundle_path); - } + int rc = NOSTR_ERROR_CASHU_HTTP_FAILED; + long status = 0; if (strcmp(method, "POST") == 0) { - curl_easy_setopt(curl, CURLOPT_POST, 1L); - curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body ? body : "{}"); + rc = nostr_http_post_json(url, + body ? body : "{}", + timeout_seconds > 0 ? timeout_seconds : 10, + response_out, + &status); + } else { + rc = nostr_http_get(url, + timeout_seconds > 0 ? timeout_seconds : 10, + response_out, + &status); } - CURLcode res = curl_easy_perform(curl); - long response_code = 0; - curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &response_code); + if (status_out) *status_out = status; - curl_slist_free_all(headers); - curl_easy_cleanup(curl); - - if (status_out) *status_out = response_code; - - if (res != CURLE_OK) { - free(response.data); + if (rc != NOSTR_SUCCESS) { + if (*response_out) { + free(*response_out); + *response_out = NULL; + } return NOSTR_ERROR_CASHU_HTTP_FAILED; } - *response_out = response.data; return NOSTR_SUCCESS; } diff --git a/nostr_core/nip005.c b/nostr_core/nip005.c index cb9325da..a2e2361b 100644 --- a/nostr_core/nip005.c +++ b/nostr_core/nip005.c @@ -12,46 +12,7 @@ #include -#include - - -// Structure for HTTP response handling -typedef struct { - char* data; - size_t size; - size_t capacity; -} nip05_http_response_t; - -/** - * Callback function for curl to write HTTP response data - */ -static size_t nip05_write_callback(void* contents, size_t size, size_t nmemb, void* userp) { - nip05_http_response_t* response = (nip05_http_response_t*)userp; - size_t total_size = size * nmemb; - - // Check if we need to expand the buffer - if (response->size + total_size >= response->capacity) { - size_t new_capacity = response->capacity * 2; - if (new_capacity < response->size + total_size + 1) { - new_capacity = response->size + total_size + 1; - } - - char* new_data = realloc(response->data, new_capacity); - if (!new_data) { - return 0; // Out of memory - } - - response->data = new_data; - response->capacity = new_capacity; - } - - // Append the new data - memcpy(response->data + response->size, contents, total_size); - response->size += total_size; - response->data[response->size] = '\0'; - - return total_size; -} +#include "nostr_http.h" /** * Parse and validate a NIP-05 identifier into local part and domain @@ -104,49 +65,22 @@ static int nip05_http_get(const char* url, int timeout_seconds, char** response_ if (!url || !response_data) { return NOSTR_ERROR_INVALID_INPUT; } - - CURL* curl = curl_easy_init(); - if (!curl) { - return NOSTR_ERROR_NETWORK_FAILED; - } - - nip05_http_response_t response = {0}; - response.capacity = 1024; - response.data = malloc(response.capacity); - if (!response.data) { - curl_easy_cleanup(curl); - return NOSTR_ERROR_MEMORY_FAILED; - } - response.data[0] = '\0'; - - // Set curl options with proper type casting to fix warnings - curl_easy_setopt(curl, CURLOPT_URL, url); - curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, (curl_write_callback)nip05_write_callback); - curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); - curl_easy_setopt(curl, CURLOPT_TIMEOUT, (long)(timeout_seconds > 0 ? timeout_seconds : NIP05_DEFAULT_TIMEOUT)); - curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 0L); // NIP-05 forbids redirects - curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L); - curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L); - curl_easy_setopt(curl, CURLOPT_USERAGENT, "nostr-core/1.0"); - - // Perform the request - CURLcode res = curl_easy_perform(curl); - long response_code = 0; - curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &response_code); - - curl_easy_cleanup(curl); - - if (res != CURLE_OK) { - free(response.data); + + long status = 0; + int rc = nostr_http_get(url, + timeout_seconds > 0 ? timeout_seconds : NIP05_DEFAULT_TIMEOUT, + response_data, + &status); + if (rc != NOSTR_SUCCESS) { return NOSTR_ERROR_NIP05_HTTP_FAILED; } - - if (response_code != 200) { - free(response.data); + + if (status != 200) { + free(*response_data); + *response_data = NULL; return NOSTR_ERROR_NIP05_HTTP_FAILED; } - - *response_data = response.data; + return NOSTR_SUCCESS; } diff --git a/nostr_core/nip011.c b/nostr_core/nip011.c index 05912e4c..0e64c58b 100644 --- a/nostr_core/nip011.c +++ b/nostr_core/nip011.c @@ -11,50 +11,13 @@ #ifndef DISABLE_NIP05 // NIP-11 uses the same HTTP infrastructure as NIP-05 -#include +#include "nostr_http.h" // Maximum sizes for NIP-11 operations #define NIP11_MAX_URL_SIZE 512 #define NIP11_MAX_RESPONSE_SIZE 16384 #define NIP11_DEFAULT_TIMEOUT 10 -// Structure for HTTP response handling (same as NIP-05) -typedef struct { - char* data; - size_t size; - size_t capacity; -} nip11_http_response_t; - -/** - * Callback function for curl to write HTTP response data - */ -static size_t nip11_write_callback(void* contents, size_t size, size_t nmemb, nip11_http_response_t* response) { - size_t total_size = size * nmemb; - - // Check if we need to expand the buffer - if (response->size + total_size >= response->capacity) { - size_t new_capacity = response->capacity * 2; - if (new_capacity < response->size + total_size + 1) { - new_capacity = response->size + total_size + 1; - } - - char* new_data = realloc(response->data, new_capacity); - if (!new_data) { - return 0; // Out of memory - } - - response->data = new_data; - response->capacity = new_capacity; - } - - // Append the new data - memcpy(response->data + response->size, contents, total_size); - response->size += total_size; - response->data[response->size] = '\0'; - - return total_size; -} - /** * Convert WebSocket URL to HTTP URL for NIP-11 document retrieval */ @@ -341,60 +304,35 @@ int nostr_nip11_fetch_relay_info(const char* relay_url, nostr_relay_info_t** inf return NOSTR_ERROR_MEMORY_FAILED; } - // Make HTTP request with NIP-11 required header - CURL* curl = curl_easy_init(); - if (!curl) { - free(http_url); - free(info); - return NOSTR_ERROR_NETWORK_FAILED; - } - - // Use the HTTP response structure - nip11_http_response_t response = {0}; - response.capacity = 1024; - response.data = malloc(response.capacity); - if (!response.data) { - curl_easy_cleanup(curl); - free(http_url); - free(info); - return NOSTR_ERROR_MEMORY_FAILED; - } - response.data[0] = '\0'; - - // Set up headers for NIP-11 - struct curl_slist* headers = NULL; - headers = curl_slist_append(headers, "Accept: application/nostr+json"); - - // Set curl options - use proper function pointer cast - curl_easy_setopt(curl, CURLOPT_URL, http_url); - curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); - curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, (curl_write_callback)nip11_write_callback); - curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); - curl_easy_setopt(curl, CURLOPT_TIMEOUT, (long)(timeout_seconds > 0 ? timeout_seconds : NIP11_DEFAULT_TIMEOUT)); - curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); // NIP-11 allows redirects - curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 3L); - curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L); - curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L); - curl_easy_setopt(curl, CURLOPT_USERAGENT, "nostr-core/1.0"); - - // Perform the request - CURLcode res = curl_easy_perform(curl); - long response_code = 0; - curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &response_code); - - curl_slist_free_all(headers); - curl_easy_cleanup(curl); + const char* headers[] = { + "Accept: application/nostr+json", + NULL + }; + + nostr_http_request_t req; + memset(&req, 0, sizeof(req)); + req.method = "GET"; + req.url = http_url; + req.headers = headers; + req.timeout_seconds = timeout_seconds > 0 ? timeout_seconds : NIP11_DEFAULT_TIMEOUT; + req.follow_redirects = 1; + req.max_redirects = 3; + + nostr_http_response_t response; + int http_rc = nostr_http_request(&req, &response); free(http_url); - - if (res != CURLE_OK || response_code != 200) { - free(response.data); + + if (http_rc != NOSTR_SUCCESS || response.status_code != 200) { + if (http_rc == NOSTR_SUCCESS) { + nostr_http_response_free(&response); + } free(info); return NOSTR_ERROR_NIP05_HTTP_FAILED; } - + // Parse the relay information - int parse_result = nip11_parse_relay_info(response.data, info); - free(response.data); + int parse_result = nip11_parse_relay_info(response.body, info); + nostr_http_response_free(&response); if (parse_result != NOSTR_SUCCESS) { nostr_nip11_relay_info_free(info); diff --git a/nostr_core/nostr_core.h b/nostr_core/nostr_core.h index 4cf5de14..71117bcd 100644 --- a/nostr_core/nostr_core.h +++ b/nostr_core/nostr_core.h @@ -193,6 +193,9 @@ extern "C" { #include "nip061.h" // Nutzaps #include "cashu_mint.h" // Cashu mint HTTP client +#include "nostr_http.h" // Shared HTTP client +#include "blossom_client.h" // Blossom HTTP client + // Authentication and request validation system #include "request_validator.h" // Request validation and authentication rules diff --git a/nostr_core/nostr_http.c b/nostr_core/nostr_http.c new file mode 100644 index 00000000..66eb0ce1 --- /dev/null +++ b/nostr_core/nostr_http.c @@ -0,0 +1,289 @@ +/* + * NOSTR Core Library - Shared HTTP Client + */ + +#include "nostr_http.h" + +#include +#include +#include +#include +#include + +typedef struct { + char* data; + size_t len; + size_t cap; + size_t max_bytes; + int truncated; +} nostr_http_buffer_t; + +static char* nostr_http_strdup_local(const char* s) { + if (!s) return NULL; + size_t n = strlen(s); + char* out = (char*)malloc(n + 1U); + if (!out) return NULL; + memcpy(out, s, n + 1U); + return out; +} + +static char g_ca_bundle_path[512] = {0}; + +static int append_bytes(nostr_http_buffer_t* b, const void* src, size_t n) { + if (!b || !src || n == 0) return 1; + + if (b->max_bytes > 0 && b->len >= b->max_bytes) { + b->truncated = 1; + return 1; + } + + size_t to_copy = n; + if (b->max_bytes > 0) { + size_t remaining = b->max_bytes - b->len; + if (to_copy > remaining) { + to_copy = remaining; + b->truncated = 1; + } + } + + if (b->len + to_copy + 1U > b->cap) { + size_t new_cap = b->cap == 0 ? 1024U : b->cap; + while (new_cap < b->len + to_copy + 1U) { + new_cap *= 2U; + } + char* p = (char*)realloc(b->data, new_cap); + if (!p) return 0; + b->data = p; + b->cap = new_cap; + } + + memcpy(b->data + b->len, src, to_copy); + b->len += to_copy; + b->data[b->len] = '\0'; + return 1; +} + +static size_t write_cb(void* contents, size_t size, size_t nmemb, void* userp) { + nostr_http_buffer_t* rb = (nostr_http_buffer_t*)userp; + size_t total = size * nmemb; + if (!rb || total == 0) return total; + if (!append_bytes(rb, contents, total)) return 0; + return total; +} + +static size_t header_cb(void* contents, size_t size, size_t nmemb, void* userp) { + nostr_http_buffer_t* hb = (nostr_http_buffer_t*)userp; + size_t total = size * nmemb; + if (!hb || total == 0) return total; + if (!append_bytes(hb, contents, total)) return 0; + return total; +} + +void nostr_http_set_ca_bundle(const char* ca_bundle_path) { + if (!ca_bundle_path || ca_bundle_path[0] == '\0') { + g_ca_bundle_path[0] = '\0'; + return; + } + strncpy(g_ca_bundle_path, ca_bundle_path, sizeof(g_ca_bundle_path) - 1); + g_ca_bundle_path[sizeof(g_ca_bundle_path) - 1] = '\0'; +} + +const char* nostr_http_detect_ca_bundle(void) { + const char* env = getenv("SSL_CERT_FILE"); + if (env && env[0] != '\0' && access(env, R_OK) == 0) { + return env; + } + + static const char* candidates[] = { + "/etc/ssl/certs/ca-certificates.crt", + "/etc/ssl/cert.pem", + "/etc/pki/tls/certs/ca-bundle.crt", + "/etc/ssl/ca-bundle.pem" + }; + + for (size_t i = 0; i < sizeof(candidates) / sizeof(candidates[0]); i++) { + if (access(candidates[i], R_OK) == 0) { + return candidates[i]; + } + } + return NULL; +} + +int nostr_http_request(const nostr_http_request_t* req, nostr_http_response_t* resp) { + if (!req || !req->url || !resp) return NOSTR_ERROR_INVALID_INPUT; + + memset(resp, 0, sizeof(*resp)); + + CURL* curl = curl_easy_init(); + if (!curl) return NOSTR_ERROR_NETWORK_FAILED; + + const char* method = (req->method && req->method[0] != '\0') ? req->method : "GET"; + int timeout = req->timeout_seconds > 0 ? req->timeout_seconds : 30; + int follow = req->follow_redirects; + if (follow != 0 && follow != 1) follow = 1; + int max_redirs = req->max_redirects > 0 ? req->max_redirects : 3; + const char* user_agent = (req->user_agent && req->user_agent[0] != '\0') ? req->user_agent : "nostr-core/1.0"; + + nostr_http_buffer_t body = {0}; + body.max_bytes = req->max_response_bytes; + + nostr_http_buffer_t headers = {0}; + + struct curl_slist* header_list = NULL; + if (req->headers) { + for (const char** h = req->headers; *h; h++) { + if ((*h)[0] != '\0') header_list = curl_slist_append(header_list, *h); + } + } + + curl_easy_setopt(curl, CURLOPT_URL, req->url); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, (long)timeout); + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, (long)follow); + curl_easy_setopt(curl, CURLOPT_MAXREDIRS, (long)max_redirs); + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L); + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L); + curl_easy_setopt(curl, CURLOPT_USERAGENT, user_agent); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &body); + + if (req->capture_headers) { + curl_easy_setopt(curl, CURLOPT_HEADERFUNCTION, header_cb); + curl_easy_setopt(curl, CURLOPT_HEADERDATA, &headers); + } + + if (header_list) { + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, header_list); + } + + if (g_ca_bundle_path[0] != '\0') { + curl_easy_setopt(curl, CURLOPT_CAINFO, g_ca_bundle_path); + } + + if (strcasecmp(method, "GET") == 0) { + curl_easy_setopt(curl, CURLOPT_HTTPGET, 1L); + } else if (strcasecmp(method, "POST") == 0) { + curl_easy_setopt(curl, CURLOPT_POST, 1L); + if (req->body && req->body_len > 0) { + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, req->body); + curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, (long)req->body_len); + } + } else if (strcasecmp(method, "HEAD") == 0) { + curl_easy_setopt(curl, CURLOPT_NOBODY, 1L); + curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "HEAD"); + } else { + curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, method); + if (req->body && req->body_len > 0) { + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, req->body); + curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, (long)req->body_len); + } + } + + CURLcode rc = curl_easy_perform(curl); + long status = 0; + char* content_type = NULL; + char* content_type_copy = NULL; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status); + curl_easy_getinfo(curl, CURLINFO_CONTENT_TYPE, &content_type); + if (content_type && content_type[0] != '\0') { + content_type_copy = nostr_http_strdup_local(content_type); + } + + if (header_list) curl_slist_free_all(header_list); + curl_easy_cleanup(curl); + + if (rc != CURLE_OK) { + free(body.data); + free(headers.data); + free(content_type_copy); + return NOSTR_ERROR_NETWORK_FAILED; + } + + resp->status_code = status; + resp->body = body.data ? body.data : nostr_http_strdup_local(""); + resp->body_len = body.data ? body.len : 0; + resp->headers_raw = headers.data; + resp->content_type = content_type_copy; + resp->truncated = body.truncated; + + if (!resp->body) { + free(resp->headers_raw); + free(resp->content_type); + memset(resp, 0, sizeof(*resp)); + return NOSTR_ERROR_MEMORY_FAILED; + } + + return NOSTR_SUCCESS; +} + +void nostr_http_response_free(nostr_http_response_t* resp) { + if (!resp) return; + free(resp->body); + free(resp->content_type); + free(resp->headers_raw); + memset(resp, 0, sizeof(*resp)); +} + +int nostr_http_get(const char* url, int timeout_seconds, char** body_out, long* status_out) { + if (!url || !body_out) return NOSTR_ERROR_INVALID_INPUT; + + *body_out = NULL; + if (status_out) *status_out = 0; + + nostr_http_request_t req; + memset(&req, 0, sizeof(req)); + req.method = "GET"; + req.url = url; + req.timeout_seconds = timeout_seconds; + req.follow_redirects = 1; + req.max_redirects = 3; + + nostr_http_response_t resp; + int rc = nostr_http_request(&req, &resp); + if (rc != NOSTR_SUCCESS) return rc; + + if (status_out) *status_out = resp.status_code; + *body_out = resp.body; + free(resp.content_type); + free(resp.headers_raw); + return NOSTR_SUCCESS; +} + +int nostr_http_post_json(const char* url, + const char* json_body, + int timeout_seconds, + char** body_out, + long* status_out) { + if (!url || !body_out) return NOSTR_ERROR_INVALID_INPUT; + + *body_out = NULL; + if (status_out) *status_out = 0; + + const char* headers[] = { + "Accept: application/json", + "Content-Type: application/json", + NULL + }; + + const char* payload = json_body ? json_body : "{}"; + + nostr_http_request_t req; + memset(&req, 0, sizeof(req)); + req.method = "POST"; + req.url = url; + req.headers = headers; + req.body = (const unsigned char*)payload; + req.body_len = strlen(payload); + req.timeout_seconds = timeout_seconds; + req.follow_redirects = 1; + req.max_redirects = 3; + + nostr_http_response_t resp; + int rc = nostr_http_request(&req, &resp); + if (rc != NOSTR_SUCCESS) return rc; + + if (status_out) *status_out = resp.status_code; + *body_out = resp.body; + free(resp.content_type); + free(resp.headers_raw); + return NOSTR_SUCCESS; +} diff --git a/nostr_core/nostr_http.h b/nostr_core/nostr_http.h new file mode 100644 index 00000000..5892598a --- /dev/null +++ b/nostr_core/nostr_http.h @@ -0,0 +1,55 @@ +/* + * NOSTR Core Library - Shared HTTP Client + */ + +#ifndef NOSTR_HTTP_H +#define NOSTR_HTTP_H + +#include "nostr_common.h" +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct { + char* body; + size_t body_len; + long status_code; + char* content_type; + char* headers_raw; + int truncated; +} nostr_http_response_t; + +typedef struct { + const char* method; + const char* url; + const char** headers; + const unsigned char* body; + size_t body_len; + int timeout_seconds; + size_t max_response_bytes; + int follow_redirects; + int max_redirects; + const char* user_agent; + int capture_headers; +} nostr_http_request_t; + +void nostr_http_set_ca_bundle(const char* ca_bundle_path); +const char* nostr_http_detect_ca_bundle(void); + +int nostr_http_request(const nostr_http_request_t* req, nostr_http_response_t* resp); +void nostr_http_response_free(nostr_http_response_t* resp); + +int nostr_http_get(const char* url, int timeout_seconds, char** body_out, long* status_out); +int nostr_http_post_json(const char* url, + const char* json_body, + int timeout_seconds, + char** body_out, + long* status_out); + +#ifdef __cplusplus +} +#endif + +#endif /* NOSTR_HTTP_H */ diff --git a/tests/blossom_client_live_test b/tests/blossom_client_live_test new file mode 100755 index 00000000..f68cf3a7 Binary files /dev/null and b/tests/blossom_client_live_test differ diff --git a/tests/blossom_client_live_test.c b/tests/blossom_client_live_test.c new file mode 100644 index 00000000..9bbfc25f --- /dev/null +++ b/tests/blossom_client_live_test.c @@ -0,0 +1,213 @@ +/* + * Blossom Live Client Integration Test + * + * Uploads a text file to a Blossom server, downloads it back, + * and verifies the round-trip content. + * + * Required env: + * BLOSSOM_TEST_PRIVKEY_HEX = 64-char hex private key + * + * Optional env: + * BLOSSOM_TEST_SERVER = Blossom base URL (default: https://blossom.laantungir.net) + */ + +#include +#include +#include +#include + +#include "../nostr_core/nostr_core.h" + +static int write_text_file(const char* path, const char* text) { + if (!path || !text) return -1; + FILE* fp = fopen(path, "wb"); + if (!fp) return -1; + size_t len = strlen(text); + size_t n = fwrite(text, 1, len, fp); + fclose(fp); + return (n == len) ? 0 : -1; +} + +static int read_text_file(const char* path, char** out_text, size_t* out_len) { + if (!path || !out_text || !out_len) return -1; + + *out_text = NULL; + *out_len = 0; + + FILE* fp = fopen(path, "rb"); + if (!fp) return -1; + + if (fseek(fp, 0, SEEK_END) != 0) { + fclose(fp); + return -1; + } + + long sz = ftell(fp); + if (sz < 0) { + fclose(fp); + return -1; + } + + if (fseek(fp, 0, SEEK_SET) != 0) { + fclose(fp); + return -1; + } + + char* buf = (char*)malloc((size_t)sz + 1U); + if (!buf) { + fclose(fp); + return -1; + } + + size_t n = fread(buf, 1, (size_t)sz, fp); + fclose(fp); + if (n != (size_t)sz) { + free(buf); + return -1; + } + + buf[n] = '\0'; + *out_text = buf; + *out_len = n; + return 0; +} + +static int load_private_key_from_env(unsigned char out_privkey[32]) { + const char* hex = getenv("BLOSSOM_TEST_PRIVKEY_HEX"); + if (!hex || hex[0] == '\0') { + printf("[SKIP] BLOSSOM_TEST_PRIVKEY_HEX not set\n"); + return 1; + } + + if (strlen(hex) != 64) { + printf("[SKIP] BLOSSOM_TEST_PRIVKEY_HEX must be 64 hex chars\n"); + return 1; + } + + if (nostr_hex_to_bytes(hex, out_privkey, 32) != 0) { + printf("[SKIP] BLOSSOM_TEST_PRIVKEY_HEX is not valid hex\n"); + return 1; + } + + return 0; +} + +int main(void) { + const char* server = getenv("BLOSSOM_TEST_SERVER"); + if (!server || server[0] == '\0') { + server = "https://blossom.laantungir.net"; + } + + printf("Blossom Live Client Integration Test\n"); + printf("====================================\n"); + printf("Server: %s\n", server); + + const char* ca_bundle = nostr_http_detect_ca_bundle(); + if (ca_bundle && ca_bundle[0] != '\0') { + nostr_http_set_ca_bundle(ca_bundle); + } + + unsigned char private_key[32] = {0}; + int key_rc = load_private_key_from_env(private_key); + if (key_rc != 0) { + return (key_rc == 1) ? 0 : 1; + } + + char input_text[1024]; + time_t now = time(NULL); + int nw = snprintf(input_text, + sizeof(input_text), + "Didactyl Blossom live test\n" + "timestamp=%ld\n" + "server=%s\n" + "message=Round-trip file upload/download validation.\n", + (long)now, + server); + if (nw < 0 || (size_t)nw >= sizeof(input_text)) { + printf("[FAIL] Unable to create input text payload\n"); + return 1; + } + + const char* input_path = "tests/.tmp_blossom_input.txt"; + const char* output_path = "tests/.tmp_blossom_output.txt"; + + if (write_text_file(input_path, input_text) != 0) { + printf("[FAIL] Unable to write input file: %s\n", input_path); + return 1; + } + + blossom_blob_descriptor_t uploaded; + memset(&uploaded, 0, sizeof(uploaded)); + + int rc = blossom_upload_file(server, + input_path, + "text/plain; charset=utf-8", + private_key, + 30, + &uploaded); + if (rc != NOSTR_SUCCESS) { + printf("[FAIL] blossom_upload_file rc=%d\n", rc); + remove(input_path); + return 1; + } + + if (uploaded.sha256[0] == '\0') { + printf("[FAIL] Upload succeeded but returned empty sha256\n"); + remove(input_path); + return 1; + } + + printf("[INFO] Uploaded sha256: %s\n", uploaded.sha256); + printf("[INFO] Uploaded url: %s\n", uploaded.url); + + blossom_blob_descriptor_t downloaded; + memset(&downloaded, 0, sizeof(downloaded)); + + rc = blossom_download_to_file(server, + uploaded.sha256, + output_path, + 30, + 1024U * 1024U, + &downloaded); + if (rc != NOSTR_SUCCESS) { + printf("[FAIL] blossom_download_to_file rc=%d\n", rc); + remove(input_path); + return 1; + } + + char* output_text = NULL; + size_t output_len = 0; + if (read_text_file(output_path, &output_text, &output_len) != 0) { + printf("[FAIL] Unable to read output file: %s\n", output_path); + remove(input_path); + remove(output_path); + return 1; + } + + size_t input_len = strlen(input_text); + int same = (input_len == output_len) && (memcmp(input_text, output_text, input_len) == 0); + if (!same) { + printf("[FAIL] Round-trip mismatch: input_len=%zu output_len=%zu\n", input_len, output_len); + free(output_text); + remove(input_path); + remove(output_path); + return 1; + } + + if (strcmp(uploaded.sha256, downloaded.sha256) != 0) { + printf("[FAIL] SHA mismatch: uploaded=%s downloaded=%s\n", uploaded.sha256, downloaded.sha256); + free(output_text); + remove(input_path); + remove(output_path); + return 1; + } + + printf("[PASS] Upload/download round-trip verified\n"); + printf("[PASS] sha256=%s\n", uploaded.sha256); + printf("[PASS] bytes=%zu\n", output_len); + + free(output_text); + remove(input_path); + remove(output_path); + return 0; +} diff --git a/tests/blossom_client_test b/tests/blossom_client_test new file mode 100755 index 00000000..82a4289f Binary files /dev/null and b/tests/blossom_client_test differ diff --git a/tests/blossom_client_test.c b/tests/blossom_client_test.c new file mode 100644 index 00000000..f80591c7 --- /dev/null +++ b/tests/blossom_client_test.c @@ -0,0 +1,139 @@ +/* + * blossom_client unit/integration tests using local mock Blossom server. + */ + +#include +#include +#include + +#include "../nostr_core/nostr_core.h" + +static int tests_run = 0; +static int tests_passed = 0; + +#define TEST_ASSERT(cond, msg) do { \ + tests_run++; \ + if (cond) { tests_passed++; printf("✅ %s\n", msg); } \ + else { printf("❌ %s\n", msg); } \ +} while (0) + +static const char* base_url(void) { + const char* env = getenv("MOCK_BLOSSOM_BASE"); + return (env && env[0] != '\0') ? env : "http://127.0.0.1:18081"; +} + +static void test_auth_header(void) { + unsigned char priv[32]; + memset(priv, 1, sizeof(priv)); + + char sha[65]; + memset(sha, 'a', 64); + sha[64] = '\0'; + + char* hdr = blossom_create_auth_header(priv, "upload", sha, 300); + TEST_ASSERT(hdr != NULL, "blossom_create_auth_header returns non-null"); + TEST_ASSERT(hdr && strncmp(hdr, "Nostr ", 6) == 0, "blossom_create_auth_header prefix is 'Nostr '"); + free(hdr); +} + +static void test_invalid_inputs(void) { + blossom_blob_descriptor_t d; + memset(&d, 0, sizeof(d)); + + int rc = blossom_upload(NULL, (const unsigned char*)"x", 1, NULL, NULL, NULL, 5, &d); + TEST_ASSERT(rc == NOSTR_ERROR_INVALID_INPUT, "blossom_upload rejects null server"); + + unsigned char* body = NULL; + size_t body_len = 0; + rc = blossom_download(base_url(), "not-a-sha", 5, 1024, &body, &body_len, NULL, 0); + TEST_ASSERT(rc == NOSTR_ERROR_INVALID_INPUT, "blossom_download rejects invalid sha"); + + rc = blossom_head(base_url(), "not-a-sha", 5, &d); + TEST_ASSERT(rc == NOSTR_ERROR_INVALID_INPUT, "blossom_head rejects invalid sha"); + + rc = blossom_delete(base_url(), "not-a-sha", (const unsigned char*)"x", 5); + TEST_ASSERT(rc == NOSTR_ERROR_INVALID_INPUT, "blossom_delete rejects invalid sha"); + + blossom_blob_descriptor_t* items = NULL; + int count = 0; + rc = blossom_list(NULL, "abc", 5, &items, &count); + TEST_ASSERT(rc == NOSTR_ERROR_INVALID_INPUT, "blossom_list rejects null server"); +} + +static void test_round_trip_with_mock(void) { + const char* server = base_url(); + const unsigned char payload[] = "blossom-client-test-payload"; + + blossom_blob_descriptor_t uploaded; + memset(&uploaded, 0, sizeof(uploaded)); + + int rc = blossom_upload(server, + payload, + sizeof(payload) - 1, + "text/plain", + NULL, + NULL, + 5, + &uploaded); + TEST_ASSERT(rc == NOSTR_SUCCESS, "blossom_upload mock success"); + TEST_ASSERT(uploaded.sha256[0] != '\0', "blossom_upload returns sha256"); + + unsigned char* body = NULL; + size_t body_len = 0; + char content_type[128] = {0}; + + rc = blossom_download(server, + uploaded.sha256, + 5, + 4096, + &body, + &body_len, + content_type, + sizeof(content_type)); + TEST_ASSERT(rc == NOSTR_SUCCESS, "blossom_download mock success"); + TEST_ASSERT(body && body_len == sizeof(payload) - 1, "blossom_download length matches"); + TEST_ASSERT(body && memcmp(body, payload, body_len) == 0, "blossom_download payload matches"); + free(body); + + blossom_blob_descriptor_t head; + memset(&head, 0, sizeof(head)); + rc = blossom_head(server, uploaded.sha256, 5, &head); + TEST_ASSERT(rc == NOSTR_SUCCESS, "blossom_head mock success"); + TEST_ASSERT(head.size == (long)(sizeof(payload) - 1), "blossom_head size matches"); + + blossom_blob_descriptor_t* list = NULL; + int list_count = 0; + rc = blossom_list(server, "dummy_pubkey", 5, &list, &list_count); + TEST_ASSERT(rc == NOSTR_SUCCESS, "blossom_list mock success"); + TEST_ASSERT(list_count >= 1, "blossom_list returns at least one blob"); + free(list); + + unsigned char fake_priv[32]; + memset(fake_priv, 2, sizeof(fake_priv)); + rc = blossom_delete(server, uploaded.sha256, fake_priv, 5); + TEST_ASSERT(rc == NOSTR_SUCCESS, "blossom_delete mock success"); + + memset(&head, 0, sizeof(head)); + rc = blossom_head(server, uploaded.sha256, 5, &head); + TEST_ASSERT(rc == NOSTR_ERROR_NETWORK_FAILED, "blossom_head after delete reports missing blob"); +} + +int main(void) { + printf("blossom_client Tests\n"); + printf("====================\n"); + printf("Base URL: %s\n", base_url()); + + if (nostr_init() != NOSTR_SUCCESS) { + printf("❌ nostr_init failed\n"); + return 1; + } + + test_auth_header(); + test_invalid_inputs(); + test_round_trip_with_mock(); + + nostr_cleanup(); + + printf("\nSummary: %d/%d passed\n", tests_passed, tests_run); + return (tests_passed == tests_run) ? 0 : 1; +} diff --git a/tests/blossom_mock_error_test b/tests/blossom_mock_error_test new file mode 100755 index 00000000..1ba9a063 Binary files /dev/null and b/tests/blossom_mock_error_test differ diff --git a/tests/blossom_mock_error_test.c b/tests/blossom_mock_error_test.c new file mode 100644 index 00000000..ee59433c --- /dev/null +++ b/tests/blossom_mock_error_test.c @@ -0,0 +1,67 @@ +/* + * Blossom mock integration error-path tests. + */ + +#include +#include +#include + +#include "../nostr_core/blossom_client.h" +#include "../nostr_core/nostr_common.h" + +static int tests_run = 0; +static int tests_passed = 0; + +#define TEST_ASSERT(cond, msg) do { \ + tests_run++; \ + if (cond) { tests_passed++; printf("✅ %s\n", msg); } \ + else { printf("❌ %s\n", msg); } \ +} while (0) + +static const char* base_url(void) { + const char* env = getenv("MOCK_BLOSSOM_BASE"); + return (env && env[0] != '\0') ? env : "http://127.0.0.1:18081"; +} + +static void make_error_server(char* out, size_t out_size, int status_code) { + snprintf(out, out_size, "%s/blossom/error/%d", base_url(), status_code); +} + +static void test_forced_error_paths(void) { + char server[512]; + char sha[65]; + memset(sha, 'a', 64); + sha[64] = '\0'; + + unsigned char* body = NULL; + size_t body_len = 0; + + make_error_server(server, sizeof(server), 404); + int rc = blossom_download(server, sha, 5, 1024, &body, &body_len, NULL, 0); + TEST_ASSERT(rc == NOSTR_ERROR_NETWORK_FAILED, "blossom_download handles forced 404"); + + make_error_server(server, sizeof(server), 401); + blossom_blob_descriptor_t d; + memset(&d, 0, sizeof(d)); + rc = blossom_upload(server, (const unsigned char*)"x", 1, "text/plain", NULL, NULL, 5, &d); + TEST_ASSERT(rc == NOSTR_ERROR_NETWORK_FAILED, "blossom_upload handles forced 401"); + + make_error_server(server, sizeof(server), 413); + rc = blossom_upload(server, (const unsigned char*)"x", 1, "text/plain", NULL, NULL, 5, &d); + TEST_ASSERT(rc == NOSTR_ERROR_NETWORK_FAILED, "blossom_upload handles forced 413"); + + make_error_server(server, sizeof(server), 500); + rc = blossom_head(server, sha, 5, &d); + TEST_ASSERT(rc == NOSTR_ERROR_NETWORK_FAILED, "blossom_head handles forced 500"); +} + +int main(void) { + printf("blossom mock error-path tests\n"); + printf("=============================\n"); + printf("Base URL: %s\n", base_url()); + + test_forced_error_paths(); + + printf("\nSummary: %d/%d passed\n", tests_passed, tests_run); + return (tests_passed == tests_run) ? 0 : 1; +} diff --git a/tests/mock_blossom_server.py b/tests/mock_blossom_server.py new file mode 100644 index 00000000..6717e704 --- /dev/null +++ b/tests/mock_blossom_server.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +import hashlib +import json +import sys +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import urlparse, parse_qs + +HOST = "127.0.0.1" +PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 18081 + +STORE = {} # sha256 -> {bytes, content_type, created} + + +def json_bytes(obj): + return json.dumps(obj).encode("utf-8") + + +class Handler(BaseHTTPRequestHandler): + server_version = "mock-blossom/0.1" + + def _write_json(self, code, obj): + body = json_bytes(obj) + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _read_body(self): + length = int(self.headers.get("Content-Length", "0")) + return self.rfile.read(length) if length > 0 else b"" + + def _maybe_error_prefix(self): + # /blossom/error//... + parts = [p for p in self.path.split("?")[0].split("/") if p] + if len(parts) >= 3 and parts[0] == "blossom" and parts[1] == "error": + try: + code = int(parts[2]) + return code, parts[3:] + except ValueError: + return None, None + return None, None + + def do_HEAD(self): + parsed = urlparse(self.path) + path = parsed.path + + code, remaining = self._maybe_error_prefix() + if code is not None: + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", "0") + self.end_headers() + return + + if path.startswith("/http/head"): + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", "0") + self.send_header("X-Mock", "head-ok") + self.end_headers() + return + + sha = path.lstrip("/") + item = STORE.get(sha) + if not item: + self.send_response(404) + self.send_header("Content-Length", "0") + self.end_headers() + return + + self.send_response(200) + self.send_header("Content-Type", item["content_type"]) + self.send_header("Content-Length", str(len(item["bytes"]))) + self.end_headers() + + def do_GET(self): + parsed = urlparse(self.path) + path = parsed.path + qs = parse_qs(parsed.query) + + code, remaining = self._maybe_error_prefix() + if code is not None: + self._write_json(code, {"error": f"forced_{code}"}) + return + + if path == "/health": + self._write_json(200, {"ok": True}) + return + + if path == "/http/get": + self._write_json(200, {"ok": True, "method": "GET"}) + return + + if path == "/http/slow": + delay = int(qs.get("seconds", ["2"])[0]) + time.sleep(delay) + self._write_json(200, {"ok": True, "delay": delay}) + return + + if path == "/http/large": + size = int(qs.get("size", ["4096"])[0]) + body = ("x" * size).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "text/plain") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + return + + if path.startswith("/list/"): + out = [] + for sha, item in STORE.items(): + out.append({ + "sha256": sha, + "url": f"http://{HOST}:{PORT}/{sha}", + "size": len(item["bytes"]), + "content_type": item["content_type"], + "created": item["created"], + }) + self._write_json(200, out) + return + + sha = path.lstrip("/") + item = STORE.get(sha) + if not item: + self._write_json(404, {"error": "not_found"}) + return + + body = item["bytes"] + self.send_response(200) + self.send_header("Content-Type", item["content_type"]) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_POST(self): + parsed = urlparse(self.path) + path = parsed.path + body = self._read_body() + + code, remaining = self._maybe_error_prefix() + if code is not None: + self._write_json(code, {"error": f"forced_{code}"}) + return + + if path == "/http/post": + self._write_json(200, { + "ok": True, + "method": "POST", + "content_type": self.headers.get("Content-Type", ""), + "body": body.decode("utf-8", errors="replace"), + }) + return + + self._write_json(404, {"error": "not_found"}) + + def do_PUT(self): + parsed = urlparse(self.path) + path = parsed.path + body = self._read_body() + + code, remaining = self._maybe_error_prefix() + if code is not None: + self._write_json(code, {"error": f"forced_{code}"}) + return + + if path == "/http/put": + self._write_json(200, { + "ok": True, + "method": "PUT", + "content_type": self.headers.get("Content-Type", ""), + "body": body.decode("utf-8", errors="replace"), + }) + return + + if path == "/upload": + sha = hashlib.sha256(body).hexdigest() + ctype = self.headers.get("Content-Type", "application/octet-stream") + created = int(time.time()) + STORE[sha] = {"bytes": body, "content_type": ctype, "created": created} + self._write_json(200, { + "sha256": sha, + "url": f"http://{HOST}:{PORT}/{sha}", + "size": len(body), + "content_type": ctype, + "created": created, + }) + return + + self._write_json(404, {"error": "not_found"}) + + def do_DELETE(self): + parsed = urlparse(self.path) + path = parsed.path + + code, remaining = self._maybe_error_prefix() + if code is not None: + self._write_json(code, {"error": f"forced_{code}"}) + return + + if path == "/http/delete": + self._write_json(200, {"ok": True, "method": "DELETE"}) + return + + sha = path.lstrip("/") + if sha in STORE: + del STORE[sha] + self._write_json(200, {"deleted": True, "sha256": sha}) + else: + self._write_json(404, {"error": "not_found"}) + + def log_message(self, fmt, *args): + # keep test output clean + pass + + +if __name__ == "__main__": + server = ThreadingHTTPServer((HOST, PORT), Handler) + print(f"mock_blossom_server listening on http://{HOST}:{PORT}", flush=True) + server.serve_forever() diff --git a/tests/nostr_http_test b/tests/nostr_http_test new file mode 100755 index 00000000..95ccc8f0 Binary files /dev/null and b/tests/nostr_http_test differ diff --git a/tests/nostr_http_test.c b/tests/nostr_http_test.c new file mode 100644 index 00000000..00488855 --- /dev/null +++ b/tests/nostr_http_test.c @@ -0,0 +1,150 @@ +/* + * nostr_http unit tests using local mock server. + */ + +#include +#include +#include + +#include "../nostr_core/nostr_http.h" +#include "../nostr_core/nostr_common.h" + +static int tests_run = 0; +static int tests_passed = 0; + +#define TEST_ASSERT(cond, msg) do { \ + tests_run++; \ + if (cond) { tests_passed++; printf("✅ %s\n", msg); } \ + else { printf("❌ %s\n", msg); } \ +} while (0) + +static const char* base_url(void) { + const char* env = getenv("MOCK_BLOSSOM_BASE"); + return (env && env[0] != '\0') ? env : "http://127.0.0.1:18081"; +} + +static void make_url(char* out, size_t out_size, const char* path) { + snprintf(out, out_size, "%s%s", base_url(), path ? path : ""); +} + +static void test_get_and_post_helpers(void) { + char url[512]; + + make_url(url, sizeof(url), "/http/get"); + char* body = NULL; + long status = 0; + int rc = nostr_http_get(url, 5, &body, &status); + TEST_ASSERT(rc == NOSTR_SUCCESS, "nostr_http_get transport success"); + TEST_ASSERT(status == 200, "nostr_http_get status=200"); + TEST_ASSERT(body && strstr(body, "\"method\": \"GET\""), "nostr_http_get response body contains method"); + free(body); + + make_url(url, sizeof(url), "/http/post"); + body = NULL; + status = 0; + rc = nostr_http_post_json(url, "{\"hello\":\"world\"}", 5, &body, &status); + TEST_ASSERT(rc == NOSTR_SUCCESS, "nostr_http_post_json transport success"); + TEST_ASSERT(status == 200, "nostr_http_post_json status=200"); + TEST_ASSERT(body && strstr(body, "\"method\": \"POST\""), "nostr_http_post_json response contains method"); + free(body); +} + +static void test_request_methods_and_headers(void) { + char url[512]; + int rc; + + nostr_http_request_t req; + nostr_http_response_t resp; + + make_url(url, sizeof(url), "/http/put"); + memset(&req, 0, sizeof(req)); + req.method = "PUT"; + req.url = url; + req.body = (const unsigned char*)"abc"; + req.body_len = 3; + req.timeout_seconds = 5; + rc = nostr_http_request(&req, &resp); + TEST_ASSERT(rc == NOSTR_SUCCESS, "nostr_http_request PUT transport success"); + TEST_ASSERT(resp.status_code == 200, "nostr_http_request PUT status=200"); + TEST_ASSERT(resp.body && strstr(resp.body, "\"method\": \"PUT\""), "nostr_http_request PUT response contains method"); + nostr_http_response_free(&resp); + + make_url(url, sizeof(url), "/http/delete"); + memset(&req, 0, sizeof(req)); + req.method = "DELETE"; + req.url = url; + req.timeout_seconds = 5; + rc = nostr_http_request(&req, &resp); + TEST_ASSERT(rc == NOSTR_SUCCESS, "nostr_http_request DELETE transport success"); + TEST_ASSERT(resp.status_code == 200, "nostr_http_request DELETE status=200"); + TEST_ASSERT(resp.body && strstr(resp.body, "\"method\": \"DELETE\""), "nostr_http_request DELETE response contains method"); + nostr_http_response_free(&resp); + + make_url(url, sizeof(url), "/http/head"); + memset(&req, 0, sizeof(req)); + req.method = "HEAD"; + req.url = url; + req.timeout_seconds = 5; + req.capture_headers = 1; + rc = nostr_http_request(&req, &resp); + TEST_ASSERT(rc == NOSTR_SUCCESS, "nostr_http_request HEAD transport success"); + TEST_ASSERT(resp.status_code == 200, "nostr_http_request HEAD status=200"); + TEST_ASSERT(resp.headers_raw != NULL, "nostr_http_request HEAD captured headers"); + nostr_http_response_free(&resp); +} + +static void test_timeout_and_max_response_bytes(void) { + char url[512]; + int rc; + + nostr_http_request_t req; + nostr_http_response_t resp; + + make_url(url, sizeof(url), "/http/slow?seconds=2"); + memset(&req, 0, sizeof(req)); + req.method = "GET"; + req.url = url; + req.timeout_seconds = 1; + rc = nostr_http_request(&req, &resp); + TEST_ASSERT(rc == NOSTR_ERROR_NETWORK_FAILED, "nostr_http_request timeout returns network error"); + + make_url(url, sizeof(url), "/http/large?size=4096"); + memset(&req, 0, sizeof(req)); + req.method = "GET"; + req.url = url; + req.timeout_seconds = 5; + req.max_response_bytes = 128; + rc = nostr_http_request(&req, &resp); + TEST_ASSERT(rc == NOSTR_SUCCESS, "nostr_http_request large body transport success"); + TEST_ASSERT(resp.status_code == 200, "nostr_http_request large body status=200"); + TEST_ASSERT(resp.body_len <= 128, "nostr_http_request respects max_response_bytes"); + TEST_ASSERT(resp.truncated == 1, "nostr_http_request marks response as truncated"); + nostr_http_response_free(&resp); +} + +static void test_ca_bundle_helpers(void) { + const char* detected = nostr_http_detect_ca_bundle(); + TEST_ASSERT(detected == NULL || detected[0] != '\0', "nostr_http_detect_ca_bundle callable"); + + nostr_http_set_ca_bundle(""); + TEST_ASSERT(1, "nostr_http_set_ca_bundle accepts empty string"); + + if (detected && detected[0] != '\0') { + nostr_http_set_ca_bundle(detected); + TEST_ASSERT(1, "nostr_http_set_ca_bundle accepts detected CA path"); + } +} + +int main(void) { + printf("nostr_http Unit Tests\n"); + printf("=====================\n"); + printf("Base URL: %s\n", base_url()); + + test_get_and_post_helpers(); + test_request_methods_and_headers(); + test_timeout_and_max_response_bytes(); + test_ca_bundle_helpers(); + + printf("\nSummary: %d/%d passed\n", tests_passed, tests_run); + return (tests_passed == tests_run) ? 0 : 1; +}