Completed
Push — master ( f8b859...530e5d )
by Andreas
24:22
created

midcom_services_rcs_backend_rcs::update()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 15
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 2

Importance

Changes 0
Metric Value
cc 2
eloc 7
nc 2
nop 2
dl 0
loc 15
ccs 7
cts 7
cp 1
crap 2
rs 10
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 81
    public function __construct($object, midcom_services_rcs_config $config)
30
    {
31 81
        $this->_config = $config;
32 81
        $this->_guid = $object->guid;
33 81
    }
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 56
            debug_add("Directory {$dirpath} does not exist, attempting to create", MIDCOM_LOG_INFO);
41 56
            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 = array_keys($this->list_history());
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 = array_keys($this->list_history());
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
     * Lists the number of changes that has been done to the object
142
     * Order: The first entry is the newest.
143
     *
144
     * @return array list of changeids
145
     */
146 7
    public function list_history() : array
147
    {
148 7
        if (empty($this->_guid)) {
149
            return [];
150
        }
151
152 7
        if ($this->_history === null) {
153 7
            $filepath = $this->_generate_rcs_filename($this->_guid);
154 7
            $this->_history = $this->rcs_gethistory($filepath);
155
        }
156
157 7
        return $this->_history;
158
    }
159
160
    /* it is debatable to move this into the object when it resides nicely in a library... */
161
162 7
    private function rcs_parse_history_entry(array $entry) : array
163
    {
164
        // Create the empty history array
165
        $history = [
166 7
            'revision' => null,
167
            'date'     => null,
168
            'lines'    => null,
169
            'user'     => null,
170
            'ip'       => null,
171
            'message'  => null,
172
        ];
173
174
        // Revision number is in format
175
        // revision 1.11
176 7
        $history['revision'] = preg_replace('/(\d+\.\d+).*/', '$1', substr($entry[0], 9));
177
178
        // Entry metadata is in format
179
        // date: 2006/01/10 09:40:49;  author: www-data;  state: Exp;  lines: +2 -2
180
        // NOTE: Time here appears to be stored as UTC according to http://parand.com/docs/rcs.html
181 7
        $metadata_array = explode(';', $entry[1]);
182 7
        foreach ($metadata_array as $metadata) {
183 7
            $metadata = trim($metadata);
184 7
            if (substr($metadata, 0, 5) == 'date:') {
185 7
                $history['date'] = strtotime(substr($metadata, 6));
186 7
            } elseif (substr($metadata, 0, 6) == 'lines:') {
187 6
                $history['lines'] = substr($metadata, 7);
188
            }
189
        }
190
191
        // Entry message is in format
192
        // user:27b841929d1e04118d53dd0a45e4b93a|84.34.133.194|message
193 7
        $message_array = explode('|', $entry[2]);
194 7
        if (count($message_array) == 1) {
195
            $history['message'] = $message_array[0];
196
        } else {
197 7
            if ($message_array[0] != 'Object') {
198 7
                $history['user'] = $message_array[0];
199
            }
200 7
            $history['ip'] = $message_array[1];
201 7
            $history['message'] = $message_array[2];
202
        }
203 7
        return $history;
204
    }
205
206
    /*
207
     * the functions below are mostly rcs functions moved into the class. Someday I'll get rid of the
208
     * old files...
209
     */
210
    /**
211
     * Get a list of the object's history
212
     *
213
     * @param string $what objectid (usually the guid)
214
     */
215 7
    private function rcs_gethistory(string $what) : array
216
    {
217 7
        $history = $this->rcs_exec('rlog', $what . ',v');
218 7
        $revisions = [];
219 7
        $lines = explode("\n", $history);
220 7
        $total = count($lines);
221
222 7
        for ($i = 0; $i < $total; $i++) {
223 7
            if (substr($lines[$i], 0, 9) == "revision ") {
224 7
                $history_entry = [$lines[$i], $lines[$i + 1], $lines[$i + 2]];
225 7
                $history = $this->rcs_parse_history_entry($history_entry);
226
227 7
                $revisions[$history['revision']] = $history;
228
229 7
                $i += 3;
230
231 7
                while (   $i < $total
232 7
                       && substr($lines[$i], 0, 4) != '----'
233 7
                       && substr($lines[$i], 0, 5) != '=====') {
234
                    $i++;
235
                }
236
            }
237
        }
238 7
        return $revisions;
239
    }
240
241
    /**
242
     * execute a command
243
     *
244
     * @param string $command The command to execute
245
     * @param string $filename The file to operate on
246
     * @return string command result.
247
     */
248 7
    private function rcs_exec(string $command, string $filename) : string
249
    {
250 7
        if (!is_readable($filename)) {
251 3
            debug_add('file ' . $filename . ' is not readable, returning empty result', MIDCOM_LOG_INFO);
252 3
            return '';
253
        }
254 7
        $fh = popen($this->_config->get_bin_prefix() . $command . ' "' . $filename . '" 2>&1', "r");
255 7
        $ret = "";
256 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

256
        while ($reta = fgets(/** @scrutinizer ignore-type */ $fh, 1024)) {
Loading history...
257 7
            $ret .= $reta;
258
        }
259 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

259
        pclose(/** @scrutinizer ignore-type */ $fh);
Loading history...
260
261 7
        return $ret;
262
    }
263
264
    /**
265
     * Update object to RCS
266
     * Should be called just before $object->update()
267
     *
268
     * @param object $object to be updated.
269
     * @param string $message
270
     * @return int :
271
     *      0 on success
272
     *      3 on missing object->guid
273
     *      nonzero on error in one of the commands.
274
     */
275 74
    private function rcs_update(midcom_core_dbaobject $object, $message)
276
    {
277 74
        if (empty($object->guid)) {
278
            debug_add("Missing GUID, returning error");
279
            return 3;
280
        }
281
282 74
        $filename = $this->_generate_rcs_filename($object->guid);
283 74
        $rcsfilename = "{$filename},v";
284 74
        $message = escapeshellarg($message);
285
286 74
        if (file_exists($rcsfilename)) {
287 37
            $this->exec('co -q -f -l ' . escapeshellarg($filename));
288 37
            $command = 'ci -q -m' . $message . " {$filename}";
289
        } else {
290 64
            $command = 'ci -q -i -t-' . $message . ' -m' . $message . " {$filename}";
291
        }
292 74
        if (is_writable($this->_config->get_rcs_root())) {
293 74
            file_put_contents($filename, $this->rcs_object2data($object));
294
        }
295 74
        $stat = $this->exec($command);
296
297 74
        if (file_exists($rcsfilename)) {
298 74
            chmod($rcsfilename, 0770);
299
        }
300
301 74
        return $stat;
302
    }
303
304
    /**
305
     * Make xml out of an object.
306
     *
307
     * @param midcom_core_dbaobject $object
308
     */
309 74
    private function rcs_object2data(midcom_core_dbaobject $object) : string
310
    {
311 74
        $mapper = new midcom_helper_exporter_xml();
312 74
        return $mapper->object2data($object);
313
    }
314
315 78
    private function exec(string $command, $use_rcs_bindir = true)
316
    {
317 78
        $status = null;
318 78
        $output = null;
319
320
        // Always append stderr redirect
321 78
        $command .= ' 2>&1';
322
323 78
        if ($use_rcs_bindir) {
324 78
            $command = $this->_config->get_bin_prefix() . $command;
325
        }
326
327 78
        debug_add("Executing '{$command}'");
328
329
        try {
330 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

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