1<?php 2 3namespace dokuwiki\ChangeLog; 4 5use dokuwiki\Logger; 6 7/** 8 * ChangeLog Prototype; methods for handling changelog 9 */ 10abstract class ChangeLog 11{ 12 use ChangeLogTrait; 13 14 /** @var string */ 15 protected $id; 16 /** @var false|int */ 17 protected $currentRevision; 18 /** @var array */ 19 protected $cache = []; 20 21 /** 22 * Constructor 23 * 24 * @param string $id page id 25 * @param int $chunk_size maximum block size read from file 26 */ 27 public function __construct($id, $chunk_size = 8192) 28 { 29 global $cache_revinfo; 30 31 $this->cache =& $cache_revinfo; 32 if (!isset($this->cache[$id])) { 33 $this->cache[$id] = []; 34 } 35 36 $this->id = $id; 37 $this->setChunkSize($chunk_size); 38 } 39 40 /** 41 * Returns path to current page/media 42 * 43 * @param string|int $rev empty string or revision timestamp 44 * @return string path to file 45 */ 46 abstract protected function getFilename($rev = ''); 47 48 /** 49 * Returns mode 50 * 51 * @return string RevisionInfo::MODE_MEDIA or RevisionInfo::MODE_PAGE 52 */ 53 abstract protected function getMode(); 54 55 /** 56 * Returns path to the global changelog file (the cross-page recent-changes feed) 57 * 58 * @return string path to file 59 */ 60 abstract protected function getGlobalChangelogFilename(); 61 62 /** 63 * Check whether given revision is the current page 64 * 65 * @param int $rev timestamp of current page 66 * @return bool true if $rev is current revision, otherwise false 67 */ 68 public function isCurrentRevision($rev) 69 { 70 return $rev == $this->currentRevision(); 71 } 72 73 /** 74 * Checks if the revision is last revision 75 * 76 * @param int $rev revision timestamp 77 * @return bool true if $rev is last revision, otherwise false 78 */ 79 public function isLastRevision($rev = null) 80 { 81 return $rev === $this->lastRevision(); 82 } 83 84 /** 85 * Return the current revision identifier 86 * 87 * The "current" revision means current version of the page or media file. It is either 88 * identical with or newer than the "last" revision, that depends on whether the file 89 * has modified, created or deleted outside of DokuWiki. 90 * The value of identifier can be determined by timestamp as far as the file exists, 91 * otherwise it must be assigned larger than any other revisions to keep them sortable. 92 * 93 * @return int|false revision timestamp 94 */ 95 public function currentRevision() 96 { 97 if (!isset($this->currentRevision)) { 98 // set ChangeLog::currentRevision property 99 $this->getCurrentRevisionInfo(); 100 } 101 return $this->currentRevision; 102 } 103 104 /** 105 * Return the last revision identifier, date value of the last entry of the changelog 106 * 107 * @return int|false revision timestamp 108 */ 109 public function lastRevision() 110 { 111 $revs = $this->getRevisions(-1, 1); 112 return empty($revs) ? false : $revs[0]; 113 } 114 115 /** 116 * Parses a changelog line into its components and save revision info to the cache pool 117 * 118 * @param string $value changelog line 119 * @return array|bool parsed line or false 120 */ 121 protected function parseAndCacheLogLine($value) 122 { 123 $info = static::parseLogLine($value); 124 if (is_array($info)) { 125 $info['mode'] = $this->getMode(); 126 $this->cache[$this->id][$info['date']] ??= $info; 127 return $info; 128 } 129 return false; 130 } 131 132 /** 133 * Get the changelog information for a specific revision (timestamp) 134 * 135 * Adjacent changelog lines are optimistically parsed and cached to speed up 136 * consecutive calls to getRevisionInfo. For large changelog files, only the chunk 137 * containing the requested changelog line is read. 138 * 139 * @param int $rev revision timestamp 140 * @param bool $retrieveCurrentRevInfo allows to skip for getting other revision info in the 141 * getCurrentRevisionInfo() where $currentRevision is not yet determined 142 * @return bool|array false or array with entries: 143 * - date: unix timestamp 144 * - ip: IPv4 address (127.0.0.1) 145 * - type: log line type 146 * - id: page id 147 * - user: user name 148 * - sum: edit summary (or action reason) 149 * - extra: extra data (varies by line type) 150 * - sizechange: change of filesize 151 * additional: 152 * - mode: page or media 153 * 154 * @author Ben Coburn <btcoburn@silicodon.net> 155 * @author Kate Arzamastseva <pshns@ukr.net> 156 */ 157 public function getRevisionInfo($rev, $retrieveCurrentRevInfo = true) 158 { 159 $rev = max(0, $rev); 160 if (!$rev) return false; 161 162 //ensure the external edits are cached as well 163 if (!isset($this->currentRevision) && $retrieveCurrentRevInfo) { 164 $this->getCurrentRevisionInfo(); 165 } 166 167 // check if it's already in the memory cache 168 if (isset($this->cache[$this->id][$rev])) { 169 return $this->cache[$this->id][$rev]; 170 } 171 172 //read lines from changelog 173 $result = $this->readloglines($rev); 174 if ($result === false) return false; 175 [$fp, $lines] = $result; 176 if ($fp) { 177 fclose($fp); 178 } 179 if (empty($lines)) return false; 180 181 // parse and cache changelog lines 182 foreach ($lines as $line) { 183 $this->parseAndCacheLogLine($line); 184 } 185 186 return $this->cache[$this->id][$rev] ?? false; 187 } 188 189 /** 190 * Return a list of page revisions numbers 191 * 192 * Does not guarantee that the revision exists in the attic, 193 * only that a line with the date exists in the changelog. 194 * By default the current revision is skipped. 195 * 196 * The current revision is automatically skipped when the page exists. 197 * See $INFO['meta']['last_change'] for the current revision. 198 * A negative $first let read the current revision too. 199 * 200 * For efficiency, the log lines are parsed and cached for later 201 * calls to getRevisionInfo. Large changelog files are read 202 * backwards in chunks until the requested number of changelog 203 * lines are received. 204 * 205 * @param int $first skip the first n changelog lines 206 * @param int $num number of revisions to return 207 * @return array with the revision timestamps 208 * 209 * @author Ben Coburn <btcoburn@silicodon.net> 210 * @author Kate Arzamastseva <pshns@ukr.net> 211 */ 212 public function getRevisions($first, $num) 213 { 214 $revs = []; 215 $lines = []; 216 $count = 0; 217 218 $logfile = $this->getChangelogFilename(); 219 if (!file_exists($logfile)) return $revs; 220 221 $num = max($num, 0); 222 if ($num == 0) { 223 return $revs; 224 } 225 226 if ($first < 0) { 227 $first = 0; 228 } else { 229 $fileLastMod = $this->getFilename(); 230 if (file_exists($fileLastMod) && $this->isLastRevision(filemtime($fileLastMod))) { 231 // skip last revision if the page exists 232 $first = max($first + 1, 0); 233 } 234 } 235 236 if (filesize($logfile) < $this->chunk_size || $this->chunk_size == 0) { 237 // read whole file 238 $lines = file($logfile); 239 if ($lines === false) { 240 return $revs; 241 } 242 } else { 243 // read chunks backwards 244 $fp = fopen($logfile, 'rb'); // "file pointer" 245 if ($fp === false) { 246 return $revs; 247 } 248 fseek($fp, 0, SEEK_END); 249 $tail = ftell($fp); 250 251 // chunk backwards 252 $finger = max($tail - $this->chunk_size, 0); 253 while ($count < $num + $first) { 254 $nl = $this->getNewlinepointer($fp, $finger); 255 256 // was the chunk big enough? if not, take another bite 257 if ($nl > 0 && $tail <= $nl) { 258 $finger = max($finger - $this->chunk_size, 0); 259 continue; 260 } else { 261 $finger = $nl; 262 } 263 264 // read chunk 265 $chunk = ''; 266 $read_size = max($tail - $finger, 0); // found chunk size 267 $got = 0; 268 while ($got < $read_size && !feof($fp)) { 269 $tmp = @fread($fp, max(min($this->chunk_size, $read_size - $got), 0)); 270 if ($tmp === false) { 271 break; 272 } //error state 273 $got += strlen($tmp); 274 $chunk .= $tmp; 275 } 276 $tmp = explode("\n", $chunk); 277 array_pop($tmp); // remove trailing newline 278 279 // combine with previous chunk 280 $count += count($tmp); 281 $lines = [...$tmp, ...$lines]; 282 283 // next chunk 284 if ($finger == 0) { 285 break; 286 } else { // already read all the lines 287 $tail = $finger; 288 $finger = max($tail - $this->chunk_size, 0); 289 } 290 } 291 fclose($fp); 292 } 293 294 // skip parsing extra lines 295 $num = max(min(count($lines) - $first, $num), 0); 296 if ($first > 0 && $num > 0) { 297 $lines = array_slice($lines, max(count($lines) - $first - $num, 0), $num); 298 } elseif ($first > 0 && $num == 0) { 299 $lines = array_slice($lines, 0, max(count($lines) - $first, 0)); 300 } elseif ($first == 0 && $num > 0) { 301 $lines = array_slice($lines, max(count($lines) - $num, 0)); 302 } 303 304 // handle lines in reverse order 305 for ($i = count($lines) - 1; $i >= 0; $i--) { 306 $info = $this->parseAndCacheLogLine($lines[$i]); 307 if (is_array($info)) { 308 $revs[] = $info['date']; 309 } 310 } 311 312 return $revs; 313 } 314 315 /** 316 * Get the nth revision left or right-hand side for a specific page id and revision (timestamp) 317 * 318 * For large changelog files, only the chunk containing the 319 * reference revision $rev is read and sometimes a next chunk. 320 * 321 * Adjacent changelog lines are optimistically parsed and cached to speed up 322 * consecutive calls to getRevisionInfo. 323 * 324 * @param int $rev revision timestamp used as start date 325 * (doesn't need to be exact revision number) 326 * @param int $direction give position of returned revision with respect to $rev; 327 positive=next, negative=prev 328 * @return bool|int 329 * timestamp of the requested revision 330 * otherwise false 331 */ 332 public function getRelativeRevision($rev, $direction) 333 { 334 $rev = max($rev, 0); 335 $direction = (int)$direction; 336 337 //no direction given or last rev, so no follow-up 338 if (!$direction || ($direction > 0 && $this->isCurrentRevision($rev))) { 339 return false; 340 } 341 342 //get lines from changelog 343 $result = $this->readloglines($rev); 344 if ($result === false) return false; 345 [$fp, $lines, $head, $tail, $eof] = $result; 346 if (empty($lines)) return false; 347 348 // look for revisions later/earlier than $rev, when founded count till the wanted revision is reached 349 // also parse and cache changelog lines for getRevisionInfo(). 350 $revCounter = 0; 351 $relativeRev = false; 352 $checkOtherChunk = true; //always runs once 353 while (!$relativeRev && $checkOtherChunk) { 354 $info = []; 355 //parse in normal or reverse order 356 $count = count($lines); 357 if ($direction > 0) { 358 $start = 0; 359 $step = 1; 360 } else { 361 $start = $count - 1; 362 $step = -1; 363 } 364 for ($i = $start; $i >= 0 && $i < $count; $i += $step) { 365 $info = $this->parseAndCacheLogLine($lines[$i]); 366 if (is_array($info)) { 367 //look for revs older/earlier then reference $rev and select $direction-th one 368 if (($direction > 0 && $info['date'] > $rev) || ($direction < 0 && $info['date'] < $rev)) { 369 $revCounter++; 370 if ($revCounter == abs($direction)) { 371 $relativeRev = $info['date']; 372 } 373 } 374 } 375 } 376 377 //true when $rev is found, but not the wanted follow-up. 378 $checkOtherChunk = $fp 379 && ($info['date'] == $rev || ($revCounter > 0 && !$relativeRev)) 380 && (!($tail == $eof && $direction > 0) && !($head == 0 && $direction < 0)); 381 382 if ($checkOtherChunk) { 383 [$lines, $head, $tail] = $this->readAdjacentChunk($fp, $head, $tail, $direction); 384 385 if (empty($lines)) break; 386 } 387 } 388 if ($fp) { 389 fclose($fp); 390 } 391 392 return $relativeRev; 393 } 394 395 /** 396 * Returns revisions around rev1 and rev2 397 * When available it returns $max entries for each revision 398 * 399 * @param int $rev1 oldest revision timestamp 400 * @param int $rev2 newest revision timestamp (0 looks up last revision) 401 * @param int $max maximum number of revisions returned 402 * @return array with two arrays with revisions surrounding rev1 respectively rev2 403 */ 404 public function getRevisionsAround($rev1, $rev2, $max = 50) 405 { 406 $max = (int) (abs($max) / 2) * 2 + 1; 407 $rev1 = max($rev1, 0); 408 $rev2 = max($rev2, 0); 409 410 if ($rev2) { 411 if ($rev2 < $rev1) { 412 $rev = $rev2; 413 $rev2 = $rev1; 414 $rev1 = $rev; 415 } 416 } else { 417 //empty right side means a removed page. Look up last revision. 418 $rev2 = $this->currentRevision(); 419 } 420 //collect revisions around rev2 421 $result2 = $this->retrieveRevisionsAround($rev2, $max); 422 if ($result2 === false) return [[], []]; 423 [$revs2, $allRevs, $fp, $lines, $head, $tail] = $result2; 424 425 if (empty($revs2)) return [[], []]; 426 427 //collect revisions around rev1 428 $index = array_search($rev1, $allRevs); 429 if ($index === false) { 430 //no overlapping revisions 431 $result1 = $this->retrieveRevisionsAround($rev1, $max); 432 if ($result1 === false) { 433 $revs1 = []; 434 } else { 435 [$revs1, , , , , ] = $result1; 436 if (empty($revs1)) $revs1 = []; 437 } 438 } else { 439 //revisions overlaps, reuse revisions around rev2 440 $lastRev = array_pop($allRevs); //keep last entry that could be external edit 441 $revs1 = $allRevs; 442 while ($head > 0) { 443 for ($i = count($lines) - 1; $i >= 0; $i--) { 444 $info = $this->parseAndCacheLogLine($lines[$i]); 445 if (is_array($info)) { 446 $revs1[] = $info['date']; 447 $index++; 448 449 if ($index > (int) ($max / 2)) { 450 break 2; 451 } 452 } 453 } 454 455 [$lines, $head, $tail] = $this->readAdjacentChunk($fp, $head, $tail, -1); 456 } 457 sort($revs1); 458 $revs1[] = $lastRev; //push back last entry 459 460 //return wanted selection 461 $revs1 = array_slice($revs1, max($index - (int) ($max / 2), 0), $max); 462 } 463 464 return [array_reverse($revs1), array_reverse($revs2)]; 465 } 466 467 /** 468 * Return an existing revision for a specific date which is 469 * the current one or younger or equal then the date 470 * 471 * @param number $date_at timestamp 472 * @return string revision ('' for current) 473 */ 474 public function getLastRevisionAt($date_at) 475 { 476 $fileLastMod = $this->getFilename(); 477 //requested date_at(timestamp) younger or equal then modified_time($this->id) => load current 478 if (file_exists($fileLastMod) && $date_at >= @filemtime($fileLastMod)) { 479 return ''; 480 } elseif ($rev = $this->getRelativeRevision($date_at + 1, -1)) { 481 //+1 to get also the requested date revision 482 return $rev; 483 } else { 484 return false; 485 } 486 } 487 488 /** 489 * Collect the $max revisions near to the timestamp $rev 490 * 491 * Ideally, half of retrieved timestamps are older than $rev, another half are newer. 492 * The returned array $requestedRevs may not contain the reference timestamp $rev 493 * when it does not match any revision value recorded in changelog. 494 * 495 * @param int $rev revision timestamp 496 * @param int $max maximum number of revisions to be returned 497 * @return bool|array 498 * return array with entries: 499 * - $requestedRevs: array of with $max revision timestamps 500 * - $revs: all parsed revision timestamps 501 * - $fp: file pointer only defined for chuck reading, needs closing. 502 * - $lines: non-parsed changelog lines before the parsed revisions 503 * - $head: position of first read changelog line 504 * - $lastTail: position of end of last read changelog line 505 * otherwise false 506 */ 507 protected function retrieveRevisionsAround($rev, $max) 508 { 509 $revs = []; 510 $afterCount = 0; 511 $beforeCount = 0; 512 513 //get lines from changelog 514 $result = $this->readloglines($rev); 515 if ($result === false) return false; 516 [$fp, $lines, $startHead, $startTail, $eof] = $result; 517 if (empty($lines)) return false; 518 519 //parse changelog lines in chunk, and read forward more chunks until $max/2 is reached 520 $head = $startHead; 521 $tail = $startTail; 522 while (count($lines) > 0) { 523 foreach ($lines as $line) { 524 $info = $this->parseAndCacheLogLine($line); 525 if (is_array($info)) { 526 $revs[] = $info['date']; 527 if ($info['date'] >= $rev) { 528 //count revs after reference $rev 529 $afterCount++; 530 if ($afterCount == 1) { 531 $beforeCount = count($revs); 532 } 533 } 534 //enough revs after reference $rev? 535 if ($afterCount > (int) ($max / 2)) { 536 break 2; 537 } 538 } 539 } 540 //retrieve next chunk 541 [$lines, $head, $tail] = $this->readAdjacentChunk($fp, $head, $tail, 1); 542 } 543 $lastTail = $tail; 544 545 // add a possible revision of external edit, create or deletion 546 if ( 547 $lastTail == $eof && $afterCount <= (int) ($max / 2) && 548 count($revs) && !$this->isCurrentRevision($revs[count($revs) - 1]) 549 ) { 550 $revs[] = $this->currentRevision; 551 $afterCount++; 552 } 553 554 if ($afterCount == 0) { 555 //given timestamp $rev is newer than the most recent line in chunk 556 return false; //FIXME: or proceed to collect older revisions? 557 } 558 559 //read more chunks backward until $max/2 is reached and total number of revs is equal to $max 560 $lines = []; 561 $i = 0; 562 $head = $startHead; 563 $tail = $startTail; 564 while ($head > 0) { 565 [$lines, $head, $tail] = $this->readAdjacentChunk($fp, $head, $tail, -1); 566 567 for ($i = count($lines) - 1; $i >= 0; $i--) { 568 $info = $this->parseAndCacheLogLine($lines[$i]); 569 if (is_array($info)) { 570 $revs[] = $info['date']; 571 $beforeCount++; 572 //enough revs before reference $rev? 573 if ($beforeCount > max((int) ($max / 2), $max - $afterCount)) { 574 break 2; 575 } 576 } 577 } 578 } 579 //keep only non-parsed lines 580 $lines = array_slice($lines, 0, $i); 581 582 sort($revs); 583 584 //trunk desired selection 585 $requestedRevs = array_slice($revs, -$max, $max); 586 587 return [$requestedRevs, $revs, $fp, $lines, $head, $lastTail]; 588 } 589 590 /** 591 * Get the current revision information, considering external edit, create or deletion 592 * 593 * When the file has not modified since its last revision, the information of the last 594 * change that had already recorded in the changelog is returned as current change info. 595 * Otherwise, the change information since the last revision caused outside DokuWiki 596 * should be returned, which is referred as "external revision". 597 * 598 * External revisions are persisted to the changelog (and, for non-delete cases, copied 599 * to the attic) on first detection so subsequent reads see one canonical entry instead 600 * of recomputing a synthesized one. If persistence fails (e.g. the data dir is not 601 * writable in the current process context), the in-memory synthesized entry is still 602 * returned so the read path keeps working. 603 * 604 * @return bool|array false when page had never existed or array with entries: 605 * - date: revision identifier (timestamp or last revision +1) 606 * - ip: IPv4 address (127.0.0.1) 607 * - type: log line type 608 * - id: id of page or media 609 * - user: user name 610 * - sum: edit summary (or action reason) 611 * - extra: extra data (varies by line type) 612 * - sizechange: change of filesize 613 * - timestamp: unix timestamp or false (key set only for external edit occurred) 614 * additional: 615 * - mode: page or media 616 * 617 * @author Satoshi Sahara <sahara.satoshi@gmail.com> 618 */ 619 public function getCurrentRevisionInfo() 620 { 621 global $lang; 622 623 if (isset($this->currentRevision)) { 624 return $this->getRevisionInfo($this->currentRevision); 625 } 626 627 // get revision id from the item file timestamp and changelog 628 $fileLastMod = $this->getFilename(); 629 $fileRev = @filemtime($fileLastMod); // false when the file not exist 630 $lastRev = $this->lastRevision(); // false when no changelog 631 632 if (!$fileRev && !$lastRev) { // has never existed 633 $this->currentRevision = false; 634 return false; 635 } elseif ($fileRev === $lastRev) { // not external edit 636 $this->currentRevision = $lastRev; 637 return $this->getRevisionInfo($lastRev); 638 } 639 640 if (!$fileRev && $lastRev) { // item file does not exist 641 // check consistency against changelog 642 $revInfo = $this->getRevisionInfo($lastRev, false); 643 if ($revInfo['type'] == DOKU_CHANGE_TYPE_DELETE) { 644 $this->currentRevision = $lastRev; 645 return $revInfo; 646 } 647 648 // externally deleted, set revision date as late as possible 649 $revInfo = [ 650 'date' => max($lastRev + 1, time() - 1), // 1 sec before now or last revision +1 651 'ip' => '127.0.0.1', 652 'type' => DOKU_CHANGE_TYPE_DELETE, 653 'id' => $this->id, 654 'user' => '', 655 'sum' => $lang['deleted'] . ' - ' . $lang['external_edit'] . ' (' . $lang['unknowndate'] . ')', 656 'extra' => '', 657 'sizechange' => -io_getSizeFile($this->getFilename($lastRev)), 658 'timestamp' => false, 659 'mode' => $this->getMode() 660 ]; 661 } else { // item file exists, with timestamp $fileRev 662 // here, file timestamp $fileRev is different with last revision timestamp $lastRev in changelog 663 $isJustCreated = $lastRev === false || ( 664 $fileRev > $lastRev && 665 $this->getRevisionInfo($lastRev, false)['type'] == DOKU_CHANGE_TYPE_DELETE 666 ); 667 $filesize_new = filesize($this->getFilename()); 668 $filesize_old = $isJustCreated ? 0 : io_getSizeFile($this->getFilename($lastRev)); 669 $sizechange = $filesize_new - $filesize_old; 670 671 if ($isJustCreated) { 672 $timestamp = $fileRev; 673 $sum = $lang['created'] . ' - ' . $lang['external_edit']; 674 } elseif ($fileRev > $lastRev) { 675 $timestamp = $fileRev; 676 $sum = $lang['external_edit']; 677 } else { 678 // $fileRev is older than $lastRev, that is erroneous/incorrect occurrence. 679 $msg = "Warning: current file modification time is older than last revision date"; 680 $details = 'File revision: ' . $fileRev . ' ' . dformat($fileRev, "%Y-%m-%d %H:%M:%S") . "\n" 681 . 'Last revision: ' . $lastRev . ' ' . dformat($lastRev, "%Y-%m-%d %H:%M:%S"); 682 Logger::error($msg, $details, $this->getFilename()); 683 $timestamp = false; 684 $sum = $lang['external_edit'] . ' (' . $lang['unknowndate'] . ')'; 685 } 686 687 // externally created or edited 688 $revInfo = [ 689 'date' => $timestamp ?: $lastRev + 1, 690 'ip' => '127.0.0.1', 691 'type' => $isJustCreated ? DOKU_CHANGE_TYPE_CREATE : DOKU_CHANGE_TYPE_EDIT, 692 'id' => $this->id, 693 'user' => '', 694 'sum' => $sum, 695 'extra' => '', 696 'sizechange' => $sizechange, 697 'timestamp' => $timestamp, 698 'mode' => $this->getMode() 699 ]; 700 } 701 702 // persist the synthesized entry so subsequent reads see it as a real changelog entry 703 $this->persistCurrentRevisionInfo($revInfo); 704 705 // cache current revision information of external edition 706 $this->currentRevision = $revInfo['date']; 707 $this->cache[$this->id][$this->currentRevision] = $revInfo; 708 return $this->getRevisionInfo($this->currentRevision); 709 } 710 711 /** 712 * Adds an entry to the changelog 713 * 714 * Locks the local changelog file for the duration of the write so concurrent writers 715 * serialize through the same key. Subclasses provide the actual append logic via 716 * writeLogEntry() so persistCurrentRevisionInfo() can append while already holding 717 * the lock without re-entering it. 718 * 719 * Best-effort: if writeLogEntry() throws, surfaces the error via msg() and still 720 * returns the info dict so existing callers (saveWikiText etc.) keep working. 721 * 722 * @param array $info Revision info structure of a page or media file 723 * @param int $timestamp log line date (optional) 724 * @return array revision info of added log line 725 */ 726 public function addLogEntry(array $info, $timestamp = null) 727 { 728 $logfile = $this->getChangelogFilename(); 729 io_lock($logfile); 730 try { 731 return $this->writeLogEntry($info, $timestamp); 732 } catch (\RuntimeException $e) { 733 msg($e->getMessage(), -1); 734 $info['mode'] = $this->getMode(); 735 return $info; 736 } finally { 737 io_unlock($logfile); 738 } 739 } 740 741 /** 742 * Append a log entry to the changelog files and update the in-memory cache. 743 * 744 * The caller MUST hold io_lock() on the local changelog file. The global changelog 745 * is a different file with its own lock (acquired briefly here). This is what 746 * addLogEntry() runs under its lock, and what persistCurrentRevisionInfo() calls 747 * directly while it's holding the same lock for the detect-and-write critical section. 748 * 749 * @param array $info Revision info structure 750 * @param int $timestamp log line date (optional) 751 * @return array revision info of added log line 752 * @throws \RuntimeException if the local changelog write fails 753 */ 754 protected function writeLogEntry(array $info, $timestamp = null) 755 { 756 global $conf; 757 758 if (isset($timestamp)) unset($this->cache[$this->id][$info['date']]); 759 760 $logline = static::buildLogLine($info, $timestamp); 761 762 // append to local changelog without re-locking (caller holds the lock) 763 $localFile = $this->getChangelogFilename(); 764 io_makeFileDir($localFile); 765 $fileexists = file_exists($localFile); 766 $fh = @fopen($localFile, 'ab'); 767 if (!$fh || @fwrite($fh, $logline) === false) { 768 if ($fh) @fclose($fh); 769 throw new \RuntimeException("Writing $localFile failed"); 770 } 771 fclose($fh); 772 if (!$fileexists && !empty($conf['fperm'])) chmod($localFile, $conf['fperm']); 773 774 // global changelog has its own lock and msg() reporting via io_saveFile() 775 io_saveFile($this->getGlobalChangelogFilename(), $logline, true); 776 777 $this->currentRevision = $info['date']; 778 $info['mode'] = $this->getMode(); 779 $this->cache[$this->id][$this->currentRevision] = $info; 780 return $info; 781 } 782 783 /** 784 * Persist a synthesized external-revision entry to the changelog 785 * 786 * Holds the local changelog lock around the entire detect-and-write critical section 787 * (idempotency check, attic copy, log append) so it serializes against any other 788 * writer that goes through addLogEntry(). The append uses writeLogEntry() to avoid 789 * re-entering the lock we already hold. 790 * 791 * Returns false (without raising) when the attic write fails or another request 792 * already persisted the entry. The caller falls back to the in-memory synthesized 793 * entry. 794 * 795 * @param array $revInfo synthesized revision info 796 * @return bool true if newly persisted, false otherwise 797 */ 798 protected function persistCurrentRevisionInfo(array $revInfo) 799 { 800 // only the synthesized branches carry the 'timestamp' key 801 if (!array_key_exists('timestamp', $revInfo)) return false; 802 803 $logfile = $this->getChangelogFilename(); 804 io_lock($logfile); 805 try { 806 // re-read lastRev under the lock — another request may have just persisted 807 $lastRev = $this->lastRevision(); 808 if ($lastRev !== false && $lastRev >= $revInfo['date']) { 809 return false; 810 } 811 812 if ($revInfo['type'] !== DOKU_CHANGE_TYPE_DELETE) { 813 if (!$this->saveExternalAttic($revInfo)) return false; 814 } 815 816 $this->writeLogEntry($revInfo); 817 return true; 818 } catch (\RuntimeException) { 819 // silent fallback to in-memory synthesis 820 return false; 821 } finally { 822 io_unlock($logfile); 823 } 824 } 825 826 /** 827 * Save the externally-modified file to the attic before the synthesized log entry is 828 * persisted. For deletions there is no file to copy and implementations should return 829 * true (the persist flow skips this call for the delete branch). 830 * 831 * @param array $revInfo synthesized revision info with 'date' set 832 * @return bool true on success or no-op, false to abort persistence 833 */ 834 abstract protected function saveExternalAttic(array $revInfo); 835 836 /** 837 * Mechanism to trace no-actual external current revision 838 * @param int $rev 839 */ 840 public function traceCurrentRevision($rev) 841 { 842 if ($rev > $this->lastRevision()) { 843 $rev = $this->currentRevision(); 844 } 845 return $rev; 846 } 847} 848