pathConfig = $pathConfig; $this->maxDepth = max(0, $maxDepth); } /** * Generate a v4 UUID (lowercase). */ public static function createUuidV4(): string { $bytes = random_bytes(16); $bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40); $bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80); $hex = bin2hex($bytes); return sprintf( '%s-%s-%s-%s-%s', substr($hex, 0, 8), substr($hex, 8, 4), substr($hex, 12, 4), substr($hex, 16, 4), substr($hex, 20, 12) ); } /** * Normalize and validate a UUID v4 string. * * @param string $uuid * @return string|null */ public static function normalizeUuid(string $uuid): ?string { $uuid = strtolower(trim($uuid)); if ($uuid === '') return null; if (!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/', $uuid)) { return null; } return $uuid; } /** * Get the page's pagelink UUID from metadata (if valid). */ public function getPageUuid(string $pageId): ?string { if ($pageId === '') return null; if (!function_exists('p_get_metadata')) return null; $value = p_get_metadata($pageId, self::META_KEY, METADATA_DONT_RENDER); if (!is_string($value)) return null; return self::normalizeUuid($value); } /** * Persist a pagelink UUID in page metadata. */ public function setPageUuid(string $pageId, string $uuid): bool { if ($pageId === '') return false; if (!function_exists('p_set_metadata')) return false; $uuid = self::normalizeUuid($uuid); if ($uuid === null) return false; return (bool)p_set_metadata($pageId, [self::META_KEY => $uuid]); } /** * Remove the pagelink UUID from page metadata. */ public function removePageUuid(string $pageId): bool { if ($pageId === '') return false; if (!function_exists('p_set_metadata')) return false; return (bool)p_set_metadata($pageId, [self::META_KEY => '']); } /** * Unlink a page: remove UUID, delete linked .pagelink file if present, and clear cache. * * @param string $pageId * @return array{ok: bool, uuid: string|null, folder: string|null} */ public function unlinkPage(string $pageId): array { $uuid = $this->getPageUuid($pageId); if ($uuid === null) { return ['ok' => true, 'uuid' => null, 'folder' => null]; } $linkInfo = $this->resolveUuid($uuid); $folder = $linkInfo['folder'] ?? null; if (is_string($folder) && $folder !== '') { $file = rtrim($folder, '/\\') . '/.pagelink'; if (is_file($file) && !is_link($file)) { @unlink($file); } } $this->removeCacheEntry($uuid); $this->removePageUuid($pageId); return ['ok' => true, 'uuid' => $uuid, 'folder' => is_string($folder) ? $folder : null]; } /** * Resolve a pagelink UUID to a linked folder (if any). * * @param string $uuid * @return array{folder: string|null, multiple: bool} */ public function resolveUuid(string $uuid): array { $uuid = self::normalizeUuid($uuid); if ($uuid === null) { return ['folder' => null, 'multiple' => false]; } $cache = $this->loadCache(); if (isset($cache[$uuid]) && is_string($cache[$uuid]) && $cache[$uuid] !== '') { $cachedPath = $cache[$uuid]; if ($this->isValidLink($cachedPath, $uuid)) { return ['folder' => $cachedPath, 'multiple' => false]; } unset($cache[$uuid]); $this->cacheDirty = true; } $matches = $this->scanRootsForUuid($uuid, 2); if ($matches !== []) { $cache[$uuid] = $matches[0]; $this->cacheDirty = true; } if ($this->cacheDirty) { $this->writeCache($cache); } return [ 'folder' => $matches[0] ?? null, 'multiple' => count($matches) > 1, ]; } /** * Read the cache file into memory. * * @return array */ protected function loadCache(): array { if ($this->cache !== null) return $this->cache; $this->cache = []; $file = $this->getCacheFile(); if (!is_file($file) || !is_readable($file)) return $this->cache; $raw = @file_get_contents($file); if (!is_string($raw) || $raw === '') return $this->cache; $decoded = json_decode($raw, true); if (!is_array($decoded)) return $this->cache; $this->cache = $decoded; return $this->cache; } /** * Write cache to disk atomically. */ protected function writeCache(array $cache): void { $file = $this->getCacheFile(); $dir = dirname($file); if (function_exists('io_mkdir_p')) { io_mkdir_p($dir); } elseif (!@is_dir($dir)) { @mkdir($dir, 0777, true); } $tmp = $file . '.tmp.' . getmypid(); $data = json_encode($cache, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); if ($data === false) return; @file_put_contents($tmp, $data, LOCK_EX); if (!@rename($tmp, $file)) { @copy($tmp, $file); @unlink($tmp); } $this->cache = $cache; $this->cacheDirty = false; } /** * Remove a specific UUID from cache. */ public function removeCacheEntry(string $uuid): void { $uuid = self::normalizeUuid($uuid); if ($uuid === null) return; $cache = $this->loadCache(); if (!isset($cache[$uuid])) return; unset($cache[$uuid]); $this->writeCache($cache); } /** * Get cache file path for pagelink mappings. */ protected function getCacheFile(): string { global $conf; $cacheDir = rtrim((string)$conf['cachedir'], '/'); return $cacheDir . '/luxtools/' . self::CACHE_FILE; } /** * Check if the cached path still points to a valid .pagelink file. */ protected function isValidLink(string $folder, string $uuid): bool { if ($folder === '') return false; if (!is_dir($folder)) return false; if (is_link($folder)) return false; $file = rtrim($folder, '/\\') . '/.pagelink'; if (!is_file($file) || is_link($file) || !is_readable($file)) return false; $content = @file_get_contents($file); if (!is_string($content)) return false; $content = self::normalizeUuid($content); return $content !== null && $content === $uuid; } /** * Scan configured roots for matching .pagelink files. * * @param string $uuid * @param int $limit Maximum number of matches to collect. * @return string[] */ protected function scanRootsForUuid(string $uuid, int $limit = 2): array { $roots = $this->getConfiguredRoots(); if ($roots === []) return []; $matches = []; foreach ($roots as $root) { $this->scanDirectory($root, 0, $uuid, $limit, $matches); if (count($matches) >= $limit) break; } return $matches; } /** * Recursively scan a directory for .pagelink files. * * @param string $dir * @param int $depth * @param string $uuid * @param int $limit * @param array $matches */ protected function scanDirectory(string $dir, int $depth, string $uuid, int $limit, array &$matches): void { if ($dir === '' || count($matches) >= $limit) return; if (!is_dir($dir) || is_link($dir)) return; if (!is_readable($dir)) return; if ($depth > $this->maxDepth) return; $file = rtrim($dir, '/\\') . '/.pagelink'; if (is_file($file) && !is_link($file) && is_readable($file)) { $content = @file_get_contents($file); if (is_string($content)) { $content = self::normalizeUuid($content); if ($content !== null && $content === $uuid) { $matches[] = rtrim($dir, '/\\'); if (count($matches) >= $limit) return; } } } if ($depth >= $this->maxDepth) return; $handle = @opendir($dir); if ($handle === false) return; $base = rtrim($dir, '/\\'); while (($entry = readdir($handle)) !== false) { if ($entry === '.' || $entry === '..') continue; if ($entry === '.pagelink') continue; $path = $base . '/' . $entry; if (!is_dir($path) || is_link($path)) continue; $this->scanDirectory($path, $depth + 1, $uuid, $limit, $matches); if (count($matches) >= $limit) break; } closedir($handle); } /** * Resolve configured root paths (excluding aliases). * * @return string[] */ protected function getConfiguredRoots(): array { $pathConfig = trim($this->pathConfig); if ($pathConfig === '') return []; $helper = new Path($pathConfig); $paths = $helper->getPaths(); $roots = []; foreach ($paths as $key => $info) { if (!isset($info['root']) || $key !== $info['root']) continue; $roots[] = $info['root']; } return $roots; } }