diff --git a/cmd/red/main.go b/cmd/red/main.go index 9e71f4f..2dc494a 100644 --- a/cmd/red/main.go +++ b/cmd/red/main.go @@ -30,7 +30,6 @@ func main() { cfg = loaded } - // --- NEW: Security Token Warning --- if cfg.AdminToken == "" || cfg.AdminToken == "secret123" { log.Println("=================================================================") log.Println("⚠️ SECURITY WARNING: Using default or missing Admin Token! ⚠️") @@ -41,7 +40,6 @@ func main() { log.Println("Windows: .\\manage-token.ps1") log.Println("=================================================================") } - // ----------------------------------- // 1. Core Knowledge Base Pulling if *pull && cfg.SourceURL != "" { @@ -52,11 +50,11 @@ func main() { log.Println("fetch complete") } - // 2. Startup Sync (Ported from Legacy Gateway) + // 2. Startup Sync if len(cfg.StartupSync) > 0 { + // Calculate the absolute path based on the config (e.g., /app/data/remote) remoteDir := filepath.Join(cfg.DataDir, "remote") - // Check for permission errors right here before proceeding if err := os.MkdirAll(remoteDir, 0755); err != nil { log.Fatalf("CRITICAL: Failed to create remote directory. Check volume permissions: %v", err) } @@ -65,7 +63,16 @@ func main() { for _, sync := range cfg.StartupSync { log.Printf("Startup Sync: Fetching %s...", sync.Filename) - if err := executeSync(client, sync.URL, filepath.Join(remoteDir, sync.Filename)); err != nil { + + // Auto-convert awesome-markdown shortcut to raw GitHub URL + downloadURL := sync.URL + if downloadURL == "https://github.com/mundimark/awesome-markdown" { + downloadURL = "https://raw.githubusercontent.com/mundimark/awesome-markdown/master/README.md" + } + + // Pass the absolute target path directly to avoid double-appending "data" + targetPath := filepath.Join(remoteDir, sync.Filename) + if err := executeSync(client, downloadURL, targetPath); err != nil { log.Printf("Startup Sync Error (%s): %v", sync.Filename, err) } else { log.Printf("Startup Sync: Successfully downloaded %s", sync.Filename) @@ -79,17 +86,18 @@ func main() { log.Fatalf("store: %v", err) } - // 4. Start HTTP Server with the Refactored Router + // Start Hot Reload Watcher + if err := s.Watch(); err != nil { + log.Printf("⚠️ Warning: Could not start hot reloader: %v", err) + } + + // 4. Start HTTP Server h := router.New(s, &cfg, *cfgPath) log.Printf("RED listening on %s", cfg.Addr) log.Fatal(http.ListenAndServe(cfg.Addr, h)) } -func executeSync(client *http.Client, targetURL, destSubPath string) error { - // Reconstruct target file paths relative to data root directory - // Note: main.go has context of cfg.DataDir - - // Let's resolve the path correctly depending on the initialization parameters +func executeSync(client *http.Client, targetURL, destPath string) error { lowerURL := strings.ToLower(targetURL) if strings.HasSuffix(lowerURL, ".tar.gz") || strings.HasSuffix(lowerURL, ".zip") { @@ -97,11 +105,9 @@ func executeSync(client *http.Client, targetURL, destSubPath string) error { if strings.HasSuffix(lowerURL, ".zip") { srcType = "zip" } - // Pull the dynamic folder contents using the internal archive worker - return fetch.Pull(targetURL, srcType, filepath.Join("data", destSubPath)) + return fetch.Pull(targetURL, srcType, destPath) } - // Otherwise, proceed with single file retrieval flow req, err := http.NewRequest(http.MethodGet, targetURL, nil) if err != nil { return err @@ -118,12 +124,12 @@ func executeSync(client *http.Client, targetURL, destSubPath string) error { return os.ErrPermission } - fullFilePath := filepath.Join("data", destSubPath) - if err := os.MkdirAll(filepath.Dir(fullFilePath), 0755); err != nil { + // Use destPath exactly as provided, removing the hardcoded "data" string + if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil { return err } - outFile, err := os.Create(fullFilePath) + outFile, err := os.Create(destPath) if err != nil { return err } diff --git a/docker-compose.yml b/docker-compose.yml index eafe97d..d2a5afc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,6 +4,7 @@ networks: services: red_engine: + build: . image: red-engine-image container_name: red_engine_node restart: unless-stopped @@ -20,8 +21,8 @@ services: container_name: caddy_proxy restart: unless-stopped ports: - - "80:80" - - "443:443" + - "8080:80" + - "8443:443" networks: - clearnet-tier volumes: diff --git a/go.mod b/go.mod index 7183ad8..7474651 100644 --- a/go.mod +++ b/go.mod @@ -2,11 +2,18 @@ module github.com/RED-Collective/red-engine go 1.26.2 -require github.com/yuin/goldmark v1.8.2 +require ( + github.com/microcosm-cc/bluemonday v1.0.27 + github.com/yuin/goldmark v1.8.2 +) + +require ( + github.com/fsnotify/fsnotify v1.10.1 // direct + golang.org/x/sys v0.45.0 // indirect +) require ( github.com/aymerick/douceur v0.2.0 // indirect github.com/gorilla/css v1.0.1 // indirect - github.com/microcosm-cc/bluemonday v1.0.27 // direct - golang.org/x/net v0.26.0 // indirect + golang.org/x/net v0.55.0 // indirect ) diff --git a/go.sum b/go.sum index 884e507..fc77dd8 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,14 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= -golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/install-red-engine.ps1 b/install-red-engine.ps1 index 8c94b5a..685632c 100644 --- a/install-red-engine.ps1 +++ b/install-red-engine.ps1 @@ -1,118 +1,57 @@ Write-Host "========================================" -ForegroundColor Cyan -Write-Host "🚀 Installing RED Engine..." -ForegroundColor Cyan +Write-Host "🚀 Installing RED Engine (Production Mode)..." -ForegroundColor Cyan Write-Host "========================================" -ForegroundColor Cyan -# 1. Check if we are inside the repository; if not, clone it. -if (-Not (Test-Path "docker-compose.yml")) +# Check for Administrator privileges +$currentPrincipal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent()) +if (-not $currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { - Write-Host "[*] Repository not detected in current directory." - - if (-Not (Get-Command "git" -ErrorAction SilentlyContinue)) - { - Write-Host "❌ Error: 'git' is not installed. Please install Git for Windows to continue." -ForegroundColor Red - Exit - } + Write-Host "⚠️ Administrator privileges are required to bind network ports." -ForegroundColor Yellow + Write-Host "Re-launching as Administrator..." -ForegroundColor Yellow + Start-Process powershell.exe -ArgumentList "-NoProfile -ExecutionPolicy Bypass -File `"$PSCommandPath`"" -Verb RunAs + Exit +} - Write-Host "[*] Cloning RED Engine repository..." +# 1. Repository Check +if (-Not (Test-Path "docker-compose.yml")) +{ git clone https://github.com/RED-Collective/red-engine.git - - if ($LASTEXITCODE -ne 0) - { - Write-Host "❌ Error: Failed to clone repository." -ForegroundColor Red - Exit - } - - Write-Host "[*] Navigating into red-engine directory..." Set-Location "red-engine" -} else -{ - Write-Host "[*] Running from inside existing repository." } -# 2. Create data directory safely as the standard user +# 2. Setup Directories if (-Not (Test-Path ".\data")) -{ - Write-Host "[*] Creating .\data directory..." - New-Item -ItemType Directory -Path ".\data" | Out-Null -} else -{ - Write-Host "[*] .\data directory already exists." +{ New-Item -ItemType Directory -Path ".\data" | Out-Null } -# 3. Check for or create config.json with a secure token +# 3. Handle config.json if (-Not (Test-Path "config.json")) { - Write-Host "[*] Generating default config.json..." - - # Generate a cryptographically secure 32-character hexadecimal token $Bytes = New-Object Byte[] 16 [Security.Cryptography.RNGCryptoServiceProvider]::Create().GetBytes($Bytes) $NewToken = [BitConverter]::ToString($Bytes) -replace '-' - - $DefaultConfig = @{ - addr = ":8080" - siteName = "RED Engine" - dataDir = "/app/data" - adminToken = $NewToken - startupSync = @() - } + $DefaultConfig = @{ addr = ":8080"; siteName = "RED Engine"; dataDir = "/app/data"; adminToken = $NewToken; startupSync = @() } $DefaultConfig | ConvertTo-Json -Depth 10 | Set-Content "config.json" - - Write-Host "[*] Generated secure Admin Token: $NewToken" -ForegroundColor Green - Write-Host "⚠️ PLEASE SAVE THIS TOKEN! You will need it to log in to the admin panel." -ForegroundColor Yellow -} else -{ - Write-Host "[*] config.json already exists. Skipping default generation." } -# 4. Check for or create contributors.json +# 4. Handle contributors.json if (-Not (Test-Path "contributors.json")) -{ - Write-Host "[*] Generating default contributors.json..." - "[]" | Set-Content "contributors.json" -} else -{ - Write-Host "[*] contributors.json already exists." +{ "[]" | Set-Content "contributors.json" } -# 5. Detect the container engine -$ComposeCmd = "" -$ComposeArgs = @("up", "--build", "-d") - -if (Get-Command "podman-compose" -ErrorAction SilentlyContinue) -{ - $ComposeCmd = "podman-compose" -} elseif (Get-Command "docker-compose" -ErrorAction SilentlyContinue) -{ - $ComposeCmd = "docker-compose" -} elseif (Get-Command "docker" -ErrorAction SilentlyContinue) -{ - # Check if modern 'docker compose' (V2) is available - try - { - $null = Invoke-Expression "docker compose version 2>&1" - if ($LASTEXITCODE -eq 0) - { - $ComposeCmd = "docker" - $ComposeArgs = @("compose", "up", "--build", "-d") - } - } catch - { - } -} - -if ($ComposeCmd -eq "") -{ - Write-Host "❌ Error: Neither podman-compose nor docker compose found on this system." -ForegroundColor Red - Write-Host "Please install Podman or Docker Desktop to continue." -ForegroundColor Red - Exit -} +# 5. Build and Deploy +Write-Host "[*] Building local image..." -ForegroundColor Green +podman build --network=host -t red-engine-image . -Write-Host "[*] Starting RED Engine..." -& $ComposeCmd $ComposeArgs +Write-Host "[*] Starting services..." -ForegroundColor Green +podman-compose up -d +# 6. Final Status +$Config = Get-Content "config.json" | ConvertFrom-Json Write-Host "========================================" -ForegroundColor Cyan Write-Host "✅ Installation Complete!" -ForegroundColor Green -Write-Host "🌐 Your node is running at: http://localhost" +Write-Host "🌐 Node running at: http://localhost" Write-Host "⚙️ Admin Panel: http://localhost/-/admin" +Write-Host "🔑 YOUR ADMIN TOKEN: $($Config.adminToken)" -ForegroundColor Yellow +Write-Host "⚠️ PLEASE SAVE THIS TOKEN!" -ForegroundColor Yellow Write-Host "========================================" -ForegroundColor Cyan diff --git a/install-red-engine.sh b/install-red-engine.sh index 55e0d72..04cf4e8 100755 --- a/install-red-engine.sh +++ b/install-red-engine.sh @@ -1,44 +1,29 @@ #!/bin/bash echo "========================================" -echo "🚀 Installing RED Engine..." +echo "🚀 Installing RED Engine (Production Mode)..." echo "========================================" -# 1. Check if we are inside the repository; if not, clone it. -if [ ! -f "docker-compose.yml" ]; then - echo "[*] Repository not detected in current directory." - - if ! command -v git &> /dev/null; then - echo "❌ Error: 'git' is not installed. Please install git to continue." - exit 1 - fi +# Check for root/sudo privileges to bind ports 80/443 +if [[ $EUID -ne 0 ]]; then + echo "⚠️ Sudo privileges are required to bind ports 80 and 443." + echo "Please enter your password when prompted." + sudo "$0" "$@" + exit $? +fi +# 1. Repository Check +if [ ! -f "docker-compose.yml" ]; then echo "[*] Cloning RED Engine repository..." git clone https://github.com/RED-Collective/red-engine.git - if [ $? -ne 0 ]; then - echo "❌ Error: Failed to clone repository." - exit 1 - fi - - echo "[*] Navigating into red-engine directory..." cd red-engine || exit 1 -else - echo "[*] Running from inside existing repository." fi -# 2. Create data directory safely as the standard user (NO SUDO) -if [ ! -d "./data" ]; then - echo "[*] Creating ./data directory..." - mkdir -p ./data -else - echo "[*] ./data directory already exists." -fi +# 2. Setup Directories +mkdir -p ./data -# 3. Check for or create config.json with a secure token +# 3. Handle config.json if [ ! -f "config.json" ]; then - echo "[*] Generating default config.json..." - # Generate a secure 24-character token NEW_TOKEN=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 24 | head -n 1) - cat < config.json { "addr": ":8080", @@ -48,38 +33,27 @@ if [ ! -f "config.json" ]; then "startupSync": [] } EOF - echo "[*] Generated secure Admin Token: $NEW_TOKEN" - echo "⚠️ PLEASE SAVE THIS TOKEN! You will need it to log in to the admin panel." -else - echo "[*] config.json already exists. Skipping default generation." + echo "[*] Generated Admin Token: $NEW_TOKEN" fi -# 4. Check for or create contributors.json +# 4. Handle contributors.json if [ ! -f "contributors.json" ]; then - echo "[*] Generating default contributors.json..." echo "[]" > contributors.json -else - echo "[*] contributors.json already exists." fi -# 5. Detect the container engine -if command -v podman-compose &> /dev/null; then - COMPOSE_CMD="podman-compose up --build -d" -elif command -v docker-compose &> /dev/null; then - COMPOSE_CMD="docker-compose up --build -d" -elif command -v docker &> /dev/null && docker compose version &> /dev/null; then - COMPOSE_CMD="docker compose up --build -d" -else - echo "❌ Error: Neither podman-compose nor docker compose found on this system." - echo "Please install Podman or Docker to continue." - exit 1 -fi +# 5. Build and Deploy +echo "[*] Building local image..." +podman build --network=host -t red-engine-image . -echo "[*] Starting RED Engine using container engine..." -$COMPOSE_CMD +echo "[*] Starting services..." +podman-compose up -d +# 6. Final Status +TOKEN=$(grep -oP '"adminToken": "\K[^"]+' config.json) echo "========================================" echo "✅ Installation Complete!" -echo "🌐 Your node is running at: http://localhost" +echo "🌐 Node running at: http://localhost" echo "⚙️ Admin Panel: http://localhost/-/admin" +echo "🔑 YOUR ADMIN TOKEN: $TOKEN" +echo "⚠️ PLEASE SAVE THIS TOKEN!" echo "========================================" diff --git a/internal/router/serve.go b/internal/router/serve.go index d2556cd..36072c0 100644 --- a/internal/router/serve.go +++ b/internal/router/serve.go @@ -68,12 +68,22 @@ func (h *handler) serve(w http.ResponseWriter, r *http.Request) { d.Body = template.HTML(sectionHTML(sec)) default: - art := h.store.Get(path) + // Clean the incoming URL + cleanPath := strings.TrimSuffix(path, ".md") + art := h.store.Get(cleanPath) + + // Fallback lookup if exact match failed + if art == nil { + art = h.store.Get(path) + } + if art == nil { http.NotFound(w, r) return } + d.Title = capitalize(parts[len(parts)-1]) + d.Title = strings.TrimSuffix(d.Title, ".md") // Remove .md from the UI title d.Crumb = buildCrumbs(parts) d.Body = art.Body d.Verified = art.Verified diff --git a/internal/store/store.go b/internal/store/store.go index bcc0490..38d9ea9 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -14,15 +14,16 @@ import ( "sync" "github.com/RED-Collective/red-engine/internal/render" + "github.com/fsnotify/fsnotify" ) type Article struct { Path string Title string Body template.HTML - Hash string // SHA-256 Hash for UI Display - Verified bool // Ed25519 Verification Status - Author string // Name of the verified signer + Hash string + Verified bool + Author string } type Section struct { @@ -48,7 +49,6 @@ func (s *Store) DataDir() string { return s.dataDir } -// --- Cryptographic Structs matching the Obsidian Plugin --- type Contributor struct { Name string `json:"name"` PublicKey string `json:"public_key"` @@ -56,7 +56,7 @@ type Contributor struct { type ManifestEntry struct { FileHash string `json:"file_hash"` - Hash string `json:"hash"` // Fallback support + Hash string `json:"hash"` PublicKey string `json:"public_key"` Signature string `json:"signature"` } @@ -65,28 +65,62 @@ type Manifest struct { Files map[string]ManifestEntry `json:"files"` } -// Helper to unmarshal flexible manifest format func parseManifestJSON(data []byte) map[string]ManifestEntry { result := make(map[string]ManifestEntry) - - // Try wrapped format first var wrapped Manifest if err := json.Unmarshal(data, &wrapped); err == nil && len(wrapped.Files) > 0 { return wrapped.Files } - - // Try flat format: {filepath: entry, ...} if err := json.Unmarshal(data, &result); err == nil { return result } - return result } +func (s *Store) Watch() error { + watcher, err := fsnotify.NewWatcher() + if err != nil { + return err + } + + absDataDir, _ := filepath.Abs(s.dataDir) + + filepath.WalkDir(absDataDir, func(path string, d fs.DirEntry, err error) error { + if err == nil && d.IsDir() { + watcher.Add(path) + } + return nil + }) + + go func() { + defer watcher.Close() + for { + select { + case event, ok := <-watcher.Events: + if !ok { + return + } + if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Remove|fsnotify.Rename) != 0 { + log.Printf("🔄 File change detected: %s. Reloading store...", event.Name) + s.Reload() + } + case err, ok := <-watcher.Errors: + if !ok { + return + } + log.Println("⚠️ Watcher error:", err) + } + } + }() + + log.Printf("[DEBUG] File watcher started on %s", absDataDir) + return nil +} + func (s *Store) Reload() error { s.mu.Lock() defer s.mu.Unlock() - // 1. Load the Trusted Public Keys from contributors.json + trustedKeys := make(map[string]string) if trustData, err := os.ReadFile("contributors.json"); err == nil { var contributors []Contributor @@ -99,26 +133,19 @@ func (s *Store) Reload() error { log.Println("⚠️ Warning: contributors.json not found. Verification checks disabled.") } - // 2. Pre-load all signatures from any manifest.json in the data directory - // Map by filepath for easier lookup allSignatures := make(map[string]ManifestEntry) filepath.WalkDir(s.dataDir, func(path string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() || filepath.Base(path) != "manifest.json" { return nil } - manifestData, err := os.ReadFile(path) if err != nil { - log.Printf("Warning: cannot read manifest %s: %v", path, err) return nil } - manifest := parseManifestJSON(manifestData) if len(manifest) == 0 { return nil } - - // Get manifest directory relative to dataDir manifestDir := filepath.Dir(path) relManifestDir, err := filepath.Rel(s.dataDir, manifestDir) if err != nil { @@ -141,11 +168,6 @@ func (s *Store) Reload() error { return nil }) - log.Printf("[DEBUG] Loaded %d signature entries", len(allSignatures)) - for k := range allSignatures { - log.Printf("[DEBUG] %s", k) - } - newNav := make(map[string]*Section) err := filepath.WalkDir(s.dataDir, func(path string, d fs.DirEntry, err error) error { @@ -158,7 +180,6 @@ func (s *Store) Reload() error { return nil } - // 3. Calculate SHA-256 hashBytes := sha256.Sum256(content) fileHash := hex.EncodeToString(hashBytes[:]) @@ -167,64 +188,51 @@ func (s *Store) Reload() error { return nil } - // 4. Ed25519 Cryptographic Verification - isVerified := false - authorName := "Unverified / Unknown Origin" - - // Get relative path in the format used in manifest rel, _ := filepath.Rel(s.dataDir, path) relativePath := strings.TrimPrefix(filepath.ToSlash(rel), "/") - log.Printf("[DEBUG] Checking file: %s -> relativePath=%s", path, relativePath) - // Try to find manifest entry by filepath + // Clean the path for web URL building + cleanPath := strings.TrimSuffix(relativePath, ".md") + + isVerified := false + authorName := "Unverified / Unknown Origin" + if entry, exists := allSignatures[relativePath]; exists { - // Use file_hash if available, otherwise hash entryHash := entry.FileHash if entryHash == "" { entryHash = entry.Hash } - // Does the hash match? if entryHash == fileHash { - // Does the signature belong to a trusted public key? if trustedAuthor, isTrusted := trustedKeys[strings.ToLower(entry.PublicKey)]; isTrusted { pubBytes, err1 := hex.DecodeString(entry.PublicKey) sigBytes, err2 := hex.DecodeString(entry.Signature) if err1 == nil && err2 == nil && len(pubBytes) == ed25519.PublicKeySize { - // Check 1: Did the plugin sign the raw Markdown content? if ed25519.Verify(pubBytes, content, sigBytes) { isVerified = true authorName = trustedAuthor - // Check 2: Did the plugin sign the Hex string of the SHA256 hash? (Very common) } else if ed25519.Verify(pubBytes, []byte(fileHash), sigBytes) { isVerified = true authorName = trustedAuthor - // Check 3: Did the plugin sign the raw SHA256 bytes? } else if ed25519.Verify(pubBytes, hashBytes[:], sigBytes) { isVerified = true authorName = trustedAuthor - } else { - log.Printf("[DEBUG] Signature verification failed for %s (Tried Content, Hex Hash, and Byte Hash)", relativePath) } } - } else { - log.Printf("[DEBUG] Public key not trusted for %s: %s", relativePath, entry.PublicKey) } - } else { - log.Printf("[DEBUG] Hash mismatch for %s: stored=%s, actual=%s", relativePath, entryHash, fileHash) } } - // 5. Build Article Structure - parts := strings.Split(filepath.ToSlash(rel), "/") + // Use cleanPath (no .md) to build the tree and URLs + parts := strings.Split(filepath.ToSlash(cleanPath), "/") - title := strings.TrimSuffix(parts[len(parts)-1], ".md") + title := parts[len(parts)-1] title = strings.ReplaceAll(title, "-", " ") title = strings.Title(title) art := &Article{ - Path: "/" + filepath.ToSlash(rel), + Path: "/" + filepath.ToSlash(cleanPath), Title: title, Body: template.HTML(res.HTMLContent), Hash: fileHash, @@ -232,7 +240,6 @@ func (s *Store) Reload() error { Author: authorName, } - // Tree Building if len(parts) == 1 { if newNav["root"] == nil { newNav["root"] = &Section{Name: "root"} diff --git a/internal/store/watcher.go b/internal/store/watcher.go new file mode 100644 index 0000000..7cd39f7 --- /dev/null +++ b/internal/store/watcher.go @@ -0,0 +1,51 @@ +package store + +import ( + "io/fs" + "log" + "path/filepath" + + "github.com/fsnotify/fsnotify" +) + +func (s *Store) Watcher() error { + watcher, err := fsnotify.NewWatcher() + if err != nil { + return err + } + + absDataDir, _ := filepath.Abs(s.dataDir) + + // Watch the root and all subdirectories + filepath.WalkDir(absDataDir, func(path string, d fs.DirEntry, err error) error { + if err == nil && d.IsDir() { + watcher.Add(path) + } + return nil + }) + + go func() { + defer watcher.Close() + for { + select { + case event, ok := <-watcher.Events: + if !ok { + return + } + // Trigger a reload if a file is written, created, removed, or renamed + if event.Op&fsnotify.Write == fsnotify.Write || event.Op&fsnotify.Create == fsnotify.Create || event.Op&fsnotify.Remove == fsnotify.Remove || event.Op&fsnotify.Rename == fsnotify.Rename { + log.Printf("🔄 File change detected: %s. Reloading store...", event.Name) + s.Reload() + } + case err, ok := <-watcher.Errors: + if !ok { + return + } + log.Println("⚠️ Watcher error:", err) + } + } + }() + + log.Printf("[DEBUG] File watcher started on %s", absDataDir) + return nil +} diff --git a/manage-token.ps1 b/manage-token.ps1 index b9d111c..feed2b1 100644 --- a/manage-token.ps1 +++ b/manage-token.ps1 @@ -1,84 +1,52 @@ $ConfigFile = "config.json" -if (-Not (Test-Path $ConfigFile)) +# Helper function to generate a secure token +function New-SecureToken { - Write-Host "Error: $ConfigFile not found in the current directory!" -ForegroundColor Red - Exit + $Bytes = New-Object Byte[] 16 + [Security.Cryptography.RNGCryptoServiceProvider]::Create().GetBytes($Bytes) + return [BitConverter]::ToString($Bytes) -replace '-' } -# Parse the JSON file -$Config = Get-Content $ConfigFile -Raw | ConvertFrom-Json - -$CurrentToken = $Config.adminToken - -Write-Host "----------------------------------------" -if ([string]::IsNullOrEmpty($CurrentToken)) -{ - Write-Host "Current Admin Token: [NONE / NOT SET]" -ForegroundColor Yellow -} else +# 1. Check/Create Config +if (-Not (Test-Path $ConfigFile)) { - Write-Host "Current Admin Token: $CurrentToken" -ForegroundColor Cyan + Write-Host "⚠️ $ConfigFile not found. Creating a default configuration..." -ForegroundColor Yellow + + $NewToken = New-SecureToken + $DefaultConfig = [PSCustomObject]@{ + addr = ":8080" + siteName = "RED Engine" + dataDir = "/app/data" + adminToken = $NewToken + startupSync = @( + @{ + url = "https://github.com/mundimark/awesome-markdown" + filename = "awesome-markdown.md" + } + ) + } + $DefaultConfig | ConvertTo-Json -Depth 10 | Set-Content $ConfigFile + Write-Host "✅ Created new config.json with secure token." -ForegroundColor Green } + +# 2. Parse and Display +$Config = Get-Content $ConfigFile -Raw | ConvertFrom-Json +Write-Host "`n----------------------------------------" +Write-Host "Current Admin Token: $($Config.adminToken)" -ForegroundColor Cyan Write-Host "----------------------------------------`n" +# 3. Interactive Update $Choice = Read-Host "Would you like to generate and save a new secure token? (y/N)" if ($Choice -match "^[yY]") { - # Generate a cryptographically secure 32-character hexadecimal token - $Bytes = New-Object Byte[] 16 - [Security.Cryptography.RNGCryptoServiceProvider]::Create().GetBytes($Bytes) - $NewToken = [BitConverter]::ToString($Bytes) -replace '-' - - # Update the object and save it back to disk - $Config.adminToken = $NewToken - $Config | ConvertTo-Json -Depth 10 | Set-Content $ConfigFile$ConfigFile = "config.json" - - if (-Not (Test-Path $ConfigFile)) - { - Write-Host "Error: $ConfigFile not found in the current directory!" -ForegroundColor Red - Exit - } - - # Parse the JSON file - $Config = Get-Content $ConfigFile -Raw | ConvertFrom-Json - - $CurrentToken = $Config.adminToken - - Write-Host "----------------------------------------" - if ([string]::IsNullOrEmpty($CurrentToken)) - { - Write-Host "Current Admin Token: [NONE / NOT SET]" -ForegroundColor Yellow - } else - { - Write-Host "Current Admin Token: $CurrentToken" -ForegroundColor Cyan - } - Write-Host "----------------------------------------`n" - - $Choice = Read-Host "Would you like to generate and save a new secure token? (y/N)" - - if ($Choice -match "^[yY]") - { - # Generate a cryptographically secure 32-character hexadecimal token - $Bytes = New-Object Byte[] 16 - [Security.Cryptography.RNGCryptoServiceProvider]::Create().GetBytes($Bytes) - $NewToken = [BitConverter]::ToString($Bytes) -replace '-' - - # Update the object and save it back to disk - $Config.adminToken = $NewToken - $Config | ConvertTo-Json -Depth 10 | Set-Content $ConfigFile - - Write-Host "✅ Token updated successfully!" -ForegroundColor Green - Write-Host "Your new token is: $NewToken" -ForegroundColor Cyan - Write-Host "⚠️ Make sure to restart your node: podman-compose restart red_engine" -ForegroundColor Yellow - } else - { - Write-Host "Operation cancelled. Token unchanged." - } + $Config.adminToken = New-SecureToken + $Config | ConvertTo-Json -Depth 10 | Set-Content $ConfigFile - Write-Host "✅ Token updated successfully!" -ForegroundColor Green - Write-Host "Your new token is: $NewToken" -ForegroundColor Cyan - Write-Host "⚠️ Make sure to restart your node: podman-compose restart red_engine" -ForegroundColor Yellow + Write-Host "`n✅ Token updated successfully!" -ForegroundColor Green + Write-Host "Your new token is: $($Config.adminToken)" -ForegroundColor Cyan + Write-Host "⚠️ Restart your node: podman-compose restart red_engine" -ForegroundColor Yellow } else { Write-Host "Operation cancelled. Token unchanged." diff --git a/manage-token.sh b/manage-token.sh index ac053a0..ff07f2f 100755 --- a/manage-token.sh +++ b/manage-token.sh @@ -1,43 +1,49 @@ #!/bin/bash CONFIG_FILE="config.json" +# Helper function for token +generate_token() { + cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1 +} + +# 1. Check/Create Config if [ ! -f "$CONFIG_FILE" ]; then - echo "Error: $CONFIG_FILE not found in the current directory!" - exit 1 -fi + echo "⚠️ $CONFIG_FILE not found. Creating a default configuration..." + NEW_TOKEN=$(generate_token) -# Extract the current token safely -CURRENT_TOKEN=$(grep '"adminToken"' "$CONFIG_FILE" | sed -E 's/.*"adminToken"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/') + cat < "$CONFIG_FILE" +{ + "addr": ":8080", + "siteName": "RED Engine", + "dataDir": "/app/data", + "adminToken": "$NEW_TOKEN", + "startupSync": [ + { + "url": "https://github.com/mundimark/awesome-markdown", + "filename": "awesome-markdown.md" + } + ] +} +EOF + echo "✅ Created new config.json." +fi +# 2. Display Current Token +TOKEN=$(grep -oP '"adminToken": "\K[^"]+' "$CONFIG_FILE") echo "----------------------------------------" -if [ -z "$CURRENT_TOKEN" ]; then - echo "Current Admin Token: [NONE / NOT SET]" -else - echo "Current Admin Token: $CURRENT_TOKEN" -fi +echo "Current Admin Token: $TOKEN" echo "----------------------------------------" -echo "" +# 3. Interactive Update read -p "Would you like to generate and save a new secure token? (y/N): " choice -case "$choice" in - y|Y ) - # Generate a secure 24-character random string - NEW_TOKEN=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 24 | head -n 1) - - # Safely replace the token in the JSON file - if grep -q '"adminToken"' "$CONFIG_FILE"; then - sed -i.bak -E 's/("adminToken"[[:space:]]*:[[:space:]]*")[^"]*(")/\1'"$NEW_TOKEN"'\2/' "$CONFIG_FILE" - rm -f "$CONFIG_FILE.bak" - else - echo "Error: 'adminToken' key not found in $CONFIG_FILE. Please add it manually." - exit 1 - fi +if [[ "$choice" =~ ^[yY]$ ]]; then + NEW_TOKEN=$(generate_token) + # Use sed to replace the token safely + sed -i "s/\"adminToken\": \".*\"/\"adminToken\": \"$NEW_TOKEN\"/" "$CONFIG_FILE" echo "✅ Token updated successfully!" echo "Your new token is: $NEW_TOKEN" - echo "⚠️ Make sure to restart your node: podman-compose restart red_engine" - ;; - * ) + echo "⚠️ Restart your node: podman-compose restart red_engine" +else echo "Operation cancelled. Token unchanged." - ;; -esac +fi