[packages/dokuwiki-plugin-editx] Rel 2. Fix DokuWiki 2026 compatibility

arekm arekm at pld-linux.org
Wed Oct 21 20:23:49 CEST 2026


commit f4f3a28958d14e10f0e7964790e6e6bc3c6b023e
Author: Arkadiusz Miśkiewicz <arekm at maven.pl>
Date:   Wed Oct 21 20:22:41 2026 +0200

    Rel 2. Fix DokuWiki 2026 compatibility

 dokuwiki-plugin-editx-compat.patch | 119 +++++++++++++
 dokuwiki-plugin-editx-safety.patch | 354 +++++++++++++++++++++++++++++++++++++
 dokuwiki-plugin-editx.spec         |  13 +-
 regression.php                     | 184 +++++++++++++++++++
 4 files changed, 666 insertions(+), 4 deletions(-)
---
diff --git a/dokuwiki-plugin-editx.spec b/dokuwiki-plugin-editx.spec
index c44bda1..84812e7 100644
--- a/dokuwiki-plugin-editx.spec
+++ b/dokuwiki-plugin-editx.spec
@@ -3,16 +3,19 @@
 Summary:	DokuWiki editx plugin
 Name:		dokuwiki-plugin-%{plugin}
 Version:	20160207
-Release:	1
+Release:	2
 License:	GPL v2
 Group:		Applications/WWW
-Source0:	https://github.com/danny0838/dw-editx/tarball/master/%{plugin}-%{version}.tar.gz
+Source0:	https://github.com/danny0838/dokuwiki-plugin-editx/tarball/644a3148c5aa2079e6df7ed62baa48d545de073e/%{plugin}-%{version}.tar.gz
 # Source0-md5:	dbc337cf034cefb1ecd9a686c0de97d4
+Source1:	regression.php
+Patch0:		dokuwiki-plugin-editx-compat.patch
+Patch1:		dokuwiki-plugin-editx-safety.patch
 URL:		https://www.dokuwiki.org/plugin:editx
 BuildRequires:	rpm-php-pearprov >= 4.4.2-11
 BuildRequires:	rpmbuild(macros) >= 1.553
-BuildRequires:	unzip
-Requires:	dokuwiki >= 20061106
+# checkSecurityToken() and formSecurityToken()
+Requires:	dokuwiki >= 20080505
 Requires:	php(core) >= %{php_min_version}
 Requires:	php(date)
 Requires:	php(pcre)
@@ -31,6 +34,8 @@ Extended edit functions such as renaming or deleting a page.
 %setup -qc
 mv *%{plugin}*/* .
 %undos -f php
+%patch -P0 -p1
+%patch -P1 -p1
 
 # using date from latest commit
 #version=$(awk '/^date/{print $2}' plugin.info.txt)
diff --git a/dokuwiki-plugin-editx-compat.patch b/dokuwiki-plugin-editx-compat.patch
new file mode 100644
index 0000000..d8c07a7
--- /dev/null
+++ b/dokuwiki-plugin-editx-compat.patch
@@ -0,0 +1,119 @@
+# PHP 8 rejects curly string offsets and bare array keys; DokuWiki loads fulltext via compatibility functions.
+--- a/action.php
++++ b/action.php
+@@ -8,9 +8,13 @@
+ if(!defined('DOKU_INC')) die();
+ if (!defined('DOKU_PLUGIN')) define('DOKU_PLUGIN', DOKU_INC . 'lib/plugins/');
+ require_once (DOKU_PLUGIN . 'action.php');
+-require_once(DOKU_INC.'inc/fulltext.php');
++if (!function_exists('ft_backlinks')) {
++    require_once(DOKU_INC.'inc/fulltext.php');
++}
+ 
+ class action_plugin_editx extends DokuWiki_Action_Plugin {
++
++    var $errors = array();
+ 
+     /**
+      * register the eventhandlers
+@@ -81,8 +85,8 @@
+ 
+         if(!count($allowed)) return true; //no restrictions
+ 
+-        $user   = $_SERVER['REMOTE_USER'];
+-        $groups = (array) $USERINFO['grps'];
++        $user   = isset($_SERVER['REMOTE_USER']) ? $_SERVER['REMOTE_USER'] : '';
++        $groups = isset($USERINFO['grps']) ? (array) $USERINFO['grps'] : array();
+ 
+         if(in_array($user,$allowed)) return true; //user explicitly mentioned
+ 
+@@ -101,8 +105,8 @@
+     }
+     
+     function _auth_can_rename($id) {
+-        static $cache = null;
+-        if (!$cache[$id]) {
++        static $cache = array();
++        if (!isset($cache[$id])) {
+             $cache[$id] = auth_quickaclcheck($id)>=AUTH_EDIT &&
+                 $this->_auth_check_list($this->getConf('user_rename'));
+         }
+@@ -110,8 +114,8 @@
+     }
+ 
+     function _auth_can_rename_nr($id) {
+-        static $cache = null;
+-        if (!$cache[$id]) {
++        static $cache = array();
++        if (!isset($cache[$id])) {
+             $cache[$id] = auth_quickaclcheck($id)>=AUTH_DELETE &&
+                 $this->_auth_check_list($this->getConf('user_rename_nr'));
+         }
+@@ -119,8 +123,8 @@
+     }
+ 
+     function _auth_can_delete($id) {
+-        static $cache = null;
+-        if (!$cache[$id]) {
++        static $cache = array();
++        if (!isset($cache[$id])) {
+             $cache[$id] = auth_quickaclcheck($id)>=AUTH_DELETE &&
+                 $this->_auth_check_list($this->getConf('user_delete'));
+         }
+@@ -130,17 +134,17 @@
+     function _locate_filepairs(&$opts, $dir, $regex ){
+         global $conf;
+         $oldpath = $conf[$dir].'/'.str_replace(':','/',$opts['oldns']);
+-        $newpath = $conf[$dir].'/'.str_replace(':','/',$opts['newns']);
+-        if (!$opts['oldfiles']) $opts['oldfiles'] = array();
+-        if (!$opts['newfiles']) $opts['newfiles'] = array();
++        $newpath = empty($opts['move']) ? '' : $conf[$dir].'/'.str_replace(':','/',$opts['newns']);
++        if (!isset($opts['oldfiles'])) $opts['oldfiles'] = array();
++        if (!isset($opts['newfiles'])) $opts['newfiles'] = array();
+         $dh = @opendir($oldpath);
+         if($dh) {
+             while(($file = readdir($dh)) !== false){
+-                if ($file{0}=='.') continue;
++                if ($file[0]=='.') continue;
+                 $oldfile = $oldpath.$file;
+                 if (is_file($oldfile) && preg_match($regex,$file)){
+                     $opts['oldfiles'][] = $oldfile;
+-                    if ($opts['move']) {
++                    if (!empty($opts['move'])) {
+                         $newfilebase = str_replace($opts['oldname'], $opts['newname'], $file);
+                         $newfile = $newpath.$newfilebase;
+                         if (@file_exists($newfile)) {
+@@ -173,7 +177,7 @@
+     }
+ 
+     function _FN($id) {
+-        $id = str_replace(':','/',$id);
++        $id = str_replace(':','/',(string) $id);
+         $id = utf8_encodeFN($id);
+         return $id;
+     }
+@@ -291,7 +295,7 @@
+             msg( $msg, 1 );
+         }
+         // display form and table
+-        $data = array( rp_newpage => $opts['newpage'], rp_summary => $opts['summary'], rp_nr => $opts['rp_nr'] );
++        $data = array( 'rp_newpage' => $opts['newpage'], 'rp_summary' => $opts['summary'], 'rp_nr' => $opts['rp_nr'] );
+         $this->_print_form($data);
+     }
+ 
+@@ -325,11 +329,13 @@
+             msg( $msg, 1 );
+         }
+         // display form and table
+-        $data = array( dp_purge => $opts['purge'], dp_summary => $opts['summary'] );
++        $data = array( 'dp_purge' => $opts['purge'], 'dp_summary' => $opts['summary'] );
+         $this->_print_form($data);
+     }
+ 
+-    function _print_form($data=null) {
++    function _print_form($data=array()) {
++        $data = (array) $data + array('rp_newpage' => '', 'rp_summary' => '',
++            'rp_nr' => false, 'dp_purge' => false, 'dp_summary' => '');
+         global $ID, $lang;
+         $chk = ' checked="checked"';
+ ?>
diff --git a/dokuwiki-plugin-editx-safety.patch b/dokuwiki-plugin-editx-safety.patch
new file mode 100644
index 0000000..4c9064c
--- /dev/null
+++ b/dokuwiki-plugin-editx-safety.patch
@@ -0,0 +1,354 @@
+# Require CSRF-protected POSTs, escape form values, enforce create ACLs and handle file operation failures.
+# Match history filenames literally and rename only their page-name prefix.
+--- a/action.php
++++ b/action.php
+@@ -47,20 +47,25 @@
+         if($event->data != 'editx') return;
+         $event->preventDefault();
+ 
+-        switch ($_REQUEST['work']) {
++        if ($this->_post('work') && !checkSecurityToken($this->_post('sectok'))) {
++            $this->_print_form();
++            return;
++        }
++
++        switch ($this->_post('work')) {
+             case 'rename':
+-                $opts['oldpage'] = cleanID($_REQUEST['oldpage']);
+-                $opts['newpage'] = cleanID($_REQUEST['newpage']);
+-                $opts['summary'] = $_REQUEST['summary'];
+-                $opts['rp_nr'] = $_REQUEST['rp_nr'];
+-                $opts['confirm'] = $_REQUEST['rp_confirm'];
++                $opts['oldpage'] = cleanID($this->_post('oldpage'));
++                $opts['newpage'] = cleanID($this->_post('newpage'));
++                $opts['summary'] = $this->_post('summary');
++                $opts['rp_nr'] = $this->_post('rp_nr');
++                $opts['confirm'] = $this->_post('rp_confirm');
+                 $this->_rename_page($opts);
+                 break;
+             case 'delete':
+-                $opts['oldpage'] = cleanID($_REQUEST['oldpage']);
+-                $opts['summary'] = $_REQUEST['summary'];
+-                $opts['purge'] = $_REQUEST['dp_purge'];
+-                $opts['confirm'] = $_REQUEST['dp_confirm'];
++                $opts['oldpage'] = cleanID($this->_post('oldpage'));
++                $opts['summary'] = $this->_post('summary');
++                $opts['purge'] = $this->_post('dp_purge');
++                $opts['confirm'] = $this->_post('dp_confirm');
+                 $this->_delete_page($opts);
+                 break;
+             default:
+@@ -72,6 +77,10 @@
+     /**
+      * helper functions
+      */
++    function _post($key) {
++        return isset($_POST[$key]) && is_string($_POST[$key]) ? $_POST[$key] : '';
++    }
++
+     function _auth_check_list($list) {
+         global $conf;
+         global $USERINFO;
+@@ -145,10 +154,11 @@
+                 if (is_file($oldfile) && preg_match($regex,$file)){
+                     $opts['oldfiles'][] = $oldfile;
+                     if (!empty($opts['move'])) {
+-                        $newfilebase = str_replace($opts['oldname'], $opts['newname'], $file);
++                        $newfilebase = $opts['newname'].substr($file, strlen($opts['oldname']));
+                         $newfile = $newpath.$newfilebase;
+                         if (@file_exists($newfile)) {
+-                            $this->errors[] = sprintf( $this->getLang('rp_msg_file_conflict'), '<a href="'. wl($opts['newpage']) . '">'.$opts['newpage'].'</a>', $newfilebase );
++                            $this->errors[] = sprintf( $this->getLang('rp_msg_file_conflict'), '<a href="'. wl($opts['newpage']) . '">'.hsc($opts['newpage']).'</a>', hsc($newfilebase) );
++                            closedir($dh);
+                             return false;
+                         }
+                         $opts['newfiles'][] = $newfile;
+@@ -158,22 +168,42 @@
+             closedir($dh);
+             return true;
+         }
+-        return false;
++        if (is_dir($oldpath)) {
++            $this->errors[] = sprintf($this->getLang('msg_io_error'), hsc($oldpath));
++            return false;
++        }
++        return true;
++    }
++
++    function _rollback_moves(&$opts, $count) {
++        for ($i = $count - 1; $i >= 0; $i--) {
++            if (!io_rename($opts['newfiles'][$i], $opts['oldfiles'][$i])) {
++                $this->errors[] = sprintf($this->getLang('msg_io_error'), hsc($opts['oldfiles'][$i]));
++            }
++        }
+     }
+ 
+     function _apply_moves(&$opts) {
+         foreach ($opts['oldfiles'] as $i => $oldfile) {
+             $newfile = $opts['newfiles'][$i];
+             $newdir = dirname($newfile);
+-            if (!io_mkdir_p($newdir)) continue;
+-            io_rename($oldfile, $newfile);
+-        }
++            if (!io_mkdir_p($newdir) || !io_rename($oldfile, $newfile)) {
++                $this->errors[] = sprintf($this->getLang('msg_io_error'), hsc($newfile));
++                $this->_rollback_moves($opts, $i);
++                return false;
++            }
++        }
++        return true;
+     }
+ 
+     function _apply_deletes(&$opts) {
+         foreach ($opts['oldfiles'] as $oldfile) {
+-            unlink($oldfile);
+-        }
++            if (!@unlink($oldfile)) {
++                $this->errors[] = sprintf($this->getLang('msg_io_error'), hsc($oldfile));
++                return false;
++            }
++        }
++        return true;
+     }
+ 
+     function _FN($id) {
+@@ -184,18 +214,27 @@
+ 
+     function _custom_delete_page($id, $summary) {
+         global $ID, $INFO, $conf;
+-        // mark as nonexist to prevent indexerWebBug
+-        if ($id==$ID) $INFO['exists'] = 0;
+         // delete page, meta and attic
+         $file = wikiFN($id);
+         $old = @filemtime($file); // from page
+-        if (file_exists($file)) unlink($file);
++
+         $opts['oldname'] = $this->_FN(noNS($id));
+         $opts['oldns'] = $this->_FN(getNS($id));
+         if ($opts['oldns']) $opts['oldns'] .= '/';
+-        $this->_locate_filepairs( $opts, 'metadir', '/^'.$opts['oldname'].'\.(?!mlist)\w*?$/' );
+-        $this->_locate_filepairs( $opts, 'olddir', '/^'.$opts['oldname'].'\.\d{10}\.txt(\.gz|\.bz2)?$/' );
+-        $this->_apply_deletes($opts);
++        $this->_locate_filepairs( $opts, 'metadir', '/^'.preg_quote($opts['oldname'], '/').'\.(?!mlist)\w*?$/' );
++        $this->_locate_filepairs( $opts, 'olddir', '/^'.preg_quote($opts['oldname'], '/').'\.\d{10}\.txt(\.gz|\.bz2)?$/' );
++        if ($this->errors) return false;
++        if (file_exists($file)) $opts['oldfiles'][] = $file;
++        // Check all parent directories before removing any history.
++        foreach ($opts['oldfiles'] as $oldfile) {
++            if (!is_writable(dirname($oldfile))) {
++                $this->errors[] = sprintf($this->getLang('msg_io_error'), hsc($oldfile));
++                return false;
++            }
++        }
++        if (!$this->_apply_deletes($opts)) return false;
++        // mark as nonexist to prevent indexerWebBug
++        if ($id==$ID) $INFO['exists'] = 0;
+         io_sweepNS($id, 'datadir');
+         io_sweepNS($id, 'metadir');
+         io_sweepNS($id, 'olddir');
+@@ -212,12 +251,14 @@
+                 $cache->removeCache();
+             }
+         }
++        return true;
+     }
+ 
+     /**
+      * main functions
+      */
+     function _rename_page(&$opts) {
++        $this->errors = array();
+         // check confirmation
+         if (!$opts['confirm']) {
+             $this->errors[] = $this->getLang('rp_msg_unconfirmed');
+@@ -226,11 +267,11 @@
+         if (!$opts['oldpage']) {
+             $this->errors[] = $this->getLang('rp_msg_old_empty');
+         } else if (!page_exists($opts['oldpage'])) {
+-            $this->errors[] = sprintf( $this->getLang('rp_msg_old_noexist'), $opts['oldpage'] );
++            $this->errors[] = sprintf( $this->getLang('rp_msg_old_noexist'), hsc($opts['oldpage']) );
+         } else if (!$this->_auth_can_rename($opts['oldpage'])) {
+-            $this->errors[] = sprintf( $this->getLang('rp_msg_auth'), $opts['oldpage'] );
++            $this->errors[] = sprintf( $this->getLang('rp_msg_auth'), hsc($opts['oldpage']) );
+         } else if (checklock($opts['oldpage'])) {
+-            $this->errors[] = sprintf( $this->getLang('rp_msg_locked'), $opts['oldpage'] );
++            $this->errors[] = sprintf( $this->getLang('rp_msg_locked'), hsc($opts['oldpage']) );
+         }
+         // check noredirect
+         if ($opts['rp_nr'] && !$this->_auth_can_rename_nr($opts['oldpage']))
+@@ -239,11 +280,12 @@
+         if (!$opts['newpage']) {
+             $this->errors[] = $this->getLang('rp_msg_new_empty');
+         } else if (page_exists($opts['newpage'])) {
+-            $this->errors[] = sprintf( $this->getLang('rp_msg_new_exist'), '<a href="'. wl($opts['newpage']) . '">'.$opts['newpage'].'</a>' );
+-        } else if (!$this->_auth_can_rename($opts['newpage'])) {
+-            $this->errors[] = sprintf( $this->getLang('rp_msg_auth'), $opts['newpage'] );
++            $this->errors[] = sprintf( $this->getLang('rp_msg_new_exist'), '<a href="'. wl($opts['newpage']) . '">'.hsc($opts['newpage']).'</a>' );
++        } else if (!$this->_auth_can_rename($opts['newpage']) ||
++                   auth_quickaclcheck($opts['newpage']) < AUTH_CREATE) {
++            $this->errors[] = sprintf( $this->getLang('rp_msg_auth'), hsc($opts['newpage']) );
+         } else if (checklock($opts['newpage'])) {
+-            $this->errors[] = sprintf( $this->getLang('rp_msg_locked'), $opts['newpage'] );
++            $this->errors[] = sprintf( $this->getLang('rp_msg_locked'), hsc($opts['newpage']) );
+         }
+         // try to locate moves
+         if (!$this->errors) {
+@@ -254,13 +296,11 @@
+             $opts['newns'] = $this->_FN(getNS($opts['newpage']));
+             if ($opts['oldns']) $opts['oldns'] .= '/';
+             if ($opts['newns']) $opts['newns'] .= '/';
+-            $this->_locate_filepairs( $opts, 'metadir', '/^'.$opts['oldname'].'\.(?!mlist|meta|indexed)\w*?$/' );
+-            $this->_locate_filepairs( $opts, 'olddir', '/^'.$opts['oldname'].'\.\d{10}\.txt(\.gz|\.bz2)?$/' );
++            $this->_locate_filepairs( $opts, 'metadir', '/^'.preg_quote($opts['oldname'], '/').'\.(?!mlist|meta|indexed)\w*?$/' );
++            $this->_locate_filepairs( $opts, 'olddir', '/^'.preg_quote($opts['oldname'], '/').'\.\d{10}\.txt(\.gz|\.bz2)?$/' );
+         }
+         // if no error do rename
+-        if (!$this->errors) {
+-            // move meta and attic
+-            $this->_apply_moves($opts);
++        if (!$this->errors && $this->_apply_moves($opts)) {
+             // save to newpage
+             $text = rawWiki($opts['oldpage']);
+             if ($opts['summary'])
+@@ -268,17 +308,22 @@
+             else
+                 $summary = sprintf( $this->getLang('rp_newsummary'), $opts['oldpage'], $opts['newpage'] );
+             saveWikiText($opts['newpage'],$text,$summary);
++            if (!page_exists($opts['newpage']) || rawWiki($opts['newpage']) !== $text) {
++                $this->errors[] = sprintf($this->getLang('msg_io_error'), hsc(wikiFN($opts['newpage'])));
++                $this->_rollback_moves($opts, count($opts['oldfiles']));
++            }
+             // purge or recreate old page
+             $summary = $opts['summary'] ?
+                 sprintf( $this->getLang('rp_oldsummaryx'), $opts['oldpage'], $opts['newpage'], $opts['summary'] ) :
+                 sprintf( $this->getLang('rp_oldsummary'), $opts['oldpage'], $opts['newpage'] );
+-            if ($opts['rp_nr']) {
+-                $this->_custom_delete_page( $opts['oldpage'], $summary );
+-                // write change log afterwards, or it would be deleted
+-                addLogEntry( null, $opts['oldpage'], DOKU_CHANGE_TYPE_DELETE, $summary ); // also writes to global changes
+-                unlink(metaFN($opts['oldpage'],'.changes')); // purge page changes
+-            }
+-            else {
++            if (!$this->errors && $opts['rp_nr']) {
++                if ($this->_custom_delete_page( $opts['oldpage'], $summary )) {
++                    // write change log afterwards, or it would be deleted
++                    addLogEntry( null, $opts['oldpage'], DOKU_CHANGE_TYPE_DELETE, $summary ); // also writes to global changes
++                    unlink(metaFN($opts['oldpage'],'.changes')); // purge page changes
++                }
++            }
++            else if (!$this->errors) {
+                 $text = $this->getConf('redirecttext');
+                 if (!$text) $text = $this->getLang('redirecttext');
+                 $text = str_replace( '@ID@', $opts['newpage'], $text );
+@@ -291,7 +336,7 @@
+             foreach ($this->errors as $error) msg( $error, -1 );
+         }
+         else {
+-            $msg = sprintf( $this->getLang('rp_msg_success'), $opts['oldpage'], '<a href="'. wl($opts['newpage']) . '">'.$opts['newpage'].'</a>' );
++            $msg = sprintf( $this->getLang('rp_msg_success'), hsc($opts['oldpage']), '<a href="'. wl($opts['newpage']) . '">'.hsc($opts['newpage']).'</a>' );
+             msg( $msg, 1 );
+         }
+         // display form and table
+@@ -300,6 +345,7 @@
+     }
+ 
+     function _delete_page(&$opts) {
++        $this->errors = array();
+         // check confirm
+         if (!$opts['confirm']) {
+             $this->errors[] = $this->getLang('dp_msg_unconfirmed');
+@@ -308,24 +354,27 @@
+         if (!$opts['oldpage']) {
+             $this->errors[] = $this->getLang('dp_msg_old_empty');
+         } else if (!$this->_auth_can_delete($opts['oldpage'])) {
+-            $this->errors[] = sprintf( $this->getLang('dp_msg_auth'), $opts['oldpage'] );
++            $this->errors[] = sprintf( $this->getLang('dp_msg_auth'), hsc($opts['oldpage']) );
++        } else if (checklock($opts['oldpage'])) {
++            $this->errors[] = sprintf( $this->getLang('rp_msg_locked'), hsc($opts['oldpage']) );
+         }
+         // if no error do delete
+         if (!$this->errors) {
+             $summary = $opts['summary'] ? 
+                 sprintf( $this->getLang('dp_oldsummaryx'), $opts['summary'] ) :
+                 $this->getLang('dp_oldsummary');
+-            $this->_custom_delete_page( $opts['oldpage'], $summary );
+-            // write change log afterwards, or it would be deleted
+-            addLogEntry( null, $opts['oldpage'], DOKU_CHANGE_TYPE_DELETE, $summary ); // also writes to global changes
+-            if ($opts['purge']) unlink(metaFN($opts['oldpage'],'.changes')); // purge page changes
++            if ($this->_custom_delete_page( $opts['oldpage'], $summary )) {
++                // write change log afterwards, or it would be deleted
++                addLogEntry( null, $opts['oldpage'], DOKU_CHANGE_TYPE_DELETE, $summary ); // also writes to global changes
++                if ($opts['purge']) unlink(metaFN($opts['oldpage'],'.changes')); // purge page changes
++            }
+         }
+         // show messages
+         if ($this->errors) {
+             foreach ($this->errors as $error) msg( $error, -1 );
+         }
+         else {
+-            $msg = sprintf( $this->getLang('dp_msg_success'), $opts['oldpage'] );
++            $msg = sprintf( $this->getLang('dp_msg_success'), hsc($opts['oldpage']) );
+             msg( $msg, 1 );
+         }
+         // display form and table
+@@ -339,25 +388,26 @@
+         global $ID, $lang;
+         $chk = ' checked="checked"';
+ ?>
+-<h1><?php echo sprintf( $this->getLang('title'), $ID); ?></h1>
++<h1><?php echo sprintf( $this->getLang('title'), hsc($ID)); ?></h1>
+ <div id="config__manager">
+ <?php 
+     if ($this->_auth_can_rename($ID)) {
+ ?>
+     <form action="<?php echo wl($ID); ?>" method="post">
++    <?php formSecurityToken(); ?>
+     <fieldset>
+     <legend><?php echo $this->getLang('rp_title'); ?></legend>
+         <input type="hidden" name="do" value="editx" />
+         <input type="hidden" name="work" value="rename" />
+-        <input type="hidden" name="oldpage" value="<?php echo $ID; ?>" />
++        <input type="hidden" name="oldpage" value="<?php echo hsc($ID); ?>" />
+         <table class="inline">
+             <tr>
+                 <td class="label"><?php echo $this->getLang('rp_newpage'); ?></td>
+-                <td class="value"><input class="edit" type="input" name="newpage" value="<?php echo $data['rp_newpage']; ?>" /></td>
++                <td class="value"><input class="edit" type="input" name="newpage" value="<?php echo hsc($data['rp_newpage']); ?>" /></td>
+             </tr>
+             <tr>
+                 <td class="label"><?php echo $this->getLang('rp_summary'); ?></td>
+-                <td class="value"><input class="edit" type="input" name="summary" value="<?php echo $data['rp_summary']; ?>" /></td>
++                <td class="value"><input class="edit" type="input" name="summary" value="<?php echo hsc($data['rp_summary']); ?>" /></td>
+             </tr>
+ <?php 
+         if ($this->_auth_can_rename_nr($ID)) {
+@@ -385,15 +435,16 @@
+     if ($this->_auth_can_delete($ID)) {
+ ?>
+     <form action="<?php echo wl($ID); ?>" method="post">
++    <?php formSecurityToken(); ?>
+     <fieldset>
+     <legend><?php echo $this->getLang('dp_title'); ?></legend>
+         <input type="hidden" name="do" value="editx" />
+         <input type="hidden" name="work" value="delete" />
+-        <input type="hidden" name="oldpage" value="<?php echo $ID; ?>" />
++        <input type="hidden" name="oldpage" value="<?php echo hsc($ID); ?>" />
+         <table class="inline">
+             <tr>
+                 <td class="label"><?php echo $this->getLang('dp_summary'); ?></td>
+-                <td class="value"><input class="edit" type="input" name="summary" value="<?php echo $data['dp_summary']; ?>" /></td>
++                <td class="value"><input class="edit" type="input" name="summary" value="<?php echo hsc($data['dp_summary']); ?>" /></td>
+             </tr>
+             <tr>
+                 <td class="label"><?php echo $this->getLang('dp_purge'); ?></td>
+--- a/lang/en/lang.php
++++ b/lang/en/lang.php
+@@ -8,6 +8,8 @@
+ $lang['title']              = '%s Management';
+ $lang['redirecttext']       = 'This page is redirected to [[:@ID@]].';
+ $lang['pagemanagement']     = 'page management';
++
++$lang['msg_io_error'] = 'Could not update file %s. Check file permissions.';
+ 
+ // rename page = rp
+ $lang['rp_title']           = 'Page Rename';
diff --git a/regression.php b/regression.php
new file mode 100644
index 0000000..a4fccca
--- /dev/null
+++ b/regression.php
@@ -0,0 +1,184 @@
+<?php
+// Integration regressions for PHP 8.5 and DokuWiki 2026-07-14c.
+// Extract an official DokuWiki tarball into a scratch directory, then copy or
+// symlink the plugin extracted from the built RPM into its lib/plugins/editx.
+// Run as a regular user: php regression.php /path/to/scratch/dokuwiki
+// Configuration and all page data are created in a fresh temporary directory.
+// Failed fixtures are retained at the printed path for inspection.
+if ($argc != 2) { fwrite(STDERR, "Usage: php regression.php DOKUWIKI_ROOT\n"); exit(2); }
+define('DOKU_INC', rtrim(realpath($argv[1]), '/') . '/');
+$fixture = sys_get_temp_dir() . '/editx-test-' . bin2hex(random_bytes(8));
+mkdir($fixture); mkdir($fixture . '/conf'); mkdir($fixture . '/data');
+foreach (glob(DOKU_INC . 'conf/*') as $file) {
+    if (is_file($file) && !in_array(basename($file), ['local.php', 'local.protected.php', 'acl.auth.php', 'users.auth.php'])) {
+        copy($file, $fixture . '/conf/' . basename($file));
+    }
+}
+file_put_contents($fixture . '/conf/local.php', '<?php $conf["savedir"] = ' . var_export($fixture . '/data', true) . '; $conf["useacl"] = 1; $conf["superuser"] = ""; $conf["notify"] = ""; $conf["subscribers"] = 0; $conf["send404"] = 0;');
+file_put_contents($fixture . '/conf/users.auth.php', "<?php // test fixture\n");
+file_put_contents($fixture . '/conf/acl.auth.php', "*\t at test\t16\nrestricted:*\t at test\t2\nreadonly:*\t at test\t1\n");
+foreach (['attic', 'cache', 'index', 'log', 'locks', 'media', 'media_attic', 'media_meta', 'meta', 'pages', 'tmp'] as $dir) mkdir($fixture . '/data/' . $dir);
+define('DOKU_CONF', $fixture . '/conf/'); define('NOSESSION', 1); define('DOKU_E_LEVEL', E_ALL);
+$_SERVER['REMOTE_ADDR'] = '127.0.0.1'; $_SERVER['HTTP_HOST'] = 'localhost'; $_SERVER['SERVER_PORT'] = '80';
+$_SERVER['SCRIPT_NAME'] = '/doku.php';
+session_id('editx-regression');
+require DOKU_INC . 'inc/init.php';
+$auth = plugin_load('auth', 'authplain'); $AUTH_ACL = auth_loadACL();
+$_SERVER['REMOTE_USER'] = 'tester'; $USERINFO = ['grps' => ['test']];
+$ID = 'start'; $REV = ''; $INFO = ['exists' => true];
+set_error_handler(function ($level, $message, $file, $line) {
+    if (error_reporting() & $level) throw new ErrorException($message, 0, $level, $file, $line);
+});
+require_once DOKU_PLUGIN . 'editx/action.php';
+function expect($condition, $message) { if (!$condition) throw new RuntimeException($message); }
+function seed($id, $text = "Original content\n") { saveWikiText($id, $text, 'fixture'); clearstatcache(); }
+function request($post = [], $get = []) {
+    global $ID, $INPUT, $MSG;
+    $_POST = $post; $_GET = $get; $_REQUEST = array_merge($get, $post);
+    $INPUT = new dokuwiki\Input\Input(); $MSG = [];
+    $ID = $post['oldpage'] ?? $get['oldpage'] ?? 'start';
+    $action = 'editx'; $event = new Doku_Event('TPL_ACT_UNKNOWN', $action);
+    $plugin = new action_plugin_editx();
+    ob_start();
+    try { $plugin->_handle_tpl_act($event, null); return ob_get_contents(); }
+    finally { ob_end_clean(); }
+}
+function renameRequest($old, $new, $extra = []) {
+    return request($extra + ['work' => 'rename', 'oldpage' => $old, 'newpage' => $new, 'rp_confirm' => '1', 'sectok' => getSecurityToken()]);
+}
+function deleteRequest($old, $extra = []) {
+    return request($extra + ['work' => 'delete', 'oldpage' => $old, 'dp_confirm' => '1', 'sectok' => getSecurityToken()]);
+}
+$tests = [];
+$tests['empty form and hooks'] = function () {
+    $plugin = new action_plugin_editx(); $handler = new Doku_Event_Handler(); $plugin->register($handler);
+    $html = request(); expect(substr_count($html, 'name="sectok"') == 2, 'both forms need CSRF tokens');
+};
+$tests['rename with redirect and history'] = function () {
+    seed('source'); io_saveFile(wikiFN('source', 1500000000), 'old revision');
+    renameRequest('source', 'dest:target');
+    expect(rawWiki('dest:target') === "Original content\n", 'target content');
+    expect(str_contains(rawWiki('source'), 'dest:target'), 'source redirect');
+    expect(io_readFile(wikiFN('dest:target', 1500000000)) === 'old revision', 'history transferred');
+};
+$tests['rename without redirect'] = function () {
+    seed('noredirect'); renameRequest('noredirect', 'renamed', ['rp_nr' => '1']);
+    expect(!page_exists('noredirect') && page_exists('renamed'), 'rename without redirect');
+};
+$tests['delete with and without deletion log'] = function () {
+    foreach (['keepdelete' => '0', 'purgedelete' => '1'] as $id => $purge) {
+        seed($id); io_saveFile(wikiFN($id, 1500000000), 'history');
+        deleteRequest($id, ['dp_purge' => $purge]);
+        expect(!page_exists($id), 'page removed'); expect(!file_exists(wikiFN($id, 1500000000)), 'attic removed');
+        expect(file_exists(metaFN($id, '.changes')) === ($purge === '0'), 'deletion log policy');
+    }
+};
+$tests['confirmation and optional inputs'] = function () {
+    seed('unconfirmed'); renameRequest('unconfirmed', 'unconfirmedtarget', ['rp_confirm' => '']);
+    deleteRequest('unconfirmed', ['dp_confirm' => '']);
+    expect(page_exists('unconfirmed') && !page_exists('unconfirmedtarget'), 'confirmation required');
+    request(['work' => ['rename'], 'summary' => ['invalid']]);
+};
+$tests['CSRF and GET do not mutate'] = function () {
+    seed('csrf');
+    foreach (['', 'invalid'] as $token) {
+        renameRequest('csrf', 'csrftarget', ['sectok' => $token]); deleteRequest('csrf', ['sectok' => $token]);
+    }
+    request([], ['work' => 'delete', 'oldpage' => 'csrf', 'dp_confirm' => '1', 'sectok' => getSecurityToken()]);
+    expect(page_exists('csrf') && !page_exists('csrftarget'), 'CSRF/GET rejected');
+};
+$tests['HTML attributes escaped'] = function () {
+    seed('xss'); $payload = '\"><script>alert(1)</script>';
+    $html = renameRequest('xss', 'target', ['summary' => $payload, 'rp_confirm' => '']);
+    expect(!str_contains($html, '<script>') && str_contains($html, hsc($payload)), 'rename summary escaped');
+    $html = deleteRequest('xss', ['summary' => $payload, 'dp_confirm' => '']);
+    expect(!str_contains($html, '<script>') && str_contains($html, hsc($payload)), 'delete summary escaped');
+};
+$tests['create and delete ACLs'] = function () {
+    seed('acltest'); renameRequest('acltest', 'restricted:target');
+    expect(!page_exists('restricted:target') && rawWiki('acltest') === "Original content\n", 'AUTH_CREATE required');
+    seed('readonly:page'); deleteRequest('readonly:page');
+    expect(page_exists('readonly:page'), 'delete ACL enforced');
+};
+$tests['locked pages'] = function () {
+    seed('locked'); io_saveFile(wikiLockFN('locked'), 'someone-else');
+    deleteRequest('locked'); renameRequest('locked', 'lockedtarget');
+    expect(page_exists('locked') && !page_exists('lockedtarget'), 'locked source protected');
+};
+$tests['history names and regex metacharacters'] = function () {
+    seed('txt'); io_saveFile(wikiFN('txt', 1500000000), 'history'); renameRequest('txt', 'newname');
+    expect(io_readFile(wikiFN('newname', 1500000000)) === 'history', 'extension not renamed');
+    seed('a.b'); seed('axb'); io_saveFile(wikiFN('a.b', 1500000000), 'own'); io_saveFile(wikiFN('axb', 1500000000), 'neighbor');
+    deleteRequest('a.b'); expect(page_exists('axb') && io_readFile(wikiFN('axb', 1500000000)) === 'neighbor', 'literal page filename matching');
+};
+$tests['conflicting history aborts rename'] = function () {
+    seed('conflict'); io_saveFile(wikiFN('conflict', 1500000000), 'source history'); io_saveFile(wikiFN('occupied', 1500000000), 'target history');
+    renameRequest('conflict', 'occupied');
+    expect(rawWiki('conflict') === "Original content\n" && !page_exists('occupied'), 'conflict preserves source');
+    expect(io_readFile(wikiFN('occupied', 1500000000)) === 'target history', 'conflict preserves target history');
+};
+$tests['move failure preserves source and history'] = function () {
+    global $conf, $MSG;
+    seed('movefail'); io_saveFile(wikiFN('movefail', 1500000000), 'history');
+    $dir = $conf['olddir'] . '/blocked'; mkdir($dir); chmod($dir, 0500);
+    try {
+        renameRequest('movefail', 'blocked:target', ['rp_nr' => '1']);
+        expect(rawWiki('movefail') === "Original content\n" && !page_exists('blocked:target'), 'failed move preserves source');
+        expect(io_readFile(wikiFN('movefail', 1500000000)) === 'history', 'failed move preserves history');
+        expect(file_exists(metaFN('movefail', '.changes')), 'earlier metadata move rolled back');
+        expect(!array_filter($MSG, fn($msg) => $msg['lvl'] === 1), 'failure must not report success');
+    } finally { chmod($dir, 0700); }
+};
+$tests['cancelled save preserves source and history'] = function () {
+    global $EVENT_HANDLER;
+    seed('cancelled'); io_saveFile(wikiFN('cancelled', 1500000000), 'history');
+    $hook = new class {
+        function cancel($event, $param) { if ($event->data['id'] === 'cancelledtarget') $event->preventDefault(); }
+    };
+    $EVENT_HANDLER->register_hook('COMMON_WIKIPAGE_SAVE', 'BEFORE', $hook, 'cancel');
+    renameRequest('cancelled', 'cancelledtarget', ['rp_nr' => '1']);
+    expect(rawWiki('cancelled') === "Original content\n" && !page_exists('cancelledtarget'), 'cancelled target save preserves source');
+    expect(io_readFile(wikiFN('cancelled', 1500000000)) === 'history', 'cancelled save restores history');
+};
+$tests['delete failure is reported'] = function () {
+    global $conf, $MSG;
+    seed('deletefail:page'); $dir = dirname(wikiFN('deletefail:page')); chmod($dir, 0500);
+    try {
+        deleteRequest('deletefail:page');
+        expect(page_exists('deletefail:page'), 'unwritable page retained');
+        expect(file_exists(metaFN('deletefail:page', '.changes')), 'history retained on delete failure');
+        expect(!array_filter($MSG, fn($msg) => $msg['lvl'] === 1), 'failed delete must not report success');
+    } finally { chmod($dir, 0700); }
+};
+$tests['real revision remains readable after rename'] = function () {
+    seed('revision', "First version\n"); $rev = filemtime(wikiFN('revision'));
+    while (time() <= $rev) usleep(100000);
+    seed('revision', "Second version\n");
+    renameRequest('revision', 'revisiontarget', ['rp_nr' => '1']);
+    expect(rawWiki('revisiontarget', $rev) === "First version\n", 'DokuWiki reads renamed revision');
+    expect(rawWiki('revisiontarget') === "Second version\n", 'latest revision retained');
+};
+$tests['rename dotted page preserves neighboring history'] = function () {
+    seed('c.d'); seed('cxd'); io_saveFile(wikiFN('cxd', 1500000000), 'neighbor');
+    renameRequest('c.d', 'dotrenamed', ['rp_nr' => '1']);
+    expect(io_readFile(wikiFN('cxd', 1500000000)) === 'neighbor', 'neighbor history must not be moved');
+};
+$tests['missing and existing page validation'] = function () {
+    renameRequest('missing', 'missingtarget'); expect(!page_exists('missingtarget'), 'missing source rejected');
+    seed('existingsource'); seed('existingtarget', 'target content');
+    renameRequest('existingsource', 'existingtarget');
+    expect(rawWiki('existingtarget') === 'target content', 'existing destination preserved');
+};
+$tests['anonymous membership check'] = function () {
+    global $USERINFO;
+    $saved = $USERINFO; unset($_SERVER['REMOTE_USER']); $USERINFO = null;
+    try { expect(!(new action_plugin_editx())->_auth_check_list('@admin'), 'anonymous user not privileged'); }
+    finally { $_SERVER['REMOTE_USER'] = 'tester'; $USERINFO = $saved; }
+};
+$failures = 0;
+foreach ($tests as $name => $test) {
+    try { $test(); fwrite(STDOUT, "PASS $name\n"); }
+    catch (Throwable $error) { $failures++; fwrite(STDERR, "FAIL $name: $error\n"); }
+}
+fwrite(STDOUT, count($tests) . " tests, $failures failures; fixture: $fixture\n");
+exit($failures ? 1 : 0);
================================================================

---- gitweb:

http://git.pld-linux.org/gitweb.cgi/packages/dokuwiki-plugin-editx.git/commitdiff/f4f3a28958d14e10f0e7964790e6e6bc3c6b023e



More information about the pld-cvs-commit mailing list