diff --git a/tools/main.py b/tools/main.py index d182fd7..e69cbfd 100644 --- a/tools/main.py +++ b/tools/main.py @@ -7,6 +7,7 @@ import traceback from pathlib import Path from rokuHandler import RokuWrapper, ROKU_IP +from privatebinHandler import PrivateBinWrapper HOST = os.environ.get("HOST", "0.0.0.0") PORT = int(os.environ.get("PORT", "1331")) @@ -19,6 +20,7 @@ class HTTPHandler(BaseHTTPRequestHandler): super().setup() self.rapp = RokuWrapper(self) + self.pbin = PrivateBinWrapper(self) def _cors(self): self.send_header("Access-Control-Allow-Origin", "*") @@ -53,11 +55,16 @@ class HTTPHandler(BaseHTTPRequestHandler): try: # specs - if path == "/roku/openapi.json": - return self._send_file(ROOT / "spec" / "roku.openapi.json", "application/json") - if path.startswith("/roku"): + if path == "/roku/openapi.json": + return self._send_file(ROOT / "spec" / "roku.openapi.json", "application/json") + return self.rapp.run_command(path.replace("/roku/", '')) + + if path.startswith("/privatebin"): + if path == "/privatebin/openapi.json": + return self._send_file(ROOT / "spec" / "privatebin.openapi.json", "application/json") + return self.pbin.run_command(path.replace("/privatebin/", '')) # catch-all self._send(404, json.dumps({"error": "unknown endpoint"}).encode()) diff --git a/tools/privatebinHandler.py b/tools/privatebinHandler.py new file mode 100644 index 0000000..ac2aad4 --- /dev/null +++ b/tools/privatebinHandler.py @@ -0,0 +1,101 @@ +from __future__ import annotations +from typing import Dict, Any +import json +import urllib.parse as urlparse +import privatebinapi + +class PrivateBinWrapper(): + def __init__(self, parent): + self.parent = parent + + # read query params from the current request + def _qs(self) -> Dict[str, Any]: + parsed = urlparse.urlparse(self.parent.path) + + # parse_qs returns lists and flatten singletons + raw = urlparse.parse_qs(parsed.query, keep_blank_values=True) + flat: Dict[str, Any] = {} + + for k, v in raw.items(): + flat[k] = v[0] if len(v) == 1 else v + return flat + + def _ok(self, payload: Dict[str, Any], code: int = 200): + body = json.dumps(payload).encode() + self.parent._send(code, body) + + def _err(self, msg: str, code: int = 400): + self._ok({"error": msg}, code) + + def _create(self, qs: Dict[str, Any]): + base_url = qs.get("base_url") + text = qs.get("text") + password = qs.get("password") + expiration = qs.get("expiration", "1day") + formatting = qs.get("formatting", "plaintext") + burn_after_reading = qs.get("burn_after_reading", "false").lower() == "true" + discussion = qs.get("discussion", "false").lower() == "true" + + if not base_url: + return self._err("missing required 'base_url'") + if not text: + return self._err("missing required 'text'") + + try: + resp = privatebinapi.send( + base_url, + text=text, + password=password, + expiration=expiration, + formatting=formatting, + burn_after_reading=burn_after_reading, + discussion=discussion, + ) + # resp already contains: status, id, url, full_url, deletetoken, passcode? + return self._ok(resp, 200) + except Exception as e: + return self._err(f"privatebin send failed: {e}", 502) + + def _read(self, qs: Dict[str, Any]): + full_url = qs.get("full_url") + password = qs.get("password") + if not full_url: + return self._err("missing required 'full_url'") + try: + resp = privatebinapi.get(full_url, password=password) + # has status, id, url, v, text, meta, attachment + return self._ok(resp, 200) + except Exception as e: + return self._err(f"privatebin get failed: {e}", 502) + + def _delete(self, qs: Dict[str, Any]): + full_url = qs.get("full_url") + deletetoken = qs.get("deletetoken") + if not full_url or not deletetoken: + return self._err("missing required 'full_url' or 'deletetoken'") + try: + resp = privatebinapi.delete(full_url, deletetoken) + if resp == None: + return self._err("response is null!") + else: + return self._ok(resp, 200) + except Exception as e: + return self._err(f"privatebin delete failed: {e}", 502) + + def run_command(self, cmd_path: str): + # expected forms: + # /privatebin/create?base_url=...&text=...&password=...&expiration=... + # /privatebin/read?full_url=...&password=... + # /privatebin/delete?full_url=...&deletetoken=... + cmd = (cmd_path or "").strip("/").lower() + qs = self._qs() + + if cmd == "create": + return self._create(qs) + if cmd == "read": + return self._read(qs) + if cmd == "delete": + return self._delete(qs) + + # unknown subpath + return self._err("unknown privatebin command", 404) diff --git a/tools/requirements.txt b/tools/requirements.txt index f76832d..08f863c 100644 --- a/tools/requirements.txt +++ b/tools/requirements.txt @@ -1,6 +1,18 @@ +anyio==4.10.0 +argcomplete==3.6.2 +base58==2.1.1 certifi==2025.8.3 charset-normalizer==3.4.3 +h11==0.16.0 +httpcore==1.0.9 +httpx==0.28.1 idna==3.10 +PBinCLI==0.3.7 +PrivateBinAPI==1.0.0 +pycryptodome==3.23.0 +PySocks==1.7.1 requests==2.32.5 roku==4.1.0 +sjcl==0.2.1 +sniffio==1.3.1 urllib3==2.5.0 diff --git a/tools/spec/privatebin.openapi.json b/tools/spec/privatebin.openapi.json new file mode 100644 index 0000000..de8ff24 --- /dev/null +++ b/tools/spec/privatebin.openapi.json @@ -0,0 +1,367 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "PrivateBin HTTP API", + "description": "wrapper server for privatebin API (create/read/delete) via query parameters", + "version": "1.0.0" + }, + "servers": [ + { + "url": "http://{host}:{port}", + "variables": { + "host": { + "default": "0.0.0.0" + }, + "port": { + "default": "1331" + } + } + } + ], + "paths": { + "/privatebin/create": { + "get": { + "operationId": "privatebinCreate", + "summary": "create a privatebin paste", + "parameters": [ + { + "name": "base_url", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "uri" + }, + "description": "root url of your privatebin instance, e.g. https://privatebin.example" + }, + { + "name": "text", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "plain text of the paste" + }, + { + "name": "password", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "optional password to protect the paste" + }, + { + "name": "expiration", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "5min", + "10min", + "1hour", + "1day", + "1week", + "1month", + "1year", + "never" + ], + "default": "1day" + }, + "description": "expire time" + }, + { + "name": "formatting", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "plaintext", + "syntaxhighlighting", + "markdown" + ], + "default": "plaintext" + }, + "description": "one of plaintext, syntaxhighlighting, markdown" + }, + { + "name": "burn_after_reading", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false + }, + "description": "delete after first read" + }, + { + "name": "discussion", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false + }, + "description": "enable comments/discussion" + } + ], + "responses": { + "200": { + "description": "paste created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreatePasteResponse" + } + } + } + }, + "400": { + "description": "invalid request parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "upstream privatebin send failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/privatebin/read": { + "get": { + "operationId": "privatebinRead", + "summary": "read a privatebin paste", + "parameters": [ + { + "name": "full_url", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "uri" + }, + "description": "full url of the paste (including key fragment)" + }, + { + "name": "password", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "password if the paste is protected" + } + ], + "responses": { + "200": { + "description": "paste fetched successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadPasteResponse" + } + } + } + }, + "400": { + "description": "missing full_url parameter", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "upstream privatebin get failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/privatebin/delete": { + "get": { + "operationId": "privatebinDelete", + "summary": "delete a privatebin paste", + "parameters": [ + { + "name": "full_url", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "uri" + }, + "description": "full url of the paste (including key fragment)" + }, + { + "name": "deletetoken", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "deletetoken returned upon create" + } + ], + "responses": { + "200": { + "description": "paste deleted successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeletePasteResponse" + } + } + } + }, + "400": { + "description": "missing full_url or deletetoken", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "upstream privatebin delete failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "CreatePasteResponse": { + "type": "object", + "additionalProperties": false, + "properties": { + "status": { + "type": "integer" + }, + "id": { + "type": "string" + }, + "url": { + "type": "string" + }, + "full_url": { + "type": "string" + }, + "deletetoken": { + "type": "string" + }, + "passcode": { + "type": "string" + } + }, + "required": [ + "status", + "id", + "url", + "full_url", + "deletetoken" + ] + }, + "ReadPasteResponse": { + "type": "object", + "additionalProperties": false, + "properties": { + "status": { + "type": "integer" + }, + "id": { + "type": "string" + }, + "url": { + "type": "string" + }, + "v": { + "type": "integer" + }, + "text": { + "type": "string" + }, + "meta": { + "type": "object", + "additionalProperties": true + }, + "attachment": { + "type": "object", + "additionalProperties": true, + "description": "attachment object: includes filename and content (base64 or bytes)" + } + }, + "required": [ + "status", + "id", + "url", + "v" + ] + }, + "DeletePasteResponse": { + "type": "object", + "additionalProperties": false, + "properties": { + "status": { + "type": "integer" + }, + "id": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "status", + "id", + "url" + ] + }, + "Error": { + "type": "object", + "additionalProperties": false, + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } +} \ No newline at end of file