From 8d92c6477b310574394daf710ee34cdcf7cd49ab Mon Sep 17 00:00:00 2001 From: Joakim Date: Wed, 10 Sep 2025 23:05:09 +0200 Subject: [PATCH] feat: implement server-hosted static site enhancement with real-time content updates - Add SiteManager for registering and managing static sites with file-based enhancement - Implement EnhanceInPlace method for in-place file modification using database content - Integrate automatic file enhancement triggers in UpdateContent API handler - Add comprehensive site configuration support in insertr.yaml with auto-enhancement - Extend serve command to automatically register and manage configured sites - Add backup system for original files before enhancement - Support multi-site hosting with individual auto-enhancement settings - Update documentation for server-hosted enhancement workflow This enables real-time content deployment where database content changes immediately update static files without requiring rebuilds or redeployment. The database remains the single source of truth while maintaining static file performance benefits. --- .gitignore | 1 + COMMANDS.md | 15 +- README.md | 76 +++++++- cmd/serve.go | 41 +++++ go.mod | 2 +- insertr.yaml | 24 ++- internal/api/handlers.go | 20 +++ internal/content/enhancer.go | 106 +++++++++++ internal/content/site_manager.go | 300 +++++++++++++++++++++++++++++++ 9 files changed, 576 insertions(+), 9 deletions(-) create mode 100644 internal/content/site_manager.go diff --git a/.gitignore b/.gitignore index f00aa53..8f2ca5c 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,4 @@ tmp/ .air/ # Node.js root dependencies +insertr-backups/ diff --git a/COMMANDS.md b/COMMANDS.md index c035cca..d3b51e5 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -65,7 +65,7 @@ insertr enhance [input-dir] [flags] ## 🔌 serve Command -Runtime API server - provides HTTP endpoints for content management. +Runtime API server - provides HTTP endpoints for content management and server-hosted static site enhancement. ```bash insertr serve [flags] @@ -96,6 +96,19 @@ insertr serve [flags] ./insertr --config production.yaml serve ``` +### Server-Hosted Static Sites + +When running `insertr serve`, the server automatically: +- **Registers sites** from `insertr.yaml` configuration +- **Enhances static files** with latest database content +- **Auto-updates files** when content changes via API +- **Creates backups** of original files (if enabled) + +**Live Enhancement Process:** +1. Content updated via API → Database updated +2. If site has `auto_enhance: true` → File enhancement triggered +3. Static files updated in-place → Changes immediately live + ### API Endpoints When running `insertr serve`, these endpoints are available: diff --git a/README.md b/README.md index efa6c5c..592aab6 100644 --- a/README.md +++ b/README.md @@ -395,6 +395,57 @@ mock_content: false # Use mock content instead of real data - Works with any framework or generator - Developer experience focused +## 🏗️ **Server-Hosted Static Sites** (NEW) + +**Real-time content updates for server-hosted static sites with immediate deployment.** + +### **Overview** +Insertr now supports hosting and enhancing static sites directly on the server. When content changes through the editor, static files are automatically updated in-place without requiring rebuilds or redeployment. + +### **Key Benefits** +- ✅ **Immediate Updates**: Content changes are live instantly +- ✅ **Database Source of Truth**: Content stored in database, not files +- ✅ **No Rebuilds Required**: Only content updates, templates stay the same +- ✅ **Multi-Site Support**: Host multiple enhanced sites on one server +- ✅ **Automatic Backups**: Original files backed up before enhancement + +### **Workflow** +``` +1. Developer deploys static site → Server directory (e.g., /var/www/mysite) +2. Insertr enhances files → Adds editing capabilities + injects content +3. Editor makes changes → API updates database + triggers file enhancement +4. Changes immediately live → Visitors see updated content instantly +``` + +### **Configuration Example** +```yaml +# insertr.yaml +server: + port: 8080 + sites: + - site_id: "mysite" + path: "/var/www/mysite" + domain: "mysite.example.com" + auto_enhance: true + backup_originals: true + + - site_id: "blog" + path: "/var/www/blog" + domain: "blog.example.com" + auto_enhance: true + backup_originals: true +``` + +### **Quick Start** +```bash +# 1. Configure sites in insertr.yaml +# 2. Start the server +./insertr serve --dev-mode + +# 3. Your sites are automatically registered and enhanced +# 4. Content changes via editor immediately update static files +``` + ## ⚙️ Configuration ### **YAML Configuration (insertr.yaml)** @@ -403,21 +454,34 @@ mock_content: false # Use mock content instead of real data database: path: "./insertr.db" # SQLite file path or PostgreSQL connection string +# Server configuration (with site hosting) +server: + port: 8080 # HTTP server port + sites: # Server-hosted static sites + - site_id: "demo" + path: "./demo-site" + domain: "localhost:3000" + auto_enhance: true + backup_originals: true + # API configuration (for remote content API) api: url: "" # Content API URL (leave empty to use local database) key: "" # API authentication key -# Server configuration -server: - port: 8080 # HTTP server port - dev_mode: false # Enable development mode features - -# Build configuration +# Build configuration (for CLI enhancement) build: input: "./src" # Default input directory for enhancement output: "./dist" # Default output directory for enhanced files +# Authentication configuration +auth: + provider: "mock" # "mock", "jwt", "authentik" + jwt_secret: "" # JWT signing secret + oidc: + endpoint: "" # https://auth.example.com/application/o/insertr/ + client_id: "" # OAuth2 client ID + # Global settings site_id: "demo" # Default site ID for content lookup mock_content: false # Use mock content instead of real data diff --git a/cmd/serve.go b/cmd/serve.go index b7ac88e..4a2bc1c 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -14,6 +14,7 @@ import ( "github.com/insertr/insertr/internal/api" "github.com/insertr/insertr/internal/auth" + "github.com/insertr/insertr/internal/content" "github.com/insertr/insertr/internal/db" ) @@ -68,8 +69,48 @@ func runServe(cmd *cobra.Command, args []string) { authService := auth.NewAuthService(authConfig) + // Initialize content client for site manager + contentClient := content.NewDatabaseClient(database) + + // Initialize site manager + siteManager := content.NewSiteManager(contentClient, "./insertr-backups") + + // Load sites from configuration + if siteConfigs := viper.Get("server.sites"); siteConfigs != nil { + if configs, ok := siteConfigs.([]interface{}); ok { + var sites []*content.SiteConfig + for _, configInterface := range configs { + if configMap, ok := configInterface.(map[string]interface{}); ok { + site := &content.SiteConfig{} + if siteID, ok := configMap["site_id"].(string); ok { + site.SiteID = siteID + } + if path, ok := configMap["path"].(string); ok { + site.Path = path + } + if domain, ok := configMap["domain"].(string); ok { + site.Domain = domain + } + if autoEnhance, ok := configMap["auto_enhance"].(bool); ok { + site.AutoEnhance = autoEnhance + } + if backupOriginals, ok := configMap["backup_originals"].(bool); ok { + site.BackupOriginals = backupOriginals + } + if site.SiteID != "" && site.Path != "" { + sites = append(sites, site) + } + } + } + if err := siteManager.RegisterSites(sites); err != nil { + log.Printf("⚠️ Failed to register some sites: %v", err) + } + } + } + // Initialize handlers contentHandler := api.NewContentHandler(database, authService) + contentHandler.SetSiteManager(siteManager) // Setup router router := mux.NewRouter() diff --git a/go.mod b/go.mod index cfe06b4..a70f35b 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/insertr/insertr go 1.24.6 require ( + github.com/golang-jwt/jwt/v5 v5.3.0 github.com/gorilla/mux v1.8.1 github.com/lib/pq v1.10.9 github.com/mattn/go-sqlite3 v1.14.32 @@ -13,7 +14,6 @@ require ( require ( github.com/fsnotify/fsnotify v1.7.0 // indirect - github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/magiconair/properties v1.8.7 // indirect diff --git a/insertr.yaml b/insertr.yaml index fc5a362..0573f82 100644 --- a/insertr.yaml +++ b/insertr.yaml @@ -11,6 +11,18 @@ database: # Server configuration (multi-site ready) server: port: 8080 # HTTP API server port + sites: # Registered sites for file-based enhancement + - site_id: "demo" + path: "./demo-site" + domain: "localhost:3000" + auto_enhance: true + backup_originals: true + # Example additional site configuration: + # - site_id: "mysite" + # path: "/var/www/mysite" + # domain: "mysite.example.com" + # auto_enhance: true + # backup_originals: true # CLI enhancement configuration cli: @@ -20,4 +32,14 @@ cli: # API client configuration (for CLI remote mode) api: url: "" # Content API URL (empty = use local database) - key: "" # API authentication key \ No newline at end of file + key: "" # API authentication key + +# Authentication configuration +auth: + provider: "mock" # "mock", "jwt", "authentik" + jwt_secret: "" # JWT signing secret (auto-generated in dev mode) + # Authentik OIDC configuration (for production) + oidc: + endpoint: "" # https://auth.example.com/application/o/insertr/ + client_id: "" # insertr-client + client_secret: "" # OAuth2 client secret \ No newline at end of file diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 35d7d4c..e34aa3c 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -5,6 +5,7 @@ import ( "database/sql" "encoding/json" "fmt" + "log" "net/http" "strconv" "strings" @@ -12,6 +13,7 @@ import ( "github.com/gorilla/mux" "github.com/insertr/insertr/internal/auth" + "github.com/insertr/insertr/internal/content" "github.com/insertr/insertr/internal/db" "github.com/insertr/insertr/internal/db/postgresql" "github.com/insertr/insertr/internal/db/sqlite" @@ -21,6 +23,7 @@ import ( type ContentHandler struct { database *db.Database authService *auth.AuthService + siteManager *content.SiteManager } // NewContentHandler creates a new content handler @@ -28,9 +31,15 @@ func NewContentHandler(database *db.Database, authService *auth.AuthService) *Co return &ContentHandler{ database: database, authService: authService, + siteManager: nil, // Will be set via SetSiteManager } } +// SetSiteManager sets the site manager for file enhancement +func (h *ContentHandler) SetSiteManager(siteManager *content.SiteManager) { + h.siteManager = siteManager +} + // GetContent handles GET /api/content/{id} func (h *ContentHandler) GetContent(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) @@ -315,6 +324,17 @@ func (h *ContentHandler) UpdateContent(w http.ResponseWriter, r *http.Request) { item := h.convertToAPIContent(upsertedContent) + // Trigger file enhancement if site is registered for auto-enhancement + if h.siteManager != nil && h.siteManager.IsAutoEnhanceEnabled(siteID) { + go func() { + if err := h.siteManager.EnhanceSite(siteID); err != nil { + log.Printf("⚠️ Failed to enhance site %s: %v", siteID, err) + } else { + log.Printf("✅ Enhanced files for site %s", siteID) + } + }() + } + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(item) } diff --git a/internal/content/enhancer.go b/internal/content/enhancer.go index cb903b1..bce4ca8 100644 --- a/internal/content/enhancer.go +++ b/internal/content/enhancer.go @@ -214,3 +214,109 @@ func (e *Enhancer) writeHTML(doc *html.Node, outputPath string) error { // Write HTML return html.Render(file, doc) } + +// EnhanceInPlace performs in-place enhancement of static site files +func (e *Enhancer) EnhanceInPlace(sitePath string, siteID string) error { + // Update the injector with the correct siteID + e.injector.siteID = siteID + + // Use existing parser logic to discover elements + result, err := e.parser.ParseDirectory(sitePath) + if err != nil { + return fmt.Errorf("parsing directory: %w", err) + } + + if len(result.Elements) == 0 { + fmt.Printf("📄 No insertr elements found in %s\n", sitePath) + return nil + } + + // Group elements by file for efficient processing + fileElements := make(map[string][]parser.Element) + for _, elem := range result.Elements { + fileElements[elem.FilePath] = append(fileElements[elem.FilePath], elem) + } + + // Process each file in-place + enhancedCount := 0 + for filePath, elements := range fileElements { + if err := e.enhanceFileInPlace(filePath, elements); err != nil { + fmt.Printf("⚠️ Failed to enhance %s: %v\n", filepath.Base(filePath), err) + } else { + enhancedCount++ + } + } + + fmt.Printf("✅ Enhanced %d files with %d elements in site %s\n", + enhancedCount, len(result.Elements), siteID) + + return nil +} + +// enhanceFileInPlace modifies an HTML file in-place with database content +func (e *Enhancer) enhanceFileInPlace(filePath string, elements []parser.Element) error { + // Read original file + htmlContent, err := os.ReadFile(filePath) + if err != nil { + return fmt.Errorf("reading file: %w", err) + } + + // Parse HTML + doc, err := html.Parse(strings.NewReader(string(htmlContent))) + if err != nil { + return fmt.Errorf("parsing HTML: %w", err) + } + + // Convert parser elements to injector format with content IDs + elementIDs := make([]ElementWithID, 0, len(elements)) + for _, elem := range elements { + // Find the corresponding node in the parsed document + node := e.findNodeInDocument(doc, elem) + if node != nil { + elementIDs = append(elementIDs, ElementWithID{ + Element: &Element{ + Node: node, + Type: string(elem.Type), + Tag: elem.Tag, + }, + ContentID: elem.ContentID, + }) + } + } + + // Use existing bulk injection logic for efficiency + if len(elementIDs) > 0 { + if err := e.injector.InjectBulkContent(elementIDs); err != nil { + return fmt.Errorf("injecting content: %w", err) + } + } + + // Write enhanced HTML back to the same file (in-place update) + return e.writeHTML(doc, filePath) +} + +// findNodeInDocument finds a specific node in the HTML document tree +func (e *Enhancer) findNodeInDocument(doc *html.Node, elem parser.Element) *html.Node { + // This is a simplified approach - in a production system we might need + // more sophisticated node matching based on attributes, position, etc. + return e.findNodeByTagAndClass(doc, elem.Tag, "insertr") +} + +// findNodeByTagAndClass recursively searches for a node with specific tag and class +func (e *Enhancer) findNodeByTagAndClass(node *html.Node, targetTag, targetClass string) *html.Node { + if node.Type == html.ElementNode && node.Data == targetTag { + classes := getClasses(node) + if containsClass(classes, targetClass) { + return node + } + } + + // Search children + for child := node.FirstChild; child != nil; child = child.NextSibling { + if result := e.findNodeByTagAndClass(child, targetTag, targetClass); result != nil { + return result + } + } + + return nil +} diff --git a/internal/content/site_manager.go b/internal/content/site_manager.go new file mode 100644 index 0000000..d363449 --- /dev/null +++ b/internal/content/site_manager.go @@ -0,0 +1,300 @@ +package content + +import ( + "fmt" + "log" + "os" + "path/filepath" + "sync" + "time" +) + +// SiteConfig represents configuration for a registered site +type SiteConfig struct { + SiteID string `yaml:"site_id"` + Path string `yaml:"path"` + Domain string `yaml:"domain,omitempty"` + AutoEnhance bool `yaml:"auto_enhance"` + BackupOriginals bool `yaml:"backup_originals"` +} + +// SiteManager handles registration and enhancement of static sites +type SiteManager struct { + sites map[string]*SiteConfig + enhancer *Enhancer + mutex sync.RWMutex + backupDir string +} + +// NewSiteManager creates a new site manager +func NewSiteManager(contentClient ContentClient, backupDir string) *SiteManager { + if backupDir == "" { + backupDir = "./insertr-backups" + } + + return &SiteManager{ + sites: make(map[string]*SiteConfig), + enhancer: NewEnhancer(contentClient, ""), // siteID will be set per operation + backupDir: backupDir, + } +} + +// RegisterSite adds a site to the manager +func (sm *SiteManager) RegisterSite(config *SiteConfig) error { + sm.mutex.Lock() + defer sm.mutex.Unlock() + + // Validate site configuration + if config.SiteID == "" { + return fmt.Errorf("site_id is required") + } + if config.Path == "" { + return fmt.Errorf("path is required for site %s", config.SiteID) + } + + // Check if path exists + if _, err := os.Stat(config.Path); os.IsNotExist(err) { + return fmt.Errorf("site path does not exist: %s", config.Path) + } + + // Convert to absolute path + absPath, err := filepath.Abs(config.Path) + if err != nil { + return fmt.Errorf("failed to resolve absolute path for %s: %w", config.Path, err) + } + config.Path = absPath + + sm.sites[config.SiteID] = config + log.Printf("📁 Registered site %s at %s", config.SiteID, config.Path) + + return nil +} + +// RegisterSites bulk registers multiple sites from configuration +func (sm *SiteManager) RegisterSites(configs []*SiteConfig) error { + for _, config := range configs { + if err := sm.RegisterSite(config); err != nil { + return fmt.Errorf("failed to register site %s: %w", config.SiteID, err) + } + } + return nil +} + +// GetSite returns a registered site configuration +func (sm *SiteManager) GetSite(siteID string) (*SiteConfig, bool) { + sm.mutex.RLock() + defer sm.mutex.RUnlock() + + site, exists := sm.sites[siteID] + return site, exists +} + +// GetAllSites returns all registered sites +func (sm *SiteManager) GetAllSites() map[string]*SiteConfig { + sm.mutex.RLock() + defer sm.mutex.RUnlock() + + // Return a copy to prevent external modification + result := make(map[string]*SiteConfig) + for id, site := range sm.sites { + result[id] = site + } + return result +} + +// IsAutoEnhanceEnabled checks if a site has auto-enhancement enabled +func (sm *SiteManager) IsAutoEnhanceEnabled(siteID string) bool { + sm.mutex.RLock() + defer sm.mutex.RUnlock() + + site, exists := sm.sites[siteID] + return exists && site.AutoEnhance +} + +// EnhanceSite performs in-place enhancement of a registered site +func (sm *SiteManager) EnhanceSite(siteID string) error { + sm.mutex.RLock() + site, exists := sm.sites[siteID] + sm.mutex.RUnlock() + + if !exists { + return fmt.Errorf("site %s is not registered", siteID) + } + + log.Printf("🔄 Enhancing site %s at %s", siteID, site.Path) + + // Create backup if enabled + if site.BackupOriginals { + if err := sm.createBackup(siteID, site.Path); err != nil { + log.Printf("⚠️ Failed to create backup for site %s: %v", siteID, err) + // Continue with enhancement even if backup fails + } + } + + // Perform in-place enhancement + if err := sm.enhancer.EnhanceInPlace(site.Path, siteID); err != nil { + return fmt.Errorf("failed to enhance site %s: %w", siteID, err) + } + + log.Printf("✅ Successfully enhanced site %s", siteID) + return nil +} + +// EnhanceAllSites enhances all registered sites that have auto-enhancement enabled +func (sm *SiteManager) EnhanceAllSites() error { + sm.mutex.RLock() + sites := make([]*SiteConfig, 0, len(sm.sites)) + for _, site := range sm.sites { + if site.AutoEnhance { + sites = append(sites, site) + } + } + sm.mutex.RUnlock() + + var errors []error + for _, site := range sites { + if err := sm.EnhanceSite(site.SiteID); err != nil { + errors = append(errors, err) + } + } + + if len(errors) > 0 { + return fmt.Errorf("enhancement failed for some sites: %v", errors) + } + + return nil +} + +// createBackup creates a timestamped backup of the site +func (sm *SiteManager) createBackup(siteID, sitePath string) error { + // Create backup directory structure + timestamp := time.Now().Format("20060102-150405") + backupPath := filepath.Join(sm.backupDir, siteID, timestamp) + + if err := os.MkdirAll(backupPath, 0755); err != nil { + return fmt.Errorf("failed to create backup directory: %w", err) + } + + // Copy HTML files to backup + return filepath.Walk(sitePath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Only backup HTML files + if !info.IsDir() && filepath.Ext(path) == ".html" { + relPath, err := filepath.Rel(sitePath, path) + if err != nil { + return err + } + + backupFilePath := filepath.Join(backupPath, relPath) + + // Create directory structure in backup + if err := os.MkdirAll(filepath.Dir(backupFilePath), 0755); err != nil { + return err + } + + // Copy file + content, err := os.ReadFile(path) + if err != nil { + return err + } + + return os.WriteFile(backupFilePath, content, info.Mode()) + } + + return nil + }) +} + +// RestoreFromBackup restores a site from a specific backup +func (sm *SiteManager) RestoreFromBackup(siteID, timestamp string) error { + sm.mutex.RLock() + site, exists := sm.sites[siteID] + sm.mutex.RUnlock() + + if !exists { + return fmt.Errorf("site %s is not registered", siteID) + } + + backupPath := filepath.Join(sm.backupDir, siteID, timestamp) + if _, err := os.Stat(backupPath); os.IsNotExist(err) { + return fmt.Errorf("backup not found: %s", backupPath) + } + + log.Printf("🔄 Restoring site %s from backup %s", siteID, timestamp) + + // Copy backup files back to site directory + return filepath.Walk(backupPath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if !info.IsDir() { + relPath, err := filepath.Rel(backupPath, path) + if err != nil { + return err + } + + targetPath := filepath.Join(site.Path, relPath) + + // Create directory structure + if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil { + return err + } + + // Copy file + content, err := os.ReadFile(path) + if err != nil { + return err + } + + return os.WriteFile(targetPath, content, info.Mode()) + } + + return nil + }) +} + +// ListBackups returns available backups for a site +func (sm *SiteManager) ListBackups(siteID string) ([]string, error) { + backupSitePath := filepath.Join(sm.backupDir, siteID) + + if _, err := os.Stat(backupSitePath); os.IsNotExist(err) { + return []string{}, nil // No backups exist + } + + entries, err := os.ReadDir(backupSitePath) + if err != nil { + return nil, fmt.Errorf("failed to read backup directory: %w", err) + } + + var backups []string + for _, entry := range entries { + if entry.IsDir() { + backups = append(backups, entry.Name()) + } + } + + return backups, nil +} + +// GetStats returns statistics about registered sites +func (sm *SiteManager) GetStats() map[string]interface{} { + sm.mutex.RLock() + defer sm.mutex.RUnlock() + + autoEnhanceCount := 0 + for _, site := range sm.sites { + if site.AutoEnhance { + autoEnhanceCount++ + } + } + + return map[string]interface{}{ + "total_sites": len(sm.sites), + "auto_enhance_sites": autoEnhanceCount, + "backup_directory": sm.backupDir, + } +}