* Description: Ouroboros-like API for Shimmie * Version: 0.2 * Documentation: * Currently working features * * Tested to work with CartonBox using "Danbooru 1.18.x" as site type. * Does not work with Andbooru or Danbooru Gallery for reasons beyond me, took me a while to figure rating "u" is bad... * Lots of Ouroboros/Danbooru specific values use their defaults (or what I gathered them to be default) * and tons of stuff not supported directly in Shimmie is botched to work */ class _SafeOuroborosImage { /** * Author */ /** * Post author * @var string */ public $author = ''; /** * Post author user ID * @var integer */ public $creator_id = null; /** * Image */ /** * Image height * @var integer */ public $height = null; /** * Image width * @var integer */ public $width = null; /** * File Size in bytes * @var integer */ public $file_size = null; /** * URL to the static file * @var string */ public $file_url = ''; /** * File MD5 hash * @var string */ public $md5 = ''; /** * Post Meta */ /** * (Unknown) Change * @var integer */ public $change = null; /** * Timestamp for post creation * @var integer */ public $created_at = null; /** * Post ID * @var integer */ public $id = null; /** * Parent post ID * @var integer */ public $parent_id = null; /** * Post content rating * @var string */ public $rating = 'q'; /** * Post score * @var integer */ public $score = 1; /** * Post source * @var string */ public $source = ''; /** * Post status * @var string */ public $status = ''; /** * Post tags * @var string */ public $tags = ''; /** * Flag if the post has child posts * @var bool */ public $has_children = false; /** * Flag if the post has comments * @var bool */ public $has_comments = false; /** * Flag if the post has notes * @var bool */ public $has_notes = false; /** * Post description * @var string */ public $description = ''; /** * Thumbnail */ /** * Thumbnail Height * @var integer */ public $preview_height = null; /** * Thumbnail URL * @var string */ public $preview_url = ''; /** * Thumbnail Width * @var integer */ public $preview_width = null; /** * Downscaled Image */ /** * Downscaled image height * @var integer */ public $sample_height = null; /** * Downscaled image * @var string */ public $sample_url = ''; /** * Downscaled image * @var integer */ public $sample_width = null; /** * Constructor * @param Image $img */ function __construct(Image $img) { global $config; // author $author = $img->get_owner(); $this->author = $author->name; $this->creator_id = intval($author->id); // file $this->height = intval($img->height); $this->width = intval($img->width); $this->file_ext = $img->ext; $this->file_size = intval($img->filesize); $this->file_url = make_http($img->get_image_link()); $this->md5 = $img->hash; // meta $this->change = intval($img->id); //DaFug is this even supposed to do? ChangeID? // Should be JSON specific, just strip this when converting to XML $this->created_at = array('n' => 123456789, 's' => $img->posted_timestamp, 'json_class' => 'Time'); $this->id = intval($img->id); $this->parent_id = null; if (defined('ENABLED_EXTS')) { if (strstr(ENABLED_EXTS, 'rating') !== false) { // 'u' is not a "valid" rating if ($img->rating == 's' || $img->rating == 'q' || $img->rating == 'e') { $this->rating = $img->rating; } } if (strstr(ENABLED_EXTS, 'numeric_score') !== false) { $this->score = $img->numeric_score; } } $this->source = $img->source; $this->status = 'active'; //not supported in Shimmie... yet $this->tags = $img->get_tag_list(); $this->has_children = false; $this->has_comments = false; $this->has_notes = false; // thumb $this->preview_height = $config->get_int('thumb_height'); $this->preview_width = $config->get_int('thumb_width'); $this->preview_url = make_http($img->get_thumb_link()); // sample (use the full image here) $this->sample_height = intval($img->height); $this->sample_width = intval($img->width); $this->sample_url = make_http($img->get_image_link()); } } class OuroborosPost extends _SafeOuroborosImage { /** * Multipart File * @var array */ public $file = array(); /** * Create with rating locked * @var bool */ public $is_rating_locked = false; /** * Create with notes locked * @var bool */ public $is_note_locked = false; /** * Initialize an OuroborosPost for creation * Mainly just acts as a wrapper and validation layer * @TODO implement more validation from OuroborosAPI * @param array $post */ public function __construct(array $post) { if (array_key_exists('tags', $post)) { $this->tags = $post['tags']; } if (array_key_exists('file', $post)) { if (!is_null($post['file'])) { assert(is_array($post['file'])); assert(array_key_exists('tmp_name', $post['file'])); assert(array_key_exists('name', $post['file'])); $this->file = $post['file']; } } if (array_key_exists('rating', $post)) { assert( $post['rating'] == 's' || $post['rating'] == 'q' || $post['rating'] == 'e' ); $this->rating = $post['rating']; } if (array_key_exists('source', $post)) { $this->file_url = $post['source']; } if (array_key_exists('sourceurl', $post)) { $this->source = $post['sourceurl']; } if (array_key_exists('description', $post)) { $this->description = $post['description']; } if (array_key_exists('is_rating_locked', $post)) { $this->is_rating_locked = $post['is_rating_locked']; } if (array_key_exists('is_note_locked', $post)) { $this->is_note_locked = $post['is_note_locked']; } if (array_key_exists('parent_id', $post)) { $this->parent_id = $post['parent_id']; } } } class _SafeOuroborosTag { public $ambiguous = false; public $count = 0; public $id = 0; public $name = ''; public $type = 0; function __construct(array $tag) { $this->count = $tag['count']; $this->id = $tag['id']; $this->name = $tag['tag']; } } class OuroborosAPI extends Extension { private $event; private $type; const HEADER_HTTP_200 = 'OK'; const MSG_HTTP_200 = 'Request was successful'; const HEADER_HTTP_403 = 'Forbidden'; const MSG_HTTP_403 = 'Access denied'; const HEADER_HTTP_404 = 'Not found'; const MSG_HTTP_404 = 'Not found'; const HEADER_HTTP_418 = 'I\'m a teapot'; const MSG_HTTP_418 = 'Short and stout'; const HEADER_HTTP_420 = 'Invalid Record'; const MSG_HTTP_420 = 'Record could not be saved'; const HEADER_HTTP_421 = 'User Throttled'; const MSG_HTTP_421 = 'User is throttled, try again later'; const HEADER_HTTP_422 = 'Locked'; const MSG_HTTP_422 = 'The resource is locked and cannot be modified'; const HEADER_HTTP_423 = 'Already Exists'; const MSG_HTTP_423 = 'Resource already exists'; const HEADER_HTTP_424 = 'Invalid Parameters'; const MSG_HTTP_424 = 'The given parameters were invalid'; const HEADER_HTTP_500 = 'Internal Server Error'; const MSG_HTTP_500 = 'Some unknown error occurred on the server'; const HEADER_HTTP_503 = 'Service Unavailable'; const MSG_HTTP_503 = 'Server cannot currently handle the request, try again later'; const ERROR_POST_CREATE_MD5 = 'MD5 mismatch'; const ERROR_POST_CREATE_DUPE = 'Duplicate'; public function onPageRequest(PageRequestEvent $event) { global $database, $page, $config, $user; if (preg_match("%\.(xml|json)$%", implode('/', $event->args), $matches) === 1) { $this->event = $event; $this->type = $matches[1]; if ($this->type == 'json') { $page->set_type('application/json; charset=utf-8'); } elseif ($this->type == 'xml') { $page->set_type('text/xml; charset=utf-8'); } $page->set_mode('data'); $this->tryAuth(); if ($event->page_matches('post')) { if ($this->match('create')) { // Create // @TODO Should move the validation logic into OuroborosPost instead? if ($user->can("create_image")) { $post = array( 'tags' => !empty($_REQUEST['post']['tags']) ? filter_var( urldecode($_REQUEST['post']['tags']), FILTER_SANITIZE_STRING ) : 'tagme', 'file' => !empty($_REQUEST['post']['file']) ? filter_var( $_REQUEST['post']['file'], FILTER_UNSAFE_RAW ) : null, 'rating' => !empty($_REQUEST['post']['rating']) ? filter_var( $_REQUEST['post']['rating'], FILTER_SANITIZE_NUMBER_INT ) : 'q', 'source' => !empty($_REQUEST['post']['source']) ? filter_var( urldecode($_REQUEST['post']['source']), FILTER_SANITIZE_URL ) : null, 'sourceurl' => !empty($_REQUEST['post']['sourceurl']) ? filter_var( urldecode($_REQUEST['post']['sourceurl']), FILTER_SANITIZE_URL ) : '', 'description' => !empty($_REQUEST['post']['description']) ? filter_var( $_REQUEST['post']['description'], FILTER_SANITIZE_STRING ) : '', 'is_rating_locked' => !empty($_REQUEST['post']['is_rating_locked']) ? filter_var( $_REQUEST['post']['is_rating_locked'], FILTER_SANITIZE_NUMBER_INT ) : false, 'is_note_locked' => !empty($_REQUEST['post']['is_note_locked']) ? filter_var( $_REQUEST['post']['is_note_locked'], FILTER_SANITIZE_NUMBER_INT ) : false, 'parent_id' => !empty($_REQUEST['post']['parent_id']) ? filter_var( $_REQUEST['post']['parent_id'], FILTER_SANITIZE_NUMBER_INT ) : null, ); $md5 = !empty($_REQUEST['md5']) ? filter_var($_REQUEST['md5'], FILTER_SANITIZE_STRING) : null; $this->postCreate(new OuroborosPost($post), $md5); } else { $this->sendResponse(403, 'You cannot create new posts'); } } elseif ($this->match('update')) { // Update //@todo add post update } elseif ($this->match('show')) { // Show $id = !empty($_REQUEST['id']) ? filter_var($_REQUEST['id'], FILTER_SANITIZE_NUMBER_INT) : null; $this->postShow($id); } elseif ($this->match('index') || $this->match('list')) { // List $limit = !empty($_REQUEST['limit']) ? intval( filter_var($_REQUEST['limit'], FILTER_SANITIZE_NUMBER_INT) ) : 45; $p = !empty($_REQUEST['page']) ? intval( filter_var($_REQUEST['page'], FILTER_SANITIZE_NUMBER_INT) ) : 1; $tags = !empty($_REQUEST['tags']) ? filter_var($_REQUEST['tags'], FILTER_SANITIZE_STRING) : array(); if (!empty($tags)) { $tags = Tag::explode($tags); } $this->postIndex($limit, $p, $tags); } } elseif ($event->page_matches('tag')) { if ($this->match('index') || $this->match('list')) { $limit = !empty($_REQUEST['limit']) ? intval( filter_var($_REQUEST['limit'], FILTER_SANITIZE_NUMBER_INT) ) : 50; $p = !empty($_REQUEST['page']) ? intval( filter_var($_REQUEST['page'], FILTER_SANITIZE_NUMBER_INT) ) : 1; $order = (!empty($_REQUEST['order']) && ($_REQUEST['order'] == 'date' || $_REQUEST['order'] == 'count' || $_REQUEST['order'] == 'name')) ? filter_var( $_REQUEST['order'], FILTER_SANITIZE_STRING ) : 'date'; $id = !empty($_REQUEST['id']) ? intval( filter_var($_REQUEST['id'], FILTER_SANITIZE_NUMBER_INT) ) : null; $after_id = !empty($_REQUEST['after_id']) ? intval( filter_var($_REQUEST['after_id'], FILTER_SANITIZE_NUMBER_INT) ) : null; $name = !empty($_REQUEST['name']) ? filter_var($_REQUEST['name'], FILTER_SANITIZE_STRING) : ''; $name_pattern = !empty($_REQUEST['name_pattern']) ? filter_var( $_REQUEST['name_pattern'], FILTER_SANITIZE_STRING ) : ''; $this->tagIndex($limit, $p, $order, $id, $after_id, $name, $name_pattern); } } } elseif ($event->page_matches('post/show')) { $page->set_mode('redirect'); $page->set_redirect(make_link(str_replace('post/show', 'post/view', implode('/', $event->args)))); $page->display(); die(); } } /** * Post */ /** * Wrapper for post creation * @param OuroborosPost $post * @param string $md5 */ protected function postCreate(OuroborosPost $post, $md5 = '') { global $page, $config, $user; if (!empty($md5)) { $img = Image::by_hash($md5); if (!is_null($img)) { $this->sendResponse(420, self::ERROR_POST_CREATE_DUPE); return; } } $meta = array(); $meta['tags'] = $post->tags; $meta['source'] = $post->source; if (defined('ENABLED_EXTS')) { if (strstr(ENABLED_EXTS, 'rating') !== false) { $meta['rating'] = $post->rating; } } // Check where we should try for the file if (empty($post->file) && !empty($post->file_url) && filter_var( $post->file_url, FILTER_VALIDATE_URL ) !== false ) { // Transload from source $meta['file'] = tempnam('/tmp', 'shimmie_transload_' . $config->get_string('transload_engine')); $meta['filename'] = basename($post->file_url); if (!transload($post->file_url, $meta['file'])) { $this->sendResponse(500, 'Transloading failed'); return; } $meta['hash'] = md5_file($meta['file']); } else { // Use file $meta['file'] = $post->file['tmp_name']; $meta['filename'] = $post->file['name']; $meta['hash'] = md5_file($meta['file']); } if (!empty($md5) && $md5 !== $meta['hash']) { $this->sendResponse(420, self::ERROR_POST_CREATE_MD5); return; } if (!empty($meta['hash'])) { $img = Image::by_hash($meta['hash']); if (!is_null($img)) { $this->sendResponse(420, self::ERROR_POST_CREATE_DUPE); return; } } $meta['extension'] = pathinfo($meta['filename'], PATHINFO_EXTENSION); try { $upload = new DataUploadEvent($meta['file'], $meta); send_event($upload); $image = Image::by_hash($meta['hash']); if (!is_null($image)) { $this->sendResponse(200, make_link('post/view/' . $image->id), true); return; } else { // Fail, unsupported file? $this->sendResponse(500, 'Unknown error'); return; } } catch (UploadException $e) { // Cleanup in case shit hit the fan $this->sendResponse(500, $e->getMessage()); return; } } /** * Wrapper for getting a single post * @param int $id */ protected function postShow($id = null) { if (!is_null($id)) { $post = new _SafeOuroborosImage(Image::by_id($id)); $this->sendData('post', $post); } else { $this->sendResponse(424, 'ID is mandatory'); } } /** * Wrapper for getting a list of posts * @param $limit * @param $page * @param $tags */ protected function postIndex($limit, $page, $tags) { $start = ($page - 1) * $limit; $results = Image::find_images(max($start, 0), min($limit, 100), $tags); $posts = array(); foreach ($results as $img) { if (!is_object($img)) { continue; } $posts[] = new _SafeOuroborosImage($img); } $this->sendData('post', $posts, max($start, 0)); } /** * Tag */ /** * Wrapper for getting a list of tags * @param $limit * @param $page * @param $order * @param $id * @param $after_id * @param $name * @param $name_pattern */ protected function tagIndex($limit, $page, $order, $id, $after_id, $name, $name_pattern) { global $database, $config; $start = ($page - 1) * $limit; $tag_data = array(); switch ($order) { case 'name': $tag_data = $database->get_col( $database->scoreql_to_sql( " SELECT DISTINCT id, SCORE_STRNORM(substr(tag, 1, 1)), count FROM tags WHERE count >= :tags_min ORDER BY SCORE_STRNORM(substr(tag, 1, 1)) LIMIT :start, :max_items " ), array('tags_min' => $config->get_int('tags_min'), 'start' => $start, 'max_items' => $limit) ); break; case 'count': $tag_data = $database->get_all( " SELECT id, tag, count FROM tags WHERE count >= :tags_min ORDER BY count DESC, tag ASC LIMIT :start, :max_items ", array('tags_min' => $config->get_int('tags_min'), 'start' => $start, 'max_items' => $limit) ); break; case 'date': $tag_data = $database->get_all( " SELECT id, tag, count FROM tags WHERE count >= :tags_min ORDER BY count DESC, tag ASC LIMIT :start, :max_items ", array('tags_min' => $config->get_int('tags_min'), 'start' => $start, 'max_items' => $limit) ); break; } $tags = array(); foreach ($tag_data as $tag) { if (!is_array($tag)) { continue; } $tags[] = new _SafeOuroborosTag($tag); } $this->sendData('tag', $tags, $start); } /** * Utility methods */ /** * Sends a simple {success,reason} message to browser * * @param int $code HTTP equivalent code for the message * @param string $reason Reason for the code * @param bool $location Is $reason a location? (used mainly for post/create) */ private function sendResponse($code = 200, $reason = '', $location = false) { global $page; if ($code == 200) { $success = true; } else { $success = false; } if (empty($reason)) { if (defined("self::MSG_HTTP_{$code}")) { $reason = constant("self::MSG_HTTP_{$code}"); } else { $reason = self::MSG_HTTP_418; } } if ($code != 200) { $proto = $_SERVER['SERVER_PROTOCOL']; if (defined("self::HEADER_HTTP_{$code}")) { $header = constant("self::HEADER_HTTP_{$code}"); } else { // I'm a teapot! $code = 418; $header = self::HEADER_HTTP_418; } header("{$proto} {$code} {$header}", true); } $response = array('success' => $success, 'reason' => $reason); if ($this->type == 'json') { if ($location !== false) { $response['location'] = $response['reason']; unset($response['reason']); } $response = json_encode($response); } elseif ($this->type == 'xml') { // Seriously, XML sucks... $xml = new XMLWriter(); $xml->openMemory(); $xml->startDocument('1.0', 'utf-8'); $xml->startElement('response'); $xml->writeAttribute('success', var_export($success, true)); if ($location !== false) { $xml->writeAttribute('location', $reason); } else { $xml->writeAttribute('reason', $reason); } $xml->endElement(); $xml->endDocument(); $response = $xml->outputMemory(true); unset($xml); } $page->set_data($response); } /** * Send data to the browser * @param string $type * @param mixed $data * @param int $offset */ private function sendData($type = '', $data = array(), $offset = 0) { global $page; $response = ''; if ($this->type == 'json') { $response = json_encode($data); } elseif ($this->type == 'xml') { $xml = new XMLWriter(); $xml->openMemory(); $xml->startDocument('1.0', 'utf-8'); if (array_key_exists(0, $data)) { $xml->startElement($type . 's'); if ($type == 'post') { $xml->writeAttribute('count', count($data)); $xml->writeAttribute('offset', $offset); } if ($type == 'tag') { $xml->writeAttribute('type', 'array'); } foreach ($data as $item) { $this->createItemXML($xml, $type, $item); } $xml->endElement(); } else { $this->createItemXML($xml, $type, $data); } $xml->endDocument(); $response = $xml->outputMemory(true); unset($xml); } $page->set_data($response); } private function createItemXML(XMLWriter &$xml, $type, $item) { $xml->startElement($type); foreach ($item as $key => $val) { if ($key == 'created_at' && $type == 'post') { $xml->writeAttribute($key, $val['s']); } else { if (is_bool($val)) { $val = $val ? 'true' : 'false'; } $xml->writeAttribute($key, $val); } } $xml->endElement(); } /** * Try to figure who is uploading * * Currently checks for either user & session in request or cookies * and initializes a global User * @param void * @return void */ private function tryAuth() { global $config, $user; if (isset($_REQUEST['user']) && isset($_REQUEST['session'])) { //Auth by session data from query $name = $_REQUEST['user']; $session = $_REQUEST['session']; $duser = User::by_session($name, $session); if (!is_null($duser)) { $user = $duser; } else { $user = User::by_id($config->get_int("anon_id", 0)); } } elseif (isset($_COOKIE[$config->get_string('cookie_prefix', 'shm') . '_' . 'session']) && isset($_COOKIE[$config->get_string('cookie_prefix', 'shm') . '_' . 'user']) ) { //Auth by session data from cookies $session = $_COOKIE[$config->get_string('cookie_prefix', 'shm') . '_' . 'session']; $user = $_COOKIE[$config->get_string('cookie_prefix', 'shm') . '_' . 'user']; $duser = User::by_session($user, $session); if (!is_null($duser)) { $user = $duser; } else { $user = User::by_id($config->get_int("anon_id", 0)); } } } /** * Helper for matching API methods from event * @param $page * @return bool */ private function match($page) { return (preg_match("%{$page}\.(xml|json)$%", implode('/', $this->event->args), $matches) === 1); } }