Passed
Push — master ( d1d333...89223c )
by Andreas
31:15 queued 11:58
created

midcom_services_rcs_backend_rcs::rcs_gethistory()   A

Complexity

Conditions 6
Paths 3

Size

Total Lines 24
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 15
CRAP Score 6.0087

Importance

Changes 0
Metric Value
cc 6
eloc 15
nc 3
nop 1
dl 0
loc 24
ccs 15
cts 16
cp 0.9375
crap 6.0087
rs 9.2222
c 0
b 0
f 0
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 63
            debug_add("Directory {$dirpath} does not exist, attempting to create", MIDCOM_LOG_INFO);
41 63
            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 4
    public function get_revision($revision) : array
76
    {
77 4
        if (empty($this->_guid)) {
78
            return [];
79
        }
80 4
        $filepath = $this->_generate_rcs_filename($this->_guid);
81 4
        if ($this->exec('co -q -f -r' . escapeshellarg(trim($revision)) . " {$filepath} 2>/dev/null") != 0) {
82
            return [];
83
        }
84
85 4
        $data = (file_exists($filepath)) ? file_get_contents($filepath) : '';
86
87 4
        $mapper = new midcom_helper_exporter_xml();
88 4
        $revision = $mapper->data2array($data);
89
90 4
        $this->exec("rm -f {$filepath}", false);
91
92 4
        return $revision;
93
    }
94
95
    /**
96
     * Check if a revision exists
97
     *
98
     * @param string $version
99
     */
100 2
    public function version_exists($version) : bool
101
    {
102 2
        $history = $this->list_history();
103 2
        return array_key_exists($version, $history);
104
    }
105
106
    /**
107
     * Get the previous versionID
108
     *
109
     * @param string $version
110
     * @return string versionid before this one or empty string.
111
     */
112 4
    public function get_prev_version($version)
113
    {
114 4
        $versions = $this->list_history_numeric();
115 4
        $position = array_search($version, $versions);
116
117 4
        if ($position === false || $position == count($versions) - 1) {
118 2
            return '';
119
        }
120 2
        return $versions[$position + 1];
121
    }
122
123
    /**
124
     * Get the next versionID
125
     *
126
     * @param string $version
127
     * @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 4
        $position = array_search($version, $versions);
133
134 4
        if ($position === false || $position == 0) {
135 2
            return '';
136
        }
137 2
        return $versions[$position - 1];
138
    }
139
140
    /**
141
     * Return a list of the revisions as a key => value pair where
142
     * the key is the index of the revision and the value is the revision id.
143
     * Order: revision 0 is the newest.
144
     */
145 4
    public function list_history_numeric() : array
146
    {
147 4
        $revs = $this->list_history();
148 4
        return array_keys($revs);
149
    }
150
151
    /**
152
     * Lists the number of changes that has been done to the object
153
     *
154
     * @return array list of changeids
155
     */
156 7
    public function list_history() : array
157
    {
158 7
        if (empty($this->_guid)) {
159
            return [];
160
        }
161
162 7
        if ($this->_history === null) {
163 7
            $filepath = $this->_generate_rcs_filename($this->_guid);
164 7
            $this->_history = $this->rcs_gethistory($filepath);
165
        }
166
167 7
        return $this->_history;
168
    }
169
170
    /* it is debatable to move this into the object when it resides nicely in a library... */
171
172 7
    private function rcs_parse_history_entry(array $entry) : array
173
    {
174
        // Create the empty history array
175
        $history = [
176 7
            'revision' => null,
177
            'date'     => null,
178
            'lines'    => null,
179
            'user'     => null,
180
            'ip'       => null,
181
            'message'  => null,
182
        ];
183
184
        // Revision number is in format
185
        // revision 1.11
186 7
        $history['revision'] = substr($entry[0], 9);
187
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 7
        $metadata_array = explode(';', $entry[1]);
192 7
        foreach ($metadata_array as $metadata) {
193 7
            $metadata = trim($metadata);
194 7
            if (substr($metadata, 0, 5) == 'date:') {
195 7
                $history['date'] = strtotime(substr($metadata, 6));
196 7
            } elseif (substr($metadata, 0, 6) == 'lines:') {
197 6
                $history['lines'] = substr($metadata, 7);
198
            }
199
        }
200
201
        // Entry message is in format
202
        // user:27b841929d1e04118d53dd0a45e4b93a|84.34.133.194|message
203 7
        $message_array = explode('|', $entry[2]);
204 7
        if (count($message_array) == 1) {
205
            $history['message'] = $message_array[0];
206
        } else {
207 7
            if ($message_array[0] != 'Object') {
208 7
                $history['user'] = $message_array[0];
209
            }
210 7
            $history['ip'] = $message_array[1];
211 7
            $history['message'] = $message_array[2];
212
        }
213 7
        return $history;
214
    }
215
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 7
        $history = $this->rcs_exec('rlog', $what . ',v');
228 7
        $revisions = [];
229 7
        $lines = explode("\n", $history);
230 7
        $total = count($lines);
231
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
237 7
                $revisions[$history['revision']] = $history;
238
239 7
                $i += 3;
240
241 7
                while (   $i < $total
242 7
                       && substr($lines[$i], 0, 4) != '----'
243 7
                       && substr($lines[$i], 0, 5) != '=====') {
244
                    $i++;
245
                }
246
            }
247
        }
248 7
        return $revisions;
249
    }
250
251
    /**
252
     * 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 7
    private function rcs_exec(string $command, string $filename) : string
259
    {
260 7
        if (!is_readable($filename)) {
261 3
            debug_add('file ' . $filename . ' is not readable, returning empty result', MIDCOM_LOG_INFO);
262 3
            return '';
263
        }
264 7
        $fh = popen($this->_config->get_bin_prefix() . $command . ' "' . $filename . '" 2>&1', "r");
265 7
        $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
        }
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
    }
273
274
    /**
275
     * Update object to RCS
276
     * Should be called just before $object->update()
277
     *
278
     * @param object $object to be updated.
279
     * @param string $message
280
     * @return int :
281
     *      0 on success
282
     *      3 on missing object->guid
283
     *      nonzero on error in one of the commands.
284
     */
285 74
    private function rcs_update(midcom_core_dbaobject $object, $message)
286
    {
287 74
        if (empty($object->guid)) {
288
            debug_add("Missing GUID, returning error");
289
            return 3;
290
        }
291
292 74
        $filename = $this->_generate_rcs_filename($object->guid);
293 74
        $rcsfilename = "{$filename},v";
294 74
        $message = escapeshellarg($message);
295
296 74
        if (file_exists($rcsfilename)) {
297 37
            $this->exec('co -q -f -l ' . escapeshellarg($filename));
298 37
            $command = 'ci -q -m' . $message . " {$filename}";
299
        } else {
300 64
            $command = 'ci -q -i -t-' . $message . ' -m' . $message . " {$filename}";
301
        }
302 74
        if (is_writable($this->_config->get_rcs_root())) {
303 74
            file_put_contents($filename, $this->rcs_object2data($object));
304
        }
305 74
        $stat = $this->exec($command);
306
307 74
        if (file_exists($rcsfilename)) {
308 74
            chmod($rcsfilename, 0770);
309
        }
310
311 74
        return $stat;
312
    }
313
314
    /**
315
     * Make xml out of an object.
316
     *
317
     * @param midcom_core_dbaobject $object
318
     */
319 74
    private function rcs_object2data(midcom_core_dbaobject $object) : string
320
    {
321 74
        $mapper = new midcom_helper_exporter_xml();
322 74
        return $mapper->object2data($object);
323
    }
324
325 78
    private function exec(string $command, $use_rcs_bindir = true)
326
    {
327 78
        $status = null;
328 78
        $output = null;
329
330
        // Always append stderr redirect
331 78
        $command .= ' 2>&1';
332
333 78
        if ($use_rcs_bindir) {
334 78
            $command = $this->_config->get_bin_prefix() . $command;
335
        }
336
337 78
        debug_add("Executing '{$command}'");
338
339
        try {
340 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

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