Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
75.96% covered (warning)
75.96%
218 / 287
50.00% covered (danger)
50.00%
8 / 16
CRAP
n/a
0 / 0
_get_log_version
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
_get_log_fields_for_line
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
4.01
_get_log_fields
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
1
_read_last_line
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
5.01
_log_fields_ini
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
_log_fields_end
78.95% covered (warning)
78.95%
15 / 19
0.00% covered (danger)
0.00%
0 / 1
6.34
_write_log_line
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
8
close_log_file
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
110
append_logline
78.57% covered (warning)
78.57%
55 / 70
0.00% covered (danger)
0.00%
0 / 1
37.28
registry_canonical_path
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
registry_pending_path
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
registry_pending_default
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
registry_pending_load_locked
60.71% covered (warning)
60.71%
17 / 28
0.00% covered (danger)
0.00%
0 / 1
20.73
registry_pending_save_locked
56.00% covered (warning)
56.00%
14 / 25
0.00% covered (danger)
0.00%
0 / 1
13.45
registry_pending_unlock
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
registry_append_guarded
86.36% covered (success)
86.36%
38 / 44
0.00% covered (danger)
0.00%
0 / 1
14.50
LogEvent
n/a
0 / 0
n/a
0 / 0
0
n/a
0 / 0
1<?php
2    include_once __DIR__."/../../Legacy/common/session.php";
3    require_once __DIR__."/../legacy/base/mutex.php";
4
5    abstract class LogEvent {
6        const TicketUpdate = 'e0';     // ticket event
7        const Drawer = 'e1';           // drawer event
8        const TicketSII = 'e2';        // ticket SII event
9        const TicketBAI = 'e3';        // ticket BAI event
10        const VeriFactu = 'e4';        // verifactu event
11        const TicketPrint = 'e5';      // ticket print event
12        const UsrLogin = 'l1';         // user place login event
13        const UsrLogout = 'l2';        // user place logout event
14        const GstLogin = 'l3';         // guest login event
15        const GstLogout = 'l4';        // guest logout event
16    }
17
18    function _get_log_version(){
19        return 'v8';
20    }
21
22    function _get_log_fields_for_line(int $fieldCount): array
23    {
24        $current = _get_log_fields();
25        $withoutPaidin = array_values(array_filter($current, static function (string $field): bool {
26            return $field !== 'paidin';
27        }));
28        $withoutPaidinAndCustom = array_values(array_filter($current, static function (string $field): bool {
29            return $field !== 'paidin' && $field !== 'custom';
30        }));
31
32        $countWithPaidin = 3 + count($current) + 2;
33        $countWithCustomOnly = 3 + count($withoutPaidin) + 2;
34
35        if ($fieldCount >= $countWithPaidin){
36            return $current;
37        }
38
39        if ($fieldCount >= $countWithCustomOnly){
40            return $withoutPaidin;
41        }
42
43        return $withoutPaidinAndCustom;
44    }
45
46    function _get_log_fields(){
47        return [
48            'objid',            // the ticket database objid
49            'orderno',          // the ticket orderno
50            'series',           // The ticket series
51            'invceno',          // the ticket invceno
52            'status',           // the ticket status
53            'price',            // the ticket price
54            'refund',           // the ticket refund value
55            'payment',          // the payment method
56            'webpay',           // objid for the payment entry for this ticket (if paid online)
57            'account',          // objid for the account entry for this ticket (if paid to account)
58            'session',          // session for this operation
59            'paidby',           // session that requested payment (pending collection)
60            'paidin',           // session that collected payment with real income
61            'paidon',           // date when ticket was paid
62            'drawer',           // the device name for drawer events
63            'change',           // change type for SII/BAI events
64            'error',            // received error (if any) for SII/BAI events
65            'custom'            // base64 payload for custom events
66        ];
67    }
68
69    function _read_last_line($filepath){
70        clearstatcache(true, $filepath);
71        $file = fopen($filepath, 'rb');
72        if ($file === false){
73            return false;
74        }
75        if (fstat($file)['size'] == 0){
76            fclose($file);
77            return false;   // empty file
78        }
79
80        $offset = -1;
81        do {
82            fseek($file, --$offset, SEEK_END);
83        } while (fgetc($file) != "\n" && $offset > -1048576);
84
85        $line = fread($file, 0 - $offset);
86        fclose($file);
87        return $line !== false ? rtrim($line, "\r\n") : false;
88    }
89
90    function _log_fields_ini($timestamp, $type, &$logline){
91        $line = '';
92        $date = $timestamp; // $conx->now();
93        $vers = _get_log_version();
94        $evtp = $type;
95
96        $line .= $date.";";                             // current timestamp
97        $line .= $vers.";";                             // current log version
98        $line .= $evtp.";";                             // event type
99
100        $logline .= $line;                              // update the log line
101    }
102
103    function _log_fields_end($dstfile, &$updt, &$logline){
104        $line = '';
105        $vers = '';
106
107        $hash = '00000000000000000000000000000000';
108
109        $tip = \Mutex::fileTipGet($dstfile);
110        if (is_string($tip) && strpos($tip, ';') !== false) {
111            $parts = explode(';', $tip, 2);
112            $vers = $parts[0];
113            if (isset($parts[1]) && preg_match('/^[a-f0-9]{32}$/', $parts[1])) {
114                $hash = $parts[1];
115            }
116        }
117        else {
118            $last = _read_last_line($dstfile);
119            if ($last){
120                $last = explode(';', $last, -1);
121                $vers = $last[1];                           // read the last log version
122                $hash = $last[count($last)-2];              // read the last log hash
123            }
124        }
125
126        $updt = (_get_log_version() != $vers);          // check if log version is updated
127
128        $lineHash = md5($logline);
129        $line .= $lineHash.";";                         // md5 for this line
130        $line .= $hash.";";                             // md5 of the previous line
131
132        $logline .= $line;                              // update the log line
133    }
134
135    /**
136     * Write header (when needed) and one log line to an open canonical registry file.
137     * Verifies every fwrite byte count; returns false if any write is short or fails.
138     *
139     * @param resource $file Open file handle (append mode).
140     * @param bool $updated True when the log version header must be rewritten.
141     * @param array $fields Field names for the header row.
142     * @param string $logline Fully built line (without trailing newline).
143     * @return bool True only if every expected byte was written.
144     */
145    function _write_log_line($file, $updated, &$fields, &$logline): bool
146    {
147        $ok = true;
148
149        // write the file header (new file or version change)
150        if ((fstat($file)['size'] == 0) || $updated){
151            $hdrline = 'date;version;event;';
152            foreach($fields as $field){
153                $hdrline .= $field.";";
154            }
155            $hdrline .= 'hash;prev;';
156
157            if ($updated){
158                $sep = PHP_EOL;
159                $written = fwrite($file, $sep);
160                $ok = $ok && ($written === strlen($sep));
161            }
162
163            $hdr = $hdrline.PHP_EOL;
164            $written = fwrite($file, $hdr);
165            $ok = $ok && ($written === strlen($hdr));
166        }
167
168        // write the log contents
169        $line = $logline.PHP_EOL;
170        $written = fwrite($file, $line);
171        $ok = $ok && ($written === strlen($line));
172
173        return $ok;
174    }
175
176    function close_log_file($logfile, $sigfile){
177        if (!function_exists('openssl_sign')) {
178            addlog(__FILE__, LogLevel::Warning, "OpenSSL library extension is not available!");
179            return false;
180        }
181
182        $logdata = file_exists($logfile) ? file_get_contents($logfile) : null;
183        if ($logdata){
184            $privkeyfile = __DIR__."/../../Resources/certs/selfsigned.key";
185
186            $keycontents = file_exists($privkeyfile) ? file_get_contents($privkeyfile) : null;
187            if (!$keycontents){
188                addlog(__FILE__, LogLevel::Error, "Could not read content for private key: '".$privkeyfile."'");
189                return false;
190            }
191            else {
192                if (!openssl_sign($logdata, $sigdata, $keycontents, OPENSSL_ALGO_SHA256)){
193                    while ($msg = openssl_error_string()) {
194                        addlog(__FILE__, LogLevel::Error, "OpenSSL error: ".$msg."");
195                    }
196
197                    return false;
198                }
199
200                if (!file_put_contents($sigfile, $sigdata) || !chmod($sigfile, 0444)){
201                    addlog(__FILE__, LogLevel::Error, "Could not write signed contents into: '".$sigfile."'");
202                    return false;
203                }
204
205                return true;    // succesfully signed
206            }
207        }
208        else {
209            addlog(__FILE__, LogLevel::Error, "Could not read content for source file: '".$logfile."'");
210            return false;
211        }
212    }
213
214    /**
215     * Append one fiscal chain link to the canonical registry CSV for `$place`.
216     *
217     * Return value reflects only the canonical file (`{place}_ticket_activity.csv`):
218     * Success when fopen, MUTEX_FILE lock, and verified fwrite all succeed. The per-date
219     * copy (`{date}_{type}.csv`) and `.sig` signing remain best-effort and never change
220     * the returned code (they are not the legal chain).
221     *
222     * @param string $filepath Canonical CSV path under RegLogPath.
223     * @param mixed $timestamp Server timestamp string for the event.
224     * @param mixed $place Place objid (used for the per-date copy folder).
225     * @param string $type LogEvent code.
226     * @param array $lineinfo Field map for the log line.
227     * @return int ApplicationError::Success or ApplicationError::Generic.
228     */
229    function append_logline($filepath, $timestamp, $place, $type, $lineinfo): int
230    {
231        $retval = ApplicationError::Success;
232        $logline = '';
233        $fields = _get_log_fields();
234        $updated = false;
235        $writeOk = false;
236
237        // MUTEX_FILE: $filepath is under AppConstants::RegLogPath() (fiscal registry root),
238        // so this serializes across Gluster-mounted nodes when DATA_SHARE_MODE=REDIS, and
239        // falls back to local flock() for SAFE/APCU â€” see mutex.php `Mutex::create()`.
240        $locked = with_lock($filepath, MUTEX_FILE, function () use ($filepath, $timestamp, $type, $lineinfo, $fields, &$logline, &$updated, &$writeOk) {
241            $file = @fopen($filepath, 'a+');
242            if (!$file){
243                addlog(__FILE__, LogLevel::Error, "Could not open registry log file in '".$filepath."' (for append)");
244                $error = error_get_last();
245                if ($error) addlog(__FILE__, LogLevel::Debug, $error['message']);
246                $writeOk = false;
247                return;
248            }
249
250            // build the log line
251            _log_fields_ini($timestamp, $type, $logline);
252            foreach($fields as $field) {  // add the log contents
253                $logline .= ((array_key_exists($field, $lineinfo)) ? $lineinfo[$field] : '').";";
254            }
255            _log_fields_end($filepath, $updated, $logline);
256
257            // append to the file (byte-verified)
258            $writeOk = _write_log_line($file, $updated, $fields, $logline);
259            if (!$writeOk){
260                addlog(__FILE__, LogLevel::Error, "Could not write registry log line to '".$filepath."'");
261                $error = error_get_last();
262                if ($error) addlog(__FILE__, LogLevel::Debug, $error['message']);
263            }
264            else {
265                // Push bytes to Gluster before releasing MUTEX_FILE so the next
266                // holder on another node does not read a stale FUSE view.
267                fflush($file);
268                if (function_exists('fsync')) {
269                    fsync($file);
270                }
271            }
272
273            fclose($file);
274
275            // Publish tip only after the line is on disk (Redis, not Gluster).
276            if ($writeOk){
277                $parts = explode(';', $logline);
278                
279                if (end($parts) === '') {
280                    array_pop($parts);
281                }
282                
283                if (count($parts) >= 2) {
284                    $lineHash = $parts[count($parts) - 2];
285                    if (preg_match('/^[a-f0-9]{32}$/', $lineHash)) {
286                        \Mutex::fileTipSet($filepath, _get_log_version().';'.$lineHash);
287                    }
288                }
289            }
290        });
291
292        if (!$locked){
293            addlog(__FILE__, LogLevel::Error, "Could not lock the file '".$filepath."' for writing.");
294            $retval = ApplicationError::Generic;
295        }
296        else if (!$writeOk){
297            $retval = ApplicationError::Generic;
298        }
299
300        // Best-effort: per-date copy and signatures do not affect $retval.
301        $dstpath = sprintf("%s/%05d", AppConstants::RegLogPath(), $place);
302        if (!file_exists($dstpath) && !mkdir($dstpath, 0775)){
303            addlog(__FILE__, LogLevel::Error, "Could not create local registry log folder in '".$dstpath."'");
304            $error = error_get_last();
305            if ($error) addlog(__FILE__, LogLevel::Debug, $error['message']);
306        }
307
308        $hasTimestamp = is_string($timestamp) && ($timestamp !== '');
309        if (is_dir($dstpath) && ($logline !== '') && $hasTimestamp){
310            $dstfile = sprintf('%s/%s_%s.csv', $dstpath, explode(' ', $timestamp)[0], $type);
311            $file = @fopen($dstfile, 'a+');
312            if (!$file){
313                addlog(__FILE__, LogLevel::Error, "Could not open registry log file in '".$dstfile."' (for append)");
314                $error = error_get_last();
315                if ($error) addlog(__FILE__, LogLevel::Debug, $error['message']);
316            }
317            else {
318                _write_log_line($file, $updated, $fields, $logline);
319                fclose($file);
320            }
321        }
322
323        // check if all the files (except the current one are signed)
324        if (is_dir($dstpath) && $hasTimestamp){
325            $dstdate = explode(' ', $timestamp)[0];
326
327            $logfiles = glob($dstpath . '/*.csv');
328            foreach ($logfiles as $logfile) {
329                $filename = basename($logfile);
330
331                $filedate = explode('_', explode('.', $filename)[0])[0];
332                if ($filedate == $dstdate){
333                    continue;   // do not verify signature for current file
334                }
335
336                $sigfile = preg_replace('/\.csv$/', '.sig', $logfile);
337                if (!file_exists($sigfile)) {
338                    addlog(__FILE__, LogLevel::Information, "Closing and signing logs file '".$logfile."'..");
339                    
340                    if (close_log_file($logfile, $sigfile)){
341                        addlog(__FILE__, LogLevel::Information, "Logs file signature written in'".$sigfile."'");
342                    }
343                    else {
344                        addlog(__FILE__, LogLevel::Error, "Error generating signed content in'".$sigfile."'");
345                    }
346                }
347            }
348        }
349
350        return $retval;
351    }
352
353    /************************************************/
354    /* Pending fiscal-link queue (durable, per place) */
355    /************************************************/
356
357    /**
358     * Canonical fiscal chain CSV for a place (legal append-only register).
359     *
360     * @param mixed $place Place objid.
361     * @return string Absolute path under RegLogPath.
362     */
363    function registry_canonical_path($place): string
364    {
365        return sprintf('%s/%05d_ticket_activity.csv', AppConstants::RegLogPath(), $place);
366    }
367
368    /**
369     * Durable pending-queue JSON path for a place (Gluster regstr volume).
370     *
371     * @param mixed $place Place objid.
372     * @return string Absolute path under RegLogPath/pending.
373     */
374    function registry_pending_path($place): string
375    {
376        return sprintf('%s/pending/%05d.json', AppConstants::RegLogPath(), $place);
377    }
378
379    /**
380     * Empty pending-queue document (same records[] spirit as verifactu_queue_default).
381     *
382     * @return array{records: array<int, array>}
383     */
384    function registry_pending_default(): array
385    {
386        return [ 'records' => [] ];
387    }
388
389    /**
390     * Load the per-place pending registry queue while holding MUTEX_FILE on `$dstfile`.
391     * `$mutex` must be released via `registry_pending_unlock()` (same pattern as
392     * `verifactu_queue_load_locked`).
393     *
394     * @param mixed $place Place objid.
395     * @param array|null $queue Loaded queue document (output).
396     * @param string|null $dstfile Absolute JSON path (output).
397     * @param mixed $mutex Held MutexBase instance or null (output).
398     * @return int ApplicationError code.
399     */
400    function registry_pending_load_locked($place, &$queue, &$dstfile, &$mutex): int
401    {
402        $retval = ApplicationError::Success;
403        $queue = null;
404        $dstfile = null;
405        $mutex = null;
406
407        $dstpath = AppConstants::RegLogPath().'/pending';
408        if (!file_exists($dstpath) && !mkdir($dstpath, 0775, true)){
409            addlog(__FILE__, LogLevel::Error, "Could not create registry pending folder in '".$dstpath."'");
410            $error = error_get_last();
411            if ($error) addlog(__FILE__, LogLevel::Debug, $error['message']);
412            return ApplicationError::Generic;
413        }
414
415        $dstfile = registry_pending_path($place);
416        $mutex = Mutex::create($dstfile, MUTEX_FILE);
417        if (!$mutex->acquire()){
418            addlog(__FILE__, LogLevel::Error, "Could not lock registry pending file in '".$dstfile."'");
419            $mutex = null;
420            return ApplicationError::Generic;
421        }
422
423        if (file_exists($dstfile)){
424            $contents = file_get_contents($dstfile);
425            if (($contents === false) || (trim($contents) === '')){
426                $queue = registry_pending_default();
427            }
428            else {
429                $queue = json_decode($contents, true);
430                if (!is_array($queue)){
431                    addlog(__FILE__, LogLevel::Error, "Registry pending file should be in json format!");
432                    $retval = ApplicationError::Invalid;
433                }
434            }
435        }
436        else {
437            $queue = registry_pending_default();
438        }
439
440        if (success($retval) && (!isset($queue['records']) || !is_array($queue['records']))){
441            $queue['records'] = [];
442        }
443
444        return $retval;
445    }
446
447    /**
448     * Persist the pending queue JSON (atomic write via temp + rename).
449     *
450     * @param string $dstfile Absolute JSON path.
451     * @param array $queue Queue document with records[].
452     * @return int ApplicationError code.
453     */
454    function registry_pending_save_locked($dstfile, &$queue): int
455    {
456        $retval = ApplicationError::Success;
457        $content = json_encode($queue, JSON_PRETTY_PRINT);
458        if ($content === false){
459            addlog(__FILE__, LogLevel::Error, "Could not update registry pending items! (error in json encoding)");
460            return ApplicationError::Generic;
461        }
462
463        $tmpdir = dirname($dstfile);
464        $tmpfile = $tmpdir.'/.'.basename($dstfile).'.'.getmypid().'.'.uniqid('', true).'.tmp';
465        if (file_put_contents($tmpfile, $content) === false){
466            addlog(__FILE__, LogLevel::Error, "Could not update registry pending items!");
467            $error = error_get_last();
468            if ($error) addlog(__FILE__, LogLevel::Debug, $error['message']);
469            return ApplicationError::Generic;
470        }
471
472        $tmpHandle = @fopen($tmpfile, 'r+');
473        if ($tmpHandle){
474            fflush($tmpHandle);
475            if (function_exists('fsync')){
476                @fsync($tmpHandle);
477            }
478            fclose($tmpHandle);
479        }
480
481        if (!rename($tmpfile, $dstfile)){
482            @unlink($tmpfile);
483            addlog(__FILE__, LogLevel::Error, "Could not update registry pending items!");
484            $error = error_get_last();
485            if ($error) addlog(__FILE__, LogLevel::Debug, $error['message']);
486            return ApplicationError::Generic;
487        }
488
489        return $retval;
490    }
491
492    /** Release the mutex acquired by `registry_pending_load_locked()`. Idempotent. */
493    function registry_pending_unlock(&$mutex): void
494    {
495        if ($mutex instanceof MutexBase){
496            $mutex->release();
497        }
498        $mutex = null;
499    }
500
501    /**
502     * Enqueue a fiscal chain link for `$place` and drain the pending queue FIFO into the
503     * canonical CSV via `append_logline()`. Stops on the first write failure so a newer
504     * link is never appended while an older pending one for the same place is still waiting
505     * (chain order must match event order).
506     *
507     * @param \ConxHelper $conx Connection helper (reserved for callers; not required for I/O).
508     * @param mixed $place Place objid.
509     * @param string $type LogEvent code.
510     * @param mixed $timestamp Event timestamp.
511     * @param array $lineinfo Field map for the log line.
512     * @return int ApplicationError::Success only when the queue is empty after drain.
513     */
514    function registry_append_guarded(\ConxHelper $conx, $place, $type, $timestamp, $lineinfo): int
515    {
516        $retval = ApplicationError::Success;
517        $queue = null;
518        $dstfile = null;
519        $mutex = null;
520
521        if (success($retval)){
522            $retval = registry_pending_load_locked($place, $queue, $dstfile, $mutex);
523        }
524
525        if (success($retval)){
526            $queue['records'][] = [
527                'timestamp' => $timestamp,
528                'place' => $place,
529                'type' => $type,
530                'lineinfo' => $lineinfo,
531                'queued_at' => $timestamp,
532            ];
533
534            $canonical = registry_canonical_path($place);
535            $regRoot = AppConstants::RegLogPath();
536            if (!file_exists($regRoot) && !mkdir($regRoot, 0775, true)){
537                addlog(__FILE__, LogLevel::Error, "Could not create registry root in '".$regRoot."'");
538                $error = error_get_last();
539                if ($error) addlog(__FILE__, LogLevel::Debug, $error['message']);
540                $retval = ApplicationError::Generic;
541            }
542
543            // Drain FIFO; stop on first failure so newer links cannot overtake older ones.
544            while (success($retval) && count($queue['records']) > 0){
545                $record = $queue['records'][0];
546                $writeResult = append_logline(
547                    $canonical,
548                    $record['timestamp'],
549                    $record['place'],
550                    $record['type'],
551                    is_array($record['lineinfo'] ?? null) ? $record['lineinfo'] : []
552                );
553                if (!success($writeResult)){
554                    $retval = $writeResult;
555                    break;
556                }
557                array_shift($queue['records']);
558            }
559
560            $saveResult = registry_pending_save_locked($dstfile, $queue);
561            if (!success($saveResult) && success($retval)){
562                $retval = $saveResult;
563            }
564
565            if (count($queue['records']) > 0){
566                $oldest = $queue['records'][0]['queued_at'] ?? null;
567                addlog(__FILE__, LogLevel::Critical, "Registry pending queue for place '".$place."' still has ".count($queue['records'])." link(s) after drain; oldest queued_at='".($oldest ?? 'null')."'");
568                if (success($retval)){
569                    $retval = ApplicationError::Generic;
570                }
571            }
572        }
573
574        registry_pending_unlock($mutex);
575        return $retval;
576    }
577?>