diff --git a/CLAUDE.md b/CLAUDE.md index 124d2e4..161bf3d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,7 +91,8 @@ c.delete_node(node_id) # permanent, moderator only ### Key details -- **Client source**: `remarkbox/api/remarkbox_client.py` (stdlib only, no pip) +- **Python client**: `remarkbox/api/remarkbox_client.py` (stdlib only, no pip) +- **C client**: `remarkbox/api/rb.c` (compile: `gcc rb.c -o rb -lcurl`) - **API docs**: `docs/api.md` - **Identity**: Authenticated as `timehexon@unturf.com` (display name: `timehexon`) - **Journey thread**: `9f970183-ffaf-11f0-b565-040140774501` on `meta.remarkbox.com` -- update this after finishing work @@ -115,6 +116,7 @@ c.delete_node(node_id) # permanent, moderator only | GET | `/api/v1/user/profile` | Get profile | | PATCH | `/api/v1/user/profile` | Update display name | | GET | `/api/v1/clients/python` | Download Python client | +| GET | `/api/v1/clients/c` | Download C client (rb.c) | | GET | `/api/v1/admin/namespaces` | List all namespaces (superuser only) | | GET | `/api/v1/admin/recent-nodes?days=7` | Recent nodes network-wide (superuser only) | @@ -248,7 +250,7 @@ Follow Russell Ballestrini's capability-driven presentation practice (russell.ballestrini.net/capability-driven-presentation/). A page need not look identical across all browsers. Accommodate what the user's browser can do: -1. **Single canonical URI** — one URL serves the content. +1. **Single canonical URI** — one URI serves the content. 2. **Consistent content** — regardless of viewer capabilities. 3. **Graceful enhancement/degradation** — use available capabilities to enhance presentation. diff --git a/remarkbox/api/__init__.py b/remarkbox/api/__init__.py index cb132db..5015517 100644 --- a/remarkbox/api/__init__.py +++ b/remarkbox/api/__init__.py @@ -9,6 +9,7 @@ def includeme(config): config.add_route("api-auth-verify", "/api/v1/auth/verify") config.add_route("api-user-profile", "/api/v1/user/profile") config.add_route("api-client-python", "/api/v1/clients/python") + config.add_route("api-client-c", "/api/v1/clients/c") config.add_route("api-webmention", "/api/v1/webmention") config.add_route("api-admin-namespaces", "/api/v1/admin/namespaces") config.add_route("api-admin-recent-nodes", "/api/v1/admin/recent-nodes") diff --git a/remarkbox/api/rb.c b/remarkbox/api/rb.c new file mode 100644 index 0000000..cfe10ad --- /dev/null +++ b/remarkbox/api/rb.c @@ -0,0 +1,156 @@ +/* + * rb.c — Remarkbox CLI client (uses rb.h library) + * + * Compile: + * gcc rb.c -o rb -lcurl + * + * Download both files: + * curl -s https://REMARKBOX/api/v1/clients/c -o rb.c + * curl -s https://REMARKBOX/api/v1/clients/c?file=rb.h -o rb.h + * gcc rb.c -o rb -lcurl + * + * Usage: + * rb [uri] [args...] + * + * If uri is omitted, uses REMARKBOX_URL environment variable. + * Session cookies stored at REMARKBOX_COOKIES or ~/.config/remarkbox/cookies.txt + * + * License: Same as Remarkbox + */ + +#define RB_IMPLEMENTATION +#include "rb.h" + +#include +#include + +#define CLI_VERSION RB_LIB_VERSION + +static void usage(void) +{ + fprintf(stderr, + "rb %s — Remarkbox CLI client\n" + "\n" + "Usage: rb [uri] [args...]\n" + "\n" + "If uri is omitted, uses REMARKBOX_URL environment variable.\n" + "Session cookies are stored at REMARKBOX_COOKIES or\n" + "~/.config/remarkbox/cookies.txt\n" + "\n" + "Commands:\n" + " version Deployed version\n" + " threads List threads\n" + " thread Get thread with replies\n" + " search Search threads\n" + " node Get a single node\n" + " post <data> [name] Create thread\n" + " reply <node_id> <data> [name] Reply to node\n" + " edit <node_id> <data> Edit a node\n" + " disable <node_id> Disable a node\n" + " enable <node_id> Enable a node\n" + " approve <node_id> Approve a node\n" + " lock <node_id> Lock a thread\n" + " unlock <node_id> Unlock a thread\n" + " delete <node_id> Delete a node\n" + " login <email> Request OTP\n" + " verify <email> <otp> Verify OTP\n" + " profile Show current user\n" + "\n" + "Examples:\n" + " rb https://my.remarkbox.com threads meta.remarkbox.com\n" + " rb threads meta.remarkbox.com # uses REMARKBOX_URL\n" + " rb login user@example.com\n" + " rb verify user@example.com 123456\n" + " rb post meta.remarkbox.com \"Title\" \"Body\" MyBot\n" + "\n" + "Compile: gcc rb.c -o rb -lcurl\n" + "\n", CLI_VERSION); +} + +/* Run a command, print the response, return exit code. */ +static int run(rb_response_t r) +{ + if (r.body && r.body_len > 0) + rb_json_pretty(r.body); + int rc = r.ok ? 0 : 1; + rb_response_free(&r); + return rc; +} + +int main(int argc, char *argv[]) +{ + const char *base_uri; + int cmd_start; + + if (argc < 2) { usage(); return 1; } + + /* Determine if first arg is a URI or a command */ + if (strncmp(argv[1], "http://", 7) == 0 || strncmp(argv[1], "https://", 8) == 0) { + base_uri = argv[1]; + cmd_start = 2; + } else { + base_uri = getenv("REMARKBOX_URL"); + if (!base_uri || !*base_uri) { + fprintf(stderr, "rb: no URI provided and REMARKBOX_URL not set\n"); + return 1; + } + cmd_start = 1; + } + + if (cmd_start >= argc) { usage(); return 1; } + + curl_global_init(CURL_GLOBAL_DEFAULT); + + rb_client_t *c = rb_client_new(base_uri, NULL); + const char *cmd = argv[cmd_start]; + int nargs = argc - cmd_start - 1; + char **args = argv + cmd_start + 1; + int rc = 1; + + if (strcmp(cmd, "version") == 0) { + rc = run(rb_version(c)); + } else if (strcmp(cmd, "threads") == 0 && nargs >= 1) { + rc = run(rb_list_threads(c, args[0])); + } else if (strcmp(cmd, "thread") == 0 && nargs >= 1) { + rc = run(rb_get_thread(c, args[0])); + } else if (strcmp(cmd, "search") == 0 && nargs >= 2) { + rc = run(rb_search_threads(c, args[0], args[1])); + } else if (strcmp(cmd, "node") == 0 && nargs >= 1) { + rc = run(rb_get_node(c, args[0])); + } else if (strcmp(cmd, "post") == 0 && nargs >= 3) { + rc = run(rb_create_thread(c, args[0], args[1], args[2], + nargs >= 4 ? args[3] : NULL)); + } else if (strcmp(cmd, "reply") == 0 && nargs >= 2) { + rc = run(rb_reply(c, args[0], args[1], nargs >= 3 ? args[2] : NULL)); + } else if (strcmp(cmd, "edit") == 0 && nargs >= 2) { + rc = run(rb_edit_node(c, args[0], args[1])); + } else if (strcmp(cmd, "disable") == 0 && nargs >= 1) { + rc = run(rb_disable_node(c, args[0])); + } else if (strcmp(cmd, "enable") == 0 && nargs >= 1) { + rc = run(rb_enable_node(c, args[0])); + } else if (strcmp(cmd, "approve") == 0 && nargs >= 1) { + rc = run(rb_approve_node(c, args[0])); + } else if (strcmp(cmd, "lock") == 0 && nargs >= 1) { + rc = run(rb_lock_node(c, args[0])); + } else if (strcmp(cmd, "unlock") == 0 && nargs >= 1) { + rc = run(rb_unlock_node(c, args[0])); + } else if (strcmp(cmd, "delete") == 0 && nargs >= 1) { + rc = run(rb_delete_node(c, args[0])); + } else if (strcmp(cmd, "login") == 0 && nargs >= 1) { + rc = run(rb_login(c, args[0])); + } else if (strcmp(cmd, "verify") == 0 && nargs >= 2) { + rc = run(rb_verify(c, args[0], args[1])); + } else if (strcmp(cmd, "profile") == 0) { + rc = run(rb_get_profile(c)); + } else if (strcmp(cmd, "help") == 0 || strcmp(cmd, "--help") == 0 || + strcmp(cmd, "-h") == 0) { + usage(); rc = 0; + } else { + fprintf(stderr, "rb: unknown command '%s'\n\n", cmd); + usage(); rc = 1; + } + + rb_client_free(c); + curl_global_cleanup(); + return rc; +} diff --git a/remarkbox/api/rb.h b/remarkbox/api/rb.h new file mode 100644 index 0000000..d31767a --- /dev/null +++ b/remarkbox/api/rb.h @@ -0,0 +1,485 @@ +/* + * rb.h — Remarkbox C library (single-header, libcurl only) + * + * Download: + * curl -s https://REMARKBOX/api/v1/clients/c?file=rb.h -o rb.h + * + * Usage as a library: + * // In exactly ONE .c file, before including: + * #define RB_IMPLEMENTATION + * #include "rb.h" + * + * // In all other .c files, just: + * #include "rb.h" + * + * Quick start: + * rb_client_t *c = rb_client_new("https://my.remarkbox.com", NULL); + * rb_response_t r = rb_version(c); + * if (r.ok) printf("%s\n", r.body); + * rb_response_free(&r); + * rb_client_free(c); + * + * Requires: libcurl (link with -lcurl) + * License: Same as Remarkbox + */ + +#ifndef RB_H +#define RB_H + +#include <stdlib.h> +#include <curl/curl.h> + +#define RB_LIB_VERSION "0.1.0" +#define RB_MAX_URI 2048 +#define RB_MAX_BODY 1048576 /* 1 MB response limit */ + +/* --------------------------------------------------------------------------- + * Types + * ------------------------------------------------------------------------- */ + +typedef struct { + char *base_uri; /* e.g. "https://my.remarkbox.com" */ + char *cookie_path; /* path to Netscape cookie jar */ +} rb_client_t; + +typedef struct { + int ok; /* 1 if HTTP 2xx, 0 otherwise */ + long status; /* HTTP status code */ + char *body; /* raw JSON response (caller must rb_response_free) */ + size_t body_len; +} rb_response_t; + +/* --------------------------------------------------------------------------- + * Client lifecycle + * ------------------------------------------------------------------------- */ + +/* Create a client. cookie_path may be NULL for default + (~/.config/remarkbox/cookies.txt or REMARKBOX_COOKIES). */ +rb_client_t *rb_client_new(const char *base_uri, const char *cookie_path); + +/* Free a client. */ +void rb_client_free(rb_client_t *c); + +/* Free response body. */ +void rb_response_free(rb_response_t *r); + +/* --------------------------------------------------------------------------- + * API methods — each returns rb_response_t with raw JSON in body + * ------------------------------------------------------------------------- */ + +/* Version */ +rb_response_t rb_version(rb_client_t *c); + +/* Threads */ +rb_response_t rb_list_threads(rb_client_t *c, const char *ns); +rb_response_t rb_get_thread(rb_client_t *c, const char *node_id); +rb_response_t rb_search_threads(rb_client_t *c, const char *ns, const char *query); +rb_response_t rb_create_thread(rb_client_t *c, const char *ns, const char *title, + const char *data, const char *anon_name); + +/* Replies */ +rb_response_t rb_reply(rb_client_t *c, const char *node_id, const char *data, + const char *anon_name); + +/* Nodes */ +rb_response_t rb_get_node(rb_client_t *c, const char *node_id); +rb_response_t rb_edit_node(rb_client_t *c, const char *node_id, const char *data); +rb_response_t rb_disable_node(rb_client_t *c, const char *node_id); +rb_response_t rb_enable_node(rb_client_t *c, const char *node_id); +rb_response_t rb_approve_node(rb_client_t *c, const char *node_id); +rb_response_t rb_lock_node(rb_client_t *c, const char *node_id); +rb_response_t rb_unlock_node(rb_client_t *c, const char *node_id); +rb_response_t rb_delete_node(rb_client_t *c, const char *node_id); + +/* Auth */ +rb_response_t rb_login(rb_client_t *c, const char *email); +rb_response_t rb_verify(rb_client_t *c, const char *email, const char *otp); + +/* Profile */ +rb_response_t rb_get_profile(rb_client_t *c); + +/* --------------------------------------------------------------------------- + * Utility + * ------------------------------------------------------------------------- */ + +/* Pretty-print JSON to stdout. */ +void rb_json_pretty(const char *json); + +/* Escape a string for use inside a JSON string value. + Writes to out (must be at least out_size bytes). */ +void rb_json_escape(const char *s, char *out, size_t out_size); + + +/* ========================================================================= + * IMPLEMENTATION — include this in exactly one .c file + * ========================================================================= */ + +#ifdef RB_IMPLEMENTATION + +#include <stdio.h> +#include <string.h> + +/* --------------------------------------------------------------------------- + * Internal: response buffer + * ------------------------------------------------------------------------- */ + +struct rb__buf { + char *data; + size_t len; + size_t cap; +}; + +static void rb__buf_init(struct rb__buf *b) +{ + b->cap = 4096; + b->data = (char *)malloc(b->cap); + b->len = 0; + if (b->data) b->data[0] = '\0'; +} + +static size_t rb__write_cb(void *ptr, size_t size, size_t nmemb, void *userdata) +{ + struct rb__buf *b = (struct rb__buf *)userdata; + size_t bytes = size * nmemb; + + if (b->len + bytes + 1 > RB_MAX_BODY) + bytes = RB_MAX_BODY - b->len - 1; + + if (b->len + bytes + 1 > b->cap) { + size_t newcap = b->cap * 2; + while (newcap < b->len + bytes + 1) newcap *= 2; + if (newcap > RB_MAX_BODY) newcap = RB_MAX_BODY; + char *tmp = (char *)realloc(b->data, newcap); + if (!tmp) return 0; + b->data = tmp; + b->cap = newcap; + } + + memcpy(b->data + b->len, ptr, bytes); + b->len += bytes; + b->data[b->len] = '\0'; + return size * nmemb; +} + +/* --------------------------------------------------------------------------- + * Internal: default cookie path + * ------------------------------------------------------------------------- */ + +static const char *rb__default_cookie_path(void) +{ + const char *env = getenv("REMARKBOX_COOKIES"); + if (env && *env) return env; + + static char path[1024]; + const char *home = getenv("HOME"); + if (!home) home = "."; + snprintf(path, sizeof(path), "%s/.config/remarkbox/cookies.txt", home); + return path; +} + +/* --------------------------------------------------------------------------- + * Client lifecycle + * ------------------------------------------------------------------------- */ + +rb_client_t *rb_client_new(const char *base_uri, const char *cookie_path) +{ + rb_client_t *c = (rb_client_t *)calloc(1, sizeof(rb_client_t)); + if (!c) return NULL; + + /* Strip trailing slash */ + size_t len = strlen(base_uri); + while (len > 0 && base_uri[len - 1] == '/') len--; + c->base_uri = (char *)malloc(len + 1); + if (c->base_uri) { memcpy(c->base_uri, base_uri, len); c->base_uri[len] = '\0'; } + + const char *cp = cookie_path ? cookie_path : rb__default_cookie_path(); + c->cookie_path = (char *)malloc(strlen(cp) + 1); + if (c->cookie_path) strcpy(c->cookie_path, cp); + + return c; +} + +void rb_client_free(rb_client_t *c) +{ + if (!c) return; + free(c->base_uri); + free(c->cookie_path); + free(c); +} + +void rb_response_free(rb_response_t *r) +{ + free(r->body); + r->body = NULL; + r->body_len = 0; +} + +/* --------------------------------------------------------------------------- + * Internal: HTTP request + * ------------------------------------------------------------------------- */ + +static rb_response_t rb__request(rb_client_t *c, const char *method, + const char *api_path, const char *json_body, + const char *extra_header) +{ + rb_response_t result = {0, 0, NULL, 0}; + char uri[RB_MAX_URI]; + struct rb__buf resp; + + snprintf(uri, sizeof(uri), "%s%s", c->base_uri, api_path); + rb__buf_init(&resp); + + CURL *curl = curl_easy_init(); + if (!curl) { + result.body = (char *)malloc(32); + if (result.body) { strcpy(result.body, "{\"error\":\"curl init failed\"}"); result.body_len = strlen(result.body); } + return result; + } + + curl_easy_setopt(curl, CURLOPT_URL, uri); + curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, method); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, rb__write_cb); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &resp); + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); + curl_easy_setopt(curl, CURLOPT_COOKIEFILE, c->cookie_path); + curl_easy_setopt(curl, CURLOPT_COOKIEJAR, c->cookie_path); + curl_easy_setopt(curl, CURLOPT_USERAGENT, "rb/" RB_LIB_VERSION); + + struct curl_slist *headers = NULL; + headers = curl_slist_append(headers, "Accept: application/json"); + if (json_body) { + headers = curl_slist_append(headers, "Content-Type: application/json"); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, json_body); + } + if (extra_header) + headers = curl_slist_append(headers, extra_header); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + + CURLcode res = curl_easy_perform(curl); + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &result.status); + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (res != CURLE_OK) { + free(resp.data); + result.body = (char *)malloc(128); + if (result.body) { + snprintf(result.body, 128, "{\"error\":\"%s\"}", curl_easy_strerror(res)); + result.body_len = strlen(result.body); + } + return result; + } + + result.ok = (result.status >= 200 && result.status < 400); + result.body = resp.data; /* caller owns this now */ + result.body_len = resp.len; + return result; +} + +/* --------------------------------------------------------------------------- + * Utility + * ------------------------------------------------------------------------- */ + +void rb_json_escape(const char *s, char *out, size_t out_size) +{ + size_t j = 0; + for (size_t i = 0; s[i] && j + 6 < out_size; i++) { + switch (s[i]) { + case '"': out[j++] = '\\'; out[j++] = '"'; break; + case '\\': out[j++] = '\\'; out[j++] = '\\'; break; + case '\n': out[j++] = '\\'; out[j++] = 'n'; break; + case '\r': out[j++] = '\\'; out[j++] = 'r'; break; + case '\t': out[j++] = '\\'; out[j++] = 't'; break; + default: + if ((unsigned char)s[i] < 0x20) + j += snprintf(out + j, out_size - j, "\\u%04x", (unsigned char)s[i]); + else + out[j++] = s[i]; + break; + } + } + out[j] = '\0'; +} + +void rb_json_pretty(const char *s) +{ + int indent = 0, in_string = 0, escaped = 0; + for (; *s; s++) { + if (escaped) { putchar(*s); escaped = 0; continue; } + if (*s == '\\' && in_string) { putchar(*s); escaped = 1; continue; } + if (*s == '"') { in_string = !in_string; putchar(*s); continue; } + if (in_string) { putchar(*s); continue; } + switch (*s) { + case '{': case '[': + putchar(*s); putchar('\n'); indent += 2; + for (int i = 0; i < indent; i++) putchar(' '); + break; + case '}': case ']': + putchar('\n'); indent -= 2; if (indent < 0) indent = 0; + for (int i = 0; i < indent; i++) putchar(' '); + putchar(*s); break; + case ',': + putchar(*s); putchar('\n'); + for (int i = 0; i < indent; i++) putchar(' '); + break; + case ':': putchar(*s); putchar(' '); break; + default: + if (*s != ' ' && *s != '\t' && *s != '\n' && *s != '\r') + putchar(*s); + break; + } + } + putchar('\n'); +} + +/* --------------------------------------------------------------------------- + * Internal: URI encoding helper + * ------------------------------------------------------------------------- */ + +static void rb__uri_encode(const char *s, char *out, size_t out_size) +{ + CURL *curl = curl_easy_init(); + if (curl) { + char *enc = curl_easy_escape(curl, s, 0); + if (enc) { snprintf(out, out_size, "%s", enc); curl_free(enc); } + else { snprintf(out, out_size, "%s", s); } + curl_easy_cleanup(curl); + } else { + snprintf(out, out_size, "%s", s); + } +} + +/* --------------------------------------------------------------------------- + * API methods + * ------------------------------------------------------------------------- */ + +rb_response_t rb_version(rb_client_t *c) +{ + return rb__request(c, "GET", "/api/v1/version", NULL, NULL); +} + +rb_response_t rb_list_threads(rb_client_t *c, const char *ns) +{ + char path[RB_MAX_URI], enc[1024]; + rb__uri_encode(ns, enc, sizeof(enc)); + snprintf(path, sizeof(path), "/api/v1/threads?namespace=%s", enc); + return rb__request(c, "GET", path, NULL, NULL); +} + +rb_response_t rb_get_thread(rb_client_t *c, const char *node_id) +{ + char path[RB_MAX_URI]; + snprintf(path, sizeof(path), "/api/v1/threads/%s", node_id); + return rb__request(c, "GET", path, NULL, NULL); +} + +rb_response_t rb_search_threads(rb_client_t *c, const char *ns, const char *query) +{ + char path[RB_MAX_URI], enc_ns[512], enc_q[512]; + rb__uri_encode(ns, enc_ns, sizeof(enc_ns)); + rb__uri_encode(query, enc_q, sizeof(enc_q)); + snprintf(path, sizeof(path), "/api/v1/threads/search?namespace=%s&q=%s", + enc_ns, enc_q); + return rb__request(c, "GET", path, NULL, NULL); +} + +rb_response_t rb_create_thread(rb_client_t *c, const char *ns, const char *title, + const char *data, const char *anon_name) +{ + char body[RB_MAX_BODY]; + char ens[2048], etitle[2048], edata[524288], ename[512]; + rb_json_escape(ns, ens, sizeof(ens)); + rb_json_escape(title, etitle, sizeof(etitle)); + rb_json_escape(data, edata, sizeof(edata)); + if (anon_name) { + rb_json_escape(anon_name, ename, sizeof(ename)); + snprintf(body, sizeof(body), + "{\"namespace\":\"%s\",\"title\":\"%s\",\"data\":\"%s\"," + "\"anonymous_name\":\"%s\"}", ens, etitle, edata, ename); + } else { + snprintf(body, sizeof(body), + "{\"namespace\":\"%s\",\"title\":\"%s\",\"data\":\"%s\"}", + ens, etitle, edata); + } + return rb__request(c, "POST", "/api/v1/threads", body, NULL); +} + +rb_response_t rb_reply(rb_client_t *c, const char *node_id, const char *data, + const char *anon_name) +{ + char path[RB_MAX_URI], body[RB_MAX_BODY]; + char edata[524288], ename[512]; + rb_json_escape(data, edata, sizeof(edata)); + snprintf(path, sizeof(path), "/api/v1/threads/%s/replies", node_id); + if (anon_name) { + rb_json_escape(anon_name, ename, sizeof(ename)); + snprintf(body, sizeof(body), + "{\"data\":\"%s\",\"anonymous_name\":\"%s\"}", edata, ename); + } else { + snprintf(body, sizeof(body), "{\"data\":\"%s\"}", edata); + } + return rb__request(c, "POST", path, body, NULL); +} + +rb_response_t rb_get_node(rb_client_t *c, const char *node_id) +{ + char path[RB_MAX_URI]; + snprintf(path, sizeof(path), "/api/v1/nodes/%s", node_id); + return rb__request(c, "GET", path, NULL, NULL); +} + +rb_response_t rb_edit_node(rb_client_t *c, const char *node_id, const char *data) +{ + char path[RB_MAX_URI], body[RB_MAX_BODY], edata[524288]; + rb_json_escape(data, edata, sizeof(edata)); + snprintf(path, sizeof(path), "/api/v1/nodes/%s", node_id); + snprintf(body, sizeof(body), "{\"data\":\"%s\"}", edata); + return rb__request(c, "PATCH", path, body, NULL); +} + +static rb_response_t rb__mod_flag(rb_client_t *c, const char *node_id, + const char *field, const char *value) +{ + char path[RB_MAX_URI], body[256]; + snprintf(path, sizeof(path), "/api/v1/nodes/%s", node_id); + snprintf(body, sizeof(body), "{\"%s\":%s}", field, value); + return rb__request(c, "PATCH", path, body, NULL); +} + +rb_response_t rb_disable_node(rb_client_t *c, const char *id) { return rb__mod_flag(c, id, "disabled", "true"); } +rb_response_t rb_enable_node(rb_client_t *c, const char *id) { return rb__mod_flag(c, id, "disabled", "false"); } +rb_response_t rb_approve_node(rb_client_t *c, const char *id) { return rb__mod_flag(c, id, "approved", "true"); } +rb_response_t rb_lock_node(rb_client_t *c, const char *id) { return rb__mod_flag(c, id, "locked", "true"); } +rb_response_t rb_unlock_node(rb_client_t *c, const char *id) { return rb__mod_flag(c, id, "locked", "false"); } + +rb_response_t rb_delete_node(rb_client_t *c, const char *node_id) +{ + char path[RB_MAX_URI]; + snprintf(path, sizeof(path), "/api/v1/nodes/%s", node_id); + return rb__request(c, "DELETE", path, NULL, NULL); +} + +rb_response_t rb_login(rb_client_t *c, const char *email) +{ + char body[512], eemail[256]; + rb_json_escape(email, eemail, sizeof(eemail)); + snprintf(body, sizeof(body), "{\"email\":\"%s\"}", eemail); + return rb__request(c, "POST", "/api/v1/auth/login", body, NULL); +} + +rb_response_t rb_verify(rb_client_t *c, const char *email, const char *otp) +{ + char body[512], eemail[256], eotp[64]; + rb_json_escape(email, eemail, sizeof(eemail)); + rb_json_escape(otp, eotp, sizeof(eotp)); + snprintf(body, sizeof(body), "{\"email\":\"%s\",\"otp\":\"%s\"}", eemail, eotp); + return rb__request(c, "POST", "/api/v1/auth/verify", body, NULL); +} + +rb_response_t rb_get_profile(rb_client_t *c) +{ + return rb__request(c, "GET", "/api/v1/user/profile", NULL, NULL); +} + +#endif /* RB_IMPLEMENTATION */ +#endif /* RB_H */ diff --git a/remarkbox/api/views.py b/remarkbox/api/views.py index 4a50e09..fb0fe67 100644 --- a/remarkbox/api/views.py +++ b/remarkbox/api/views.py @@ -1094,6 +1094,29 @@ def api_client_python(request): ) +@view_config( + route_name="api-client-c", + request_method="GET", + require_csrf=False, +) +def api_client_c(request): + """Serve the C client for download. + + ?file=rb.h returns the header library instead of the CLI. + """ + filename = request.params.get("file", "rb.c") + if filename not in ("rb.c", "rb.h"): + filename = "rb.c" + path = os.path.join(_client_dir, filename) + with open(path) as f: + content = f.read() + return Response( + body=content, + content_type="text/plain", + charset="utf-8", + ) + + # --------------------------------------------------------------------------- # Admin (superuser only) # --------------------------------------------------------------------------- diff --git a/remarkbox/templates/namespace-settings.j2 b/remarkbox/templates/namespace-settings.j2 index 477544b..5385c37 100644 --- a/remarkbox/templates/namespace-settings.j2 +++ b/remarkbox/templates/namespace-settings.j2 @@ -275,6 +275,14 @@ Embed this snippet into your web page to enable comments: </ul> +<label>API Clients</label> +Programmatic access to this Namespace's threads and comments: +<ul> +<li><a href="/api/v1/clients/python" target="_blank">Python client</a> — <code>remarkbox_client.py</code> (stdlib only, no pip)</li> +<li><a href="/api/v1/clients/c" target="_blank">C client</a> — <code>rb.c</code> + <code><a href="/api/v1/clients/c?file=rb.h" target="_blank">rb.h</a></code> (libcurl only, <code>gcc rb.c -o rb -lcurl</code>)</li> +</ul> +<br> + <label>Statistics</a></label> This <a href="/ns/{{ request.namespace.name }}/stats.json" target="_blank">stats.json</a> endpoint returns various statistics about the threads in this Namespace.