<?php
/**
 * @license    GPL 2 (http://www.gnu.org/licenses/gpl.html)
 * @author     Andreas Gohr <andi@splitbrain.org>
 */

// must be run within Dokuwiki
if(!defined('DOKU_INC')) die();
if(!defined('DOKU_PLUGIN')) define('DOKU_PLUGIN', DOKU_INC . 'lib/plugins/');

/**
 * Class helper_plugin_captcha
 */
class helper_plugin_captcha extends DokuWiki_Plugin {

    protected $field_in = 'plugin__captcha';
    protected $field_sec = 'plugin__captcha_secret';
    protected $field_hp = 'plugin__captcha_honeypot';

    /**
     * Constructor. Initializes field names
     */
    public function __construct() {
        $this->field_in  = md5($this->_fixedIdent().$this->field_in);
        $this->field_sec = md5($this->_fixedIdent().$this->field_sec);
        $this->field_hp  = md5($this->_fixedIdent().$this->field_hp);
    }

    /**
     * Check if the CAPTCHA should be used. Always check this before using the methods below.
     *
     * @return bool true when the CAPTCHA should be used
     */
    public function isEnabled() {
        if(!$this->getConf('forusers') && $_SERVER['REMOTE_USER']) return false;
        return true;
    }

    /**
     * Returns the HTML to display the CAPTCHA with the chosen method
     */
    public function getHTML() {
        global $ID;

        $rand = (float) (rand(0, 10000)) / 10000;
        $this->storeCaptchaCookie($this->_fixedIdent(), $rand);

        if($this->getConf('mode') == 'math') {
            $code = $this->_generateMATH($this->_fixedIdent(), $rand);
            $code = $code[0];
            $text = $this->getLang('fillmath');
        } elseif($this->getConf('mode') == 'question') {
            $code = ''; // not used
            $text = $this->getConf('question');
        } else {
            $code = $this->_generateCAPTCHA($this->_fixedIdent(), $rand);
            $text = $this->getLang('fillcaptcha');
        }
        $secret = $this->encrypt($rand);

        $txtlen = $this->getConf('lettercount');

        $out = '';
        $out .= '<div id="plugin__captcha_wrapper">';
        $out .= '<input type="hidden" name="'.$this->field_sec.'" value="'.hsc($secret).'" />';
        $out .= '<label for="plugin__captcha">'.$text.'</label> ';

        switch($this->getConf('mode')) {
            case 'math':
            case 'text':
                $out .= $this->_obfuscateText($code);
                break;
            case 'js':
                $out .= '<span id="plugin__captcha_code">'.$this->_obfuscateText($code).'</span>';
                break;
            case 'svg':
                $out .= '<span class="svg" style="width:'.$this->getConf('width').'px; height:'.$this->getConf('height').'px">';
                $out .= $this->_svgCAPTCHA($code);
                $out .= '</span>';
                break;
            case 'svgaudio':
                $out .= '<span class="svg" style="width:'.$this->getConf('width').'px; height:'.$this->getConf('height').'px">';
                $out .= $this->_svgCAPTCHA($code);
                $out .= '</span>';
                $out .= '<a href="'.DOKU_BASE.'lib/plugins/captcha/wav.php?secret='.rawurlencode($secret).'&amp;id='.$ID.'"'.
                    ' class="JSnocheck" title="'.$this->getLang('soundlink').'">';
                $out .= '<img src="'.DOKU_BASE.'lib/plugins/captcha/sound.png" width="16" height="16"'.
                    ' alt="'.$this->getLang('soundlink').'" /></a>';
                break;
            case 'image':
                $out .= '<img src="'.DOKU_BASE.'lib/plugins/captcha/img.php?secret='.rawurlencode($secret).'&amp;id='.$ID.'" '.
                    ' width="'.$this->getConf('width').'" height="'.$this->getConf('height').'" alt="" /> ';
                break;
            case 'audio':
                $out .= '<img src="'.DOKU_BASE.'lib/plugins/captcha/img.php?secret='.rawurlencode($secret).'&amp;id='.$ID.'" '.
                    ' width="'.$this->getConf('width').'" height="'.$this->getConf('height').'" alt="" /> ';
                $out .= '<a href="'.DOKU_BASE.'lib/plugins/captcha/wav.php?secret='.rawurlencode($secret).'&amp;id='.$ID.'"'.
                    ' class="JSnocheck" title="'.$this->getLang('soundlink').'">';
                $out .= '<img src="'.DOKU_BASE.'lib/plugins/captcha/sound.png" width="16" height="16"'.
                    ' alt="'.$this->getLang('soundlink').'" /></a>';
                break;
            case 'figlet':
                require_once(dirname(__FILE__).'/figlet.php');
                $figlet = new phpFiglet();
                if($figlet->loadfont(dirname(__FILE__).'/figlet.flf')) {
                    $out .= '<pre>';
                    $out .= rtrim($figlet->fetch($code));
                    $out .= '</pre>';
                } else {
                    msg('Failed to load figlet.flf font file. CAPTCHA broken', -1);
                }
                break;
        }
        $out .= ' <input type="text" size="'.$txtlen.'" name="'.$this->field_in.'" class="edit" /> ';

        // add honeypot field
        $out .= '<label class="no">'.$this->getLang('honeypot').'<input type="text" name="'.$this->field_hp.'" /></label>';
        $out .= '</div>';
        return $out;
    }

    /**
     * Checks if the the CAPTCHA was solved correctly
     *
     * @param  bool $msg when true, an error will be signalled through the msg() method
     * @return bool true when the answer was correct, otherwise false
     */
    public function check($msg = true) {
        global $INPUT;

        $field_sec = $INPUT->str($this->field_sec);
        $field_in  = $INPUT->str($this->field_in);
        $field_hp  = $INPUT->str($this->field_hp);

        // reconstruct captcha from provided $field_sec
        $rand = $this->decrypt($field_sec);

        if($this->getConf('mode') == 'math') {
            $code = $this->_generateMATH($this->_fixedIdent(), $rand);
            $code = $code[1];
        } elseif($this->getConf('mode') == 'question') {
            $code = $this->getConf('answer');
        } else {
            $code = $this->_generateCAPTCHA($this->_fixedIdent(), $rand);
        }

        // compare values
        if(!$field_sec ||
            !$field_in ||
            $rand === false ||
            utf8_strtolower($field_in) != utf8_strtolower($code) ||
            trim($field_hp) !== '' ||
            !$this->retrieveCaptchaCookie($this->_fixedIdent(), $rand)
        ) {
            if($msg) msg($this->getLang('testfailed'), -1);
            return false;
        }
        return true;
    }

    /**
     * Get the path where a captcha cookie would be stored
     *
     * We use a daily temp directory which is easy to clean up
     *
     * @param $fixed string the fixed part, any string
     * @param $rand  float  some random number between 0 and 1
     * @return string the path to the cookie file
     */
    protected function getCaptchaCookiePath($fixed, $rand) {
        global $conf;
        $path = $conf['tmpdir'] . '/captcha/' . date('Y-m-d') . '/' . md5($fixed . $rand) . '.cookie';
        io_makeFileDir($path);
        return $path;
    }

    /**
     * remove all outdated captcha cookies
     */
    public function _cleanCaptchaCookies() {
        global $conf;
        $path = $conf['tmpdir'] . '/captcha/';
        $dirs = glob("$path/*", GLOB_ONLYDIR);
        $today = date('Y-m-d');
        foreach($dirs as $dir) {
            if(basename($dir) === $today) continue;
            if(!preg_match('/\/captcha\//', $dir)) continue; // safety net
            io_rmdir($dir, true);
        }
    }

    /**
     * Creates a one time captcha cookie
     *
     * This is used to prevent replay attacks. It is generated when the captcha form
     * is shown and checked with the captcha check. Since we can not be sure about the
     * session state (might be closed or open) we're not using it.
     *
     * We're not using the stored values for displaying the captcha image (or audio)
     * but continue to use our encryption scheme. This way it's still possible to have
     * multiple captcha checks going on in parallel (eg. with multiple browser tabs)
     *
     * @param $fixed string the fixed part, any string
     * @param $rand  float  some random number between 0 and 1
     */
    protected function storeCaptchaCookie($fixed, $rand) {
        $cache = $this->getCaptchaCookiePath($fixed, $rand);
        touch($cache);
    }

    /**
     * Checks if the captcha cookie exists and deletes it
     *
     * @param $fixed string the fixed part, any string
     * @param $rand  float  some random number between 0 and 1
     * @return bool true if the cookie existed
     */
    protected function retrieveCaptchaCookie($fixed, $rand) {
        $cache = $this->getCaptchaCookiePath($fixed, $rand);
        if(file_exists($cache)) {
            unlink($cache);
            return true;
        }
        return false;
    }

    /**
     * Build a semi-secret fixed string identifying the current page and user
     *
     * This string is always the same for the current user when editing the same
     * page revision, but only for one day. Editing a page before midnight and saving
     * after midnight will result in a failed CAPTCHA once, but makes sure it can
     * not be reused which is especially important for the registration form where the
     * $ID usually won't change.
     *
     * @return string
     */
    public function _fixedIdent() {
        global $ID;
        $lm = @filemtime(wikiFN($ID));
        $td = date('Y-m-d');
        return auth_browseruid() .
            auth_cookiesalt() .
            $ID . $lm . $td;
    }

    /**
     * Adds random space characters within the given text
     *
     * Keeps subsequent numbers without spaces (for math problem)
     *
     * @param $text
     * @return string
     */
    protected function _obfuscateText($text) {
        $new = '';

        $spaces = array(
            "\r",
            "\n",
            "\r\n",
            ' ',
            "\xC2\xA0", // \u00A0    NO-BREAK SPACE
            "\xE2\x80\x80", // \u2000    EN QUAD
            "\xE2\x80\x81", // \u2001    EM QUAD
            "\xE2\x80\x82", // \u2002    EN SPACE
            //         "\xE2\x80\x83", // \u2003    EM SPACE
            "\xE2\x80\x84", // \u2004    THREE-PER-EM SPACE
            "\xE2\x80\x85", // \u2005    FOUR-PER-EM SPACE
            "\xE2\x80\x86", // \u2006    SIX-PER-EM SPACE
            "\xE2\x80\x87", // \u2007    FIGURE SPACE
            "\xE2\x80\x88", // \u2008    PUNCTUATION SPACE
            "\xE2\x80\x89", // \u2009    THIN SPACE
            "\xE2\x80\x8A", // \u200A    HAIR SPACE
            "\xE2\x80\xAF", // \u202F    NARROW NO-BREAK SPACE
            "\xE2\x81\x9F", // \u205F    MEDIUM MATHEMATICAL SPACE

            "\xE1\xA0\x8E\r\n", // \u180E    MONGOLIAN VOWEL SEPARATOR
            "\xE2\x80\x8B\r\n", // \u200B    ZERO WIDTH SPACE
            "\xEF\xBB\xBF\r\n", // \uFEFF    ZERO WIDTH NO-BREAK SPACE
        );

        $len = strlen($text);
        for($i = 0; $i < $len - 1; $i++) {
            $new .= $text{$i};

            if(!is_numeric($text{$i + 1})) {
                $new .= $spaces[array_rand($spaces)];
            }
        }
        $new .= $text{$len - 1};
        return $new;
    }

    /**
     * Generate some numbers from a known string and random number
     *
     * @param $fixed string the fixed part, any string
     * @param $rand  float  some random number between 0 and 1
     * @return string
     */
    protected function _generateNumbers($fixed, $rand) {
        $fixed   = hexdec(substr(md5($fixed), 5, 5)); // use part of the md5 to generate an int
        $rand = $rand * 0xFFFFF; // bitmask from the random number
        return md5($rand ^ $fixed); // combine both values
    }

    /**
     * Generates a random char string
     *
     * @param $fixed string the fixed part, any string
     * @param $rand  float  some random number between 0 and 1
     * @return string
     */
    public function _generateCAPTCHA($fixed, $rand) {
        $numbers = $this->_generateNumbers($fixed, $rand);

        // now create the letters
        $code = '';
        $lettercount = $this->getConf('lettercount') * 2;
        if($lettercount > strlen($numbers)) $lettercount = strlen($numbers);
        for($i = 0; $i < $lettercount; $i += 2) {
            $code .= chr(floor(hexdec($numbers[$i].$numbers[$i + 1]) / 10) + 65);
        }

        return $code;
    }

    /**
     * Create a mathematical task and its result
     *
     * @param $fixed string the fixed part, any string
     * @param $rand  float  some random number between 0 and 1
     * @return array taks, result
     */
    protected function _generateMATH($fixed, $rand) {
        $numbers = $this->_generateNumbers($fixed, $rand);

        // first letter is the operator (+/-)
        $op  = (hexdec($numbers[0]) > 8) ? -1 : 1;
        $num = array(hexdec($numbers[1].$numbers[2]), hexdec($numbers[3]));

        // we only want positive results
        if(($op < 0) && ($num[0] < $num[1])) rsort($num);

        // prepare result and task text
        $res  = $num[0] + ($num[1] * $op);
        $task = $num[0].(($op < 0) ? '-' : '+').$num[1].'=?';

        return array($task, $res);
    }

    /**
     * Create a CAPTCHA image
     *
     * @param string $text the letters to display
     */
    public function _imageCAPTCHA($text) {
        $w = $this->getConf('width');
        $h = $this->getConf('height');

        $fonts = glob(dirname(__FILE__).'/fonts/*.ttf');

        // create a white image
        $img   = imagecreatetruecolor($w, $h);
        $white = imagecolorallocate($img, 255, 255, 255);
        imagefill($img, 0, 0, $white);

        // add some lines as background noise
        for($i = 0; $i < 30; $i++) {
            $color = imagecolorallocate($img, rand(100, 250), rand(100, 250), rand(100, 250));
            imageline($img, rand(0, $w), rand(0, $h), rand(0, $w), rand(0, $h), $color);
        }

        // draw the letters
        $txtlen = strlen($text);
        for($i = 0; $i < $txtlen; $i++) {
            $font  = $fonts[array_rand($fonts)];
            $color = imagecolorallocate($img, rand(0, 100), rand(0, 100), rand(0, 100));
            $size  = rand(floor($h / 1.8), floor($h * 0.7));
            $angle = rand(-35, 35);

            $x       = ($w * 0.05) + $i * floor($w * 0.9 / $txtlen);
            $cheight = $size + ($size * 0.5);
            $y       = floor($h / 2 + $cheight / 3.8);

            imagettftext($img, $size, $angle, $x, $y, $color, $font, $text[$i]);
        }

        header("Content-type: image/png");
        imagepng($img);
        imagedestroy($img);
    }

    /**
     * Create an SVG of the given text
     *
     * @param string $text
     * @return string
     */
    public function _svgCAPTCHA($text) {
        require_once(__DIR__ . '/EasySVG.php');

        $fonts = glob(__DIR__ . '/fonts/*.svg');

        $x = 0; // where we start to draw
        $y = 100; // our max height

        $svg = new EasySVG();

        // draw the letters
        $txtlen = strlen($text);
        for($i = 0; $i < $txtlen; $i++) {
            $char = $text[$i];
            $size = rand($y / 2, $y - $y * 0.1); // 50-90%
            $svg->setFontSVG($fonts[array_rand($fonts)]);

            $svg->setFontSize($size);
            $svg->setLetterSpacing(round(rand(1, 4) / 10, 2)); // 0.1 - 0.4
            $svg->addText($char, $x, rand(0, round($y - $size))); // random up and down

            list($w) = $svg->textDimensions($char);
            $x += $w;
        }

        $svg->addAttribute('width', $x . 'px');
        $svg->addAttribute('height', $y . 'px');
        $svg->addAttribute('viewbox', "0 0 $x $y");
        return $svg->asXML();
    }

    /**
     * Encrypt the given string with the cookie salt
     *
     * @param string $data
     * @return string
     */
    public function encrypt($data) {
        if(function_exists('auth_encrypt')) {
            $data = auth_encrypt($data, auth_cookiesalt()); // since binky
        } else {
            $data = PMA_blowfish_encrypt($data, auth_cookiesalt()); // deprecated
        }

        return base64_encode($data);
    }

    /**
     * Decrypt the given string with the cookie salt
     *
     * @param string $data
     * @return string
     */
    public function decrypt($data) {
        $data = base64_decode($data);
        if($data === false || $data === '') return false;

        if(function_exists('auth_decrypt')) {
            return auth_decrypt($data, auth_cookiesalt()); // since binky
        } else {
            return PMA_blowfish_decrypt($data, auth_cookiesalt()); // deprecated
        }
    }
}
