, * Lukas Bestle * @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; } }