File.php 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586
  1. <?php
  2. /*
  3. * StatusNet - the distributed open-source microblogging tool
  4. * Copyright (C) 2008, 2009, StatusNet, Inc.
  5. *
  6. * This program is free software: you can redistribute it and/or modify
  7. * it under the terms of the GNU Affero General Public License as published by
  8. * the Free Software Foundation, either version 3 of the License, or
  9. * (at your option) any later version.
  10. *
  11. * This program is distributed in the hope that it will be useful,
  12. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. * GNU Affero General Public License for more details.
  15. *
  16. * You should have received a copy of the GNU Affero General Public License
  17. * along with this program. If not, see <http://www.gnu.org/licenses/>.
  18. */
  19. if (!defined('GNUSOCIAL')) { exit(1); }
  20. /**
  21. * Table Definition for file
  22. */
  23. class File extends Managed_DataObject
  24. {
  25. public $__table = 'file'; // table name
  26. public $id; // int(4) primary_key not_null
  27. public $url; // varchar(255) unique_key
  28. public $mimetype; // varchar(50)
  29. public $size; // int(4)
  30. public $title; // varchar(255)
  31. public $date; // int(4)
  32. public $protected; // int(4)
  33. public $filename; // varchar(255)
  34. public $width; // int(4)
  35. public $height; // int(4)
  36. public $modified; // timestamp() not_null default_CURRENT_TIMESTAMP
  37. public static function schemaDef()
  38. {
  39. return array(
  40. 'fields' => array(
  41. 'id' => array('type' => 'serial', 'not null' => true),
  42. 'url' => array('type' => 'varchar', 'length' => 255, 'description' => 'destination URL after following redirections'),
  43. 'mimetype' => array('type' => 'varchar', 'length' => 50, 'description' => 'mime type of resource'),
  44. 'size' => array('type' => 'int', 'description' => 'size of resource when available'),
  45. 'title' => array('type' => 'varchar', 'length' => 255, 'description' => 'title of resource when available'),
  46. 'date' => array('type' => 'int', 'description' => 'date of resource according to http query'),
  47. 'protected' => array('type' => 'int', 'description' => 'true when URL is private (needs login)'),
  48. 'filename' => array('type' => 'varchar', 'length' => 255, 'description' => 'if a local file, name of the file'),
  49. 'width' => array('type' => 'int', 'description' => 'width in pixels, if it can be described as such and data is available'),
  50. 'height' => array('type' => 'int', 'description' => 'height in pixels, if it can be described as such and data is available'),
  51. 'modified' => array('type' => 'timestamp', 'not null' => true, 'description' => 'date this record was modified'),
  52. ),
  53. 'primary key' => array('id'),
  54. 'unique keys' => array(
  55. 'file_url_key' => array('url'),
  56. ),
  57. );
  58. }
  59. function isProtected($url) {
  60. return 'http://www.facebook.com/login.php' === $url;
  61. }
  62. /**
  63. * Save a new file record.
  64. *
  65. * @param array $redir_data lookup data eg from File_redirection::where()
  66. * @param string $given_url
  67. * @return File
  68. */
  69. public static function saveNew(array $redir_data, $given_url) {
  70. // I don't know why we have to keep doing this but I'm adding this last check to avoid
  71. // uniqueness bugs.
  72. $file = File::getKV('url', $given_url);
  73. if (!$file instanceof File) {
  74. $file = new File;
  75. $file->url = $given_url;
  76. if (!empty($redir_data['protected'])) $file->protected = $redir_data['protected'];
  77. if (!empty($redir_data['title'])) $file->title = $redir_data['title'];
  78. if (!empty($redir_data['type'])) $file->mimetype = $redir_data['type'];
  79. if (!empty($redir_data['size'])) $file->size = intval($redir_data['size']);
  80. if (isset($redir_data['time']) && $redir_data['time'] > 0) $file->date = intval($redir_data['time']);
  81. $file_id = $file->insert();
  82. }
  83. Event::handle('EndFileSaveNew', array($file, $redir_data, $given_url));
  84. assert ($file instanceof File);
  85. return $file;
  86. }
  87. /**
  88. * Go look at a URL and possibly save data about it if it's new:
  89. * - follow redirect chains and store them in file_redirection
  90. * - if a thumbnail is available, save it in file_thumbnail
  91. * - save file record with basic info
  92. * - optionally save a file_to_post record
  93. * - return the File object with the full reference
  94. *
  95. * @fixme refactor this mess, it's gotten pretty scary.
  96. * @param string $given_url the URL we're looking at
  97. * @param int $notice_id (optional)
  98. * @param bool $followRedirects defaults to true
  99. *
  100. * @return mixed File on success, -1 on some errors
  101. *
  102. * @throws ServerException on failure
  103. */
  104. public static function processNew($given_url, $notice_id=null, $followRedirects=true) {
  105. if (empty($given_url)) {
  106. throw new ServerException('No given URL to process');
  107. }
  108. $given_url = File_redirection::_canonUrl($given_url);
  109. if (empty($given_url)) {
  110. throw new ServerException('No canonical URL from given URL to process');
  111. }
  112. $file = File::getKV('url', $given_url);
  113. if (!$file instanceof File) {
  114. // First check if we have a lookup trace for this URL already
  115. $file_redir = File_redirection::getKV('url', $given_url);
  116. if ($file_redir instanceof File_redirection) {
  117. $file = File::getKV('id', $file_redir->file_id);
  118. if (!$file instanceof File) {
  119. // File did not exist, let's clean up the File_redirection entry
  120. $file_redir->delete();
  121. }
  122. }
  123. // If we still don't have a File object, let's create one now!
  124. if (!$file instanceof File) {
  125. // @fixme for new URLs this also looks up non-redirect data
  126. // such as target content type, size, etc, which we need
  127. // for File::saveNew(); so we call it even if not following
  128. // new redirects.
  129. $redir_data = File_redirection::where($given_url);
  130. if (is_array($redir_data)) {
  131. $redir_url = $redir_data['url'];
  132. } elseif (is_string($redir_data)) {
  133. $redir_url = $redir_data;
  134. $redir_data = array();
  135. } else {
  136. // TRANS: Server exception thrown when a URL cannot be processed.
  137. throw new ServerException(sprintf(_("Cannot process URL '%s'"), $given_url));
  138. }
  139. // TODO: max field length
  140. if ($redir_url === $given_url || strlen($redir_url) > 255 || !$followRedirects) {
  141. // Save the File object based on our lookup trace
  142. $file = File::saveNew($redir_data, $given_url);
  143. } else {
  144. // This seems kind of messed up... for now skipping this part
  145. // if we're already under a redirect, so we don't go into
  146. // horrible infinite loops if we've been given an unstable
  147. // redirect (where the final destination of the first request
  148. // doesn't match what we get when we ask for it again).
  149. //
  150. // Seen in the wild with clojure.org, which redirects through
  151. // wikispaces for auth and appends session data in the URL params.
  152. $file = self::processNew($redir_url, $notice_id, /*followRedirects*/false);
  153. File_redirection::saveNew($redir_data, $file->id, $given_url);
  154. }
  155. }
  156. if (!$file instanceof File) {
  157. // This should only happen if File::saveNew somehow did not return a File object,
  158. // though we have an assert for that in case the event there might've gone wrong.
  159. // If anything else goes wrong, there should've been an exception thrown.
  160. throw new ServerException('URL processing failed without new File object');
  161. }
  162. }
  163. if (!empty($notice_id)) {
  164. File_to_post::processNew($file->id, $notice_id);
  165. }
  166. return $file;
  167. }
  168. public static function respectsQuota(Profile $scoped, $fileSize) {
  169. if ($fileSize > common_config('attachments', 'file_quota')) {
  170. // TRANS: Message used to be inserted as %2$s in the text "No file may
  171. // TRANS: be larger than %1$d byte and the file you sent was %2$s.".
  172. // TRANS: %1$d is the number of bytes of an uploaded file.
  173. $fileSizeText = sprintf(_m('%1$d byte','%1$d bytes',$fileSize),$fileSize);
  174. $fileQuota = common_config('attachments', 'file_quota');
  175. // TRANS: Message given if an upload is larger than the configured maximum.
  176. // TRANS: %1$d (used for plural) is the byte limit for uploads,
  177. // TRANS: %2$s is the proper form of "n bytes". This is the only ways to have
  178. // TRANS: gettext support multiple plurals in the same message, unfortunately...
  179. throw new ClientException(
  180. sprintf(_m('No file may be larger than %1$d byte and the file you sent was %2$s. Try to upload a smaller version.',
  181. 'No file may be larger than %1$d bytes and the file you sent was %2$s. Try to upload a smaller version.',
  182. $fileQuota),
  183. $fileQuota, $fileSizeText));
  184. }
  185. $file = new File;
  186. $query = "select sum(size) as total from file join file_to_post on file_to_post.file_id = file.id join notice on file_to_post.post_id = notice.id where profile_id = {$scoped->id} and file.url like '%/notice/%/file'";
  187. $file->query($query);
  188. $file->fetch();
  189. $total = $file->total + $fileSize;
  190. if ($total > common_config('attachments', 'user_quota')) {
  191. // TRANS: Message given if an upload would exceed user quota.
  192. // TRANS: %d (number) is the user quota in bytes and is used for plural.
  193. throw new ClientException(
  194. sprintf(_m('A file this large would exceed your user quota of %d byte.',
  195. 'A file this large would exceed your user quota of %d bytes.',
  196. common_config('attachments', 'user_quota')),
  197. common_config('attachments', 'user_quota')));
  198. }
  199. $query .= ' AND EXTRACT(month FROM file.modified) = EXTRACT(month FROM now()) and EXTRACT(year FROM file.modified) = EXTRACT(year FROM now())';
  200. $file->query($query);
  201. $file->fetch();
  202. $total = $file->total + $fileSize;
  203. if ($total > common_config('attachments', 'monthly_quota')) {
  204. // TRANS: Message given id an upload would exceed a user's monthly quota.
  205. // TRANS: $d (number) is the monthly user quota in bytes and is used for plural.
  206. throw new ClientException(
  207. sprintf(_m('A file this large would exceed your monthly quota of %d byte.',
  208. 'A file this large would exceed your monthly quota of %d bytes.',
  209. common_config('attachments', 'monthly_quota')),
  210. common_config('attachments', 'monthly_quota')));
  211. }
  212. return true;
  213. }
  214. // where should the file go?
  215. static function filename(Profile $profile, $origname, $mimetype)
  216. {
  217. try {
  218. $ext = common_supported_mime_to_ext($mimetype);
  219. } catch (Exception $e) {
  220. // We don't support this mimetype, but let's guess the extension
  221. $ext = substr(strrchr($mimetype, '/'), 1);
  222. }
  223. // Normalize and make the original filename more URL friendly.
  224. $origname = basename($origname, ".$ext");
  225. if (class_exists('Normalizer')) {
  226. // http://php.net/manual/en/class.normalizer.php
  227. // http://www.unicode.org/reports/tr15/
  228. $origname = Normalizer::normalize($origname, Normalizer::FORM_KC);
  229. }
  230. $origname = preg_replace('/[^A-Za-z0-9\.\_]/', '_', $origname);
  231. $nickname = $profile->getNickname();
  232. $datestamp = strftime('%Y%m%d', time());
  233. do {
  234. // generate new random strings until we don't run into a filename collision.
  235. $random = strtolower(common_confirmation_code(16));
  236. $filename = "$nickname-$datestamp-$origname-$random.$ext";
  237. } while (file_exists(self::path($filename)));
  238. return $filename;
  239. }
  240. /**
  241. * Validation for as-saved base filenames
  242. */
  243. static function validFilename($filename)
  244. {
  245. return preg_match('/^[A-Za-z0-9._-]+$/', $filename);
  246. }
  247. /**
  248. * @throws ClientException on invalid filename
  249. */
  250. static function path($filename)
  251. {
  252. if (!self::validFilename($filename)) {
  253. // TRANS: Client exception thrown if a file upload does not have a valid name.
  254. throw new ClientException(_("Invalid filename."));
  255. }
  256. $dir = common_config('attachments', 'dir');
  257. if ($dir[strlen($dir)-1] != '/') {
  258. $dir .= '/';
  259. }
  260. return $dir . $filename;
  261. }
  262. static function url($filename)
  263. {
  264. if (!self::validFilename($filename)) {
  265. // TRANS: Client exception thrown if a file upload does not have a valid name.
  266. throw new ClientException(_("Invalid filename."));
  267. }
  268. if (common_config('site','private')) {
  269. return common_local_url('getfile',
  270. array('filename' => $filename));
  271. }
  272. if (StatusNet::useHTTPS()) {
  273. $sslserver = common_config('attachments', 'sslserver');
  274. if (empty($sslserver)) {
  275. // XXX: this assumes that background dir == site dir + /file/
  276. // not true if there's another server
  277. if (is_string(common_config('site', 'sslserver')) &&
  278. mb_strlen(common_config('site', 'sslserver')) > 0) {
  279. $server = common_config('site', 'sslserver');
  280. } else if (common_config('site', 'server')) {
  281. $server = common_config('site', 'server');
  282. }
  283. $path = common_config('site', 'path') . '/file/';
  284. } else {
  285. $server = $sslserver;
  286. $path = common_config('attachments', 'sslpath');
  287. if (empty($path)) {
  288. $path = common_config('attachments', 'path');
  289. }
  290. }
  291. $protocol = 'https';
  292. } else {
  293. $path = common_config('attachments', 'path');
  294. $server = common_config('attachments', 'server');
  295. if (empty($server)) {
  296. $server = common_config('site', 'server');
  297. }
  298. $ssl = common_config('attachments', 'ssl');
  299. $protocol = ($ssl) ? 'https' : 'http';
  300. }
  301. if ($path[strlen($path)-1] != '/') {
  302. $path .= '/';
  303. }
  304. if ($path[0] != '/') {
  305. $path = '/'.$path;
  306. }
  307. return $protocol.'://'.$server.$path.$filename;
  308. }
  309. function getEnclosure(){
  310. $enclosure = (object) array();
  311. foreach (array('title', 'url', 'date', 'modified', 'size', 'mimetype') as $key) {
  312. $enclosure->$key = $this->$key;
  313. }
  314. $needMoreMetadataMimetypes = array(null, 'application/xhtml+xml');
  315. if (!isset($this->filename) && in_array(common_bare_mime($enclosure->mimetype), $needMoreMetadataMimetypes)) {
  316. // This fetches enclosure metadata for non-local links with unset/HTML mimetypes,
  317. // which may be enriched through oEmbed or similar (implemented as plugins)
  318. Event::handle('FileEnclosureMetadata', array($this, &$enclosure));
  319. }
  320. if (empty($enclosure->mimetype) || in_array(common_bare_mime($enclosure->mimetype), $needMoreMetadataMimetypes)) {
  321. // This means we either don't know what it is, so it can't
  322. // be shown as an enclosure, or it is an HTML link which
  323. // does not link to a resource with further metadata.
  324. throw new ServerException('Unknown enclosure mimetype, not enough metadata');
  325. }
  326. return $enclosure;
  327. }
  328. /**
  329. * Get the attachment's thumbnail record, if any.
  330. * Make sure you supply proper 'int' typed variables (or null).
  331. *
  332. * @param $width int Max width of thumbnail in pixels. (if null, use common_config values)
  333. * @param $height int Max height of thumbnail in pixels. (if null, square-crop to $width)
  334. * @param $crop bool Crop to the max-values' aspect ratio
  335. *
  336. * @return File_thumbnail
  337. */
  338. public function getThumbnail($width=null, $height=null, $crop=false, $force_still=true)
  339. {
  340. // Get some more information about this file through our ImageFile class
  341. $image = ImageFile::fromFileObject($this);
  342. if ($image->animated && !common_config('thumbnail', 'animated')) {
  343. // null means "always use file as thumbnail"
  344. // false means you get choice between frozen frame or original when calling getThumbnail
  345. if (is_null(common_config('thumbnail', 'animated')) || !$force_still) {
  346. throw new UseFileAsThumbnailException($this->id);
  347. }
  348. }
  349. if ($width === null) {
  350. $width = common_config('thumbnail', 'width');
  351. $height = common_config('thumbnail', 'height');
  352. $crop = common_config('thumbnail', 'crop');
  353. }
  354. if ($height === null) {
  355. $height = $width;
  356. $crop = true;
  357. }
  358. // Get proper aspect ratio width and height before lookup
  359. // We have to do it through an ImageFile object because of orientation etc.
  360. // Only other solution would've been to rotate + rewrite uploaded files.
  361. list($width, $height, $x, $y, $w, $h) =
  362. $image->scaleToFit($width, $height, $crop);
  363. $params = array('file_id'=> $this->id,
  364. 'width' => $width,
  365. 'height' => $height);
  366. $thumb = File_thumbnail::pkeyGet($params);
  367. if ($thumb instanceof File_thumbnail) {
  368. return $thumb;
  369. }
  370. // throws exception on failure to generate thumbnail
  371. $outname = "thumb-{$width}x{$height}-" . $image->filename;
  372. $outpath = self::path($outname);
  373. // The boundary box for our resizing
  374. $box = array('width'=>$width, 'height'=>$height,
  375. 'x'=>$x, 'y'=>$y,
  376. 'w'=>$w, 'h'=>$h);
  377. // Doublecheck that parameters are sane and integers.
  378. if ($box['width'] < 1 || $box['width'] > common_config('thumbnail', 'maxsize')
  379. || $box['height'] < 1 || $box['height'] > common_config('thumbnail', 'maxsize')
  380. || $box['w'] < 1 || $box['x'] >= $image->width
  381. || $box['h'] < 1 || $box['y'] >= $image->height) {
  382. // Fail on bad width parameter. If this occurs, it's due to algorithm in ImageFile->scaleToFit
  383. common_debug("Boundary box parameters for resize of {$image->filepath} : ".var_export($box,true));
  384. throw new ServerException('Bad thumbnail size parameters.');
  385. }
  386. common_debug(sprintf('Generating a thumbnail of File id==%u of size %ux%u', $this->id, $width, $height));
  387. // Perform resize and store into file
  388. $image->resizeTo($outpath, $box);
  389. // Avoid deleting the original
  390. if ($image->getPath() != self::path($image->filename)) {
  391. $image->unlink();
  392. }
  393. return File_thumbnail::saveThumbnail($this->id,
  394. self::url($outname),
  395. $width, $height,
  396. $outname);
  397. }
  398. public function getPath()
  399. {
  400. return self::path($this->filename);
  401. }
  402. public function getUrl()
  403. {
  404. if (!empty($this->filename)) {
  405. // A locally stored file, so let's generate a URL for our instance.
  406. $url = self::url($this->filename);
  407. if ($url != $this->url) {
  408. // For indexing purposes, in case we do a lookup on the 'url' field.
  409. // also we're fixing possible changes from http to https, or paths
  410. $this->updateUrl($url);
  411. }
  412. return $url;
  413. }
  414. // No local filename available, return the URL we have stored
  415. return $this->url;
  416. }
  417. public function updateUrl($url)
  418. {
  419. $file = File::getKV('url', $url);
  420. if ($file instanceof File) {
  421. throw new ServerException('URL already exists in DB');
  422. }
  423. $sql = 'UPDATE %1$s SET url=%2$s WHERE url=%3$s;';
  424. $result = $this->query(sprintf($sql, $this->__table,
  425. $this->_quote((string)$url),
  426. $this->_quote((string)$this->url)));
  427. if ($result === false) {
  428. common_log_db_error($this, 'UPDATE', __FILE__);
  429. throw new ServerException("Could not UPDATE {$this->__table}.url");
  430. }
  431. return $result;
  432. }
  433. /**
  434. * Blow the cache of notices that link to this URL
  435. *
  436. * @param boolean $last Whether to blow the "last" cache too
  437. *
  438. * @return void
  439. */
  440. function blowCache($last=false)
  441. {
  442. self::blow('file:notice-ids:%s', $this->url);
  443. if ($last) {
  444. self::blow('file:notice-ids:%s;last', $this->url);
  445. }
  446. self::blow('file:notice-count:%d', $this->id);
  447. }
  448. /**
  449. * Stream of notices linking to this URL
  450. *
  451. * @param integer $offset Offset to show; default is 0
  452. * @param integer $limit Limit of notices to show
  453. * @param integer $since_id Since this notice
  454. * @param integer $max_id Before this notice
  455. *
  456. * @return array ids of notices that link to this file
  457. */
  458. function stream($offset=0, $limit=NOTICES_PER_PAGE, $since_id=0, $max_id=0)
  459. {
  460. $stream = new FileNoticeStream($this);
  461. return $stream->getNotices($offset, $limit, $since_id, $max_id);
  462. }
  463. function noticeCount()
  464. {
  465. $cacheKey = sprintf('file:notice-count:%d', $this->id);
  466. $count = self::cacheGet($cacheKey);
  467. if ($count === false) {
  468. $f2p = new File_to_post();
  469. $f2p->file_id = $this->id;
  470. $count = $f2p->count();
  471. self::cacheSet($cacheKey, $count);
  472. }
  473. return $count;
  474. }
  475. public function isLocal()
  476. {
  477. return !empty($this->filename);
  478. }
  479. public function delete($useWhere=false)
  480. {
  481. // Delete the file, if it exists locally
  482. if (!empty($this->filename) && file_exists(self::path($this->filename))) {
  483. $deleted = @unlink(self::path($this->filename));
  484. if (!$deleted) {
  485. common_log(LOG_ERR, sprintf('Could not unlink existing file: "%s"', self::path($this->filename)));
  486. }
  487. }
  488. // Clear out related things in the database and filesystem, such as thumbnails
  489. if (Event::handle('FileDeleteRelated', array($this))) {
  490. $thumbs = new File_thumbnail();
  491. $thumbs->file_id = $this->id;
  492. if ($thumbs->find()) {
  493. while ($thumbs->fetch()) {
  494. $thumbs->delete();
  495. }
  496. }
  497. }
  498. // And finally remove the entry from the database
  499. return parent::delete($useWhere);
  500. }
  501. public function getTitle()
  502. {
  503. $title = $this->title ?: $this->filename;
  504. return $title ?: null;
  505. }
  506. }