Completed
Push — master ( 163177...d8dd6e )
by Andreas
23:26
created

midcom_services_rcs_backend_rcs::get_history()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 12
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 3.0261

Importance

Changes 0
Metric Value
cc 3
eloc 6
c 0
b 0
f 0
nc 3
nop 0
dl 0
loc 12
ccs 6
cts 7
cp 0.8571
crap 3.0261
rs 10
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
     * @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->_guid = $object->guid;
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 60
            debug_add("Directory {$dirpath} does not exist, attempting to create", MIDCOM_LOG_INFO);
43 60
            mkdir($dirpath, 0777, true);
44
        }
45 80
        return "{$dirpath}/{$guid}";
46
    }
47
48
    /**
49
     * Save a new revision
50
     *
51
     * @param object $object object to be saved
52
     * @return boolean true on success.
53
     */
54 74
    public function update($object, $updatemessage = null) : bool
55
    {
56
        // Store user identifier and IP address to the update string
57 74
        $message = $_SERVER['REMOTE_ADDR'] . '|' . $updatemessage;
58 74
        $message = (midcom::get()->auth->user->id ?? 'NOBODY') . '|' . $message;
59
60 74
        $filename = $this->_generate_rcs_filename($object->guid);
61 74
        $rcsfilename = "{$filename},v";
62 74
        $message = escapeshellarg($message);
63
64 74
        if (file_exists($rcsfilename)) {
65 37
            $this->exec('co -q -f -l ' . escapeshellarg($filename));
66 37
            $command = 'ci -q -m' . $message . " {$filename}";
67
        } else {
68 64
            $command = 'ci -q -i -t-' . $message . ' -m' . $message . " {$filename}";
69
        }
70 74
        $mapper = new midcom_helper_exporter_xml;
71 74
        file_put_contents($filename, $mapper->object2data($object));
72 74
        $stat = $this->exec($command);
73
74 74
        if (file_exists($rcsfilename)) {
75 74
            chmod($rcsfilename, 0770);
76
        }
77
78
        // The methods return basically what the RCS unix level command returns, so nonzero value is error and zero is ok...
79 74
        return $stat == 0;
80
    }
81
82
    /**
83
     * Get the object of a revision
84
     *
85
     * @param string $revision identifier of revision wanted
86
     * @return array array representation of the object
87
     */
88 4
    public function get_revision($revision) : array
89
    {
90 4
        if (empty($this->_guid)) {
91
            return [];
92
        }
93 4
        $filepath = $this->_generate_rcs_filename($this->_guid);
94 4
        if ($this->exec('co -q -f -r' . escapeshellarg(trim($revision)) . " {$filepath} 2>/dev/null") != 0) {
95
            return [];
96
        }
97
98 4
        $data = (file_exists($filepath)) ? file_get_contents($filepath) : '';
99
100 4
        $mapper = new midcom_helper_exporter_xml();
101 4
        $revision = $mapper->data2array($data);
102
103 4
        $this->exec("rm -f {$filepath}", false);
104
105 4
        return $revision;
106
    }
107
108
    /**
109
     * Lists the number of changes that has been done to the object
110
     * Order: The first entry is the newest.
111
     *
112
     * @return array list of changeids
113
     */
114 7
    public function get_history() : ?midcom_services_rcs_history
115
    {
116 7
        if (empty($this->_guid)) {
117
            return null;
118
        }
119
120 7
        if ($this->history === null) {
121 7
            $filepath = $this->_generate_rcs_filename($this->_guid);
122 7
            $this->history = $this->rcs_gethistory($filepath);
123
        }
124
125 7
        return $this->history;
126
    }
127
128
    /* it is debatable to move this into the object when it resides nicely in a library... */
129
130 7
    private function rcs_parse_history_entry(array $entry) : array
131
    {
132
        // Create the empty history array
133
        $history = [
134 7
            'revision' => null,
135
            'date'     => null,
136
            'lines'    => null,
137
            'user'     => null,
138
            'ip'       => null,
139
            'message'  => null,
140
        ];
141
142
        // Revision number is in format
143
        // revision 1.11
144 7
        $history['revision'] = preg_replace('/(\d+\.\d+).*/', '$1', substr($entry[0], 9));
145
146
        // Entry metadata is in format
147
        // date: 2006/01/10 09:40:49;  author: www-data;  state: Exp;  lines: +2 -2
148
        // NOTE: Time here appears to be stored as UTC according to http://parand.com/docs/rcs.html
149 7
        $metadata_array = explode(';', $entry[1]);
150 7
        foreach ($metadata_array as $metadata) {
151 7
            $metadata = trim($metadata);
152 7
            if (substr($metadata, 0, 5) == 'date:') {
153 7
                $history['date'] = strtotime(substr($metadata, 6));
154 7
            } elseif (substr($metadata, 0, 6) == 'lines:') {
155 6
                $history['lines'] = substr($metadata, 7);
156
            }
157
        }
158
159
        // Entry message is in format
160
        // user:27b841929d1e04118d53dd0a45e4b93a|84.34.133.194|message
161 7
        $message_array = explode('|', $entry[2]);
162 7
        if (count($message_array) == 1) {
163
            $history['message'] = $message_array[0];
164
        } else {
165 7
            if ($message_array[0] != 'Object') {
166 7
                $history['user'] = $message_array[0];
167
            }
168 7
            $history['ip'] = $message_array[1];
169 7
            $history['message'] = $message_array[2];
170
        }
171 7
        return $history;
172
    }
173
174
    /*
175
     * the functions below are mostly rcs functions moved into the class. Someday I'll get rid of the
176
     * old files...
177
     */
178
    /**
179
     * Get a list of the object's history
180
     *
181
     * @param string $what objectid (usually the guid)
182
     */
183 7
    private function rcs_gethistory(string $what) : midcom_services_rcs_history
184
    {
185 7
        $history = $this->rcs_exec('rlog', $what . ',v');
186 7
        $revisions = [];
187 7
        $lines = explode("\n", $history);
188 7
        $total = count($lines);
189
190 7
        for ($i = 0; $i < $total; $i++) {
191 7
            if (substr($lines[$i], 0, 9) == "revision ") {
192 7
                $history_entry = [$lines[$i], $lines[$i + 1], $lines[$i + 2]];
193 7
                $history = $this->rcs_parse_history_entry($history_entry);
194
195 7
                $revisions[$history['revision']] = $history;
196
197 7
                $i += 3;
198
199 7
                while (   $i < $total
200 7
                       && substr($lines[$i], 0, 4) != '----'
201 7
                       && substr($lines[$i], 0, 5) != '=====') {
202
                    $i++;
203
                }
204
            }
205
        }
206 7
        return new midcom_services_rcs_history($revisions);
207
    }
208
209
    /**
210
     * execute a command
211
     *
212
     * @param string $command The command to execute
213
     * @param string $filename The file to operate on
214
     * @return string command result.
215
     */
216 7
    private function rcs_exec(string $command, string $filename) : string
217
    {
218 7
        if (!is_readable($filename)) {
219 3
            debug_add('file ' . $filename . ' is not readable, returning empty result', MIDCOM_LOG_INFO);
220 3
            return '';
221
        }
222 7
        $fh = popen($this->_config->get_bin_prefix() . $command . ' "' . $filename . '" 2>&1', "r");
223 7
        $ret = "";
224 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

224
        while ($reta = fgets(/** @scrutinizer ignore-type */ $fh, 1024)) {
Loading history...
225 7
            $ret .= $reta;
226
        }
227 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

227
        pclose(/** @scrutinizer ignore-type */ $fh);
Loading history...
228
229 7
        return $ret;
230
    }
231
232 78
    private function exec(string $command, $use_rcs_bindir = true)
233
    {
234 78
        $status = null;
235 78
        $output = null;
236
237
        // Always append stderr redirect
238 78
        $command .= ' 2>&1';
239
240 78
        if ($use_rcs_bindir) {
241 78
            $command = $this->_config->get_bin_prefix() . $command;
242
        }
243
244 78
        debug_add("Executing '{$command}'");
245
246
        try {
247 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

247
            /** @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...
248
        } catch (Exception $e) {
249
            debug_add($e->getMessage());
250
        }
251
252 78
        if ($status !== 0) {
253
            debug_add("Command '{$command}' returned with status {$status}, see debug log for output", MIDCOM_LOG_WARN);
254
            debug_print_r('Got output: ', $output);
255
        }
256 78
        return $status;
257
    }
258
259
    /**
260
     * Get a html diff between two versions.
261
     *
262
     * @param string $oldest_revision id of the oldest revision
263
     * @param string $latest_revision id of the latest revision
264
     */
265 2
    public function get_diff($oldest_revision, $latest_revision) : array
266
    {
267 2
        $oldest = $this->get_revision($oldest_revision);
268 2
        $newest = $this->get_revision($latest_revision);
269
270 2
        $return = [];
271 2
        $oldest = array_intersect_key($oldest, $newest);
272
273
        $repl = [
274 2
            '<del>' => "<span class=\"deleted\">",
275
            '</del>' => '</span>',
276
            '<ins>' => "<span class=\"inserted\">",
277
            '</ins>' => '</span>'
278
        ];
279 2
        foreach ($oldest as $attribute => $oldest_value) {
280 2
            if (is_array($oldest_value)) {
281 2
                continue;
282
            }
283
284 2
            $return[$attribute] = [
285 2
                'old' => $oldest_value,
286 2
                'new' => $newest[$attribute]
287
            ];
288
289 2
            if ($oldest_value != $newest[$attribute]) {
290 2
                $lines1 = explode("\n", $oldest_value);
291 2
                $lines2 = explode("\n", $newest[$attribute]);
292
293 2
                $renderer = new midcom_services_rcs_renderer_html_sidebyside(['old' => $oldest_revision, 'new' => $latest_revision]);
294
295 2
                if ($lines1 != $lines2) {
296 2
                    $diff = new Diff($lines1, $lines2);
297
                    // Run the diff
298 2
                    $return[$attribute]['diff'] = $diff->render($renderer);
299
                    // Modify the output for nicer rendering
300 2
                    $return[$attribute]['diff'] = strtr($return[$attribute]['diff'], $repl);
301
                }
302
            }
303
        }
304
305 2
        return $return;
306
    }
307
308
    /**
309
     * Restore an object to a certain revision.
310
     *
311
     * @param string $revision of revision to restore object to.
312
     * @return boolean true on success.
313
     */
314
    public function restore_to_revision($revision) : bool
315
    {
316
        $new = $this->get_revision($revision);
317
318
        try {
319
            $object = midcom::get()->dbfactory->get_object_by_guid($this->_guid);
320
        } catch (midcom_error $e) {
321
            debug_add("{$this->_guid} could not be resolved to object", MIDCOM_LOG_ERROR);
322
            return false;
323
        }
324
        $mapper = new midcom_helper_exporter_xml();
325
        $object = $mapper->data2object($new, $object);
326
327
        $object->set_rcs_message("Reverted to revision {$revision}");
328
329
        return $object->update();
330
    }
331
}
332