cf9cba40b6
A little detail: the "Add image" button was moved to a new table row, so it stands out more. Known bugs/errors: - When cropping an animated GIF inline (with a secondary JPG inline), although the images are correctly cropped, an empty error is displayed.
418 lines
13 KiB
PHP
Executable File
418 lines
13 KiB
PHP
Executable File
<?php
|
|
/*
|
|
* InlineImages can be uploaded, copied directly from posts, or cropped from other InlineImages.
|
|
* To create an image by cropping a post, the post must be copied to an InlineImage of its own,
|
|
* and cropped from there; the only UI for cropping is InlineImage->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();
|
|
}
|
|
}
|