<?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]);
}
}
?>