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


?>