Test Failed
Push — master ( 7e3eb0...d1d333 )
by Andreas
32:35
created

midcom_services_rcs_backend_rcs::rcs_exec()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 14
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 3

Importance

Changes 0
Metric Value
cc 3
eloc 9
c 0
b 0
f 0
nc 3
nop 2
dl 0
loc 14
ccs 6
cts 6
cp 1
crap 3
rs 9.9666
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
     * GUID of the current object
16
     */
17
    private $_guid;
18
19
    /**
20
     * Cached revision history for the object
21
     */
22
    private $_history;
23
24
    /**
25
     * @var midcom_services_rcs_config
26
     */
27
    private $_config;
28
29 82
    public function __construct($object, midcom_services_rcs_config $config)
30
    {
31 82
        $this->_config = $config;
32 82
        $this->_guid = $object->guid;
33 82
    }
34
35 80
    private function _generate_rcs_filename(string $guid) : string
36
    {
37
        // Keep files organized to subfolders to keep filesystem sane
38 80
        $dirpath = $this->_config->get_rcs_root() . "/{$guid[0]}/{$guid[1]}";
39 80
        if (!file_exists($dirpath)) {
40 53
            debug_add("Directory {$dirpath} does not exist, attempting to create", MIDCOM_LOG_INFO);
41 53
            mkdir($dirpath, 0777, true);
42
        }
43 80
        return "{$dirpath}/{$guid}";
44
    }
45
46
    /**
47
     * Save a new revision
48
     *
49
     * @param object $object object to be saved
50
     * @return boolean true on success.
51
     */
52 74
    public function update($object, $updatemessage = null) : bool
53
    {
54
        // Store user identifier and IP address to the update string
55 74
        if (midcom::get()->auth->user) {
56 39
            $update_string = midcom::get()->auth->user->id . "|{$_SERVER['REMOTE_ADDR']}";
57
        } else {
58 35
            $update_string = "NOBODY|{$_SERVER['REMOTE_ADDR']}";
59
        }
60
61 74
        $update_string .= "|{$updatemessage}";
62
63 74
        $result = $this->rcs_update($object, $update_string);
64
65
        // The methods return basically what the RCS unix level command returns, so nonzero value is error and zero is ok...
66 74
        return $result == 0;
67
    }
68
69
    /**
70
     * Get the object of a revision
71
     *
72
     * @param string $revision identifier of revision wanted
73
     * @return array array representation of the object
74
     */
75
    public function get_revision($revision) : array
76
    {
77
        if (empty($this->_guid)) {
78
            return [];
79
        }
80
        $filepath = $this->_generate_rcs_filename($this->_guid);
81 74
        if ($this->exec('co -q -f -r' . escapeshellarg(trim($revision)) . " {$filepath} 2>/dev/null") != 0) {
82
            return [];
83 74
        }
84
85
        $data = $this->rcs_readfile($this->_guid);
86
87
        $mapper = new midcom_helper_exporter_xml();
88 74
        $revision = $mapper->data2array($data);
89 74
90
        $this->exec("rm -f {$filepath}", false);
91 74
92
        return $revision;
93 64
    }
94
95
    /**
96 37
     * Check if a revision exists
97
     *
98 37
     * @param string $version
99
     */
100 37
    public function version_exists($version) : bool
101 37
    {
102
        $history = $this->list_history();
103 37
        return array_key_exists($version, $history);
104
    }
105 37
106
    /**
107
     * Get the previous versionID
108
     *
109
     * @param string $version
110
     * @return string versionid before this one or empty string.
111
     */
112
    public function get_prev_version($version)
113
    {
114 4
        $versions = $this->list_history_numeric();
115
        $position = array_search($version, $versions);
116 4
117
        if ($position === false || $position == count($versions) - 1) {
118
            return '';
119 4
        }
120 4
        return $versions[$position + 1];
121
    }
122
123
    /**
124 4
     * Get the next versionID
125
     *
126 4
     * @param string $version
127 4
     * @return string versionid before this one or empty string.
128
     */
129 4
    public function get_next_version($version)
130
    {
131 4
        $versions = $this->list_history_numeric();
132
        $position = array_search($version, $versions);
133
134
        if ($position === false || $position == 0) {
135
            return '';
136
        }
137
        return $versions[$position - 1];
138
    }
139 2
140
    /**
141 2
     * Return a list of the revisions as a key => value pair where
142 2
     * the key is the index of the revision and the value is the revision id.
143
     * Order: revision 0 is the newest.
144
     */
145
    public function list_history_numeric() : array
146
    {
147
        $revs = $this->list_history();
148
        return array_keys($revs);
149
    }
150
151 4
    /**
152
     * Lists the number of changes that has been done to the object
153 4
     *
154 4
     * @return array list of changeids
155
     */
156 4
    public function list_history() : array
157 2
    {
158
        if (empty($this->_guid)) {
159 2
            return [];
160
        }
161
162
        if ($this->_history === null) {
163
            $filepath = $this->_generate_rcs_filename($this->_guid);
164
            $this->_history = $this->rcs_gethistory($filepath);
165
        }
166
167
        return $this->_history;
168 4
    }
169
170 4
    /* it is debatable to move this into the object when it resides nicely in a library... */
171 4
172
    private function rcs_parse_history_entry(array $entry) : array
173 4
    {
174 2
        // Create the empty history array
175
        $history = [
176 2
            'revision' => null,
177
            'date'     => null,
178
            'lines'    => null,
179
            'user'     => null,
180
            'ip'       => null,
181
            'message'  => null,
182
        ];
183
184 4
        // Revision number is in format
185
        // revision 1.11
186 4
        $history['revision'] = substr($entry[0], 9);
187 4
188
        // Entry metadata is in format
189
        // date: 2006/01/10 09:40:49;  author: www-data;  state: Exp;  lines: +2 -2
190
        // NOTE: Time here appears to be stored as UTC according to http://parand.com/docs/rcs.html
191
        $metadata_array = explode(';', $entry[1]);
192
        foreach ($metadata_array as $metadata) {
193
            $metadata = trim($metadata);
194
            if (substr($metadata, 0, 5) == 'date:') {
195 7
                $history['date'] = strtotime(substr($metadata, 6));
196
            } elseif (substr($metadata, 0, 6) == 'lines:') {
197 7
                $history['lines'] = substr($metadata, 7);
198
            }
199
        }
200
201 7
        // Entry message is in format
202 7
        // user:27b841929d1e04118d53dd0a45e4b93a|84.34.133.194|message
203 7
        $message_array = explode('|', $entry[2]);
204
        if (count($message_array) == 1) {
205
            $history['message'] = $message_array[0];
206 7
        } else {
207
            if ($message_array[0] != 'Object') {
208
                $history['user'] = $message_array[0];
209
            }
210
            $history['ip'] = $message_array[1];
211 7
            $history['message'] = $message_array[2];
212
        }
213
        return $history;
214
    }
215 7
216
    /*
217
     * the functions below are mostly rcs functions moved into the class. Someday I'll get rid of the
218
     * old files...
219
     */
220
    /**
221
     * Get a list of the object's history
222
     *
223
     * @param string $what objectid (usually the guid)
224
     */
225 7
    private function rcs_gethistory(string $what) : array
226
    {
227
        $history = $this->rcs_exec('rlog', $what . ',v');
228
        $revisions = [];
229
        $lines = explode("\n", $history);
230 7
        $total = count($lines);
231 7
232 7
        for ($i = 0; $i < $total; $i++) {
233 7
            if (substr($lines[$i], 0, 9) == "revision ") {
234 7
                $history_entry = [$lines[$i], $lines[$i + 1], $lines[$i + 2]];
235 7
                $history = $this->rcs_parse_history_entry($history_entry);
236 6
237
                $revisions[$history['revision']] = $history;
238
239
                $i += 3;
240
241
                while (   $i < $total
242 7
                       && substr($lines[$i], 0, 4) != '----'
243 7
                       && substr($lines[$i], 0, 5) != '=====') {
244
                    $i++;
245
                }
246 7
            }
247 7
        }
248
        return $revisions;
249 7
    }
250 7
251
    /**
252 7
     * execute a command
253
     *
254
     * @param string $command The command to execute
255
     * @param string $filename The file to operate on
256
     * @return string command result.
257
     */
258
    private function rcs_exec(string $command, string $filename) : string
259
    {
260
        if (!is_readable($filename)) {
261
            debug_add('file ' . $filename . ' is not readable, returning empty result', MIDCOM_LOG_INFO);
262
            return '';
263
        }
264 7
        $fh = popen($this->_config->get_bin_prefix() . $command . ' "' . $filename . '" 2>&1', "r");
265
        $ret = "";
266 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

266
        while ($reta = fgets(/** @scrutinizer ignore-type */ $fh, 1024)) {
Loading history...
267 7
            $ret .= $reta;
268 7
        }
269 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

269
        pclose(/** @scrutinizer ignore-type */ $fh);
Loading history...
270
271 7
        return $ret;
272 7
    }
273 7
274 7
    /**
275
     * Update object to RCS
276 7
     * Should be called just before $object->update()
277
     *
278 7
     * @param object $object to be updated.
279
     * @param string $message
280 7
     * @return int :
281 7
     *      0 on success
282 7
     *      3 on missing object->guid
283
     *      nonzero on error in one of the commands.
284
     */
285
    private function rcs_update(midcom_core_dbaobject $object, $message)
286
    {
287 7
        if (empty($object->guid)) {
288
            debug_add("Missing GUID, returning error");
289
            return 3;
290
        }
291
292
        $filename = $this->_generate_rcs_filename($object->guid);
293
        $rcsfilename = "{$filename},v";
294
        $message = escapeshellarg($message);
295
296
        if (!file_exists($rcsfilename)) {
297 7
            $this->rcs_writefile($object);
298
            $filepath = $this->_generate_rcs_filename($object->guid);
299 7
            return $this->exec('ci -q -i -t-' . $message . ' -m' . $message . " {$filepath}");
300 3
        }
301 3
302
        $this->exec('co -q -f -l ' . escapeshellarg($filename));
303 7
        $this->rcs_writefile($object);
304 7
        return $this->exec('ci -q -m' . $message . " {$filename}");
305 7
    }
306 7
307
    /**
308 7
     * Writes object data to file, does not return anything.
309
     */
310 7
    private function rcs_writefile($object)
311
    {
312
        if (   !is_writable($this->_config->get_rcs_root())
313
            || empty($object->guid)) {
314
            return;
315
        }
316 74
        $data = $this->rcs_object2data($object);
317
        $filename = $this->_generate_rcs_filename($object->guid);
318 74
        file_put_contents($filename, $data);
319 74
        chmod($filename . ',v', 0770);
320
    }
321
322 74
    /**
323 74
     * Reads data from file $guid and returns it.
324 74
     *
325
     * @param string $guid
326
     * @return string xml representation of guid
327
     */
328
    private function rcs_readfile(string $guid)
329
    {
330
        $filename = $this->_generate_rcs_filename($guid);
331
        if (file_exists($filename)) {
332 4
            return file_get_contents($filename);
333
        }
334 4
        return '';
335 4
    }
336 4
337
    /**
338
     * Make xml out of an object.
339
     *
340
     * @param midcom_core_dbaobject $object
341
     */
342
    private function rcs_object2data(midcom_core_dbaobject $object) : string
343
    {
344
        $mapper = new midcom_helper_exporter_xml();
345
        return $mapper->object2data($object);
346 74
    }
347
348 74
    private function exec(string $command, $use_rcs_bindir = true)
349 74
    {
350
        $status = null;
351
        $output = null;
352
353
        // Always append stderr redirect
354
        $command .= ' 2>&1';
355
356
        if ($use_rcs_bindir) {
357
            $command = $this->_config->get_bin_prefix() . $command;
358
        }
359
360
        debug_add("Executing '{$command}'");
361
362 64
        try {
363
            @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

363
            /** @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...
364 64
        } catch (Exception $e) {
365
            debug_add($e->getMessage());
366 64
        }
367
368
        if ($status !== 0) {
369 64
            debug_add("Command '{$command}' returned with status {$status}, see debug log for output", MIDCOM_LOG_WARN);
370 64
            debug_print_r('Got output: ', $output);
371 64
        }
372
        return $status;
373 64
    }
374
375 64
    /**
376 64
     * Get a html diff between two versions.
377 64
     *
378
     * @param string $oldest_revision id of the oldest revision
379 64
     * @param string $latest_revision id of the latest revision
380
     */
381
    public function get_diff($oldest_revision, $latest_revision) : array
382 78
    {
383
        $oldest = $this->get_revision($oldest_revision);
384 78
        $newest = $this->get_revision($latest_revision);
385 78
386
        $return = [];
387
        $oldest = array_intersect_key($oldest, $newest);
388 78
389
        $repl = [
390 78
            '<del>' => "<span class=\"deleted\">",
391 78
            '</del>' => '</span>',
392
            '<ins>' => "<span class=\"inserted\">",
393
            '</ins>' => '</span>'
394 78
        ];
395
        foreach ($oldest as $attribute => $oldest_value) {
396
            if (is_array($oldest_value)) {
397 78
                continue;
398
            }
399
400
            $return[$attribute] = [
401
                'old' => $oldest_value,
402 78
                'new' => $newest[$attribute]
403
            ];
404
405
            if ($oldest_value != $newest[$attribute]) {
406 78
                $lines1 = explode("\n", $oldest_value);
407
                $lines2 = explode("\n", $newest[$attribute]);
408
409
                $renderer = new midcom_services_rcs_renderer_html_sidebyside(['old' => $oldest_revision, 'new' => $latest_revision]);
410
411
                if ($lines1 != $lines2) {
412
                    $diff = new Diff($lines1, $lines2);
413
                    // Run the diff
414
                    $return[$attribute]['diff'] = $diff->render($renderer);
415 2
                    // Modify the output for nicer rendering
416
                    $return[$attribute]['diff'] = strtr($return[$attribute]['diff'], $repl);
417 2
                }
418 2
            }
419
        }
420 2
421 2
        return $return;
422
    }
423
424 2
    /**
425
     * Get the comment of one revision.
426
     *
427
     * @param string $revision id
428
     * @return string comment
429 2
     */
430 2
    public function get_comment($revision)
431 2
    {
432
        $this->list_history();
433
        return $this->_history[$revision];
434 2
    }
435 2
436 2
    /**
437
     * Restore an object to a certain revision.
438
     *
439 2
     * @param string $revision of revision to restore object to.
440 2
     * @return boolean true on success.
441 2
     */
442
    public function restore_to_revision($revision) : bool
443 2
    {
444
        $new = $this->get_revision($revision);
445 2
446 2
        try {
447
            $object = midcom::get()->dbfactory->get_object_by_guid($this->_guid);
448 2
        } catch (midcom_error $e) {
449
            debug_add("{$this->_guid} could not be resolved to object", MIDCOM_LOG_ERROR);
450 2
            return false;
451
        }
452
        $mapper = new midcom_helper_exporter_xml();
453
        $object = $mapper->data2object($new, $object);
454
455 2
        $object->set_rcs_message("Reverted to revision {$revision}");
456
457
        return $object->update();
458
    }
459
}
460