This repository has been archived on 2024-09-05. You can view files and clone it, but cannot push or open issues or pull requests.
shimmie2/core/imageboard/image.php

655 lines
19 KiB
PHP
Raw Normal View History

2021-12-14 18:32:47 +00:00
<?php
declare(strict_types=1);
namespace Shimmie2;
2023-02-04 13:27:27 +00:00
use GQLA\Type;
use GQLA\Field;
use GQLA\Query;
2023-02-01 12:50:00 +00:00
enum ImagePropType
{
case BOOL;
case INT;
case STRING;
}
2009-07-19 07:38:13 +00:00
/**
2014-04-29 05:33:03 +00:00
* Class Image
*
* An object representing an entry in the images table.
*
* As of 2.2, this no longer necessarily represents an
* image per se, but could be a video, sound file, or any
* other supported upload type.
*/
2023-02-04 13:27:27 +00:00
#[Type(name: "Post")]
class Image implements \ArrayAccess
{
public const IMAGE_DIR = "images";
public const THUMBNAIL_DIR = "thumbs";
public ?int $id = null;
2023-02-04 13:27:27 +00:00
#[Field]
2021-03-16 01:49:48 +00:00
public int $height = 0;
2023-02-04 13:27:27 +00:00
#[Field]
2021-03-16 01:49:48 +00:00
public int $width = 0;
2023-02-04 13:27:27 +00:00
#[Field]
public string $hash;
2023-02-04 13:27:27 +00:00
#[Field]
public int $filesize;
2023-02-04 13:27:27 +00:00
#[Field]
public string $filename;
2023-02-04 13:27:27 +00:00
#[Field]
private string $ext;
private string $mime;
2021-09-25 12:40:41 +00:00
/** @var ?string[] */
public ?array $tag_array;
public int $owner_id;
public string $owner_ip;
2023-02-07 13:18:00 +00:00
#[Field]
2022-03-20 22:09:47 +00:00
public ?string $posted = null;
2023-02-07 13:18:00 +00:00
#[Field]
public ?string $source = null;
2023-02-07 13:18:00 +00:00
#[Field]
public bool $locked = false;
public ?bool $lossless = null;
public ?bool $video = null;
public ?string $video_codec = null;
public ?bool $image = null;
public ?bool $audio = null;
public ?int $length = null;
public ?string $tmp_file = null;
/** @var array<string, ImagePropType> */
public static array $prop_types = [
"id" => ImagePropType::INT,
"owner_id" => ImagePropType::INT,
"locked" => ImagePropType::BOOL,
"lossless" => ImagePropType::BOOL,
"video" => ImagePropType::BOOL,
"video_codec" => ImagePropType::STRING,
"image" => ImagePropType::BOOL,
"audio" => ImagePropType::BOOL,
"height" => ImagePropType::INT,
"width" => ImagePropType::INT,
"filesize" => ImagePropType::INT,
"length" => ImagePropType::INT,
];
/** @var array<string, mixed> */
private array $dynamic_props = [];
2020-01-29 20:22:50 +00:00
/**
* One will very rarely construct an image directly, more common
* would be to use Image::by_id, Image::by_hash, etc.
*/
2023-11-11 21:49:12 +00:00
public function __construct(?array $row = null)
{
if (!is_null($row)) {
foreach ($row as $name => $value) {
2020-10-24 21:16:18 +00:00
if (is_numeric($name)) {
continue;
}
2020-10-24 18:13:46 +00:00
// some databases use table.name rather than name
$name = str_replace("images.", "", $name);
2020-01-29 20:22:50 +00:00
if (is_null($value)) {
$value = null;
} else {
if(array_key_exists($name, static::$prop_types)) {
$value = match(static::$prop_types[$name]) {
ImagePropType::BOOL => bool_escape((string)$value),
ImagePropType::INT => int_escape((string)$value),
ImagePropType::STRING => (string)$value,
};
}
}
if(property_exists($this, $name)) {
2020-01-26 13:19:35 +00:00
$this->$name = $value;
} else {
$this->dynamic_props[$name] = $value;
2020-01-26 13:19:35 +00:00
}
}
}
}
public function offsetExists(mixed $offset): bool
{
return isset($this->dynamic_props[$offset]);
}
public function offsetGet(mixed $offset): mixed
{
return $this->dynamic_props[$offset];
}
public function offsetSet(mixed $offset, mixed $value): void
{
$this->dynamic_props[$offset] = $value;
}
public function offsetUnset(mixed $offset): void
{
unset($this->dynamic_props[$offset]);
}
2023-02-13 22:28:50 +00:00
#[Field(name: "post_id")]
public function graphql_oid(): int
{
return $this->id;
}
#[Field(name: "id")]
public function graphql_guid(): string
{
return "post:{$this->id}";
}
2023-02-04 18:24:34 +00:00
#[Query(name: "post")]
2023-02-13 22:28:50 +00:00
public static function by_id(int $post_id): ?Image
{
global $database;
2023-11-11 21:49:12 +00:00
if ($post_id > 2 ** 32) {
2023-01-11 19:45:26 +00:00
// for some reason bots query huge numbers and pollute the DB error logs...
return null;
}
2023-11-11 21:49:12 +00:00
$row = $database->get_row("SELECT * FROM images WHERE images.id=:id", ["id" => $post_id]);
return ($row ? new Image($row) : null);
}
2019-05-29 17:23:29 +00:00
public static function by_hash(string $hash): ?Image
{
global $database;
$hash = strtolower($hash);
2023-11-11 21:49:12 +00:00
$row = $database->get_row("SELECT images.* FROM images WHERE hash=:hash", ["hash" => $hash]);
return ($row ? new Image($row) : null);
}
2019-10-04 19:48:21 +00:00
public static function by_id_or_hash(string $id): ?Image
{
return (is_numeric($id) && strlen($id) != 32) ? Image::by_id((int)$id) : Image::by_hash($id);
}
2023-11-11 21:49:12 +00:00
public static function by_random(array $tags = [], int $limit_range = 0): ?Image
{
$max = Search::count_images($tags);
if ($max < 1) {
return null;
} // From Issue #22 - opened by HungryFeline on May 30, 2011.
2019-10-15 13:22:23 +00:00
if ($limit_range > 0 && $max > $limit_range) {
$max = $limit_range;
}
2023-11-11 21:49:12 +00:00
$rand = mt_rand(0, $max - 1);
$set = Search::find_images($rand, 1, $tags);
if (count($set) > 0) {
return $set[0];
} else {
return null;
}
}
/*
* Accessors & mutators
*/
/**
* Find the next image in the sequence.
*
* Rather than simply $this_id + 1, one must take into account
* deleted images and search queries
*
2023-08-17 17:12:36 +00:00
* @param string[] $tags
*/
2023-11-11 21:49:12 +00:00
public function get_next(array $tags = [], bool $next = true): ?Image
{
global $database;
if ($next) {
$gtlt = "<";
$dir = "DESC";
} else {
$gtlt = ">";
$dir = "ASC";
}
$tags[] = 'id'. $gtlt . $this->id;
$tags[] = 'order:id_'. strtolower($dir);
$images = Search::find_images(0, 1, $tags);
return (count($images) > 0) ? $images[0] : null;
}
/**
* The reverse of get_next
*
2023-08-17 17:12:36 +00:00
* @param string[] $tags
*/
2023-11-11 21:49:12 +00:00
public function get_prev(array $tags = []): ?Image
{
return $this->get_next($tags, false);
}
/**
* Find the User who owns this Image
*/
2023-02-04 13:27:27 +00:00
#[Field(name: "owner")]
public function get_owner(): User
{
return User::by_id($this->owner_id);
}
/**
* Set the image's owner.
*/
2019-05-29 17:23:29 +00:00
public function set_owner(User $owner): void
{
global $database;
if ($owner->id != $this->owner_id) {
$database->execute("
2016-06-07 01:05:19 +00:00
UPDATE images
SET owner_id=:owner_id
WHERE id=:id
2023-11-11 21:49:12 +00:00
", ["owner_id" => $owner->id, "id" => $this->id]);
2020-10-26 15:10:00 +00:00
log_info("core_image", "Owner for Post #{$this->id} set to {$owner->name}");
}
}
2024-01-15 15:08:22 +00:00
public function save_to_db(): void
2020-01-29 20:22:50 +00:00
{
global $database, $user;
$cut_name = substr($this->filename, 0, 255);
2022-03-20 22:09:47 +00:00
if (is_null($this->posted) || $this->posted == "") {
$this->posted = date('Y-m-d H:i:s', time());
2022-03-20 22:09:47 +00:00
}
2020-01-29 20:22:50 +00:00
if (is_null($this->id)) {
$database->execute(
2020-01-29 20:22:50 +00:00
"INSERT INTO images(
owner_id, owner_ip,
filename, filesize,
2020-06-14 16:05:55 +00:00
hash, mime, ext,
2020-01-29 20:22:50 +00:00
width, height,
posted, source
)
VALUES (
:owner_id, :owner_ip,
:filename, :filesize,
2020-06-14 16:05:55 +00:00
:hash, :mime, :ext,
2020-01-29 20:22:50 +00:00
0, 0,
2022-03-20 22:09:47 +00:00
:posted, :source
)",
2020-01-29 20:22:50 +00:00
[
"owner_id" => $user->id, "owner_ip" => get_real_ip(),
2020-01-29 20:22:50 +00:00
"filename" => $cut_name, "filesize" => $this->filesize,
2020-06-14 16:05:55 +00:00
"hash" => $this->hash, "mime" => strtolower($this->mime),
2022-03-20 22:09:47 +00:00
"ext" => strtolower($this->ext),
"posted" => $this->posted, "source" => $this->source
2020-01-29 20:22:50 +00:00
]
);
$this->id = $database->get_last_insert_id('images_id_seq');
2020-01-29 20:22:50 +00:00
} else {
$database->execute(
"UPDATE images SET ".
"filename = :filename, filesize = :filesize, hash = :hash, ".
2022-03-20 22:09:47 +00:00
"mime = :mime, ext = :ext, width = 0, height = 0, ".
"posted = :posted, source = :source ".
2020-01-29 20:22:50 +00:00
"WHERE id = :id",
[
"filename" => $cut_name,
"filesize" => $this->filesize,
"hash" => $this->hash,
2020-06-14 16:05:55 +00:00
"mime" => strtolower($this->mime),
2020-01-29 20:22:50 +00:00
"ext" => strtolower($this->ext),
2022-03-20 22:09:47 +00:00
"posted" => $this->posted,
2020-01-29 20:22:50 +00:00
"source" => $this->source,
"id" => $this->id,
]
);
}
// For the future: automatically save dynamic props instead of
// requiring each extension to do it manually.
/*
$props_sql = "UPDATE images SET ";
$props_sql .= implode(", ", array_map(fn ($prop) => "$prop = :$prop", array_keys($this->dynamic_props)));
$props_sql .= " WHERE id = :id";
$database->execute($props_sql, array_merge($this->dynamic_props, ["id" => $this->id]));
*/
2020-01-29 20:22:50 +00:00
$database->execute(
"UPDATE images SET ".
"lossless = :lossless, ".
"video = :video, video_codec = :video_codec, audio = :audio,image = :image, ".
2020-01-29 20:22:50 +00:00
"height = :height, width = :width, ".
"length = :length WHERE id = :id",
[
"id" => $this->id,
"width" => $this->width ?? 0,
"height" => $this->height ?? 0,
2020-10-27 00:49:50 +00:00
"lossless" => $this->lossless,
"video" => $this->video,
2020-10-26 23:18:14 +00:00
"video_codec" => $this->video_codec,
2020-10-27 00:34:28 +00:00
"image" => $this->image,
2020-10-27 00:49:50 +00:00
"audio" => $this->audio,
2020-01-29 20:22:50 +00:00
"length" => $this->length
]
);
}
2020-01-29 20:34:02 +00:00
/**
* Get this image's tags as an array.
*
2023-08-17 17:12:36 +00:00
* @return string[]
*/
2023-02-12 12:26:55 +00:00
#[Field(name: "tags", type: "[string!]!")]
public function get_tag_array(): array
{
global $database;
if (!isset($this->tag_array)) {
$this->tag_array = $database->get_col("
2016-06-07 01:05:19 +00:00
SELECT tag
FROM image_tags
JOIN tags ON image_tags.tag_id = tags.id
WHERE image_id=:id
ORDER BY tag
2023-11-11 21:49:12 +00:00
", ["id" => $this->id]);
sort($this->tag_array);
}
return $this->tag_array;
}
/**
* Get this image's tags as a string.
*/
public function get_tag_list(): string
{
return Tag::implode($this->get_tag_array());
}
/**
* Get the URL for the full size image
*/
2023-02-04 13:27:27 +00:00
#[Field(name: "image_link")]
public function get_image_link(): string
{
return $this->get_link(ImageConfig::ILINK, '_images/$hash/$id%20-%20$tags.$ext', 'image/$id.$ext');
}
/**
* Get the nicely formatted version of the file name
*/
2023-02-04 13:27:27 +00:00
#[Field(name: "nice_name")]
public function get_nice_image_name(): string
{
2023-02-04 20:50:26 +00:00
return send_event(new ParseLinkTemplateEvent('$id - $tags.$ext', $this))->text;
}
/**
* Get the URL for the thumbnail
*/
2023-02-04 13:27:27 +00:00
#[Field(name: "thumb_link")]
public function get_thumb_link(): string
{
global $config;
2020-06-14 16:05:55 +00:00
$mime = $config->get_string(ImageConfig::THUMB_MIME);
$ext = FileExtension::get_for_mime($mime);
return $this->get_link(ImageConfig::TLINK, '_thumbs/$hash/thumb.'.$ext, 'thumb/$id.'.$ext);
}
/**
* Check configured template for a link, then try nice URL, then plain URL
*/
private function get_link(string $template, string $nice, string $plain): string
{
global $config;
$image_link = $config->get_string($template);
if (!empty($image_link)) {
if (!str_contains($image_link, "://") && !str_starts_with($image_link, "/")) {
$image_link = make_link($image_link);
}
2020-02-25 12:18:18 +00:00
$chosen = $image_link;
} elseif ($config->get_bool('nice_urls', false)) {
2020-02-25 12:18:18 +00:00
$chosen = make_link($nice);
} else {
2020-02-25 12:18:18 +00:00
$chosen = make_link($plain);
}
2020-02-25 12:18:18 +00:00
return $this->parse_link_template($chosen);
}
/**
* Get the tooltip for this image, formatted according to the
* configured template.
*/
2023-02-04 13:27:27 +00:00
#[Field(name: "tooltip")]
public function get_tooltip(): string
{
global $config;
2023-02-04 20:50:26 +00:00
return send_event(new ParseLinkTemplateEvent($config->get_string(ImageConfig::TIP), $this))->text;
}
/**
* Get the info for this image, formatted according to the
* configured template.
*/
2023-02-04 13:27:27 +00:00
#[Field(name: "info")]
public function get_info(): string
{
global $config;
2023-02-04 20:50:26 +00:00
return send_event(new ParseLinkTemplateEvent($config->get_string(ImageConfig::INFO), $this))->text;
}
/**
* Figure out where the full size image is on disk.
*/
public function get_image_filename(): string
{
if(!is_null($this->tmp_file)) {
return $this->tmp_file;
}
return warehouse_path(self::IMAGE_DIR, $this->hash);
}
/**
* Figure out where the thumbnail is on disk.
*/
public function get_thumb_filename(): string
{
return warehouse_path(self::THUMBNAIL_DIR, $this->hash);
}
/**
* Get the original filename.
*/
2023-02-14 01:02:58 +00:00
#[Field(name: "filename")]
public function get_filename(): string
{
return $this->filename;
}
2020-06-14 16:05:55 +00:00
/**
* Get the image's extension.
*/
2023-02-14 01:02:58 +00:00
#[Field(name: "ext")]
2020-06-14 16:05:55 +00:00
public function get_ext(): string
{
return $this->ext;
}
/**
* Get the image's mime type.
*/
2023-02-14 01:02:58 +00:00
#[Field(name: "mime")]
public function get_mime(): ?string
{
2023-11-11 21:49:12 +00:00
if ($this->mime === MimeType::WEBP && $this->lossless) {
2020-06-14 16:05:55 +00:00
return MimeType::WEBP_LOSSLESS;
}
2024-01-15 12:18:28 +00:00
return strtolower($this->mime);
}
/**
2020-06-14 16:05:55 +00:00
* Set the image's mime type.
*/
2020-06-14 16:05:55 +00:00
public function set_mime($mime): void
{
2020-06-14 16:05:55 +00:00
$this->mime = $mime;
$ext = FileExtension::get_for_mime($this->get_mime());
2023-06-27 16:45:35 +00:00
assert($ext !== null);
$this->ext = $ext;
}
2020-06-14 16:05:55 +00:00
/**
* Get the image's source URL
*/
2019-05-28 18:00:23 +00:00
public function get_source(): ?string
{
return $this->source;
}
/**
* Set the image's source URL
*/
public function set_source(string $new_source): void
{
global $database;
$old_source = $this->source;
if (empty($new_source)) {
$new_source = null;
}
if ($new_source != $old_source) {
2023-11-11 21:49:12 +00:00
$database->execute("UPDATE images SET source=:source WHERE id=:id", ["source" => $new_source, "id" => $this->id]);
2020-10-26 15:10:00 +00:00
log_info("core_image", "Source for Post #{$this->id} set to: $new_source (was $old_source)");
}
}
/**
* Check if the image is locked.
*/
public function is_locked(): bool
{
return $this->locked;
}
2020-10-27 01:05:12 +00:00
public function set_locked(bool $locked): void
{
global $database;
2020-10-27 01:05:12 +00:00
if ($locked !== $this->locked) {
2023-11-11 21:49:12 +00:00
$database->execute("UPDATE images SET locked=:yn WHERE id=:id", ["yn" => $locked, "id" => $this->id]);
2020-10-27 22:42:47 +00:00
log_info("core_image", "Setting Post #{$this->id} lock to: $locked");
}
}
/**
* Delete all tags from this image.
*
* Normally in preparation to set them to a new set.
*/
public function delete_tags_from_image(): void
{
global $database;
2023-06-25 14:59:10 +00:00
$database->execute("
UPDATE tags
SET count = count - 1
WHERE id IN (
SELECT tag_id
FROM image_tags
WHERE image_id = :id
)
2023-11-11 21:49:12 +00:00
", ["id" => $this->id]);
$database->execute("
2016-06-07 01:05:19 +00:00
DELETE
FROM image_tags
WHERE image_id=:id
2023-11-11 21:49:12 +00:00
", ["id" => $this->id]);
}
/**
* Set the tags for this image.
*/
2019-05-29 17:23:29 +00:00
public function set_tags(array $unfiltered_tags): void
{
global $cache, $database, $page;
$tags = array_unique($unfiltered_tags);
foreach ($tags as $tag) {
if (mb_strlen($tag, 'UTF-8') > 255) {
throw new TagSetException("Can't set a tag longer than 255 characters");
}
if (str_starts_with($tag, "-")) {
throw new TagSetException("Can't set a tag which starts with a minus");
}
if (str_contains($tag, "*")) {
throw new TagSetException("Can't set a tag which contains a wildcard (*)");
}
}
if (count($tags) <= 0) {
throw new TagSetException('Tried to set zero tags');
}
2023-06-25 21:47:04 +00:00
if (strtolower(Tag::implode($tags)) != strtolower($this->get_tag_list())) {
// delete old
$this->delete_tags_from_image();
// insert each new tags
2023-06-25 19:32:05 +00:00
$ids = array_map(fn ($tag) => Tag::get_or_create_id($tag), $tags);
$values = implode(", ", array_map(fn ($id) => "({$this->id}, $id)", $ids));
$database->execute("INSERT INTO image_tags(image_id, tag_id) VALUES $values");
2023-06-25 14:59:10 +00:00
$database->execute("
UPDATE tags
SET count = count + 1
WHERE id IN (
SELECT tag_id
FROM image_tags
WHERE image_id = :id
)
2023-11-11 21:49:12 +00:00
", ["id" => $this->id]);
2020-10-26 15:10:00 +00:00
log_info("core_image", "Tags for Post #{$this->id} set to: ".Tag::implode($tags));
$cache->delete("image-{$this->id}-tags");
}
}
/**
* Delete this image from the database and disk
*/
public function delete(): void
{
global $database;
$this->delete_tags_from_image();
2023-11-11 21:49:12 +00:00
$database->execute("DELETE FROM images WHERE id=:id", ["id" => $this->id]);
2020-10-26 15:10:00 +00:00
log_info("core_image", 'Deleted Post #'.$this->id.' ('.$this->hash.')');
$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(bool $quiet = false): void
{
$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}");
}
}
2023-11-11 21:49:12 +00:00
public function parse_link_template(string $tmpl, int $n = 0): string
{
$plte = send_event(new ParseLinkTemplateEvent($tmpl, $this));
$tmpl = $plte->link;
return load_balance_url($tmpl, $this->hash, $n);
}
}