InlineImage. * * InlineImages can be posted directly in the forum and wiki (and possibly comments). * * An inline image can have three versions, like a post. For consistency, they use the * same names: image, sample, preview. As with posts, sample and previews are always JPEG, * and the dimensions of preview is derived from image rather than stored. * * Image files are effectively garbage collected: InlineImages can share files, and the file * is deleted when the last one using it is deleted. This allows any user to copy another user's * InlineImage, to crop it or to include it in an Inline. * * Example use cases: * * - Plain inlining, eg. for tutorials. Thumbs and larger images can be shown inline, allowing * a click to expand. * - Showing edits. Each user can upload his edit as an InlineImage and post it directly * into the forum. * - Comparing edits. A user can upload his own edit, pair it with another version (using * Inline), crop to a region of interest, and post that inline. The images can then be * compared in-place. This can be used to clearly show editing problems and differences. */ class InlineImage extends Rails\ActiveRecord\Base { use Moebooru\TempfilePrefix; public $source; public $received_file; public $file_needs_move; protected function associations() { return [ 'belongs_to' => [ 'inline' ] ]; } protected function callbacks() { return [ 'before_validation_on_create' => [ 'download_source', 'determine_content_type', 'set_image_dimensions', 'generate_sample', 'generate_preview', 'move_file', 'set_default_sequence' ], 'after_destroy' => [ 'delete_file' ], 'before_create' => [ 'validate_uniqueness' ] ]; } public function tempfile_image_path() { return $this->tempfile_prefix() . '.upload'; } public function tempfile_sample_path() { return $this->tempfile_prefix() . '-sample.upload'; } public function tempfile_preview_path() { return $this->tempfile_prefix() . '-preview.upload'; } /** * MI: Warning for Windows: * The PHP function symlink only works on Windows Vista, Server 2008 or greater. */ public function setPostId($id) { $post = Post::find($id); $file = $post->file_path(); symlink($file, $this->tempfile_image_path()); $this->received_file = true; $this->md5 = $post->md5; } # Call once a file is available in tempfile_image_path. public function got_file() { $this->generate_hash($this->tempfile_image_path()); chmod($this->tempfile_image_path(), 0775); $this->file_needs_move = true; $this->received_file = true; } /** * @param Rails\ActionDispatch\Http\UploadedFile $f */ public function setFile($f) { if (!$f->size()) { return; } copy($f->tempName(), $this->tempfile_image_path()); $this->got_file(); } public function download_source() { if (!preg_match('/^https?:\/\//', $this->source) || $this->file_ext || $this->received_file ) { return; } try { $file = Danbooru::http_get_streaming($this->source); file_put_contents($this->tempfile_image_path(), $file); unset($file); $this->got_file(); } catch (Exception $e) { $this->delete_tempfile(); $this->errors()->add('source', "couldn't be opened: " . $e->getMessage()); return false; } } public function determine_content_type() { if ($this->file_ext) { return true; } if (!file_exists($this->tempfile_image_path())) { $this->errors()->addToBase("No file received"); return false; } $imgsize = getimagesize($this->tempfile_image_path()); $this->file_ext = strtolower(image_type_to_extension($imgsize[2], false)); if ($this->file_ext == 'jpeg') { $this->file_ext = 'jpg'; } if (!in_array($this->file_ext, ['jpg', 'png', 'gif'])) { $this->errors()->add('file', 'is an invalid content type: ' . $this->file_ext ?: 'unknown'); } return true; } public function set_image_dimensions() { if ($this->width and $this->height) { return true; } $imgsize = getimagesize($this->tempfile_image_path()); $this->width = $imgsize[0]; $this->height = $imgsize[1]; return true; } public function preview_dimensions() { return Moebooru\Resizer::reduce_to(['width' => $this->width, 'height' => $this->height], ['width' => 150, 'height' => 150]); } public function thumb_size() { return Moebooru\Resizer::reduce_to(['width' => $this->width, 'height' => $this->height], ['width' => 400, 'height' => 400]); } public function generate_sample() { if (is_file($this->sample_path())) { return true; } /** * We can generate the sample image during upload or offline. Use tempfile_image_path * if it exists, otherwise use file_path. */ $path = $this->tempfile_image_path(); if (!is_file($path)) { $path = $this->file_path(); } if (!is_file($path)) { $this->errors()->add('file', 'not found'); return false; } # If we're not reducing the resolution for the sample image, only reencode if the # source image is above the reencode threshold. Anything smaller won't be reduced # enough by the reencode to bother, so don't reencode it and save disk space. $sample_size = Moebooru\Resizer::reduce_to(['width' => $this->width, 'height' => $this->height], ['width' => CONFIG()->inline_sample_width, 'height' => CONFIG()->inline_sample_height]); if ($sample_size['width'] == $this->width && $sample_size['height'] == $this->height && filesize($path) < CONFIG()->sample_always_generate_size) { return true; } # If we already have a sample image, and the parameters havn't changed, # don't regenerate it. if ($sample_size['width'] == $this->sample_width && $sample_size['height'] == $this->sample_height) { return true; } try { Moebooru\Resizer::resize($this->file_ext, $path, $this->tempfile_sample_path(), $sample_size, 95); } catch (Exception $e) { $this->errors()->add('sample', "couldn't be created:" . $e->getMessage()); return false; } $this->sample_width = $sample_size['width']; $this->sample_height = $sample_size['height']; return true; } public function generate_preview() { if (is_file($this->preview_path())) { return true; } if (!is_file($this->tempfile_image_path())) { $this->errors()->add('file', 'not found'); return false; } # Generate the preview from the new sample if we have one to save CPU, otherwise from the image. if (is_file($this->tempfile_sample_path())) { $path = $this->tempfile_sample_path(); $ext = 'jpg'; } else { $path = $this->tempfile_image_path(); $ext = $this->file_ext; } try { Moebooru\Resizer::resize($ext, $path, $this->tempfile_preview_path(), $this->preview_dimensions(), 95); } catch (Exception $e) { $this->errors()->add('preview', "couldn't be generated: " . $e->getMessage()); return false; } return true; } public function move_file() { if (!$this->file_needs_move) { return true; } if (!is_dir(dirname($this->file_path()))) { mkdir(dirname($this->file_path()), 0777, true); } rename($this->tempfile_image_path(), $this->file_path()); if (is_file($this->tempfile_preview_path())) { if (!is_dir(dirname($this->preview_path()))) { mkdir(dirname($this->preview_path()), 0777, true); } rename($this->tempfile_preview_path(), $this->preview_path()); } if (is_file($this->tempfile_sample_path())) { if (!is_dir(dirname($this->sample_path()))) { mkdir(dirname($this->sample_path()), 0777, true); } rename($this->tempfile_sample_path(), $this->sample_path()); } $this->file_needs_move = false; return true; } public function set_default_sequence() { if ($this->sequence) { return; } $siblings = $this->inline->inline_images; $max_sequence = ($siblings->getAttributes('sequence') && max($siblings->getAttributes('sequence'))) ?: 0; $this->sequence = $max_sequence + 1; } public function generate_hash($path) { $this->md5 = md5_file($path); } public function has_sample() { return (bool)$this->sample_height; } public function file_name() { return $this->md5 . '.' . $this->file_ext; } public function file_name_jpg() { return $this->md5 . '.jpg'; } public function file_path() { return Rails::publicPath() . '/data/inline/image/' . $this->file_name(); } public function preview_path() { return Rails::publicPath() . '/data/inline/preview/' . $this->file_name_jpg(); } public function sample_path() { return Rails::publicPath() . '/data/inline/sample/' . $this->file_name_jpg(); } public function file_url() { return CONFIG()->url_base . '/data/inline/image/' . $this->file_name(); } public function sample_url() { if ($this->has_sample()) { return CONFIG()->url_base . '/data/inline/sample/' . $this->file_name_jpg(); } else { return $this->file_url(); } } public function preview_url() { return CONFIG()->url_base . '/data/inline/preview/' . $this->file_name_jpg(); } public function delete_file() { # If several inlines use the same image, they'll share the same file via the MD5. Only # delete the file if this is the last one using it. $exists = InlineImage::where('id <> ? AND md5 = ?', $this->id, $this->md5)->first(); if ($exists) { return; } if (is_file($this->file_path())) { unlink($this->file_path()); } if (is_file($this->preview_path())) { unlink($this->preview_path()); } if (is_file($this->sample_path())) { unlink($this->sample_path()); } } # We should be able to use validates_uniqueness_of for this, but Rails is completely # brain-damaged: it only lets you specify an error message that starts with the name # of the column, capitalized, so if we say "foo", the message is "Md5 foo". This is # useless. public function validate_uniqueness() { $siblings = $this->inline->inline_images; foreach ($siblings as $s) { if ($s->id == $this->id) { continue; } if ($s->md5 == $this->md5) { $this->errors()->add('base', '#' . $s->sequence . ' already exists.'); return false; } } return true; } public function api_attributes() { return [ 'id' => (int)$this->id, 'sequence' => $this->sequence, 'md5' => $this->md5, 'width' => (int)$this->width, 'height' => (int)$this->height, 'sample_width' => $this->sample_width, 'sample_height' => $this->sample_height, 'preview_width' => $this->preview_dimensions()['width'], 'preview_height' => $this->preview_dimensions()['height'], 'description' => (string)$this->description, 'file_url' => $this->file_url(), 'sample_url' => $this->sample_url(), 'preview_url' => $this->preview_url() ]; } public function asJson() { return $this->api_attributes(); } }