#!/usr/bin/php-cgi -dcgi.force_redirect=0
<?php
# encoding: utf-8
# api: cgi
# type: json
# title: MicroPub API
# description: Accepts blog/ticket/chat entries from micropub clients
# version: 0.0
# state: untested
# depends: php:sqlite
# doc: https://indieweb.org/obtaining-an-access-token
# config: -
#
# Supposed to feed micropub requests back into `fossil`.
# Verifies auth/token, unpacks request parameters, and invokes fossil bin
# on the current -R repository.
# ยท cat "content..." | fossil wiki create|commit -M text/x-markdown
# ยท cat "content..." | fossil wiki --technote -M text/x-markdown
# ยท ticket add CONTENT ... STATUS ... ETC ...
#
#
if ($_REQUEST["dbg"]) {
error_reporting(E_ALL); ini_set("display_errors", 1);
}
#-- database (== fossil repo)
function db($sql="", $params=[]) {
static $db;
if (empty($db)) {
$db = new PDO("sqlite:$_SERVER[FOSSIL_REPOSITORY]");
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_WARNING);
}
if ($params) {
$stmt = $db->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll();
}
else {
return $db->query($sql);
}
}
#-- JSON/form-encoded output
function json_response($r, $status) {
if ($status) {
header("Status: $status");
if (is_string($r)) {
$r = ["error"=>"invalid_request", "error_description"=>$r];
}
}
if (stristr($_SERVER["HTTP_ACCEPT"], "/json")) {
header("Content-Type: text/json");
die(json_encode($r, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES));
}
elseif (stristr($_SERVER["HTTP_ACCEPT"], "/x-www-form")) {
header("Content-Type: application/x-www-form-urlencoded");
array_walk($r, function(&$v, $k) { $v = "$k=" . urlencode($v); });
die(join("&", $r));
}
else {
header("Content-Type: text/php-source");
die(var_export($r, True));
}
}
#-- load authorization properties by auth code
function get_token_by_code($code) {
return db("SELECT * FROM fx_auth WHERE code=? AND `type`=? AND expires>?", [$code, 'token', time()]) ?: [[]];
}
#-- get code from Authorization: header
function bearer() {
if (empty($_SERVER["HTTP_AUTHORIZATION"])) {
return "";
}
if (preg_match("/Bearer\s+(\S+)/i", $_SERVER["HTTP_AUTHORIZATION"], $uu)) {
return $uu[1];
}
}
#-- fetch token for Authorization: Bearer id
function get_token($access_token=NULL) {
# find Auth: token
$code = $access_token ?: bearer();
return get_token_by_code($code)[0];
}
#-- alias for generic types to fossil items
function scope_map($h_type) {
$h_type = preg_replace("/^h-", "", $h_type);
$map = [
"entry" => "technote",
"issue" => "ticket",
"event" => "chat",
"cite" => "wiki",
];
return $map[$h_type] ?: $h_type;
}
# check if request action+type entry all match token scopes
function verify_scope($token=[], $scopes=[]) {
preg_match_all("/(\w+)/", $token["scope"], $scope_ls);
$ok = NULL;
# comapare against token scope list
foreach ($scopes as $t) {
$ok = ($a !== False) && in_array(scope_map($t), $scope_ls[1]);
}
return $ok;
}
#-- transform $_POST to JSON body
function request() {
if (preg_match("~^\s(application|text|json)/json\s(;|$)~", $_SERVER["HTTP_CONTENT_TYPE"])) {
$json = json_decode(file_get_contents("php://input"), True);
}
elseif (count($_POST)) {
$json = [
"q" => "$_POST[q]",
"type" => "h-$_POST[h]",
"properties" => array_map(function ($v) { return [$v]; }, $_POST),
];
}
return $_POST = $json;
}
#-- transform post to fossil command
class create {
public $token = [];
public $url = "";
# dispatch `type` onto functions
function __construct($post, $token) {
$this->token = $token;
$action = $post["action"];
$type = scope_map($post["type"]);
if (method_exists($this, $type)) {
$r = call_user_func([$this, $type], $post["properties"]);
if (!$this->url) {
$this->new_url("", $r);
}
if ($this->url) {
header("Location: $this->url");
json_response(["result"=>"$r"], "201 Created");
}
}
json_response("Unknown h-type: `$type`", "400 No");
}
# grep new Location: url from fossil command output
function new_url($path, $r="") {
if (preg_match("/Created new wiki page (.+)\./i", $r, $uu)) {
$path = "/wiki/$uu[1]";
}
elseif (preg_match("/Created new tech note ([\d\-:.\s]+)$/im", $r, $uu)) {
if (preg_match("/(\w+) $uu[1]/", $this->fossil(["wiki","list","-t","-s"]), $uu)) {
$path = "/technote/$uu[1]";
}
}
elseif (preg_match("/ticket add succeeded for (\w+)/i", $r, $uu)) {
$path = "/ticket/$uu[1]";
}
$this->url = preg_replace("/ext/\w+", "", "https://$_SERVER[SERVER_NAME]$_SERVER[PHP_SELF]$path");
}
function technote($p, $act="create") {
# &content=
# &category[]=
$args = ["wiki", "$act", "--technote", ($this->id ?: "now"), "--mimetype", "text/x-markdown", "-"];
if ($p["category"]) { $args += ["--technote-tags", implode(",", $p["category"])]; }
$md = join("\n", $p["content"]);
return $this->fossil($args, $md);
}
function wiki($p, $act="create") {
# &title=
# &content=
# &category[]=
$args = ["wiki", "$act", "--mimetype", "text/x-markdown", ($this->id ?: $p["title"][0])];
$md = join("\n", $p["content"]);
return $this->fossil($args, $md);
}
function ticket($p, $args=["ticket", "add"]) {
# comment type status subsystem priority severity foundin private_contact resolution title comment
$map = ["type"=>"type", "summary"=>"title", "content"=>"comment", "priority"=>"priority", "severity"=>"severity", "category"=>"subsystem", "version"=>"foundin", "contact"=>"private_contact"];
foreach ($map as $from=>$to) {
if ($p[$from]) {
$args[] = $to;
$args[] = implode(", ", $p[$from]);
}
}
return $this->fossil($args);
}
function chat($p, $act="send") {
$args = ["chat", "send", "-m", $p["content"]];
if (count($_FILES)) {
$args += ["-f", current($_FILES)["tmp_name"]];
}
$_FILES = [];
$this->new_url("/chat/#" . md5(rand()));
return $this->fossil($args);
}
# a bit more escaping
static function esc($s) {
if (is_array($s)) { $s = implode(" ", $s); } // join any list
$s = preg_replace("/[^\\r\\n\\t\\x20-\\xFF]/", "", $s); // strip control characters
return escapeshellarg($s); // quote for shell
}
# invoke fossil binary with escaped arguments, and possibly piping content as stdin
function fossil($args, $input) {
$bin = "fossil";
$args = ["-R", $_SERVER["FOSSIL_REPOSITORY"]] + $args;
$args = [$bin] + array_map("create::esc", $args);
if (empty($input)) {
exec(implode(" ", $args), $stdout);
return implode("\n", $stdout);
}
else {
$pipespec = [["pipe","r"],["pipe","w"],["pipe","a"]];
$pipes = [NULL, NULL, NULL];
$env = [ # override ENV so as not to trigger nested CGI mode
"PATH" => $_SERVER["PATH"],
"TEMP" => $_SERVER["TEMP"],
"USER" => $this->token["user"],
"LANG" => "C",
"EDITOR" => "/bin/false",
];
$proc = proc_open(implode(" ", $args), $pipespec, $pipes, "/tmp", $env);
fwrite($pipes[0], $input);
$stdout = fread($pipes[1]);
array_map("fclose", $pipes);
proc_close($proc);
return $stdout;
}
}
}
# same commands, but usually `commit` instead of `create`
class update extends create {
function __construct($post, $token) {
if (!$this->url = $post["url"]) {
json_response("No original url: given", "400 Params");
}
$this->id = preg_replace("~^.+/(?=\w+)|/$~", "", $this->url);
$post["properties"] = $post["replace"] ?: $post["add"];
parent::__construct($post, $token);
}
function technote($p, $act="commit") {
return parent::technote($p, $act);
}
function wiki($p, $act="commit") {
return parent::wiki($p, $act);
}
function ticket($p, $args=["ticket", "set", "ID"]) {
return parent::ticket($p, ["ticket", "set", $this->id]);
}
function chat($p, $act="send") {}
}
# fetch source code
class source extends update {
function __construct($get, $token) {
if (!$this->url = $get["url"]) {
json_response("No original url: given", "400 Params");
}
$this->id = preg_replace("~^.+/(?=\w+)|/$~", "", $this->url);
$this->token = $token;
$type = scope_map($get["type"]);
if (method_exists($this, $type)) {
$r = call_user_func([$this, $type], []);
json_response([
"type" => [$get["type"]],
"properties" => (is_array($r) ? $r : [
"content"=> [$r],
])
]);
}
json_response("Unknown h-type: `$type`", "400 No");
}
function technote($p, $act="export") {
die($this->fossil(["wiki", "export", "-t", $this->id]));
}
function wiki($p, $act="export") {
die($this->fossil(["wiki", "export", $this->id]));
}
function ticket($p, $act="history") {
die($this->fossil(["ticket", "history", $this->id]));
}
function chat($p, $act="send") {}
}
#-- run
if (!request()) {
print<<<HTML
<div class='fossil-doc' data-title='MicroPub API'>
<svg style="float:left; margin-right:30pt" width="156" height="126" version="1.1" viewBox="0 0 156.51 126.77" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-29.749 -72.377)" fill="#5c3566">
<path d="m103.29 73.103c3.9056-0.01807 7.8113-0.03615 11.717-0.05422 2.526 1.5001 1.5486 4.8454 2.0569 7.3146 3.1429 39.032 6.2859 78.064 9.4288 117.1-0.49825 2.5589-3.4725 1.3775-5.3001 1.6403-9.3872-0.0112-18.774-0.0224-28.162-0.0336-2.3887-0.63707-0.91351-3.4807-1.0714-5.2336 3.2757-39.864 6.5514-79.729 9.8271-119.59 0.42052-0.44951 0.84244-1.0337 1.5034-1.1356z"/>
<path d="m120.38 73.127c5.1936 1.1052 12.911-2.4119 16.035 2.898 16.539 40.252 33.24 80.453 49.841 120.69-2.5876 3.7337-8.9293 0.82482-13.081 1.6871-11.989-0.0484-23.982 0.0943-35.967-0.30639-2.8022-4.0246-1.711-10.44-3.0477-15.379-4.8334-36.042-9.9045-72.071-14.589-108.12 0.0326-0.55322 0.17007-1.2857 0.80831-1.4647z"/>
<path d="m90.484 72.482c2.3372 0.13672 4.732-0.08479 7.0317 0.28512 1.709 1.4705 0.18503 3.9654 0.26679 5.9122-5.1709 39.581-10.342 79.163-15.513 118.74-0.52456 2.2879-3.6851 0.74965-5.4418 1.1551-15.525-0.2027-31.05-0.40541-46.575-0.60812-1.5214-1.1172 0.86267-3.4736 1.1244-5.0745 16.758-39.681 33.516-79.363 50.275-119.04 1.2482-2.5695 6.3795-0.89786 8.8317-1.3698z"/>
</g>
</svg>
<h3>MicroPub endpoint</h3>
Interface for micropub clients to post blog/technotes (entry), tickets (issue), or possibly wiki or forum posts.
<p>
Should be registered in the repo template or a user homepage with:<br>
<code><link rel=token_endpoint href='https://$_SERVER[SERVER_NAME]$_SERVER[PHP_SELF]'></code>
</div>
HTML;
die();
}
elseif ($_POST["q"] == "config") {
json_response([
"media-endpoint" => "https://$_SERVER[SERVER_NAME]$_SERVER[PHP_SELF]", # same
"syndicate-to" => [],
"supported-types" => [
"entry" => [
"internal-name" => "technote",
"properties" => ["content"],
],
"issue" => [
"internal-name" => "ticket",
"properties" => ["content", "summary"],
],
"event" => [
"internal-name" => "chat",
"accepts-media" => TRUE,
"properties" => ["content", "file"],
],
"cite" => [
"internal-name" => "wiki",
"properties" => ["content"],
],
],
]);
}
elseif (!$token = get_token($_POST["access_token"])) {
json_response("token expired", "401 Token expired");
}
elseif (!verify_scope($token, [$_POST["action"], $_POST["type"]])) {
json_response("scope insufficient", "403 Scope insufficient");
}
elseif ($_GET["q"] == "source") {
new source($_GET, $token);
}
elseif ($_POST["action"] == "update") {
new update($_POST, $token);
}
else {
new create($_POST, $token);
}
?>