diff --git a/.htaccess b/.htaccess index 3050e2e7..e2f1ca28 100644 --- a/.htaccess +++ b/.htaccess @@ -27,7 +27,7 @@ ExpiresActive On - + Header set Cache-Control "public, max-age=2629743" @@ -46,6 +46,7 @@ AddType image/jpeg jpg jpeg AddType image/gif gif AddType image/png png +AddType image/webp webp #EXT: handle_ico AddType image/x-icon ico ani cur diff --git a/core/exceptions.php b/core/exceptions.php index a201eba4..bf923d96 100644 --- a/core/exceptions.php +++ b/core/exceptions.php @@ -35,3 +35,22 @@ class ImageDoesNotExist extends SCoreException class InvalidInput extends SCoreException { } + +/* + * This is used by the image resizing code when there is not enough memory to perform a resize. + */ +class InsufficientMemoryException extends SCoreException +{ +} +/* + * This is used by the image resizing code when there is an error while resizing + */ +class ImageResizeException extends SCoreException +{ + public $error; + + public function __construct(string $error) + { + $this->error = $error; + } +} \ No newline at end of file diff --git a/core/extension.php b/core/extension.php index 0b6134f2..7274f868 100644 --- a/core/extension.php +++ b/core/extension.php @@ -219,13 +219,21 @@ abstract class DataHandlerExtension extends Extension public function onThumbnailGeneration(ThumbnailGenerationEvent $event) { + $result = false; if ($this->supported_ext($event->type)) { - if (method_exists($this, 'create_thumb_force') && $event->force == true) { - $this->create_thumb_force($event->hash); + if($event->force) { + $result = $this->create_thumb($event->hash); } else { - $this->create_thumb($event->hash); + $outname = warehouse_path("thumbs", $event->hash); + if(file_exists($outname)) { + return; + } + $result = $this->create_thumb($event->hash); } } + if($result) { + $event->generated = true; + } } public function onDisplayingImage(DisplayingImageEvent $event) diff --git a/core/imageboard/event.php b/core/imageboard/event.php index 6ed27177..2fc40fef 100644 --- a/core/imageboard/event.php +++ b/core/imageboard/event.php @@ -99,6 +99,10 @@ class ThumbnailGenerationEvent extends Event /** @var bool */ public $force; + /** @var bool */ + public $generated; + + /** * Request a thumbnail be made for an image object */ @@ -107,6 +111,7 @@ class ThumbnailGenerationEvent extends Event $this->hash = $hash; $this->type = $type; $this->force = $force; + $this->generated = false; } } diff --git a/core/imageboard/image.php b/core/imageboard/image.php index b43a0844..928d4914 100644 --- a/core/imageboard/image.php +++ b/core/imageboard/image.php @@ -145,6 +145,51 @@ class Image return $images; } + /** + * Search for an array of image IDs + * + * #param string[] $tags + * #return int[] + */ + public static function find_image_ids(int $start, int $limit, array $tags=[]): array + { + global $database, $user, $config; + + $images = []; + + 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", ["limit"=>$limit, "offset"=>$start])); + #var_dump($querylet->sql); var_dump($querylet->variables); + $result = $database->execute($querylet->sql, $querylet->variables); + } + + while ($row = $result->fetch()) { + $images[] = $row["id"]; + } + Image::$order_sql = null; + return $images; + } + /* * Accelerator stuff */ @@ -385,12 +430,22 @@ class Image return $this->get_link('image_ilink', '_images/$hash/$id%20-%20$tags.$ext', 'image/$id.$ext'); } + /** + * Get the nicely formatted version of the file name + */ + public function get_nice_image_name(): string + { + return $this->parse_link_template('$id - $tags.$ext'); + } + /** * Get the URL for the thumbnail */ public function get_thumb_link(): string { - return $this->get_link('image_tlink', '_thumbs/$hash/thumb.jpg', 'thumb/$id.jpg'); + global $config; + $ext = $config->get_string("thumb_type"); + return $this->get_link('image_tlink', '_thumbs/$hash/thumb.'.$ext, 'thumb/$id.'.$ext); } /** diff --git a/core/imageboard/misc.php b/core/imageboard/misc.php index 5ae727f3..eb3c4146 100644 --- a/core/imageboard/misc.php +++ b/core/imageboard/misc.php @@ -53,21 +53,34 @@ function add_image(string $tmpname, string $filename, string $tags): void assert(file_exists($tmpname)); $pathinfo = pathinfo($filename); - if (!array_key_exists('extension', $pathinfo)) { - throw new UploadException("File has no extension"); - } $metadata = []; $metadata['filename'] = $pathinfo['basename']; - $metadata['extension'] = $pathinfo['extension']; + if (array_key_exists('extension', $pathinfo)) { + $metadata['extension'] = $pathinfo['extension']; + } + $metadata['tags'] = Tag::explode($tags); $metadata['source'] = null; $event = new DataUploadEvent($tmpname, $metadata); send_event($event); - if ($event->image_id == -1) { - throw new UploadException("File type not recognised"); - } } + +function get_extension_from_mime(String $file_path): ?String +{ + global $config; + $mime = mime_content_type($file_path); + if(!empty($mime)) { + $ext = get_extension($mime); + if(!empty($ext)) { + return $ext; + } + throw new UploadException("Could not determine extension for mimetype ".$mime); + } + throw new UploadException("Could not determine file mime type: ".$file_path); +} + + /** * Given a full size pair of dimensions, return a pair scaled down to fit * into the configured thumbnail square, with ratio intact @@ -105,3 +118,344 @@ function get_thumbnail_size(int $orig_width, int $orig_height): array return [(int)($orig_width*$scale), (int)($orig_height*$scale)]; } } + +/** + * Given a full size pair of dimensions, return a pair scaled down to fit + * into the configured thumbnail square, with ratio intact, using thumb_scaling + * + * #return int[] + */ +function get_thumbnail_size_scaled(int $orig_width, int $orig_height): array +{ + 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_size = get_thumbnail_max_size_scaled(); + $max_width = $max_size[0]; + $max_height = $max_size[1]; + + $xscale = ($max_height / $orig_height); + $yscale = ($max_width / $orig_width); + $scale = ($xscale < $yscale) ? $xscale : $yscale; + + if ($scale > 1 && $config->get_bool('thumb_upscale')) { + return [(int)$orig_width, (int)$orig_height]; + } else { + return [(int)($orig_width*$scale), (int)($orig_height*$scale)]; + } +} + +function get_thumbnail_max_size_scaled(): array +{ + global $config; + + $scaling = $config->get_int("thumb_scaling"); + $max_width = $config->get_int('thumb_width') * ($scaling/100); + $max_height = $config->get_int('thumb_height') * ($scaling/100); + return [$max_width, $max_height]; +} + +function create_thumbnail_convert($hash): bool +{ + global $config; + + $inname = warehouse_path("images", $hash); + $outname = warehouse_path("thumbs", $hash); + + $q = $config->get_int("thumb_quality"); + $convert = $config->get_string("thumb_convert_path"); + + if($convert==null||$convert=="") + { + return false; + } + + // ffff imagemagick fails sometimes, not sure why + //$format = "'%s' '%s[0]' -format '%%[fx:w] %%[fx:h]' info:"; + //$cmd = sprintf($format, $convert, $inname); + //$size = shell_exec($cmd); + //$size = explode(" ", trim($size)); + $tsize = get_thumbnail_max_size_scaled(); + $w = $tsize[0]; + $h = $tsize[1]; + + + // running the call with cmd.exe requires quoting for our paths + $type = $config->get_string('thumb_type'); + + $options = ""; + if (!$config->get_bool('thumb_upscale')) { + $options .= "\>"; + } + + $bg = "black"; + if($type=="webp") { + $bg = "none"; + } + $format = '"%s" -flatten -strip -thumbnail %ux%u%s -quality %u -background %s "%s[0]" %s:"%s"'; + + $cmd = sprintf($format, $convert, $w, $h, $options, $q, $bg, $inname, $type, $outname); + $cmd = str_replace("\"convert\"", "convert", $cmd); // quotes are only needed if the path to convert contains a space; some other times, quotes break things, see github bug #27 + exec($cmd, $output, $ret); + + log_debug('handle_pixel', "Generating thumbnail with command `$cmd`, returns $ret"); + + if ($config->get_bool("thumb_optim", false)) { + exec("jpegoptim $outname", $output, $ret); + } + + return true; +} + +function create_thumbnail_ffmpeg($hash): bool +{ + global $config; + + $ffmpeg = $config->get_string("thumb_ffmpeg_path"); + if($ffmpeg==null||$ffmpeg=="") { + return false; + } + + $inname = warehouse_path("images", $hash); + $outname = warehouse_path("thumbs", $hash); + + $orig_size = video_size($inname); + $scaled_size = get_thumbnail_size_scaled($orig_size[0], $orig_size[1]); + + $codec = "mjpeg"; + $quality = $config->get_int("thumb_quality"); + if($config->get_string("thumb_type")=="webp") { + $codec = "libwebp"; + } else { + // mjpeg quality ranges from 2-31, with 2 being the best quality. + $quality = floor(31 - (31 * ($quality/100))); + if($quality<2) { + $quality = 2; + } + } + + $args = [ + escapeshellarg($ffmpeg), + "-y", "-i", escapeshellarg($inname), + "-vf", "thumbnail,scale={$scaled_size[0]}:{$scaled_size[1]}", + "-f", "image2", + "-vframes", "1", + "-c:v", $codec, + "-q:v", $quality, + escapeshellarg($outname), + ]; + + $cmd = escapeshellcmd(implode(" ", $args)); + + exec($cmd, $output, $ret); + + if ((int)$ret == (int)0) { + log_debug('imageboard/misc', "Generating thumbnail with command `$cmd`, returns $ret"); + return true; + } else { + log_error('imageboard/misc', "Generating thumbnail with command `$cmd`, returns $ret"); + return false; + } +} + +function video_size(string $filename): array +{ + global $config; + $ffmpeg = $config->get_string("thumb_ffmpeg_path"); + $cmd = escapeshellcmd(implode(" ", [ + escapeshellarg($ffmpeg), + "-y", "-i", escapeshellarg($filename), + "-vstats" + ])); + $output = shell_exec($cmd . " 2>&1"); + // error_log("Getting size with `$cmd`"); + + $regex_sizes = "/Video: .* ([0-9]{1,4})x([0-9]{1,4})/"; + if (preg_match($regex_sizes, $output, $regs)) { + if (preg_match("/displaymatrix: rotation of (90|270).00 degrees/", $output)) { + $size = [$regs[2], $regs[1]]; + } else { + $size = [$regs[1], $regs[2]]; + } + } else { + $size = [1, 1]; + } + log_debug('imageboard/misc', "Getting video size with `$cmd`, returns $output -- $size[0], $size[1]"); + return $size; +} + +/** + * Check Memory usage limits + * + * Old check: $memory_use = (filesize($image_filename)*2) + ($width*$height*4) + (4*1024*1024); + * New check: $memory_use = $width * $height * ($bits_per_channel) * channels * 2.5 + * + * It didn't make sense to compute the memory usage based on the NEW size for the image. ($width*$height*4) + * We need to consider the size that we are GOING TO instead. + * + * The factor of 2.5 is simply a rough guideline. + * http://stackoverflow.com/questions/527532/reasonable-php-memory-limit-for-image-resize + */ +function calc_memory_use(array $info): int +{ + if (isset($info['bits']) && isset($info['channels'])) { + $memory_use = ($info[0] * $info[1] * ($info['bits'] / 8) * $info['channels'] * 2.5) / 1024; + } else { + // If we don't have bits and channel info from the image then assume default values + // of 8 bits per color and 4 channels (R,G,B,A) -- ie: regular 24-bit color + $memory_use = ($info[0] * $info[1] * 1 * 4 * 2.5) / 1024; + } + return (int)$memory_use; +} + +function image_resize_gd(String $image_filename, array $info, int $new_width, int $new_height, + string $output_filename=null, string $output_type=null, int $output_quality = 80) +{ + $width = $info[0]; + $height = $info[1]; + + if($output_type==null) { + /* If not specified, output to the same format as the original image */ + switch ($info[2]) { + case IMAGETYPE_GIF: $output_type = "gif"; break; + case IMAGETYPE_JPEG: $output_type = "jpeg"; break; + case IMAGETYPE_PNG: $output_type = "png"; break; + case IMAGETYPE_WEBP: $output_type = "webp"; break; + case IMAGETYPE_BMP: $output_type = "bmp"; break; + default: throw new ImageResizeException("Failed to save the new image - Unsupported image type."); + } + } + + $memory_use = calc_memory_use($info); + $memory_limit = get_memory_limit(); + if ($memory_use > $memory_limit) { + throw new InsufficientMemoryException("The image is too large to resize given the memory limits. ($memory_use > $memory_limit)"); + } + + $image = imagecreatefromstring(file_get_contents($image_filename)); + $image_resized = imagecreatetruecolor($new_width, $new_height); + try { + if($image===false) { + throw new ImageResizeException("Could not load image: ".$image_filename); + } + if($image_resized===false) { + throw new ImageResizeException("Could not create output image with dimensions $new_width c $new_height "); + } + + // Handle transparent images + switch($info[2]) { + case IMAGETYPE_GIF: + $transparency = imagecolortransparent($image); + $palletsize = imagecolorstotal($image); + + // If we have a specific transparent color + if ($transparency >= 0 && $transparency < $palletsize) { + // Get the original image's transparent color's RGB values + $transparent_color = imagecolorsforindex($image, $transparency); + + // Allocate the same color in the new image resource + $transparency = imagecolorallocate($image_resized, $transparent_color['red'], $transparent_color['green'], $transparent_color['blue']); + if($transparency===false) { + throw new ImageResizeException("Unable to allocate transparent color"); + } + + // Completely fill the background of the new image with allocated color. + if(imagefill($image_resized, 0, 0, $transparency)===false) { + throw new ImageResizeException("Unable to fill new image with transparent color"); + } + + // Set the background color for new image to transparent + imagecolortransparent($image_resized, $transparency); + } + break; + case IMAGETYPE_PNG: + case IMAGETYPE_WEBP: + // + // More info here: http://stackoverflow.com/questions/279236/how-do-i-resize-pngs-with-transparency-in-php + // + if(imagealphablending($image_resized, false)===false) { + throw new ImageResizeException("Unable to disable image alpha blending"); + } + if(imagesavealpha($image_resized, true)===false) { + throw new ImageResizeException("Unable to enable image save alpha"); + } + $transparent_color = imagecolorallocatealpha($image_resized, 255, 255, 255, 127); + if($transparent_color===false) { + throw new ImageResizeException("Unable to allocate transparent color"); + } + if(imagefilledrectangle($image_resized, 0, 0, $new_width, $new_height, $transparent_color)===false) { + throw new ImageResizeException("Unable to fill new image with transparent color"); + } + break; + } + + // Actually resize the image. + if(imagecopyresampled( + $image_resized, + $image, + 0, + 0, + 0, + 0, + $new_width, + $new_height, + $width, + $height + )===false) { + throw new ImageResizeException("Unable to copy resized image data to new image"); + } + + $result = false; + switch($output_type) { + case "bmp": + $result = imagebmp($image_resized, $output_filename, true); + break; + case "webp": + $result = imagewebp($image_resized, $output_filename, $output_quality); + break; + case "jpg": + case "jpeg": + $result = imagejpeg($image_resized, $output_filename, $output_quality); + break; + case "png": + $result = imagepng($image_resized, $output_filename, 9); + break; + case "gif": + $result = imagegif($image_resized, $output_filename); + break; + default: + throw new ImageResizeException("Failed to save the new image - Unsupported image type: $output_type"); + } + if($result==false) { + throw new ImageResizeException("Failed to save the new image, function returned false when saving type: $output_type"); + } + } finally { + imagedestroy($image); + imagedestroy($image_resized); + } +} + +function is_animated_gif(String $image_filename) { + $isanigif = 0; + if (($fh = @fopen($image_filename, 'rb'))) { + //check if gif is animated (via http://www.php.net/manual/en/function.imagecreatefromgif.php#104473) + while (!feof($fh) && $isanigif < 2) { + $chunk = fread($fh, 1024 * 100); + $isanigif += preg_match_all('#\x00\x21\xF9\x04.{4}\x00(\x2C|\x21)#s', $chunk, $matches); + } + } + return ($isanigif == 0); +} \ No newline at end of file diff --git a/ext/admin/main.php b/ext/admin/main.php index 4ca59eb7..212b07fb 100644 --- a/ext/admin/main.php +++ b/ext/admin/main.php @@ -124,13 +124,13 @@ class AdminPage extends Extension } } - public function onPostListBuilding(PostListBuildingEvent $event) - { - global $user; - if ($user->can("manage_admintools") && !empty($event->search_terms)) { - $event->add_control($this->theme->dbq_html(Tag::implode($event->search_terms))); - } - } + // public function onPostListBuilding(PostListBuildingEvent $event) + // { + // global $user; + // if ($user->can("manage_admintools") && !empty($event->search_terms)) { + // $event->add_control($this->theme->dbq_html(Tag::implode($event->search_terms))); + // } + // } private function delete_by_query() { diff --git a/ext/bulk_actions/main.php b/ext/bulk_actions/main.php new file mode 100644 index 00000000..291dd513 --- /dev/null +++ b/ext/bulk_actions/main.php @@ -0,0 +1,270 @@ +, contributions by Shish and Agasa. + */ + + +class BulkActionBlockBuildingEvent extends Event +{ + /** @var array */ + public $actions = array(); + + public function add_action(String $action, string $button_text, String $confirmation_message = "", String $block = "", int $position = 40) + { + if ($block == null) + $block = ""; + + array_push( + $this->actions, + array( + "block" => $block, + "confirmation_message" => $confirmation_message, + "action" => $action, + "button_text" => $button_text, + "position" => $position + ) + ); + } +} + +class BulkActionEvent extends Event +{ + /** @var string */ + public $action; + /** @var array */ + public $items; + /** @var PageRequestEvent */ + public $page_request; + + function __construct(String $action, PageRequestEvent $pageRequestEvent, array $items) + { + $this->action = $action; + $this->page_request = $pageRequestEvent; + $this->items = $items; + } +} + +class BulkActions extends Extension +{ + public function onPostListBuilding(PostListBuildingEvent $event) + { + global $config, $page, $user; + + if ($user->is_logged_in()) { + $babbe = new BulkActionBlockBuildingEvent(); + send_event($babbe); + + if (sizeof($babbe->actions) == 0) + return; + + usort($babbe->actions, array($this, "sort_blocks")); + + $this->theme->display_selector($page, $babbe->actions, Tag::implode($event->search_terms)); + } + } + + public function onBulkActionBlockBuilding(BulkActionBlockBuildingEvent $event) + { + global $user; + + if ($user->can("delete_image")) { + $event->add_action("bulk_delete","Delete", "Delete selected images?", "", 10); + } + + if ($user->can("bulk_edit_image_tag")) { + $event->add_action("bulk_tag","Tag", "", $this->theme->render_tag_input(), 10); + } + + if ($user->can("bulk_edit_image_source")) { + $event->add_action("bulk_source","Set Source", "", $this->theme->render_source_input(), 10); + } + } + + public function onBulkAction(BulkActionEvent $event) + { + global $user; + + switch ($event->action) { + case "bulk_delete": + if ($user->can("delete_image")) { + $i = $this->delete_items($event->items); + flash_message("Deleted $i items"); + } + break; + case "bulk_tag": + if (!isset($_POST['bulk_tags'])) { + return; + } + if ($user->can("bulk_edit_image_tag")) { + $tags = $_POST['bulk_tags']; + $replace = false; + if (isset($_POST['bulk_tags_replace']) && $_POST['bulk_tags_replace'] == "true") { + $replace = true; + } + + $i= $this->tag_items($event->items, $tags, $replace); + flash_message("Tagged $i items"); + } + break; + case "bulk_source": + if (!isset($_POST['bulk_source'])) { + return; + } + if ($user->can("bulk_edit_image_source")) { + $source = $_POST['bulk_source']; + $i = $this->set_source($event->items, $source); + flash_message("Set source for $i items"); + } + break; + } + } + + public function onPageRequest(PageRequestEvent $event) + { + global $page, $user; + if ($event->page_matches("bulk_action") && $user->is_admin()) { + if (!isset($_POST['bulk_action'])) { + return; + } + + $action = $_POST['bulk_action']; + + $items = []; + if (isset($_POST['bulk_selected_ids']) && $_POST['bulk_selected_ids'] != "") { + $data = json_decode($_POST['bulk_selected_ids']); + if (is_array($data)) { + foreach ($data as $id) { + if (is_numeric($id)) { + array_push($items, int_escape($id)); + } + } + } + } else if (isset($_POST['bulk_query']) && $_POST['bulk_query'] != "") { + $query = $_POST['bulk_query']; + if ($query != null && $query != "") { + $n = 0; + $tags = Tag::explode($query); + while (true) { + $results = Image::find_image_ids($n, 100, $tags); + if (count($results) == 0) { + break; + } + + reset($results); // rewind to first element in array. + $items = array_merge($items, $results); + $n += count($results); + } + } + } + + if (sizeof($items) > 0) { + reset($items); // rewind to first element in array. + $newEvent = new BulkActionEvent($action, $event, $items); + send_event($newEvent); + } + + + $page->set_mode("redirect"); + if (!isset($_SERVER['HTTP_REFERER'])) { + $_SERVER['HTTP_REFERER'] = make_link(); + } + $page->set_redirect($_SERVER['HTTP_REFERER']); + } + } + + private function sort_blocks($a, $b) + { + return $a["position"] - $b["position"]; + } + + private function delete_items(array $items): int + { + $total = 0; + foreach ($items as $id) { + try { + $image = Image::by_id($id); + if($image==null) { + continue; + } + + send_event(new ImageDeletionEvent($image)); + $total++; + } catch (Exception $e) { + flash_message("Error while removing $id: " . $e->getMessage(), "error"); + } + } + return $total; + } + + private function tag_items(array $items, string $tags, bool $replace): int + { + $tags = Tag::explode($tags); + + $pos_tag_array = []; + $neg_tag_array = []; + foreach ($tags as $new_tag) { + if (strpos($new_tag, '-') === 0) { + $neg_tag_array[] = substr($new_tag, 1); + } else { + $pos_tag_array[] = $new_tag; + } + } + + $total = 0; + if ($replace) { + foreach ($items as $id) { + $image = Image::by_id($id); + if($image==null) { + continue; + } + + send_event(new TagSetEvent($image, $tags)); + $total++; + } + } else { + foreach ($items as $id) { + $image = Image::by_id($id); + if($image==null) { + continue; + } + + $img_tags = []; + if (!empty($neg_tag_array)) { + $img_tags = array_merge($pos_tag_array, $image->get_tag_array()); + $img_tags = array_diff($img_tags, $neg_tag_array); + } else { + $img_tags = array_merge($tags, $image->get_tag_array()); + } + send_event(new TagSetEvent($image, $img_tags)); + $total++; + } + } + + return $total; + } + + private function set_source(array $items, String $source): int + { + $total = 0; + foreach ($items as $id) { + try { + $image = Image::by_id($id); + if($image==null) { + continue; + } + + send_event(new SourceSetEvent($image, $source)); + $total++; + } catch (Exception $e) { + flash_message("Error while setting source for $id: " . $e->getMessage(), "error"); + } + } + + return $total; + } +} diff --git a/ext/bulk_actions/script.js b/ext/bulk_actions/script.js new file mode 100644 index 00000000..b5e73f8b --- /dev/null +++ b/ext/bulk_actions/script.js @@ -0,0 +1,197 @@ +/*jshint bitwise:true, curly:true, forin:false, noarg:true, noempty:true, nonew:true, undef:true, strict:false, browser:true, jquery:true */ + +var bulk_selector_active = false; +var bulk_selector_initialized = false; +var bulk_selector_valid = false; + +function validate_selections(form, confirmationMessage) { + var queryOnly = false; + if(bulk_selector_active) { + var data = get_selected_items(); + if(data.length==0) { + return false; + } + } else { + var query = $(form).find('input[name="bulk_query"]').val(); + + if (query == null || query == "") { + return false; + } else { + queryOnly = true; + } + } + + + if(confirmationMessage!=null&&confirmationMessage!="") { + return confirm(confirmationMessage); + } else if(queryOnly) { + var action = $(form).find('input[name="submit_button"]').val(); + + return confirm("Perform bulk action \"" + action + "\" on all images matching the current search?"); + } + + return true; +} + + +function activate_bulk_selector () { + set_selected_items([]); + if(!bulk_selector_initialized) { + $("a.shm-thumb").each( + function (index, block) { + add_selector_button($(block)); + } + ); + } + $('#bulk_selector_controls').show(); + $('#bulk_selector_activate').hide(); + bulk_selector_active = true; + bulk_selector_initialized = true; +} + +function deactivate_bulk_selector() { + set_selected_items([]); + $('#bulk_selector_controls').hide(); + $('#bulk_selector_activate').show(); + bulk_selector_active = false; +} + +function get_selected_items() { + var data = $('#bulk_selected_ids').val(); + if(data==""||data==null) { + data = []; + } else { + data = JSON.parse(data); + } + return data; +} + +function set_selected_items(items) { + $("a.shm-thumb").removeClass('selected'); + + $(items).each( + function(index,item) { + $('a.shm-thumb[data-post-id="' + item + '"]').addClass('selected'); + } + ); + + $('input[name="bulk_selected_ids"]').val(JSON.stringify(items)); +} + +function select_item(id) { + var data = get_selected_items(); + if(!data.includes(id)) + data.push(id); + set_selected_items(data); +} + +function deselect_item(id) { + var data = get_selected_items(); + if(data.includes(id)) + data.splice(data.indexOf(id, 1)); + set_selected_items(data); +} + +function toggle_selection( id ) { + var data = get_selected_items(); + console.log(id); + if(data.includes(id)) { + data.splice(data.indexOf(id),1); + set_selected_items(data); + return false; + } else { + data.push(id); + set_selected_items(data); + return true; + } +} + + +function select_all() { + var items = []; + $("a.shm-thumb").each( + function ( index, block ) { + block = $(block); + var id = block.data("post-id"); + items.push(id); + } + ); + set_selected_items(items); +} + +function select_invert() { + var currentItems = get_selected_items(); + var items = []; + $("a.shm-thumb").each( + function ( index, block ) { + block = $(block); + var id = block.data("post-id"); + if(!currentItems.includes(id)) { + items.push(id); + } + } + ); + set_selected_items(items); +} + +function select_none() { + set_selected_items([]); +} + +function select_range(start, end) { + var data = get_selected_items(); + var selecting = false; + $("a.shm-thumb").each( + function ( index, block ) { + block = $(block); + var id = block.data("post-id"); + if(id==start) + selecting = true; + + if(selecting) { + if(!data.includes(id)) + data.push(id); + } + + if(id==end) { + selecting = false; + } + } + ); + set_selected_items(data); +} + +var last_clicked_item; + +function add_selector_button($block) { + var c = function(e) { + if(!bulk_selector_active) + return true; + + e.preventDefault(); + e.stopPropagation(); + + var id = $block.data("post-id"); + if(e.shiftKey) { + if(last_clicked_item" . + "" . + "" . + $action["block"] . + "" . + ""; + } + + if (!$hasQuery) { + $body .= ""; + } + $block = new Block("Bulk Actions", $body, "left", 30); + $page->add_block($block); + } + + public function render_tag_input() + { + return "" . + ""; + } + + public function render_source_input() + { + return ""; + } +} diff --git a/ext/cron_uploader/main.php b/ext/cron_uploader/main.php index 82ac79b6..05eb0162 100644 --- a/ext/cron_uploader/main.php +++ b/ext/cron_uploader/main.php @@ -244,8 +244,11 @@ class CronUploader extends Extension */ public function process_upload(int $upload_count = 0): bool { - global $config; + global $config, $database; + set_time_limit(0); + + $output_subdir = date('Ymd-His', time())."/"; $this->set_dir(); $this->generate_image_queue(); @@ -262,24 +265,50 @@ class CronUploader extends Extension } // Randomize Images - shuffle($this->image_queue); + //shuffle($this->image_queue); + + $merged = 0; + $added = 0; + $failed = 0; + + $failedItems = []; // Upload the file(s) for ($i = 0; $i < $upload_count && sizeof($this->image_queue)>0; $i++) { $img = array_pop($this->image_queue); try { - $this->add_image($img[0], $img[1], $img[2]); - $this->move_uploaded($img[0], $img[1], false); + $database->beginTransaction(); + $result = $this->add_image($img[0], $img[1], $img[2]); + $database->commit(); + $this->move_uploaded($img[0], $img[1], $output_subdir, false); + if($result==null) { + $merged++; + } else { + $added++; + } } catch (Exception $e) { - $this->move_uploaded($img[0], $img[1], true); + $failed++; + $this->move_uploaded($img[0], $img[1], $output_subdir, true); + $msgNumber = $this->add_upload_info("(".gettype($e).") ".$e->getMessage()); + $msgNumber = $this->add_upload_info($e->getTraceAsString()); if (strpos($e->getMessage(), 'SQLSTATE') !== false) { // Postgres invalidates the transaction if there is an SQL error, // so all subsequence transactions will fail. break; } + try { + $database->rollback(); + } catch (Exception $e) {} } } + + + $msgNumber = $this->add_upload_info("Items added: $added"); + $msgNumber = $this->add_upload_info("Items merged: $merged"); + $msgNumber = $this->add_upload_info("Items failed: $failed"); + + // Display & save upload log $this->handle_log(); @@ -287,7 +316,7 @@ class CronUploader extends Extension return true; } - private function move_uploaded($path, $filename, $corrupt = false) + private function move_uploaded($path, $filename, $output_subdir, $corrupt = false) { global $config; @@ -296,16 +325,17 @@ class CronUploader extends Extension $relativeDir = dirname(substr($path, strlen($this->root_dir) + 7)); - // Determine which dir to move to - if ($corrupt) { - // Move to corrupt dir - $newDir .= "/failed_to_upload/".$relativeDir; - $info = "ERROR: Image was not uploaded."; - } else { - $newDir .= "/uploaded/".$relativeDir; - $info = "Image successfully uploaded. "; - } - $newDir = str_replace("//", "/", $newDir."/"); + // Determine which dir to move to + if ($corrupt) { + // Move to corrupt dir + $newDir .= "/failed_to_upload/".$output_subdir.$relativeDir; + $info = "ERROR: Image was not uploaded."; + } + else { + $newDir .= "/uploaded/".$output_subdir.$relativeDir; + $info = "Image successfully uploaded. "; + } + $newDir = str_replace ( "//", "/", $newDir."/" ); if (!is_dir($newDir)) { mkdir($newDir, 0775, true); @@ -328,9 +358,9 @@ class CronUploader extends Extension $metadata = []; $metadata ['filename'] = $pathinfo ['basename']; if (array_key_exists('extension', $pathinfo)) { - $metadata['extension'] = $pathinfo['extension']; + $metadata ['extension'] = $pathinfo ['extension']; } - $metadata ['tags'] = Tag::explode($tags); + $metadata ['tags'] = Tag::explode($tags); $metadata ['source'] = null; $event = new DataUploadEvent($tmpname, $metadata); send_event($event); @@ -339,10 +369,13 @@ class CronUploader extends Extension $infomsg = ""; // Will contain info message if ($event->image_id == -1) { throw new Exception("File type not recognised. Filename: {$filename}"); + } else if ($event->image_id == null) { + $infomsg = "Image merged. Filename: {$filename}"; } else { - $infomsg = "Image uploaded. ID: {$event->image_id} - Filename: {$filename} - Tags: {$tags}"; + $infomsg = "Image uploaded. ID: {$event->image_id} - Filename: {$filename}"; } $msgNumber = $this->add_upload_info($infomsg); + return $event->image_id; } private function generate_image_queue(): void diff --git a/ext/danbooru_api/main.php b/ext/danbooru_api/main.php index 94a2bc8c..a831f366 100644 --- a/ext/danbooru_api/main.php +++ b/ext/danbooru_api/main.php @@ -366,8 +366,8 @@ class DanbooruApi extends Extension $fileinfo = pathinfo($filename); $metadata = []; $metadata['filename'] = $fileinfo['basename']; - if (array_key_exists('extension', $pathinfo)) { - $metadata['extension'] = $pathinfo['extension']; + if (array_key_exists('extension', $fileinfo)) { + $metadata['extension'] = $fileinfo['extension']; } $metadata['tags'] = $posttags; $metadata['source'] = $source; diff --git a/ext/et/main.php b/ext/et/main.php index c56c0f4d..702c9c9c 100644 --- a/ext/et/main.php +++ b/ext/et/main.php @@ -57,6 +57,8 @@ class ET extends Extension $info['thumb_quality'] = $config->get_int('thumb_quality'); $info['thumb_width'] = $config->get_int('thumb_width'); $info['thumb_height'] = $config->get_int('thumb_height'); + $info['thumb_scaling'] = $config->get_int('thumb_scaling'); + $info['thumb_type'] = $config->get_string('thumb_type'); $info['thumb_mem'] = $config->get_int("thumb_mem_limit"); $info['stat_images'] = $database->get_one("SELECT COUNT(*) FROM images"); diff --git a/ext/et/theme.php b/ext/et/theme.php index f23ea296..cb55ffb9 100644 --- a/ext/et/theme.php +++ b/ext/et/theme.php @@ -37,10 +37,12 @@ Disk use: {$info['sys_disk']} Thumbnail Generation: Engine: {$info['thumb_engine']} +Type: {$info['thumb_type']} Memory: {$info['thumb_mem']} Quality: {$info['thumb_quality']} Width: {$info['thumb_width']} Height: {$info['thumb_height']} +Scaling: {$info['thumb_scaling']} Shimmie stats: Images: {$info['stat_images']} diff --git a/ext/handle_flash/main.php b/ext/handle_flash/main.php index c1ef4bdb..cec86d13 100644 --- a/ext/handle_flash/main.php +++ b/ext/handle_flash/main.php @@ -3,14 +3,18 @@ * Name: Handle Flash * Author: Shish * Link: http://code.shishnet.org/shimmie2/ - * Description: Handle Flash files. (No thumbnail is generated for flash files) + * Description: Handle Flash files. */ class FlashFileHandler extends DataHandlerExtension { protected function create_thumb(string $hash): bool { - copy("ext/handle_flash/thumb.jpg", warehouse_path("thumbs", $hash)); + global $config; + + if(!create_thumbnail_ffmpeg($hash)) { + copy("ext/handle_flash/thumb.jpg", warehouse_path("thumbs", $hash)); + } return true; } diff --git a/ext/handle_ico/main.php b/ext/handle_ico/main.php index 504b09e1..56e3f373 100644 --- a/ext/handle_ico/main.php +++ b/ext/handle_ico/main.php @@ -24,13 +24,6 @@ class IcoFileHandler extends Extension } } - public function onThumbnailGeneration(ThumbnailGenerationEvent $event) - { - if ($this->supported_ext($event->type)) { - $this->create_thumb($event->hash); - } - } - public function onDisplayingImage(DisplayingImageEvent $event) { global $page; @@ -88,8 +81,10 @@ class IcoFileHandler extends Extension $inname = warehouse_path("images", $hash); $outname = warehouse_path("thumbs", $hash); - $w = $config->get_int("thumb_width"); - $h = $config->get_int("thumb_height"); + $tsize = get_thumbnail_size_scaled($width, $height); + $w = $tsize[0]; + $h = $tsise[1]; + $q = $config->get_int("thumb_quality"); $mem = $config->get_int("thumb_mem_limit") / 1024 / 1024; // IM takes memory in MB diff --git a/ext/handle_pixel/main.php b/ext/handle_pixel/main.php index 6d196910..04b26448 100644 --- a/ext/handle_pixel/main.php +++ b/ext/handle_pixel/main.php @@ -3,14 +3,14 @@ * Name: Handle Pixel * Author: Shish * Link: http://code.shishnet.org/shimmie2/ - * Description: Handle JPEG, PNG, GIF, etc files + * Description: Handle JPEG, PNG, GIF, WEBP, etc files */ class PixelFileHandler extends DataHandlerExtension { protected function supported_ext(string $ext): bool { - $exts = ["jpg", "jpeg", "gif", "png"]; + $exts = ["jpg", "jpeg", "gif", "png", "webp"]; $ext = (($pos = strpos($ext, '?')) !== false) ? substr($ext, 0, $pos) : $ext; return in_array(strtolower($ext), $exts); } @@ -39,7 +39,7 @@ class PixelFileHandler extends DataHandlerExtension protected function check_contents(string $tmpname): bool { - $valid = [IMAGETYPE_PNG, IMAGETYPE_GIF, IMAGETYPE_JPEG]; + $valid = [IMAGETYPE_PNG, IMAGETYPE_GIF, IMAGETYPE_JPEG, IMAGETYPE_WEBP]; if (!file_exists($tmpname)) { return false; } @@ -54,15 +54,6 @@ class PixelFileHandler extends DataHandlerExtension } protected function create_thumb(string $hash): bool - { - $outname = warehouse_path("thumbs", $hash); - if (file_exists($outname)) { - return true; - } - return $this->create_thumb_force($hash); - } - - protected function create_thumb_force(string $hash): bool { global $config; @@ -77,7 +68,7 @@ class PixelFileHandler extends DataHandlerExtension $ok = $this->make_thumb_gd($inname, $outname); break; case 'convert': - $ok = $this->make_thumb_convert($inname, $outname); + $ok = create_thumbnail_convert($hash); break; } @@ -98,100 +89,31 @@ class PixelFileHandler extends DataHandlerExtension ", 20); } - // IM thumber {{{ - private function make_thumb_convert(string $inname, string $outname): bool + // GD thumber {{{ + private function make_thumb_gd(string $inname, string $outname): bool { global $config; - $w = $config->get_int("thumb_width"); - $h = $config->get_int("thumb_height"); - $q = $config->get_int("thumb_quality"); - $convert = $config->get_string("thumb_convert_path"); - - // ffff imagemagick fails sometimes, not sure why - //$format = "'%s' '%s[0]' -format '%%[fx:w] %%[fx:h]' info:"; - //$cmd = sprintf($format, $convert, $inname); - //$size = shell_exec($cmd); - //$size = explode(" ", trim($size)); - $size = getimagesize($inname); - if ($size[0] > $size[1]*5) { - $size[0] = $size[1]*5; - } - if ($size[1] > $size[0]*5) { - $size[1] = $size[0]*5; - } - - // running the call with cmd.exe requires quoting for our paths - $format = '"%s" "%s[0]" -extent %ux%u -flatten -strip -thumbnail %ux%u -quality %u jpg:"%s"'; - $cmd = sprintf($format, $convert, $inname, $size[0], $size[1], $w, $h, $q, $outname); - $cmd = str_replace("\"convert\"", "convert", $cmd); // quotes are only needed if the path to convert contains a space; some other times, quotes break things, see github bug #27 - exec($cmd, $output, $ret); - - log_debug('handle_pixel', "Generating thumbnail with command `$cmd`, returns $ret"); - - if ($config->get_bool("thumb_optim", false)) { - exec("jpegoptim $outname", $output, $ret); + try { + $info = getimagesize($inname); + $tsize = get_thumbnail_size_scaled($info[0], $info[1]); + $image = image_resize_gd($inname, $info, $tsize[0], $tsize[1], + $outname, $config->get_string('thumb_type'),$config->get_int('thumb_quality')); + } catch(InsufficientMemoryException $e) { + $tsize = get_thumbnail_max_size_scaled(); + $thumb = imagecreatetruecolor($tsize[0], min($tsize[1], 64)); + $white = imagecolorallocate($thumb, 255, 255, 255); + $black = imagecolorallocate($thumb, 0, 0, 0); + imagefill($thumb, 0, 0, $white); + log_warning("handle_pixel","Insufficient memory while creating thumbnail: ".$e->getMessage()); + imagestring($thumb, 5, 10, 24, "Image Too Large :(", $black); + return true; + } catch(Exception $e) { + log_error("handle_pixel","Error while creating thumbnail: ".$e->getMessage()); + return false; } return true; } // }}} - // GD thumber {{{ - private function make_thumb_gd(string $inname, string $outname): bool - { - global $config; - $thumb = $this->get_thumb($inname); - $ok = imagejpeg($thumb, $outname, $config->get_int('thumb_quality')); - imagedestroy($thumb); - return $ok; - } - - private function get_thumb(string $tmpname) - { - global $config; - - $info = getimagesize($tmpname); - $width = $info[0]; - $height = $info[1]; - - $memory_use = (filesize($tmpname)*2) + ($width*$height*4) + (4*1024*1024); - $memory_limit = get_memory_limit(); - - if ($memory_use > $memory_limit) { - $w = $config->get_int('thumb_width'); - $h = $config->get_int('thumb_height'); - $thumb = imagecreatetruecolor($w, min($h, 64)); - $white = imagecolorallocate($thumb, 255, 255, 255); - $black = imagecolorallocate($thumb, 0, 0, 0); - imagefill($thumb, 0, 0, $white); - imagestring($thumb, 5, 10, 24, "Image Too Large :(", $black); - return $thumb; - } else { - if ($width > $height*5) { - $width = $height*5; - } - if ($height > $width*5) { - $height = $width*5; - } - - $image = imagecreatefromstring(file_get_contents($tmpname)); - $tsize = get_thumbnail_size($width, $height); - - $thumb = imagecreatetruecolor($tsize[0], $tsize[1]); - imagecopyresampled( - $thumb, - $image, - 0, - 0, - 0, - 0, - $tsize[0], - $tsize[1], - $width, - $height - ); - return $thumb; - } - } - // }}} } diff --git a/ext/handle_svg/main.php b/ext/handle_svg/main.php index 0b74a552..97127816 100644 --- a/ext/handle_svg/main.php +++ b/ext/handle_svg/main.php @@ -3,12 +3,12 @@ * Name: Handle SVG * Author: Shish * Link: http://code.shishnet.org/shimmie2/ - * Description: Handle static SVG files. (No thumbnail is generated for SVG files) + * Description: Handle static SVG files. */ use enshrined\svgSanitize\Sanitizer; -class SVGFileHandler extends Extension +class SVGFileHandler extends DataHandlerExtension { public function onDataUpload(DataUploadEvent $event) { @@ -32,13 +32,12 @@ class SVGFileHandler extends Extension } } - public function onThumbnailGeneration(ThumbnailGenerationEvent $event) + protected function create_thumb(string $hash): bool { - if ($this->supported_ext($event->type)) { - $hash = $event->hash; - + if(!create_thumbnail_convert($hash)) { copy("ext/handle_svg/thumb.jpg", warehouse_path("thumbs", $hash)); } + return true; } public function onDisplayingImage(DisplayingImageEvent $event) @@ -68,13 +67,13 @@ class SVGFileHandler extends Extension } } - private function supported_ext(string $ext): bool + protected function supported_ext(string $ext): bool { $exts = ["svg"]; return in_array(strtolower($ext), $exts); } - private function create_image_from_data(string $filename, array $metadata): Image + protected function create_image_from_data(string $filename, array $metadata): Image { $image = new Image(); @@ -92,7 +91,7 @@ class SVGFileHandler extends Extension return $image; } - private function check_contents(string $file): bool + protected function check_contents(string $file): bool { if (!file_exists($file)) { return false; diff --git a/ext/handle_video/main.php b/ext/handle_video/main.php index 683ced7e..192547b2 100644 --- a/ext/handle_video/main.php +++ b/ext/handle_video/main.php @@ -55,62 +55,16 @@ class VideoFileHandler extends DataHandlerExtension */ protected function create_thumb(string $hash): bool { - global $config; - $ok = false; - $ffmpeg = $config->get_string("thumb_ffmpeg_path"); - $inname = warehouse_path("images", $hash); - $outname = warehouse_path("thumbs", $hash); - - $orig_size = $this->video_size($inname); - $scaled_size = get_thumbnail_size($orig_size[0], $orig_size[1]); - $cmd = escapeshellcmd(implode(" ", [ - escapeshellarg($ffmpeg), - "-y", "-i", escapeshellarg($inname), - "-vf", "scale={$scaled_size[0]}:{$scaled_size[1]}", - "-ss", "00:00:00.0", - "-f", "image2", - "-vframes", "1", - escapeshellarg($outname), - ])); - - exec($cmd, $output, $ret); - - if ((int)$ret == (int)0) { - $ok = true; - log_error('handle_video', "Generating thumbnail with command `$cmd`, returns $ret"); - } else { - log_debug('handle_video', "Generating thumbnail with command `$cmd`, returns $ret"); - } + $ok = create_thumbnail_ffmpeg($hash); return $ok; } - protected function video_size(string $filename) + protected function video_size(string $filename): array { - global $config; - $ffmpeg = $config->get_string("thumb_ffmpeg_path"); - $cmd = escapeshellcmd(implode(" ", [ - escapeshellarg($ffmpeg), - "-y", "-i", escapeshellarg($filename), - "-vstats" - ])); - $output = shell_exec($cmd . " 2>&1"); - // error_log("Getting size with `$cmd`"); - - $regex_sizes = "/Video: .* ([0-9]{1,4})x([0-9]{1,4})/"; - if (preg_match($regex_sizes, $output, $regs)) { - if (preg_match("/displaymatrix: rotation of (90|270).00 degrees/", $output)) { - $size = [$regs[2], $regs[1]]; - } else { - $size = [$regs[1], $regs[2]]; - } - } else { - $size = [1, 1]; - } - log_debug('handle_video', "Getting video size with `$cmd`, returns $output -- $size[0], $size[1]"); - return $size; + return video_size($filename); } protected function supported_ext(string $ext): bool diff --git a/ext/image/main.php b/ext/image/main.php index 116ae5db..362cb944 100644 --- a/ext/image/main.php +++ b/ext/image/main.php @@ -19,7 +19,9 @@ class ImageIO extends Extension global $config; $config->set_default_int('thumb_width', 192); $config->set_default_int('thumb_height', 192); + $config->set_default_int('thumb_scaling', 100); $config->set_default_int('thumb_quality', 75); + $config->set_default_string('thumb_type', 'jpg'); $config->set_default_int('thumb_mem_limit', parse_shorthand_int('8MB')); $config->set_default_string('thumb_convert_path', 'convert'); @@ -137,8 +139,15 @@ class ImageIO extends Extension $thumbers['Built-in GD'] = "gd"; $thumbers['ImageMagick'] = "convert"; + $thumb_types = []; + $thumb_types['JPEG'] = "jpg"; + $thumb_types['WEBP'] = "webp"; + + $sb = new SetupBlock("Thumbnailing"); $sb->add_choice_option("thumb_engine", $thumbers, "Engine: "); + $sb->add_label("
"); + $sb->add_choice_option("thumb_type", $thumb_types, "Filetype: "); $sb->add_label("
Size "); $sb->add_int_option("thumb_width"); @@ -147,7 +156,11 @@ class ImageIO extends Extension $sb->add_label(" px at "); $sb->add_int_option("thumb_quality"); $sb->add_label(" % quality "); - + + $sb->add_label("
High-DPI scaling "); + $sb->add_int_option("thumb_scaling"); + $sb->add_label("%"); + if ($config->get_string("thumb_engine") == "convert") { $sb->add_label("
ImageMagick Binary: "); $sb->add_text_option("thumb_convert_path"); @@ -240,7 +253,13 @@ class ImageIO extends Extension if (!is_null($image)) { $page->set_mode("data"); if ($type == "thumb") { - $page->set_type("image/jpeg"); + $ext = $config->get_string("thumb_type"); + if (array_key_exists($ext, MIME_TYPE_MAP)) { + $page->set_type(MIME_TYPE_MAP[$ext]); + } else { + $page->set_type("image/jpeg"); + } + $file = $image->get_thumb_filename(); } else { $page->set_type($image->get_mime_type()); @@ -259,6 +278,9 @@ class ImageIO extends Extension $page->set_data(""); } else { $page->add_http_header("Last-Modified: $gmdate_mod"); + if ($type != "thumb") { + $page->add_http_header("Content-Disposition: inline; filename=".$image->get_nice_image_name()); + } $page->set_data(file_get_contents($file)); if ($config->get_int("image_expires")) { @@ -291,7 +313,7 @@ class ImageIO extends Extension if (is_null($existing)) { throw new ImageReplaceException("Image to replace does not exist!"); } - + if (strlen(trim($image->source)) == 0) { $image->source = $existing->get_source(); } @@ -301,6 +323,7 @@ class ImageIO extends Extension and have it stored in a 'replaced images' list that could be inspected later by an admin? */ + log_debug("image", "Removing image with hash ".$existing->hash); $existing->remove_image_only(); // Actually delete the old image file from disk @@ -319,6 +342,9 @@ class ImageIO extends Extension ] ); + /* Generate new thumbnail */ + send_event(new ThumbnailGenerationEvent($image->hash, strtolower($image->ext))); + log_info("image", "Replaced Image #{$id} with ({$image->hash})"); } // }}} end replace diff --git a/ext/qr_code/main.php b/ext/qr_code/main.php index 6d21cc37..236c5c6d 100644 --- a/ext/qr_code/main.php +++ b/ext/qr_code/main.php @@ -11,6 +11,6 @@ class QRImage extends Extension { public function onDisplayingImage(DisplayingImageEvent $event) { - $this->theme->links_block(make_http(make_link('image/'.$event->image->id.'.jpg'))); + $this->theme->links_block(make_http(make_link('image/'.$event->image->id.'.'.$event->image->ext))); } } diff --git a/ext/rating/main.php b/ext/rating/main.php index eba2c262..948e4b1a 100644 --- a/ext/rating/main.php +++ b/ext/rating/main.php @@ -73,13 +73,13 @@ class Ratings extends Extension $event->panel->add_block($sb); } - public function onPostListBuilding(PostListBuildingEvent $event) - { - global $user; - if ($user->is_admin() && !empty($event->search_terms)) { - $this->theme->display_bulk_rater(Tag::implode($event->search_terms)); - } - } + // public function onPostListBuilding(PostListBuildingEvent $event) + // { + // global $user; + // if ($user->is_admin() && !empty($event->search_terms)) { + // $this->theme->display_bulk_rater(Tag::implode($event->search_terms)); + // } + // } public function onDisplayingImage(DisplayingImageEvent $event) @@ -143,6 +143,65 @@ class Ratings extends Extension } } + public function onTagTermParse(TagTermParseEvent $event) + { + $matches = []; + + if (preg_match("/^rating[=|:](?:([sqeu]+)|(safe|questionable|explicit|unknown))$/D", strtolower($event->term), $matches) && $event->parse) { + $ratings = $matches[1] ? $matches[1] : $matches[2][0]; + $ratings = array_intersect(str_split($ratings), str_split(Ratings::get_user_privs($user))); + + $rating = $ratings[0]; + + $image = Image::by_id($event->id); + + $re = new RatingSetEvent($image, $rating); + + send_event($re); + } + + if (!empty($matches)) { + $event->metatag = true; + } + } + + public function onBulkActionBlockBuilding(BulkActionBlockBuildingEvent $event) + { + global $user; + + if ($user->is_admin()) { + $event->add_action("bulk_rate","Set Rating","",$this->theme->get_selection_rater_html("bulk_rating")); + } + + } + + public function onBulkAction(BulkActionEvent $event) + { + global $user; + + switch($event->action) { + case "bulk_rate": + if (!isset($_POST['bulk_rating'])) { + return; + } + if ($user->is_admin()) { + $rating = $_POST['bulk_rating']; + $total = 0; + foreach ($event->items as $id) { + $image = Image::by_id($id); + if($image==null) { + continue; + } + + send_event(new RatingSetEvent($image, $rating)); + $total++; + } + flash_message("Rating set for $total items"); + } + break; + } + } + public function onPageRequest(PageRequestEvent $event) { global $user, $page; @@ -271,8 +330,17 @@ class Ratings extends Extension } if ($config->get_int("ext_ratings2_version") < 3) { - $database->Execute("ALTER TABLE images CHANGE rating rating CHAR(1) NOT NULL DEFAULT 'u'"); - $config->set_int("ext_ratings2_version", 3); + $database->Execute("UPDATE images SET rating = 'u' WHERE rating is null"); + switch($database->get_driver_name()) { + case "mysql": + $database->Execute("ALTER TABLE images CHANGE rating rating CHAR(1) NOT NULL DEFAULT 'u'"); + break; + case "pgsql": + $database->Execute("ALTER TABLE images ALTER COLUMN rating SET DEFAULT 'u'"); + $database->Execute("ALTER TABLE images ALTER COLUMN rating SET NOT NULL"); + break; + } + $config->set_int("ext_ratings2_version", 3); } } diff --git a/ext/rating/theme.php b/ext/rating/theme.php index 241c20c7..67f85831 100644 --- a/ext/rating/theme.php +++ b/ext/rating/theme.php @@ -45,4 +45,13 @@ class RatingsTheme extends Themelet "; $page->add_block(new Block("List Controls", $html, "left")); } + + public function get_selection_rater_html(String $id = "select_rating") { + return ""; + } } diff --git a/ext/regen_thumb/main.php b/ext/regen_thumb/main.php index 5df24698..0a0e8963 100644 --- a/ext/regen_thumb/main.php +++ b/ext/regen_thumb/main.php @@ -15,14 +15,24 @@ class RegenThumb extends Extension { + public function regenerate_thumbnail($image, $force = true): string + { + global $database; + $event = new ThumbnailGenerationEvent($image->hash, $image->ext, $force); + send_event($event); + $database->cache->delete("thumb-block:{$image->id}"); + return $event->generated; + } + public function onPageRequest(PageRequestEvent $event) { global $database, $page, $user; if ($event->page_matches("regen_thumb/one") && $user->can("delete_image") && isset($_POST['image_id'])) { $image = Image::by_id(int_escape($_POST['image_id'])); - send_event(new ThumbnailGenerationEvent($image->hash, $image->ext, true)); - $database->cache->delete("thumb-block:{$image->id}"); + + $this->regenerate_thumbnail($image); + $this->theme->display_results($page, $image); } if ($event->page_matches("regen_thumb/mass") && $user->can("delete_image") && isset($_POST['tags'])) { @@ -30,8 +40,7 @@ class RegenThumb extends Extension $images = Image::find_images(0, 10000, $tags); foreach ($images as $image) { - send_event(new ThumbnailGenerationEvent($image->hash, $image->ext, true)); - $database->cache->delete("thumb-block:{$image->id}"); + $this->regenerate_thumbnail($image); } $page->set_mode("redirect"); @@ -47,11 +56,156 @@ class RegenThumb extends Extension } } - public function onPostListBuilding(PostListBuildingEvent $event) + // public function onPostListBuilding(PostListBuildingEvent $event) + // { + // global $user; + // if ($user->can("delete_image") && !empty($event->search_terms)) { + // $event->add_control($this->theme->mtr_html(Tag::implode($event->search_terms))); + // } + // } + + public function onBulkActionBlockBuilding(BulkActionBlockBuildingEvent $event) { global $user; - if ($user->can("delete_image") && !empty($event->search_terms)) { - $event->add_control($this->theme->mtr_html(Tag::implode($event->search_terms))); + + if ($user->can("delete_image")) { + $event->add_action("bulk_regen","Regen Thumbnails","",$this->theme->bulk_html()); + } + + } + + public function onBulkAction(BulkActionEvent $event) + { + global $user; + + switch($event->action) { + case "bulk_regen": + if ($user->can("delete_image")) { + $force = true; + if(isset($_POST["bulk_regen_thumb_missing_only"]) + &&$_POST["bulk_regen_thumb_missing_only"]=="true") + { + $force=false; + } + + $total = 0; + foreach ($event->items as $id) { + $image = Image::by_id($id); + if($image==null) { + continue; + } + + if($this->regenerate_thumbnail($image, $force)) { + $total++; + } + } + flash_message("Regenerated thumbnails for $total items"); + } + break; } } + + public function onAdminBuilding(AdminBuildingEvent $event) + { + $this->theme->display_admin_block(); + } + + public function onAdminAction(AdminActionEvent $event) { + global $database; + + switch($event->action) { + case "regen_thumbs": + $event->redirect = true; + $force = false; + if(isset($_POST["regen_thumb_force"])&&$_POST["regen_thumb_force"]=="true") { + $force=true; + } + $limit = 1000; + if(isset($_POST["regen_thumb_limit"])&&is_numeric($_POST["regen_thumb_limit"])) { + $limit=intval($_POST["regen_thumb_limit"]); + } + + $type = ""; + if(isset($_POST["regen_thumb_limit"])) { + $type = $_POST["regen_thumb_type"]; + } + $images = $this->get_images($type); + + $i = 0; + foreach ($images as $image) { + if(!$force) { + $path = warehouse_path("thumbs", $image["hash"], false); + if(file_exists($path)) { + continue; + } + } + $event = new ThumbnailGenerationEvent($image["hash"], $image["ext"], $force); + send_event($event); + if($event->generated) { + $i++; + } + if($i>=$limit) { + break; + } + } + flash_message("Re-generated $i thumbnails"); + break; + case "delete_thumbs": + $event->redirect = true; + + if(isset($_POST["delete_thumb_type"])&&$_POST["delete_thumb_type"]!="") { + $images = $this->get_images($_POST["delete_thumb_type"]); + + $i = 0; + foreach ($images as $image) { + $outname = warehouse_path("thumbs", $image["hash"]); + if(file_exists($outname)) { + unlink($outname); + $i++; + } + } + flash_message("Deleted $i thumbnails for ".$_POST["delete_thumb_type"]." images"); + } else { + $dir = "data/thumbs/"; + $this->remove_dir_recursively($dir); + flash_message("Deleted all thumbnails"); + } + + + break; + } + } + + function get_images(String $ext = null) + { + global $database; + + $query = "SELECT hash, ext FROM images"; + $args = []; + if($ext!=null&&$ext!="") { + $query .= " WHERE ext = :ext"; + $args["ext"] = $ext; + } + + return $database->get_all($query, $args); + } + + function remove_dir_recursively($dir) + { + if (is_dir($dir)) { + $objects = scandir($dir); + foreach ($objects as $object) { + if ($object != "." && $object != "..") { + if (filetype($dir."/".$object) == "dir") { + $this->remove_dir_recursively($dir."/".$object); + } else { + unlink ($dir."/".$object); + } + } + } + reset($objects); + rmdir($dir); + } + } + } diff --git a/ext/regen_thumb/theme.php b/ext/regen_thumb/theme.php index fc40d835..b118f194 100644 --- a/ext/regen_thumb/theme.php +++ b/ext/regen_thumb/theme.php @@ -37,4 +37,48 @@ class RegenThumbTheme extends Themelet "; return $html; } + + public function bulk_html() { + return ""; + } + + public function display_admin_block() + { + global $page, $database; + + $types = []; + $results = $database->get_all("SELECT ext, count(*) count FROM images group by ext"); + foreach ($results as $result) { + array_push($types,""); + } + + $html = " + Will only regenerate missing thumbnails, unless force is selected. Force will override the limit and will likely take a very long time to process. +

".make_form(make_link("admin/regen_thumbs"))." + + + + + +
+ +
+

+

".make_form(make_link("admin/delete_thumbs"),"POST",False, "","return confirm('Are you sure you want to delete all thumbnails?')")." + + + +
+ +
+

+ "; + $page->add_block(new Block("Regen Thumbnails", $html)); + } } diff --git a/ext/resize/main.php b/ext/resize/main.php index 39de0850..41eabde0 100644 --- a/ext/resize/main.php +++ b/ext/resize/main.php @@ -11,25 +11,20 @@ * Documentation: * This extension allows admins to resize images. */ - -/** - * This class is just a wrapper around SCoreException. - */ -class ImageResizeException extends SCoreException -{ - public $error; - - public function __construct(string $error) - { - $this->error = $error; - } -} - /** * This class handles image resize requests. */ class ResizeImage extends Extension { + /** + * Needs to be after the data processing extensions + */ + public function get_priority(): int + { + return 55; + } + + public function onInitExt(InitExtEvent $event) { global $config; @@ -69,7 +64,7 @@ class ResizeImage extends Extension $image_obj = Image::by_id($event->image_id); - if ($config->get_bool("resize_upload") == true && ($image_obj->ext == "jpg" || $image_obj->ext == "png" || $image_obj->ext == "gif")) { + if ($config->get_bool("resize_upload") == true && ($image_obj->ext == "jpg" || $image_obj->ext == "png" || $image_obj->ext == "gif" || $image_obj->ext == "webp")) { $width = $height = 0; if ($config->get_int("resize_default_width") !== 0) { @@ -159,11 +154,6 @@ class ResizeImage extends Extension // Private functions /* ----------------------------- */ - - /** - * This function could be made much smaller by using the ImageReplaceEvent - * ie: Pretend that we are replacing the image with a resized copy. - */ private function resize_image(Image $image_obj, int $width, int $height) { global $database; @@ -174,134 +164,42 @@ class ResizeImage extends Extension $hash = $image_obj->hash; $image_filename = warehouse_path("images", $hash); + $info = getimagesize($image_filename); - /* Get the image file type */ - $pathinfo = pathinfo($image_obj->filename); - $filetype = strtolower($pathinfo['extension']); - if (($image_obj->width != $info[0]) || ($image_obj->height != $info[1])) { throw new ImageResizeException("The current image size does not match what is set in the database! - Aborting Resize."); } - $memory_use = $this->calc_memory_use($info); - $memory_limit = get_memory_limit(); - if ($memory_use > $memory_limit) { - throw new ImageResizeException("The image is too large to resize given the memory limits. ($memory_use > $memory_limit)"); - } - list($new_height, $new_width) = $this->calc_new_size($image_obj, $width, $height); - /* Attempt to load the image */ - switch ($info[2]) { - case IMAGETYPE_GIF: $image = imagecreatefromgif($image_filename); break; - case IMAGETYPE_JPEG: $image = imagecreatefromjpeg($image_filename); break; - case IMAGETYPE_PNG: $image = imagecreatefrompng($image_filename); break; - default: - throw new ImageResizeException("Unsupported image type (Only GIF, JPEG, and PNG are supported)."); - } - - // Handle transparent images - - $image_resized = imagecreatetruecolor($new_width, $new_height); - - if ($info[2] == IMAGETYPE_GIF) { - $transparency = imagecolortransparent($image); - - // If we have a specific transparent color - if ($transparency >= 0) { - // Get the original image's transparent color's RGB values - $transparent_color = imagecolorsforindex($image, $transparency); - - // Allocate the same color in the new image resource - $transparency = imagecolorallocate($image_resized, $transparent_color['red'], $transparent_color['green'], $transparent_color['blue']); - - // Completely fill the background of the new image with allocated color. - imagefill($image_resized, 0, 0, $transparency); - - // Set the background color for new image to transparent - imagecolortransparent($image_resized, $transparency); - } - } elseif ($info[2] == IMAGETYPE_PNG) { - // - // More info here: http://stackoverflow.com/questions/279236/how-do-i-resize-pngs-with-transparency-in-php - // - imagealphablending($image_resized, false); - imagesavealpha($image_resized, true); - $transparent_color = imagecolorallocatealpha($image_resized, 255, 255, 255, 127); - imagefilledrectangle($image_resized, 0, 0, $new_width, $new_height, $transparent_color); - } - - // Actually resize the image. - imagecopyresampled($image_resized, $image, 0, 0, 0, 0, $new_width, $new_height, $image_obj->width, $image_obj->height); - /* Temp storage while we resize */ $tmp_filename = tempnam("/tmp", 'shimmie_resize'); if (empty($tmp_filename)) { throw new ImageResizeException("Unable to save temporary image file."); } - - /* Output to the same format as the original image */ - switch ($info[2]) { - case IMAGETYPE_GIF: imagegif($image_resized, $tmp_filename); break; - case IMAGETYPE_JPEG: imagejpeg($image_resized, $tmp_filename); break; - case IMAGETYPE_PNG: imagepng($image_resized, $tmp_filename); break; - default: - throw new ImageResizeException("Failed to save the new image - Unsupported image type."); - } - + + image_resize_gd($image_filename, $info, $new_width, $new_height, $tmp_filename); + + $new_image = new Image(); + $new_image->hash = md5_file($tmp_filename); + $new_image->filesize = filesize($tmp_filename); + $new_image->filename = 'resized-'.$image_obj->filename; + $new_image->width = $new_width; + $new_image->height = $new_height; + $new_image->ext = $image_obj->ext; + /* Move the new image into the main storage location */ - $new_hash = md5_file($tmp_filename); - $new_size = filesize($tmp_filename); - $target = warehouse_path("images", $new_hash); + $target = warehouse_path("images", $new_image->hash); if (!@copy($tmp_filename, $target)) { throw new ImageResizeException("Failed to copy new image file from temporary location ({$tmp_filename}) to archive ($target)"); } - $new_filename = 'resized-'.$image_obj->filename; /* Remove temporary file */ @unlink($tmp_filename); - /* Delete original image and thumbnail */ - log_debug("image", "Removing image with hash ".$hash); - $image_obj->remove_image_only(); + send_event(new ImageReplaceEvent($image_obj->id, $new_image)); - /* Generate new thumbnail */ - send_event(new ThumbnailGenerationEvent($new_hash, $filetype)); - - /* Update the database */ - $database->Execute(" - UPDATE images SET filename = :filename, filesize = :filesize, hash = :hash, width = :width, height = :height - WHERE id = :id - ", [ - "filename"=>$new_filename, "filesize"=>$new_size, "hash"=>$new_hash, - "width"=>$new_width, "height"=>$new_height, "id"=>$image_obj->id - ]); - - log_info("resize", "Resized Image #{$image_obj->id} - New hash: {$new_hash}"); - } - - /** - * Check Memory usage limits - * - * Old check: $memory_use = (filesize($image_filename)*2) + ($width*$height*4) + (4*1024*1024); - * New check: $memory_use = $width * $height * ($bits_per_channel) * channels * 2.5 - * - * It didn't make sense to compute the memory usage based on the NEW size for the image. ($width*$height*4) - * We need to consider the size that we are GOING TO instead. - * - * The factor of 2.5 is simply a rough guideline. - * http://stackoverflow.com/questions/527532/reasonable-php-memory-limit-for-image-resize - */ - private function calc_memory_use(array $info): int - { - if (isset($info['bits']) && isset($info['channels'])) { - $memory_use = ($info[0] * $info[1] * ($info['bits'] / 8) * $info['channels'] * 2.5) / 1024; - } else { - // If we don't have bits and channel info from the image then assume default values - // of 8 bits per color and 4 channels (R,G,B,A) -- ie: regular 24-bit color - $memory_use = ($info[0] * $info[1] * 1 * 4 * 2.5) / 1024; - } - return (int)$memory_use; + log_info("resize", "Resized Image #{$image_obj->id} - New hash: {$new_image->hash}"); } /** diff --git a/ext/rotate/main.php b/ext/rotate/main.php index ba5d0e98..68ac609d 100644 --- a/ext/rotate/main.php +++ b/ext/rotate/main.php @@ -106,11 +106,6 @@ class RotateImage extends Extension // Private functions /* ----------------------------- */ - - /** - * This function could be made much smaller by using the ImageReplaceEvent - * ie: Pretend that we are replacing the image with a rotated copy. - */ private function rotate_image(int $image_id, int $deg) { global $database; @@ -129,24 +124,10 @@ class RotateImage extends Extension if (file_exists($image_filename)==false) { throw new ImageRotateException("$image_filename does not exist."); } + $info = getimagesize($image_filename); - /* Get the image file type */ - $pathinfo = pathinfo($image_obj->filename); - $filetype = strtolower($pathinfo['extension']); - /* - Check Memory usage limits - - Old check: $memory_use = (filesize($image_filename)*2) + ($width*$height*4) + (4*1024*1024); - New check: memory_use = width * height * (bits per channel) * channels * 2.5 - - It didn't make sense to compute the memory usage based on the NEW size for the image. ($width*$height*4) - We need to consider the size that we are GOING TO instead. - - The factor of 2.5 is simply a rough guideline. - http://stackoverflow.com/questions/527532/reasonable-php-memory-limit-for-image-resize - */ - $memory_use = ($info[0] * $info[1] * ($info['bits'] / 8) * $info['channels'] * 2.5) / 1024; + $memory_use =calc_memory_use ($info); $memory_limit = get_memory_limit(); if ($memory_use > $memory_limit) { @@ -181,9 +162,23 @@ class RotateImage extends Extension } } */ - - $image_rotated = imagerotate($image, $deg, 0); - + + $background_color = 0; + switch($info[2]){ + case IMAGETYPE_PNG: + case IMAGETYPE_WEBP: + $background_color = imagecolorallocatealpha($image, 0, 0, 0, 127); + break; + } + if($background_color===false) { + throw new ImageRotateException("Unable to allocate transparent color"); + } + + $image_rotated = imagerotate($image, $deg, $background_color); + if($image_rotated===false) { + throw new ImageRotateException("Image rotate failed"); + } + /* Temp storage while we rotate */ $tmp_filename = tempnam(ini_get('upload_tmp_dir'), 'shimmie_rotate'); if (empty($tmp_filename)) { @@ -191,49 +186,42 @@ class RotateImage extends Extension } /* Output to the same format as the original image */ + $result = false; switch ($info[2]) { - case IMAGETYPE_GIF: imagegif($image_rotated, $tmp_filename); break; - case IMAGETYPE_JPEG: imagejpeg($image_rotated, $tmp_filename); break; - case IMAGETYPE_PNG: imagepng($image_rotated, $tmp_filename); break; + case IMAGETYPE_GIF: $result = imagegif($image_rotated, $tmp_filename); break; + case IMAGETYPE_JPEG: $result = imagejpeg($image_rotated, $tmp_filename); break; + case IMAGETYPE_PNG: $result = imagepng($image_rotated, $tmp_filename,9); break; + case IMAGETYPE_WEBP: $result = imagewebp($image_rotated, $tmp_filename); break; + case IMAGETYPE_BMP: $result = imagebmp($image_rotated, $tmp_filename,true); break; default: throw new ImageRotateException("Unsupported image type."); } - + + if($result===false) { + throw new ImageRotateException("Could not save image: ".$tmp_filename); + } + + list($new_width, $new_height) = getimagesize($tmp_filename); + + $new_image = new Image(); + $new_image->hash = md5_file($tmp_filename); + $new_image->filesize = filesize($tmp_filename); + $new_image->filename = 'rotated-'.$image_obj->filename; + $new_image->width = $new_width; + $new_image->height = $new_height; + $new_image->ext = $image_obj->ext; + /* Move the new image into the main storage location */ - $new_hash = md5_file($tmp_filename); - $new_size = filesize($tmp_filename); - $target = warehouse_path("images", $new_hash); + $target = warehouse_path("images", $new_image->hash); if (!@copy($tmp_filename, $target)) { throw new ImageRotateException("Failed to copy new image file from temporary location ({$tmp_filename}) to archive ($target)"); } - $new_filename = 'rotated-'.$image_obj->filename; - - list($new_width, $new_height) = getimagesize($target); - /* Remove temporary file */ @unlink($tmp_filename); - /* Delete original image and thumbnail */ - log_debug("image", "Removing image with hash ".$hash); - $image_obj->remove_image_only(); - - /* Generate new thumbnail */ - send_event(new ThumbnailGenerationEvent($new_hash, $filetype)); - - /* Update the database */ - $database->Execute( - "UPDATE images SET - filename = :filename, filesize = :filesize, hash = :hash, width = :width, height = :height - WHERE - id = :id - ", - [ - "filename"=>$new_filename, "filesize"=>$new_size, "hash"=>$new_hash, - "width"=>$new_width, "height"=>$new_height, "id"=>$image_id - ] - ); - - log_info("rotate", "Rotated Image #{$image_id} - New hash: {$new_hash}"); + send_event(new ImageReplaceEvent($image_id, $new_image)); + + log_info("rotate", "Rotated Image #{$image_id} - New hash: {$new_image->hash}"); } } diff --git a/ext/tag_edit/main.php b/ext/tag_edit/main.php index 8a74750a..19f0dfa0 100644 --- a/ext/tag_edit/main.php +++ b/ext/tag_edit/main.php @@ -179,13 +179,13 @@ class TagEdit extends Extension } } - public function onPostListBuilding(PostListBuildingEvent $event) - { - global $user; - if ($user->can("bulk_edit_image_source") && !empty($event->search_terms)) { - $event->add_control($this->theme->mss_html(Tag::implode($event->search_terms))); - } - } + // public function onPostListBuilding(PostListBuildingEvent $event) + // { + // global $user; + // if ($user->can("bulk_edit_image_source") && !empty($event->search_terms)) { + // $event->add_control($this->theme->mss_html(Tag::implode($event->search_terms))); + // } + // } public function onImageInfoSet(ImageInfoSetEvent $event) { diff --git a/ext/transcode/main.php b/ext/transcode/main.php new file mode 100644 index 00000000..9e63233e --- /dev/null +++ b/ext/transcode/main.php @@ -0,0 +1,459 @@ + + * Description: Allows admins to automatically and manually transcode images. + * License: MIT + * Version: 1.0 + * Documentation: + * Can transcode on-demand and automatically on upload. Config screen allows choosing an output format for each of the supported input formats. + * Supports GD and ImageMagick. Both support bmp, gif, jpg, png, and webp as inputs, and jpg, png, and lossy webp as outputs. + * ImageMagick additionally supports tiff and psd inputs, and webp lossless output. + * If and image is uanble to be transcoded for any reason, the upload will continue unaffected. + */ + + /* + * This is used by the image transcoding code when there is an error while transcoding + */ +class ImageTranscodeException extends SCoreException{ } + + +class TranscodeImage extends Extension +{ + const CONVERSION_ENGINES = [ + "GD" => "gd", + "ImageMagick" => "convert", + ]; + + const ENGINE_INPUT_SUPPORT = [ + "gd" => [ + "bmp", + "gif", + "jpg", + "png", + "webp", + ], + "convert" => [ + "bmp", + "gif", + "jpg", + "png", + "psd", + "tiff", + "webp", + ] + ]; + + const ENGINE_OUTPUT_SUPPORT = [ + "gd" => [ + "jpg", + "png", + "webp-lossy", + ], + "convert" => [ + "jpg", + "png", + "webp-lossy", + "webp-lossless", + ] + ]; + + const LOSSLESS_FORMATS = [ + "webp-lossless", + "png", + ]; + + const INPUT_FORMATS = [ + "BMP" => "bmp", + "GIF" => "gif", + "JPG" => "jpg", + "PNG" => "png", + "PSD" => "psd", + "TIFF" => "tiff", + "WEBP" => "webp", + ]; + + const FORMAT_ALIASES = [ + "tif" => "tiff", + "jpeg" => "jpg", + ]; + + const OUTPUT_FORMATS = [ + "" => "", + "JPEG (lossy)" => "jpg", + "PNG (lossless)" => "png", + "WEBP (lossy)" => "webp-lossy", + "WEBP (lossless)" => "webp-lossless", + ]; + + /** + * Needs to be after upload, but before the processing extensions + */ + public function get_priority(): int + { + return 45; + } + + + public function onInitExt(InitExtEvent $event) + { + global $config; + $config->set_default_bool('transcode_enabled', true); + $config->set_default_bool('transcode_upload', false); + $config->set_default_string('transcode_engine', "gd"); + $config->set_default_int('transcode_quality', 80); + + foreach(array_values(self::INPUT_FORMATS) as $format) { + $config->set_default_string('transcode_upload_'.$format, ""); + } + } + + public function onImageAdminBlockBuilding(ImageAdminBlockBuildingEvent $event) + { + global $user, $config; + + if ($user->is_admin() && $config->get_bool("resize_enabled")) { + $engine = $config->get_string("transcode_engine"); + if($this->can_convert_format($engine,$event->image->ext)) { + $options = $this->get_supported_output_formats($engine, $event->image->ext); + $event->add_part($this->theme->get_transcode_html($event->image, $options)); + } + } + } + + public function onSetupBuilding(SetupBuildingEvent $event) + { + global $config; + + $engine = $config->get_string("transcode_engine"); + + + $sb = new SetupBlock("Image Transcode"); + $sb->add_bool_option("transcode_enabled", "Allow transcoding images: "); + $sb->add_bool_option("transcode_upload", "
Transcode on upload: "); + $sb->add_choice_option('transcode_engine',self::CONVERSION_ENGINES,"
Transcode engine: "); + foreach(self::INPUT_FORMATS as $display=>$format) { + if(in_array($format, self::ENGINE_INPUT_SUPPORT[$engine])) { + $outputs = $this->get_supported_output_formats($engine, $format); + $sb->add_choice_option('transcode_upload_'.$format,$outputs,"
$display to: "); + } + } + $sb->add_int_option("transcode_quality", "
Lossy format quality: "); + $event->panel->add_block($sb); + } + + public function onDataUpload(DataUploadEvent $event) + { + global $config, $page; + + if ($config->get_bool("transcode_upload") == true) { + $ext = strtolower($event->type); + + $ext = $this->clean_format($ext); + + if($event->type=="gif"&&is_animated_gif($event->tmpname)) { + return; + } + + if(in_array($ext, array_values(self::INPUT_FORMATS))) { + $target_format = $config->get_string("transcode_upload_".$ext); + if(empty($target_format)) { + return; + } + try { + $new_image = $this->transcode_image($event->tmpname, $ext, $target_format); + $event->set_type($this->determine_ext($target_format)); + $event->set_tmpname($new_image); + } catch(Exception $e) { + log_error("transcode","Error while performing upload transcode: ".$e->getMessage()); + // We don't want to interfere with the upload process, + // so if something goes wrong the untranscoded image jsut continues + } + } + } + } + + + + public function onPageRequest(PageRequestEvent $event) + { + global $page, $user; + + if ($event->page_matches("transcode") && $user->is_admin()) { + $image_id = int_escape($event->get_arg(0)); + if (empty($image_id)) { + $image_id = isset($_POST['image_id']) ? int_escape($_POST['image_id']) : null; + } + // Try to get the image ID + if (empty($image_id)) { + throw new ImageTranscodeException("Can not resize Image: No valid Image ID given."); + } + $image_obj = Image::by_id($image_id); + if (is_null($image_obj)) { + $this->theme->display_error(404, "Image not found", "No image in the database has the ID #$image_id"); + } else { + if (isset($_POST['transcode_format'])) { + + try { + $this->transcode_and_replace_image($image_obj, $_POST['transcode_format']); + $page->set_mode("redirect"); + $page->set_redirect(make_link("post/view/".$image_id)); + } catch (ImageTranscodeException $e) { + $this->theme->display_transcode_error($page, "Error Transcoding", $e->getMessage()); + } + } + } + } + } + + + public function onBulkActionBlockBuilding(BulkActionBlockBuildingEvent $event) + { + global $user, $config; + + $engine = $config->get_string("transcode_engine"); + + if ($user->is_admin()) { + $event->add_action("bulk_transcode","Transcode","",$this->theme->get_transcode_picker_html($this->get_supported_output_formats($engine))); + } + + } + + public function onBulkAction(BulkActionEvent $event) + { + global $user, $database; + + switch($event->action) { + case "bulk_transcode": + if (!isset($_POST['transcode_format'])) { + return; + } + if ($user->is_admin()) { + $format = $_POST['transcode_format']; + $total = 0; + foreach ($event->items as $id) { + try { + $database->beginTransaction(); + $image = Image::by_id($id); + if($image==null) { + continue; + } + + $this->transcode_and_replace_image($image, $format); + // If a subsequent transcode fails, the database need to have everything about the previous transcodes recorded already, + // otherwise the image entries will be stuck pointing to missing image files + $database->commit(); + $total++; + } catch(Exception $e) { + log_error("transcode", "Error while bulk transcode on item $id to $format: ".$e->getMessage()); + try { + $database->rollback(); + } catch (Exception $e) {} + } + } + flash_message("Transcoded $total items"); + + } + break; + } + } + + private function clean_format($format): ?string { + if(array_key_exists($format, self::FORMAT_ALIASES)) { + return self::FORMAT_ALIASES[$format]; + } + return $format; + } + + private function can_convert_format($engine, $format): bool + { + $format = $this->clean_format($format); + if(!in_array($format, self::ENGINE_INPUT_SUPPORT[$engine])) { + return false; + } + return true; + } + + private function get_supported_output_formats($engine, ?String $omit_format = null): array + { + $omit_format = $this->clean_format($omit_format); + $output = []; + foreach(self::OUTPUT_FORMATS as $key=>$value) { + if($value=="") { + $output[$key] = $value; + continue; + } + if(in_array($value, self::ENGINE_OUTPUT_SUPPORT[$engine]) + &&(empty($omit_format)||$omit_format!=$this->determine_ext($value))) { + $output[$key] = $value; + } + } + return $output; + } + + private function determine_ext(String $format): String + { + switch($format) { + case "webp-lossless": + case "webp-lossy": + return "webp"; + default: + return $format; + } + } + + private function transcode_and_replace_image(Image $image_obj, String $target_format) + { + $target_format = $this->clean_format($target_format); + $original_file = warehouse_path("images", $image_obj->hash); + + $tmp_filename = $this->transcode_image($original_file, $image_obj->ext, $target_format); + + $new_image = new Image(); + $new_image->hash = md5_file($tmp_filename); + $new_image->filesize = filesize($tmp_filename); + $new_image->filename = $image_obj->filename; + $new_image->width = $image_obj->width; + $new_image->height = $image_obj->height; + $new_image->ext = $this->determine_ext($target_format); + + /* Move the new image into the main storage location */ + $target = warehouse_path("images", $new_image->hash); + if (!@copy($tmp_filename, $target)) { + throw new ImageTranscodeException("Failed to copy new image file from temporary location ({$tmp_filename}) to archive ($target)"); + } + + /* Remove temporary file */ + @unlink($tmp_filename); + + send_event(new ImageReplaceEvent($image_obj->id, $new_image)); + + } + + + private function transcode_image(String $source_name, String $source_format, string $target_format): string + { + global $config; + + if($source_format==$this->determine_ext($target_format)) { + throw new ImageTranscodeException("Source and target formats are the same: ".$source_format); + } + + $engine = $config->get_string("transcode_engine"); + + + + if(!$this->can_convert_format($engine,$source_format)) { + throw new ImageTranscodeException("Engine $engine does not support input format $source_format"); + } + if(!in_array($target_format, self::ENGINE_OUTPUT_SUPPORT[$engine])) { + throw new ImageTranscodeException("Engine $engine does not support output format $target_format"); + } + + switch($engine) { + case "gd": + return $this->transcode_image_gd($source_name, $source_format, $target_format); + case "convert": + return $this->transcode_image_convert($source_name, $source_format, $target_format); + } + + } + + private function transcode_image_gd(String $source_name, String $source_format, string $target_format): string + { + global $config; + + $q = $config->get_int("transcode_quality"); + + $tmp_name = tempnam("/tmp", "shimmie_transcode"); + + $image = imagecreatefromstring(file_get_contents($source_name)); + try { + $result = false; + switch($target_format) { + case "webp-lossy": + $result = imagewebp($image, $tmp_name, $q); + break; + case "png": + $result = imagepng($image, $tmp_name, 9); + break; + case "jpg": + // In case of alpha channels + $width = imagesx($image); + $height = imagesy($image); + $new_image = imagecreatetruecolor($width, $height); + if($new_image===false) { + throw new ImageTranscodeException("Could not create image with dimensions $width x $height"); + } + try{ + $black = imagecolorallocate($new_image, 0, 0, 0); + if($black===false) { + throw new ImageTranscodeException("Could not allocate background color"); + } + if(imagefilledrectangle($new_image, 0, 0, $width, $height, $black)===false) { + throw new ImageTranscodeException("Could not fill background color"); + } + if(imagecopy($new_image, $image, 0, 0, 0, 0, $width, $height)===false) { + throw new ImageTranscodeException("Could not copy source image to new image"); + } + $result = imagejpeg($new_image, $tmp_name, $q); + } finally { + imagedestroy($new_image); + } + break; + } + if($result===false) { + throw new ImageTranscodeException("Error while transcoding ".$source_name." to ".$target_format); + } + return $tmp_name; + } finally { + imagedestroy($image); + } + } + + private function transcode_image_convert(String $source_name, String $source_format, string $target_format): string + { + global $config; + + $q = $config->get_int("transcode_quality"); + $convert = $config->get_string("thumb_convert_path"); + + if($convert==null||$convert=="") + { + throw new ImageTranscodeException("ImageMagick path not configured"); + } + $ext = $this->determine_ext($target_format); + + $args = " -flatten "; + $bg = "none"; + switch($target_format) { + case "webp-lossless": + $args .= '-define webp:lossless=true'; + break; + case "webp-lossy": + $args .= ''; + break; + case "png": + $args .= '-define png:compression-level=9'; + break; + default: + $bg = "black"; + break; + } + $tmp_name = tempnam("/tmp", "shimmie_transcode"); + + $format = '"%s" %s -quality %u -background %s "%s" %s:"%s"'; + $cmd = sprintf($format, $convert, $args, $q, $bg, $source_name, $ext, $tmp_name); + $cmd = str_replace("\"convert\"", "convert", $cmd); // quotes are only needed if the path to convert contains a space; some other times, quotes break things, see github bug #27 + exec($cmd, $output, $ret); + + log_debug('transcode', "Transcoding with command `$cmd`, returns $ret"); + + if($ret!==0) { + throw new ImageTranscodeException("Transcoding failed with command ".$cmd); + } + + return $tmp_name; + } + +} diff --git a/ext/transcode/theme.php b/ext/transcode/theme.php new file mode 100644 index 00000000..24ecc613 --- /dev/null +++ b/ext/transcode/theme.php @@ -0,0 +1,41 @@ +id}"), 'POST')." + + ".$this->get_transcode_picker_html($options)." +
+ + "; + + return $html; + } + + public function get_transcode_picker_html(array $options) { + $html = ""; + + } + + public function display_transcode_error(Page $page, string $title, string $message) + { + $page->set_title("Transcode Image"); + $page->set_heading("Transcode Image"); + $page->add_block(new NavBlock()); + $page->add_block(new Block($title, $message)); + } + +} diff --git a/ext/upload/main.php b/ext/upload/main.php index 434c0092..acb853de 100644 --- a/ext/upload/main.php +++ b/ext/upload/main.php @@ -18,9 +18,15 @@ class DataUploadEvent extends Event /** @var string */ public $hash; /** @var string */ - public $type; + public $type = ""; /** @var int */ public $image_id = -1; + /** @var bool */ + public $handled = false; + /** @var bool */ + public $merged = false; + + /** * Some data is being uploaded. @@ -29,21 +35,39 @@ class DataUploadEvent extends Event */ public function __construct(string $tmpname, array $metadata) { + global $config; + assert(file_exists($tmpname)); assert(is_string($metadata["filename"])); - assert(is_string($metadata["extension"])); assert(is_array($metadata["tags"])); assert(is_string($metadata["source"]) || is_null($metadata["source"])); - $this->tmpname = $tmpname; - $this->metadata = $metadata; + + $this->set_tmpname($tmpname); + + if($config->get_bool("upload_use_mime")) { + $this->set_type(get_extension_from_mime($tmpname)); + } else { + if(array_key_exists('extension',$metadata)&&!empty($metadata['extension'])) { + $this->type = strtolower($metadata['extension']); + } else { + throw new UploadException("Could not determine extension for file ".$metadata["filename"]); + } + } + } + + public function set_type(String $type) { + $this->type = strtolower($type); + $this->metadata["extension"] = $this->type; + } + + public function set_tmpname(String $tmpname) { + $this->tmpname = $tmpname; $this->metadata['hash'] = md5_file($tmpname); $this->metadata['size'] = filesize($tmpname); - // useful for most file handlers, so pull directly into fields $this->hash = $this->metadata['hash']; - $this->type = strtolower($metadata['extension']); } } @@ -76,6 +100,7 @@ class Upload extends Extension $config->set_default_int('upload_size', parse_shorthand_int('1MB')); $config->set_default_int('upload_min_free_space', parse_shorthand_int('100MB')); $config->set_default_bool('upload_tlsource', true); + $config->set_default_bool('upload_use_mime', false); $this->is_full = false; @@ -108,6 +133,7 @@ class Upload extends Extension $sb->add_label("PHP Limit = ".ini_get('upload_max_filesize').""); $sb->add_choice_option("transload_engine", $tes, "
Transload: "); $sb->add_bool_option("upload_tlsource", "
Use transloaded URL as source if none is provided: "); + $sb->add_bool_option("upload_use_mime", "
Use mime type to determine file types: "); $event->panel->add_block($sb); } @@ -320,9 +346,6 @@ class Upload extends Extension $event = new DataUploadEvent($file['tmp_name'], $metadata); send_event($event); - if ($event->image_id == -1) { - throw new UploadException("File type not recognised"); - } $page->add_http_header("X-Shimmie-Image-ID: ".int_escape($event->image_id)); } catch (UploadException $ex) { $this->theme->display_upload_error( diff --git a/ext/upload/theme.php b/ext/upload/theme.php index 6f0c11da..71041030 100644 --- a/ext/upload/theme.php +++ b/ext/upload/theme.php @@ -219,7 +219,7 @@ class UploadTheme extends Themelet $html .= ' (Drag & drop onto your bookmarks toolbar, then click when looking at an image)'; // Bookmarklet checks if shimmie supports ext. If not, won't upload to site/shows alert saying not supported. - $supported_ext = "jpg jpeg gif png"; + $supported_ext = "jpg jpeg gif png webp"; if (class_exists("FlashFileHandler")) { $supported_ext .= " swf"; }