$value) { // some databases use table.name rather than name $name = str_replace("images.", "", $name); $this->$name = $value; // hax, this is likely the cause of much scrutinizer-ci complaints. } $this->locked = bool_escape($this->locked); assert(is_numeric($this->id)); assert(is_numeric($this->height)); assert(is_numeric($this->width)); } } public static function by_id(int $id) { global $database; $row = $database->get_row("SELECT * FROM images WHERE images.id=:id", array("id"=>$id)); return ($row ? new Image($row) : null); } public static function by_hash(string $hash) { global $database; $row = $database->get_row("SELECT images.* FROM images WHERE hash=:hash", array("hash"=>$hash)); return ($row ? new Image($row) : null); } public static function by_random(array $tags=array()) { $max = Image::count_images($tags); if ($max < 1) return null; // From Issue #22 - opened by HungryFeline on May 30, 2011. $rand = mt_rand(0, $max-1); $set = Image::find_images($rand, 1, $tags); if(count($set) > 0) return $set[0]; else return null; } /** * Search for an array of images * * @param int $start * @param int $limit * @param string[] $tags * @throws SCoreException * @return Image[] */ public static function find_images(int $start, int $limit, array $tags=array()): array { global $database, $user, $config; $images = array(); if($start < 0) $start = 0; if($limit < 1) $limit = 1; if(SPEED_HAX) { if(!$user->can("big_search") and count($tags) > 3) { throw new SCoreException("Anonymous users may only search for up to 3 tags at a time"); } } $result = null; if(SEARCH_ACCEL) { $result = Image::get_accelerated_result($tags, $start, $limit); } if(!$result) { $querylet = Image::build_search_querylet($tags); $querylet->append(new Querylet(" ORDER BY ".(Image::$order_sql ?: "images.".$config->get_string("index_order")))); $querylet->append(new Querylet(" LIMIT :limit OFFSET :offset", array("limit"=>$limit, "offset"=>$start))); #var_dump($querylet->sql); var_dump($querylet->variables); $result = $database->execute($querylet->sql, $querylet->variables); } while($row = $result->fetch()) { $images[] = new Image($row); } Image::$order_sql = null; return $images; } /* * Accelerator stuff */ public static function get_acceleratable(array $tags) { $ret = array( "yays" => array(), "nays" => array(), ); $yays = 0; $nays = 0; foreach($tags as $tag) { if(!preg_match("/^-?[a-zA-Z0-9_-]+$/", $tag)) { return false; } if($tag[0] == "-") {$nays++; $ret["nays"][] = substr($tag, 1);} else {$yays++; $ret["yays"][] = $tag;} } if($yays > 1 || $nays > 0) { return $ret; } return false; } public static function get_accelerated_result(array $tags, int $offset, int $limit) { global $database; $req = Image::get_acceleratable($tags); if(!$req) {return null;} $req["offset"] = $offset; $req["limit"] = $limit; $response = Image::query_accelerator($req); $list = implode(",", $response); if($list) { $result = $database->execute("SELECT * FROM images WHERE id IN ($list) ORDER BY images.id DESC"); } else { $result = $database->execute("SELECT * FROM images WHERE 1=0 ORDER BY images.id DESC"); } return $result; } public static function get_accelerated_count(array $tags) { $req = Image::get_acceleratable($tags); if(!$req) {return null;} $req["count"] = true; return Image::query_accelerator($req); } public static function query_accelerator($req) { $fp = @fsockopen("127.0.0.1", 21212); if (!$fp) { return null; } fwrite($fp, json_encode($req)); $data = ""; while (($buffer = fgets($fp, 4096)) !== false) { $data .= $buffer; } if (!feof($fp)) { die("Error: unexpected fgets() fail in query_accelerator($req)\n"); } fclose($fp); return json_decode($data); } /* * Image-related utility functions */ /** * Count the number of image results for a given search * * @param string[] $tags * @return int */ public static function count_images(array $tags=array()): int { global $database; $tag_count = count($tags); if($tag_count === 0) { $total = $database->cache->get("image-count"); if(!$total) { $total = $database->get_one("SELECT COUNT(*) FROM images"); $database->cache->set("image-count", $total, 600); } } else if($tag_count === 1 && !preg_match("/[:=><\*\?]/", $tags[0])) { $total = $database->get_one( $database->scoreql_to_sql("SELECT count FROM tags WHERE SCORE_STRNORM(tag) = SCORE_STRNORM(:tag)"), array("tag"=>$tags[0])); } else { $total = Image::get_accelerated_count($tags); if(is_null($total)) { $querylet = Image::build_search_querylet($tags); $total = $database->get_one("SELECT COUNT(*) AS cnt FROM ($querylet->sql) AS tbl", $querylet->variables); } } if(is_null($total)) return 0; return $total; } /** * Count the number of pages for a given search * * @param string[] $tags * @return float */ public static function count_pages(array $tags=array()): float { global $config; return ceil(Image::count_images($tags) / $config->get_int('index_images')); } /* * Accessors & mutators */ /** * Find the next image in the sequence. * * Rather than simply $this_id + 1, one must take into account * deleted images and search queries * * @param string[] $tags * @param bool $next * @return Image */ public function get_next(array $tags=array(), bool $next=true) { global $database; if($next) { $gtlt = "<"; $dir = "DESC"; } else { $gtlt = ">"; $dir = "ASC"; } if(count($tags) === 0) { $row = $database->get_row(' SELECT images.* FROM images WHERE images.id '.$gtlt.' '.$this->id.' ORDER BY images.id '.$dir.' LIMIT 1 '); } else { $tags[] = 'id'. $gtlt . $this->id; $querylet = Image::build_search_querylet($tags); $querylet->append_sql(' ORDER BY images.id '.$dir.' LIMIT 1'); $row = $database->get_row($querylet->sql, $querylet->variables); } return ($row ? new Image($row) : null); } /** * The reverse of get_next * * @param string[] $tags * @return Image */ public function get_prev(array $tags=array()) { return $this->get_next($tags, false); } /** * Find the User who owns this Image * * @return User */ public function get_owner() { return User::by_id($this->owner_id); } /** * Set the image's owner. * * @param User $owner */ public function set_owner(User $owner) { global $database; if($owner->id != $this->owner_id) { $database->execute(" UPDATE images SET owner_id=:owner_id WHERE id=:id ", array("owner_id"=>$owner->id, "id"=>$this->id)); log_info("core_image", "Owner for Image #{$this->id} set to {$owner->name}", false, array("image_id" => $this->id)); } } /** * Get this image's tags as an array. * * @return string[] */ public function get_tag_array() { global $database; if(!isset($this->tag_array)) { $this->tag_array = $database->get_col(" SELECT tag FROM image_tags JOIN tags ON image_tags.tag_id = tags.id WHERE image_id=:id ORDER BY tag ", array("id"=>$this->id)); } return $this->tag_array; } /** * Get this image's tags as a string. * * @return string */ public function get_tag_list() { return Tag::implode($this->get_tag_array()); } /** * Get the URL for the full size image * * @return string */ public function get_image_link() { return $this->get_link('image_ilink', '_images/$hash/$id%20-%20$tags.$ext', 'image/$id.$ext'); } /** * Get the URL for the thumbnail * * @return string */ public function get_thumb_link() { return $this->get_link('image_tlink', '_thumbs/$hash/thumb.jpg', 'thumb/$id.jpg'); } /** * Check configured template for a link, then try nice URL, then plain URL * * @param string $template * @param string $nice * @param string $plain * @return string */ private function get_link($template, $nice, $plain) { global $config; $image_link = $config->get_string($template); if(!empty($image_link)) { if(!(strpos($image_link, "://") > 0) && !startsWith($image_link, "/")) { $image_link = make_link($image_link); } return $this->parse_link_template($image_link); } else if($config->get_bool('nice_urls', false)) { return $this->parse_link_template(make_link($nice)); } else { return $this->parse_link_template(make_link($plain)); } } /** * Get the tooltip for this image, formatted according to the * configured template. * * @return string */ public function get_tooltip() { global $config; $tt = $this->parse_link_template($config->get_string('image_tip'), "no_escape"); // Removes the size tag if the file is an mp3 if($this->ext === 'mp3'){ $iitip = $tt; $mp3tip = array("0x0"); $h_tip = str_replace($mp3tip, " ", $iitip); // Makes it work with a variation of the default tooltips (I.E $tags // $filesize // $size) $justincase = array(" //", "// ", " //", "// ", " "); if(strstr($h_tip, " ")) { $h_tip = html_escape(str_replace($justincase, "", $h_tip)); }else{ $h_tip = html_escape($h_tip); } return $h_tip; } else { return $tt; } } /** * Figure out where the full size image is on disk. * * @return string */ public function get_image_filename() { return warehouse_path("images", $this->hash); } /** * Figure out where the thumbnail is on disk. * * @return string */ public function get_thumb_filename() { return warehouse_path("thumbs", $this->hash); } /** * Get the original filename. * * @return string */ public function get_filename() { return $this->filename; } /** * Get the image's mime type. * * @return string */ public function get_mime_type() { return getMimeType($this->get_image_filename(), $this->get_ext()); } /** * Get the image's filename extension * * @return string */ public function get_ext() { return $this->ext; } /** * Get the image's source URL * * @return string */ public function get_source() { return $this->source; } /** * Set the image's source URL * * @param string $new_source */ public function set_source(string $new_source) { global $database; $old_source = $this->source; if(empty($new_source)) $new_source = null; if($new_source != $old_source) { $database->execute("UPDATE images SET source=:source WHERE id=:id", array("source"=>$new_source, "id"=>$this->id)); log_info("core_image", "Source for Image #{$this->id} set to: $new_source (was $old_source)", false, array("image_id" => $this->id)); } } /** * Check if the image is locked. * @return bool */ public function is_locked() { return $this->locked; } /** * @param bool $tf * @throws SCoreException */ public function set_locked($tf) { global $database; $ln = $tf ? "Y" : "N"; $sln = $database->scoreql_to_sql('SCORE_BOOL_'.$ln); $sln = str_replace("'", "", $sln); $sln = str_replace('"', "", $sln); if(bool_escape($sln) !== $this->locked) { $database->execute("UPDATE images SET locked=:yn WHERE id=:id", array("yn"=>$sln, "id"=>$this->id)); log_info("core_image", "Setting Image #{$this->id} lock to: $ln", false, array("image_id" => $this->id)); } } /** * Delete all tags from this image. * * Normally in preparation to set them to a new set. */ public function delete_tags_from_image() { global $database; if($database->get_driver_name() == "mysql") { //mysql < 5.6 has terrible subquery optimization, using EXISTS / JOIN fixes this $database->execute(" UPDATE tags t INNER JOIN image_tags it ON t.id = it.tag_id SET count = count - 1 WHERE it.image_id = :id", array("id"=>$this->id) ); } else { $database->execute(" UPDATE tags SET count = count - 1 WHERE id IN ( SELECT tag_id FROM image_tags WHERE image_id = :id ) ", array("id"=>$this->id)); } $database->execute(" DELETE FROM image_tags WHERE image_id=:id ", array("id"=>$this->id)); } /** * Set the tags for this image. */ public function set_tags(array $unfiltered_tags) { global $database; $tags = []; foreach ($unfiltered_tags as $tag) { if(mb_strlen($tag, 'UTF-8') > 255){ flash_message("Can't set a tag longer than 255 characters"); continue; } if(startsWith($tag, "-")) { flash_message("Can't set a tag which starts with a minus"); continue; } $tags[] = $tag; } if(count($tags) <= 0) { throw new SCoreException('Tried to set zero tags'); } if(Tag::implode($tags) != $this->get_tag_list()) { // delete old $this->delete_tags_from_image(); // insert each new tags foreach($tags as $tag) { $id = $database->get_one( $database->scoreql_to_sql(" SELECT id FROM tags WHERE SCORE_STRNORM(tag) = SCORE_STRNORM(:tag) "), array("tag"=>$tag) ); if(empty($id)) { // a new tag $database->execute( "INSERT INTO tags(tag) VALUES (:tag)", array("tag"=>$tag)); $database->execute( "INSERT INTO image_tags(image_id, tag_id) VALUES(:id, (SELECT id FROM tags WHERE tag = :tag))", array("id"=>$this->id, "tag"=>$tag)); } else { // user of an existing tag $database->execute(" INSERT INTO image_tags(image_id, tag_id) VALUES(:iid, :tid) ", array("iid"=>$this->id, "tid"=>$id)); } $database->execute( $database->scoreql_to_sql(" UPDATE tags SET count = count + 1 WHERE SCORE_STRNORM(tag) = SCORE_STRNORM(:tag) "), array("tag"=>$tag) ); } log_info("core_image", "Tags for Image #{$this->id} set to: ".Tag::implode($tags), null, array("image_id" => $this->id)); $database->cache->delete("image-{$this->id}-tags"); } } /** * Send list of metatags to be parsed. * * @param string[] $metatags * @param int $image_id */ public function parse_metatags($metatags, $image_id) { foreach($metatags as $tag) { $ttpe = new TagTermParseEvent($tag, $image_id, TRUE); send_event($ttpe); } } /** * Delete this image from the database and disk */ public function delete() { global $database; $this->delete_tags_from_image(); $database->execute("DELETE FROM images WHERE id=:id", array("id"=>$this->id)); log_info("core_image", 'Deleted Image #'.$this->id.' ('.$this->hash.')', false, array("image_id" => $this->id)); unlink($this->get_image_filename()); unlink($this->get_thumb_filename()); } /** * This function removes an image (and thumbnail) from the DISK ONLY. * It DOES NOT remove anything from the database. */ public function remove_image_only() { log_info("core_image", 'Removed Image File ('.$this->hash.')', false, array("image_id" => $this->id)); @unlink($this->get_image_filename()); @unlink($this->get_thumb_filename()); } /** * Someone please explain this * * @param string $tmpl * @param string $_escape * @return string */ public function parse_link_template($tmpl, $_escape="url_escape", $n=0) { global $config; // don't bother hitting the database if it won't be used... $tags = ""; if(strpos($tmpl, '$tags') !== false) { // * stabs dynamically typed languages with a rusty spoon * $tags = $this->get_tag_list(); $tags = str_replace("/", "", $tags); $tags = preg_replace("/^\.+/", "", $tags); } $base_href = $config->get_string('base_href'); $fname = $this->get_filename(); $base_fname = strpos($fname, '.') ? substr($fname, 0, strrpos($fname, '.')) : $fname; $tmpl = str_replace('$id', $this->id, $tmpl); $tmpl = str_replace('$hash_ab', substr($this->hash, 0, 2), $tmpl); $tmpl = str_replace('$hash_cd', substr($this->hash, 2, 2), $tmpl); $tmpl = str_replace('$hash', $this->hash, $tmpl); $tmpl = str_replace('$tags', $_escape($tags), $tmpl); $tmpl = str_replace('$base', $base_href, $tmpl); $tmpl = str_replace('$ext', $this->ext, $tmpl); $tmpl = str_replace('$size', "{$this->width}x{$this->height}", $tmpl); $tmpl = str_replace('$filesize', to_shorthand_int($this->filesize), $tmpl); $tmpl = str_replace('$filename', $_escape($base_fname), $tmpl); $tmpl = str_replace('$title', $_escape($config->get_string("title")), $tmpl); $tmpl = str_replace('$date', $_escape(autodate($this->posted, false)), $tmpl); // nothing seems to use this, sending the event out to 50 exts is a lot of overhead if(!SPEED_HAX) { $plte = new ParseLinkTemplateEvent($tmpl, $this); send_event($plte); $tmpl = $plte->link; } static $flexihash = null; static $fh_last_opts = null; $matches = array(); if(preg_match("/(.*){(.*)}(.*)/", $tmpl, $matches)) { $pre = $matches[1]; $opts = $matches[2]; $post = $matches[3]; if($opts != $fh_last_opts) { $fh_last_opts = $opts; $flexihash = new Flexihash\Flexihash(); foreach(explode(",", $opts) as $opt) { $parts = explode("=", $opt); $parts_count = count($parts); $opt_val = ""; $opt_weight = 0; if($parts_count === 2) { $opt_val = $parts[0]; $opt_weight = $parts[1]; } elseif($parts_count === 1) { $opt_val = $parts[0]; $opt_weight = 1; } $flexihash->addTarget($opt_val, $opt_weight); } } // $choice = $flexihash->lookup($pre.$post); $choices = $flexihash->lookupList($this->hash, $n+1); // hash doesn't change $choice = $choices[$n]; $tmpl = $pre.$choice.$post; } return $tmpl; } /** * @param string[] $terms * @return \Querylet */ private static function build_search_querylet(array $terms): Querylet { global $database; $tag_querylets = array(); $img_querylets = array(); $positive_tag_count = 0; $negative_tag_count = 0; /* * Turn a bunch of strings into a bunch of TagQuerylet * and ImgQuerylet objects */ $stpe = new SearchTermParseEvent(null, $terms); send_event($stpe); if ($stpe->is_querylet_set()) { foreach ($stpe->get_querylets() as $querylet) { $img_querylets[] = new ImgQuerylet($querylet, true); } } foreach ($terms as $term) { $positive = true; if (is_string($term) && !empty($term) && ($term[0] == '-')) { $positive = false; $term = substr($term, 1); } if (strlen($term) === 0) { continue; } $stpe = new SearchTermParseEvent($term, $terms); send_event($stpe); if ($stpe->is_querylet_set()) { foreach ($stpe->get_querylets() as $querylet) { $img_querylets[] = new ImgQuerylet($querylet, $positive); } } else { // if the whole match is wild, skip this; // if not, translate into SQL if(str_replace("*", "", $term) != "") { $term = str_replace('_', '\_', $term); $term = str_replace('%', '\%', $term); $term = str_replace('*', '%', $term); $tag_querylets[] = new TagQuerylet($term, $positive); if ($positive) $positive_tag_count++; else $negative_tag_count++; } } } /* * Turn a bunch of Querylet objects into a base query * * Must follow the format * * SELECT images.* * FROM (...) AS images * WHERE (...) * * ie, return a set of images.* columns, and end with a WHERE */ // no tags, do a simple search if($positive_tag_count === 0 && $negative_tag_count === 0) { $query = new Querylet(" SELECT images.* FROM images WHERE 1=1 "); } // one positive tag (a common case), do an optimised search else if($positive_tag_count === 1 && $negative_tag_count === 0) { # "LIKE" to account for wildcards $query = new Querylet($database->scoreql_to_sql(" SELECT * FROM ( SELECT images.* FROM images JOIN image_tags ON images.id=image_tags.image_id JOIN tags ON image_tags.tag_id=tags.id WHERE SCORE_STRNORM(tag) LIKE SCORE_STRNORM(:tag) GROUP BY images.id ) AS images WHERE 1=1 "), array("tag"=>$tag_querylets[0]->tag)); } // more than one positive tag, or more than zero negative tags else { if($database->get_driver_name() === "mysql") $query = Image::build_ugly_search_querylet($tag_querylets); else $query = Image::build_accurate_search_querylet($tag_querylets); } /* * Merge all the image metadata searches into one generic querylet * and append to the base querylet with "AND blah" */ if(!empty($img_querylets)) { $n = 0; $img_sql = ""; $img_vars = array(); foreach ($img_querylets as $iq) { if ($n++ > 0) $img_sql .= " AND"; if (!$iq->positive) $img_sql .= " NOT"; $img_sql .= " (" . $iq->qlet->sql . ")"; $img_vars = array_merge($img_vars, $iq->qlet->variables); } $query->append_sql(" AND "); $query->append(new Querylet($img_sql, $img_vars)); } return $query; } /** * WARNING: this description is no longer accurate, though it does get across * the general idea - the actual method has a few extra optimisations * * "foo bar -baz user=foo" becomes * * SELECT * FROM images WHERE * images.id IN (SELECT image_id FROM image_tags WHERE tag='foo') * AND images.id IN (SELECT image_id FROM image_tags WHERE tag='bar') * AND NOT images.id IN (SELECT image_id FROM image_tags WHERE tag='baz') * AND images.id IN (SELECT id FROM images WHERE owner_name='foo') * * This is: * A) Incredibly simple: * Each search term maps to a list of image IDs * B) Runs really fast on a good database: * These lists are calculated once, and the set intersection taken * C) Runs really slow on bad databases: * All the subqueries are executed every time for every row in the * images table. Yes, MySQL does suck this much. * * @param TagQuerylet[] $tag_querylets * @return Querylet */ private static function build_accurate_search_querylet(array $tag_querylets): Querylet { global $database; $positive_tag_id_array = array(); $negative_tag_id_array = array(); foreach ($tag_querylets as $tq) { $tag_ids = $database->get_col( $database->scoreql_to_sql(" SELECT id FROM tags WHERE SCORE_STRNORM(tag) LIKE SCORE_STRNORM(:tag) "), array("tag" => $tq->tag) ); if ($tq->positive) { $positive_tag_id_array = array_merge($positive_tag_id_array, $tag_ids); if (count($tag_ids) == 0) { # one of the positive tags had zero results, therefor there # can be no results; "where 1=0" should shortcut things return new Querylet(" SELECT images.* FROM images WHERE 1=0 "); } } else { $negative_tag_id_array = array_merge($negative_tag_id_array, $tag_ids); } } assert($positive_tag_id_array || $negative_tag_id_array, @$_GET['q']); $wheres = array(); if (!empty($positive_tag_id_array)) { $positive_tag_id_list = join(', ', $positive_tag_id_array); $wheres[] = "tag_id IN ($positive_tag_id_list)"; } if (!empty($negative_tag_id_array)) { $negative_tag_id_list = join(', ', $negative_tag_id_array); $wheres[] = "tag_id NOT IN ($negative_tag_id_list)"; } $wheres_str = join(" AND ", $wheres); return new Querylet(" SELECT images.* FROM images WHERE images.id IN ( SELECT image_id FROM image_tags WHERE $wheres_str GROUP BY image_id HAVING COUNT(image_id) >= :search_score ) ", array("search_score"=>count($positive_tag_id_array))); } /** * this function exists because mysql is a turd, see the docs for * build_accurate_search_querylet() for a full explanation * * @param TagQuerylet[] $tag_querylets * @return Querylet */ private static function build_ugly_search_querylet($tag_querylets) { global $database; $positive_tag_count = 0; foreach($tag_querylets as $tq) { if($tq->positive) $positive_tag_count++; } // only negative tags - shortcut to fail if($positive_tag_count == 0) { // TODO: This isn't currently implemented. // SEE: https://github.com/shish/shimmie2/issues/66 return new Querylet(" SELECT images.* FROM images WHERE 1=0 "); } // merge all the tag querylets into one generic one $sql = "0"; $terms = array(); foreach($tag_querylets as $tq) { $sign = $tq->positive ? "+" : "-"; $sql .= ' '.$sign.' IF(SUM(tag LIKE :tag'.Image::$tag_n.'), 1, 0)'; $terms['tag'.Image::$tag_n] = $tq->tag; Image::$tag_n++; } $tag_search = new Querylet($sql, $terms); $tag_id_array = array(); foreach($tag_querylets as $tq) { $tag_ids = $database->get_col( $database->scoreql_to_sql(" SELECT id FROM tags WHERE SCORE_STRNORM(tag) LIKE SCORE_STRNORM(:tag) "), array("tag" => $tq->tag) ); $tag_id_array = array_merge($tag_id_array, $tag_ids); if($tq->positive && count($tag_ids) == 0) { # one of the positive tags had zero results, therefor there # can be no results; "where 1=0" should shortcut things return new Querylet(" SELECT images.* FROM images WHERE 1=0 "); } } Image::$tag_n = 0; return new Querylet(' SELECT * FROM ( SELECT images.*, ('.$tag_search->sql.') AS score FROM images LEFT JOIN image_tags ON image_tags.image_id = images.id JOIN tags ON image_tags.tag_id = tags.id WHERE tags.id IN (' . join(', ', $tag_id_array) . ') GROUP BY images.id HAVING score = :score ) AS images WHERE 1=1 ', array_merge( $tag_search->variables, array("score"=>$positive_tag_count) )); } }