added blossom client functionality. Unified curl

This commit is contained in:
Your Name
2026-03-21 20:40:25 -04:00
parent 2fe36acde4
commit a3a68f0fde
18 changed files with 1794 additions and 249 deletions

View File

@@ -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"

513
nostr_core/blossom_client.c Normal file
View File

@@ -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 <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
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;
}

View File

@@ -0,0 +1,82 @@
/*
* Blossom HTTP Client
*/
#ifndef NOSTR_BLOSSOM_CLIENT_H
#define NOSTR_BLOSSOM_CLIENT_H
#include "nostr_common.h"
#include <stddef.h>
#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 */

View File

@@ -5,6 +5,7 @@
#include "cashu_mint.h"
#include "../cjson/cJSON.h"
#include "utils.h"
#include "nostr_http.h"
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
@@ -26,22 +27,8 @@ int nostr_secp256k1_ec_pubkey_combine(nostr_secp256k1_pubkey* out,
const nostr_secp256k1_pubkey* const* ins,
size_t n);
#include <curl/curl.h>
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;
}

View File

@@ -12,46 +12,7 @@
#include <ctype.h>
#include <curl/curl.h>
// 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;
}

View File

@@ -11,50 +11,13 @@
#ifndef DISABLE_NIP05 // NIP-11 uses the same HTTP infrastructure as NIP-05
#include <curl/curl.h>
#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);

View File

@@ -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

289
nostr_core/nostr_http.c Normal file
View File

@@ -0,0 +1,289 @@
/*
* NOSTR Core Library - Shared HTTP Client
*/
#include "nostr_http.h"
#include <curl/curl.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <unistd.h>
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;
}

55
nostr_core/nostr_http.h Normal file
View File

@@ -0,0 +1,55 @@
/*
* NOSTR Core Library - Shared HTTP Client
*/
#ifndef NOSTR_HTTP_H
#define NOSTR_HTTP_H
#include "nostr_common.h"
#include <stddef.h>
#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 */

BIN
tests/blossom_client_live_test Executable file

Binary file not shown.

View File

@@ -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 <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#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;
}

BIN
tests/blossom_client_test Executable file

Binary file not shown.

139
tests/blossom_client_test.c Normal file
View File

@@ -0,0 +1,139 @@
/*
* blossom_client unit/integration tests using local mock Blossom server.
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#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;
}

BIN
tests/blossom_mock_error_test Executable file

Binary file not shown.

View File

@@ -0,0 +1,67 @@
/*
* Blossom mock integration error-path tests.
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#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;
}

View File

@@ -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/<code>/...
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()

BIN
tests/nostr_http_test Executable file

Binary file not shown.

150
tests/nostr_http_test.c Normal file
View File

@@ -0,0 +1,150 @@
/*
* nostr_http unit tests using local mock server.
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#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;
}