βŒˆβŒ‹ βŽ‡ branch:  freshcode


Artifact [6b1f366087]

Artifact 6b1f3660871e719e17c5368812b0acf81a33567d:

  • File handler_api.php — part of check-in [49047f4b8d] at 2015-04-21 19:52:03 on branch trunk — Return just HTTP 200 status with project:{name:null} on non-existing entries. Alias "hide" and "hidden" fields. (user: mario size: 22962)

<?php
/**
 * api: freshcode
 * title: Submit API
 * description: Implements the Freecode JSON Rest API for release updates
 * version: 0.6
 * type: handler
 * category: API
 * doc: http://fossil.include-once.org/freshcode/wiki/API2
 * author: mario
 * license: AGPL
 *
 * This utility code provides a project+release submission API for
 * freecode-submit and similar tools. The base features fit well with
 * the freshcode.club database scheme. It has been simplified to
 * allow more literal access and control.
 *
 * Our RewriteRules map following request schemes:
 *
 *       GET   projects/<name>.json                query
 *    CREATE   projects/<name>.json                new_project
 *       PUT   projects/<name>.json                update_core
 *      POST   projects/<name>/releases.json       (redundant)
 *       PUT   projects/<name>/urls.json           (redundant)
 *    DELETE   projects/<name>/releases/<vs>.json  version_DELETE
 *       GET   feed/<name>.json                    external (data federation)
 *
 * All requests should be sent onto `https://api .freshcode.club/...`,
 * where only CREATE requires a client SSL cert, see `submit.pem`.
 *
 * Retrieval requests usually come with an ?auth_code= token. For POST
 * or PUT access it's part of the JSON request body. Which can have
 * varying payloads depending on request type:
 *
 *   {
 *     "auth_code": "pw123",
 *     "project": {
 *       "license_list": "GNU GPL",
 *       "project_tags": "kernel,operating-system",
 *       "summary": "Linux kernel desc",
 *       "description": "Does this and that.."
 *     },
 *     "urls": {
 *       "homepage": "http://example.org/",
 *       "hg-repo": "http://hg.example.org/",
 *     }
 *     "release": {
 *       "version": "4.0.0",
 *       "changes": "Bugfix for foo and bar."
 *     }
 *   }
 *
 * Any crypt(3) password hash in a projects `lock` field will be checked
 * against the plain `auth_code`.
 * When CREATEing a new project, the auth_code will be stored as hash.
 *
 * At this point everything went through index.php already; runtime env
 * thus initialized. Therefore API methods can be invoked directly, which
 * either retrieve or store project data, and prepare a JSON response.
 *
 */

#ifdef TESTING_ONLY #####
db(new PDO("sqlite:./test.db"));   //@TODO: Preset in `config.local.php` for http://api. and http://test.
#endif ##################


/*
 @Test @sh

 @t query
   wget http://freshcode/projects/linux.json?auth_code=unused -O-
   ./fc-submit -q linux

 @t change_core
   ./fc-submit -P linux -D "new proj" -S "oneliner" -L "GNU GPL" -T "kernel,linux" -n -V

 @t publish
   ./fc-submit -P linux -v "3.55.1" -c "Change all the things" -t "major,bugfix" -n -V

 @t delete
   ./fc-submit -P linux -v "3.55.1" -d -n -V

 @t urls
   wget http://freshcode/projects/linux/urls.json?auth_code=0 -O-
*/



// Wraps API methods and utility code
class FreeCode_API {


    // HTTP method
    var $method;

    // API function
    var $api;

    // Project name
    var $name;

    // Optional revision ID (just used for releases/; either "pending" or t_published timestamp) 
    var $rev;
    
    // inner @array from JSON request body
    var $body;
    
    // Optional auth_code (from URL or JSON body)
    var $auth_code;
    
    
    // Logging
    var $log = TRUE;
    var $timestamp = 0;

    // defaults
    const EDITOR_NOTE = "Submitted via API. You can add an extra OpenID handle or further passwords, by revisiting the /login, and reapplying the `lock` (green action link) below.";



    /**
     * Initialize params from RewriteRule args.
     *
     */
    function __construct() {
    
        // URL params
        $this->name = $_GET->proj_name["name"];
        $this->api = $_GET->id->strtolower->in_array("api", "query,update_core,new_project,publish,urls,version_get,version_delete");
        $this->method = $_SERVER->id->strtoupper["REQUEST_METHOD"];
        $this->auth_code = $_REQUEST->text["auth_code"];
        $this->rev = $_REQUEST->text["id"];  // optional param, now literal `version` string
        
        // Request body is only copied, because it comes with varying payloads (release, project, urls)
        if ($_SERVER->int["CONTENT_LENGTH"] && $_SERVER->stripos…json->is_int["CONTENT_TYPE"]) {
            $this->body = json_decode(file_get_contents("php://input"), TRUE);
            $this->auth_code = $this->body["auth_code"];
        }
        
        // Logging
        $this->timestamp = sprintf("%s (%s)", time(), gmdate(DATE_ISO8601, time()));
        $this->log($this, "\n\n\n/* << REQUEST */");
        // Testing
        set_error_handler(function($errno, $str, $fn, $line, $ctx) {
            $this->log("", "### Runtime Warning [$errno]: $str ($fn:$line) ###");
        });
    }
    
    
    /**
     * Log incoming request, outgoing response, or data set as prepared prior updating DB.
     *
     */
    function log($what, $prefix) {
        if ($this->log) {
            file_put_contents("api-log.txt", "$prefix\n" . json_encode($what, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n", FILE_APPEND);
        }
        return $what;
    }


    
    /**
     * Invoke API target function.
     * After retrieving current project data.
     *
     */
    function dispatch() {
    
        // Fetch latest revision
        $project = new release($this->name);
        if (empty($project["name"]) && ($this->api !== "new_project")) {
            // Return 204 with empty →project→name field.
            $this->error_exit(NULL, "200 No content", "Unknown project name");
        }
        
        // Run dialed method, then output JSON response.
        if ($this->api) {
            $this->json_exit($this->{$this->api}($project));
        }
        else {
            $this->error_exit(NULL, "501 Not implemented", "Unknown request scheme");
        }
    }



    /**
     * GET project description
     * -----------------------
     *
     * @unauthorized
     *
     * Just returns the current project description, urls and release fields:
     *
     *   β†’ name
     *   β†’ title
     *   β†’ summary
     *   β†’ description
     *   β†’ license_list
     *   β†’ project_tags
     *   β†’ homepage
     *   β†’ urls {}
     *   β†’ version
     *   β†’ changes
     *   β†’ release_tags
     *   β†’ project_tags
     *   β†’ download
     *   β†’ author/submitter
     *
     * A few entries are duplicates. Also appends extraneous freshcode fields.
     *
     */
    function query($project) {
    
        // Everyone can access this, but only the owner will see private fields
        $data = $this->auth_filter($project);
    
        // Alias some fields for fc-submit, but append our data scheme intact
        $r = array(
            "project" => array(
                 #"id"        => "\$OBSOLETE β†’ name",
                 #"permalink" => "\$OBSOLETE β†’ name",
                 #"oneliner"  => "\$OBSOLETE β†’ summary",
                 #"tag_list"  => "\$OBSOLETE β†’ project_tags",
                 #"approved_urls" => [],
                 "name" => $data["name"],
                 "summary" => $data["summary"],
                 "license_list" => $data["license"],
                 "project_tags" => $data["tags"],
                 "urls" => $this->urls_expand($data, $data["urls"]),
                 "release_tags" => "$data[state],$data[scope]",
                 "author" => $data["submitter"],
                 "submitter" => NULL,
            )
            + $data->getArrayCopy()  // literal DB fields
        );
        return $r;
    }
    


    /**
     * PUT project base fields
     * -----------------------
     *
     * @auth-required
     *
     * Whereas the "project" payload section can contain:
     *   β†’ title
     *   β†’ summary
     *   β†’ description
     *   β†’ homepage
     *   β†’ license_list
     *   β†’ project_tags
     *   β†’ download
     * And an URL dictionary (within project or grouped out):
     *   β†’ urls[download]
     *   β†’ urls[changelog]
     *   β†’ urls[screenshot]
     *   β†’ urls[custom-name]
     * Release infos (embedded or separate JSON struct):
     *   β†’ version
     *   β†’ changes
     *   β†’ release_tags
     *   β†’ hide
     *   β†’ state
     *   β†’ scope
     * Additionally we'd accept:
     *   β†’ author
     *
     */
    function update_core($project, $is_new=0) {
        
        // Authorization (redundant here, since ->insert checks already)
        $data = $this->auth_filter($project);

        // Join optionally sectioned JSON dicts "project", "release" and "urls"
        $body = $this->merge_body();
        
        // extract fields
        $in = new input($body, "PUT_update_core");
        $new = array(

            // basic project fields
            "title" => $in->text["title"],
            "summary" => $in->text["summary"],
            "description" => $in->text["description"],
            "tags" => f_tags($in->text["project_tags"]),
            "license" => $this->map_licenses($in->words["license_list"]),
            "submitter" => $in->text["author"],
            "homepage" => $in->url->http["homepage"],
            "image" => $in->url->http["image"],

            // release fields
            "version" => $in->text["version"],
            "changes" => $in->text["changes"],
            "state" => tags::state_tag($in->multi->join->words->default…prerelease["state,release_tags,release_tag_list"]),
            "scope" => tags::scope_tags($in->multi->join->words->default…initial["scope,release_tags,release_tag_list"]),
            "download" => $in->url->http["download"],

            // extras, or internals
        );
        
        // merge in core "urls" if present, turn urls{} into key=value list
        if ($in->has("urls")) {
            $this->urls_map($new, $in->raw["urls"]);
        }
        // automatically use NEWS file if present
        if (preg_match("/NEWS/", $new["autoupdate_url"])) {
            $new["autoupdate_module"] = "changelog";
        }
        
        // Update flags
        $flags = array(
            "hidden" => $in->int["hidden"] || $in->int["hide"] || !empty($new["hidden"]),
            "deleted" => 0,   // empty title,description,homepage
        );

        return $this->insert($project, $new, $flags);
    }


    /**
     * Expand CSV list of license names into our internal denominators;
     * and keep comma-separation.
     *
     */
    function map_licenses($csv_licenses, $r=[]) {
        foreach (p_csv($csv_licenses) as $license) {
            $r[] = tags::map_license($license);
        }
        return $r ? join(", ", $r) : NULL;
    }


    /**
     * Combine sectioned JSON payload.
     *   {
     *     "project": {},
     *     "urls": {},
     *     "release": {},
     *   }
     * All get merged into single request structure.
     *
     */
    function merge_body() {
        $body = [];
        if (isset($this->body["project"])) {
            $body = array_merge($body, $this->body["project"]);
        }
        if (isset($this->body["release"])) {
            $body = array_merge($body, $this->body["release"]);
        }
        if (isset($this->body["urls"])) {
            $body["urls"] = $this->body["urls"];
        }
        return $body;
    }



    /**
     * CREATE project with base fields
     * -------------------------------
     *
     * @auth-required
     * @cert-required
     *
     * Same fields supported as for `update_core` / PUT request,
     * but additionally/optionally accepts::
     *    β†’ title
     *    β†’ image         (URL to project logo/screenshot)
     *    β†’ release_tags  ("initial,bugfix")
     *    β†’ author        ("nickname, user@sourceforge")
     *    β†’ version
     *    β†’ changes
     *    β†’ changelog-URL (to NEWS file)
     *
     * But can create entirely new projects. (WORKS JUST ONCE).
     * Populates the `lock` field with the hashed `auth_code`.
     *
     */
    function new_project($project) {
    
        // Check anti-spam certificate
        # $this->requires_ssl_cert();
        
        // Populate new project record
        $in = new input($this->body["project"], "CREATE_new_project");
        $project = new release(array(
            "name" => $this->name, // already filtered
            "title" => $in->text->default("title", $this->name),
            "submitter" => $in->text->default("author", "freecode-submit, api@freshcode.club"),
            "editor_note" => self::EDITOR_NOTE,
            "hidden" => empty($in->text["version"]),
            "autoupdate_module" => "api",
            "flag" => 0,
            "deleted" => 0,
            "social_links" => 0,
            "submitter_image" => "",
            "autoupdate_delay" => 0.25,
            "lock" => password_hash($this->auth_code, PASSWORD_DEFAULT),
        ));
        
        return $this->update_core($project, $is_new=1);
    }



    /**
     * POST release/version
     * --------------------
     *
     * @auth-required
     *
     * This is now just a wrapper around the regular PUT method,
     * which already handles the release fields.
     *
     */
    function publish($project) {
        return $this->update_core($project);
    }
    

    /**
     * Check for "pending" releases
     * ----------------------------
     *
     * @obsolete
     *
     */
    function version_GET($project) {
        return $this->error_exit(NULL, '410 Obsolete', "There are no pending releases. Just use DELETE on /projects/\$name/\$versionstr.json instead.");
    }



    /**
     * "Withdraw" a "pending" release
     * ------------------------------
     *
     * @auth-required
     *
     * We're faking two things here. Firstly that the review process
     * was enabled by default. Secondly that you could delete things.
     * (The database is designed to be somewhat "immutable", we just
     * pile up revisions normally.)
     *
     * So withdrawing a release just means it gets marked "deleted"
     * (formerly just "hidden" and/or flagged for moderator attention.)
     * This somewhat still may terminate a project lifeline (due to VIEW
     * revision grouping), but can be undone by submitting the release
     * anew.
     *
     * The reasoning being that withdrawn releases are really just
     * authors making last minute fixes; commonly retracted releases
     * are just resent later, or with a different changelog.
     *
     */
    function version_DELETE($project) {

        // Obviously requires a valid `lock` hash
        $this->requires_permission($project);
        assert('is_numeric($this->rev)');

        // Hide all entries for revision
        $r = db([
         " UPDATE release ",
         "    SET :,  " => ["hidden" => 1, "deleted" => 1, "flag" => 0],
         "  WHERE :&  " => ["name" => $this->name, "version" => $this->rev]
        ]);
        
        return $r ? array("success" => TRUE) : $this->error_exit(NULL);
    }


    /**
     * URL editing
     * -----------
     *
     * @auth-required
     *
     * There is one associative URL blob for reading and updating all URLs
     * at once. The urls{} dict can be present by itself, in all PUT or POST
     * requests, or be a subarray of the outer project{} structure.
     *
     *   GET /projects/name/urls.json   { "urls" : { "src": "http://..",
     *                                       "github": "http://.." }    }
     *   PUT /projects/name/urls.json   { "urls": { "txz":, "doc":.. }  }
     *
     * Our labels use the tag-form, so incoming labels will be adapted.
     * "Tar/BZ2" becomes "Tar-BZ2" with case preserved, but dashed-names.
     *
     * Internally the urls are stored in an INI-style key=value text blob.
     * (But the API stays somewhat more RESTy with an associative dict.)
     *
     */
    function urls($project) {
    
        /**
         * For a GET query just mirror "Other URLs" as dict
         *
         * @unauthorized
         *
         */
        if ($this->method == "GET") {
            return array("urls" => $this->urls_expand($project, $project["urls"]));
        }
        
        /**
         * Updates may come as PUT, POST, PUSH request
         *
         * @auth-required
         *
         */
        else {
            $this->urls_map($project, $this->body["urls"]);
            // Update DB
            return $this->insert($project, $project->getArrayCopy());
        }
    }


    /**
     * @outgoing >>>
     *
     * Unpacks "urls" from key=value format into array,
     * and joins core URLs (homepage, download, image) in
     *
     * @param  Release      $project object/array
     * @param  string       "title=url" list, ini/yaml-style
     */
    function urls_expand($project, $urls) {
        $urls = p_key_value($urls, NULL);
        $urls["Homepage"] = $project["homepage"];
        $urls["Download"] = $project["download"];
        $urls["Screenshot"] = $project["image"];
        $urls["Changelog"] = $project["autoupdate_url"];
        return array_filter($urls, "strlen");
    }


    /**
     * @incoming <<<
     *
     * Compacts {title:url} dictionary into key=value field for custom entries,
     * and separates out core URL fields into release-> storage.
     *
     * @param  &reference   $project object/array
     * @param  array        title => url dictionary
     *
     *@Yikes: rather return [$new, $urls] list?
     *
     */
    function urls_map(&$project, $dict) {
    
        if (empty($dict)) {
            return;
        }
    
        // Filter incoming URLs
        $urls = new input($dict, "urls_dict");
        $urls = $urls->list->url->http[$urls->keys()];

        // Update struct
        $new_text = "# Expanded from API request\r\n";
        $map_core = array(
            "homepage" => "homepage",
            "home-page" => "homepage",
            "website" => "homepage",
            "download" => "download",
            "screenshot" => "image",
            "changelog" => "autoupdate_url",
        );
        foreach ($urls as $label => $url) {

            // Remove non-alphanumeric characters
            $label = trim(preg_replace("/\W+/", "-", $label), "-");
            $lower = strtolower($label);

            // Split homepage, download URL into separate fields,
            if (isset($map_core[$lower])) {
                if ($lower == "image" and !preg_match("/(jpe?g|gif|png|webp)($|[?])/i", $url)) {
                    continue;
                }
                $project[$lower] = $url;
            }
            // While remaining go into `urls` key=value block, retain case-sensitivity here
            else {
                $new_text .= "$label = $url\r\n";
            }
        }

        // overwrite incoming dict
        $project["urls"] = $new_text;
    }
    
    
    /**
     * Perform partial update
     *
     * @auth-required
     *
     */
    function insert($project, $new, $flags=[]) {

        // Write permissions required obviously.
        $this->requires_permission($project);
 
        // Log data
        $this->log($new, "/* ++ STORE DATA */");

        // Add new fields to $project
        $flage["submitter_openid"] = $_SERVER->text["HTTP_USER_AGENT"];
        $flags["via"] = "api";
        $project->update(array_filter($new, "strlen"), $flags, [], TRUE);

        // Store or return JSON API error.
        return ($project->store() and (header("Status: 201 Created") + 1))
             ? array("success" => TRUE)
             : $this->error_exit(NULL, '500 Internal Issues', "Database mistake");
    }
    
    
    /**
     * Strip down raw project data for absent auth_code
     * in read/GET requests.
     *
     */
    function auth_filter($data) {
        if (!$this->is_authorized($data)) {
            unset(
                $data["lock"],
                $data["submitter_openid"],
                #$data["submitter"],
                $data["submitter_image"],
                $data["hidden"], $data["deleted"], $data["flag"],
                $data["social_links"],
                $data["autoupdate_module"], $data["autoupdate_delay"],
                $data["autoupdate_regex"],
                $data["editor_note"],
                $data["t_changed"]
            );
        }
        return $data;
    }

    
    /**
     * Prevent further operations for (write) requests that
     * actually REQUIRE a valid authorization token.
     *
     * @exit if unauthorized
     *
     */
    function requires_permission($data) {
        if ($this->is_authorized($data)) {
            return $data;
        }
        $this->error_exit(NULL, "401 Unauthorized", "No matching API auth_token hash. Add a crypt(3) password in your freshcode.club project entries `lock` field, comma-delimited to your OpenID handle. See http://fossil.include-once.org/freshcode/wiki/API2");
    }
    
    
    /**
     * Requires a valid client SSL certificate for CREATE requests.
     * Currently just checks the serial ID against a list of valid stubs.
     *
     * @exit if unauthorized
     *
     */
    function requires_ssl_cert() {
        $serial = $_SERVER->name->ascii->basename->strtoupper['SSL_CLIENT_M_SERIAL'];
        $remain = $_SERVER->int['SSL_CLIENT_V_REMAIN'];
        if (strlen($serial) and ($remain >= 0) and file_exists("config/cert/$serial")) {
            return TRUE;
        }
        $this->error_exit($serial, "495 Cert Error", "Unregistered or expired SSL certificate. Please use the default `http://fossil.include-once.org/freshcode/doc/trunk/doc/submit.pem` from the Wiki.");
    }


    /**
     * The `lock` field usually contains one or more OpenID urls. It's
     * a comma-delimited field.
     *
     * Using the API additionally requires a password hash, as in crypt(3)
     * or `openssl passwd -1` or PHPs password_hash(), to be present.
     *
     * It will simply be compared against the ?auth_code= parameter.
     *
     */
    function is_authorized($data) {
        foreach (preg_grep("/^[^:]+$/", p_csv($data["lock"])) as $hash) {
            if (password_verify($this->auth_code, $hash)) {
                return TRUE;
            }
        }
        return FALSE;
    }


    /**
     * JSON encode and finish.
     *
     */
    function json_exit($data) {
        header("Content-Type2: json/vnd.freecode.com; version=3; charset=UTF-8");
        header("Content-Type: application/json");
        $this->log($data, "/* >> RESPONSE */");
        $data["\$feed-license"] = "CC-BY-SA 3.0";
        $data["\$feed-origin"] = "http://freshcode.club/";
        exit(
            json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
        );
    }


    /**
     * Bail with error response.
     *
     */
    function error_exit($data, $http = "503 Unavailable", $json = "unknown method") {
        header("Status: $http");
        $this->json_exit(["error" => "$json", "project" => ["name" => NULL], "\$data" => $data]);
    }

}



?>