This repository has been archived on 2024-09-05. You can view files and clone it, but cannot push or open issues or pull requests.
shimmie2/core/imageboard.pack.php
Shish 7be951b271 Convert tags from user-supplied string to array once, on input
This results in a fuckton of refactoring and code cancelling out
other code -- we no longer have a whole bunch of places trying
to support string params and array params, and doing their own
esaping and unescaping, never being quite sure if the data they've
been passed is escaped or not.

Also adds a bunch of type hinting, since we can now know what
data we're dealing with better.
2016-07-30 22:11:49 +01:00

1274 lines
32 KiB
PHP

<?php
/**
* All the imageboard-specific bits of code should be in this file, everything
* else in /core should be standard SCore bits.
*/
/**
* \page search Shimmie2: Searching
*
* The current search system is built of several search item -> image ID list
* translators, eg:
*
* \li the item "fred" will search the image_tags table to find image IDs with the fred tag
* \li the item "size=640x480" will search the images table to find image IDs of 640x480 images
*
* So the search "fred size=640x480" will calculate two lists and take the
* intersection. (There are some optimisations in there making it more
* complicated behind the scenes, but as long as you can turn a single word
* into a list of image IDs, making a search plugin should be simple)
*/
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *\
* Classes *
\* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
/**
* Class Image
*
* An object representing an entry in the images table.
*
* As of 2.2, this no longer necessarily represents an
* image per se, but could be a video, sound file, or any
* other supported upload type.
*/
class Image {
private static $tag_n = 0; // temp hack
public static $order_sql = null; // this feels ugly
/** @var null|int */
public $id = null;
/** @var int */
public $height;
/** @var int */
public $width;
/** @var string */
public $hash;
public $filesize;
/** @var string */
public $filename;
/** @var string */
public $ext;
/** @var string[]|null */
public $tag_array;
public $owner_id, $owner_ip;
public $posted;
public $source;
public $locked;
/**
* One will very rarely construct an image directly, more common
* would be to use Image::by_id, Image::by_hash, etc.
*
* @param null|mixed $row
*/
public function __construct($row=null) {
assert('is_null($row) || is_array($row)');
if(!is_null($row)) {
foreach($row as $name => $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));
}
}
/**
* Find an image by ID.
*
* @param int $id
* @return Image
*/
public static function by_id(/*int*/ $id) {
assert('is_numeric($id)');
global $database;
$row = $database->get_row("SELECT * FROM images WHERE images.id=:id", array("id"=>$id));
return ($row ? new Image($row) : null);
}
/**
* Find an image by hash.
*
* @param string $hash
* @return Image
*/
public static function by_hash(/*string*/ $hash) {
assert('is_string($hash)');
global $database;
$row = $database->get_row("SELECT images.* FROM images WHERE hash=:hash", array("hash"=>$hash));
return ($row ? new Image($row) : null);
}
/**
* Pick a random image out of a set.
*
* @param string[] $tags
* @return Image
*/
public static function by_random($tags=array()) {
assert('is_array($tags)');
$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, $tags=array()) {
assert('is_numeric($start)');
assert('is_numeric($limit)');
assert('is_array($tags)');
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;
}
public function validate_accel($tags) {
$yays = 0;
$nays = 0;
foreach($tags as $tag) {
if(!preg_match("/^-?[a-zA-Z0-9_]+$/", $tag)) {
return false;
}
if($tag[0] == "-") $nays++;
else $yays++;
}
return ($yays > 1 || $nays > 0);
}
/**
* @param string[] $tags
* @param int $offset
* @param int $limit
* @return null|PDOStatement
* @throws SCoreException
*/
public function get_accelerated_result($tags, $offset, $limit) {
global $database;
if(!Image::validate_accel($tags)) {
return null;
}
$yays = array();
$nays = array();
foreach($tags as $tag) {
if($tag[0] == "-") {
$nays[] = substr($tag, 1);
}
else {
$yays[] = $tag;
}
}
$req = array(
"yays" => $yays,
"nays" => $nays,
"offset" => $offset,
"limit" => $limit,
);
$fp = fsockopen("127.0.0.1", 21212);
if (!$fp) {
return null;
}
fwrite($fp, json_encode($req));
$data = fgets($fp, 1024);
fclose($fp);
$response = json_decode($data);
$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;
}
/*
* Image-related utility functions
*/
/**
* Count the number of image results for a given search
*
* @param string[] $tags
* @return int
*/
public static function count_images($tags=array()) {
assert('is_array($tags)');
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);
}
return $total;
}
else if($tag_count === 1 && !preg_match("/[:=><\*\?]/", $tags[0])) {
return $database->get_one(
$database->scoreql_to_sql("SELECT count FROM tags WHERE SCORE_STRNORM(tag) = SCORE_STRNORM(:tag)"),
array("tag"=>$tags[0]));
}
else {
$querylet = Image::build_search_querylet($tags);
$result = $database->execute($querylet->sql, $querylet->variables);
return $result->rowCount();
}
}
/**
* Count the number of pages for a given search
*
* @param string[] $tags
* @return float
*/
public static function count_pages($tags=array()) {
assert('is_array($tags)');
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($tags=array(), $next=true) {
assert('is_array($tags)');
assert('is_bool($next)');
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($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.
*
* @param string[] $tags
* @throws Exception
*/
public function set_tags($tags) {
assert('is_array($tags) && count($tags) > 0', var_export($tags, true));
global $database;
if(count($tags) <= 0) {
throw new SCoreException('Tried to set zero tags');
}
if(implode(" ", $tags) != $this->get_tag_list()) {
// delete old
$this->delete_tags_from_image();
// insert each new tags
foreach($tags as $tag) {
if(mb_strlen($tag, 'UTF-8') > 255){
flash_message("The tag below is longer than 255 characters, please use a shorter tag.\n$tag\n");
continue;
}
$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: ".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") {
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);
$tmpl = $pre.$choice.$post;
}
return $tmpl;
}
/**
* @param string[] $terms
* @return \Querylet
*/
private static function build_search_querylet($terms) {
assert('is_array($terms)');
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($tag_querylets) {
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');
$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)
));
}
}
/**
* Class Tag
*
* A class for organising the tag related functions.
*
* All the methods are static, one should never actually use a tag object.
*
*/
class Tag {
/**
* @param string[] $tags
* @return string
*/
public static function implode($tags) {
assert('is_array($tags)');
sort($tags);
$tags = implode(' ', $tags);
return $tags;
}
/**
* Turn a human-supplied string into a valid tag array.
*
* @param string $tags
* @param bool $tagme add "tagme" if the string is empty
* @return string[]
*/
public static function explode($tags, $tagme=true) {
global $database;
assert('is_string($tags)');
$tags = explode(' ', trim($tags));
/* sanitise by removing invisible / dodgy characters */
$tag_array = array();
foreach($tags as $tag) {
$tag = preg_replace("/\s/", "", $tag); # whitespace
$tag = preg_replace('/\x20(\x0e|\x0f)/', '', $tag); # unicode RTL
$tag = preg_replace("/\.+/", ".", $tag); # strings of dots?
$tag = preg_replace("/^(\.+[\/\\\\])+/", "", $tag); # trailing slashes?
$tag = trim($tag, ", \t\n\r\0\x0B");
if(!empty($tag)) {
$tag_array[] = $tag;
}
}
/* if user supplied a blank string, add "tagme" */
if(count($tag_array) === 0 && $tagme) {
$tag_array = array("tagme");
}
/* resolve aliases */
$new = array();
$i = 0;
$tag_count = count($tag_array);
while($i<$tag_count) {
$tag = $tag_array[$i];
$negative = '';
if(!empty($tag) && ($tag[0] == '-')) {
$negative = '-';
$tag = substr($tag, 1);
}
$newtags = $database->get_one(
$database->scoreql_to_sql("
SELECT newtag
FROM aliases
WHERE SCORE_STRNORM(oldtag)=SCORE_STRNORM(:tag)
"),
array("tag"=>$tag)
);
if(empty($newtags)) {
//tag has no alias, use old tag
$aliases = array($tag);
}
else {
$aliases = Tag::explode($newtags);
}
foreach($aliases as $alias) {
if(!in_array($alias, $new)) {
if($tag == $alias) {
$new[] = $negative.$alias;
}
elseif(!in_array($alias, $tag_array)) {
$tag_array[] = $negative.$alias;
$tag_count++;
}
}
}
$i++;
}
/* remove any duplicate tags */
$tag_array = array_iunique($new);
/* tidy up */
sort($tag_array);
return $tag_array;
}
}
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *\
* Misc functions *
\* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
/**
* Move a file from PHP's temporary area into shimmie's image storage
* hierarchy, or throw an exception trying.
*
* @param DataUploadEvent $event
* @throws UploadException
*/
function move_upload_to_archive(DataUploadEvent $event) {
$target = warehouse_path("images", $event->hash);
if(!@copy($event->tmpname, $target)) {
$errors = error_get_last();
throw new UploadException(
"Failed to copy file from uploads ({$event->tmpname}) to archive ($target): ".
"{$errors['type']} / {$errors['message']}"
);
}
}
/**
* Add a directory full of images
*
* @param $base string
* @return array
*/
function add_dir($base) {
$results = array();
foreach(list_files($base) as $full_path) {
$short_path = str_replace($base, "", $full_path);
$filename = basename($full_path);
$tags = path_to_tags($short_path);
$result = "$short_path (".str_replace(" ", ", ", $tags).")... ";
try {
add_image($full_path, $filename, $tags);
$result .= "ok";
}
catch(UploadException $ex) {
$result .= "failed: ".$ex->getMessage();
}
$results[] = $result;
}
return $results;
}
/**
* @param string $tmpname
* @param string $filename
* @param string $tags
* @throws UploadException
*/
function add_image($tmpname, $filename, $tags) {
assert(file_exists($tmpname));
$pathinfo = pathinfo($filename);
if(!array_key_exists('extension', $pathinfo)) {
throw new UploadException("File has no extension");
}
$metadata = array();
$metadata['filename'] = $pathinfo['basename'];
$metadata['extension'] = $pathinfo['extension'];
$metadata['tags'] = $tags;
$metadata['source'] = null;
$event = new DataUploadEvent($tmpname, $metadata);
send_event($event);
if($event->image_id == -1) {
throw new UploadException("File type not recognised");
}
}
/**
* Given a full size pair of dimensions, return a pair scaled down to fit
* into the configured thumbnail square, with ratio intact
*
* @param int $orig_width
* @param int $orig_height
* @return int[]
*/
function get_thumbnail_size(/*int*/ $orig_width, /*int*/ $orig_height) {
global $config;
if($orig_width === 0) $orig_width = 192;
if($orig_height === 0) $orig_height = 192;
if($orig_width > $orig_height * 5) $orig_width = $orig_height * 5;
if($orig_height > $orig_width * 5) $orig_height = $orig_width * 5;
$max_width = $config->get_int('thumb_width');
$max_height = $config->get_int('thumb_height');
$xscale = ($max_height / $orig_height);
$yscale = ($max_width / $orig_width);
$scale = ($xscale < $yscale) ? $xscale : $yscale;
if($scale > 1 && $config->get_bool('thumb_upscale')) {
return array((int)$orig_width, (int)$orig_height);
}
else {
return array((int)($orig_width*$scale), (int)($orig_height*$scale));
}
}