julienmonnerie/kirby/src/Cms/ContentLocks.php

229 lines
6.3 KiB
PHP
Raw Normal View History

2022-06-17 17:51:59 +02:00
<?php
namespace Kirby\Cms;
use Kirby\Data\Data;
use Kirby\Exception\Exception;
use Kirby\Filesystem\F;
/**
* Manages all content lock files
*
* @package Kirby Cms
* @author Nico Hoffmann <nico@getkirby.com>,
* Lukas Bestle <lukas@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class ContentLocks
{
/**
* Data from the `.lock` files
* that have been read so far
* cached by `.lock` file path
*
* @var array
*/
protected $data = [];
/**
* PHP file handles for all currently
* open `.lock` files
*
* @var array
*/
protected $handles = [];
/**
* Closes the open file handles
*
* @codeCoverageIgnore
*/
public function __destruct()
{
foreach ($this->handles as $file => $handle) {
$this->closeHandle($file);
}
}
/**
* Removes the file lock and closes the file handle
*
* @param string $file
* @return void
* @throws \Kirby\Exception\Exception
*/
protected function closeHandle(string $file)
{
if (isset($this->handles[$file]) === false) {
return;
}
$handle = $this->handles[$file];
$result = flock($handle, LOCK_UN) && fclose($handle);
if ($result !== true) {
throw new Exception('Unexpected file system error.'); // @codeCoverageIgnore
}
unset($this->handles[$file]);
}
/**
* Returns the path to a model's lock file
*
* @param \Kirby\Cms\ModelWithContent $model
* @return string
*/
public static function file(ModelWithContent $model): string
{
return $model->contentFileDirectory() . '/.lock';
}
/**
* Returns the lock/unlock data for the specified model
*
* @param \Kirby\Cms\ModelWithContent $model
* @return array
*/
public function get(ModelWithContent $model): array
{
$file = static::file($model);
$id = static::id($model);
// return from cache if file was already loaded
if (isset($this->data[$file]) === true) {
return $this->data[$file][$id] ?? [];
}
// first get a handle to ensure a file system lock
$handle = $this->handle($file);
if (is_resource($handle) === true) {
// read data from file
clearstatcache();
$filesize = filesize($file);
if ($filesize > 0) {
// always read the whole file
rewind($handle);
$string = fread($handle, $filesize);
$data = Data::decode($string, 'yaml');
}
}
$this->data[$file] = $data ?? [];
return $this->data[$file][$id] ?? [];
}
/**
* Returns the file handle to a `.lock` file
*
* @param string $file
* @param bool $create Whether to create the file if it does not exist
* @return resource|null File handle
* @throws \Kirby\Exception\Exception
*/
protected function handle(string $file, bool $create = false)
{
// check for an already open handle
if (isset($this->handles[$file]) === true) {
return $this->handles[$file];
}
// don't create a file if not requested
if (is_file($file) !== true && $create !== true) {
return null;
}
$handle = @fopen($file, 'c+b');
if (is_resource($handle) === false) {
throw new Exception('Lock file ' . $file . ' could not be opened.'); // @codeCoverageIgnore
}
// lock the lock file exclusively to prevent changes by other threads
$result = flock($handle, LOCK_EX);
if ($result !== true) {
throw new Exception('Unexpected file system error.'); // @codeCoverageIgnore
}
return $this->handles[$file] = $handle;
}
/**
* Returns model ID used as the key for the data array;
* prepended with a slash because the $site otherwise won't have an ID
*
* @param \Kirby\Cms\ModelWithContent $model
* @return string
*/
public static function id(ModelWithContent $model): string
{
return '/' . $model->id();
}
/**
* Sets and writes the lock/unlock data for the specified model
*
* @param \Kirby\Cms\ModelWithContent $model
* @param array $data
* @return bool
* @throws \Kirby\Exception\Exception
*/
public function set(ModelWithContent $model, array $data): bool
{
$file = static::file($model);
$id = static::id($model);
$handle = $this->handle($file, true);
$this->data[$file][$id] = $data;
// make sure to unset model id entries,
// if no lock data for the model exists
foreach ($this->data[$file] as $id => $data) {
// there is no data for that model whatsoever
if (
isset($data['lock']) === false &&
(isset($data['unlock']) === false ||
count($data['unlock']) === 0)
) {
unset($this->data[$file][$id]);
// there is empty unlock data, but still lock data
} elseif (
isset($data['unlock']) === true &&
count($data['unlock']) === 0
) {
unset($this->data[$file][$id]['unlock']);
}
}
// there is no data left in the file whatsoever, delete the file
if (count($this->data[$file]) === 0) {
unset($this->data[$file]);
// close the file handle, otherwise we can't delete it on Windows
$this->closeHandle($file);
return F::remove($file);
}
$yaml = Data::encode($this->data[$file], 'yaml');
// delete all file contents first
if (rewind($handle) !== true || ftruncate($handle, 0) !== true) {
throw new Exception('Could not write lock file ' . $file . '.'); // @codeCoverageIgnore
}
// write the new contents
$result = fwrite($handle, $yaml);
if (is_int($result) === false || $result === 0) {
throw new Exception('Could not write lock file ' . $file . '.'); // @codeCoverageIgnore
}
return true;
}
}