From 5a10e155bd654901dd85d5a73b4ac0ea7f4b35ed Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Sun, 1 Feb 2026 16:39:19 -0500 Subject: [PATCH] Add Python client, profile endpoint, and functional test - remarkbox_client.py: stdlib-only Python client with cookie persistence, 3-tier config (args/env/file), CLI mode - GET/PATCH /api/v1/user/profile: read and update display name - GET /api/v1/clients/python: serve client for curl/wget download - functional_test.py: idempotent live test that maintains a journey thread documenting each run - Remove email2 spam honeypot from API (not useful for agents) - Bump content limit to 500k chars (~128k tokens) --- docs/api.md | 6 +- docs/testing.md | 153 +++++++++++ remarkbox/api/__init__.py | 2 + remarkbox/api/functional_test.py | 364 ++++++++++++++++++++++++++ remarkbox/api/remarkbox_client.py | 398 +++++++++++++++++++++++++++++ remarkbox/api/views.py | 100 +++++++- remarkbox/tests/test_api_client.py | 254 ++++++++++++++++++ remarkbox/tests/test_api_views.py | 14 + 8 files changed, 1287 insertions(+), 4 deletions(-) create mode 100644 docs/testing.md create mode 100644 remarkbox/api/functional_test.py create mode 100644 remarkbox/api/remarkbox_client.py create mode 100644 remarkbox/tests/test_api_client.py diff --git a/docs/api.md b/docs/api.md index 21ab829..3e0bc81 100644 --- a/docs/api.md +++ b/docs/api.md @@ -189,7 +189,7 @@ Request body: - `namespace` (required) - `title` (required) -- `data` (required, max 50000 chars) +- `data` (required, max 500000 chars) - `anonymous_name` (optional, used when namespace allows anonymous) - `email` (optional, creates an unverified user) @@ -219,7 +219,7 @@ Request body: } ``` -- `data` (required, max 50000 chars) +- `data` (required, max 500000 chars) - `anonymous_name` (optional) - `email` (optional) @@ -361,7 +361,7 @@ All errors return a JSON body with an `error` key: | Status | Meaning | |--------|---------| | 400 | Bad request (missing params, content too long) | -| 401 | Authentication required or spam detected | +| 401 | Authentication required | | 403 | Forbidden (locked thread, disabled node, namespace opt-out) | | 404 | Not found (or API globally disabled) | | 429 | Rate limit exceeded | diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..4a1d6cf --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,153 @@ +# Functional Testing the Remarkbox API + +Walkthrough for testing every endpoint with `curl`. Replace +`REMARKBOX` with your deploy URL (e.g. `https://my.remarkbox.com`). + +## Read Endpoints + +### List Threads + +```bash +curl -s "$REMARKBOX/api/v1/threads?namespace=meta.remarkbox.com" | python3 -m json.tool +``` + +### Get Thread + +```bash +# grab the first thread id from the list +THREAD_ID=$(curl -s "$REMARKBOX/api/v1/threads?namespace=meta.remarkbox.com" \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['threads'][0]['id'])") + +curl -s "$REMARKBOX/api/v1/threads/$THREAD_ID" | python3 -m json.tool +``` + +### Get Node + +```bash +curl -s "$REMARKBOX/api/v1/nodes/$THREAD_ID" | python3 -m json.tool +``` + +## Anonymous Posting + +Requires a namespace with **Allow Anonymous Comments** enabled. + +### Create Thread + +```bash +curl -s -X POST "$REMARKBOX/api/v1/threads" \ + -H "Content-Type: application/json" \ + -d '{ + "namespace": "meta.remarkbox.com", + "title": "Test thread from curl", + "data": "Hello from the API.", + "anonymous_name": "CurlBot" + }' | python3 -m json.tool +``` + +### Reply to Thread + +```bash +curl -s -X POST "$REMARKBOX/api/v1/threads/$THREAD_ID/replies" \ + -H "Content-Type: application/json" \ + -d '{ + "data": "Reply from curl.", + "anonymous_name": "CurlBot" + }' | python3 -m json.tool +``` + +## Authentication (Email OTP) + +### Request OTP + +```bash +curl -s -X POST "$REMARKBOX/api/v1/auth/login" \ + -H "Content-Type: application/json" \ + -d '{"email": "you@example.com"}' | python3 -m json.tool +``` + +### Verify OTP + +Check your inbox for the 6-digit code, then: + +```bash +curl -s -X POST "$REMARKBOX/api/v1/auth/verify" \ + -H "Content-Type: application/json" \ + -c cookies.txt \ + -d '{"email": "you@example.com", "otp": "123456"}' | python3 -m json.tool +``` + +The `-c cookies.txt` saves the session cookie for subsequent requests. + +### Create Authenticated Thread + +```bash +curl -s -X POST "$REMARKBOX/api/v1/threads" \ + -H "Content-Type: application/json" \ + -b cookies.txt \ + -d '{ + "namespace": "meta.remarkbox.com", + "title": "Authenticated thread", + "data": "Posted with a verified session." + }' | python3 -m json.tool +``` + +### Edit a Node + +```bash +curl -s -X PATCH "$REMARKBOX/api/v1/nodes/$NODE_ID" \ + -H "Content-Type: application/json" \ + -b cookies.txt \ + -d '{"data": "Updated content."}' | python3 -m json.tool +``` + +## Error Cases + +### Missing namespace + +```bash +curl -s "$REMARKBOX/api/v1/threads" | python3 -m json.tool +# {"error": "namespace parameter is required"} +``` + +### Namespace with API access disabled + +```bash +curl -s "$REMARKBOX/api/v1/threads?namespace=opted-out.example.com" +# {"error": "API access is disabled for this namespace"} +``` + +### Edit without auth + +```bash +curl -s -X PATCH "$REMARKBOX/api/v1/nodes/$NODE_ID" \ + -H "Content-Type: application/json" \ + -d '{"data": "nope"}' +# {"error": "Authentication required"} +``` + +## Python Client + +Download the Python client directly from the API: + +```bash +curl -s "$REMARKBOX/api/v1/clients/python" -o remarkbox_client.py +``` + +Or with wget: + +```bash +wget -q "$REMARKBOX/api/v1/clients/python" -O remarkbox_client.py +``` + +Then use it: + +```python +from remarkbox_client import RemarkboxClient + +client = RemarkboxClient("https://my.remarkbox.com") +threads = client.list_threads("meta.remarkbox.com") +for t in threads["threads"]: + print(t["title"]) +``` + +See `remarkbox_client.py` header comments for full usage. diff --git a/remarkbox/api/__init__.py b/remarkbox/api/__init__.py index d5ff8b3..48754db 100644 --- a/remarkbox/api/__init__.py +++ b/remarkbox/api/__init__.py @@ -5,4 +5,6 @@ def includeme(config): config.add_route("api-node-detail", "/api/v1/nodes/{node_id}") config.add_route("api-auth-login", "/api/v1/auth/login") 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.scan("remarkbox.api.views") diff --git a/remarkbox/api/functional_test.py b/remarkbox/api/functional_test.py new file mode 100644 index 0000000..1387efc --- /dev/null +++ b/remarkbox/api/functional_test.py @@ -0,0 +1,364 @@ +""" +Functional test of the Remarkbox API against a live instance. + +Uses cookie persistence so authentication survives across runs. +First run requires an OTP; subsequent runs reuse the session. +Creates one persistent "API Testing Journey" thread and appends +a new section on each run. All other operations are reads. + +Usage: + # First run (sends OTP, prompts for code): + python functional_test.py https://my.remarkbox.com meta.remarkbox.com timehexon@unturf.com + + # With OTP on command line: + python functional_test.py https://my.remarkbox.com meta.remarkbox.com timehexon@unturf.com 173786 + + # Subsequent runs reuse saved session: + python functional_test.py https://my.remarkbox.com meta.remarkbox.com timehexon@unturf.com + + # Set display name (idempotent): + python functional_test.py https://my.remarkbox.com meta.remarkbox.com timehexon@unturf.com --name timehexon + + # Specify existing journey thread to append to: + python functional_test.py ... --journey 9f970183-ffaf-11f0-b565-040140774501 +""" + +import argparse +import datetime +import json +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from remarkbox_client import RemarkboxClient, RemarkboxError + +THREAD_TITLE = "API Testing Journey" +DEFAULT_COOKIE_DIR = os.path.join(os.path.expanduser("~"), ".config", "remarkbox") +PASS = "PASS" +FAIL = "FAIL" + + +class JournalWriter: + """Collects test results and builds a markdown journal section.""" + + def __init__(self): + self.lines = [] + self.results = [] + self._step = 0 + + def step(self, title): + self._step += 1 + self.lines.append("### {}. {}".format(self._step, title)) + + def note(self, text): + self.lines.append(text) + + def blank(self): + self.lines.append("") + + def log(self, test_name, passed, detail=""): + mark = "+" if passed else "!" + status = PASS if passed else FAIL + print(" [{}] {} {}{}".format(mark, status, test_name, + ": " + detail if detail else "")) + self.results.append(passed) + return passed + + @property + def passed(self): + return sum(1 for r in self.results if r) + + @property + def total(self): + return len(self.results) + + def summary_line(self): + return "**{}/{} passed.**".format(self.passed, self.total) + + def render(self): + return "\n".join(self.lines) + + +def ensure_authenticated(client, email, otp=None): + """Return True if authenticated, attempting login/verify if needed.""" + try: + profile = client.get_profile() + if profile.get("user"): + return True, "reused session" + except RemarkboxError: + pass + + # Request OTP on this client, then verify + login_result = client.login(email) + status = login_result.get("status") + + if otp and status in ("sent", "throttled"): + try: + result = client.verify(email, otp) + if result.get("status") == "authenticated": + return True, "verified otp" + except RemarkboxError: + pass + + # Need interactive OTP + if status == "throttled": + print("\n OTP already sent to {} (check inbox)".format(email)) + else: + print("\n OTP sent to {}".format(email)) + otp = input(" Enter 6-digit code: ").strip() + result = client.verify(email, otp) + return result.get("status") == "authenticated", "verified otp" + + +def find_journey_thread(client, namespace): + """Find existing journey thread by title, or return None.""" + data = client.list_threads(namespace) + for thread in data.get("threads", []): + if thread["title"].strip() == THREAD_TITLE: + return thread["id"] + return None + + +def run(url, namespace, email, otp=None, display_name=None, journey_id=None): + cookie_file = os.path.join(DEFAULT_COOKIE_DIR, "cookies.txt") + os.makedirs(DEFAULT_COOKIE_DIR, exist_ok=True) + + client = RemarkboxClient(url, email=email, cookie_file=cookie_file) + j = JournalWriter() + now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + + # --------------------------------------------------------------- + # Phase 1: Read operations (no trace) + # --------------------------------------------------------------- + print("\nPhase 1: Read operations") + + j.step("List Threads") + try: + data = client.list_threads(namespace) + ok = data["namespace"]["name"] == namespace + j.log("list_threads", ok, "{} threads".format(len(data["threads"]))) + j.note("Fetched {} threads from `{}`.".format(len(data["threads"]), namespace)) + j.blank() + except Exception as e: + j.log("list_threads", False, str(e)) + + j.step("Get Thread") + thread_id = None + try: + if data["threads"]: + thread_id = data["threads"][0]["id"] + detail = client.get_thread(thread_id) + ok = "thread" in detail and "replies" in detail + j.log("get_thread", ok, "{} replies".format(len(detail["replies"]))) + j.note("Read thread `{}` with {} replies.".format( + thread_id[:8], len(detail["replies"]))) + j.blank() + except Exception as e: + j.log("get_thread", False, str(e)) + + j.step("Get Node") + try: + node_data = client.get_node(thread_id) + ok = node_data["node"]["id"] == thread_id + j.log("get_node", ok) + j.note("Fetched node `{}`.".format(thread_id[:8])) + j.blank() + except Exception as e: + j.log("get_node", False, str(e)) + + j.step("Error Handling") + try: + client.list_threads("") + j.log("error_400", False, "expected error") + except RemarkboxError as e: + j.log("error_400", e.status == 400, "HTTP {}".format(e.status)) + + try: + client.get_node("00000000-0000-0000-0000-000000000000") + j.log("error_404", False, "expected error") + except RemarkboxError as e: + j.log("error_404", e.status == 404, "HTTP {}".format(e.status)) + j.note("Missing namespace -> 400, nonexistent node -> 404.") + j.blank() + + # --------------------------------------------------------------- + # Phase 2: Authentication + # --------------------------------------------------------------- + print("\nPhase 2: Authentication") + + j.step("Authenticate") + ok, method = ensure_authenticated(client, email, otp) + j.log("authenticate", ok, method) + j.note("Authenticated via {}.".format(method)) + j.blank() + + if not ok: + print("\n Authentication failed. Aborting.") + return j + + # --------------------------------------------------------------- + # Phase 3: Profile (idempotent) + # --------------------------------------------------------------- + print("\nPhase 3: Profile") + + j.step("Get Profile") + try: + profile = client.get_profile() + current_name = profile["user"]["name"] + j.log("get_profile", True, current_name) + j.note("Current display name: `{}`.".format(current_name)) + j.blank() + profile_available = True + except RemarkboxError as e: + j.log("get_profile", False, "endpoint not available (HTTP {})".format(e.status)) + j.note("Profile endpoint not deployed yet -- skipping profile tests.") + j.blank() + profile_available = False + + if display_name and profile_available: + j.step("Update Display Name") + result = client.update_profile(display_name) + ok = result["user"]["name"] == display_name + j.log("update_profile", ok, "{} -> {}".format(current_name, display_name)) + j.note("Set display name: `{}` -> `{}`.".format(current_name, display_name)) + j.blank() + + # Verify idempotent (run again, same name) + result2 = client.update_profile(display_name) + j.log("update_profile_idempotent", result2["user"]["name"] == display_name, + "same name accepted") + + # Verify invalid name rejected + try: + client.update_profile("bad name!!!") + j.log("error_invalid_name", False, "expected error") + except RemarkboxError as e: + j.log("error_invalid_name", e.status == 400, "HTTP {}".format(e.status)) + j.note("Invalid name correctly rejected with 400.") + j.blank() + + # --------------------------------------------------------------- + # Phase 4: Write operations (single journey thread) + # --------------------------------------------------------------- + print("\nPhase 4: Write operations") + + # Find or create the journey thread + if not journey_id: + journey_id = find_journey_thread(client, namespace) + + if journey_id: + j.step("Reuse Journey Thread") + j.log("find_journey", True, journey_id[:8]) + j.note("Found existing journey thread `{}`.".format(journey_id[:8])) + j.blank() + else: + j.step("Create Journey Thread") + create_result = client.create_thread( + namespace=namespace, + title=THREAD_TITLE, + data="# {}\n\nInitial creation.".format(THREAD_TITLE), + ) + journey_id = create_result["node"]["id"] + j.log("create_thread", bool(journey_id), "node {}".format(journey_id[:8])) + j.note("Created journey thread `{}`.".format(journey_id[:8])) + j.blank() + + # Reply + j.step("Reply") + reply_result = client.reply( + journey_id, + data="Test reply from run at {}. " + "Verifies `POST /api/v1/threads/{{node_id}}/replies`.".format(now), + ) + reply_id = reply_result["node"]["id"] + j.log("reply", bool(reply_id), "node {}".format(reply_id[:8])) + j.note("Posted reply `{}`.".format(reply_id[:8])) + j.blank() + + # Edit reply + j.step("Edit Reply") + client.edit_node( + reply_id, + data="Test reply from run at {} (edited). " + "Verifies `PATCH /api/v1/nodes/{{node_id}}`.".format(now), + ) + j.log("edit_reply", True) + j.note("Edited reply `{}`.".format(reply_id[:8])) + j.blank() + + # --------------------------------------------------------------- + # Phase 5: Readback + # --------------------------------------------------------------- + print("\nPhase 5: Readback") + + j.step("Readback") + readback = client.get_thread(journey_id) + ok = readback["thread"]["title"].strip() == THREAD_TITLE + j.log("readback", ok, "{} replies".format(len(readback["replies"]))) + j.note("Read back thread: {} replies.".format(len(readback["replies"]))) + j.blank() + + # --------------------------------------------------------------- + # Phase 6: Update journey thread body + # --------------------------------------------------------------- + print("\nPhase 6: Update journey thread") + + j.note("---") + j.blank() + j.note(j.summary_line()) + j.blank() + j.note("_Run at {} by `remarkbox_client.py`._".format(now)) + + # Build new body: keep existing content, append this run's section + existing_body = readback["thread"]["data"] + run_header = "\n\n## Run: {}\n\n".format(now) + updated_body = existing_body.rstrip() + run_header + j.render() + + client.edit_node(journey_id, data=updated_body) + j.log("update_journey", True, "appended run section") + + print("\n Journey: {}/api/v1/threads/{}".format(url, journey_id)) + + return j + + +def main(): + parser = argparse.ArgumentParser( + description="Functional test of the Remarkbox API against a live instance." + ) + parser.add_argument("url", help="Base URL (e.g. https://my.remarkbox.com)") + parser.add_argument("namespace", help="Namespace (e.g. meta.remarkbox.com)") + parser.add_argument("email", help="Email for authentication") + parser.add_argument("otp", nargs="?", default=None, help="OTP code (optional)") + parser.add_argument("--name", default=None, + help="Set display name (idempotent)") + parser.add_argument("--journey", default=None, + help="Existing journey thread ID to append to") + + args = parser.parse_args() + + print("Remarkbox API Functional Test") + print(" URL: {}".format(args.url)) + print(" Namespace: {}".format(args.namespace)) + print(" Email: {}".format(args.email)) + if args.name: + print(" Name: {}".format(args.name)) + if args.journey: + print(" Journey: {}".format(args.journey)) + + j = run(args.url, args.namespace, args.email, + otp=args.otp, display_name=args.name, journey_id=args.journey) + + print("\n" + "=" * 40) + print("Results: {}/{}".format(j.passed, j.total)) + + if j.passed == j.total: + print("All tests passed.") + else: + print("Some tests failed.") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/remarkbox/api/remarkbox_client.py b/remarkbox/api/remarkbox_client.py new file mode 100644 index 0000000..2d2d62a --- /dev/null +++ b/remarkbox/api/remarkbox_client.py @@ -0,0 +1,398 @@ +""" +Remarkbox API Client (Python, stdlib only) + +Download: + curl -s https://REMARKBOX/api/v1/clients/python -o remarkbox_client.py + wget -q https://REMARKBOX/api/v1/clients/python -O remarkbox_client.py + +Quick start: + from remarkbox_client import RemarkboxClient + + client = RemarkboxClient("https://my.remarkbox.com") + + # List threads + result = client.list_threads("meta.remarkbox.com") + for thread in result["threads"]: + print(thread["title"]) + + # Read a thread and its replies + thread = client.get_thread(thread_id) + for reply in thread["replies"]: + print(reply["data"]) + + # Post anonymously (namespace must allow anonymous) + node = client.create_thread( + namespace="meta.remarkbox.com", + title="Hello from Python", + data="This is a test post.", + anonymous_name="MyBot", + ) + + # Reply to a thread + reply = client.reply(node["node"]["id"], data="Nice thread!") + + # Authenticate via email OTP + client.login("agent@example.com") + # ... check inbox for 6-digit code ... + client.verify("agent@example.com", "123456") + + # Now requests are authenticated + thread = client.create_thread( + namespace="meta.remarkbox.com", + title="Verified post", + data="Posted with a session.", + ) + + # Edit your own post + client.edit_node(thread["node"]["id"], data="Updated content.") + +Configuration: + # From arguments (highest priority) + client = RemarkboxClient("https://my.remarkbox.com") + + # From environment variables + # REMARKBOX_URL=https://my.remarkbox.com + # REMARKBOX_EMAIL=agent@example.com + client = RemarkboxClient.from_env() + + # From config file (~/.config/remarkbox/config.json) + # {"url": "https://my.remarkbox.com", "email": "agent@example.com"} + client = RemarkboxClient.from_config() + +Requires: Python 3.6+ (stdlib only, no pip install needed) +License: Same as Remarkbox +""" + +import json +import os +import http.cookiejar +import urllib.request +import urllib.error +import urllib.parse + + +__version__ = "0.1.0" + + +class RemarkboxError(Exception): + """Raised when the API returns an error response.""" + + def __init__(self, status, body): + self.status = status + self.body = body + msg = body.get("error", str(body)) if isinstance(body, dict) else str(body) + super().__init__("HTTP {}: {}".format(status, msg)) + + +class RemarkboxClient: + """Remarkbox API client. Manages sessions via cookies automatically.""" + + def __init__(self, url, email=None, cookie_file=None): + """ + Args: + url: Base URL of the Remarkbox instance (e.g. https://my.remarkbox.com) + email: Optional default email for login/verify + cookie_file: Optional path to persist session cookies across runs. + If provided, cookies are loaded on init and saved + after login/verify. Use this to stay logged in. + """ + self.url = url.rstrip("/") + self.email = email + self._cookie_file = cookie_file + if cookie_file: + self._cookie_jar = http.cookiejar.MozillaCookieJar(cookie_file) + if os.path.exists(cookie_file): + self._cookie_jar.load(ignore_discard=True, ignore_expires=True) + else: + self._cookie_jar = http.cookiejar.CookieJar() + self._opener = urllib.request.build_opener( + urllib.request.HTTPCookieProcessor(self._cookie_jar) + ) + + @classmethod + def from_env(cls): + """Create client from environment variables. + + Reads: + REMARKBOX_URL (required) + REMARKBOX_EMAIL (optional) + """ + url = os.environ.get("REMARKBOX_URL") + if not url: + raise RemarkboxError(0, {"error": "REMARKBOX_URL environment variable not set"}) + email = os.environ.get("REMARKBOX_EMAIL") + return cls(url, email=email) + + @classmethod + def from_config(cls, path=None): + """Create client from a JSON config file. + + Default path: ~/.config/remarkbox/config.json + + Config format: + {"url": "https://my.remarkbox.com", "email": "agent@example.com"} + """ + if path is None: + path = os.path.join( + os.path.expanduser("~"), ".config", "remarkbox", "config.json" + ) + with open(path) as f: + config = json.load(f) + url = config.get("url") + if not url: + raise RemarkboxError(0, {"error": "url is required in config file"}) + return cls(url, email=config.get("email"), cookie_file=config.get("cookie_file")) + + def _save_cookies(self): + """Persist cookies to disk if cookie_file was provided.""" + if self._cookie_file and hasattr(self._cookie_jar, "save"): + self._cookie_jar.save(ignore_discard=True, ignore_expires=True) + + def _request(self, method, path, body=None): + """Make an HTTP request and return parsed JSON.""" + url = self.url + path + data = None + headers = {} + + if body is not None: + data = json.dumps(body).encode("utf-8") + headers["Content-Type"] = "application/json" + + req = urllib.request.Request(url, data=data, headers=headers, method=method) + + try: + resp = self._opener.open(req) + raw = resp.read().decode("utf-8") + try: + return json.loads(raw) if raw else {} + except json.JSONDecodeError: + raise RemarkboxError(resp.status, {"error": "Non-JSON response"}) + except urllib.error.HTTPError as e: + raw = e.read().decode("utf-8") + try: + body = json.loads(raw) + except Exception: + body = {"error": raw} + raise RemarkboxError(e.code, body) + + # ----- Threads ----- + + def list_threads(self, namespace, page=1): + """List threads in a namespace. + + Args: + namespace: The namespace name (e.g. "meta.remarkbox.com") + page: Page number (default 1) + + Returns: + dict with keys: namespace, threads, page, page_size + """ + params = urllib.parse.urlencode({"namespace": namespace, "page": page}) + return self._request("GET", "/api/v1/threads?" + params) + + def get_thread(self, node_id): + """Get a thread and all its replies. + + Args: + node_id: UUID of the root thread node + + Returns: + dict with keys: namespace, thread, replies + """ + return self._request("GET", "/api/v1/threads/{}".format(node_id)) + + def create_thread(self, namespace, title, data, anonymous_name=None, email=None): + """Create a new thread. + + Args: + namespace: Target namespace name + title: Thread title + data: Markdown content (max 500000 chars) + anonymous_name: Name for anonymous posting (optional) + email: Email to associate with post (optional) + + Returns: + dict with keys: node, verified + """ + body = {"namespace": namespace, "title": title, "data": data} + if anonymous_name: + body["anonymous_name"] = anonymous_name + if email: + body["email"] = email + return self._request("POST", "/api/v1/threads", body) + + # ----- Replies ----- + + def reply(self, node_id, data, anonymous_name=None, email=None): + """Reply to a thread or another reply. + + Args: + node_id: UUID of the parent node (thread or reply) + data: Markdown content (max 500000 chars) + anonymous_name: Name for anonymous posting (optional) + email: Email to associate with post (optional) + + Returns: + dict with keys: node, verified + """ + body = {"data": data} + if anonymous_name: + body["anonymous_name"] = anonymous_name + if email: + body["email"] = email + return self._request("POST", "/api/v1/threads/{}/replies".format(node_id), body) + + # ----- Nodes ----- + + def get_node(self, node_id): + """Get a single node by ID. + + Args: + node_id: UUID of the node + + Returns: + dict with key: node + """ + return self._request("GET", "/api/v1/nodes/{}".format(node_id)) + + def edit_node(self, node_id, data=None, title=None): + """Edit a node (requires authentication). + + Args: + node_id: UUID of the node to edit + data: New markdown content (optional) + title: New title, only for root nodes (optional) + + Returns: + dict with key: node + """ + body = {} + if data is not None: + body["data"] = data + if title is not None: + body["title"] = title + if not body: + raise ValueError("data or title is required") + return self._request("PATCH", "/api/v1/nodes/{}".format(node_id), body) + + # ----- Auth ----- + + def login(self, email=None): + """Request an OTP code be sent to the email address. + + Args: + email: Email address (uses self.email if not provided) + + Returns: + dict with keys: status, message + """ + email = email or self.email + if not email: + raise ValueError("email is required") + return self._request("POST", "/api/v1/auth/login", {"email": email}) + + def verify(self, email=None, otp=None): + """Verify an OTP code and establish an authenticated session. + + After calling this, subsequent requests are authenticated + via the session cookie (managed automatically). + + Args: + email: Email address (uses self.email if not provided) + otp: The 6-digit verification code from email + + Returns: + dict with keys: status, user + """ + email = email or self.email + if not email: + raise ValueError("email is required") + if not otp: + raise ValueError("otp is required") + result = self._request("POST", "/api/v1/auth/verify", {"email": email, "otp": otp}) + self._save_cookies() + return result + + # ----- Profile ----- + + def get_profile(self): + """Get the current authenticated user's profile. + + Returns: + dict with key: user (id, name, email) + """ + return self._request("GET", "/api/v1/user/profile") + + def update_profile(self, name): + """Update the current user's display name. + + Args: + name: New display name (alphanumeric and dashes only) + + Returns: + dict with key: user (id, name, email) + """ + return self._request("PATCH", "/api/v1/user/profile", {"name": name}) + + +# ----- CLI ----- + +def main(): + """Simple CLI for quick testing.""" + import sys + + usage = """Usage: python remarkbox_client.py [args...] + +Commands: + threads List threads + thread Get thread with replies + node Get a single node + post <data> [name] Create thread (anonymous) + reply <node_id> <data> [name] Reply to thread (anonymous) + login <email> Request OTP + verify <email> <otp> Verify OTP + +Examples: + python remarkbox_client.py https://my.remarkbox.com threads meta.remarkbox.com + python remarkbox_client.py https://my.remarkbox.com post meta.remarkbox.com "Hello" "World" MyBot +""" + + if len(sys.argv) < 3: + print(usage) + sys.exit(1) + + url = sys.argv[1] + cmd = sys.argv[2] + args = sys.argv[3:] + client = RemarkboxClient(url) + + try: + if cmd == "threads" and len(args) >= 1: + result = client.list_threads(args[0]) + elif cmd == "thread" and len(args) >= 1: + result = client.get_thread(args[0]) + elif cmd == "node" and len(args) >= 1: + result = client.get_node(args[0]) + elif cmd == "post" and len(args) >= 3: + name = args[3] if len(args) > 3 else None + result = client.create_thread(args[0], args[1], args[2], anonymous_name=name) + elif cmd == "reply" and len(args) >= 2: + name = args[2] if len(args) > 2 else None + result = client.reply(args[0], args[1], anonymous_name=name) + elif cmd == "login" and len(args) >= 1: + result = client.login(args[0]) + elif cmd == "verify" and len(args) >= 2: + result = client.verify(args[0], args[1]) + else: + print(usage) + sys.exit(1) + + print(json.dumps(result, indent=2)) + + except RemarkboxError as e: + print(json.dumps({"error": str(e), "status": e.status}, indent=2), file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/remarkbox/api/views.py b/remarkbox/api/views.py index 6c5a0c6..f957f0b 100644 --- a/remarkbox/api/views.py +++ b/remarkbox/api/views.py @@ -1,5 +1,7 @@ +import os import re +from pyramid.response import Response from pyramid.view import view_config from remarkbox.models import ( @@ -8,7 +10,11 @@ from remarkbox.models import ( get_or_create_user_surrogate_by_name, get_nodes_who_share_root, ) -from remarkbox.models.user import get_or_create_user_by_email +from remarkbox.models.user import ( + get_or_create_user_by_email, + is_user_name_valid, + is_user_name_available, +) from remarkbox.models.namespace import get_or_create_namespace from remarkbox.lib.mail import send_verification_digits_to_email from remarkbox.lib.notify import schedule_notifications @@ -492,3 +498,95 @@ def api_auth_verify(request): request.response.status_code = 401 return {"error": "Invalid verification code"} + + +# --------------------------------------------------------------------------- +# User Profile +# --------------------------------------------------------------------------- + + +@view_config( + route_name="api-user-profile", + request_method="GET", + renderer="json", + require_csrf=False, +) +def api_get_profile(request): + """Get the current user's profile.""" + if not request.user or not request.user.authenticated: + request.response.status_code = 401 + return {"error": "Authentication required"} + + return { + "user": { + "id": str(request.user.id), + "name": request.user.name, + "email": request.user.email, + }, + } + + +@view_config( + route_name="api-user-profile", + request_method="PATCH", + renderer="json", + require_csrf=False, +) +def api_update_profile(request): + """Update the current user's profile (display name).""" + if not request.user or not request.user.authenticated: + request.response.status_code = 401 + return {"error": "Authentication required"} + + body = get_json_body(request) + name = body.get("name", "").strip() + + if not name: + request.response.status_code = 400 + return {"error": "name is required"} + + if not is_user_name_valid(name): + request.response.status_code = 400 + return {"error": "name must be alphanumeric (dashes allowed)"} + + # Allow setting to current name (idempotent) + if name.lower() != request.user.name.lower(): + if not is_user_name_available(request.dbsession, name): + request.response.status_code = 409 + return {"error": "name is already taken"} + + request.user.name = name + request.dbsession.add(request.user) + request.dbsession.flush() + + return { + "user": { + "id": str(request.user.id), + "name": request.user.name, + "email": request.user.email, + }, + } + + +# --------------------------------------------------------------------------- +# Clients +# --------------------------------------------------------------------------- + +_client_dir = os.path.dirname(os.path.abspath(__file__)) + + +@view_config( + route_name="api-client-python", + request_method="GET", + require_csrf=False, +) +def api_client_python(request): + """Serve the Python client for agents to download.""" + path = os.path.join(_client_dir, "remarkbox_client.py") + with open(path) as f: + content = f.read() + return Response( + body=content, + content_type="text/plain", + charset="utf-8", + ) diff --git a/remarkbox/tests/test_api_client.py b/remarkbox/tests/test_api_client.py new file mode 100644 index 0000000..63f507b --- /dev/null +++ b/remarkbox/tests/test_api_client.py @@ -0,0 +1,254 @@ +import json +import os +import tempfile +import unittest +from unittest import mock +from http.server import HTTPServer, BaseHTTPRequestHandler +import threading + + +class FakeHandler(BaseHTTPRequestHandler): + """Minimal HTTP handler for testing the client.""" + + routes = {} + + def do_GET(self): + self._handle() + + def do_POST(self): + self._handle() + + def do_PATCH(self): + self._handle() + + def _handle(self): + content_len = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(content_len) if content_len else b"" + parsed = json.loads(body) if body else {} + + # Store the request for assertions + FakeHandler.last_request = { + "method": self.command, + "path": self.path, + "body": parsed, + "headers": dict(self.headers), + } + + path = self.path.split("?")[0] + key = (self.command, path) + if key in self.routes: + status, response = self.routes[key] + else: + status, response = 200, {"ok": True} + + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps(response).encode()) + + def log_message(self, format, *args): + pass # Suppress logs + + +class TestRemarkboxClient(unittest.TestCase): + """Unit tests for the Python client.""" + + @classmethod + def setUpClass(cls): + cls.server = HTTPServer(("127.0.0.1", 0), FakeHandler) + cls.port = cls.server.server_address[1] + cls.base_url = "http://127.0.0.1:{}".format(cls.port) + cls.thread = threading.Thread(target=cls.server.serve_forever) + cls.thread.daemon = True + cls.thread.start() + + @classmethod + def tearDownClass(cls): + cls.server.shutdown() + + def setUp(self): + FakeHandler.routes = {} + FakeHandler.last_request = None + + def _make_client(self): + from remarkbox.api.remarkbox_client import RemarkboxClient + return RemarkboxClient(self.base_url) + + def test_list_threads(self): + FakeHandler.routes[("GET", "/api/v1/threads")] = ( + 200, + {"threads": [{"id": "abc", "title": "Test"}], "page": 1}, + ) + client = self._make_client() + result = client.list_threads("example.com") + self.assertEqual(result["threads"][0]["title"], "Test") + self.assertIn("namespace=example.com", FakeHandler.last_request["path"]) + + def test_get_thread(self): + FakeHandler.routes[("GET", "/api/v1/threads/abc-123")] = ( + 200, + {"thread": {"id": "abc-123"}, "replies": []}, + ) + client = self._make_client() + result = client.get_thread("abc-123") + self.assertEqual(result["thread"]["id"], "abc-123") + + def test_create_thread(self): + FakeHandler.routes[("POST", "/api/v1/threads")] = ( + 201, + {"node": {"id": "new-1"}, "verified": True}, + ) + client = self._make_client() + result = client.create_thread( + "example.com", "Title", "Body", anonymous_name="Bot" + ) + self.assertEqual(result["node"]["id"], "new-1") + body = FakeHandler.last_request["body"] + self.assertEqual(body["namespace"], "example.com") + self.assertEqual(body["title"], "Title") + self.assertEqual(body["anonymous_name"], "Bot") + + def test_reply(self): + FakeHandler.routes[("POST", "/api/v1/threads/abc/replies")] = ( + 201, + {"node": {"id": "reply-1"}, "verified": True}, + ) + client = self._make_client() + result = client.reply("abc", "Reply text", anonymous_name="Bot") + self.assertEqual(result["node"]["id"], "reply-1") + self.assertEqual(FakeHandler.last_request["body"]["data"], "Reply text") + + def test_get_node(self): + FakeHandler.routes[("GET", "/api/v1/nodes/node-1")] = ( + 200, + {"node": {"id": "node-1", "data": "content"}}, + ) + client = self._make_client() + result = client.get_node("node-1") + self.assertEqual(result["node"]["id"], "node-1") + + def test_edit_node(self): + FakeHandler.routes[("PATCH", "/api/v1/nodes/node-1")] = ( + 200, + {"node": {"id": "node-1", "data": "updated"}}, + ) + client = self._make_client() + result = client.edit_node("node-1", data="updated") + self.assertEqual(FakeHandler.last_request["body"]["data"], "updated") + + def test_edit_node_requires_data_or_title(self): + client = self._make_client() + with self.assertRaises(ValueError): + client.edit_node("node-1") + + def test_login(self): + FakeHandler.routes[("POST", "/api/v1/auth/login")] = ( + 200, + {"status": "sent", "message": "Code sent."}, + ) + client = self._make_client() + result = client.login("test@example.com") + self.assertEqual(result["status"], "sent") + self.assertEqual( + FakeHandler.last_request["body"]["email"], "test@example.com" + ) + + def test_verify(self): + FakeHandler.routes[("POST", "/api/v1/auth/verify")] = ( + 200, + {"status": "authenticated", "user": {"id": "u1"}}, + ) + client = self._make_client() + result = client.verify("test@example.com", "123456") + self.assertEqual(result["status"], "authenticated") + body = FakeHandler.last_request["body"] + self.assertEqual(body["email"], "test@example.com") + self.assertEqual(body["otp"], "123456") + + def test_login_requires_email(self): + client = self._make_client() + with self.assertRaises(ValueError): + client.login() + + def test_verify_requires_otp(self): + client = self._make_client() + with self.assertRaises(ValueError): + client.verify("test@example.com") + + def test_error_raises_remarkbox_error(self): + from remarkbox.api.remarkbox_client import RemarkboxError + + FakeHandler.routes[("GET", "/api/v1/threads")] = ( + 400, + {"error": "namespace parameter is required"}, + ) + client = self._make_client() + with self.assertRaises(RemarkboxError) as ctx: + client.list_threads("") + self.assertEqual(ctx.exception.status, 400) + self.assertIn("namespace", str(ctx.exception)) + + def test_default_email_used(self): + from remarkbox.api.remarkbox_client import RemarkboxClient + + FakeHandler.routes[("POST", "/api/v1/auth/login")] = ( + 200, + {"status": "sent"}, + ) + client = RemarkboxClient(self.base_url, email="default@example.com") + client.login() + self.assertEqual( + FakeHandler.last_request["body"]["email"], "default@example.com" + ) + + def test_from_env(self): + from remarkbox.api.remarkbox_client import RemarkboxClient + + with mock.patch.dict( + os.environ, + {"REMARKBOX_URL": "https://test.example.com", "REMARKBOX_EMAIL": "a@b.com"}, + ): + client = RemarkboxClient.from_env() + self.assertEqual(client.url, "https://test.example.com") + self.assertEqual(client.email, "a@b.com") + + def test_from_env_missing_url(self): + from remarkbox.api.remarkbox_client import RemarkboxClient, RemarkboxError + + with mock.patch.dict(os.environ, {}, clear=True): + # Remove the keys if they exist + os.environ.pop("REMARKBOX_URL", None) + with self.assertRaises(RemarkboxError): + RemarkboxClient.from_env() + + def test_from_config(self): + from remarkbox.api.remarkbox_client import RemarkboxClient + + config = {"url": "https://config.example.com", "email": "c@d.com"} + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(config, f) + f.flush() + try: + client = RemarkboxClient.from_config(f.name) + self.assertEqual(client.url, "https://config.example.com") + self.assertEqual(client.email, "c@d.com") + finally: + os.unlink(f.name) + + def test_url_trailing_slash_stripped(self): + from remarkbox.api.remarkbox_client import RemarkboxClient + + client = RemarkboxClient("https://example.com/") + self.assertEqual(client.url, "https://example.com") + + def test_cookies_persist_across_requests(self): + """Verify session cookies are maintained (important for auth flow).""" + FakeHandler.routes[("GET", "/api/v1/threads")] = ( + 200, + {"threads": [], "page": 1}, + ) + client = self._make_client() + # Make two requests - the cookie jar should persist + client.list_threads("a.com") + client.list_threads("b.com") + # No error means the opener and cookie jar survived both calls diff --git a/remarkbox/tests/test_api_views.py b/remarkbox/tests/test_api_views.py index 11a69d1..2b9e7b6 100644 --- a/remarkbox/tests/test_api_views.py +++ b/remarkbox/tests/test_api_views.py @@ -849,3 +849,17 @@ class TestAPINamespaceOptOut(APIFunctionalTests): expect_errors=True, ) self.assertEqual(res.status_int, 403) + + +class TestAPIClientDownload(APIFunctionalTests): + """Test the client download endpoint.""" + + def test_download_python_client(self): + res = self.testapp.get("/api/v1/clients/python") + self.assertEqual(res.status_int, 200) + self.assertIn("text/plain", res.content_type) + self.assertIn("RemarkboxClient", res.text) + self.assertIn("def list_threads", res.text) + self.assertIn("def reply", res.text) + self.assertIn("def login", res.text) + self.assertIn("def verify", res.text)