<?php

use splitbrain\PHPArchive\Zip;

/**
 * DokuWiki Plugin archivegenerator (Admin Component)
 *
 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html
 * @author  Michael Große <dokuwiki@cosmocode.de>
 */
class admin_plugin_archivegenerator extends DokuWiki_Admin_Plugin
{

    protected $generateArchive = false;
    protected $type = 'full';
    protected $base = 'dokuwiki/';

    /** @inheritdoc */
    public function getMenuSort()
    {
        return 123;
    }

    /** @inheritdoc */
    public function forAdminOnly()
    {
        return true;
    }

    /** @inheritdoc */
    public function handle()
    {
        global $INPUT;

        if ($INPUT->bool('isupdate')) $this->type = 'update';

        if ($INPUT->bool('downloadArchive') && checkSecurityToken()) {
            $this->sendArchiveAndExit();
        }

        if ($INPUT->server->str('REQUEST_METHOD') !== 'POST') {
            return;
        }


        $sectok = $INPUT->post->str('sectok');
        if (!checkSecurityToken($sectok)) {
            return;
        }

        // check for email and pass on full archives only
        if ($this->type == 'full') {
            $email = $INPUT->post->str('adminMail');
            $pass = $INPUT->post->str('adminPass');

            if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
                msg(sprintf($this->getLang('message: email invalid'), hsc($email)), -1);
                return;
            }

            if (empty($pass)) {
                msg($this->getLang('message: password empty'), -1);
                return;
            }
        }

        $this->generateArchive = true;
    }

    /** @inheritdoc */
    public function html()
    {
        if (!$this->generateArchive) {
            $this->downloadView();

            ptln('<h1>' . $this->getLang('menu') . '</h1>');
            echo $this->locale_xhtml('intro');
        } else {
            ptln('<h1>' . $this->getLang('menu') . '</h1>');
            try {
                if ($this->type == 'full') {
                    $this->generateArchive();
                } else {
                    $this->generateUpdateArchive();
                }
                return;
            } catch (\splitbrain\PHPArchive\ArchiveIOException $e) {
                msg(hsc($e->getMessage()), -1);
            }
        }
        $this->showFullForm();
        $this->showUpdateForm();
    }

    /**
     * Send the existing wiki archive file and exit
     */
    protected function sendArchiveAndExit()
    {
        global $conf;
        if ($this->type == 'full') {
            $persistentArchiveFN = $conf['tmpdir'] . '/archivegenerator/archive.zip';
        } else {
            $persistentArchiveFN = $conf['tmpdir'] . '/archivegenerator/archive-update.zip';
        }
        header('Content-Type: application/zip');
        header('Content-Disposition: attachment; filename="archive.zip"');
        http_sendfile($persistentArchiveFN);
        readfile($persistentArchiveFN);
        exit();
    }

    /**
     * Build the archive based on the existing wiki
     *
     * @throws \splitbrain\PHPArchive\ArchiveIOException
     */
    protected function generateArchive()
    {
        global $conf;
        $this->log('info', $this->getLang('message: starting'));
        $tmpArchiveFN = $conf['tmpdir'] . '/archivegenerator/archive_new.zip';
        $archive = $this->createZipArchive($tmpArchiveFN);
        set_time_limit(0);
        $this->addDirToArchive($archive, '.', false);
        $this->addDirToArchive($archive, 'inc');
        $this->addDirToArchive($archive, 'bin');
        $this->addDirToArchive($archive, 'vendor');
        $this->addDirToArchive($archive, 'conf', true, '^' . $this->base . 'conf/(users\.auth\.php|acl\.auth\.php)$');
        $this->addUsersAuthToArchive($archive);
        $this->addACLToArchive($archive);
        $this->addDirToArchive($archive, 'lib', true, '^' . $this->base . 'lib/plugins$');
        $this->addDirToArchive($archive, 'lib/plugins', true, $this->buildSkipPluginRegex());
        $this->addDirToArchive($archive, 'data/pages');
        $this->addDirToArchive($archive, 'data/meta', true, '\.changes(\.trimmed)?$');
        $this->addDirToArchive($archive, 'data/media');
        $this->addDirToArchive($archive, 'data/media_meta', true, '\.changes$');
        $this->addDirToArchive($archive, 'data/index');

        $this->addEmptyDirToArchive($archive, 'data/attic');
        $this->addEmptyDirToArchive($archive, 'data/cache');
        $this->addEmptyDirToArchive($archive, 'data/log');
        $this->addEmptyDirToArchive($archive, 'data/locks');
        $this->addEmptyDirToArchive($archive, 'data/tmp');
        $this->addEmptyDirToArchive($archive, 'data/media_attic');

        $archive->close();
        $this->log('info', $this->getLang('message: adding data done'));

        $persistentArchiveFN = $conf['tmpdir'] . '/archivegenerator/archive.zip';
        io_rename($tmpArchiveFN, $persistentArchiveFN);

        $href = $this->getDownloadLinkHref();
        $link = "<a href=\"$href\">" . $this->getLang('link: download now') . '</a>';
        $this->log('success', $this->getLang('message: done') . ' ' . $link);

        // try a redirect to self
        ptln('<script type="text/javascript">window.location.href=\'' . $this->getSelfRedirect() . '\';</script>');
    }

    /**
     * Build an update archive based on the existing wiki
     *
     * @throws \splitbrain\PHPArchive\ArchiveIOException
     */
    protected function generateUpdateArchive()
    {
        global $conf;
        $this->log('info', $this->getLang('message: starting'));
        $tmpArchiveFN = $conf['tmpdir'] . '/archivegenerator/archive-update_new.zip';
        $archive = $this->createZipArchive($tmpArchiveFN);
        set_time_limit(0);
        $this->addDirToArchive($archive, '.', false);
        $this->addDirToArchive($archive, 'inc');
        $this->addDirToArchive($archive, 'bin');
        $this->addDirToArchive($archive, 'vendor');
        $this->addDirToArchive($archive, 'conf', true, '^' . $this->base . 'conf/(users\.auth\.php|acl\.auth\.php|.*local\.(php|conf))$');
        $this->addDirToArchive($archive, 'lib', true, '^' . $this->base . 'lib/plugins$');
        $this->addDirToArchive($archive, 'lib/plugins', true, $this->buildSkipPluginRegex());

        $this->addEmptyDirToArchive($archive, 'data/pages');
        $this->addEmptyDirToArchive($archive, 'data/media');
        $this->addEmptyDirToArchive($archive, 'data/index');
        $this->addEmptyDirToArchive($archive, 'data/media_meta');
        $this->addEmptyDirToArchive($archive, 'data/meta');
        $this->addEmptyDirToArchive($archive, 'data/attic');
        $this->addEmptyDirToArchive($archive, 'data/cache');
        $this->addEmptyDirToArchive($archive, 'data/log');
        $this->addEmptyDirToArchive($archive, 'data/locks');
        $this->addEmptyDirToArchive($archive, 'data/tmp');
        $this->addEmptyDirToArchive($archive, 'data/media_attic');

        $archive->close();
        $this->log('info', $this->getLang('message: adding data done'));

        $persistentArchiveFN = $conf['tmpdir'] . '/archivegenerator/archive-update.zip';
        io_rename($tmpArchiveFN, $persistentArchiveFN);

        $href = $this->getDownloadLinkHref('update');
        $link = "<a href=\"$href\">" . $this->getLang('link: download now') . '</a>';
        $this->log('success', $this->getLang('message: done') . ' ' . $link);

        // try a redirect to self
        ptln('<script type="text/javascript">window.location.href=\'' . $this->getSelfRedirect() . '\';</script>');
    }

    /**
     * Build a regex for the plugins to skip, relative to the DokuWiki root
     *
     * @return string
     */
    protected function buildSkipPluginRegex()
    {
        $list = array_map('trim', explode(',', $this->getConf('pluginsToIgnore')));
        return '^' . $this->base . 'lib/plugins/(' . implode('|', $list) . ')$';
    }

    /**
     * Generate a href for a link to download the archive
     *
     * @param string $type
     * @return string
     */
    protected function getDownloadLinkHref($type = 'full')
    {
        global $ID;
        return wl($ID, [
            'do' => 'admin',
            'page' => 'archivegenerator',
            'downloadArchive' => 1,
            'sectok' => getSecurityToken(),
            'isupdate' => (int)($type == 'update'),
        ]);
    }

    /**
     * Generate the link to the admin page itself
     *
     * @return string
     */
    protected function getSelfRedirect()
    {
        global $ID;
        return wl($ID, [
            'do' => 'admin',
            'page' => 'archivegenerator',
        ], false, '&');
    }

    /**
     * Add an empty directory to the archive.
     *
     * The directory will contain a dummy .keep file.
     *
     * @param Zip $archive
     * @param string $directory path of the directory to add relative to the dokuwiki root
     *
     * @throws \splitbrain\PHPArchive\ArchiveIOException
     */
    protected function addEmptyDirToArchive(Zip $archive, $directory)
    {
        $this->log('info', sprintf($this->getLang('message: create empty dir'), $directory));
        $dirPath = $this->base . $directory . '/.keep';
        $archive->addData($dirPath, '');
    }

    /**
     * Create a users.auth.php file with a single admin user
     *
     * @param Zip $archive
     *
     * @throws \splitbrain\PHPArchive\ArchiveIOException
     */
    protected function addUsersAuthToArchive(Zip $archive)
    {
        global $INPUT;

        $email = $INPUT->post->str('adminMail');
        $pass = $INPUT->post->str('adminPass');

        $this->log('info', $this->getLang('message: create users'));
        $authFile = '
# users.auth.php
# <?php exit()?>
# Don\'t modify the lines above
#
# Userfile
#
# Format:
#
# login:passwordhash:Real Name:email:groups,comma,separated

        ';

        $pwHash = auth_cryptPassword($pass);
        $adminLine = "admin:$pwHash:Administrator:$email:user,admin\n";
        $archive->addData($this->base . 'conf/users.auth.php', $authFile . $adminLine);
    }

    /**
     * Create an acl.auth.php file that allows reading only for logged-in users
     *
     * @param Zip $archive
     *
     * @throws \splitbrain\PHPArchive\ArchiveIOException
     */
    protected function addACLToArchive(Zip $archive)
    {
        $this->log('info', $this->getLang('message: create acl'));
        $aclFileContents = '# acl.auth.php
# <?php exit()?>
*  @ALL   0
*  @users 1
';
        $archive->addData($this->base . 'conf/acl.auth.php', $aclFileContents);
    }

    /**
     * Create the archive file
     *
     * @return Zip
     * @throws \splitbrain\PHPArchive\ArchiveIOException
     */
    protected function createZipArchive($archiveFN)
    {
        $this->log('info', sprintf($this->getLang('message: create zip archive'), hsc($archiveFN)));
        io_makeFileDir($archiveFN);
        $zip = new Zip();
        $zip->create($archiveFN);

        return $zip;
    }

    /**
     * Add the contents of an directory to the archive
     *
     * @param Zip $archive
     * @param string $srcDir the directory relative to the dokuwiki root
     * @param bool $recursive whether to add subdirectories as well
     * @param null|string $skipRegex files and directories matching this regex will be ignored. no delimiters
     *
     * @throws \splitbrain\PHPArchive\ArchiveIOException
     */
    protected function addDirToArchive(Zip $archive, $srcDir, $recursive = true, $skipRegex = null)
    {
        $message = [];
        $message[] = sprintf($this->getLang('message: add files in dir'), hsc($srcDir . '/'));
        if ($recursive) {
            $message[] = $this->getLang('message: recursive');
        }
        if ($skipRegex) {
            $message[] = sprintf($this->getLang('message: skipping files'), hsc($skipRegex));
        }
        $message[] .= '...';
        $this->log('info', implode(' ', $message));
        $this->addFilesToArchive(DOKU_INC . $srcDir, $archive, !$recursive, $skipRegex);
    }

    /**
     * Recursive method to add files and directories to a archive
     *
     * It will report large files that might cause the process to fail.
     *
     * @param string $source
     * @param Zip $archive
     * @param bool $filesOnly
     * @param null $skipRegex
     *
     * @return bool
     * @throws \splitbrain\PHPArchive\ArchiveIOException
     */
    protected function addFilesToArchive($source, Zip $archive, $filesOnly = false, $skipRegex = null)
    {
        // Simple copy for a file
        if (is_file($source)) {
            if (filesize($source) > 50 * 1024 * 1024) {
                $this->log('warning', sprintf($this->getLang('message: file is large'),
                        hsc($source)) . ' ' . filesize_h(filesize($source)));
            }

            try {
                $archive->addFile($source, $this->getDWPathName($source));
            } catch (\splitbrain\PHPArchive\ArchiveIOException $e) {
                $this->log('error', hsc($e->getMessage()));
                throw $e;
            }
            return true;
        }

        // Loop through the folder
        $dir = dir($source);
        while (false !== $entry = $dir->read()) {
            if (in_array($entry, ['.', '..', '.git', 'node_modules'])) {
                continue;
            }
            $srcFN = "$source/$entry";

            if ($skipRegex && preg_match("#$skipRegex#", $this->getDWPathName($srcFN))) {
                continue;
            }

            if (is_dir($srcFN) && $filesOnly) {
                continue;
            }

            $copyOK = $this->addFilesToArchive($srcFN, $archive, $filesOnly, $skipRegex);
            if ($copyOK === false) {
                return false;
            }
        }

        // Clean up
        $dir->close();
        return true;
    }

    /**
     * Get the filepath relative to the dokuwiki root
     *
     * @param $filepath
     *
     * @return string
     */
    protected function getDWPathName($filepath)
    {
        return $this->base . substr($filepath, strlen(DOKU_INC));
    }

    /**
     * Display the download info
     */
    protected function downloadView()
    {
        global $conf;

        $fullArchiveFN = $conf['tmpdir'] . '/archivegenerator/archive.zip';
        $updateArchiveFN = $conf['tmpdir'] . '/archivegenerator/archive-update.zip';
        if (!file_exists($fullArchiveFN) && !file_exists($updateArchiveFN)) return;

        ptln('<h1>' . $this->getLang('label: download') . '</h1>');

        if (file_exists($fullArchiveFN)) {
            $mtime = dformat(filemtime($fullArchiveFN));
            $href = $this->getDownloadLinkHref();
            ptln('<p>');
            ptln('<b>' . $this->getLang('label: full archive') . '</b><br>');
            ptln(sprintf($this->getLang('message: archive exists'), $mtime));
            ptln("<a href=\"$href\">" . $this->getLang('link: download now') . '</a>');
            ptln('</p>');
        }

        if (file_exists($updateArchiveFN)) {
            $mtime = dformat(filemtime($updateArchiveFN));
            $href = $this->getDownloadLinkHref('update');
            ptln('<p>');
            ptln('<b>' . $this->getLang('label: update archive') . '</b><br>');
            ptln(sprintf($this->getLang('message: archive exists'), $mtime));
            ptln("<a href=\"$href\">" . $this->getLang('link: download now') . '</a>');
            ptln('</p>');
        }
    }

    /**
     * Show the form to create a full archive
     */
    protected function showFullForm()
    {
        $form = new \dokuwiki\Form\Form();
        $form->addFieldsetOpen($this->getLang('label: full archive'));

        $adminMailInput = $form->addTextInput('adminMail', $this->getLang('label: admin mail'));
        $adminMailInput->addClass('block');
        $adminMailInput->attrs(['type' => 'email', 'required' => '1']);

        $adminPassInput = $form->addPasswordInput('adminPass', $this->getLang('label: admin pass'));
        $adminPassInput->addClass('block');
        $adminPassInput->attr('required', 1);

        $form->addButton('submit', $this->getLang('button: generate archive'));

        $form->addFieldsetClose();
        echo $form->toHTML();
    }

    /**
     * Show the form to create a full archive
     */
    protected function showUpdateForm()
    {
        $form = new \dokuwiki\Form\Form();
        $form->addFieldsetOpen($this->getLang('label: update archive'));

        $form->setHiddenField('isupdate', '1');

        $form->addButton('submit', $this->getLang('button: generate archive'));

        $form->addFieldsetClose();
        echo $form->toHTML();
    }

    /**
     * Print a message to the user, prefixes the time since the first message
     *
     * This adds whitespace padding to force the message being printed immediately.
     *
     * @param string $level can be 'error', 'warning' or 'info'
     * @param string $message
     */
    protected function log($level, $message)
    {
        static $startTime;
        if (!$startTime) {
            $startTime = microtime(true);
        }

        $time = round(microtime(true) - $startTime, 3);
        $timedMessage = sprintf($this->getLang('seconds'), $time) . ': ' . $message;

        switch ($level) {
            case 'error':
                $msgLVL = -1;
                break;
            case 'warning':
                $msgLVL = 2;
                break;
            case 'success':
                $msgLVL = 1;
                break;
            default:
                $msgLVL = 0;
        }

        msg($timedMessage, $msgLVL);
        echo str_repeat(' ', 16 * 1024);

        /** @noinspection MissingOrEmptyGroupStatementInspection */
        /** @noinspection LoopWhichDoesNotLoopInspection */
        /** @noinspection PhpStatementHasEmptyBodyInspection */
        while (@ob_end_flush()) {
        };
        flush();
    }
}

