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()