Completed
Push — master ( d8dd6e...85e35e )
by Andreas
24:59
created

midcom_services_rcs_backend_rcs   A

Complexity

Total Complexity 36

Size/Duplication

Total Lines 300
Duplicated Lines 0 %

Test Coverage

Coverage 89.6%

Importance

Changes 5
Bugs 0 Features 0
Metric Value
eloc 130
dl 0
loc 300
ccs 112
cts 125
cp 0.896
rs 9.52
c 5
b 0
f 0
wmc 36

11 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 4 1
A _generate_rcs_filename() 0 9 2
B rcs_parse_history_entry() 0 42 6
A restore_to_revision() 0 8 1
A get_revision() 0 15 3
A rcs_gethistory() 0 24 6
A exec() 0 25 4
A rcs_exec() 0 14 3
A get_history() 0 8 2
A update() 0 26 3
A get_diff() 0 41 5
1
<?php
2
/**
3
 * @author tarjei huse
4
 * @package midcom.services.rcs
5
 * @copyright The Midgard Project, http://www.midgard-project.org
6
 * @license http://www.gnu.org/licenses/lgpl.html GNU Lesser General Public License
7
 */
8
9
/**
10
 * @package midcom.services.rcs
11
 */
12
class midcom_services_rcs_backend_rcs implements midcom_services_rcs_backend
13
{
14
    /**
15
     * The current object
16
     */
17
    private $object;
18
19
    /**
20
     * Cached revision history for the object
21
     *
22
     * @var midcom_services_rcs_history
23
     */
24
    private $history;
25
26
    /**
27
     * @var midcom_services_rcs_config
28
     */
29
    private $_config;
30
31 81
    public function __construct($object, midcom_services_rcs_config $config)
32
    {
33 81
        $this->_config = $config;
34 81
        $this->object = $object;
35 81
    }
36
37 80
    private function _generate_rcs_filename(string $guid) : string
38
    {
39
        // Keep files organized to subfolders to keep filesystem sane
40 80
        $dirpath = $this->_config->get_rcs_root() . "/{$guid[0]}/{$guid[1]}";
41 80
        if (!file_exists($dirpath)) {
42 59
            debug_add("Directory {$dirpath} does not exist, attempting to create", MIDCOM_LOG_INFO);
43 59
            mkdir($dirpath, 0777, true);
44
        }
45 80
        return "{$dirpath}/{$guid}";
46
    }
47
48
    /**
49
     * Save a new revision
50
     */
51 74
    public function update($updatemessage = null) : bool
52
    {
53
        // Store user identifier and IP address to the update string
54 74
        $message = $_SERVER['REMOTE_ADDR'] . '|' . $updatemessage;
55 74
        $message = (midcom::get()->auth->user->id ?? 'NOBODY') . '|' . $message;
56
57 74
        $filename = $this->_generate_rcs_filename($this->object->guid);
58 74
        $rcsfilename = "{$filename},v";
59 74
        $message = escapeshellarg($message);
60
61 74
        if (file_exists($rcsfilename)) {
62 37
            $this->exec('co -q -f -l ' . escapeshellarg($filename));
63 37
            $command = 'ci -q -m' . $message . " {$filename}";
64
        } else {
65 64
            $command = 'ci -q -i -t-' . $message . ' -m' . $message . " {$filename}";
66
        }
67 74
        $mapper = new midcom_helper_exporter_xml;
68 74
        file_put_contents($filename, $mapper->object2data($this->object));
69 74
        $stat = $this->exec($command);
70
71 74
        if (file_exists($rcsfilename)) {
72 74
            chmod($rcsfilename, 0770);
73
        }
74
75
        // The methods return basically what the RCS unix level command returns, so nonzero value is error and zero is ok...
76 74
        return $stat == 0;
77
    }
78
79
    /**
80
     * Get the object of a revision
81
     *
82
     * @param string $revision identifier of revision wanted
83
     * @return array array representation of the object
84
     */
85 4
    public function get_revision($revision) : array
86
    {
87 4
        $filepath = $this->_generate_rcs_filename($this->object->guid);
88 4
        if ($this->exec('co -q -f -r' . escapeshellarg(trim($revision)) . " {$filepath} 2>/dev/null") != 0) {
89
            return [];
90
        }
91
92 4
        $data = (file_exists($filepath)) ? file_get_contents($filepath) : '';
93
94 4
        $mapper = new midcom_helper_exporter_xml();
95 4
        $revision = $mapper->data2array($data);
96
97 4
        $this->exec("rm -f {$filepath}", false);
98
99 4
        return $revision;
100
    }
101
102
    /**
103
     * Lists the number of changes that has been done to the object
104
     * Order: The first entry is the newest.
105
     *
106
     * @return array list of changeids
107
     */
108 7
    public function get_history() : ?midcom_services_rcs_history
109
    {
110 7
        if ($this->history === null) {
111 7
            $filepath = $this->_generate_rcs_filename($this->object->guid);
112 7
            $this->history = $this->rcs_gethistory($filepath);
113
        }
114
115 7
        return $this->history;
116
    }
117
118
    /* it is debatable to move this into the object when it resides nicely in a library... */
119
120 7
    private function rcs_parse_history_entry(array $entry) : array
121
    {
122
        // Create the empty history array
123
        $history = [
124 7
            'revision' => null,
125
            'date'     => null,
126
            'lines'    => null,
127
            'user'     => null,
128
            'ip'       => null,
129
            'message'  => null,
130
        ];
131
132
        // Revision number is in format
133
        // revision 1.11
134 7
        $history['revision'] = preg_replace('/(\d+\.\d+).*/', '$1', substr($entry[0], 9));
135
136
        // Entry metadata is in format
137
        // date: 2006/01/10 09:40:49;  author: www-data;  state: Exp;  lines: +2 -2
138
        // NOTE: Time here appears to be stored as UTC according to http://parand.com/docs/rcs.html
139 7
        $metadata_array = explode(';', $entry[1]);
140 7
        foreach ($metadata_array as $metadata) {
141 7
            $metadata = trim($metadata);
142 7
            if (substr($metadata, 0, 5) == 'date:') {
143 7
                $history['date'] = strtotime(substr($metadata, 6));
144 7
            } elseif (substr($metadata, 0, 6) == 'lines:') {
145 6
                $history['lines'] = substr($metadata, 7);
146
            }
147
        }
148
149
        // Entry message is in format
150
        // user:27b841929d1e04118d53dd0a45e4b93a|84.34.133.194|message
151 7
        $message_array = explode('|', $entry[2]);
152 7
        if (count($message_array) == 1) {
153
            $history['message'] = $message_array[0];
154
        } else {
155 7
            if ($message_array[0] != 'Object') {
156 7
                $history['user'] = $message_array[0];
157
            }
158 7
            $history['ip'] = $message_array[1];
159 7
            $history['message'] = $message_array[2];
160
        }
161 7
        return $history;
162
    }
163
164
    /*
165
     * the functions below are mostly rcs functions moved into the class. Someday I'll get rid of the
166
     * old files...
167
     */
168
    /**
169
     * Get a list of the object's history
170
     *
171
     * @param string $what objectid (usually the guid)
172
     */
173 7
    private function rcs_gethistory(string $what) : midcom_services_rcs_history
174
    {
175 7
        $history = $this->rcs_exec('rlog', $what . ',v');
176 7
        $revisions = [];
177 7
        $lines = explode("\n", $history);
178 7
        $total = count($lines);
179
180 7
        for ($i = 0; $i < $total; $i++) {
181 7
            if (substr($lines[$i], 0, 9) == "revision ") {
182 7
                $history_entry = [$lines[$i], $lines[$i + 1], $lines[$i + 2]];
183 7
                $history = $this->rcs_parse_history_entry($history_entry);
184
185 7
                $revisions[$history['revision']] = $history;
186
187 7
                $i += 3;
188
189 7
                while (   $i < $total
190 7
                       && substr($lines[$i], 0, 4) != '----'
191 7
                       && substr($lines[$i], 0, 5) != '=====') {
192
                    $i++;
193
                }
194
            }
195
        }
196 7
        return new midcom_services_rcs_history($revisions);
197
    }
198
199
    /**
200
     * execute a command
201
     *
202
     * @param string $command The command to execute
203
     * @param string $filename The file to operate on
204
     * @return string command result.
205
     */
206 7
    private function rcs_exec(string $command, string $filename) : string
207
    {
208 7
        if (!is_readable($filename)) {
209 3
            debug_add('file ' . $filename . ' is not readable, returning empty result', MIDCOM_LOG_INFO);
210 3
            return '';
211
        }
212 7
        $fh = popen($this->_config->get_bin_prefix() . $command . ' "' . $filename . '" 2>&1', "r");
213 7
        $ret = "";
214 7
        while ($reta = fgets($fh, 1024)) {
0 ignored issues
show
Bug introduced by
It seems like $fh can also be of type false; however, parameter $handle of fgets() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

214
        while ($reta = fgets(/** @scrutinizer ignore-type */ $fh, 1024)) {
Loading history...
215 7
            $ret .= $reta;
216
        }
217 7
        pclose($fh);
0 ignored issues
show
Bug introduced by
It seems like $fh can also be of type false; however, parameter $handle of pclose() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

217
        pclose(/** @scrutinizer ignore-type */ $fh);
Loading history...
218
219 7
        return $ret;
220
    }
221
222 78
    private function exec(string $command, $use_rcs_bindir = true)
223
    {
224 78
        $status = null;
225 78
        $output = null;
226
227
        // Always append stderr redirect
228 78
        $command .= ' 2>&1';
229
230 78
        if ($use_rcs_bindir) {
231 78
            $command = $this->_config->get_bin_prefix() . $command;
232
        }
233
234 78
        debug_add("Executing '{$command}'");
235
236
        try {
237 78
            @exec($command, $output, $status);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for exec(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

237
            /** @scrutinizer ignore-unhandled */ @exec($command, $output, $status);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
238
        } catch (Exception $e) {
239
            debug_add($e->getMessage());
240
        }
241
242 78
        if ($status !== 0) {
243
            debug_add("Command '{$command}' returned with status {$status}, see debug log for output", MIDCOM_LOG_WARN);
244
            debug_print_r('Got output: ', $output);
245
        }
246 78
        return $status;
247
    }
248
249
    /**
250
     * Get a html diff between two versions.
251
     *
252
     * @param string $oldest_revision id of the oldest revision
253
     * @param string $latest_revision id of the latest revision
254
     */
255 2
    public function get_diff($oldest_revision, $latest_revision) : array
256
    {
257 2
        $oldest = $this->get_revision($oldest_revision);
258 2
        $newest = $this->get_revision($latest_revision);
259
260 2
        $return = [];
261 2
        $oldest = array_intersect_key($oldest, $newest);
262
263
        $repl = [
264 2
            '<del>' => "<span class=\"deleted\">",
265
            '</del>' => '</span>',
266
            '<ins>' => "<span class=\"inserted\">",
267
            '</ins>' => '</span>'
268
        ];
269 2
        foreach ($oldest as $attribute => $oldest_value) {
270 2
            if (is_array($oldest_value)) {
271 2
                continue;
272
            }
273
274 2
            $return[$attribute] = [
275 2
                'old' => $oldest_value,
276 2
                'new' => $newest[$attribute]
277
            ];
278
279 2
            if ($oldest_value != $newest[$attribute]) {
280 2
                $lines1 = explode("\n", $oldest_value);
281 2
                $lines2 = explode("\n", $newest[$attribute]);
282
283 2
                $renderer = new midcom_services_rcs_renderer_html_sidebyside(['old' => $oldest_revision, 'new' => $latest_revision]);
284
285 2
                if ($lines1 != $lines2) {
286 2
                    $diff = new Diff($lines1, $lines2);
287
                    // Run the diff
288 2
                    $return[$attribute]['diff'] = $diff->render($renderer);
289
                    // Modify the output for nicer rendering
290 2
                    $return[$attribute]['diff'] = strtr($return[$attribute]['diff'], $repl);
291
                }
292
            }
293
        }
294
295 2
        return $return;
296
    }
297
298
    /**
299
     * Restore an object to a certain revision.
300
     *
301
     * @param string $revision of revision to restore object to.
302
     * @return boolean true on success.
303
     */
304
    public function restore_to_revision($revision) : bool
305
    {
306
        $new = $this->get_revision($revision);
307
        $mapper = new midcom_helper_exporter_xml();
308
        $this->object = $mapper->data2object($new, $this->object);
309
        $this->object->set_rcs_message("Reverted to revision {$revision}");
310
311
        return $this->object->update();
312
    }
313
}
314