From 5bf87d4c3f5785975194d6c525646e3299c26cda Mon Sep 17 00:00:00 2001 From: admin Date: Sat, 19 Jul 2025 15:31:29 -0600 Subject: [PATCH] New search functionality --- config.sh | 122 +++++++++++++++ configs/mkdocs-site/default.conf | 24 +++ docker-compose.yml | 9 +- map/app/config/index.js | 8 + map/app/package-lock.json | 39 +++++ map/app/package.json | 1 + map/app/public/css/style.css | 169 ++++++++++++++++++++ map/app/public/index.html | 20 +++ map/app/public/js/main.js | 36 +++++ map/app/public/js/mkdocs-search.js | 241 +++++++++++++++++++++++++++++ map/app/routes/index.js | 20 +++ map/app/server.js | 50 +++++- 12 files changed, 734 insertions(+), 5 deletions(-) create mode 100644 configs/mkdocs-site/default.conf create mode 100644 map/app/public/js/mkdocs-search.js diff --git a/config.sh b/config.sh index f5b2f28..6997448 100755 --- a/config.sh +++ b/config.sh @@ -550,6 +550,124 @@ load_env_vars() { fi } +# Function to update or create the map's .env file with domain settings +update_map_env() { + local new_domain=$1 + + # Check if the map directory exists + if [ ! -d "$SCRIPT_DIR/map" ]; then + echo "Map directory not found at $SCRIPT_DIR/map" + return + fi + + echo "Creating/updating map .env file at: $MAP_ENV_FILE" + + cat > "$MAP_ENV_FILE" << EOL +NOCODB_API_URL=https://db.$new_domain/api/v1 +NOCODB_API_TOKEN=changeme + +# NocoDB View URL is the URL to your NocoDB view where the map data is stored. +NOCODB_VIEW_URL= + +# NOCODB_LOGIN_SHEET is the URL to your NocoDB login sheet. +NOCODB_LOGIN_SHEET= + +# NOCODB_SETTINGS_SHEET is the URL to your NocoDB settings sheet. +NOCODB_SETTINGS_SHEET= + +# NOCODB_SHIFTS_SHEET is the urls to your shifts sheets. +NOCODB_SHIFTS_SHEET= + +# NOCODB_SHIFT_SIGNUPS_SHEET is the URL to your NocoDB shift signups sheet where users can add their own shifts. +NOCODB_SHIFT_SIGNUPS_SHEET= + +# MkDocs Integration (using main domain, not subdomain) +MKDOCS_URL=https://$new_domain +MKDOCS_SEARCH_URL=https://$new_domain +MKDOCS_SITE_SERVER_PORT=4002 + +# Server Configuration +PORT=3000 +NODE_ENV=production + +# Session Secret (IMPORTANT: Generate a secure random string for production) +# You can generate one with: openssl rand -hex 32 +SESSION_SECRET=$(openssl rand -hex 32 2>/dev/null || echo "changeme") + +# Map Defaults (Edmonton, Alberta, Canada) +DEFAULT_LAT=53.5461 +DEFAULT_LNG=-113.4938 +DEFAULT_ZOOM=11 + +# Optional: Map Boundaries (prevents users from adding points outside area) +# BOUND_NORTH=53.7 +# BOUND_SOUTH=53.4 +# BOUND_EAST=-113.3 +# BOUND_WEST=-113.7 + +# Cloudflare Settings +TRUST_PROXY=true +COOKIE_DOMAIN=.$new_domain + +# Update NODE_ENV to production for HTTPS +NODE_ENV=production + +# Add allowed origin +ALLOWED_ORIGINS=https://map.$new_domain,http://localhost:3000 +EOL + + echo "Map .env file updated with domain: $new_domain" +} + +# Function to sync ports from root .env to map .env +sync_map_ports() { + echo "Syncing ports from root .env to map configuration..." + + # Load the current port values from root .env + local mkdocs_port=$(grep "^MKDOCS_PORT=" "$ENV_FILE" | cut -d'=' -f2) + local mkdocs_site_port=$(grep "^MKDOCS_SITE_SERVER_PORT=" "$ENV_FILE" | cut -d'=' -f2) + local map_port=$(grep "^MAP_PORT=" "$ENV_FILE" | cut -d'=' -f2) + + # Set defaults if not found + mkdocs_port=${mkdocs_port:-4000} + mkdocs_site_port=${mkdocs_site_port:-4002} + map_port=${map_port:-3000} + + # Update the map's .env with the correct ports + if grep -q "^PORT=" "$MAP_ENV_FILE"; then + sed -i "s|^PORT=.*|PORT=$map_port|" "$MAP_ENV_FILE" + else + echo "PORT=$map_port" >> "$MAP_ENV_FILE" + fi + + # Add/Update MkDocs configuration + if grep -q "^MKDOCS_URL=" "$MAP_ENV_FILE"; then + sed -i "s|^MKDOCS_URL=.*|MKDOCS_URL=http://localhost:$mkdocs_site_port|" "$MAP_ENV_FILE" + else + echo "" >> "$MAP_ENV_FILE" + echo "# MkDocs Integration" >> "$MAP_ENV_FILE" + echo "MKDOCS_URL=http://localhost:$mkdocs_site_port" >> "$MAP_ENV_FILE" + fi + + if grep -q "^MKDOCS_SEARCH_URL=" "$MAP_ENV_FILE"; then + sed -i "s|^MKDOCS_SEARCH_URL=.*|MKDOCS_SEARCH_URL=http://localhost:$mkdocs_site_port|" "$MAP_ENV_FILE" + else + echo "MKDOCS_SEARCH_URL=http://localhost:$mkdocs_site_port" >> "$MAP_ENV_FILE" + fi + + if grep -q "^MKDOCS_SITE_SERVER_PORT=" "$MAP_ENV_FILE"; then + sed -i "s|^MKDOCS_SITE_SERVER_PORT=.*|MKDOCS_SITE_SERVER_PORT=$mkdocs_site_port|" "$MAP_ENV_FILE" + else + echo "MKDOCS_SITE_SERVER_PORT=$mkdocs_site_port" >> "$MAP_ENV_FILE" + fi + + echo "✅ Synced ports to map configuration:" + echo " - Map Port: $map_port" + echo " - MkDocs Dev Port: $mkdocs_port" + echo " - MkDocs Site Port: $mkdocs_site_port" + echo " - MkDocs URL: http://localhost:$mkdocs_site_port" +} + # Function to update or create the map's .env file with domain settings update_map_env() { local new_domain=$1 @@ -649,6 +767,10 @@ EOL echo " - COOKIE_DOMAIN=.$new_domain" echo " - ALLOWED_ORIGINS=$allowed_origins" echo " - Updated all NocoDB URLs to use $new_domain domain" + + # Sync ports to map configuration + sync_map_ports + return 0 } diff --git a/configs/mkdocs-site/default.conf b/configs/mkdocs-site/default.conf new file mode 100644 index 0000000..7fd9f83 --- /dev/null +++ b/configs/mkdocs-site/default.conf @@ -0,0 +1,24 @@ +server { + listen 80; + server_name _; + + root /config/www; + index index.html; + + # CORS configuration for search index + location /search/search_index.json { + add_header 'Access-Control-Allow-Origin' '*' always; + add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS' always; + add_header 'Access-Control-Allow-Headers' 'Origin, Content-Type, Accept' always; + + if ($request_method = 'OPTIONS') { + return 204; + } + } + + # General CORS for all requests (optional) + location / { + add_header 'Access-Control-Allow-Origin' '*' always; + try_files $uri $uri/ =404; + } +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 4995b56..759e373 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -92,13 +92,14 @@ services: image: lscr.io/linuxserver/nginx:latest container_name: mkdocs-site-server-changemaker environment: - - PUID=${USER_ID:-1000} # Uses USER_ID from your .env file, defaults to 1000 - - PGID=${GROUP_ID:-1000} # Uses GROUP_ID from your .env file, defaults to 1000 + - PUID=${USER_ID:-1000} + - PGID=${GROUP_ID:-1000} - TZ=Etc/UTC volumes: - - ./mkdocs/site:/config/www # Mounts your static site to Nginx's web root + - ./mkdocs/site:/config/www + - ./configs/mkdocs-site/default.conf:/config/nginx/site-confs/default.conf # Add this line ports: - - "${MKDOCS_SITE_SERVER_PORT:-4001}:80" # Exposes Nginx's port 80 to host port 4001 + - "${MKDOCS_SITE_SERVER_PORT:-4001}:80" restart: unless-stopped networks: - changemaker-lite diff --git a/map/app/config/index.js b/map/app/config/index.js index 6c6eb96..d5a174c 100644 --- a/map/app/config/index.js +++ b/map/app/config/index.js @@ -79,6 +79,7 @@ module.exports = { port: process.env.PORT || 3000, nodeEnv: process.env.NODE_ENV || 'development', isProduction: process.env.NODE_ENV === 'production', + domain: process.env.DOMAIN || 'cmlite.org', // Add this // NocoDB config nocodb: { @@ -117,6 +118,13 @@ module.exports = { } : null }, + // MkDocs configuration + mkdocs: { + url: process.env.MKDOCS_URL || `http://localhost:${process.env.MKDOCS_SITE_SERVER_PORT || '4002'}`, + searchUrl: process.env.MKDOCS_SEARCH_URL || `http://localhost:${process.env.MKDOCS_SITE_SERVER_PORT || '4002'}`, + port: process.env.MKDOCS_SITE_SERVER_PORT || '4002' + }, + // Utility functions parseNocoDBUrl }; \ No newline at end of file diff --git a/map/app/package-lock.json b/map/app/package-lock.json index 2c9db57..4e93318 100644 --- a/map/app/package-lock.json +++ b/map/app/package-lock.json @@ -19,6 +19,7 @@ "form-data": "^4.0.0", "helmet": "^7.1.0", "multer": "^1.4.5-lts.1", + "node-fetch": "^2.7.0", "qrcode": "^1.5.3", "winston": "^3.11.0" }, @@ -1319,6 +1320,25 @@ "node": ">= 0.6" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/nodemon": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz", @@ -1963,6 +1983,11 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, "node_modules/triple-beam": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", @@ -2042,6 +2067,20 @@ "node": ">= 0.8" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which-module": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", diff --git a/map/app/package.json b/map/app/package.json index 606a2a8..6df1457 100644 --- a/map/app/package.json +++ b/map/app/package.json @@ -28,6 +28,7 @@ "form-data": "^4.0.0", "helmet": "^7.1.0", "multer": "^1.4.5-lts.1", + "node-fetch": "^2.7.0", "qrcode": "^1.5.3", "winston": "^3.11.0" }, diff --git a/map/app/public/css/style.css b/map/app/public/css/style.css index 15416e7..475eb05 100644 --- a/map/app/public/css/style.css +++ b/map/app/public/css/style.css @@ -868,6 +868,175 @@ body { transform: scale(0.95); } +/* Documentation Search Styles */ +.docs-search-container { + position: relative; + flex: 0 1 400px; + margin: 0 1rem; +} + +.docs-search-wrapper { + position: relative; +} + +.docs-search-input { + width: 100%; + padding: 0.5rem 2.5rem 0.5rem 1rem; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; + background: white; + transition: border-color 0.2s; +} + +.docs-search-input:focus { + outline: none; + border-color: #4CAF50; + box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.1); +} + +.docs-search-icon { + position: absolute; + right: 0.75rem; + top: 50%; + transform: translateY(-50%); + opacity: 0.5; + pointer-events: none; +} + +.docs-search-results { + position: absolute; + top: calc(100% + 0.5rem); + left: 0; + right: 0; + max-height: 60vh; + background: white; + border: 1px solid #ddd; + border-radius: 4px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + z-index: 1000; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.docs-search-results.hidden { + display: none; +} + +.docs-search-results-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 1rem; + border-bottom: 1px solid #eee; + background: #f5f5f5; +} + +.results-count { + font-size: 12px; + color: #666; + font-weight: 500; +} + +.close-results { + background: none; + border: none; + font-size: 20px; + cursor: pointer; + color: #666; + padding: 0; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: background-color 0.2s; +} + +.close-results:hover { + background-color: #e0e0e0; +} + +.docs-search-results-list { + overflow-y: auto; + flex: 1; +} + +.search-result { + display: block; + padding: 1rem; + border-bottom: 1px solid #eee; + text-decoration: none; + color: inherit; + transition: background-color 0.2s; +} + +.search-result:hover { + background-color: #f5f5f5; +} + +.search-result:last-child { + border-bottom: none; +} + +.search-result-title { + font-weight: 500; + color: #333; + margin-bottom: 0.25rem; +} + +.search-result-snippet { + font-size: 13px; + color: #666; + line-height: 1.4; + margin-bottom: 0.25rem; +} + +.search-result-snippet mark { + background-color: #ffeb3b; + color: inherit; + font-weight: 500; + padding: 0 2px; +} + +.search-result-path { + font-size: 11px; + color: #999; +} + +.no-results { + padding: 2rem; + text-align: center; + color: #666; +} + +/* Mobile responsiveness */ +@media (max-width: 768px) { + .docs-search-container { + display: block; /* Show on mobile */ + width: 100%; + margin: 10px 0; + padding: 0 10px; + } + .docs-search-wrapper { + width: 100%; + } + .docs-search-input { + width: 100%; + font-size: 16px; + padding: 0.75rem 2.5rem 0.75rem 1rem; + } + .docs-search-results { + left: 0; + right: 0; + width: 100vw; + max-width: 100vw; + min-width: 0; + } +} + /* Desktop styles - show normal layout */ @media (min-width: 769px) { .mobile-dropdown { diff --git a/map/app/public/index.html b/map/app/public/index.html index 11d8c92..3b22400 100644 --- a/map/app/public/index.html +++ b/map/app/public/index.html @@ -19,6 +19,26 @@

Map for CM-lite

+ + +
+
+ + 🔍 +
+ +
+
📅 diff --git a/map/app/public/js/main.js b/map/app/public/js/main.js index 3ed6f89..da0f20a 100644 --- a/map/app/public/js/main.js +++ b/map/app/public/js/main.js @@ -5,9 +5,11 @@ import { checkAuth } from './auth.js'; import { initializeMap } from './map-manager.js'; import { loadLocations } from './location-manager.js'; import { setupEventListeners } from './ui-controls.js'; +import { MkDocsSearch } from './mkdocs-search.js'; // Application state let refreshInterval = null; +let mkdocsSearch = null; // Initialize the application document.addEventListener('DOMContentLoaded', async () => { @@ -27,6 +29,9 @@ document.addEventListener('DOMContentLoaded', async () => { setupEventListeners(); setupAutoRefresh(); + // Initialize MkDocs search + await initializeMkDocsSearch(); + } catch (error) { console.error('Initialization error:', error); showStatus('Failed to initialize application', 'error'); @@ -47,3 +52,34 @@ window.addEventListener('beforeunload', () => { clearInterval(refreshInterval); } }); + +// Initialize MkDocs search +async function initializeMkDocsSearch() { + try { + // Get config from server + const configResponse = await fetch('/api/config'); + const config = await configResponse.json(); + + mkdocsSearch = new MkDocsSearch({ + mkdocsUrl: config.mkdocsUrl || 'http://localhost:4002', + minSearchLength: 2 + }); + + const initialized = await mkdocsSearch.initialize(); + + if (initialized) { + // Bind to search input + const searchInput = document.getElementById('docs-search-input'); + const searchResults = document.getElementById('docs-search-results'); + + if (searchInput && searchResults) { + mkdocsSearch.bindToInput(searchInput, searchResults); + console.log('Documentation search ready'); + } + } else { + console.warn('Documentation search could not be initialized'); + } + } catch (error) { + console.error('Error setting up documentation search:', error); + } +} diff --git a/map/app/public/js/mkdocs-search.js b/map/app/public/js/mkdocs-search.js new file mode 100644 index 0000000..85467ec --- /dev/null +++ b/map/app/public/js/mkdocs-search.js @@ -0,0 +1,241 @@ +/** + * MkDocs Search Integration + * Integrates MkDocs Material's search functionality into the map application + */ + +export class MkDocsSearch { + constructor(config = {}) { + // Determine if we're in production based on current URL + const isProduction = window.location.hostname !== 'localhost' && !window.location.hostname.includes('127.0.0.1'); + + // Use production URL if we're not on localhost + if (isProduction && config.mkdocsUrl && config.mkdocsUrl.includes('localhost')) { + // Extract the base domain from the current hostname + // If we're on map.cmlite.org, we want cmlite.org + const currentDomain = window.location.hostname.replace(/^map\./, ''); + this.mkdocsUrl = `https://${currentDomain}`; + } else { + this.mkdocsUrl = config.mkdocsUrl || window.MKDOCS_URL || 'http://localhost:4002'; + } + + this.searchIndex = null; + this.searchDocs = null; + this.lunr = null; + this.debounceTimeout = null; + this.minSearchLength = config.minSearchLength || 2; + this.initialized = false; + + console.log('MkDocs Search initialized with URL:', this.mkdocsUrl); + } + + async initialize() { + try { + console.log('Initializing MkDocs search...'); + + // Load Lunr.js dynamically + await this.loadLunr(); + + // Try multiple approaches to get the search index + let searchData = null; + + // First try the proxy endpoint + try { + console.log('Trying proxy endpoint: /api/docs-search'); + const proxyResponse = await fetch('/api/docs-search'); + if (proxyResponse.ok) { + searchData = await proxyResponse.json(); + console.log('Successfully loaded search index via proxy'); + } + } catch (proxyError) { + console.warn('Proxy endpoint failed:', proxyError); + } + + // If proxy fails, try direct access + if (!searchData) { + console.log(`Trying direct access: ${this.mkdocsUrl}/search/search_index.json`); + const response = await fetch(`${this.mkdocsUrl}/search/search_index.json`); + if (!response.ok) { + throw new Error(`Failed to load search index: ${response.status}`); + } + searchData = await response.json(); + console.log('Successfully loaded search index directly'); + } + + // Build the Lunr index + this.searchIndex = this.lunr(function() { + this.ref('location'); + this.field('title', { boost: 10 }); + this.field('text'); + + searchData.docs.forEach(doc => { + this.add(doc); + }); + }); + + this.searchDocs = searchData.docs; + this.initialized = true; + console.log('MkDocs search initialized with', this.searchDocs.length, 'documents'); + return true; + } catch (error) { + console.error('Failed to initialize MkDocs search:', error); + this.initialized = false; + return false; + } + } + + async loadLunr() { + if (window.lunr) { + this.lunr = window.lunr; + return; + } + + // Load Lunr.js from CDN + return new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = 'https://unpkg.com/lunr@2.3.9/lunr.min.js'; + script.onload = () => { + this.lunr = window.lunr; + resolve(); + }; + script.onerror = reject; + document.head.appendChild(script); + }); + } + + search(query) { + if (!this.initialized) { + console.warn('Search not initialized'); + return []; + } + + if (!this.searchIndex || !query || query.length < this.minSearchLength) { + return []; + } + + try { + // Perform the search + const results = this.searchIndex.search(query); + + // Map results to include document data + return results.slice(0, 10).map(result => { + const doc = this.searchDocs.find(d => d.location === result.ref); + if (!doc) return null; + + // Extract a snippet around the matched text + const snippet = this.extractSnippet(doc.text, query); + + return { + ...doc, + score: result.score, + url: `${this.mkdocsUrl}/${doc.location}`, + snippet: snippet + }; + }).filter(Boolean); + } catch (error) { + console.error('Search error:', error); + return []; + } + } + + extractSnippet(text, query, maxLength = 150) { + const lowerText = text.toLowerCase(); + const lowerQuery = query.toLowerCase(); + const index = lowerText.indexOf(lowerQuery); + + if (index === -1) { + return text.substring(0, maxLength) + '...'; + } + + const start = Math.max(0, index - 50); + const end = Math.min(text.length, index + query.length + 100); + let snippet = text.substring(start, end); + + if (start > 0) snippet = '...' + snippet; + if (end < text.length) snippet = snippet + '...'; + + // Highlight the search term + const regex = new RegExp(`(${query})`, 'gi'); + snippet = snippet.replace(regex, '$1'); + + return snippet; + } + + bindToInput(inputElement, resultsElement) { + const resultsContainer = resultsElement.querySelector('.docs-search-results-list'); + const resultsCount = resultsElement.querySelector('.results-count'); + const closeBtn = resultsElement.querySelector('.close-results'); + + // Handle input with debouncing + inputElement.addEventListener('input', (e) => { + clearTimeout(this.debounceTimeout); + this.debounceTimeout = setTimeout(() => { + this.performSearch(e.target.value, resultsContainer, resultsCount, resultsElement); + }, 300); + }); + + // Handle keyboard shortcuts + inputElement.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + this.closeResults(inputElement, resultsElement); + } + }); + + // Global keyboard shortcut (Ctrl+K or Cmd+K) + document.addEventListener('keydown', (e) => { + if ((e.ctrlKey || e.metaKey) && e.key === 'k') { + e.preventDefault(); + inputElement.focus(); + inputElement.select(); + } + }); + + // Close button + closeBtn.addEventListener('click', () => { + this.closeResults(inputElement, resultsElement); + }); + + // Click outside to close + document.addEventListener('click', (e) => { + if (!inputElement.contains(e.target) && !resultsElement.contains(e.target)) { + resultsElement.classList.add('hidden'); + } + }); + } + + performSearch(query, resultsContainer, resultsCount, resultsElement) { + if (!query || query.length < this.minSearchLength) { + resultsElement.classList.add('hidden'); + return; + } + + const results = this.search(query); + + if (results.length === 0) { + resultsContainer.innerHTML = '
No results found
'; + resultsCount.textContent = 'No results'; + } else { + resultsCount.textContent = `${results.length} result${results.length > 1 ? 's' : ''}`; + resultsContainer.innerHTML = results.map(result => ` +
+
${this.escapeHtml(result.title)}
+
${result.snippet}
+
${result.location}
+
+ `).join(''); + } + + resultsElement.classList.remove('hidden'); + } + + closeResults(inputElement, resultsElement) { + resultsElement.classList.add('hidden'); + inputElement.value = ''; + inputElement.blur(); + } + + escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } +} \ No newline at end of file diff --git a/map/app/routes/index.js b/map/app/routes/index.js index 2eb0219..2b6fb12 100644 --- a/map/app/routes/index.js +++ b/map/app/routes/index.js @@ -87,6 +87,26 @@ module.exports = (app) => { }); }); + // Config endpoint + app.get('/api/config', (req, res) => { + const config = require('../config'); + + // Determine the MkDocs URL based on the request + let mkdocsUrl = config.mkdocs.url; + + // If we're in production and the request is not from localhost + if (config.isProduction && req.hostname !== 'localhost' && !req.hostname.includes('127.0.0.1')) { + // Use the domain from config + const mainDomain = config.domain; + mkdocsUrl = `https://${mainDomain}`; + } + + res.json({ + mkdocsUrl: mkdocsUrl, + mkdocsPort: config.mkdocs.port + }); + }); + // Serve static files (protected) app.use(express.static(path.join(__dirname, '../public'), { index: false // Don't serve index.html automatically diff --git a/map/app/server.js b/map/app/server.js index 1c95647..4082e5a 100644 --- a/map/app/server.js +++ b/map/app/server.js @@ -4,6 +4,7 @@ const cors = require('cors'); const session = require('express-session'); const cookieParser = require('cookie-parser'); const crypto = require('crypto'); +const fetch = require('node-fetch'); // Import configuration and utilities const config = require('./config'); @@ -33,6 +34,33 @@ app.use(session({ } })); +// Build dynamic CSP configuration +const buildConnectSrc = () => { + const sources = ["'self'"]; + + // Add MkDocs URLs from config + if (config.mkdocs?.url) { + sources.push(config.mkdocs.url); + } + + // Add localhost ports from environment + const mkdocsPort = process.env.MKDOCS_PORT || '4000'; + const mkdocsSitePort = process.env.MKDOCS_SITE_SERVER_PORT || '4002'; + + sources.push(`http://localhost:${mkdocsPort}`); + sources.push(`http://localhost:${mkdocsSitePort}`); + + // Add production domains if in production + if (config.isProduction || process.env.NODE_ENV === 'production') { + // Add the main domain from environment + const mainDomain = process.env.DOMAIN || 'cmlite.org'; + sources.push(`https://${mainDomain}`); + sources.push('https://cmlite.org'); // Fallback + } + + return sources; +}; + // Security middleware app.use(helmet({ contentSecurityPolicy: { @@ -41,7 +69,7 @@ app.use(helmet({ styleSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com"], scriptSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com", "https://cdn.jsdelivr.net"], imgSrc: ["'self'", "data:", "https://*.tile.openstreetmap.org", "https://unpkg.com"], - connectSrc: ["'self'"] + connectSrc: buildConnectSrc() } } })); @@ -70,6 +98,26 @@ app.use(express.json({ limit: '10mb' })); // Apply rate limiting to API routes app.use('/api/', apiLimiter); +// Proxy endpoint for MkDocs search +app.get('/api/docs-search', async (req, res) => { + try { + const mkdocsUrl = config.mkdocs?.url || `http://localhost:${process.env.MKDOCS_SITE_SERVER_PORT || '4002'}`; + logger.info(`Fetching search index from: ${mkdocsUrl}/search/search_index.json`); + + const response = await fetch(`${mkdocsUrl}/search/search_index.json`); + + if (!response.ok) { + throw new Error(`Failed to fetch search index: ${response.status}`); + } + + const data = await response.json(); + res.json(data); + } catch (error) { + logger.error('Error fetching search index:', error); + res.status(500).json({ error: 'Failed to fetch search index' }); + } +}); + // Import and setup routes require('./routes')(app);