[replace] split upload and replace completely, fixes #1001

This commit is contained in:
Shish 2024-01-09 03:32:22 +00:00
parent a28fb66b91
commit 4c2d6d9ca4
12 changed files with 294 additions and 221 deletions

View file

@ -309,35 +309,25 @@ abstract class DataHandlerExtension extends Extension
throw new UploadException("Invalid or corrupted file");
}
/* Check if we are replacing an image */
if (!is_null($event->replace_id)) {
$existing = Image::by_id($event->replace_id);
if (is_null($existing)) {
throw new UploadException("Post to replace does not exist!");
$this->move_upload_to_archive($event);
$image = $this->create_image_from_data(warehouse_path(Image::IMAGE_DIR, $event->hash), $event->metadata);
$existing = Image::by_hash($image->hash);
if (!is_null($existing)) {
$handler = $config->get_string(ImageConfig::UPLOAD_COLLISION_HANDLER);
if ($handler == ImageConfig::COLLISION_MERGE) {
$image = $existing;
} else {
throw new UploadException(">>{$existing->id} already has hash {$image->hash}");
}
send_event(new ImageReplaceEvent($existing, $event->tmpname));
$event->images[] = $existing;
} else {
$this->move_upload_to_archive($event);
$image = $this->create_image_from_data(warehouse_path(Image::IMAGE_DIR, $event->hash), $event->metadata);
$existing = Image::by_hash($image->hash);
if (!is_null($existing)) {
$handler = $config->get_string(ImageConfig::UPLOAD_COLLISION_HANDLER);
if ($handler == ImageConfig::COLLISION_MERGE) {
$image = $existing;
} else {
throw new UploadException(">>{$existing->id} already has hash {$image->hash}");
}
}
// ensure $image has a database-assigned ID number
// before anything else happens
$image->save_to_db();
$iae = send_event(new ImageAdditionEvent($image, $event->metadata, !is_null($existing)));
$event->images[] = $iae->image;
}
// ensure $image has a database-assigned ID number
// before anything else happens
$image->save_to_db();
$iae = send_event(new ImageAdditionEvent($image, $event->metadata, !is_null($existing)));
$event->images[] = $iae->image;
}
}

View file

@ -576,20 +576,27 @@ class Image
$this->delete_tags_from_image();
$database->execute("DELETE FROM images WHERE id=:id", ["id" => $this->id]);
log_info("core_image", 'Deleted Post #'.$this->id.' ('.$this->hash.')');
unlink($this->get_image_filename());
unlink($this->get_thumb_filename());
$this->remove_image_only(quiet: true);
}
/**
* This function removes an image (and thumbnail) from the DISK ONLY.
* It DOES NOT remove anything from the database.
*/
public function remove_image_only(): void
public function remove_image_only(bool $quiet=false): void
{
log_info("core_image", 'Removed Post File ('.$this->hash.')');
@unlink($this->get_image_filename());
@unlink($this->get_thumb_filename());
$img_del = @unlink($this->get_image_filename());
$thumb_del = @unlink($this->get_thumb_filename());
if($img_del && $thumb_del) {
if(!$quiet) {
log_info("core_image", "Deleted files for Post #{$this->id} ({$this->hash})");
}
}
else {
$img = $img_del ? '' : ' image';
$thumb = $thumb_del ? '' : ' thumbnail';
log_error('core_image', "Failed to delete files for Post #{$this->id}{$img}{$thumb}");
}
}
public function parse_link_template(string $tmpl, int $n = 0): string

View file

@ -116,10 +116,6 @@ class ImageIO extends Extension
if ($user->can(Permissions::DELETE_IMAGE)) {
$event->add_part($this->theme->get_deleter_html($event->image->id));
}
/* In the future, could perhaps allow users to replace images that they own as well... */
if ($user->can(Permissions::REPLACE_IMAGE)) {
$event->add_part($this->theme->get_replace_html($event->image->id));
}
}
public function onCommand(CommandEvent $event)
@ -141,43 +137,6 @@ class ImageIO extends Extension
log_info("image", "Uploaded >>{$event->image->id} ({$event->image->hash})");
}
public function onImageReplace(ImageReplaceEvent $event)
{
$image = $event->image;
try {
$duplicate = Image::by_hash($event->new_hash);
if (!is_null($duplicate) && $duplicate->id != $image->id) {
throw new ImageReplaceException("A different post >>{$duplicate->id} already has hash {$duplicate->hash}");
}
$image->remove_image_only(); // Actually delete the old image file from disk
$target = warehouse_path(Image::IMAGE_DIR, $event->new_hash);
if (!@copy($event->tmp_filename, $target)) {
$errors = error_get_last();
throw new UploadException(
"Failed to copy file from uploads ({$event->tmp_filename}) to archive ($target): ".
"{$errors['type']} / {$errors['message']}"
);
}
unlink($event->tmp_filename);
// update metadata and save metadata to DB
$event->image->hash = $event->new_hash;
$event->image->filesize = filesize($target);
$event->image->set_mime(MimeType::get_for_file($target));
send_event(new MediaCheckPropertiesEvent($image));
$image->save_to_db();
send_event(new ThumbnailGenerationEvent($image));
log_info("image", "Replaced >>{$image->id} {$event->original_hash} with {$event->new_hash}");
} catch (ImageReplaceException $e) {
throw new UploadException($e->error);
}
}
public function onImageDeletion(ImageDeletionEvent $event)
{
$event->image->delete();

View file

@ -20,14 +20,4 @@ class ImageIOTheme extends Themelet
INPUT(["type" => 'submit', "value" => 'Delete', "onclick" => 'return confirm("Delete the image?");', "id" => "image_delete_button"]),
)."</span>";
}
/**
* Display link to replace the image
*/
public function get_replace_html(int $image_id): string
{
$form = SHM_FORM("replace/$image_id", "GET");
$form->appendChild(INPUT(["type" => 'submit', "value" => 'Replace']));
return (string)$form;
}
}

20
ext/replace_file/info.php Normal file
View file

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Shimmie2;
class ReplaceFileInfo extends ExtensionInfo
{
public const KEY = "replace_file";
public string $key = self::KEY;
public string $name = "Replace File";
public string $url = self::SHIMMIE_URL;
public array $authors = self::SHISH_AUTHOR;
public string $description = "Allows people to replace files for existing posts";
// Core because several other extensions depend on it, this could be
// non-core if we had a way to specify dependencies dynamically
public bool $core = true;
}

90
ext/replace_file/main.php Normal file
View file

@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace Shimmie2;
class ReplaceFile extends Extension
{
/** @var ReplaceFileTheme */
protected Themelet $theme;
public function onPageRequest(PageRequestEvent $event)
{
global $cache, $page, $user;
if ($event->page_matches("replace")) {
if (!$user->can(Permissions::REPLACE_IMAGE)) {
$this->theme->display_error(403, "Error", "{$user->name} doesn't have permission to replace images");
return;
}
$image_id = int_escape($event->get_arg(0));
$image = Image::by_id($image_id);
if (is_null($image)) {
throw new UploadException("Can not replace Post: No post with ID $image_id");
}
if($event->method == "GET") {
$this->theme->display_replace_page($page, $image_id);
} elseif($event->method == "POST") {
if (!empty($_POST["url"])) {
$tmp_filename = tempnam(ini_get('upload_tmp_dir'), "shimmie_transload");
fetch_url($_POST["url"], $tmp_filename);
send_event(new ImageReplaceEvent($image, $tmp_filename));
} elseif (count($_FILES) > 0) {
send_event(new ImageReplaceEvent($image, $_FILES["data"]['tmp_name']));
}
if(!empty($_POST["source"])) {
send_event(new SourceSetEvent($image, $_POST["source"]));
}
$cache->delete("thumb-block:{$image_id}");
$page->set_mode(PageMode::REDIRECT);
$page->set_redirect(make_link("post/view/$image_id"));
}
}
}
public function onImageAdminBlockBuilding(ImageAdminBlockBuildingEvent $event)
{
global $user;
/* In the future, could perhaps allow users to replace images that they own as well... */
if ($user->can(Permissions::REPLACE_IMAGE)) {
$event->add_part($this->theme->get_replace_html($event->image->id));
}
}
public function onImageReplace(ImageReplaceEvent $event)
{
$image = $event->image;
$duplicate = Image::by_hash($event->new_hash);
if (!is_null($duplicate) && $duplicate->id != $image->id) {
throw new ImageReplaceException("A different post >>{$duplicate->id} already has hash {$duplicate->hash}");
}
$image->remove_image_only(); // Actually delete the old image file from disk
$target = warehouse_path(Image::IMAGE_DIR, $event->new_hash);
if (!@copy($event->tmp_filename, $target)) {
$errors = error_get_last();
throw new UploadException(
"Failed to copy file from uploads ({$event->tmp_filename}) to archive ($target): ".
"{$errors['type']} / {$errors['message']}"
);
}
unlink($event->tmp_filename);
// update metadata and save metadata to DB
$event->image->hash = $event->new_hash;
$event->image->filesize = filesize($target);
$event->image->set_mime(MimeType::get_for_file($target));
send_event(new MediaCheckPropertiesEvent($image));
$image->save_to_db();
send_event(new ThumbnailGenerationEvent($image));
log_info("image", "Replaced >>{$image->id} {$event->original_hash} with {$event->new_hash}");
}
}

68
ext/replace_file/test.php Normal file
View file

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Shimmie2;
class ReplaceFileTest extends ShimmiePHPUnitTestCase
{
public function testReplacePage()
{
$this->log_in_as_admin();
$image_id = $this->post_image("tests/pbx_screenshot.jpg", "pbx computer screenshot");
$this->get_page("replace/$image_id");
$this->assert_title("Replace File");
}
public function testReplace()
{
global $database;
$this->log_in_as_admin();
// upload an image
$image_id = $this->post_image("tests/pbx_screenshot.jpg", "pbx computer screenshot");
// check that the image is original
$image = Image::by_id($image_id);
$old_hash = md5_file("tests/pbx_screenshot.jpg");
//$this->assertEquals("pbx_screenshot.jpg", $image->filename);
$this->assertEquals("image/jpeg", $image->get_mime());
$this->assertEquals(19774, $image->filesize);
$this->assertEquals(640, $image->width);
$this->assertEquals($old_hash, $image->hash);
// replace it
// create a copy because the file is deleted after upload
$tmpfile = tempnam(sys_get_temp_dir(), "shimmie_test");
copy("tests/favicon.png", $tmpfile);
$new_hash = md5_file($tmpfile);
$_FILES = [
'data' => [
'name' => 'favicon.png',
'type' => 'image/png',
'tmp_name' => $tmpfile,
'error' => 0,
'size' => 246,
]
];
$page = $this->post_page("replace/$image_id");
$this->assert_response(302);
$this->assertEquals("/test/post/view/$image_id", $page->redirect);
// check that there's still one image
$this->assertEquals(1, $database->get_one("SELECT COUNT(*) FROM images"));
// check that the image was replaced
$image = Image::by_id($image_id);
// $this->assertEquals("favicon.png", $image->filename); // TODO should we update filename?
$this->assertEquals("image/png", $image->get_mime());
$this->assertEquals(246, $image->filesize);
$this->assertEquals(16, $image->width);
$this->assertEquals(md5_file("tests/favicon.png"), $image->hash);
// check that new files exist and old files don't
$this->assertFalse(file_exists(warehouse_path(Image::IMAGE_DIR, $old_hash)));
$this->assertFalse(file_exists(warehouse_path(Image::THUMBNAIL_DIR, $old_hash)));
$this->assertTrue(file_exists(warehouse_path(Image::IMAGE_DIR, $new_hash)));
$this->assertTrue(file_exists(warehouse_path(Image::THUMBNAIL_DIR, $new_hash)));
}
}

View file

@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace Shimmie2;
use function MicroHTML\{TABLE,TR,TD};
use function MicroHTML\SMALL;
use function MicroHTML\INPUT;
use function MicroHTML\emptyHTML;
use function MicroHTML\BR;
use function MicroHTML\P;
class ReplaceFileTheme extends Themelet
{
/**
* Only allows 1 file to be uploaded - for replacing another image file.
*/
public function display_replace_page(Page $page, int $image_id)
{
global $config, $page;
$tl_enabled = ($config->get_string(UploadConfig::TRANSLOAD_ENGINE, "none") != "none");
$accept = $this->get_accept();
$max_size = $config->get_int(UploadConfig::SIZE);
$max_kb = to_shorthand_int($max_size);
$image = Image::by_id($image_id);
$thumbnail = $this->build_thumb_html($image);
$form = SHM_FORM("replace/".$image_id, "POST", true);
$form->appendChild(emptyHTML(
TABLE(
["id" => "large_upload_form", "class" => "form"],
TR(
TD("File"),
TD(INPUT(["name" => "data", "type" => "file", "accept" => $accept]))
),
$tl_enabled ? TR(
TD("or URL"),
TD(INPUT(["name" => "url", "type" => "text", "value" => @$_GET['url']]))
) : null,
TR(TD("Source"), TD(["colspan" => 3], INPUT(["name" => "source", "type" => "text"]))),
TR(TD(["colspan" => 4], INPUT(["id" => "uploadbutton", "type" => "submit", "value" => "Post"]))),
)
));
$html = emptyHTML(
P(
"Replacing Post ID $image_id",
BR(),
"Please note: You will have to refresh the post page, or empty your browser cache."
),
$thumbnail,
BR(),
$form,
$max_size > 0 ? SMALL("(Max file size is $max_kb)") : null,
);
$page->set_title("Replace File");
$page->set_heading("Replace File");
$page->add_block(new NavBlock());
$page->add_block(new Block("Upload Replacement File", $html, "main", 20));
}
/**
* Display link to replace the image
*/
public function get_replace_html(int $image_id): string
{
$form = SHM_FORM("replace/$image_id", "GET");
$form->appendChild(INPUT(["type" => 'submit', "value" => 'Replace']));
return (string)$form;
}
protected function get_accept(): string
{
return ".".join(",.", DataHandlerExtension::get_all_supported_exts());
}
}

View file

@ -182,13 +182,6 @@ class TagEdit extends Extension
}
}
public function onImageReplace(ImageReplaceEvent $event)
{
if(!empty($_POST['source'])) {
send_event(new SourceSetEvent($event->image, $_POST['source']));
}
}
public function onImageInfoSet(ImageInfoSetEvent $event)
{
global $page, $user;

View file

@ -27,7 +27,6 @@ class DataUploadEvent extends Event
public function __construct(
public string $tmpname,
public array $metadata,
public ?int $replace_id = null
) {
parent::__construct();
@ -212,35 +211,7 @@ class Upload extends Extension
}
}
if ($event->page_matches("replace")) {
if (!$user->can(Permissions::REPLACE_IMAGE)) {
$this->theme->display_error(403, "Error", "{$user->name} doesn't have permission to replace images");
return;
}
if ($this->is_full) {
$this->theme->display_error(507, "Error", "Can't replace images: disk nearly full");
return;
}
$image_id = int_escape($event->get_arg(0));
$image_old = Image::by_id($image_id);
if (is_null($image_old)) {
throw new UploadException("Can not replace Post: No post with ID $image_id");
}
if($event->method == "GET") {
$this->theme->display_replace_page($page, $image_id);
} elseif($event->method == "POST") {
$results = [];
if (!empty($_POST["url"])) {
$results = $this->try_transload($_POST["url"], [], $_POST['source'] ?? null, $image_id);
} elseif (count($_FILES) > 0) {
$results = $this->try_upload($_FILES["data"], [], $_POST['source'] ?? null, $image_id);
}
$cache->delete("thumb-block:{$image_id}");
$this->theme->display_upload_status($page, $results);
}
} elseif ($event->page_matches("upload")) {
if ($event->page_matches("upload")) {
if (!$user->can(Permissions::CREATE_IMAGE)) {
$this->theme->display_error(403, "Error", "{$user->name} doesn't have permission to upload images");
return;
@ -342,7 +313,7 @@ class Upload extends Extension
* @param string[] $tags
* @return UploadResult[]
*/
private function try_upload(array $file, array $tags, ?string $source = null, ?int $replace_id = null): array
private function try_upload(array $file, array $tags, ?string $source = null): array
{
global $page, $config;
@ -376,7 +347,7 @@ class Upload extends Extension
$metadata['tags'] = $tags;
$metadata['source'] = $source;
$event = new DataUploadEvent($tmp_name, $metadata, $replace_id);
$event = new DataUploadEvent($tmp_name, $metadata);
send_event($event);
if (count($event->images) == 0) {
throw new UploadException("MIME type not supported: " . $event->mime);
@ -395,7 +366,7 @@ class Upload extends Extension
/**
* @return UploadResult[]
*/
private function try_transload(string $url, array $tags, string $source = null, ?int $replace_id = null): array
private function try_transload(string $url, array $tags, string $source = null): array
{
global $page, $config, $user;
@ -431,7 +402,7 @@ class Upload extends Extension
}
// Upload file
$event = new DataUploadEvent($tmp_filename, $metadata, $replace_id);
$event = new DataUploadEvent($tmp_filename, $metadata);
send_event($event);
if (count($event->images) == 0) {
throw new UploadException("File type not supported: " . $event->mime);

View file

@ -50,42 +50,6 @@ class UploadTest extends ShimmiePHPUnitTestCase
$this->assertEquals(4, $database->get_one("SELECT COUNT(*) FROM images"));
}
public function testRawReplace()
{
global $database;
$this->log_in_as_admin();
$image_id = $this->post_image("tests/pbx_screenshot.jpg", "pbx computer screenshot");
$original_posted = $database->get_one("SELECT posted FROM images WHERE id = $image_id");
sleep(1); // make sure the timestamp changes (see bug #903)
// create a copy because the file is deleted after upload
$tmpfile = tempnam(sys_get_temp_dir(), "shimmie_test");
copy("tests/bedroom_workshop.jpg", $tmpfile);
$_FILES = [
'data' => [
'name' => ['puppy-hugs.jpg'],
'type' => ['image/jpeg'],
'tmp_name' => [$tmpfile],
'error' => [0],
'size' => [271386],
]
];
$page = $this->post_page("replace/$image_id");
$this->assert_response(302);
$this->assertEquals("/test/post/view/$image_id", $page->redirect);
$new_posted = $database->get_one("SELECT posted FROM images WHERE id = $image_id");
$this->assertEquals(1, $database->get_one("SELECT COUNT(*) FROM images"));
// check that the original timestamp is left alone, despite the
// file being replaced (see bug #903)
$this->assertEquals($original_posted, $new_posted);
}
public function testUpload()
{
$this->log_in_as_user();

View file

@ -209,64 +209,6 @@ class UploadTheme extends Themelet
return emptyHTML($html1, $html2);
}
/**
* Only allows 1 file to be uploaded - for replacing another image file.
*/
public function display_replace_page(Page $page, int $image_id)
{
global $config, $page;
$tl_enabled = ($config->get_string(UploadConfig::TRANSLOAD_ENGINE, "none") != "none");
$accept = $this->get_accept();
$upload_list = emptyHTML(
TR(
TD("File"),
TD(INPUT(["name" => "data[]", "type" => "file", "accept" => $accept]))
)
);
if ($tl_enabled) {
$upload_list->appendChild(
TR(
TD("or URL"),
TD(INPUT(["name" => "url", "type" => "text", "value" => @$_GET['url']]))
)
);
}
$max_size = $config->get_int(UploadConfig::SIZE);
$max_kb = to_shorthand_int($max_size);
$image = Image::by_id($image_id);
$thumbnail = $this->build_thumb_html($image);
$form = SHM_FORM("replace/".$image_id, "POST", true);
$form->appendChild(emptyHTML(
TABLE(
["id" => "large_upload_form", "class" => "form"],
$upload_list,
TR(TD("Source"), TD(["colspan" => 3], INPUT(["name" => "source", "type" => "text"]))),
TR(TD(["colspan" => 4], INPUT(["id" => "uploadbutton", "type" => "submit", "value" => "Post"]))),
)
));
$html = emptyHTML(
P(
"Replacing Post ID $image_id",
BR(),
"Please note: You will have to refresh the post page, or empty your browser cache."
),
$thumbnail,
BR(),
$form,
$max_size > 0 ? SMALL("(Max file size is $max_kb)") : null,
);
$page->set_title("Replace Post");
$page->set_heading("Replace Post");
$page->add_block(new NavBlock());
$page->add_block(new Block("Upload Replacement Post", $html, "main", 20));
}
/**
* @param UploadResult[] $results
*/
@ -337,7 +279,6 @@ class UploadTheme extends Themelet
NOSCRIPT(BR(), A(["href" => make_link("upload")], "Larger Form"))
);
}
protected function get_accept(): string
{
return ".".join(",.", DataHandlerExtension::get_all_supported_exts());