1<?php
2/**
3 * Action Plugin
4 *
5 * @license     MIT License (https://opensource.org/licenses/MIT)
6 * @author      José Torrecilla <qky669@gmail.com>
7 * @version     0.1beta
8 */
9
10// must be run within Dokuwiki
11if(!defined('DOKU_INC')) die();
12if (!defined('DOKU_PLUGIN')) define('DOKU_PLUGIN', DOKU_INC . 'lib/plugins/');
13
14class action_plugin_file2dw extends DokuWiki_Action_Plugin {
15
16  /**
17  * Registers a callback function for a given event
18  */
19  function register(Doku_Event_Handler $controller) {
20
21    // File parser hook
22    $controller->register_hook('ACTION_ACT_PREPROCESS', 'BEFORE', $this, '_parser', array());
23
24    // Display form hook before the wiki page (on top)
25    $controller->register_hook('TPL_ACT_RENDER', 'BEFORE', $this, '_render', array());
26
27    //Add MENU_ITEMS_ASSEMBLY
28    $controller->register_hook('MENU_ITEMS_ASSEMBLY', 'AFTER', $this, '_addsvgbutton', array());
29  }
30
31  /**
32   * Add 'import'-button to menu
33   *
34   * @param Doku_Event $event
35   * @param mixed      $param not defined
36   */
37  function _addsvgbutton(&$event, $param) {
38    if($event->data['view'] == 'page') {
39      array_push($event->data['items'],new \dokuwiki\plugin\file2dw\MenuItem());
40    }
41  }
42
43  /**
44   * Displays the upload form in the pages according to authorized action
45   *
46   * @param Doku_Event $event It's a dokuwiki event function
47   * @param mixed      $param Not defined
48   */
49  function _render(&$event, $param) {
50    // $ID: Page identifier
51    global $ID;
52
53    // Check if should display the form
54    if ( strpos( $this->getConf('formDisplayRule'), $event->data) === false ) return;
55
56    // If the page exists but $event->data != "file2dw", return
57    if ( page_exists( $ID ) && $event->data != "file2dw" ) return;
58
59    // Check auth user can edit this page
60    if ( auth_quickaclcheck( $ID ) < AUTH_EDIT ) return;
61
62    // If page exists, show warning to the user
63    if ( page_exists( $ID ) ) echo p_render('xhtml',p_get_instructions( $this->getLang( 'formPageExistMessage' ) ), $info );
64
65    // Show form
66    echo $this->_createForm();
67
68    if ( $event->data == 'file2dw' ) $event->preventDefault();
69
70  }
71
72  /**
73   * Creates an returns a string with the HTML upload form to show to the user
74   *
75   * @return string HTML upload form
76   */
77  function _createForm() {
78
79    global $ID;
80
81    $form = new dokuwiki\Form\Form(array('id' => 'file2dw_form', 'enctype' => 'multipart/form-data'));
82
83    // Intro message
84    $message = $this->getConf('formIntroMessage');
85    if ( $message == 'default' ) $message = $this->getLang('formIntroMessage');
86    if ( $message ) {
87        $message = p_render('xhtml',p_get_instructions($message),$info);
88        $form->addHTML($message);
89    }
90
91    //Open fieldset
92    $form->addFieldsetOpen();
93
94    //legend tag
95    $legend = $form->addTag('legend');
96    $legend->attr('value',$this->getLang('formLegend'));
97
98    //hidden
99    $form->setHiddenField('MAX_FILE_SIZE',$this->getConf('formMaxFileSize'));
100    $form->setHiddenField('do','file2dw');
101    $form->setHiddenField('id',$ID);
102
103    //userFile file input
104    $userFileInputElement = new dokuwiki\Form\InputElement('file','userFile');
105    $form->addElement($userFileInputElement);
106
107    // submit
108    $submitInputElement = new dokuwiki\Form\InputElement('submit','btn_upload');
109    $submitInputElement->attr('value',$this->getLang('import_button'));
110    $form->addElement($submitInputElement);
111
112    //Close fieldset
113    $form->addFieldsetClose();
114
115    return $form->toHTML();
116  }
117
118
119  /**
120   * Checks if the file might be uploaded, then call the file2dw converter
121   *
122   * @param Doku_Event $event It's a dokuwiki event function
123   * @param mixed      $param Not defined
124   */
125  function _parser(&$event, $param) {
126
127    // Check action is file2dw
128    if ( $event->data != 'file2dw' ) return;
129
130    // Preparation of the message renderer
131    // Set the debug lvl
132    $this->logLevel = $this->getConf( 'logLevel' );
133    $this->debugShowInfo = $this->getConf( 'debugShowInfo' );
134    //If used, open the logFile
135    if ( $this->logLevel > 0 ) {
136      $this->logFile = $this->getConf( 'logFile' );
137      if ( isset( $this->logFile ) ) {
138        if ( file_exists( dirname( $this->logFile ) ) || mkdir( dirname( $this->logFile ) ) ) {
139          if ( ! ( $this->logFileHandle = @fopen( $this->logFile, 'a' ) ) ) {
140            unset( $this->logFileHandle, $this->logFile );
141          }
142        } else {
143          unset( $this->logFile );
144        }
145      }
146      if ( ! isset( $this->logFileHandle ) ) {
147        $this->_msg('er_logFile');
148      }
149    }
150
151    // Check upload file defined
152    $retorno = false;
153    if ( $_FILES['userFile'] && $_FILES['userFile']['error'] == 0 ) {
154      $this->_msg( array('ok_info','userFile found: '.$_FILES['userFile']['name']) );
155      // If parse work, change action to defined one in conf/local.php file
156      $retorno = $this->_file2dw();
157      // Delete temp folder
158      $this->_purge_env();
159    }
160
161    // if the file is correctly parsed, change the action to "show"
162    // otherwise the action stay file2dw
163    if ( $retorno === true ) {
164      $event->data = 'show';
165    } else {
166      $event->preventDefault();
167    }
168
169    // Clear the message renderer
170    // Close the log file if used
171    if ( isset( $this->logFileHandle ) ) {
172      @fclose( $this->logFileHandle );
173    }
174  }
175
176
177  /**
178   * Converts uploaded file to Dokuwiki syntax
179   *
180   * @return bool true if conversion ended ok; false if conversion failed
181   */
182  function _file2dw() {
183
184    global $ID;
185
186    ### Check parameter ###
187
188    // Page receive content
189    if ( ! $this->pageName = $ID ) return $this->_msg('er_id');
190    $this->nsName = getNS($this->pageName);
191    // Check rights to change the page
192    if ( page_exists($ID) ) {
193      if ( auth_quickaclcheck($ID) < AUTH_EDIT ) return $this->_msg('er_acl_edit');
194    } else {
195      if ( auth_quickaclcheck($ID) < AUTH_CREATE ) return $this->_msg('er_acl_create');
196    }
197
198    // Check uploaded file
199    $this->_checkUploadedFile();
200
201    // Need OpenOffice conversion?
202    // workFile is the file that will be converted by pandoc to dokuwiki syntax
203    // workFile is the userFile by default
204    // It will be changed if OpenOffice conversion is needed (example: .doc files)
205    $this->workFileName = substr($this->userFileName,0);
206    $this->workFile = substr($this->userFile,0);
207    if ($this->getConf( 'parserMimeTypeSOffice' ) != ''
208        && strpos( $this->getConf( 'parserMimeTypeSOffice' ), $_FILES['userFile']['type'] ) !== false) {
209      if ( !$this->_OOConversion() ) return false;
210    }
211
212    // pandoc conversion
213    // Resulting file name: dwpage
214    // Images folder: img
215    $this->dwpageFileName = 'dwpage';
216    $this->dwpageFile = $this->workDir.'/'.$this->dwpageFileName;
217    $this->dwimgDir = $this->workDir.'/img';
218    $output = array();
219    $command = 'pandoc -s -w dokuwiki --extract-media="'.$this->dwimgDir;
220    $command .= '" -o "'.$this->dwpageFile.'" "'.$this->workFile.'"';
221    exec( $command, $output, $return_var );
222
223    $this->_msg(array('ok_info','Executed command: '.$command));
224
225    if ( !file_exists($this->dwpageFile) ) {
226      $message = '<br>Missing file: ' . $this->dwpageFile;
227      $message .= '<br>Command: ' . $command;
228      $message .= '<br>Output: '. print_r($output,true);
229      $message .= '<br>Return: '. $return_var;
230      return $this->_msg( array('er_pandoc',$message) );
231    }
232
233    $this->_msg(array('ok_info','pandoc conversion done'));
234
235
236    // Initial result
237    $this->result = '====== '.basename($this->userFileName).' ======
238';
239    if ( $this->getConf('parserLinkToOriginalFile') && auth_quickaclcheck($ID) >= AUTH_UPLOAD ) {
240      $this->result .= '<sub>{{'.$this->userFileName.'|'.$this->getLang('parserOriginalFile').'}}</sub>
241
242';
243    }
244
245    $this->result .= file_get_contents ($this->dwpageFile);
246
247    // If dwimgDir does not exist, we do not need to porcess it
248    if (is_dir($this->dwimgDir)) {
249      $this->_msg(array('ok_info','Start processing dir '.$this->dwimgDir));
250      // Use $this->now to put a timestamp in images name
251      $this->now = date('Y-m-d_H-i-s');
252      // Use $this->importedImages to count (and store, if we need to delete them after an error)
253      $this->importedImages = array();
254      if ( !$this->_processImgDir($this->dwimgDir) ) {
255        //Delete all imported images until error from dokuwiki
256        foreach ($this->importedImages as $imgId) {
257          media_delete($imgId, null);
258        }
259        // Return error
260        return $this->_msg('er_img_dir');
261      }
262    }
263
264    $this->_msg(array('ok_info','Resultado: '.$this->result));
265
266    // Keep the original file (import the upload file in the mediaManager)
267    if ( auth_quickaclcheck($ID) >= AUTH_UPLOAD ) {
268      $destFile = mediaFN( $this->nsName.':'.$this->userFileName );
269      list( $ext, $mime ) = mimetype($this->userFile);
270      if ( media_upload_finish($this->userFile, $destFile, $this->nsName, $mime, @file_exists($destFile), 'rename' ) != $this->nsName ) {
271        return $this->_msg( array( 'er_apply_file' ) );
272      }
273    } else {
274      // If not allowed to upload, return error.
275      return $this->_msg('er_acl_upload');
276    }
277
278    // Save wiki page
279    saveWikiText( $this->pageName, $this->result, $this->getLang( 'parserSummary' ).$this->userFileName );
280    if ( ! page_exists($this->pageName) ) return $this->_msg('er_apply_content');
281
282    return true;
283  }
284
285
286  /**
287   * Add images in a directory (and its subdirectories) to Dokuwiki mediaManager.
288   * Also updates $this->result (it will be wiki page content).
289   *
290   * @param string $imgDir Full path directory to process
291   * @return bool true if process ended ok; false if failed
292   */
293  function _processImgDir($imgDir) {
294
295    // In $imgDir is not a directory, return error
296    if (!is_dir($imgDir)) return $this->_msg(array('er_img_dir',$imgDir.' is not a directory'));
297
298    // list and process directory items
299    $items = array_diff(scandir($imgDir), array('.','..'));
300    foreach ($items as $item) {
301      $itemPath = "$imgDir/$item";
302      if (is_dir($itemPath)) {
303        if (!$this->_processImgDir($itemPath)) {
304          return $this->_msg(array ('er_img_dir','Error processing directory '.$itemPath) );
305        }
306      } else {
307        if (!$this->_processImg($itemPath)) {
308          return $this->_msg(array('er_img_dir','Error processing image '.$itemPath));
309        }
310      }
311    }
312
313    $this->_msg(array('ok_info','Processed image directory: '.$imgDir));
314
315    return true;
316  }
317
318  /**
319   * Add single image to Dokuwiki mediaManager.
320   * Also updates $this->result (it will be wiki page content).
321   *
322   * @param string $imgPath Full path image to process
323   * @return bool true if process ended ok; false if failed
324   */
325  function _processImg($imgPath) {
326
327    list( $ext, $mime ) = mimetype( $imgPath );
328
329    // Sanitize original file name
330    $userFileBasename = basename($this->userFileName);
331    $userFileBasename = mb_ereg_replace("([^\w\d\-_\[\]\(\)])", '_', $userFileBasename);
332    $userFileBasename = mb_ereg_replace("(_{1,})", '_', $userFileBasename);
333
334    // Trying to get a meaningful and unique file name
335    // It will be something like "Uploaded_file_docx_2018-11-24_23-00-00_img1.jpg"
336    $imgBasename = $userFileBasename.'_'.$this->now.'_img'.strval( count($this->importedImages)+1 ).'.'.$ext;
337    $imgId = $this->nsName.':'.$imgBasename;
338    $destFile = mediaFN( $imgId );
339
340    // Add to mediaManagerif authorized
341    if ( auth_quickaclcheck($ID) >= AUTH_UPLOAD ) {
342      // Import the image file in the mediaManager (data/media)
343      $destDir = mediaFN( $this->nsName );
344      if ( ! ( file_exists( $destDir ) || mkdir( $destDir, 0777, true ) ) ) {
345        return $this->_msg( array( 'er_dirCreate', 'Directory: '.$destDir ) );
346      }
347
348      // This works, but do not know if it is a hack... Meybe it can be done other way?
349      $mediaReturn = media_upload_finish($imgPath, $destFile, $this->nsName, $mime, @file_exists($destFile), 'rename' );
350
351      if ( $mediaReturn == $this->nsName ) {
352        // "Upload" OK
353        $this->importedImages[] = $imgId;
354        // Replace string in result
355        $this->result = str_replace( '{{'.$imgPath, '{{:'.$imgId, $this->result );
356      } else {
357        // Return error
358        return $this->_msg( array( 'er_img_upload', 'Image: '.$imgPath.' Return: '.print_r($mediaReturn,true) ) );
359      }
360    } else {
361      // If not allowed to upload, return error.
362      return $this->_msg('er_acl_upload');
363    }
364
365    $this->_msg(array('ok_info','Processed image: '.$imgPath));
366
367    return true;
368  }
369
370
371  /**
372   * Converts $this->userFile to odt and stores it in $this->workFile
373   *
374   * @return bool true if process ended ok; false if failed
375   */
376  function _OOConversion() {
377
378    // Conversion to odt file
379    $output = array();
380    $command = 'cd ' . $this->workDir;
381    $command .= ' && sudo soffice --nofirststartwizard --headless --convert-to odt:"writer8" "' . $this->userFileName . '"';
382    $return_var = shell_exec( $command );
383
384    // Change original extension to ".odt"
385    $info = pathinfo($this->userFile);
386    $this->workFileName = $info['filename'] . '.odt';
387    $this->workFile = $this->workDir.'/'. $this->workFileName;
388
389    if ( !file_exists($this->workFile) ) {
390      $message = '<br>Missing file: ' . $this->workFile;
391      $message .= '<br>Command: ' . $command;
392      $message .= '<br>Return: '. $return_var;
393      return $this->_msg( array('er_soffice',$message) );
394    }
395
396    $this->_msg(array('ok_info','Open Office conversion done'));
397
398    return true;
399  }
400
401  /**
402   * Move uploaded file to a temp directory
403   *
404   * @return bool true if process ended ok; false if failed
405   */
406  function _checkUploadedFile() {
407    ### _checkUploadedFile : group all process about the uploaded file ###
408    # OUTPUT :
409    #   * true -> process successfully
410    #   * false -> something wrong; using _msg to display what's wrong
411
412    //Check if file exists
413    if ( ! $_FILES['userFile'] ) return $this->_msg('er_file_miss');
414
415    // Check the file status
416    if ( $_FILES['userFile']['error'] > 0 ) {
417      return $this->_msg( array( 'er_file_upload', $_FILES['userFile']['error'] ) );
418    }
419
420    // Removed: check file mimetype.
421    // If pandoc can convert it, then it should work.
422    // If not,then it should give an error
423
424    // Create an unique temp work dir name
425    $confUploadDir = $this->getConf('parserUploadDir');
426    if ( !file_exists($confUploadDir) ) {
427      $confUploadDir = null;
428    }
429    $this->workDir = $this->tempdir($confUploadDir, 'file2dw_', 0777);
430    if ($this->workDir == false) {
431      return $this->_msg('er_file_tmpDir');
432    }
433    chmod( $this->workDir, 0777 );
434
435    // Move the upload file into the work directory
436    $this->userFileName = $_FILES['userFile']['name'];
437    $this->userFile = $this->workDir.'/'.$this->userFileName;
438    if ( ! move_uploaded_file( $_FILES['userFile']['tmp_name'], $this->userFile ) ) {
439      return $this->_msg('er_file_getFromDownload');
440    }
441
442    $this->_msg( array('ok_info','userFile moved to '.$this->userFile) );
443
444    return true;
445  }
446
447  /**
448   * Display and/or log message using the debugLvl value
449   *
450   * @param string|array $message string: key for $this->getLang();
451   *   array: $message[0]: string: key for $this->getLang(), $message[1]: string: additional information
452   * @param int $type -1 -> error message, 0 -> normal message, 1 -> info message.
453   *   If null, the first 3 char of the key define the message type:er_ -> -1, ok_ -> 1, otherwise -> 0
454   * @param bool $force force displaying the message without checking debugLvl
455   * @return bool true -> Display normal message; false ->Display an error message
456   */
457  function _msg( $message, $type=null, $force=false ) {
458
459    ### _msg : display message using the debugLvl value
460    # $message : mixed :
461    #   * string : key for $this->getLang() function
462    #   * array :
463    #       $message[0] : string : key for $this->getLang() function
464    #       $message[1] : string : additional information
465    # $type : integer : (check the dokuwiki msg function)
466    #   * -1 : error message
467    #   *  0 : normal message
468    #   *  1 : info message
469    # if type == null, the first 3 char of the key define the message type
470    #   * er_ : -1
471    #   * ok_ :  1
472    #   * otherwise : 0
473    # $force : boolean : force displaying the message without checking debugLvl
474    # OUTPUT :
475    #   * true -> display a normal message
476    #   * false -> display an error message
477    # DISPLAY : call dokuwiki msg function
478
479    if ( is_array( $message ) ) {
480      $output = $message[0];
481    } else {
482      $output = $message;
483    }
484
485    // If output is empty, crash with error display;
486    if ( ! $output ) die( $this->getLang( 'er_msg_nomessage' ) );
487
488    // If no $type defined, get it from key
489    if ( is_null( $type ) ) {
490      $val = substr( $output, 0, strpos( $output, '_' )+1 );
491      switch ($val) {
492        case 'er_' :
493          $err = -1;
494          break;
495        case 'ok_' :
496          $err = 1;
497          break;
498        default :
499          $err = 0;
500      }
501    } else {
502      if ( $type < -1 || $type > 1 ) return false;
503      $err = $type;
504    }
505
506    // Message content
507    $content = $output.' : '.$this->getLang( $output ).( is_array( $message ) ? ' : '.$message[1] : '' );
508
509    // Determine if should show message
510    if ( $force || $this->debugShowInfo == 1 || $err == -1 ) {
511      msg( 'file2dw : '.$content, $err );
512    };
513
514    //Determine if should log message
515    if ( $this->logLevel > 0 && isset( $this->logFileHandle ) ) {
516      fwrite( $this->logFileHandle, date(DATE_ATOM).':'.$_SERVER['REMOTE_USER'].':'.$content.' ' );
517    };
518
519    return ( $err == -1 ? false : true);
520
521  }
522
523  /**
524   * Delete temp folder $this->workDir
525   *
526   * @return bool true if process ended ok; false if failed
527   */
528  function _purge_env() {
529
530    if ( file_exists($this->workDir) ) {
531      return $this->_delTree($this->workDir);
532    }
533    return true;
534
535  }
536
537  /**
538   * Creates a random unique temporary directory, with specified parameters,
539   * that does not already exist (like tempnam(), but for dirs).
540   *
541   * Created dir will begin with the specified prefix, followed by random
542   * numbers.
543   *
544   * @link https://php.net/manual/en/function.tempnam.php
545   *
546   * @param string|null $dir Base directory under which to create temp dir.
547   *     If null, the default system temp dir (sys_get_temp_dir()) will be
548   *     used.
549   * @param string $prefix String with which to prefix created dirs.
550   * @param int $mode Octal file permission mask for the newly-created dir.
551   *     Should begin with a 0.
552   * @param int $maxAttempts Maximum attempts before giving up (to prevent
553   *     endless loops).
554   * @return string|bool Full path to newly-created dir, or false on failure.
555   */
556  function tempdir($dir = null, $prefix = 'tmp_', $mode = 0700, $maxAttempts = 1000)
557  {
558    /* Use the system temp dir by default. */
559    if (is_null($dir))
560    {
561      $dir = sys_get_temp_dir();
562    }
563
564    /* Trim trailing slashes from $dir. */
565    $dir = rtrim($dir, '/');
566
567    /* If we don't have permission to create a directory, fail, otherwise we will
568     * be stuck in an endless loop.
569     */
570    if (!is_dir($dir) || !is_writable($dir))
571    {
572      return false;
573    }
574
575    /* Make sure characters in prefix are safe. */
576    if (strpbrk($prefix, '\\/:*?"<>|') !== false)
577    {
578      return false;
579    }
580
581    /* Attempt to create a random directory until it works. Abort if we reach
582     * $maxAttempts. Something screwy could be happening with the filesystem
583     * and our loop could otherwise become endless.
584     */
585    $attempts = 0;
586    do
587    {
588      $path = sprintf('%s/%s%s', $dir, $prefix, mt_rand(100000, mt_getrandmax()));
589    } while (
590      !mkdir($path, $mode) &&
591      $attempts++ < $maxAttempts
592    );
593
594
595    return $path;
596  }
597
598  /**
599   * Deletes (recursively) a directory that may not be empty
600   *
601   * @return bool true if process ended ok; false if failed
602   */
603  function _delTree($dir) {
604    $files = array_diff(scandir($dir), array('.','..'));
605    foreach ($files as $file) {
606      (is_dir("$dir/$file")) ? $this->_delTree("$dir/$file") : unlink("$dir/$file");
607    }
608    return rmdir($dir);
609  }
610
611}
612