File_thumbnail.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  1. <?php
  2. // This file is part of GNU social - https://www.gnu.org/software/social
  3. //
  4. // GNU social is free software: you can redistribute it and/or modify
  5. // it under the terms of the GNU Affero General Public License as published by
  6. // the Free Software Foundation, either version 3 of the License, or
  7. // (at your option) any later version.
  8. //
  9. // GNU social is distributed in the hope that it will be useful,
  10. // but WITHOUT ANY WARRANTY; without even the implied warranty of
  11. // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  12. // GNU Affero General Public License for more details.
  13. //
  14. // You should have received a copy of the GNU Affero General Public License
  15. // along with GNU social. If not, see <http://www.gnu.org/licenses/>.
  16. /*
  17. * @copyright 2008-2009 StatusNet, Inc.
  18. * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
  19. */
  20. defined('GNUSOCIAL') || die();
  21. /**
  22. * Table Definition for file_thumbnail
  23. */
  24. class File_thumbnail extends Managed_DataObject
  25. {
  26. public $__table = 'file_thumbnail'; // table name
  27. public $file_id; // int(4) primary_key not_null
  28. public $urlhash; // varchar(64) indexed
  29. public $url; // text
  30. public $filename; // text
  31. public $width; // int(4) primary_key
  32. public $height; // int(4) primary_key
  33. public $modified; // timestamp() not_null default_CURRENT_TIMESTAMP
  34. const URLHASH_ALG = 'sha256';
  35. public static function schemaDef()
  36. {
  37. return array(
  38. 'fields' => array(
  39. 'file_id' => array('type' => 'int', 'not null' => true, 'description' => 'thumbnail for what URL/file'),
  40. 'urlhash' => array('type' => 'varchar', 'length' => 64, 'description' => 'sha256 of url field if non-empty'),
  41. 'url' => array('type' => 'text', 'description' => 'URL of thumbnail'),
  42. 'filename' => array('type' => 'text', 'description' => 'if stored locally, filename is put here'),
  43. 'width' => array('type' => 'int', 'not null' => true, 'description' => 'width of thumbnail'),
  44. 'height' => array('type' => 'int', 'not null' => true, 'description' => 'height of thumbnail'),
  45. 'modified' => array('type' => 'timestamp', 'not null' => true, 'description' => 'date this record was modified'),
  46. ),
  47. 'primary key' => array('file_id', 'width', 'height'),
  48. 'indexes' => array(
  49. 'file_thumbnail_urlhash_idx' => array('urlhash'),
  50. ),
  51. 'foreign keys' => array(
  52. 'file_thumbnail_file_id_fkey' => array('file', array('file_id' => 'id')),
  53. )
  54. );
  55. }
  56. /**
  57. * Get the attachment's thumbnail record, if any or generate one.
  58. *
  59. * @param File $file
  60. * @param int|null $width Max width of thumbnail in pixels. (if null, use common_config values)
  61. * @param int|null $height Max height of thumbnail in pixels. (if null, square-crop to $width)
  62. * @param bool $crop Crop to the max-values' aspect ratio
  63. * @param bool $force_still Don't allow fallback to showing original (such as animated GIF)
  64. * @param bool|null $upscale Whether or not to scale smaller images up to larger thumbnail sizes. (null = site default)
  65. *
  66. * @return File_thumbnail
  67. *
  68. * @throws ClientException
  69. * @throws FileNotFoundException
  70. * @throws FileNotStoredLocallyException
  71. * @throws InvalidFilenameException
  72. * @throws NoResultException
  73. * @throws ServerException on various other errors
  74. * @throws UnsupportedMediaException if, despite trying, we can't understand how to make a thumbnail for this format
  75. * @throws UseFileAsThumbnailException if the file is considered an image itself and should be itself as thumbnail
  76. */
  77. public static function fromFileObject(
  78. File $file,
  79. ?int $width = null,
  80. ?int $height = null,
  81. bool $crop = false,
  82. bool $force_still = true,
  83. ?bool $upscale = null
  84. ): File_thumbnail {
  85. // Is file stored remotely only?
  86. $was_stored_remotely = $file->isStoredRemotely();
  87. // If StoreRemoteMedia or Embed are enabled (they will only act if appropriate btw)...
  88. $media = common_get_mime_media($file->mimetype);
  89. Event::handle('CreateFileImageThumbnailSource', [$file, &$imgPath, $media]);
  90. // If it was stored remotely, we can now assume it was sufficiently retrieved
  91. if ($was_stored_remotely) {
  92. $file = File::getById($file->getID());
  93. }
  94. if (file_exists($imgPath ?? "")) {
  95. $image = new ImageFile($imgPath, $file->getID(), null, $file->getUrl(false));
  96. } else {
  97. try {
  98. $image = ImageFile::fromFileObject($file);
  99. } catch (InvalidFilenameException $e) {
  100. // Not having an original local file doesn't mean we don't have a thumbnail.
  101. $existing_thumb = File_thumbnail::byFile($file);
  102. $image = new ImageFile($existing_thumb->getPath(), $file->getID(), null, $existing_thumb->url);
  103. }
  104. }
  105. if ($image->animated && !common_config('thumbnail', 'animated')) {
  106. // null means "always use file as thumbnail"
  107. // false means you get choice between frozen frame or original when calling getThumbnail
  108. if (is_null(common_config('thumbnail', 'animated')) || !$force_still) {
  109. try {
  110. // remote files with animated GIFs as thumbnails will match this
  111. return File_thumbnail::byFile($file);
  112. } catch (NoResultException $e) {
  113. // and if it's not a remote file, it'll be safe to use the locally stored File
  114. throw new UseFileAsThumbnailException($file);
  115. }
  116. }
  117. }
  118. return $image->getFileThumbnail(
  119. $width,
  120. $height,
  121. $crop,
  122. !is_null($upscale) ? $upscale : common_config('thumbnail', 'upscale')
  123. );
  124. }
  125. /**
  126. * Save oEmbed-provided thumbnail data
  127. *
  128. * @param object $data
  129. * @param int $file_id
  130. */
  131. public static function saveNew($data, $file_id)
  132. {
  133. if (!empty($data->thumbnail_url)) {
  134. // Non-photo types such as video will usually
  135. // show us a thumbnail, though it's not required.
  136. self::saveThumbnail(
  137. $file_id,
  138. $data->thumbnail_url,
  139. $data->thumbnail_width,
  140. $data->thumbnail_height
  141. );
  142. } elseif ($data->type == 'photo') {
  143. // The inline photo URL given should also fit within
  144. // our requested thumbnail size, per oEmbed spec.
  145. self::saveThumbnail(
  146. $file_id,
  147. $data->url,
  148. $data->width,
  149. $data->height
  150. );
  151. }
  152. }
  153. /**
  154. * Fetch an entry by using a File's id
  155. *
  156. * @param File $file The File object we're getting a thumbnail for.
  157. * @param boolean $notNullUrl Originally remote thumbnails have a URL stored, we use this to find the "original"
  158. *
  159. * @return File_thumbnail
  160. * @throws NoResultException if no File_thumbnail matched the criteria
  161. */
  162. public static function byFile(File $file, $notNullUrl = true)
  163. {
  164. $thumb = new File_thumbnail();
  165. $thumb->file_id = $file->getID();
  166. if ($notNullUrl) {
  167. $thumb->whereAdd('url IS NOT NULL');
  168. }
  169. $thumb->orderBy('modified ASC'); // the first created, a somewhat ugly hack
  170. $thumb->limit(1);
  171. if (!$thumb->find(true)) {
  172. throw new NoResultException($thumb);
  173. }
  174. return $thumb;
  175. }
  176. /**
  177. * Save a thumbnail record for the referenced file record.
  178. *
  179. * FIXME: Add error handling
  180. *
  181. * @param int $file_id
  182. * @param string $url
  183. * @param int $width
  184. * @param int $height
  185. */
  186. public static function saveThumbnail($file_id, $url, $width, $height, $filename = null)
  187. {
  188. $tn = new File_thumbnail;
  189. $tn->file_id = $file_id;
  190. $tn->url = $url;
  191. $tn->filename = $filename;
  192. $tn->width = (int)$width;
  193. $tn->height = (int)$height;
  194. $tn->insert();
  195. return $tn;
  196. }
  197. public static function path($filename): string
  198. {
  199. File::tryFilename($filename);
  200. // NOTE: If this is left empty in default config, it will be set to File::path('thumb')
  201. $dir = common_config('thumbnail', 'dir');
  202. if (!in_array($dir[mb_strlen($dir)-1], ['/', '\\'])) {
  203. $dir .= DIRECTORY_SEPARATOR;
  204. }
  205. return $dir . $filename;
  206. }
  207. public function getFilename()
  208. {
  209. return File::tryFilename($this->filename);
  210. }
  211. /**
  212. * @return string full filesystem path to the locally stored thumbnail file
  213. * @throws FileNotFoundException
  214. * @throws ServerException
  215. */
  216. public function getPath(): string
  217. {
  218. $oldpath = File::path($this->getFilename());
  219. $thumbpath = self::path($this->getFilename());
  220. // If we have a file in our old thumbnail storage path, move (or copy) it to the new one
  221. // (if the if/elseif don't match, we have a $thumbpath just as we should and can return it)
  222. if (file_exists($oldpath) && !file_exists($thumbpath)) {
  223. try {
  224. // let's get the filename of the File, to check below if it happens to be identical
  225. $file_filename = $this->getFile()->getFilename();
  226. } catch (NoResultException $e) {
  227. // reasonably the function calling us will handle the following as "File_thumbnail entry should be deleted"
  228. throw new FileNotFoundException($thumbpath);
  229. } catch (InvalidFilenameException $e) {
  230. // invalid filename in getFile()->getFilename(), just
  231. // means the File object isn't stored locally and that
  232. // means it's safe to move it below.
  233. $file_filename = null;
  234. }
  235. if ($this->getFilename() === $file_filename) {
  236. // special case where thumbnail file exactly matches stored File filename
  237. common_debug('File filename and File_thumbnail filename match on '.$this->file_id.', copying instead');
  238. copy($oldpath, $thumbpath);
  239. } elseif (!rename($oldpath, $thumbpath)) {
  240. common_log(LOG_ERR, 'Could not move thumbnail from '._ve($oldpath).' to '._ve($thumbpath));
  241. throw new ServerException('Could not move thumbnail from old path to new path.');
  242. } else {
  243. common_log(LOG_DEBUG, 'Moved thumbnail '.$this->file_id.' from '._ve($oldpath).' to '._ve($thumbpath));
  244. }
  245. } elseif (!file_exists($thumbpath)) {
  246. throw new FileNotFoundException($thumbpath);
  247. }
  248. return $thumbpath;
  249. }
  250. public function getUrl()
  251. {
  252. $url = common_local_url('attachment_thumbnail', ['attachment' => $this->getFile()->getID()]);
  253. if (strpos($url, '?') === false) {
  254. $url .= '?';
  255. }
  256. return $url . http_build_query(['w'=>$this->width, 'h'=>$this->height]);
  257. }
  258. public function getHeight()
  259. {
  260. return $this->height;
  261. }
  262. public function getWidth()
  263. {
  264. return $this->width;
  265. }
  266. /**
  267. * @throws UseFileAsThumbnailException from File_thumbnail->getUrl() for stuff like animated GIFs
  268. */
  269. public function getHtmlAttrs(array $orig=array(), $overwrite=true)
  270. {
  271. $attrs = [ 'height' => $this->getHeight(),
  272. 'width' => $this->getWidth(),
  273. 'src' => $this->getUrl() ];
  274. return $overwrite ? array_merge($orig, $attrs) : array_merge($attrs, $orig);
  275. }
  276. public function delete($useWhere=false)
  277. {
  278. try {
  279. $thumbpath = self::path($this->getFilename());
  280. // if file does not exist, try to delete it
  281. $deleted = !file_exists($thumbpath) || @unlink($thumbpath);
  282. if (!$deleted) {
  283. common_log(LOG_ERR, 'Could not unlink existing thumbnail file: '._ve($thumbpath));
  284. }
  285. } catch (InvalidFilenameException $e) {
  286. common_log(LOG_ERR, 'Deleting object but not attempting deleting file: '._ve($e->getMessage()));
  287. }
  288. return parent::delete($useWhere);
  289. }
  290. public function getFile(): File
  291. {
  292. return File::getByID($this->file_id);
  293. }
  294. public function getFileId()
  295. {
  296. return $this->file_id;
  297. }
  298. public static function hashurl($url)
  299. {
  300. if (!mb_strlen($url)) {
  301. throw new Exception('No URL provided to hash algorithm.');
  302. }
  303. return hash(self::URLHASH_ALG, $url);
  304. }
  305. public function onInsert()
  306. {
  307. $this->setUrlhash();
  308. }
  309. public function onUpdate($dataObject=false)
  310. {
  311. // if we have nothing to compare with OR it has changed from previous entry
  312. if (!$dataObject instanceof Managed_DataObject || $this->url !== $dataObject->url) {
  313. $this->setUrlhash();
  314. }
  315. }
  316. public function setUrlhash()
  317. {
  318. $this->urlhash = mb_strlen($this->url)>0 ? self::hashurl($this->url) : null;
  319. }
  320. }