themeuploader.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  1. <?php
  2. /**
  3. * StatusNet, the distributed open-source microblogging tool
  4. *
  5. * Utilities for theme files and paths
  6. *
  7. * PHP version 5
  8. *
  9. * LICENCE: This program is free software: you can redistribute it and/or modify
  10. * it under the terms of the GNU Affero General Public License as published by
  11. * the Free Software Foundation, either version 3 of the License, or
  12. * (at your option) any later version.
  13. *
  14. * This program is distributed in the hope that it will be useful,
  15. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  16. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  17. * GNU Affero General Public License for more details.
  18. *
  19. * You should have received a copy of the GNU Affero General Public License
  20. * along with this program. If not, see <http://www.gnu.org/licenses/>.
  21. *
  22. * @category Paths
  23. * @package StatusNet
  24. * @author Brion Vibber <brion@status.net>
  25. * @copyright 2010 StatusNet, Inc.
  26. * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0
  27. * @link http://status.net/
  28. */
  29. if (!defined('STATUSNET') && !defined('LACONICA')) {
  30. exit(1);
  31. }
  32. /**
  33. * Encapsulation of the validation-and-save process when dealing with
  34. * a user-uploaded StatusNet theme archive...
  35. *
  36. * @todo extract theme metadata from css/display.css
  37. * @todo allow saving multiple themes
  38. */
  39. class ThemeUploader
  40. {
  41. protected $sourceFile;
  42. protected $isUpload;
  43. private $prevErrorReporting;
  44. public function __construct($filename)
  45. {
  46. if (!class_exists('ZipArchive')) {
  47. // TRANS: Exception thrown when a compressed theme is uploaded while no support present in PHP configuration.
  48. throw new Exception(_('This server cannot handle theme uploads without ZIP support.'));
  49. }
  50. $this->sourceFile = $filename;
  51. }
  52. public static function fromUpload($name)
  53. {
  54. if (!isset($_FILES[$name]['error'])) {
  55. // TRANS: Server exception thrown when uploading a theme fails.
  56. throw new ServerException(_('The theme file is missing or the upload failed.'));
  57. }
  58. if ($_FILES[$name]['error'] != UPLOAD_ERR_OK) {
  59. // TRANS: Server exception thrown when uploading a theme fails.
  60. throw new ServerException(_('The theme file is missing or the upload failed.'));
  61. }
  62. return new ThemeUploader($_FILES[$name]['tmp_name']);
  63. }
  64. /**
  65. * @param string $destDir
  66. * @throws Exception on bogus files
  67. */
  68. public function extract($destDir)
  69. {
  70. $zip = $this->openArchive();
  71. // First pass: validate but don't save anything to disk.
  72. // Any errors will trip an exception.
  73. $this->traverseArchive($zip);
  74. // Second pass: now that we know we're good, actually extract!
  75. $tmpDir = $destDir . '.tmp' . getmypid();
  76. $this->traverseArchive($zip, $tmpDir);
  77. $zip->close();
  78. if (file_exists($destDir)) {
  79. $killDir = $tmpDir . '.old';
  80. $this->quiet();
  81. $ok = rename($destDir, $killDir);
  82. $this->loud();
  83. if (!$ok) {
  84. common_log(LOG_ERR, "Could not move old custom theme from $destDir to $killDir");
  85. // TRANS: Server exception thrown when saving an uploaded theme after decompressing it fails.
  86. throw new ServerException(_('Failed saving theme.'));
  87. }
  88. } else {
  89. $killDir = false;
  90. }
  91. $this->quiet();
  92. $ok = rename($tmpDir, $destDir);
  93. $this->loud();
  94. if (!$ok) {
  95. common_log(LOG_ERR, "Could not move saved theme from $tmpDir to $destDir");
  96. // TRANS: Server exception thrown when saving an uploaded theme after decompressing it fails.
  97. throw new ServerException(_('Failed saving theme.'));
  98. }
  99. if ($killDir) {
  100. $this->recursiveRmdir($killDir);
  101. }
  102. }
  103. /**
  104. *
  105. */
  106. protected function traverseArchive($zip, $outdir=false)
  107. {
  108. $sizeLimit = 2 * 1024 * 1024; // 2 megabyte space limit?
  109. $blockSize = 4096; // estimated; any entry probably takes this much space
  110. $totalSize = 0;
  111. $hasMain = false;
  112. $commonBaseDir = false;
  113. for ($i = 0; $i < $zip->numFiles; $i++) {
  114. $data = $zip->statIndex($i);
  115. $name = str_replace('\\', '/', $data['name']);
  116. if (substr($name, -1) == '/') {
  117. // A raw directory... skip!
  118. continue;
  119. }
  120. // Is this a safe or skippable file?
  121. $path = pathinfo($name);
  122. if ($this->skippable($path['filename'], $path['extension'])) {
  123. // Documentation and such... booooring
  124. continue;
  125. } else {
  126. $this->validateFile($path['filename'], $path['extension']);
  127. }
  128. // Check the directory structure...
  129. $dirs = explode('/', $path['dirname']);
  130. $baseDir = array_shift($dirs);
  131. if ($commonBaseDir === false) {
  132. $commonBaseDir = $baseDir;
  133. } else {
  134. if ($commonBaseDir != $baseDir) {
  135. // TRANS: Server exception thrown when an uploaded theme has an incorrect structure.
  136. throw new ClientException(_('Invalid theme: Bad directory structure.'));
  137. }
  138. }
  139. foreach ($dirs as $dir) {
  140. $this->validateFileOrFolder($dir);
  141. }
  142. $fullPath = $dirs;
  143. $fullPath[] = $path['basename'];
  144. $localFile = implode('/', $fullPath);
  145. if ($localFile == 'css/display.css') {
  146. $hasMain = true;
  147. }
  148. $size = $data['size'];
  149. $estSize = $blockSize * max(1, intval(ceil($size / $blockSize)));
  150. $totalSize += $estSize;
  151. if ($totalSize > $sizeLimit) {
  152. // TRANS: Client exception thrown when an uploaded theme is larger than the limit.
  153. // TRANS: %d is the number of bytes of the uncompressed theme.
  154. $msg = sprintf(_m('Uploaded theme is too large; must be less than %d byte uncompressed.',
  155. 'Uploaded theme is too large; must be less than %d bytes uncompressed.',
  156. $sizeLimit),
  157. $sizeLimit);
  158. throw new ClientException($msg);
  159. }
  160. if ($outdir) {
  161. $this->extractFile($zip, $data['name'], "$outdir/$localFile");
  162. }
  163. }
  164. if (!$hasMain) {
  165. // TRANS: Server exception thrown when an uploaded theme is incomplete.
  166. throw new ClientException(_('Invalid theme archive: ' .
  167. "Missing file css/display.css"));
  168. }
  169. }
  170. /**
  171. * @fixme Probably most unrecognized files should just be skipped...
  172. */
  173. protected function skippable($filename, $ext)
  174. {
  175. $skip = array('txt', 'html', 'rtf', 'doc', 'docx', 'odt', 'xcf');
  176. if (strtolower($filename) == 'readme') {
  177. return true;
  178. }
  179. if (in_array(strtolower($ext), $skip)) {
  180. return true;
  181. }
  182. if ($filename == '' || substr($filename, 0, 1) == '.') {
  183. // Skip Unix-style hidden files
  184. return true;
  185. }
  186. if ($filename == '__MACOSX') {
  187. // Skip awful metadata files Mac OS X slips in for you.
  188. // Thanks Apple!
  189. return true;
  190. }
  191. return false;
  192. }
  193. protected function validateFile($filename, $ext)
  194. {
  195. $this->validateFileOrFolder($filename);
  196. $this->validateExtension($filename, $ext);
  197. // @fixme validate content
  198. }
  199. protected function validateFileOrFolder($name)
  200. {
  201. if (!preg_match('/^[a-z0-9_\.-]+$/i', $name)) {
  202. common_log(LOG_ERR, "Bad theme filename: $name");
  203. // TRANS: Server exception thrown when an uploaded theme has an incorrect file or folder name.
  204. $msg = _("Theme contains invalid file or folder name. " .
  205. 'Stick with ASCII letters, digits, underscore, and minus sign.');
  206. throw new ClientException($msg);
  207. }
  208. if (preg_match('/\.(php|cgi|asp|aspx|js|vb)\w/i', $name)) {
  209. common_log(LOG_ERR, "Unsafe theme filename: $name");
  210. // TRANS: Server exception thrown when an uploaded theme contains files with unsafe file extensions.
  211. $msg = _('Theme contains unsafe file extension names; may be unsafe.');
  212. throw new ClientException($msg);
  213. }
  214. return true;
  215. }
  216. protected function validateExtension($base, $ext)
  217. {
  218. $allowed = array('css', // CSS may need validation
  219. 'png', 'gif', 'jpg', 'jpeg',
  220. 'svg', // SVG images/fonts may need validation
  221. 'ttf', 'eot', 'woff');
  222. if (!in_array(strtolower($ext), $allowed)) {
  223. if ($ext == 'ini' && $base == 'theme') {
  224. // theme.ini exception
  225. return true;
  226. }
  227. // TRANS: Server exception thrown when an uploaded theme contains a file type that is not allowed.
  228. // TRANS: %s is the file type that is not allowed.
  229. $msg = sprintf(_('Theme contains file of type ".%s", which is not allowed.'),
  230. $ext);
  231. throw new ClientException($msg);
  232. }
  233. return true;
  234. }
  235. /**
  236. * @return ZipArchive
  237. */
  238. protected function openArchive()
  239. {
  240. $zip = new ZipArchive;
  241. $ok = $zip->open($this->sourceFile);
  242. if ($ok !== true) {
  243. common_log(LOG_ERR, "Error opening theme zip archive: " .
  244. "{$this->sourceFile} code: {$ok}");
  245. // TRANS: Server exception thrown when an uploaded compressed theme cannot be opened.
  246. throw new Exception(_('Error opening theme archive.'));
  247. }
  248. return $zip;
  249. }
  250. /**
  251. * @param ZipArchive $zip
  252. * @param string $from original path inside ZIP archive
  253. * @param string $to final destination path in filesystem
  254. */
  255. protected function extractFile($zip, $from, $to)
  256. {
  257. $dir = dirname($to);
  258. if (!file_exists($dir)) {
  259. $this->quiet();
  260. $ok = mkdir($dir, 0755, true);
  261. $this->loud();
  262. if (!$ok) {
  263. common_log(LOG_ERR, "Failed to mkdir $dir while uploading theme");
  264. // TRANS: Server exception thrown when an uploaded theme cannot be saved during extraction.
  265. throw new ServerException(_('Failed saving theme.'));
  266. }
  267. } else if (!is_dir($dir)) {
  268. common_log(LOG_ERR, "Output directory $dir not a directory while uploading theme");
  269. // TRANS: Server exception thrown when an uploaded theme cannot be saved during extraction.
  270. throw new ServerException(_('Failed saving theme.'));
  271. }
  272. // ZipArchive::extractTo would be easier, but won't let us alter
  273. // the directory structure.
  274. $in = $zip->getStream($from);
  275. if (!$in) {
  276. common_log(LOG_ERR, "Couldn't open archived file $from while uploading theme");
  277. // TRANS: Server exception thrown when an uploaded theme cannot be saved during extraction.
  278. throw new ServerException(_('Failed saving theme.'));
  279. }
  280. $this->quiet();
  281. $out = fopen($to, "wb");
  282. $this->loud();
  283. if (!$out) {
  284. common_log(LOG_ERR, "Couldn't open output file $to while uploading theme");
  285. // TRANS: Server exception thrown when an uploaded theme cannot be saved during extraction.
  286. throw new ServerException(_('Failed saving theme.'));
  287. }
  288. while (!feof($in)) {
  289. $buffer = fread($in, 65536);
  290. fwrite($out, $buffer);
  291. }
  292. fclose($in);
  293. fclose($out);
  294. }
  295. private function quiet()
  296. {
  297. $this->prevErrorReporting = error_reporting();
  298. error_reporting($this->prevErrorReporting & ~E_WARNING);
  299. }
  300. private function loud()
  301. {
  302. error_reporting($this->prevErrorReporting);
  303. }
  304. private function recursiveRmdir($dir)
  305. {
  306. $list = dir($dir);
  307. while (($file = $list->read()) !== false) {
  308. if ($file == '.' || $file == '..') {
  309. continue;
  310. }
  311. $full = "$dir/$file";
  312. if (is_dir($full)) {
  313. $this->recursiveRmdir($full);
  314. } else {
  315. unlink($full);
  316. }
  317. }
  318. $list->close();
  319. rmdir($dir);
  320. }
  321. }