File_thumbnail.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362
  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. if (is_null($file->filename)) { // Remote file
  86. // If StoreRemoteMedia is enabled...
  87. if (Event::handle('CreateFileImageThumbnailSource', [$file, &$imgPath, 'image'])) {
  88. if (!file_exists($imgPath)) {
  89. throw new FileNotFoundException($imgPath);
  90. }
  91. // First some mimetype specific exceptions
  92. switch ($file->mimetype) {
  93. case 'image/svg+xml':
  94. throw new UseFileAsThumbnailException($file);
  95. }
  96. }
  97. throw new FileNotFoundException("This remote file has no local thumbnail.");
  98. }
  99. $image = ImageFile::fromFileObject($file);
  100. $imgPath = $image->getPath();
  101. $media = common_get_mime_media($file->mimetype);
  102. if (Event::handle('CreateFileImageThumbnailSource', [$file, &$imgPath, $media])) {
  103. if (!file_exists($imgPath)) {
  104. throw new FileNotFoundException($imgPath);
  105. }
  106. // First some mimetype specific exceptions
  107. switch ($file->mimetype) {
  108. case 'image/svg+xml':
  109. throw new UseFileAsThumbnailException($file);
  110. }
  111. }
  112. if ($image->animated && !common_config('thumbnail', 'animated')) {
  113. // null means "always use file as thumbnail"
  114. // false means you get choice between frozen frame or original when calling getThumbnail
  115. if (is_null(common_config('thumbnail', 'animated')) || !$force_still) {
  116. try {
  117. // remote files with animated GIFs as thumbnails will match this
  118. return File_thumbnail::byFile($file);
  119. } catch (NoResultException $e) {
  120. // and if it's not a remote file, it'll be safe to use the locally stored File
  121. throw new UseFileAsThumbnailException($file);
  122. }
  123. }
  124. }
  125. return $image->getFileThumbnail(
  126. $width,
  127. $height,
  128. $crop,
  129. !is_null($upscale) ? $upscale : common_config('thumbnail', 'upscale')
  130. );
  131. }
  132. /**
  133. * Save oEmbed-provided thumbnail data
  134. *
  135. * @param object $data
  136. * @param int $file_id
  137. */
  138. public static function saveNew($data, $file_id)
  139. {
  140. if (!empty($data->thumbnail_url)) {
  141. // Non-photo types such as video will usually
  142. // show us a thumbnail, though it's not required.
  143. self::saveThumbnail(
  144. $file_id,
  145. $data->thumbnail_url,
  146. $data->thumbnail_width,
  147. $data->thumbnail_height
  148. );
  149. } elseif ($data->type == 'photo') {
  150. // The inline photo URL given should also fit within
  151. // our requested thumbnail size, per oEmbed spec.
  152. self::saveThumbnail(
  153. $file_id,
  154. $data->url,
  155. $data->width,
  156. $data->height
  157. );
  158. }
  159. }
  160. /**
  161. * Fetch an entry by using a File's id
  162. *
  163. * @param File $file The File object we're getting a thumbnail for.
  164. * @param boolean $notNullUrl Originally remote thumbnails have a URL stored, we use this to find the "original"
  165. *
  166. * @return File_thumbnail
  167. * @throws NoResultException if no File_thumbnail matched the criteria
  168. */
  169. public static function byFile(File $file, $notNullUrl = true)
  170. {
  171. $thumb = new File_thumbnail();
  172. $thumb->file_id = $file->getID();
  173. if ($notNullUrl) {
  174. $thumb->whereAdd('url IS NOT NULL');
  175. }
  176. $thumb->orderBy('modified ASC'); // the first created, a somewhat ugly hack
  177. $thumb->limit(1);
  178. if (!$thumb->find(true)) {
  179. throw new NoResultException($thumb);
  180. }
  181. return $thumb;
  182. }
  183. /**
  184. * Save a thumbnail record for the referenced file record.
  185. *
  186. * FIXME: Add error handling
  187. *
  188. * @param int $file_id
  189. * @param string $url
  190. * @param int $width
  191. * @param int $height
  192. */
  193. public static function saveThumbnail($file_id, $url, $width, $height, $filename = null)
  194. {
  195. $tn = new File_thumbnail;
  196. $tn->file_id = $file_id;
  197. $tn->url = $url;
  198. $tn->filename = $filename;
  199. $tn->width = (int)$width;
  200. $tn->height = (int)$height;
  201. $tn->insert();
  202. return $tn;
  203. }
  204. public static function path($filename)
  205. {
  206. File::tryFilename($filename);
  207. // NOTE: If this is left empty in default config, it will be set to File::path('thumb')
  208. $dir = common_config('thumbnail', 'dir');
  209. if (!in_array($dir[mb_strlen($dir)-1], ['/', '\\'])) {
  210. $dir .= DIRECTORY_SEPARATOR;
  211. }
  212. return $dir . $filename;
  213. }
  214. public function getFilename()
  215. {
  216. return File::tryFilename($this->filename);
  217. }
  218. /**
  219. * @return string full filesystem path to the locally stored thumbnail file
  220. * @throws FileNotFoundException
  221. * @throws ServerException
  222. */
  223. public function getPath()
  224. {
  225. $oldpath = File::path($this->getFilename());
  226. $thumbpath = self::path($this->getFilename());
  227. // If we have a file in our old thumbnail storage path, move (or copy) it to the new one
  228. // (if the if/elseif don't match, we have a $thumbpath just as we should and can return it)
  229. if (file_exists($oldpath) && !file_exists($thumbpath)) {
  230. try {
  231. // let's get the filename of the File, to check below if it happens to be identical
  232. $file_filename = $this->getFile()->getFilename();
  233. } catch (NoResultException $e) {
  234. // reasonably the function calling us will handle the following as "File_thumbnail entry should be deleted"
  235. throw new FileNotFoundException($thumbpath);
  236. } catch (InvalidFilenameException $e) {
  237. // invalid filename in getFile()->getFilename(), just
  238. // means the File object isn't stored locally and that
  239. // means it's safe to move it below.
  240. $file_filename = null;
  241. }
  242. if ($this->getFilename() === $file_filename) {
  243. // special case where thumbnail file exactly matches stored File filename
  244. common_debug('File filename and File_thumbnail filename match on '.$this->file_id.', copying instead');
  245. copy($oldpath, $thumbpath);
  246. } elseif (!rename($oldpath, $thumbpath)) {
  247. common_log(LOG_ERR, 'Could not move thumbnail from '._ve($oldpath).' to '._ve($thumbpath));
  248. throw new ServerException('Could not move thumbnail from old path to new path.');
  249. } else {
  250. common_log(LOG_DEBUG, 'Moved thumbnail '.$this->file_id.' from '._ve($oldpath).' to '._ve($thumbpath));
  251. }
  252. } elseif (!file_exists($thumbpath)) {
  253. throw new FileNotFoundException($thumbpath);
  254. }
  255. return $thumbpath;
  256. }
  257. public function getUrl()
  258. {
  259. $url = common_local_url('attachment_thumbnail', ['filehash' => $this->getFile()->filehash]);
  260. if (strpos($url, '?') === false) {
  261. $url .= '?';
  262. }
  263. return $url . http_build_query(['w'=>$this->width, 'h'=>$this->height]);
  264. }
  265. public function getHeight()
  266. {
  267. return $this->height;
  268. }
  269. public function getWidth()
  270. {
  271. return $this->width;
  272. }
  273. /**
  274. * @throws UseFileAsThumbnailException from File_thumbnail->getUrl() for stuff like animated GIFs
  275. */
  276. public function getHtmlAttrs(array $orig=array(), $overwrite=true)
  277. {
  278. $attrs = [ 'height' => $this->getHeight(),
  279. 'width' => $this->getWidth(),
  280. 'src' => $this->getUrl() ];
  281. return $overwrite ? array_merge($orig, $attrs) : array_merge($attrs, $orig);
  282. }
  283. public function delete($useWhere=false)
  284. {
  285. try {
  286. $thumbpath = self::path($this->getFilename());
  287. // if file does not exist, try to delete it
  288. $deleted = !file_exists($thumbpath) || @unlink($thumbpath);
  289. if (!$deleted) {
  290. common_log(LOG_ERR, 'Could not unlink existing thumbnail file: '._ve($thumbpath));
  291. }
  292. } catch (InvalidFilenameException $e) {
  293. common_log(LOG_ERR, 'Deleting object but not attempting deleting file: '._ve($e->getMessage()));
  294. }
  295. return parent::delete($useWhere);
  296. }
  297. public function getFile(): File
  298. {
  299. return File::getByID($this->file_id);
  300. }
  301. public function getFileId()
  302. {
  303. return $this->file_id;
  304. }
  305. public static function hashurl($url)
  306. {
  307. if (!mb_strlen($url)) {
  308. throw new Exception('No URL provided to hash algorithm.');
  309. }
  310. return hash(self::URLHASH_ALG, $url);
  311. }
  312. public function onInsert()
  313. {
  314. $this->setUrlhash();
  315. }
  316. public function onUpdate($dataObject=false)
  317. {
  318. // if we have nothing to compare with OR it has changed from previous entry
  319. if (!$dataObject instanceof Managed_DataObject || $this->url !== $dataObject->url) {
  320. $this->setUrlhash();
  321. }
  322. }
  323. public function setUrlhash()
  324. {
  325. $this->urlhash = mb_strlen($this->url)>0 ? self::hashurl($this->url) : null;
  326. }
  327. }