<?php
/*
Name: Danbooru Client API
Author: JJS <jsutinen@gmail.com>
Description: Allow Danbooru apps like Danbooru Uploader for Firefox to communicate with Shimmie
Documentation:
 <p>Notes:
 <br>danbooru API based on documentation from danbooru 1.0 -
 http://attachr.com/7569
 <br>I've only been able to test add_post and find_tags because I use the
 old danbooru firefox extension for firefox 1.5
 <p>Functions currently implemented:
 <ul>
 <li>add_post - title and rating are currently ignored because shimmie does not support them
 <li>find_posts - sort of works, filename is returned as the original filename and probably won't help when it comes to actually downloading it
 <li>find_tags - id, name, and after_id all work but the tags parameter is ignored just like danbooru 1.0 ignores it
 </ul>

CHANGELOG
13-OCT-08 8:00PM CST - JJS
Bugfix - Properly escape source attribute

17-SEP-08 10:00PM CST - JJS
Bugfix for changed page name checker in PageRequestEvent

13-APR-08 10:00PM CST - JJS
Properly escape the tags returned in find_tags and find_posts - Caught by ATravelingGeek
Updated extension info to be a bit more clear about its purpose
Deleted add_comment code as it didn't do anything anyway

01-MAR-08 7:00PM CST - JJS
Rewrote to make it compatible with Shimmie trunk again (r723 at least)
It may or may not support the new file handling stuff correctly, I'm only testing with images and the danbooru uploader for firefox

21-OCT-07 9:07PM CST - JJS
Turns out I actually did need to implement the new parameter names
for danbooru api v1.8.1. Now danbooruup should work when used with /api/danbooru/post/create.xml
Also correctly redirects the url provided by danbooruup in the event
of a duplicate image.

19-OCT-07 4:46PM CST - JJS
Add compatibility with danbooru api v1.8.1 style urls
for find_posts and add_post. NOTE: This does not implement
the changes to the parameter names, it is simply a
workaround for the latest danbooruup firefox extension.
Completely compatibility will probably involve a rewrite with a different URL

*/

class DanbooruApi implements Extension
{
	// Receive the event
	public function receive_event(Event $event)
	{
		// Check if someone is accessing /api/danbooru (us)
		if(($event instanceof PageRequestEvent) && ($event->page_matches("api")) && ($event->get_arg(0) == 'danbooru'))
		{
			// execute the danbooru processing code
			$this->api_danbooru($event);
		}
		if($event instanceof SearchTermParseEvent)
		{
			$matches = array();
			if(preg_match("/^md5:([0-9a-fA-F]*)$/i", $event->term, $matches))
			{
				$hash = strtolower($matches[1]);
				$event->set_querylet(new Querylet("images.hash = '$hash'"));
			}
		}
	}

	// Danbooru API
	private function api_danbooru($event)
	{
		global $page;
		global $config;
		global $database;
		global $user;
		$page->set_mode("data");
		$page->set_type("application/xml");
		//debug
		//$page->set_type("text/plain");

		$results = array();

		/*
		add_post()
		Adds a post to the database.
		Parameters
		* login: login
		* password: password
		* file: file as a multipart form
		* source: source url
		* title: title **IGNORED**
		* tags: list of tags as a string, delimited by whitespace
		* md5: MD5 hash of upload in hexadecimal format
		* rating: rating of the post. can be explicit, questionable, or safe. **IGNORED**
		Notes
		* The only necessary parameter is tags and either file or source.
		* If you want to sign your post, you need a way to authenticate your account, either by supplying login and password, or by supplying a cookie.
		* If an account is not supplied or if it doesn‘t authenticate, he post will be added anonymously.
		* If the md5 parameter is supplied and does not match the hash of what‘s on the server, the post is rejected.
		Response
		The response depends on the method used:
		Post
		* X-Danbooru-Location set to the URL for newly uploaded post.
		Get
		* Redirected to the newly uploaded post.
		*/
		if(($event->get_arg(1) == 'add_post') || (($event->get_arg(1) == 'post') && ($event->get_arg(2) == 'create.xml')))
		{
			// No XML data is returned from this function
			$page->set_type("text/plain");
			// Check first if a login was supplied, if it wasn't check if the user is logged in via cookie
			// If all that fails, it's an anonymous upload
			$this->authenticate_user();
			// Now we check if a file was uploaded or a url was provided to transload
			// Much of this code is borrowed from /ext/upload
			if($config->get_bool("upload_anon") || !$user->is_anonymous())
			{
				$file = null;
				$filename = "";
				$source = "";
				if(isset($_FILES['file']))
				{	// A file was POST'd in
					$file = $_FILES['file']['tmp_name'];
					$filename = $_FILES['file']['name'];
					// If both a file is posted and a source provided, I'm assuming source is the source of the file
					if(isset($_REQUEST['source']) && !empty($_REQUEST['source']))
					{
						$source = $_REQUEST['source'];
					} else
					{
						$source = null;
					}
				} elseif(isset($_FILES['post']))
				{
					$file = $_FILES['post']['tmp_name']['file'];
					$filename = $_FILES['post']['name']['file'];
					if(isset($_REQUEST['post']['source']) && !empty($_REQUEST['post']['source']))
					{
						$source = $_REQUEST['post']['source'];
					} else
					{
						$source = null;
					}
				} elseif(isset($_REQUEST['source']) || isset($_REQUEST['post']['source']))
				{	// A url was provided
					$url = isset($_REQUEST['source']) ? $_REQUEST['source'] : $_REQUEST['post']['source'];
					$source = $url;
					$tmp_filename = tempnam("/tmp", "shimmie_transload");

					// Are we using fopen wrappers or curl?
					if($config->get_string("transload_engine") == "fopen")
					{
						$fp = fopen($url, "r");
						if(!$fp) {
							header("HTTP/1.0 409 Conflict");
							header("X-Danbooru-Errors: fopen read error");
						}

						$data = "";
						$length = 0;
						while(!feof($fp) && $length <= $config->get_int('upload_size'))
						{
							$data .= fread($fp, 8192);
							$length = strlen($data);
						}
						fclose($fp);

						$fp = fopen($tmp_filename, "w");
						fwrite($fp, $data);
						fclose($fp);
					}

					if($config->get_string("transload_engine") == "curl")
					{
						$ch = curl_init($url);
						$fp = fopen($tmp_filename, "w");

						curl_setopt($ch, CURLOPT_FILE, $fp);
						curl_setopt($ch, CURLOPT_HEADER, 0);

						curl_exec($ch);
						curl_close($ch);
						fclose($fp);
					}
					$file = $tmp_filename;
					$filename = basename($url);
				} else
				{	// Nothing was specified at all
					header("HTTP/1.0 409 Conflict");
					header("X-Danbooru-Errors: no input files");
					return;
				}

				// Get tags out of url
				$posttags = Tag::explode(isset($_REQUEST['tags']) ? $_REQUEST['tags'] : $_REQUEST['post']['tags']);
				$hash = md5_file($file);
				// Was an md5 supplied? Does it match the file hash?
				if(isset($_REQUEST['md5']))
				{
					if(strtolower($_REQUEST['md5']) != $hash)
					{
						header("HTTP/1.0 409 Conflict");
						header("X-Danbooru-Errors: md5 mismatch");
						return;
					}
				}
				// Upload size checking is now performed in the upload extension
				// It is also currently broken due to some confusion over file variable ($tmp_filename?)

				// Does it exist already?
				$existing = Image::by_hash($hash);
				if(!is_null($existing)) {
					header("HTTP/1.0 409 Conflict");
					header("X-Danbooru-Errors: duplicate");
					$existinglink = make_link("post/view/" . $existing->id);
					header("X-Danbooru-Location: $existinglink");
				}

				// Fire off an event which should process the new file and add it to the db
				$fileinfo = pathinfo($filename);
				$metadata['filename'] = $fileinfo['basename'];
				$metadata['extension'] = $fileinfo['extension'];
				$metadata['tags'] = $posttags;
				$metadata['source'] = $source;

				try {
					$nevent = new DataUploadEvent($user, $file, $metadata);
					send_event($nevent);
					// If it went ok, grab the id for the newly uploaded image and pass it in the header
					$newimg = Image::by_hash($hash);
					$newid = make_link("post/view/" . $newimg->id);
					// Did we POST or GET this call?
					if($_SERVER['REQUEST_METHOD'] == 'POST')
					{
						header("X-Danbooru-Location: $newid");
					}
					else
					header("Location: $newid");
				}
				catch(UploadException $ex) {
					// Did something screw up?
					header("HTTP/1.0 409 Conflict");
					header("X-Danbooru-Errors: ". $ex->getMessage());
					return;
				}
			} else
			{
				header("HTTP/1.0 409 Conflict");
				header("X-Danbooru-Errors: authentication error");
				return;
			}
		}

		/*
		find_posts()
		Find all posts that match the search criteria. Posts will be ordered by id descending.
		Parameters
		* md5: md5 hash to search for (comma delimited)
		* id: id to search for (comma delimited)
		* tags: what tags to search for
		* limit: limit
		* offset: offset
		* after_id: limit results to posts added after this id
		*/
		if(($event->get_arg(1) == 'find_posts') || (($event->get_arg(1) == 'post') && ($event->get_arg(2) == 'index.xml')))
		{
			$this->authenticate_user();
			if(isset($_GET['md5']))
			{
				$md5list = explode(",",$_GET['md5']);
				foreach($md5list as $md5)
				{
					$results[] = Image::by_hash($md5);
				}
			} elseif(isset($_GET['id']))
			{
				$idlist = explode(",",$_GET['id']);
				foreach($idlist as $id)
				{
					$results[] = Image::by_id($id);
				}
			} else
			{
				$limit = isset($_GET['limit']) ? int_escape($_GET['limit']) : 100;
				$start = isset($_GET['offset']) ? int_escape($_GET['offset']) : 0;
				$tags = isset($_GET['tags']) ? Tag::explode($_GET['tags']) : array();
				$results = Image::find_images($start, $limit, $tags);
			}

			// Now we have the array $results filled with Image objects
			// Let's display them
			$xml = "<posts>\n";
			foreach($results as $img)
			{
				// Sanity check to see if $img is really an image object
				// If it isn't (e.g. someone requested an invalid md5 or id), break out of the this
				if(!is_object($img))
					continue;
				$taglist = $img->get_tag_list();
				$owner = $img->get_owner();
				$xml .= "<post md5=\"$img->hash\" rating=\"Questionable\" date=\"$img->posted\" is_warehoused=\"false\" file_name=\"$img->filename\" tags=\"" . $this->xmlspecialchars($taglist) . "\" source=\"" . $this->xmlspecialchars($img->source) . "\" score=\"0\" id=\"$img->id\" author=\"$owner->name\"/>\n";
			}
			$xml .= "</posts>";
			$page->set_data($xml);
		}

		/*
		find_tags() Find all tags that match the search criteria.
		Parameters
		* id: A comma delimited list of tag id numbers.
		* name: A comma delimited list of tag names.
		* tags: any typical tag query. See Tag#parse_query for details.
		* after_id: limit results to tags with an id number after after_id. Useful if you only want to refresh
		*/
		if($event->get_arg(1) == 'find_tags')
		{
			if(isset($_GET['id']))
			{
				$idlist = explode(",",$_GET['id']);
				foreach($idlist as $id)
				{
					$sqlresult = $database->execute("SELECT id,tag,count FROM tags WHERE id = ?", array($id));
					if(!$sqlresult->EOF)
					{
						$results[] = array($sqlresult->fields['count'], $sqlresult->fields['tag'], $sqlresult->fields['id']);
					}
				}
			} elseif(isset($_GET['name']))
			{
				$namelist = explode(",",$_GET['name']);
				foreach($namelist as $name)
				{
					$sqlresult = $database->execute("SELECT id,tag,count FROM tags WHERE tag = ?", array($name));
					if(!$sqlresult->EOF)
					{
						$results[] = array($sqlresult->fields['count'], $sqlresult->fields['tag'], $sqlresult->fields['id']);
					}
				}
			}
			/* Currently disabled to maintain identical functionality to danbooru 1.0's own "broken" find_tags
			elseif(isset($_GET['tags']))
			{
				$start = isset($_GET['after_id']) ? int_escape($_GET['offset']) : 0;
				$tags = Tag::explode($_GET['tags']);

			}
			*/
			else
			{
				$start = isset($_GET['after_id']) ? int_escape($_GET['offset']) : 0;
				$sqlresult = $database->execute("SELECT id,tag,count FROM tags WHERE count > 0 AND id >= ? ORDER BY id DESC",array($start));
				while(!$sqlresult->EOF)
				{
					$results[] = array($sqlresult->fields['count'], $sqlresult->fields['tag'], $sqlresult->fields['id']);
					$sqlresult->MoveNext();
				}
			}

			// Tag results collected, build XML output
			$xml = "<tags>\n";
			foreach($results as $tag)
			{
				$xml .= "<tag type=\"0\" count=\"$tag[0]\" name=\"" . $this->xmlspecialchars($tag[1]) . "\" id=\"$tag[2]\"/>\n";
			}
			$xml .= "</tags>";
			$page->set_data($xml);
		}

		// Hackery for danbooruup 0.3.2 providing the wrong view url. This simply redirects to the proper
		// Shimmie view page
		// Example: danbooruup says the url is http://shimmie/api/danbooru/post/show/123
		// This redirects that to http://shimmie/post/view/123
		if(($event->get_arg(1) == 'post') && ($event->get_arg(2) == 'show'))
		{
			$fixedlocation = make_link("post/view/" . $event->get_arg(3));
			header("Location: $fixedlocation");
		}
	}

	// Turns out I use this a couple times so let's make it a utility function
	// Authenticates a user based on the contents of the login and password parameters
	// or makes them anonymous. Does not set any cookies or anything permanent.
	private function authenticate_user()
	{
		global $config;
		global $database;
		global $user;

		if(isset($_REQUEST['login']) && isset($_REQUEST['password']))
		{
			// Get this user from the db, if it fails the user becomes anonymous
			// Code borrowed from /ext/user
			$name = $_REQUEST['login'];
			$pass = $_REQUEST['password'];
			$hash = md5( strtolower($name) . $pass );
			$duser = User::by_name_and_hash($name, $hash);
			if(!is_null($duser)) {
				$user = $duser;
			} else
			{
				$user = User::by_id($config->get_int("anon_id", 0));
			}
		}
	}

	// From htmlspecialchars man page on php.net comments
	// If tags contain quotes they need to be htmlified
	private function xmlspecialchars($text)
	{
		return str_replace('&#039;', '&apos;', htmlspecialchars($text, ENT_QUOTES));
	}
}

add_event_listener(new DanbooruApi());
?>
