#!/usr/bin/php-cgi -dcgi.force_redirect=0
<?php
# encoding: utf-8
# api: cgi
# type: store
# category: wiki
# title: MicroPub API
# description: Accepts blog/ticket/chat entries from micropub clients
# version: 0.2
# state: incomplete
# depends: php:sqlite
# doc: https://micropub.spec.indieweb.org/#add,
# https://github.com/indieweb/micropub-extensions/issues
# config: -
# access: auth
#
# Supposed to feed micropub requests back into `fossil`. Currently supporting
# technotes (which most closely resembles a h-note/blog post), wiki pages (from
# h-entry), tickets (called h-issue in mf2), and chat (from h-event submissions).
#
# 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
# ยท ticket add CONTENT ... STATUS ... ETC ...
#
#
# Workaround for EXTCGI environment to receive Auth: header:
# ---------------------------------------------------------
# RewriteCond %{REQUEST_URI} .+/ext/(auth|token|micropub)(\?.*)?$
# RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
# #SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1
#
# + Patch in fossil/src/extcgi.c: add HTTP_AUTHORIZATION to azCgiEnv[]
# and azCgiVars[] in src/cgi.c
#
# Another limitation of fossils cgi handling:
# ------------------------------------------
# ยท any presence of a Location: header will corrupt the response status
# and body.
# ยท hence using Content-Location: instead (= not micropub-compliant)
# ยท not sure it can be changed using mod_headers `Header edit*` etc.
#
# Header add Location "expr=%{resp:Content-Location}"
#
# MicroPub protocol
# -----------------
#
# Becomes a technote:
#
# POST .../ext/micropub HTTP/1.1
# Authorization: Bearer $2y....
# Content-Type: application/json
#
# {
# "type": ["h-note"],
# "properties": {
# "content": ["## Headline\n\n [markdown](...)"],
# "title": ["commit note"]
# }
# }
#
# Mapped to a ticket:
#
# POST .../ext/micropub HTTP/1.1
# Authorization: Bearer $2y....
# Content-Type: application/json
#
# {
# "type": ["h-issue"],
# "properties": {
# "content": ["## I haz the problem..."],
# "summary": ["RuntimeException in core.py"],
# "type": ["Feature Request"],
# "priority": ["Immediate"],
# "severity": ["Low"],
# "category": ["init"],
# "version": ["v0.3"],
# "contact": ["user@localnet"]
# }
# }
#
# Probably makes sense to define a permatoken if you want to use this as
# real ticket bridge. (Micropub is sort of a side project here, the goal
# is some form of down/upstream ticket bridge.)
#
if ($_REQUEST["dbg"]) {
error_reporting(E_ALL); ini_set("display_errors", 1);
}
#-- init
define("MP_SELF", "https://$_SERVER[SERVER_NAME]$_SERVER[PHP_SELF]");
header("Link: <".MP_SELF.">; rel=micropub");
header("Access-Control-Allow-Origin: *");
#-- config
# combines https://indieweb.org/Micropub-extensions#Query_for_Supported_Vocabulary
# with https://github.com/indieweb/micropub-extensions/issues/8
# to sort of resemble https://github.com/barryf/micropublish/blob/master/config/properties.json
# also used for internal mapping of mf2 types onto fossil commands (hyphen in `-map` indicates internal use)
$map = [
[
"type" => "note", # should also be 'h-entry' really, but this simplifies mapping wo/ checking for supplied properties
"name" => "Blog/Technote",
"h" => "entry",
"-map" => "technote",
"-cap" => "[bick]",
"accepts-media" => TRUE,
"properties" => [
[
"name" => "content",
"label" => "Content",
"type" => "textarea markdown",
"value" => "## Note\n...",
"required" => true,
"-map" => "stdin",
],
[
"name" => "summary",
"label" => "Timeline note",
"type" => "text plain",
"placeholder" => "commit description...",
"-map" => "title",
],
[
"name" => "bgcolor",
"type" => "color",
"required" => false,
"-map" => "bgcolor",
],
[
"name" => "visibility",
"type" => "checkbox",
"value" => false,
"required" => false,
"-map" => "hidden",
],
[
"name" => "photo",
"type" => "file",
"accepts" => "image/*",
"is-media" => true,
"required" => false,
]
],
],
[
"type" => "entry",
"name" => "Wiki page",
"h" => "entry",
"-map" => "wiki",
"-cap" => "[kmf]",
"accepts-media" => TRUE,
"properties" => [
[
"name" => "content",
"label" => "Content",
"type" => "textarea markdown",
"value" => "## Note\n...",
"required" => true,
"-map" => "stdin",
],
[
"name" => "title",
"label" => "Title",
"type" => "text",
"pladeholder" => "PageTitle",
"required" => true,
],
[
"name" => "attachment",
"label" => "Attachment",
"type" => "file",
"accepts" => "*/*",
"is-media" => true,
"required" => false,
],
],
],
[
"type" => "issue",
"name" => "Ticket",
"h" => "entry",
"-map" => "ticket",
"-cap" => "n",
"accepts-media" => FALSE,
"properties" => [
[
"name" => "content",
"label" => "Content",
"type" => "textarea markdown",
"placeholder" => "## Note\n...",
"required" => true,
"-map" => "comment",
],
[
"name" => "title",
"label" => "Title/Summary",
"type" => "text",
"placeholder" => "Bug in...",
"required" => true,
"-map" => "title",
],
[
"name" => "type",
"label" => "Ticket type",
"type" => "select",
"select" => ["Code_Defect", "Build_Problem", "Documentation", "Feature_Request", "Incident"],
],
[
"name" => "priority",
"type" => "select",
"select" => ["Immediate", "High", "Medium", "Low", "Zero"],
],
[
"name" => "severity",
"type" => "select",
"select" => ["Critical", "Severe", "Important", "Minor", "Cosmetic"],
],
[
"name" => "category",
"-map" => "subsystem",
],
[
"name" => "version",
"-map" => "foundin",
],
[
"name" => "contact",
"type" => "text email url",
"-map" => "private_contact",
],
],
],
[
"type" => "event",
"name" => "Chat",
"h" => "entry",
"-map" => "chat",
"-cap" => "C",
"accepts-media" => TRUE,
"properties" => [
[
"name" => "content",
"label" => "Message",
"type" => "text",
"required" => true,
"-map" => "content",
],
[
"name" => "photo",
"label" => "Image attachmennt",
"type" => "file",
"accepts" => "image/*",
"is-media" => true,
"required" => false,
],
],
],
];
#-- database (== fossil repo)
function db($sql="", $params=[]) {
static $db;
if (empty($db)) {
$db = new PDO("sqlite::memory:");
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_WARNING);
$db->query("ATTACH DATABASE '$_SERVER[FOSSIL_REPOSITORY]' AS 'repo'");
}
if ($params) {
$stmt = $db->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
else {
return $db->query($sql);
}
}
#-- JSON/form-encoded output
function json_response($r, $status=false, $always_json=True) {
if ($status) {
header("Status: $status");
if (is_string($r)) {
$r = ["error"=>"invalid_request", "error_description"=>$r];
}
}
if (preg_match("~^(?!.+/json).+/x-www-form~i", $_SERVER["HTTP_ACCEPT"])) {
header("Content-Type: application/x-www-form-urlencoded");
array_walk($r, function(&$v, $k) { $v = "$k=" . urlencode($v); });
die(join("&", $r));
}
elseif (preg_match("~json/|[/+]json~i", $_SERVER["HTTP_ACCEPT"]) || $always_json) {
header("Content-Type: application/json; charset=utf-8");
die(json_encode($r, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES));
}
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 (test different variations to circumvent fossil shortening the CGI env)
function bearer() {
foreach (["HTTP_AUTHORIZATION", "HTTP_Authorization", "HTTP_COOKIE", "AUTH_CONTENT", "HTTP_AUTHORIZATION2"] as $h) {
if (preg_match("/Bearer\s+(\S+)/i", $_SERVER[$h], $m)) {
return $m[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($type) {
global $map;
if (is_array($type) && count($type)) { $type = $type[0]; }
$type = preg_replace("/^\w-/", "", $type);
foreach ($map as $row) {
if ($row["type"] == $type) {
return $row["-map"];
}
}
return $type;
}
# check if request action+type entry all match token scopes
function verify_scope($token=[], $req_scopes=[]) {
preg_match_all("/(\w+)/", $token["scope"], $allowed);
$allowed = array_map("scope_map", $allowed[1]);
$req_scopes = array_map("scope_map", array_filter($req_scopes));
#var_dump("TOKEN=", $token, " ALLOWED=", $allowed, " REQ=", $req_scopes);
# comapare against $allowed token scope list
$ok = NULL;
foreach ($req_scopes as $t) {
$ok = ($a !== False) && in_array($t, $allowed);
}
return $ok;
}
#-- transform $_POST to JSON body
function request() {
if (preg_match("~^\s*(\w+/json|json/\w+)(\+json)?\s*(;|$)~i", $_SERVER["CONTENT_TYPE"])) {
$json = json_decode(file_get_contents("php://input"), True);
}
elseif (count($_POST)) {
$json = [
"q" => "$_REQUEST[q]",
"action" => "$_POST[action]",
"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 = "";
public $tmpfn = "";
# dispatch `type` onto functions
function __construct($post, $token) {
db("DETACH DATABASE 'repo';");
$this->token = $token;
$action = $post["action"];
$type = scope_map($post["type"]);
if (empty($post["properties"]["content"])) {
json_response(["properties.content[] required", $post], "400 Basics");
}
elseif (method_exists($this, $type)) {
$this->fold($post["properties"]);
$stdout = call_user_func([$this, $type], $post["properties"]);
//if ($this->tmpfn) { unlink($this->tmpfn); }
if (!$this->url) {
$this->new_url("", $stdout);
}
//die("back in main, $this->url");
if ($this->url) {
$this->redirect201($this->url);
}
else {
json_response(["result"=>$stdout], "500 Oh noes, something went wrong");
}
}
else json_response("Unknown h-type: `$type`", "400 No");
}
function fold(&$arr) {
foreach ($arr as $k=>&$v) {
if (in_array($k, ["category", "tag"])) {
continue;
}
elseif ($k == "content") {
$v = implode("\n", $v);
}
else {
$v = implode(", ", $v);
}
}
}
function redirect201($url) { # fossil workaround: use C-L:
header("Status: 201 Created");
header("Location: {$this->url}", True, 201);
header("Content-Location: {$this->url}");
header("Content-Type: text/html");
die("<html><head><meta http-equiv=Location value=\"$this->url\"></head></html>");
}
function tmp($content) { # temp files instead of exec echo| input pipe, not used (systemds PrivateTmp= made files inaccessible to fossil cli)
$this->tmpfn = tempnam("/tmp/", "mp");
file_put_contents($this->tmpfn, $content);
return $this->tmpfn;
}
function excerpt($s) {
$s = trim(preg_replace("/[\W]+|\\n.+/s", " ", $s));
return substr($s, 0, 92);
}
function map_for($name) {
global $map;
foreach ($map as $type) { if ($type["name"] == $name) { return $type; } }
}
# grep new Location: url from fossil command output
function new_url($path, $stdout="") {
if (preg_match("/Created new wiki page (.+)\./i", $stdout, $m)) {
$path = "/wiki/" . urlencode($m[1]);
}
elseif (preg_match("/Created new tech note (\d[\d\-]+(\s[\d:]+\d)?)/im", $stdout, $m) # immediately resolve date to uuid
and preg_match("/(\w+) $m[1]/", $this->fossil(["wiki","list","-t","-s"]), $m)) {
$path = "/technote/" . urlencode($m[1]);
}
elseif (preg_match("/ticket add succeeded for (\w+)/i", $stdout, $m)) {
$path = "/ticket/" . urlencode($m[1]);
}
$this->url = preg_replace("~/ext/\w+~", "", "https://$_SERVER[SERVER_NAME]$_SERVER[PHP_SELF]$path");
}
# h-note = technote
function technote($p, $act="create") {
# &title=|&summary=
# &content=
# &category[]=
$args = [
"wiki", "$act",
$p["title"] ?: $p["summary"] ?: $this->excerpt($p["content"]),
"--technote", ($this->id ?: "now"),
"--mimetype", "text/x-markdown"
];
if ($p["category"]) {
$args[] = "--technote-tags";
$args[] = implode(",", $p["category"]);
}
list($r, $null) = [ $this->fossil($args, $p["content"]), $this->add_attachments(["attachment", "add", "%f", "-t", "now"]) ];
return $r;
}
# h-entry = wiki
function wiki($p, $act="create") {
# &title=
# &content=
$title = $this->id ?: $p["title"] ?: $this->excerpt($p["content"]);
$args = ["wiki", "$act", "--mimetype", "text/x-markdown", $title];
list($r, $null) = [ $this->fossil($args, $p["content"]), $this->add_attachments(["attachment", "add", $title, "%f"]) ];
return $r;
}
# h-issue = ticket
function ticket($p, $args=["ticket", "add"]) {
foreach ($this->map_for("issue")["properties"] as $def) {
if ($p[$def["name"]]) {
$args[] = $def["-map"] ?: $def["name"];
$args[] = implode(", ", $p[$def["name"]]);
}
}
return $this->fossil($args);
}
# h-event = chat
function chat($p, $act="send") {
$args = ["chat", "send", "-m", $p["content"]];
if (count($_FILES)) {
$args = array_merge($args, ["-f", current($_FILES)["tmp_name"]]);
}
$_FILES = [];
$this->new_url("/chat/#" . md5(rand()));
return $this->fossil($args);
}
# any $_FILES
function add_attachments($args) {
foreach ($_FILES as $f) {
if ($f["size"] and !$f["error"]) {
$args[ array_find("%f", $args) ] = $f["tmp_name"];
$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="") {
$args = array_merge(
["fossil", "--nocgi"],
array_map("create::esc", $args),
["-R", create::esc($_SERVER["FOSSIL_REPOSITORY"])],
);
$cmd = implode(" ", $args) . " 2>&1";
if ($input) {
$cmd = "echo " . create::esc($input) . " | $cmd";
}
#print_r($cmd);
#exit();
exec($cmd, $stdout, $errno);
header("X-Debug: $errno: " . implode("//", $stdout));
if ($errno) {
json_response(["errno"=>$errno, "stdout"=>$stdout], "500 err");
}
return implode("\n", $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)) {
$stdout = call_user_func([$this, $type], []);
json_response([
"type" => [$get["type"]],
"properties" => (is_array($stdout) ? $stdout : [
"content"=> [$stdout],
])
]);
}
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") {}
}
# ?q=actions
class query {
static $public = ["config", "properties"];
function __construct($token=null) {
json_response(call_user_func([$this, "$_GET[q]"], $token));
}
function config() {
global $map;
foreach ($map as &$type) {
$type["required-properties"] = [];
$type["accepts-media"] = False;
foreach ($type["properties"] as $prop) {
if ($prop["required"]) { $type["required-properties"][] = $prop["name"]; }
$type["accepts-media"] |= $prop["type"] == "file";
}
}
return [
"media-endpoint" => "https://$_SERVER[SERVER_NAME]$_SERVER[PHP_SELF]", # same
"syndicate-to" => [],
"q" => preg_grep("/^[a-z]/", get_class_methods("query")),
"extensions" => [
"unprefixed-type", "bug-local-auth-token-only",
#"bug-me-auth-field", "bug-detached-auth-token-micropub",
"http-link-rel-micropub", "http-content-location", "http-mime-major-json",
"post-types", "post-types-h", "post-types-properties-dict-input", #"post-types-properties-list",
],
"post-types" => $map,
];
}
function properties($token) {
return array_unique(array_merge(array_map(
function($t) { return $t["properties"]; },
$this->config()["post-types"]
)));
}
function source($token) {
new source($_GET, $token);
}
}
#-- run
if (!request() and !$_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 (note), wiki pages (entry), tickets (issue), or chat messages (event).
<p>
Should be registered in the repo template or a user homepage with:<br>
<code><link rel=micropub href='https://$_SERVER[SERVER_NAME]$_SERVER[PHP_SELF]'></code>
</div>
HTML;
die(
# realpath(tempnam("/tmp/", "fossil-micropub-php-")) # systemd PrivateTmp
);
}
elseif (!empty($_REQUEST["q"]) and in_array($_REQUEST["q"], query::$public)) {
new query();
}
elseif (!$token = get_token($_REQUEST["access_token"])) {
json_response([ "error"=>"unauthorized", "error_description"=>"token expired/invalid"], "401 Token expired");
}
elseif (!verify_scope($token, [$_POST["action"], $_POST["type"]])) {
#print_r($_POST);
json_response("scope insufficient", "403 Scope insufficient");
}
elseif (!empty($_GET["q"])) {
new query($token);
}
elseif ($_POST["action"] == "update") {
new update($_POST, $token);
}
else {
#print_r($_SERVER);
new create($_POST, $token);
}
?>