Files
insertr/internal/api/handlers.go
Joakim 8d92c6477b 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.
2025-09-10 23:05:09 +02:00

684 lines
19 KiB
Go

package api
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"time"
"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"
)
// ContentHandler handles all content-related HTTP requests
type ContentHandler struct {
database *db.Database
authService *auth.AuthService
siteManager *content.SiteManager
}
// NewContentHandler creates a new content handler
func NewContentHandler(database *db.Database, authService *auth.AuthService) *ContentHandler {
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)
contentID := vars["id"]
siteID := r.URL.Query().Get("site_id")
if siteID == "" {
http.Error(w, "site_id parameter is required", http.StatusBadRequest)
return
}
var content interface{}
var err error
switch h.database.GetDBType() {
case "sqlite3":
content, err = h.database.GetSQLiteQueries().GetContent(context.Background(), sqlite.GetContentParams{
ID: contentID,
SiteID: siteID,
})
case "postgresql":
content, err = h.database.GetPostgreSQLQueries().GetContent(context.Background(), postgresql.GetContentParams{
ID: contentID,
SiteID: siteID,
})
default:
http.Error(w, "Unsupported database type", http.StatusInternalServerError)
return
}
if err != nil {
if err == sql.ErrNoRows {
http.Error(w, "Content not found", http.StatusNotFound)
return
}
http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError)
return
}
item := h.convertToAPIContent(content)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(item)
}
// GetAllContent handles GET /api/content
func (h *ContentHandler) GetAllContent(w http.ResponseWriter, r *http.Request) {
siteID := r.URL.Query().Get("site_id")
if siteID == "" {
http.Error(w, "site_id parameter is required", http.StatusBadRequest)
return
}
var dbContent interface{}
var err error
switch h.database.GetDBType() {
case "sqlite3":
dbContent, err = h.database.GetSQLiteQueries().GetAllContent(context.Background(), siteID)
case "postgresql":
dbContent, err = h.database.GetPostgreSQLQueries().GetAllContent(context.Background(), siteID)
default:
http.Error(w, "Unsupported database type", http.StatusInternalServerError)
return
}
if err != nil {
http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError)
return
}
items := h.convertToAPIContentList(dbContent)
response := ContentResponse{Content: items}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// GetBulkContent handles GET /api/content/bulk
func (h *ContentHandler) GetBulkContent(w http.ResponseWriter, r *http.Request) {
siteID := r.URL.Query().Get("site_id")
if siteID == "" {
http.Error(w, "site_id parameter is required", http.StatusBadRequest)
return
}
// Parse ids parameter
idsParam := r.URL.Query()["ids[]"]
if len(idsParam) == 0 {
// Try single ids parameter
idsStr := r.URL.Query().Get("ids")
if idsStr == "" {
http.Error(w, "ids parameter is required", http.StatusBadRequest)
return
}
idsParam = strings.Split(idsStr, ",")
}
var dbContent interface{}
var err error
switch h.database.GetDBType() {
case "sqlite3":
dbContent, err = h.database.GetSQLiteQueries().GetBulkContent(context.Background(), sqlite.GetBulkContentParams{
SiteID: siteID,
Ids: idsParam,
})
case "postgresql":
dbContent, err = h.database.GetPostgreSQLQueries().GetBulkContent(context.Background(), postgresql.GetBulkContentParams{
SiteID: siteID,
Ids: idsParam,
})
default:
http.Error(w, "Unsupported database type", http.StatusInternalServerError)
return
}
if err != nil {
http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError)
return
}
items := h.convertToAPIContentList(dbContent)
response := ContentResponse{Content: items}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// CreateContent handles POST /api/content
func (h *ContentHandler) CreateContent(w http.ResponseWriter, r *http.Request) {
var req CreateContentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
siteID := r.URL.Query().Get("site_id")
if siteID == "" {
siteID = req.SiteID // fallback to request body
}
if siteID == "" {
siteID = "default" // final fallback
}
// Extract user from request using authentication service
userInfo, authErr := h.authService.ExtractUserFromRequest(r)
if authErr != nil {
http.Error(w, fmt.Sprintf("Authentication error: %v", authErr), http.StatusUnauthorized)
return
}
userID := userInfo.ID
var content interface{}
var err error
switch h.database.GetDBType() {
case "sqlite3":
content, err = h.database.GetSQLiteQueries().CreateContent(context.Background(), sqlite.CreateContentParams{
ID: req.ID,
SiteID: siteID,
Value: req.Value,
Type: req.Type,
LastEditedBy: userID,
})
case "postgresql":
content, err = h.database.GetPostgreSQLQueries().CreateContent(context.Background(), postgresql.CreateContentParams{
ID: req.ID,
SiteID: siteID,
Value: req.Value,
Type: req.Type,
LastEditedBy: userID,
})
default:
http.Error(w, "Unsupported database type", http.StatusInternalServerError)
return
}
if err != nil {
http.Error(w, fmt.Sprintf("Failed to create content: %v", err), http.StatusInternalServerError)
return
}
item := h.convertToAPIContent(content)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(item)
}
// UpdateContent handles PUT /api/content/{id} with upsert functionality
func (h *ContentHandler) UpdateContent(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
contentID := vars["id"]
siteID := r.URL.Query().Get("site_id")
if siteID == "" {
http.Error(w, "site_id parameter is required", http.StatusBadRequest)
return
}
var req UpdateContentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
// Extract user from request using authentication service
userInfo, authErr := h.authService.ExtractUserFromRequest(r)
if authErr != nil {
http.Error(w, fmt.Sprintf("Authentication error: %v", authErr), http.StatusUnauthorized)
return
}
userID := userInfo.ID
// Check if content exists for version history (non-blocking)
var existingContent interface{}
var contentExists bool
switch h.database.GetDBType() {
case "sqlite3":
existingContent, _ = h.database.GetSQLiteQueries().GetContent(context.Background(), sqlite.GetContentParams{
ID: contentID,
SiteID: siteID,
})
contentExists = existingContent != nil
case "postgresql":
existingContent, _ = h.database.GetPostgreSQLQueries().GetContent(context.Background(), postgresql.GetContentParams{
ID: contentID,
SiteID: siteID,
})
contentExists = existingContent != nil
}
// Archive existing version before upsert (only if content already exists)
if contentExists {
if err := h.createContentVersion(existingContent); err != nil {
// Log error but don't fail the request - version history is non-critical
fmt.Printf("Warning: Failed to create content version: %v\n", err)
}
}
// Determine content type: use provided type, fallback to existing type, default to "text"
contentType := req.Type
if contentType == "" && contentExists {
contentType = h.getContentType(existingContent)
}
if contentType == "" {
contentType = "text" // default type for new content
}
// Perform upsert operation
var upsertedContent interface{}
var err error
switch h.database.GetDBType() {
case "sqlite3":
upsertedContent, err = h.database.GetSQLiteQueries().UpsertContent(context.Background(), sqlite.UpsertContentParams{
ID: contentID,
SiteID: siteID,
Value: req.Value,
Type: contentType,
LastEditedBy: userID,
})
case "postgresql":
upsertedContent, err = h.database.GetPostgreSQLQueries().UpsertContent(context.Background(), postgresql.UpsertContentParams{
ID: contentID,
SiteID: siteID,
Value: req.Value,
Type: contentType,
LastEditedBy: userID,
})
default:
http.Error(w, "Unsupported database type", http.StatusInternalServerError)
return
}
if err != nil {
http.Error(w, fmt.Sprintf("Failed to upsert content: %v", err), http.StatusInternalServerError)
return
}
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)
}
// DeleteContent handles DELETE /api/content/{id}
func (h *ContentHandler) DeleteContent(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
contentID := vars["id"]
siteID := r.URL.Query().Get("site_id")
if siteID == "" {
http.Error(w, "site_id parameter is required", http.StatusBadRequest)
return
}
var err error
switch h.database.GetDBType() {
case "sqlite3":
err = h.database.GetSQLiteQueries().DeleteContent(context.Background(), sqlite.DeleteContentParams{
ID: contentID,
SiteID: siteID,
})
case "postgresql":
err = h.database.GetPostgreSQLQueries().DeleteContent(context.Background(), postgresql.DeleteContentParams{
ID: contentID,
SiteID: siteID,
})
default:
http.Error(w, "Unsupported database type", http.StatusInternalServerError)
return
}
if err != nil {
http.Error(w, fmt.Sprintf("Failed to delete content: %v", err), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// GetContentVersions handles GET /api/content/{id}/versions
func (h *ContentHandler) GetContentVersions(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
contentID := vars["id"]
siteID := r.URL.Query().Get("site_id")
if siteID == "" {
http.Error(w, "site_id parameter is required", http.StatusBadRequest)
return
}
// Parse limit parameter (default to 10)
limit := int64(10)
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
if parsedLimit, err := strconv.ParseInt(limitStr, 10, 64); err == nil {
limit = parsedLimit
}
}
var dbVersions interface{}
var err error
switch h.database.GetDBType() {
case "sqlite3":
dbVersions, err = h.database.GetSQLiteQueries().GetContentVersionHistory(context.Background(), sqlite.GetContentVersionHistoryParams{
ContentID: contentID,
SiteID: siteID,
LimitCount: limit,
})
case "postgresql":
// Note: PostgreSQL uses different parameter names due to int32 vs int64
dbVersions, err = h.database.GetPostgreSQLQueries().GetContentVersionHistory(context.Background(), postgresql.GetContentVersionHistoryParams{
ContentID: contentID,
SiteID: siteID,
LimitCount: int32(limit),
})
default:
http.Error(w, "Unsupported database type", http.StatusInternalServerError)
return
}
if err != nil {
http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError)
return
}
versions := h.convertToAPIVersionList(dbVersions)
response := ContentVersionsResponse{Versions: versions}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// RollbackContent handles POST /api/content/{id}/rollback
func (h *ContentHandler) RollbackContent(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
contentID := vars["id"]
siteID := r.URL.Query().Get("site_id")
if siteID == "" {
http.Error(w, "site_id parameter is required", http.StatusBadRequest)
return
}
var req RollbackContentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
// Get the target version
var targetVersion interface{}
var err error
switch h.database.GetDBType() {
case "sqlite3":
targetVersion, err = h.database.GetSQLiteQueries().GetContentVersion(context.Background(), req.VersionID)
case "postgresql":
targetVersion, err = h.database.GetPostgreSQLQueries().GetContentVersion(context.Background(), int32(req.VersionID))
default:
http.Error(w, "Unsupported database type", http.StatusInternalServerError)
return
}
if err != nil {
if err == sql.ErrNoRows {
http.Error(w, "Version not found", http.StatusNotFound)
return
}
http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError)
return
}
// Verify the version belongs to the correct content
if !h.versionMatches(targetVersion, contentID, siteID) {
http.Error(w, "Version does not match content", http.StatusBadRequest)
return
}
// Extract user from request using authentication service
userInfo, authErr := h.authService.ExtractUserFromRequest(r)
if authErr != nil {
http.Error(w, fmt.Sprintf("Authentication error: %v", authErr), http.StatusUnauthorized)
return
}
userID := userInfo.ID
// Archive current version before rollback
var currentContent interface{}
switch h.database.GetDBType() {
case "sqlite3":
currentContent, err = h.database.GetSQLiteQueries().GetContent(context.Background(), sqlite.GetContentParams{
ID: contentID,
SiteID: siteID,
})
case "postgresql":
currentContent, err = h.database.GetPostgreSQLQueries().GetContent(context.Background(), postgresql.GetContentParams{
ID: contentID,
SiteID: siteID,
})
default:
http.Error(w, "Unsupported database type", http.StatusInternalServerError)
return
}
if err != nil {
http.Error(w, fmt.Sprintf("Failed to get current content: %v", err), http.StatusInternalServerError)
return
}
err = h.createContentVersion(currentContent)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to create version: %v", err), http.StatusInternalServerError)
return
}
// Rollback to target version
var updatedContent interface{}
switch h.database.GetDBType() {
case "sqlite3":
sqliteVersion := targetVersion.(sqlite.ContentVersion)
updatedContent, err = h.database.GetSQLiteQueries().UpdateContent(context.Background(), sqlite.UpdateContentParams{
Value: sqliteVersion.Value,
Type: sqliteVersion.Type,
LastEditedBy: userID,
ID: contentID,
SiteID: siteID,
})
case "postgresql":
pgVersion := targetVersion.(postgresql.ContentVersion)
updatedContent, err = h.database.GetPostgreSQLQueries().UpdateContent(context.Background(), postgresql.UpdateContentParams{
Value: pgVersion.Value,
Type: pgVersion.Type,
LastEditedBy: userID,
ID: contentID,
SiteID: siteID,
})
default:
http.Error(w, "Unsupported database type", http.StatusInternalServerError)
return
}
if err != nil {
http.Error(w, fmt.Sprintf("Failed to rollback content: %v", err), http.StatusInternalServerError)
return
}
item := h.convertToAPIContent(updatedContent)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(item)
}
// Helper functions for type conversion
func (h *ContentHandler) convertToAPIContent(content interface{}) ContentItem {
switch h.database.GetDBType() {
case "sqlite3":
c := content.(sqlite.Content)
return ContentItem{
ID: c.ID,
SiteID: c.SiteID,
Value: c.Value,
Type: c.Type,
CreatedAt: time.Unix(c.CreatedAt, 0),
UpdatedAt: time.Unix(c.UpdatedAt, 0),
LastEditedBy: c.LastEditedBy,
}
case "postgresql":
c := content.(postgresql.Content)
return ContentItem{
ID: c.ID,
SiteID: c.SiteID,
Value: c.Value,
Type: c.Type,
CreatedAt: time.Unix(c.CreatedAt, 0),
UpdatedAt: time.Unix(c.UpdatedAt, 0),
LastEditedBy: c.LastEditedBy,
}
}
return ContentItem{} // Should never happen
}
func (h *ContentHandler) convertToAPIContentList(contentList interface{}) []ContentItem {
switch h.database.GetDBType() {
case "sqlite3":
list := contentList.([]sqlite.Content)
items := make([]ContentItem, len(list))
for i, content := range list {
items[i] = h.convertToAPIContent(content)
}
return items
case "postgresql":
list := contentList.([]postgresql.Content)
items := make([]ContentItem, len(list))
for i, content := range list {
items[i] = h.convertToAPIContent(content)
}
return items
}
return []ContentItem{} // Should never happen
}
func (h *ContentHandler) convertToAPIVersionList(versionList interface{}) []ContentVersion {
switch h.database.GetDBType() {
case "sqlite3":
list := versionList.([]sqlite.ContentVersion)
versions := make([]ContentVersion, len(list))
for i, version := range list {
versions[i] = ContentVersion{
VersionID: version.VersionID,
ContentID: version.ContentID,
SiteID: version.SiteID,
Value: version.Value,
Type: version.Type,
CreatedAt: time.Unix(version.CreatedAt, 0),
CreatedBy: version.CreatedBy,
}
}
return versions
case "postgresql":
list := versionList.([]postgresql.ContentVersion)
versions := make([]ContentVersion, len(list))
for i, version := range list {
versions[i] = ContentVersion{
VersionID: int64(version.VersionID),
ContentID: version.ContentID,
SiteID: version.SiteID,
Value: version.Value,
Type: version.Type,
CreatedAt: time.Unix(version.CreatedAt, 0),
CreatedBy: version.CreatedBy,
}
}
return versions
}
return []ContentVersion{} // Should never happen
}
func (h *ContentHandler) createContentVersion(content interface{}) error {
switch h.database.GetDBType() {
case "sqlite3":
c := content.(sqlite.Content)
return h.database.GetSQLiteQueries().CreateContentVersion(context.Background(), sqlite.CreateContentVersionParams{
ContentID: c.ID,
SiteID: c.SiteID,
Value: c.Value,
Type: c.Type,
CreatedBy: c.LastEditedBy,
})
case "postgresql":
c := content.(postgresql.Content)
return h.database.GetPostgreSQLQueries().CreateContentVersion(context.Background(), postgresql.CreateContentVersionParams{
ContentID: c.ID,
SiteID: c.SiteID,
Value: c.Value,
Type: c.Type,
CreatedBy: c.LastEditedBy,
})
}
return fmt.Errorf("unsupported database type")
}
func (h *ContentHandler) getContentType(content interface{}) string {
switch h.database.GetDBType() {
case "sqlite3":
return content.(sqlite.Content).Type
case "postgresql":
return content.(postgresql.Content).Type
}
return ""
}
func (h *ContentHandler) versionMatches(version interface{}, contentID, siteID string) bool {
switch h.database.GetDBType() {
case "sqlite3":
v := version.(sqlite.ContentVersion)
return v.ContentID == contentID && v.SiteID == siteID
case "postgresql":
v := version.(postgresql.ContentVersion)
return v.ContentID == contentID && v.SiteID == siteID
}
return false
}