#!/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>&lt;link rel=token_endpoint href='https://$_SERVER[SERVER_NAME]$_SERVER[PHP_SELF]'&gt;</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);
}


?>