<?php
/**
* api: freshcode
* title: Submit API
* description: Implements the Freecode JSON Rest API for release updates
* version: 0.3
* type: handler
* category: API
* doc: http://fossil.include-once.org/freshcode/wiki/Freecode+JSON+API
* author: mario
* license: AGPL
*
* This utility code emulates the freecode.com API, to support release
* submissions via freecode-submit and similar tools. The base features
* fit well with the freshcode.club database scheme.
*
* Our RewriteRules map following Freecode API request paths:
*
* GET projects/<name>.json query
* PUT projects/<name>.json update_core
* POST projects/<name>/releases.json publish
* GET projects/<name>/releases/<w>.json version_GET, id=
* DELETE projects/<name>/releases/<i>.json version_DELETE
*
* From the ridiculous amount of URL manipulation calls, we just keep:
*
* GET/PUT projects/<name>/urls.json urls (assoc array)
*
*
* Retrieval requests usually come with an ?auth_code= token. For POST
* or PUT access it's part of the JSON request body. Which comes with
* varying payloads depending on request type:
*
* { "auth_code": "pw123",
* "project": {
* "license_list": "GNU GPL",
* "project_tag_list": "kernel,operating-system",
* "oneliner": "Linux kernel desc",
* "description": "Does this and that.."
* } }
*
* Any crypt(3) password hash in a projects `lock` field will be checked
* against the plain auth_code.
*
* 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.
*
*/
/*
@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;
/**
* 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,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
// 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 */");
}
/**
* 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 (!$project["name"]) {
$this->error_exit(NULL, "404 No such project", "Invalid Project ID");
}
// Run dialed method, then output JSON response.
$this->json_exit(
$this->{ $this->api ?: "error_exit" }($project)
);
}
/**
* GET project description
* -----------------------
*
* @unauthorized
*
* Just returns the current releases project description
* and basic fields.
* Freecode API clients expect:
* β id (something numeric, which we don't have, so just a CRC32 here)
* β permalink (=name)
* β oneliner (we just extract the first line)
* β license_list
* β tag_list
* β approved_urls
*
* 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
return array(
"project" => array(
"id" => crc32($data["name"]),
"permalink" => $data["name"],
"oneliner" => substr($data["description"], 0, 100),
"license_list" => p_csv($data["license"]),
"tag_list" => p_csv($data["tags"]),
"approved_urls" => $this->array_uncolumn(p_key_value($data["urls"], NULL))
) + $data->getArrayCopy()
);
}
// Expand associative URLs into [{label:,redirector:},..] list
function array_uncolumn($kv, $ind="label", $dat="redirector", $r=array()) {
foreach ($kv as $key=>$value) {
$r[] = array($ind=>$key, $dat=>$value);
}
return $r;
}
/**
* PUT project base fields
* -----------------------
*
* @auth-required
*
* Whereas the project ->body contains:
* β license_list
* β project_tag_list
* β oneliner (IGNORED)
* β description
* Additionally we'd accept:
* β state
* β download (URL)
* β homepage
*
*/
function update_core($project) {
$core = new input($this->body["project"], "core");
// extract fields
$new = array(
// standard FC API fields
"license" => tags::map_license(p_csv($core->words["license_list"])[0]),
"tags" => f_tags(join(" ", $core->text["project_tag_list"])),
"description" => $core->text["description"],
// additional overrides
"homepage" => $core->url->http["homepage"],
"download" => $core->url->http["download"],
"state" => tags::state_tag($core->name["state"]),
);
return $this->insert($project, $new);
}
/**
* POST release/version
* --------------------
*
* @auth-required
*
* Here the release body contains:
* β version
* β changelog
* β tag_list
* β hidden_from_frontpage
* We'd also accept:
* β state
* β download
*
*/
function publish($project) {
$rel = new input($this->body["release"], "rel");
// extract fields
$new = array(
"version" => $rel->text["version"],
"changes" => $rel->text["changelog"],
"scope" => tags::scope_tags($rel->text["tag_list"]),
"state" => tags::state_tag($rel->text["state"] . $rel->text["tag_list"]),
"download" => $rel->url->http["download"],
);
$flags = array(
"hidden" => $rel->int["hidden_from_frontpage"],
"deleted" => 0,
);
return $this->insert($project, $new, $flags);
}
/**
* Check for "pending" releases
* ----------------------------
*
* @unauthorized
*
* We don't have a pre-approval scheme on Freshcode currently,
* so this just returns a history of released versions.
*
* For the `id` we're just using the `t_published` timestamp.
* Thus a "withdraw" request could reference it.
*
*/
function version_GET($project) {
assert($this->rev === "pending");
// Query release revisions
$list = db("
SELECT name, version, t_published, MAX(t_changed) AS t_changed,
scope, hidden, changes
FROM release
WHERE name=?
GROUP BY version
LIMIT 10", $this->name
);
// Assemble oddly nested result array
$r = [];
foreach ($list as $row) {
$r[] = array("release" => array(
"id" => $row["t_published"],
"version" => $row["version"],
"tag_list" => explode(" ", $row["scope"]),
"hidden_from_frontpage" => (bool)$row["hidden"],
"approved_at" => gmdate(DateTime::ISO8601, $row["t_changed"]),
"created_at" => gmdate(DateTime::ISO8601, $row["t_published"]),
"changelog" => $row["changes"],
));
}
return $r;
}
/**
* "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, "t_published" => $this->rev]
]);
return $r ? array("success" => TRUE) : $this->error_exit(NULL);
}
/**
* URL editing
* -----------
*
* @auth-required
*
* Here we deviate from the overflowing Freecode API with individual
* URL manipulation. (PUT,POST,DELETE /projects/<name>/urls/targz.json)
* That's just double the work for client and API.
*
* Instead on freshcode there is but one associative URL blob for
* reading and updating all URLs at once.
*
* 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".)
*
* 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") {
$urls = p_key_value($project["urls"], NULL);
$urls["homepage"] = $project["homepage"];
$urls["download"] = $project["download"];
return array("urls" => $urls);
}
/**
* Updates may come as PUT, POST, PUSH request
*
* @auth-required
*
*/
else {
// Filter incoming URLs
$urls = new input($this->body["urls"], "urls");
$urls = $urls->list->url[$urls->keys()];
$new["urls"] = "";
foreach ($urls as $label => $url) {
// Remove non-alphanumeric characters
$label = trim(preg_replace("/\W+/", "-", $label), "-");
$lower = strtolower($label);
in_array($lower, ["home-page", "website"]) and $lower = "homepage";
// Split homepage, download URL into separate fields,
if ($lower == "homepage" or $lower == "download") {
$new[$lower] = $url;
}
// While remaining go into `urls` key=value block, retain case-sensitivity here
else {
$new["urls"] .= "$label = $url\r\n";
}
}
// Update DB
return $this->insert($project, $new);
}
}
/**
* 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
$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["autoupdate_url"],
$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) {
return $this->is_authorized($data)
? $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/Freecode+JSON+API");
}
/**
* 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 */");
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"]);
}
}
?>