Dup Ver Goto 📝

Clipper Network Clipboard v3

To
1562 lines, 4711 words, 45724 chars Page 'NetworkClipboard3' does not exist.

This is a saner rewrite of NetworkClipboard2. The server uses the same MariaDB tables. All in all, it's just under 1400 lines of PHP and Python combined. (Small enough not to need software engineering skills and practices in order to manage the codebase.)

Usage

clipclient put garden:turnip myfile       # copy myfile to clipboard named turnip in namespace garden
echo hello world | clipclient put hello:world   # from stdin
echo hello world | clipclient put hello:world myfile -  # read from myfile, then from stdin
clipclient get garden:turnip            # get a clip
clipclient history garden:turnip        # list all versions of a clip
clipclient list 'garden:^t.*p$'         # list all clips in namespace garden matching regex ^t.*p$
clipclient listall '^t.*$'              # like list, but across all namespaces

I then make shorthand commands like e.g. cget

#!/bin/bash
export BACKEND=http://me.com/path/to/clip.php
clipclient get "$@"

Server

clip.php

Here bouncer.php checks auth credentials and bails out with an error if the user doesn't have the right credentials, else does nothing. Naturally I'm not including its contents here. But essentially it has one line: if( !userIsAuthorised() ) { die(); }

<?php
require_once("bouncer.php");
require_once("Clipper.php");

define("NAME_RE",'/^[A-Za-z0-9_-]+$/');
define('HOST',"localhost");
define('PORT',3306);
define('USER',"me");
define('PASS',"lemmein");
define('DB',"clipboard");
define('PREFIX',"turnip");

$clipper = new Jda\Clipper\Clipper(HOST,PORT,USER,PASS,DB,PREFIX,NAME_RE);
$clipper->go();

Clipper.php

<?php
namespace Jda\Clipper;

require_once("jsonetc.php");

class Clipper {
  var $conn;
  var $host, $port, $user, $pass, $db, $prefix;
  var $name_re;
  var $name, $namespace;
  var $responsecode, $result;

  function isValidName($string) {
    return preg_match($this->name_re,$string);
  }

  static function isRegularExpression($string) {
    set_error_handler(function() {}, E_WARNING);
    $isRegularExpression = preg_match($string, "") !== FALSE;
    restore_error_handler();
    return $isRegularExpression;
  }

  function __construct($host,$port,$user,$pass,$db,$prefix,$name_re) {
    $this->host = $host;
    $this->port = $port;
    $this->user = $user;
    $this->pass = $pass;
    $this->db = $db;
    $this->prefix = $prefix;
    $this->name_re = $name_re;
    $this->responsecode = 500;
    $this->result = ["ouch" => "result not set"];
  }

  function set_result($result,$responsecode = 200) {
    $this->result = $result;
    $this->responsecode = $responsecode;
  }

  function go() {
    must_post();
    $conn = $this->conn = new \mysqli($this->host, $this->user, $this->pass, $this->db);
    if ($conn->connect_error) {
      serve_error_json("connerror","SQL Connect Error",500);
    }
    $req = get_json_from_post();
    if( !isset($req['request']) ) {
      return serve_error_json("norequest","No request",400);
    }
    $request = $req['request'];
    $method = "req_".$request;
    // see if req_method exists, if so, call it, passing the req object
    if( !method_exists($this,$method) ) {
      return serve_error_json("invalidrequest","Invalid request '$request'",400);
    }
    $this->$method($req);
    $this->conn->close();
    $this->result["request"] = $req;
    serve_json($this->result,$this->responsecode);
  }

  # cfind2j.php { request: find, pattern, namespace? } // find clips containing X using match( pattern ) against in sql
  function req_find($req) {
    if( !isset($req["pattern"]) ) {
      $pattern = "";
    } else {
      $pattern = $req["pattern"];
    }

    if( !isset($req["namespace"]) ) {
      $namespace = null;
    } else {
      $namespace = $req["namespace"];
    }
    if( !is_null($namespace) && !$this->isValidName($namespace) ) {
      serve_error_json("invalidnamespace","Invalid namespace",400);
    }
    if( !isset($req["limit"]) ) {
      $limit = 20;
    } else {
      $limit = intval($req["limit"]);
      $limit = min(max(1,$limit),1000);
    }

    $conn = $this->conn;
    $prefix = $this->prefix;

    if( is_null($namespace) ) {
      $sql = "SELECT id, namespace, name FROM {$prefix}_clips WHERE MATCH( value ) AGAINST ( ? ) LIMIT $limit";
      $stmt = $conn->prepare($sql);
      $stmt->bind_param("s",$pattern);
    } else {
      $sql = "SELECT id, namespace, name FROM {$prefix}_clips 
        WHERE MATCH( value ) AGAINST ( ? ) AND namespace = ? LIMIT $limit";
      $stmt = $conn->prepare($sql);
      $stmt->bind_param("ss",$pattern,$namespace);
    }
    $error = false;
    $found = false;
    $output = [];
    if( !$stmt->execute() ) {
      serve_error_json("sqlerror","SQL Error",500);
    }
    $result = $stmt->get_result(); 
    while($row = $result->fetch_assoc()) {
      array_push($output,$row);
    }
    if( count($output) == 0 ) {
      $this->set_result([
        "namespace" => $namespace,
        "pattern" => $pattern,
        "message" => "not found",
      ],404);
    }
    $this->set_result([
      "namespace" => $namespace,
      "pattern" => $pattern,
      "message" => "success",
      "matches" => $output
    ],200);
  }

  function get_name_ns($req) {
    if( !isset($req["name"]) ) {
      serve_error_json("noname","No name",400);
    }
    $this->name = $req["name"];
    if( !$this->isValidName($this->name) ) {
      serve_error_json("invalidname","Invalid name",400);
    }
    if( !isset($req["namespace"]) ) {
      $this->namespace = "_";
    } else {
      $this->namespace = $req["namespace"];
    }
    if( !$this->isValidName($this->namespace) ) {
      serve_error_json("invalidnamespace","Invalid namespace",400);
    }
  }

  # cget2j.php { request: get, name, namespace? } // get content
  function req_get($req) {
    $this->get_name_ns($req);
    $name = $this->name;
    $namespace = $this->namespace;
    $conn = $this->conn;
    $prefix = $this->prefix;

    $sql = "SELECT {$prefix}_pointers.clipid, {$prefix}_clips.value, {$prefix}_clips.time 
      FROM {$prefix}_pointers JOIN {$prefix}_clips 
      ON ({$prefix}_pointers.clipid = {$prefix}_clips.id) 
      WHERE (
        {$prefix}_pointers.namespace = ? AND
        {$prefix}_pointers.name = ?
      )";
    $stmt = $conn->prepare($sql);
    $stmt->bind_param("ss", $namespace,$name);
    $error = false;
    $found = false;
    $output = null;
    if( !$stmt->execute() ) {
      serve_error_json("sqlerror","SQL Error",500);
    }
    $result = $stmt->get_result(); 
    while($row = $result->fetch_assoc()) {
      $output = $row['value'];
      break;
    }
    if( $output == null ) {
      return $this->set_result([
        "namespace" => $namespace,
        "name" => $name,
        "message" => "not found"],
        404);
    }
    $this->set_result([
      "namespace" => $namespace,
      "name" => $name,
      "message" => "success",
      "value" => $output
    ]);
  }

  # getlast ns:name num_items char_limit [-r]
  function req_getlast($req) {
    $this->get_name_ns($req);
    $name = $this->name;
    $namespace = $this->namespace;
    $char_limit = 1024;
    $num_items = 20;
    if( isset($req["char_limit"]) ) {
      $char_limit = intval($req["char_limit"]);
    }
    if( $char_limit < 1 ) {
      serve_error_json("invalidcharlimit","Invalid character limit (<1)",400);
    } 
    if( isset($req["num_items"]) ) {
      $num_items = intval($req["num_items"]);
    }
    if( $num_items < 1 ) {
      serve_error_json("invalidnumitems","Invalid number of items (<1)",400);
    } 

    $conn = $this->conn;
    $prefix = $this->prefix;

    $sql = "SELECT name, SUBSTRING(value FROM 1 FOR ?), time 
      FROM {$prefix}_clips
      WHERE (
        namespace = ? AND
        name = ?
      ) 
      ORDER BY time DESC
      LIMIT ?";
    $stmt = $conn->prepare($sql);
    $stmt->bind_param("ssss", $char_limit, $namespace, $name, $num_items);
    $error = false;
    $found = false;
    $output = [];
    if( !$stmt->execute() ) {
      serve_error_json("sqlerror","SQL Error",500);
    }
    $result = $stmt->get_result(); 
    while($row = $result->fetch_row()) {
      array_push($output,$row);
    }
    if( count($output) == 0 ) {
      return $this->set_result([
        "namespace" => $namespace,
        "name" => $name,
        "message" => "not found"],
        404);
    }
    $this->set_result([
      "namespace" => $namespace,
      "name" => $name,
      "message" => "success",
      "items" => $output
    ]);
  }

  # cput2j.php { request: put, name, namespace?, value }
  function req_put($req) {
    $this->get_name_ns($req);
    $name = $this->name;
    $namespace = $this->namespace;
    $conn = $this->conn;
    $prefix = $this->prefix;

    if( $name === "" ) {
      serve_error_json("emptyname","Name must not be empty string",400);
    }

    $append = false;
    if( isset($_GET['append']) ) {
      $append = $_GET['append'] == "y";
    }

    if( !isset($req["value"]) ) {
      serve_error_json("novalue","No value",400);
    }
    $value = $req["value"];

    $prev = "";
    if( $append ) {
      $sql = "SELECT {$prefix}_pointers.clipid, {$prefix}_clips.value, {$prefix}_clips.time 
        FROM {$prefix}_pointers JOIN {$prefix}_clips 
        ON ({$prefix}_pointers.clipid = {$prefix}_clips.id) 
        WHERE (
          {$prefix}_pointers.namespace = ? AND
          {$prefix}_pointers.name = ?
        )";
      $stmt = $conn->prepare($sql);
      $stmt->bind_param("ss", $namespace,$name);
      $output = null;
      if( !$stmt->execute() ) {
        serve_error_json("sqlerror","SQL Error",500);
      }
      $result = $stmt->get_result(); 
      while($row = $result->fetch_assoc()) {
        $output = $row['value'];
        break;
      }
      if( !is_null($output) ) {
        $prev = rtrim($output)."\n";
      }
    }
    $value = $prev.$value;

    $sql1 = $conn->prepare("START TRANSACTION");
    $sql2 = $conn->prepare("INSERT INTO {$prefix}_clips 
      (namespace,name,value) VALUES ( ?, ?, ? )");
    $sql2->bind_param("sss",$namespace,$name,$value);
    $sql3 = $conn->prepare("SET @a = (SELECT LAST_INSERT_ID())");
    $sql4 = $conn->prepare("INSERT INTO {$prefix}_pointers (namespace,name,clipid) 
      VALUES ( ?, ?, @a )
      ON DUPLICATE KEY UPDATE clipid=(@a)");
    $sql4->bind_param("ss",$namespace,$name);
    $sql5 = $conn->prepare("COMMIT");

    if( !$sql1->execute() ) { 
      serve_error_json("sqlerror","SQL Error put sql1",500);
    }
    if( !$sql2->execute() ) { 
      serve_error_json("sqlerror","SQL Error put sql2",500);
    }
    if( !$sql3->execute() ) { 
      serve_error_json("sqlerror","SQL Error put sql3",500);
    }
    if( !$sql4->execute() ) { 
      serve_error_json("sqlerror","SQL Error put sql4",500);
    }
    if( !$sql5->execute() ) { 
      serve_error_json("sqlerror","SQL Error put sql5",500);
    }

    $this->set_result([
      "namespace" => $namespace,
      "name" => $name,
      "message" => "inserted"
    ]);
  }

  function req_listall($req) {
    if( !isset($req["pattern"]) ) {
      $pattern = "";
    } else {
      $pattern = $req["pattern"];
    }
    if( !self::isRegularExpression('/'.$pattern.'/') ) {
      serve_error_json("invalidpattern","Invalid pattern '$pattern' -- not a valid regular expression",400);
    }

    $conn = $this->conn;
    $prefix = $this->prefix;

    $sql = "SELECT name, namespace, time FROM {$prefix}_clips ORDER BY time";
    $stmt = $conn->prepare($sql);
    $error = false;
    $found = false;
    $names = [];
    if( !$stmt->execute() ) {
      serve_error_json("sqlerror","SQL Error listall sql1",500);
    }
    $result = $stmt->get_result(); 
    $already = [];
    while($row = $result->fetch_assoc()) {
      $x = $row['name'];
      $namespace = $row['namespace'];
      $time = $row['time'];
      $nns = "$namespace:$x";
      if( array_key_exists($nns,$already) ) {
        continue;
      }
      $already[$nns] = true;
      if( @preg_match("/$pattern/",$x) ) {
        array_push($names,[$nns,$time]);
      }
    }
    if( count($names) == 0 ) {
      return $this->set_result([
        "name" => $pattern,
        "message" => "not found",
      ],404);
    }
    $this->set_result([
      "pattern" => $pattern,
      "message" => "success",
      "names" => $names
    ],200);
  }

  # clist2j.php { request: list, pattern, namespace? }
  function req_list($req) {
    if( !isset($req["pattern"]) ) {
      $pattern = "";
      //serve_error_json("nopattern","No pattern",400);
    } else {
      $pattern = $req["pattern"];
    }
    if( !self::isRegularExpression('/'.$pattern.'/') ) {
      serve_error_json("invalidpattern","Invalid pattern '$pattern' -- not a valid regular expression",400);
    }
    if( !isset($req["namespace"]) ) {
      $namespace = "_";
    } else {
      $namespace = $req["namespace"];
    }
    if( !$this->isValidName($namespace) ) {
      serve_error_json("invalidnamespace","Invalid namespace",400);
    }

    $conn = $this->conn;
    $prefix = $this->prefix;

    $sql = "SELECT name, namespace, time FROM {$prefix}_clips WHERE ( namespace = ? ) ORDER BY time";
    $stmt = $conn->prepare($sql);
    $stmt->bind_param("s", $namespace);
    $error = false;
    $found = false;
    $names = [];
    if( !$stmt->execute() ) {
      serve_error_json("sqlerror","SQL Error list sql1",500);
    }
    $result = $stmt->get_result(); 
    $already = [];
    while($row = $result->fetch_assoc()) {
      $x = $row['name'];
      $namespace = $row['namespace'];
      $time = $row['time'];
      $nns = "$x";
      if( array_key_exists($nns,$already) ) {
        continue;
      }
      $already[$nns] = true;
      if( @preg_match("/$pattern/",$x) ) {
        array_push($names,[$nns,$time]);
      }
    }
    if( count($names) == 0 ) {
      return $this->set_result([
        "name" => $pattern,
        "message" => "not found",
      ],404);
    }
    $this->set_result([
      "pattern" => $pattern,
      "message" => "success",
      "names" => $names
    ],200);
  }

  # cgeth2j.php { request: getbyindex, name, namespace? } // clips indexed in chrono order from 0..n
  function req_getbyindex($req) {
    $this->get_name_ns($req);
    $name = $this->name;
    $namespace = $this->namespace;
    $conn = $this->conn;
    $prefix = $this->prefix;
  }

  # cgetn2j.php { request: getbyid, id } // get content by id not namespace:name
  function req_getbyid($req) {
    if( !isset($req["id"]) ) {
      serve_error_json("noclipid","No clip id",400);
    }
    $clipid = $req["id"];
    if( !preg_match('/^\d+$/',$clipid) ) {
      serve_error_json("invalidclipid","Clip id is not a nonnegative integer",500);
    }
    $clipid = intval($clipid);

    $conn = $this->conn;
    $prefix = $this->prefix;

    $sql = "SELECT namespace,name, value FROM {$prefix}_clips WHERE id = ?";
    $stmt = $conn->prepare($sql);
    $stmt->bind_param("s", $clipid);
    $found = false;
    if( !$stmt->execute() ) {
      serve_error_json("sqlerror","SQL Error",500);
    }
    $result = $stmt->get_result(); 
    while($row = $result->fetch_assoc()) {
      $namespace = $row['namespace'];
      $name = $row['name'];
      $value = $row['value'];
      $found = true;
    }
    if( !$found ) {
      $this->set_result([
        "clipid" => $clipid,
        "message" => "not found",
      ],404);
    }
    $this->set_result([
      "clipid" => $clipid,
      "namespace" => $namespace,
      "name" => $name,
      "message" => "success",
      "value" => $value
    ],200);
  }

  # chist2j.php { request: history, name, namespace? }
  function req_history_regex($req) {
    if( !isset($req["pattern"]) ) {
      $pattern = "";
      //serve_error_json("nopattern","No pattern",400);
    } else {
      $pattern = $req["pattern"];
    }
    if( !self::isRegularExpression('/'.$pattern.'/') ) {
      serve_error_json("invalidpattern","Invalid pattern '$pattern' -- not a valid regular expression",400);
    }
    if( !isset($req["namespace"]) ) {
      $namespace = "_";
    } else {
      $namespace = $req["namespace"];
    }
    if( !$this->isValidName($namespace) ) {
      serve_error_json("invalidnamespace","Invalid namespace",400);
    }
    $conn = $this->conn;
    $prefix = $this->prefix;

    $sql1 = "SELECT name, clipid FROM {$prefix}_pointers WHERE namespace = ?";
    $sql2 = "SELECT id, time FROM {$prefix}_clips
      WHERE (
        namespace = ? AND
        name = ?
      )";
    $name = "";
    $stmt1 = $conn->prepare($sql1);
    $stmt2 = $conn->prepare($sql2);
    $stmt1->bind_param("s", $namespace);
    $stmt2->bind_param("ss", $namespace, $name);
    $error = false;
    $found = false;
    $output = [];
    if( !$stmt1->execute() ) {
      serve_error_json("sqlerror","SQL Error",500);
    }
    $result = $stmt1->get_result(); 
    $names = [];
    $currents = [];
    while($row = $result->fetch_assoc()) {
      $x = $row['name'];
      if( @preg_match("/$pattern/",$x) ) {
        $currents[$namespace.":".$x] = $row["clipid"];
        array_push($names,$x);
      }
    }
    foreach($names as $name) {
      if( !$stmt2->execute() ) {
        serve_error_json("sqlerror","SQL Error history2",500);
      }
      $result = $stmt2->get_result(); 
      while($row = $result->fetch_assoc()) {
        array_push($output,[ $row['id'], $namespace, $name, $row['time']]);
      }
    }
    if( count($output) == 0 ) {
      $this->set_result([
        "namespace" => $namespace,
        "pattern" => $pattern,
        "message" => "not found",
      ],404);
    }
    $this->set_result([
      "namespace" => $namespace,
      "name" => $pattern,
      "message" => "success",
      "clips" => $output,
      "currents" => $currents
    ],200);
  }
  function req_history($req) {
    $this->get_name_ns($req);
    $name = $this->name;
    $namespace = $this->namespace;

    $conn = $this->conn;
    $prefix = $this->prefix;

    $sql1 = "SELECT clipid FROM {$prefix}_pointers 
      WHERE ( 
        namespace = ? AND
        name = ?
      )";
    $sql2 = "SELECT id, time FROM {$prefix}_clips
      WHERE (
        namespace = ? AND
        name = ?
      ) ORDER BY time ";
    $stmt1 = $conn->prepare($sql1);
    $stmt1->bind_param("ss", $namespace, $name);
    $stmt2 = $conn->prepare($sql2);
    $stmt2->bind_param("ss", $namespace, $name);
    $error = false;
    $found = false;
    $output = [];
    if( !$stmt1->execute() ) {
      serve_error_json("sqlerror","SQL Error history2",500);
    }
    $result = $stmt1->get_result(); 
    if( $result->num_rows > 0 ) {
      $row = $result->fetch_assoc();
      $current = $row['clipid']; 
      if( !$stmt2->execute() ) {
        serve_error_json("sqlerror","SQL Error history2",500);
      }
      $result = $stmt2->get_result(); 
      while($row = $result->fetch_assoc()) {
        array_push($output,[ $row['id'], $namespace, $name, $row['time']]);
      }
    }
    if( count($output) == 0 ) {
      $this->set_result([
        "namespace" => $namespace,
        "name" => $name,
        "message" => "not found",
      ],404);
      return;
    }
    $this->set_result([
      "namespace" => $namespace,
      "name" => $name,
      "message" => "success",
      "clips" => $output,
      "current" => $current
    ],200);
  }

  # clistns2j.php { request: listns, pattern } // list all namespaces whose name matches pattern
  function req_listns($req) {
    if( !isset($req["pattern"]) ) {
      $pattern = "";
      //serve_error_json("nopattern","No pattern",400);
    } else {
      $pattern = $req["pattern"];
    }
    if( !self::isRegularExpression('/'.$pattern.'/') ) {
      serve_error_json("invalidpattern","Invalid pattern '$pattern' -- not a valid regular expression",400);
    }

    $conn = $this->conn;
    $prefix = $this->prefix;

    $sql1 = "SELECT DISTINCT namespace FROM {$prefix}_pointers";
    $stmt1 = $conn->prepare($sql1);
    $error = false;
    $found = false;
    $namespaces = [];
    if( !$stmt1->execute() ) {
      serve_error_json("sqlerror","SQL Error listns sql1",500);
    }
    $result = $stmt1->get_result(); 
    while($row = $result->fetch_assoc()) {
      $x = $row['namespace'];
      if( @preg_match("/$pattern/",$x) ) {
        array_push($namespaces,"$x");
      }
    }
    if( count($namespaces) == 0 ) {
      $this->set_result([
        "pattern" => $pattern,
        "message" => "not found",
      ],404);
    }
    $this->set_result([
      "pattern" => $pattern,
      "message" => "success",
      "namespaces" => $namespaces
    ],200);
  }

  # crec2j.php { request: recent, pattern, namespace?, limit? } // list most recent matching pattern (and namespace?) return limit items
  function req_recent($req) {
    if( !isset($req["pattern"]) ) {
      $pattern = "";
      //serve_error_json("nopattern","No pattern",400);
    } else {
      $pattern = $req["pattern"];
    }
    if( !self::isRegularExpression('/'.$pattern.'/') ) {
      serve_error_json("invalidpattern","Invalid pattern '$pattern' -- not a valid regular expression",400);
    }
    if( !isset($req["namespace"]) ) {
      $namespace = "_";
    } else {
      $namespace = $req["namespace"];
    }
    if( !$this->isValidName($namespace) ) {
      serve_error_json("invalidnamespace","Invalid namespace",400);
    }
    if( !isset($req["limit"]) ) {
      $limit = 20;
    } else {
      $limit = intval($req["limit"]);
      $limit = min(max(1,$limit),100);
    }

    $conn = $this->conn;
    $prefix = $this->prefix;

    if( is_null($namespace) ) {
      $sql1 = "SELECT id, name, namespace, time FROM {$prefix}_clips ORDER BY id DESC LIMIT 20";
      $stmt1 = $conn->prepare($sql1);
    } else {
      $sql1 = "SELECT id, name, namespace, time FROM {$prefix}_clips WHERE namespace = ? ORDER BY id DESC LIMIT $limit";
      $stmt1 = $conn->prepare($sql1);
      $stmt1->bind_param("s", $namespace);
    }
    $error = false;
    $found = false;
    $names = [];

    if( !$stmt1->execute() ) {
      serve_error_json("sqlerror","SQL Error recent",500);
    }
    $result = $stmt1->get_result(); 
    while($row = $result->fetch_assoc()) {
      $x = $row['name'];
      $id = $row['id'];
      $time = $row['time'];
      $ns = $row['namespace'];
      if( @preg_match("/$pattern/",$x) ) {
        array_push($names,[$id,$ns,$x,$time]);
      }
    }
    if( count($names) == 0 ) {
      $this->set_result([
        "namespace" => $namespace,
        "pattern" => $pattern,
        "message" => "not found",
      ],404);
    }
    $this->set_result([
      "namespace" => $namespace,
      "pattern" => $pattern,
      "message" => "success",
      "names" => $names
    ],200);
  }

  # cset2j.php { request: set, name, namespace?, id } // set current clip for namespace:name to id
  function req_set($req) {
    $this->get_name_ns($req);
    $name = $this->name;
    $namespace = $this->namespace;
    $conn = $this->conn;
    $prefix = $this->prefix;

    if( !isset($req["id"]) ) {
      serve_error_json("noclipid","No clip id",400);
    }
    $clipid = $req["id"];
    if( !preg_match('/^\d+$/',$clipid) ) {
      serve_error_json("invalidclipid","Clip id is not a nonnegative integer",500);
    }
    $clipid = intval($clipid);

    $sql_find = "SELECT id FROM {$prefix}_clips
      WHERE (
        namespace = ? AND
        name = ?
      )";
    $stmt_find=$conn->prepare($sql_find);
    $stmt_find->bind_param("ss",$namespace,$name);

    if( !$stmt_find->execute() ) { 
      serve_error_json("sqlerror","SQL Error sql_set find",500);
    }
    # validate clipid to namespace
    # later add a ccp to copy from one namespace:name or id
    # to another. Two commands: ccp namespace:name and ccpn id namespace:name
    $result = $stmt_find->get_result(); 
    $clipids = [];
    while($row = $result->fetch_assoc()) {
      $x = $row['id'];
      array_push($clipids,$x);
    }

    $clipid_valid = in_array($clipid,$clipids);
    if( $clipid_valid ) {
      $sql_set = "UPDATE {$prefix}_pointers SET clipid = ? WHERE namespace = ? AND name = ?";
      $stmt_set=$conn->prepare($sql_set);
      $stmt_set->bind_param("sss",$clipid,$namespace,$name);

      if( !$stmt_set->execute() ) { 
        serve_error_json("sqlerror","SQL Error sql_set set",500);
      }
    }

    $conn->commit();
    if( $clipid_valid ) {
      $this->set_result([
        "namespace" => $namespace,
        "name" => $name,
        "clipid" => $clipid,
        "message" => "clipid for $namespace:$name now $clipid"
      ],200);
    } else if ( count($clipids) == 0 ) {
      $this->set_result([
        "namespace" => $namespace,
        "name" => $name,
        "message" => "clip $namespace:$name not found",
      ],404);
    } else {
      $this->set_result([
        "namespace" => $namespace,
        "name" => $name,
        "clipid" => $clipid,
        "message" => "not valid clipid for $namespace:$name",
      ],400);
    }
  }
}

jsonetc.php

A couple of convenience methods for dealing with json.

<?php
function get_json_from_post() {
  $body = file_get_contents('php://input');
  try {
    return json_decode($body, true, 512, JSON_THROW_ON_ERROR);
  } catch( Exception $e ) {
    serve_error_json("invalidjson","Invalid request JSON",400,["invalidJson" => $body]);
  }
}
function serve_error_json($type,$message,$response_code,$additional_data = null) {
  $message = str_replace("\\","\\\\",$message);
  $message = str_replace('"',"\\\"",$message);
  $data = [ "status" => "error", "errorType" => $type, "error" => $message ];
  if( ! is_null($additional_data) ) {
    $data = array_merge($data,$additional_data);
  }
  serve_json($data, $response_code);
}
function serve_json($data, $response_code) {
  http_response_code($response_code);
  header("Content-type: application/json");
  echo json_encode($data);
  exit();
}
function must_post($response_code = 400) {
  if( $_SERVER["REQUEST_METHOD"] !== "POST" ) {
    serve_error_json("mustpost","Must POST",400);
    exit();
  }
}

Client

This is a single Python script, called clipclient. Note that the formatting code for output isn't there yet for most methods. Only get and getbyid work properly. The others just pass the returned json to icecream for pretty printed output. So basically replace the ic(...) calls with your own formatting code.

#!/usr/bin/env python
import os,sys,json
import requests
from clipclient_auth import cookies      # auth cookies go in here
from icecream import ic; ic.configureOutput(includeContext=True)

BACKEND=os.getenv("CLIP_BACKEND","http://localhost/clipboard/clip.php")

def eprint(*xs,**kw):
  kw['file'] = sys.stderr
  print(*xs,**kw)

def main():
  args = sys.argv[1:]
  clip_client = ClipClient(BACKEND,cookies)
  clip_client.go(args)

class ClipClient:
  def __init__(self,backend,cookies=None):
    self.backend = backend
    self.cookies = cookies if cookies is not None else {}

  def helpexit(self,rc=0):
    eprint(f"Clip Client --- {self.backend}")
    keys = [ x for x in self.__class__.__dict__.keys() if x.startswith("do_") ]
    for k in sorted(keys):
      method_name = k[3:]
      method = getattr(self,k)
      print(f"{method_name}: {method.__doc__}")
    exit(rc)

  def go(self,args):
    if len(args) == 0:
      self.helpexit(1)
      return
    cmd, *params = args
    method_name = "do_" + cmd
    try:
      method = getattr(self,method_name)
    except AttributeError:
      eprint(f"No command: {cmd}")
      self.helpexit(1)
    try:
      return method(*params)
    except Exception as e:
      eprint(f"Exception {e} ({type(e)}) executing command {cmd}")
      raise

  def get_name_ns(self,nsname):
    "Turns namespace:name to name,namespace, namespace default to _"
    if ":" in nsname:
      namespace, name = nsname.split(":",1)
      return name, namespace
    else:
      return nsname, "_"

  def do_put(self,*args):
    """put clip
    put <namespace:name or name> [filenames...]
    filename "." will paste from clipboar
    filename "-" will read from stdin"""
    append = False
    if "-a" in args:
      append = True
      args.pop(args.index("-a"))
    if len(args) == 0:
      eprint("Put requires a name")
      return 1
    nsname, *fns = args
    name, namespace = self.get_name_ns(nsname)
    content = []
    stdin = None
    if len(fns) == 0:
      fns = ["-"]
    for fn in fns:
      if fn == "-":
        if stdin is None:
          stdin = sys.stdin.read()
        content.append(stdin)
      elif fn == ".":
        content.append(paste())
      else:
        try:
          with open(fn) as f:
            content.append(f.read())
        except Exception as e:
          eprint(f"Exception {e} ({type(e)}) reading {fn}")
          continue
    value = "\n".join(content)
    request = {
      "request": "put",
      "name": name,
      "namespace": namespace,
      "value": value
    }
    if append:
      request["append"] = True
    request_json = json.dumps(request)
    print(request_json)

    r = requests.post(self.backend,data=request_json,cookies=self.cookies)
    if r.status_code == 200:
      eprint(f"Inserted {namespace}:{name} ({len(value)} bytes)")
    elif r.status_code == 400:
      eprint(f"Invalid request",file=sys.stderr)
      exit(1)
    elif r.status_code == 500:
      eprint(f"Server error",file=sys.stderr)
      eprint(r.text,file=sys.stderr)
      exit(1)
    elif r.status_code == 403:
      eprint(f"Access denied",file=sys.stderr)
      exit(1)
    else:
      eprint("WTF",r.status_code,r.text)
      exit(1)

  def do_get(self,*args):
    """get clip
    get <namespace:name or name> [...]"""
    for arg in args:
      name, namespace = self.get_name_ns(arg)
      request = { "request": "get", "namespace": namespace, "name": name }
      request_json = json.dumps(request)

      r = requests.post(self.backend,data=request_json,cookies=self.cookies)
      if r.status_code == 200:
        try:
          b = json.loads(r.text)
          value = b["value"]
          value = value.replace("\r","")
          print(value)
        except Exception as e:
          eprint("error",r,r.text,e,file=sys.stderr)
      elif r.status_code == 400:
        eprint(f"Error 400: {r.text}")
        return 1
      elif r.status_code == 404:
        eprint(f"{namespace}:{name} not found",file=sys.stderr)
      elif r.status_code == 403:
        eprint(f"Access denied",file=sys.stderr)
        return 1
    return 0

  def do_listall(self,*args):
    """list all clips matching pattern across all namespaces
    listall pattern"""
    if len(args) == 0:
      pattern = ""
    else:
      pattern = args[0]
    if len(args) > 1:
      eprint(f"Excess arguments for listall: {args[1:]}")

    request = { "request": "listall", "pattern": pattern }
    request_json = json.dumps(request)

    r = requests.post(self.backend,data=request_json,cookies=self.cookies)
    if r.status_code == 200:
      try:
        b = json.loads(r.text)
        ic(b)
      except Exception as e:
        eprint("error",r,r.text,e,file=sys.stderr)
    elif r.status_code == 400:
      eprint(f"Error 400: {r.text}")
      return 1
    elif r.status_code == 404:
      eprint(f"{pattern} not found",file=sys.stderr)
    elif r.status_code == 403:
      eprint(f"Access denied",file=sys.stderr)
      return 1

  def do_list(self,*args):
    """list all clips matching pattern within a namespace (default _)
    list namespace:pattern"""
    if len(args) == 0:
      pattern = ""
      namespace = "_"
    else:
      pattern, namespace = self.get_name_ns(args[0])
      if namespace == "":
        namespace = "_"
    if len(args) > 1:
      eprint(f"Excess arguments for list: {args[1:]}")

    request = { "request": "list", "namespace": namespace, "pattern": pattern }
    request_json = json.dumps(request)

    r = requests.post(self.backend,data=request_json,cookies=self.cookies)
    if r.status_code == 200:
      try:
        b = json.loads(r.text)
        ic(b)
      except Exception as e:
        eprint("error",r,r.text,e,file=sys.stderr)
    elif r.status_code == 400:
      eprint(f"Error 400: {r.text}")
      return 1
    elif r.status_code == 404:
      eprint(f"{pattern} not found",file=sys.stderr)
    elif r.status_code == 403:
      eprint(f"Access denied",file=sys.stderr)
      return 1

  def do_history(self,*args):
    """list history of named clip
    history namespace:name"""
    if len(args) == 0:
      name = ""
      namespace = "_"
    else:
      name, namespace = self.get_name_ns(args[0])
      if namespace == "":
        namespace = "_"
    if len(args) > 1:
      eprint(f"Excess arguments for history: {args[1:]}")

    request = { "request": "history", "namespace": namespace, "name": name }
    request_json = json.dumps(request)

    r = requests.post(self.backend,data=request_json,cookies=self.cookies)
    if r.status_code == 200:
      try:
        b = json.loads(r.text)
        ic(b)
      except Exception as e:
        eprint("error",r,r.text,e,file=sys.stderr)
    elif r.status_code == 400:
      eprint(f"Error 400: {r.text}")
      return 1
    elif r.status_code == 404:
      eprint(f"{pattern} not found",file=sys.stderr)
    elif r.status_code == 403:
      eprint(f"Access denied",file=sys.stderr)
      return 1

  def do_history_regex(self,*args):
    """list history of clips whose name matches regex
    history_regex namespace:pattern"""
    if len(args) == 0:
      pattern = ""
      namespace = "_"
    else:
      pattern, namespace = self.get_name_ns(args[0])
      if namespace == "":
        namespace = "_"
    if len(args) > 1:
      eprint(f"Excess arguments for history_regex: {args[1:]}")

    request = { "request": "history_regex", "namespace": namespace, "pattern": pattern }
    request_json = json.dumps(request)

    r = requests.post(self.backend,data=request_json,cookies=self.cookies)
    if r.status_code == 200:
      try:
        b = json.loads(r.text)
        ic(b)
      except Exception as e:
        eprint("error",r,r.text,e,file=sys.stderr)
    elif r.status_code == 400:
      eprint(f"Error 400: {r.text}")
      return 1
    elif r.status_code == 404:
      eprint(f"{pattern} not found",file=sys.stderr)
    elif r.status_code == 403:
      eprint(f"Access denied",file=sys.stderr)
      return 1

  def do_listns(self,*args):
    """list namespaces whose name matches regex
    listns pattern"""
    if len(args) == 0:
      pattern = ""
    else:
      pattern = args[0]

    request = { "request": "listns", "pattern": pattern }
    request_json = json.dumps(request)

    r = requests.post(self.backend,data=request_json,cookies=self.cookies)
    if r.status_code == 200:
      try:
        b = json.loads(r.text)
        ic(b)
      except Exception as e:
        eprint("error",r,r.text,e,file=sys.stderr)
    elif r.status_code == 400:
      eprint(f"Error 400: {r.text}")
      return 1
    elif r.status_code == 404:
      eprint(f"{pattern} not found",file=sys.stderr)
    elif r.status_code == 403:
      eprint(f"Access denied",file=sys.stderr)
      return 1

  def do_recent(self,*args):
    """list recent clips in chrono order whose name matches regex
    recent namespace:pattern"""
    if len(args) == 0:
      pattern = ""
      namespace = "_"
      limit = 20
    else:
      pattern, namespace = self.get_name_ns(args[0])
      if len(args) >= 2:
        try:
          limit = int(args[1])
          if limit < 1:
            raise ValueError()
        except ValueError:
          eprint(f"Invalid limit: {args[1]}")
          return 1
      else:
        limit = 20
      if namespace == "":
        namespace = "_"
    if len(args) > 1:
      eprint(f"Excess arguments for recent: {args[1:]}")

    request = { "request": "recent", "namespace": namespace, "pattern": pattern, "limit": limit }
    request_json = json.dumps(request)

    r = requests.post(self.backend,data=request_json,cookies=self.cookies)
    if r.status_code == 200:
      try:
        b = json.loads(r.text)
        ic(b)
      except Exception as e:
        eprint("error",r,r.text,e,file=sys.stderr)
    elif r.status_code == 400:
      eprint(f"Error 400: {r.text}")
      return 1
    elif r.status_code == 404:
      eprint(f"{pattern} not found",file=sys.stderr)
    elif r.status_code == 403:
      eprint(f"Access denied",file=sys.stderr)
      return 1

  def do_getlast(self,*args):
    """get last n entries for name ns:name, limit to m chars
    getlast namespace:name num_items(=20) char_limit(=1024)
    use -r to match by regex"""
    nsname, num_items, char_limit, xs = (lambda x="",y=20,z=1024,*xs: (x,y,z,xs))(*args)
    name, namespace = self.get_name_ns(args[0])
    try:
      num_items = int(num_items)
      char_limit = int(char_limit)
      if not (num_items > 0 and char_limit > 0):
        raise ValueError()
    except ValueError:
      eprint("num_items and char_limit must be positive integers")
      return 1
    if namespace == "":
      namespace = "_"
    if len(xs) > 0:
      eprint("Excess arguments for getlast")

    request = { 
               "request": "getlast", 
               "namespace": namespace, 
               "name": name, 
               "num_items": num_items,
               "char_limit": char_limit }
    request_json = json.dumps(request)

    r = requests.post(self.backend,data=request_json,cookies=self.cookies)
    if r.status_code == 200:
      try:
        b = json.loads(r.text)
        ic(b)
      except Exception as e:
        eprint("error",r,r.text,e,file=sys.stderr)
    elif r.status_code == 400:
      eprint(f"Error 400: {r.text}")
      return 1
    elif r.status_code == 404:
      eprint(f"{name} not found",file=sys.stderr)
      print(r.text)
    elif r.status_code == 403:
      eprint(f"Access denied",file=sys.stderr)
      return 1

  def do_find(self,*args):
    """find clip containing text
    find pattern limit namespace"""
    if len(args) < 1:
      eprint("No pattern to find for")
      return 1
    elif len(args) < 2:
      pattern = args[0]
      limit = 20
      namespace = "_"
    elif len(args) < 3:
      pattern = args[0]
      limit = int(args[1])
      namespace = "_"
    else:
      pattern = args[0]
      limit = int(args[1])
      namespace = args[2]
    if len(args) >= 4:
      eprint("Excess arguments for find")
    try:
      limit = int(limit)
      if limit < 1:
        eprint("Invalid limit (<1)")
        return 1
    except ValueError:
      eprint(f"Invalid limit '{args[1]}'")
      return 1

    request = { 
               "request": "find", 
               "pattern": pattern, 
               "limit": limit }
    if len(namespace) > 0:
      request["namespace"] = namespace

    request_json = json.dumps(request)

    r = requests.post(self.backend,data=request_json,cookies=self.cookies)
    if r.status_code == 200:
      try:
        b = json.loads(r.text)
        ic(b)
      except Exception as e:
        eprint("error",r,r.text,e,file=sys.stderr)
    elif r.status_code == 400:
      eprint(f"Error 400: {r.text}")
      return 1
    elif r.status_code == 404:
      eprint(f"{name} not found",file=sys.stderr)
      print(r.text)
    elif r.status_code == 403:
      eprint(f"Access denied",file=sys.stderr)
      return 1
    pass

  def do_getbyid(self,*args):
    """get clip by id
    get <id> [<id>...]"""
    for arg in args:
      try:
        clipid = int(arg)
        if clipid < 0:
          raise ValueError()
      except ValueError:
        eprint("Invalid clip id (must be positive integer): {arg}")
        continue
      request = { "request": "getbyid", "id": clipid }
      request_json = json.dumps(request)

      r = requests.post(self.backend,data=request_json,cookies=self.cookies)
      if r.status_code == 200:
        try:
          b = json.loads(r.text)
          value = b["value"]
          value = value.replace("\r","")
          print(value)
        except Exception as e:
          eprint("error",r,r.text,e,file=sys.stderr)
      elif r.status_code == 400:
        eprint(f"Error 400: {r.text}")
        return 1
      elif r.status_code == 404:
        eprint(f"{namespace}:{name} not found",file=sys.stderr)
      elif r.status_code == 403:
        eprint(f"Access denied",file=sys.stderr)
        return 1
    return 0

  def do_set(self,*args):
    """set current clip id
    set <namespace:name or name> id
    id must be id for named clip"""
    if len(args) < 2:
      eprint("Set requires a ns:name and index")
      return 1
    nsname, clipid, *xs = args
    name, namespace = self.get_name_ns(nsname)
    if len(xs) > 0:
      eprint(f"Excess parameters for set: {xs}")

    request = { "request": "set", "namespace": namespace, "name": name, "id": clipid }
    request_json = json.dumps(request)

    r = requests.post(self.backend,data=request_json,cookies=self.cookies)
    if r.status_code == 200:
      try:
        b = json.loads(r.text)
        ic(b)
      except Exception as e:
        eprint("error",r,r.text,e,file=sys.stderr)
    elif r.status_code == 400:
      eprint(f"Error 400: {r.text}")
      return 1
    elif r.status_code == 404:
      eprint(f"{namespace}:{name} not found",file=sys.stderr)
    elif r.status_code == 403:
      eprint(f"Access denied",file=sys.stderr)
      return 1

if __name__ == "__main__":
  main()

Fuse FS

The above code needs slight modification, which I'll put here later. But this is a simple FUSE filesystem that wraps clipclient. Very ugly, but my first quick play with FUSE in python. I took the hello world example and hacked it around a bit to get this result. That hello exmaple is LGPL, so this is too.

#!/usr/bin/env python

# Based on the hello example which is:
#  Copyright (C) 2006  Andrew Straw  <strawman@astraw.com>
#
# Everything else
#  Copyright (C) 2024 John Allsup
#
#  This program can be distributed under the terms of the GNU LGPL.
#

import os, stat, errno, fuse, json, subprocess
from collections import defaultdict
from fuse import Fuse
from datetime import datetime

if not hasattr(fuse, '__version__'):
  raise RuntimeError("your fuse-py doesn't know of fuse.__version__, probably it's too old.")

fuse.fuse_python_api = (0, 2)

class MyStat(fuse.Stat):
  def __init__(self):
    self.st_mode = 0
    self.st_ino = 0
    self.st_dev = 0
    self.st_nlink = 0
    self.st_uid = 0
    self.st_gid = 0
    self.st_size = 0
    self.st_atime = 0
    self.st_mtime = 0
    self.st_ctime = 0

def update_clip_data():
  global clipdata
  m = subprocess.run(["clipclient","-j","listall"],capture_output=True)
  a = json.loads(m.stdout.decode())
  names = defaultdict(dict)
  for line in a:
    nsname, when, count, length = line
    ns, name = nsname.split(":")
    when = datetime.strptime(when,"%Y-%m-%d %H:%M:%S")
    uwhen = int(when.timestamp())
    names[ns][name] = (uwhen,length)
  clipdata = names
update_clip_data()
refreshed_msg = b"Refreshed\n"

class HelloFS(Fuse):

  def getattr(self, path):
    st = MyStat()
    if path == '/':
      st.st_mode = stat.S_IFDIR | 0o755
      st.st_nlink = 2
      return st
    elif path == "/.refresh":
      st.st_mode = stat.S_IFREG | 0o444
      st.st_nlink = 1
      st.st_size = len(refreshed_msg)
      return st
    else:
      xs = path.split("/")
      if len(xs) == 2:
        _, ns = xs
        if ns in clipdata:
          st.st_mode = stat.S_IFDIR | 0o755
          st.st_nlink = 2
          return st
        else:
          return -errno.ENOENT
      elif len(xs) == 3:
        _, ns, name = xs
        if ns in clipdata:
          names = clipdata[ns]
          if name in names:
            when, length = names[name]
            st.st_mode = stat.S_IFREG | 0o444
            st.st_nlink = 1
            st.st_size = length
            st.st_atime = when
            st.st_mtime = when
            st.st_ctime = when
            return st
          else:
            return -errno.ENOENT
        else:
          return -errno.ENOENT
      else:
        return -errno.ENOENT
    return -errno.ENOENT

  def readdir(self, path, offset):
    if path == "/":
      for r in  '.', '..', '.refresh':
        yield fuse.Direntry(r)
      for r in clipdata:
        yield fuse.Direntry(r)
    else:
      xs = path.split("/")
      if len(xs) == 2:
        _, ns = xs
        names = clipdata[ns]
        for r in  '.', '..':
          yield fuse.Direntry(r)
        for r in names:
          yield fuse.Direntry(r)

  def open(self, path, flags):
    if path == "/.refresh":
      return
    xs = path.split("/")
    if len(xs) != 3:
      return -errno.ENOENT
    _, ns, name = xs
    if not ns in clipdata and name in clipdata[ns]:
      return -errno.ENOENT
    accmode = os.O_RDONLY | os.O_WRONLY | os.O_RDWR
    if (flags & accmode) != os.O_RDONLY:
      return -errno.EACCES

  def read(self, path, size, offset):
    if path == "/.refresh":
      update_clip_data()
      return refreshed_msg
    xs = path.split("/")
    if len(xs) != 3:
      return -errno.ENOENT
    _, ns, name = xs
    if not ns in clipdata and name in clipdata[ns]:
      return -errno.ENOENT
    nsname = f"{ns}:{name}"
    m = subprocess.run(["clipclient","get",nsname],capture_output=True)
    data = m.stdout
    slen = len(data)
    if offset < slen:
      if offset + size > slen:
        size = slen - offset
      buf = data[offset:offset+size]
    else:
      buf = b''
    return buf

def main():
  usage="""
Clipclient fs
Set backend by exporting CLIP_BACKEND variable.

""" + Fuse.fusage
  server = HelloFS(version="%prog " + fuse.__version__,
           usage=usage,
           dash_s_do='setsingle')

  server.parse(errex=1)
  server.main()

if __name__ == '__main__':
  main()