Check if file is readable and if not return 403
[bookreader.git] / BookReaderIA / datanode / BookReaderImages.php
1 <?php
2
3 /*
4 Copyright(c) 2008-2010 Internet Archive. Software license AGPL version 3.
5
6 This file is part of BookReader.  The full source code can be found at GitHub:
7 http://github.com/openlibrary/bookreader
8
9 The canonical short name of an image type is the same as in the MIME type.
10 For example both .jpeg and .jpg are considered to have type "jpeg" since
11 the MIME type is "image/jpeg".
12
13     BookReader is free software: you can redistribute it and/or modify
14     it under the terms of the GNU Affero General Public License as published by
15     the Free Software Foundation, either version 3 of the License, or
16     (at your option) any later version.
17
18     BookReader is distributed in the hope that it will be useful,
19     but WITHOUT ANY WARRANTY; without even the implied warranty of
20     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
21     GNU Affero General Public License for more details.
22
23     You should have received a copy of the GNU Affero General Public License
24     along with BookReader.  If not, see <http://www.gnu.org/licenses/>.
25 */
26
27 $MIMES = array('gif' => 'image/gif',
28                'jp2' => 'image/jp2',
29                'jpg' => 'image/jpeg',
30                'jpeg' => 'image/jpeg',
31                'png' => 'image/png',
32                'tif' => 'image/tiff',
33                'tiff' => 'image/tiff');
34                
35 $EXTENSIONS = array('gif' => 'gif',
36                     'jp2' => 'jp2',
37                     'jpeg' => 'jpeg',
38                     'jpg' => 'jpeg',
39                     'png' => 'png',
40                     'tif' => 'tiff',
41                     'tiff' => 'tiff');
42                
43 // Paths to command-line tools
44 $exiftool = '/petabox/sw/books/exiftool/exiftool';
45 $kduExpand = '/petabox/sw/bin/kdu_expand';
46
47 // Process some of the request parameters
48 $zipPath  = $_REQUEST['zip'];
49 $file     = $_REQUEST['file'];
50 if (isset($_REQUEST['ext'])) {
51     $ext = $_REQUEST['ext'];
52 } else {
53     // Default to jpg
54     $ext = 'jpeg';
55 }
56 if (isset($_REQUEST['callback'])) {
57     // validate callback is valid JS identifier (only)
58     $callback = $_REQUEST['callback'];
59     $identifierPatt = '/^[[:alpha:]$_]([[:alnum:]$_])*$/';
60     if (! preg_match($identifierPatt, $callback)) {
61         BRfatal('Invalid callback');
62     }
63 } else {
64     $callback = null;
65 }
66
67 // Make sure the image stack is readable - return 403 if not
68 checkPrivs($zipPath);
69
70 /*
71  * Approach:
72  * 
73  * Get info about requested image (input)
74  * Get info about requested output format
75  * Determine processing parameters
76  * Process image
77  * Return image data
78  * Clean up temporary files
79  */
80  
81 function getUnarchiveCommand($archivePath, $file)
82 {
83     $lowerPath = strtolower($archivePath);
84     if (preg_match('/\.([^\.]+)$/', $lowerPath, $matches)) {
85         $suffix = $matches[1];
86         
87         if ($suffix == 'zip') {
88             return 'unzip -p '
89                 . escapeshellarg($archivePath)
90                 . ' ' . escapeshellarg($file);
91         } else if ($suffix == 'tar') {
92             return ' ( 7z e -so '
93                 . escapeshellarg($archivePath)
94                 . ' ' . escapeshellarg($file) . ' 2>/dev/null ) ';
95         } else {
96             BRfatal('Incompatible archive format');
97         }
98
99     } else {
100         BRfatal('Bad image stack path');
101     }
102     
103     BRfatal('Bad image stack path or archive format');
104     
105 }
106
107 /*
108  * Returns the image type associated with the file extension.
109  */
110 function imageExtensionToType($extension)
111 {
112     global $EXTENSIONS;
113     
114     if (array_key_exists($extension, $EXTENSIONS)) {
115         return $EXTENSIONS[$extension];
116     } else {
117         BRfatal('Unknown image extension');
118     }            
119 }
120
121 /*
122  * Get the image information.  The returned associative array fields will
123  * vary depending on the image type.  The basic keys are width, height, type
124  * and bits.
125  */
126 function getImageInfo($zipPath, $file)
127 {
128     return getImageInfoFromExif($zipPath, $file); // this is fast
129     
130     /*
131     $fileExt = strtolower(pathinfo($file, PATHINFO_EXTENSION));
132     $type = imageExtensionToType($fileExt);
133     
134     switch ($type) {
135         case "jp2":
136             return getImageInfoFromJp2($zipPath, $file);
137             
138         default:
139             return getImageInfoFromExif($zipPath, $file);
140     }
141     */
142 }
143
144 // Get the records of of JP2 as returned by kdu_expand
145 function getJp2Records($zipPath, $file)
146 {
147     global $kduExpand;
148     
149     $cmd = getUnarchiveCommand($zipPath, $file)
150              . ' | ' . $kduExpand
151              . ' -no_seek -quiet -i /dev/stdin -record /dev/stdout';
152     exec($cmd, $output);
153     
154     $records = Array();
155     foreach ($output as $line) {
156         $elems = explode("=", $line, 2);
157         if (1 == count($elems)) {
158             // delimiter not found
159             continue;
160         }
161         $records[$elems[0]] = $elems[1];
162     }
163     
164     return $records;
165 }
166
167 /*
168  * Get the image width, height and depth using the EXIF information.
169  */
170 function getImageInfoFromExif($zipPath, $file)
171 {
172     global $exiftool;
173     
174     // We look for all the possible tags of interest then act on the
175     // ones presumed present based on the file type
176     $tagsToGet = ' -ImageWidth -ImageHeight -FileType'        // all formats
177                  . ' -BitsPerComponent -ColorSpace'          // jp2
178                  . ' -BitDepth'                              // png
179                  . ' -BitsPerSample';                        // tiff
180                         
181     $cmd = getUnarchiveCommand($zipPath, $file)
182         . ' | '. $exiftool . ' -S -fast' . $tagsToGet . ' -';
183     exec($cmd, $output);
184     
185     $tags = Array();
186     foreach ($output as $line) {
187         $keyValue = explode(": ", $line);
188         $tags[$keyValue[0]] = $keyValue[1];
189     }
190     
191     $width = intval($tags["ImageWidth"]);
192     $height = intval($tags["ImageHeight"]);
193     $type = strtolower($tags["FileType"]);
194     
195     switch ($type) {
196         case "jp2":
197             $bits = intval($tags["BitsPerComponent"]);
198             break;
199         case "tiff":
200             $bits = intval($tags["BitsPerSample"]);
201             break;
202         case "jpeg":
203             $bits = 8;
204             break;
205         case "png":
206             $bits = intval($tags["BitDepth"]);
207             break;
208         default:
209             BRfatal("Unsupported image type");
210             break;
211     }
212    
213    
214     $retval = Array('width' => $width, 'height' => $height,
215         'bits' => $bits, 'type' => $type);
216     
217     return $retval;
218 }
219
220 /*
221  * Output JSON given the imageInfo associative array
222  */
223 function outputJSON($imageInfo, $callback)
224 {
225     header('Content-type: text/plain');
226     $jsonOutput = json_encode($imageInfo);
227     if ($callback) {
228         $jsonOutput = $callback . '(' . $jsonOutput . ');';
229     }
230     echo $jsonOutput;
231 }
232
233 // Get the image size and depth
234 $imageInfo = getImageInfo($zipPath, $file);
235
236 // Output json if requested
237 if ('json' == $ext) {
238     // $$$ we should determine the output size first based on requested scale
239     outputJSON($imageInfo, $callback);
240     exit;
241 }
242
243 // Unfortunately kakadu requires us to know a priori if the
244 // output file should be .ppm or .pgm.  By decompressing to
245 // .bmp kakadu will write a file we can consistently turn into
246 // .pnm.  Really kakadu should support .pnm as the file output
247 // extension and automatically write ppm or pgm format as
248 // appropriate.
249 $decompressToBmp = true;
250 if ($decompressToBmp) {
251   $stdoutLink = '/tmp/stdout.bmp';
252 } else {
253   $stdoutLink = '/tmp/stdout.ppm';
254 }
255
256 $fileExt = strtolower(pathinfo($file, PATHINFO_EXTENSION));
257
258 // Rotate is currently only supported for jp2 since it does not add server load
259 $allowedRotations = array("0", "90", "180", "270");
260 $rotate = $_REQUEST['rotate'];
261 if ( !in_array($rotate, $allowedRotations) ) {
262     $rotate = "0";
263 }
264
265 // Image conversion options
266 $pngOptions = '';
267 $jpegOptions = '-quality 75';
268
269 // The pbmreduce reduction factor produces an image with dimension 1/n
270 // The kakadu reduction factor produceds an image with dimension 1/(2^n)
271 // $$$ handle continuous values for scale
272 if (isset($_REQUEST['height'])) {
273     $ratio = floatval($_REQUEST['origHeight']) / floatval($_REQUEST['height']);
274     if ($ratio <= 2) {
275         $scale = 2;
276         $powReduce = 1;    
277     } else if ($ratio <= 4) {
278         $scale = 4;
279         $powReduce = 2;
280     } else {
281         //$powReduce = 3; //too blurry!
282         $scale = 2;
283         $powReduce = 1;
284     }
285
286 } else {
287     // $$$ could be cleaner
288     // Provide next smaller power of two reduction
289     $scale = intval($_REQUEST['scale']);
290     if (1 >= $scale) {
291         $powReduce = 0;
292     } else if (2 > $scale) {
293         $powReduce = 0;
294     } else if (4 > $scale) {
295         $powReduce = 1;
296     } else if (8 > $scale) {
297         $powReduce = 2;
298     } else if (16 > $scale) {
299         $powReduce = 3;
300     } else if (32 > $scale) {
301         $powReduce = 4;
302     } else if (64 > $scale) {
303         $powReduce = 5;
304     } else {
305         // $$$ Leaving this in as default though I'm not sure why it is...
306         $powReduce = 3;
307     }
308     $scale = pow(2, $powReduce);
309 }
310
311 // Override depending on source image format
312 // $$$ consider doing a 302 here instead, to make better use of the browser cache
313 // Limit scaling for 1-bit images.  See https://bugs.edge.launchpad.net/bookreader/+bug/486011
314 if (1 == $imageInfo['bits']) {
315     if ($scale > 1) {
316         $scale /= 2;
317         $powReduce -= 1;
318         
319         // Hard limit so there are some black pixels to use!
320         if ($scale > 4) {
321             $scale = 4;
322             $powReduce = 2;
323         }
324     }
325 }
326
327 if (!file_exists($stdoutLink)) 
328 {  
329   system('ln -s /dev/stdout ' . $stdoutLink);  
330 }
331
332
333 putenv('LD_LIBRARY_PATH=/petabox/sw/lib/kakadu');
334
335 $unzipCmd  = getUnarchiveCommand($zipPath, $file);
336
337 function getDecompressCmd($imageType) {
338     global $kduExpand;
339     global $powReduce, $rotate, $scale; // $$$ clean up
340     global $decompressToBmp; // $$$ TODO remove now that we have bit depth info
341     global $stdoutLink;
342     
343     switch ($imageType) {
344         case 'jp2':
345             $decompressCmd = 
346                 " | " . $kduExpand . " -no_seek -quiet -reduce $powReduce -rotate $rotate -i /dev/stdin -o " . $stdoutLink;
347             if ($decompressToBmp) {
348                 // We suppress output since bmptopnm always outputs on stderr
349                 $decompressCmd .= ' | (bmptopnm 2>/dev/null)';
350             }
351             break;
352     
353         case 'tiff':
354             // We need to create a temporary file for tifftopnm since it cannot
355             // work on a pipe (the file must be seekable).
356             // We use the BookReaderTiff prefix to give a hint in case things don't
357             // get cleaned up.
358             $tempFile = tempnam("/tmp", "BookReaderTiff");
359         
360             // $$$ look at bit depth when reducing
361             $decompressCmd = 
362                 ' > ' . $tempFile . ' ; tifftopnm ' . $tempFile . ' 2>/dev/null' . reduceCommand($scale);
363             break;
364      
365         case 'jpeg':
366             $decompressCmd = ' | ( jpegtopnm 2>/dev/null ) ' . reduceCommand($scale);
367             break;
368     
369         case 'png':
370             $decompressCmd = ' | ( pngtopnm 2>/dev/null ) ' . reduceCommand($scale);
371             break;
372             
373         default:
374             BRfatal('Unknown image type: ' . $imageType);
375             break;
376     }
377     return $decompressCmd;
378 }
379
380 $decompressCmd = getDecompressCmd($imageInfo['type']);
381        
382 // Non-integer scaling is currently disabled on the cluster
383 // if (isset($_REQUEST['height'])) {
384 //     $cmd .= " | pnmscale -height {$_REQUEST['height']} ";
385 // }
386
387 switch ($ext) {
388     case 'png':
389         $compressCmd = ' | pnmtopng ' . $pngOptions;
390         break;
391         
392     case 'jpeg':
393     case 'jpg':
394     default:
395         $compressCmd = ' | pnmtojpeg ' . $jpegOptions;
396         $ext = 'jpeg'; // for matching below
397         break;
398
399 }
400
401 if (($ext == $fileExt) && ($scale == 1) && ($rotate === "0")) {
402     // Just pass through original data if same format and size
403     $cmd = $unzipCmd;
404 } else {
405     $cmd = $unzipCmd . $decompressCmd . $compressCmd;
406 }
407
408 // print $cmd;
409
410 // If the command has its initial output on stdout the headers will be emitted followed
411 // by the stdout output.  If initial output is on stderr an error message will be
412 // returned.
413 // 
414 // Returns:
415 //   true - if command emits stdout and has zero exit code
416 //   false - command has initial output on stderr or non-zero exit code
417 //   &$errorMessage - error string if there was an error
418 //
419 // $$$ Tested with our command-line image processing.  May be deadlocks for
420 //     other cases.
421 function passthruIfSuccessful($headers, $cmd, &$errorMessage)
422 {
423     $retVal = false;
424     $errorMessage = '';
425     
426     $descriptorspec = array(
427        0 => array("pipe", "r"),  // stdin is a pipe that the child will read from
428        1 => array("pipe", "w"),  // stdout is a pipe that the child will write to
429        2 => array("pipe", "w"),   // stderr is a pipe to write to
430     );
431     
432     $cwd = NULL;
433     $env = NULL;
434     
435     $process = proc_open($cmd, $descriptorspec, $pipes, $cwd, $env);
436     
437     if (is_resource($process)) {
438         // $pipes now looks like this:
439         // 0 => writeable handle connected to child stdin
440         // 1 => readable handle connected to child stdout
441         // 2 => readable handle connected to child stderr
442     
443         $stdin = $pipes[0];        
444         $stdout = $pipes[1];
445         $stderr = $pipes[2];
446         
447         // check whether we get input first on stdout or stderr
448         $read = array($stdout, $stderr);
449         $write = NULL;
450         $except = NULL;
451         $numChanged = stream_select($read, $write, $except, NULL); // $$$ no timeout
452         if (false === $numChanged) {
453             // select failed
454             $errorMessage = 'Select failed';
455             $retVal = false;
456         }
457         if ($read[0] == $stdout && (1 == $numChanged)) {
458             // Got output first on stdout (only)
459             // $$$ make sure we get all stdout
460             $output = fopen('php://output', 'w');
461             foreach($headers as $header) {
462                 header($header);
463             }
464             stream_copy_to_stream($pipes[1], $output);
465             fclose($output); // okay since tied to special php://output
466             $retVal = true;
467         } else {
468             // Got output on stderr
469             // $$$ make sure we get all stderr
470             $errorMessage = stream_get_contents($stderr);
471             $retVal = false;
472         }
473
474         fclose($stderr);
475         fclose($stdout);
476         fclose($stdin);
477
478         
479         // It is important that you close any pipes before calling
480         // proc_close in order to avoid a deadlock
481         $cmdRet = proc_close($process);
482         if (0 != $cmdRet) {
483             $retVal = false;
484             $errorMessage .= "Command failed with result code " . $cmdRet;
485         }
486     }
487     return $retVal;
488 }
489
490 $headers = array('Content-type: '. $MIMES[$ext],
491                   'Cache-Control: max-age=15552000');
492
493 $errorMessage = '';
494 if (! passthruIfSuccessful($headers, $cmd, $errorMessage)) {
495     // $$$ automated reporting
496     trigger_error('BookReader Processing Error: ' . $cmd . ' -- ' . $errorMessage, E_USER_WARNING);
497     
498     // Try some content-specific recovery
499     $recovered = false;    
500     if ($imageInfo['type'] == 'jp2') {
501         $records = getJp2Records($zipPath, $file);
502         if ($powReduce > intval($records['Clevels'])) {
503             $powReduce = $records['Clevels'];
504             $reduce = pow(2, $powReduce);
505         } else {
506             $reduce = 1;
507             $powReduce = 0;
508         }
509          
510         $cmd = $unzipCmd . getDecompressCmd($imageInfo['type']) . $compressCmd;
511         if (passthruIfSuccessful($headers, $cmd, $errorMessage)) {
512             $recovered = true;
513         } else {
514             trigger_error('BookReader fallback image processing also failed: ' . $errorMessage, E_USER_WARNING);
515         }
516     }
517     
518     if (! $recovered) {
519         BRfatal('Problem processing image - command failed');
520     }
521 }
522
523 // passthru ($cmd); # cmd returns image data
524
525 if (isset($tempFile)) {
526     unlink($tempFile);
527 }
528
529 function BRFatal($string) {
530     echo "alert('$string');\n";
531     die(-1);
532 }
533
534 // Returns true if using a power node
535 function onPowerNode() {
536     exec("lspci | fgrep -c Realtek", $output, $return);
537     if ("0" != $output[0]) {
538         return true;
539     } else {
540         exec("egrep -q AMD /proc/cpuinfo", $output, $return);
541         if ($return == 0) {
542             return true;
543         }
544     }
545     return false;
546 }
547
548 function reduceCommand($scale) {
549     if (1 != $scale) {
550         if (onPowerNode()) {
551             return ' | pnmscale -reduce ' . $scale . ' 2>/dev/null ';
552         } else {
553             return ' | pnmscale -nomix -reduce ' . $scale . ' 2>/dev/null ';
554         }
555     } else {
556         return '';
557     }
558 }
559
560 function checkPrivs($filename) {
561     if (!is_readable($filename)) {
562         header('HTTP/1.1 403 Forbidden');
563         exit(0);
564     }
565 }
566
567 ?>
568