diff --git a/engine/core/class/AssetManager.php b/engine/core/class/AssetManager.php new file mode 100644 index 0000000..01752f5 --- /dev/null +++ b/engine/core/class/AssetManager.php @@ -0,0 +1,69 @@ +css[] = $path; + } + + public function addJs(string $path): void { + $this->js[] = $path; + } + + public function addBootstrapCss(): void { + $this->addCss('/assets/css/bootstrap.min.css'); + $this->addCss('/assets/css/bootstrap-icons.css'); + } + + public function addBootstrapJs(): void { + $this->addJs('/assets/js/bootstrap.bundle.min.js'); + } + + public function addThemeCss(): void { + $this->addCss('/assets/css/style.css'); + $this->addCss('/assets/css/mobile.css'); + } + + public function addAppJs(): void { + $this->addJs('/assets/js/app.js'); + } + + public function renderCss(): string { + $html = ''; + foreach ($this->css as $path) { + $fullPath = $_SERVER['DOCUMENT_ROOT'] . $path; + $version = file_exists($fullPath) ? filemtime($fullPath) : time(); + $html .= "\n"; + } + return $html; + } + + public function renderJs(): string { + $html = ''; + foreach ($this->js as $path) { + $fullPath = $_SERVER['DOCUMENT_ROOT'] . $path; + $version = file_exists($fullPath) ? filemtime($fullPath) : time(); + $html .= "\n"; + } + return $html; + } + + public function getCssCount(): int { + return count($this->css); + } + + public function getJsCount(): int { + return count($this->js); + } + + public function clear(): void { + $this->css = []; + $this->js = []; + } +} \ No newline at end of file diff --git a/engine/core/class/Cache.php b/engine/core/class/Cache.php new file mode 100644 index 0000000..242195e --- /dev/null +++ b/engine/core/class/Cache.php @@ -0,0 +1,77 @@ +cacheDir = $cacheDir; + if (!is_dir($this->cacheDir)) { + mkdir($this->cacheDir, 0755, true); + } + } + + public function get(string $key) { + $file = $this->getCacheFile($key); + if (!file_exists($file)) { + return null; + } + + $data = unserialize(file_get_contents($file)); + if ($data['expires'] < time()) { + unlink($file); + return null; + } + + return $data['value']; + } + + public function set(string $key, $value, int $ttl = 3600): bool { + $file = $this->getCacheFile($key); + $data = [ + 'value' => $value, + 'expires' => time() + $ttl + ]; + + return file_put_contents($file, serialize($data)) !== false; + } + + public function delete(string $key): bool { + $file = $this->getCacheFile($key); + if (file_exists($file)) { + return unlink($file); + } + return true; + } + + public function clear(): bool { + $files = glob($this->cacheDir . '/*'); + foreach ($files as $file) { + if (is_file($file)) { + unlink($file); + } + } + return true; + } + + public function has(string $key): bool { + $file = $this->getCacheFile($key); + if (!file_exists($file)) { + return false; + } + + $data = unserialize(file_get_contents($file)); + return $data['expires'] > time(); + } + + private function getCacheFile(string $key): string { + return $this->cacheDir . '/' . md5($key) . '.cache'; + } +} \ No newline at end of file diff --git a/engine/core/class/ContentSecurityPolicy.php b/engine/core/class/ContentSecurityPolicy.php new file mode 100644 index 0000000..801a18e --- /dev/null +++ b/engine/core/class/ContentSecurityPolicy.php @@ -0,0 +1,50 @@ +directives = [ + 'default-src' => ["'self'"], + 'script-src' => ["'self'", "'unsafe-inline'"], + 'style-src' => ["'self'", "'unsafe-inline'"], + 'img-src' => ["'self'", 'data:', 'https:'], + 'font-src' => ["'self'"], + 'connect-src' => ["'self'"], + 'media-src' => ["'self'"], + 'object-src' => ["'none'"], + 'frame-src' => ["'none'"], + 'base-uri' => ["'self'"], + 'form-action' => ["'self'"] + ]; + } + + public function addDirective(string $name, array $values): void { + if (!isset($this->directives[$name])) { + $this->directives[$name] = []; + } + $this->directives[$name] = array_merge($this->directives[$name], $values); + } + + public function removeDirective(string $name): void { + unset($this->directives[$name]); + } + + public function setDirective(string $name, array $values): void { + $this->directives[$name] = $values; + } + + public function toHeader(): string { + $parts = []; + foreach ($this->directives as $directive => $values) { + if (!empty($values)) { + $parts[] = $directive . ' ' . implode(' ', $values); + } + } + return implode('; ', $parts); + } + + public function toMetaTag(): string { + return ''; + } +} \ No newline at end of file diff --git a/engine/core/class/RateLimiter.php b/engine/core/class/RateLimiter.php new file mode 100644 index 0000000..e22c013 --- /dev/null +++ b/engine/core/class/RateLimiter.php @@ -0,0 +1,51 @@ +maxAttempts = $maxAttempts; + $this->timeWindow = $timeWindow; + $this->cache = $cache ?? new FileCache(); + } + + public function isAllowed(string $identifier): bool { + $key = 'ratelimit_' . md5($identifier); + $attempts = $this->cache->get($key) ?? []; + + // Clean old attempts + $now = time(); + $windowStart = $now - $this->timeWindow; + $attempts = array_filter($attempts, fn($time) => $time > $windowStart); + + $attemptCount = count($attempts); + + if ($attemptCount >= $this->maxAttempts) { + return false; + } + + $attempts[] = $now; + $this->cache->set($key, $attempts, $this->timeWindow); + + return true; + } + + public function getRemainingAttempts(string $identifier): int { + $key = 'ratelimit_' . md5($identifier); + $attempts = $this->cache->get($key) ?? []; + + // Clean old attempts + $now = time(); + $windowStart = $now - $this->timeWindow; + $attempts = array_filter($attempts, fn($time) => $time > $windowStart); + + return max(0, $this->maxAttempts - count($attempts)); + } + + public function reset(string $identifier): void { + $key = 'ratelimit_' . md5($identifier); + $this->cache->delete($key); + } +} \ No newline at end of file diff --git a/engine/core/class/SearchEngine.php b/engine/core/class/SearchEngine.php new file mode 100644 index 0000000..1032941 --- /dev/null +++ b/engine/core/class/SearchEngine.php @@ -0,0 +1,127 @@ +cache = $cache ?? new FileCache(); + $this->loadIndex(); + } + + public function indexContent(string $path, string $content, array $metadata = []): void { + $words = $this->tokenize($content); + $pathHash = md5($path); + + foreach ($words as $word) { + if (!isset($this->index[$word])) { + $this->index[$word] = []; + } + if (!in_array($pathHash, $this->index[$word])) { + $this->index[$word][] = $pathHash; + } + } + + // Store metadata for this path + $this->cache->set('search_meta_' . $pathHash, [ + 'path' => $path, + 'title' => $metadata['title'] ?? basename($path), + 'snippet' => $this->generateSnippet($content), + 'last_modified' => $metadata['modified'] ?? time() + ], 86400); // 24 hours + + $this->saveIndex(); + } + + public function search(string $query, int $limit = 20): array { + $terms = $this->tokenize($query); + $results = []; + $pathScores = []; + + foreach ($terms as $term) { + if (isset($this->index[$term])) { + foreach ($this->index[$term] as $pathHash) { + if (!isset($pathScores[$pathHash])) { + $pathScores[$pathHash] = 0; + } + $pathScores[$pathHash]++; + } + } + } + + // Sort by relevance (term frequency) + arsort($pathScores); + + // Get top results + $count = 0; + foreach ($pathScores as $pathHash => $score) { + if ($count >= $limit) break; + + $metadata = $this->cache->get('search_meta_' . $pathHash); + if ($metadata) { + $results[] = array_merge($metadata, ['score' => $score]); + $count++; + } + } + + return $results; + } + + public function removeFromIndex(string $path): void { + $pathHash = md5($path); + + foreach ($this->index as $word => $paths) { + $this->index[$word] = array_filter($paths, fn($hash) => $hash !== $pathHash); + if (empty($this->index[$word])) { + unset($this->index[$word]); + } + } + + $this->cache->delete('search_meta_' . $pathHash); + $this->saveIndex(); + } + + public function clearIndex(): void { + $this->index = []; + $this->cache->clear(); + $this->saveIndex(); + } + + private function tokenize(string $text): array { + // Convert to lowercase, remove punctuation, split into words + $text = strtolower($text); + $text = preg_replace('/[^\w\s]/u', ' ', $text); + $words = preg_split('/\s+/u', $text, -1, PREG_SPLIT_NO_EMPTY); + + // Filter out common stop words and short words + $stopWords = ['the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by', 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could', 'should', 'may', 'might', 'must', 'can']; + $words = array_filter($words, function($word) use ($stopWords) { + return strlen($word) > 2 && !in_array($word, $stopWords); + }); + + return array_unique($words); + } + + private function generateSnippet(string $content, int $length = 150): string { + // Remove HTML tags and extra whitespace + $content = strip_tags($content); + $content = preg_replace('/\s+/', ' ', $content); + + if (strlen($content) <= $length) { + return $content; + } + + return substr($content, 0, $length) . '...'; + } + + private function loadIndex(): void { + $cached = $this->cache->get('search_index'); + if ($cached) { + $this->index = $cached; + } + } + + private function saveIndex(): void { + $this->cache->set('search_index', $this->index, 86400); // 24 hours + } +} \ No newline at end of file diff --git a/engine/templates/assets/header.mustache b/engine/templates/assets/header.mustache index 5560f08..5d06f95 100644 --- a/engine/templates/assets/header.mustache +++ b/engine/templates/assets/header.mustache @@ -7,19 +7,30 @@
-
- - + +
+ + +
Enter keywords to search through the documentation
+
+
@@ -30,12 +41,16 @@ - -
@@ -44,9 +59,16 @@ diff --git a/engine/templates/assets/navigation.mustache b/engine/templates/assets/navigation.mustache index 7a7898e..b59b192 100644 --- a/engine/templates/assets/navigation.mustache +++ b/engine/templates/assets/navigation.mustache @@ -1,13 +1,14 @@ -