46.0 KB
admin_api.go
package controllers
import (
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/readysite/readysite/pkg/application"
"github.com/readysite/readysite/website/internal/access"
"github.com/readysite/readysite/website/internal/assist"
"github.com/readysite/readysite/website/internal/content"
"github.com/readysite/readysite/website/internal/helpers"
"github.com/readysite/readysite/website/models"
)
// AdminAPI returns the admin API controller for JSON endpoints.
func AdminAPI() (string, *AdminAPIController) {
return "adminapi", &AdminAPIController{}
}
// AdminAPIController handles admin JSON API endpoints.
type AdminAPIController struct {
application.BaseController
}
// Setup registers routes.
func (c *AdminAPIController) Setup(app *application.App) {
c.BaseController.Setup(app)
// Pages API
http.Handle("GET /api/admin/pages", app.Method(c, "ListPages", RequireAuthAPI))
http.Handle("GET /api/admin/pages/{id}", app.Method(c, "GetPage", RequireAuthAPI))
http.Handle("POST /api/admin/pages", app.Method(c, "CreatePage", RequireAuthAPI))
http.Handle("PUT /api/admin/pages/{id}", app.Method(c, "UpdatePage", RequireAuthAPI))
http.Handle("PATCH /api/admin/pages/{id}", app.Method(c, "UpdatePage", RequireAuthAPI))
http.Handle("DELETE /api/admin/pages/{id}", app.Method(c, "DeletePage", RequireAuthAPI))
// Page versioning API
http.Handle("GET /api/admin/pages/{id}/versions", app.Method(c, "ListPageVersions", RequireAuthAPI))
http.Handle("GET /api/admin/pages/{id}/content/{contentId}", app.Method(c, "GetPageContent", RequireAuthAPI))
http.Handle("GET /api/admin/pages/{id}/content/latest", app.Method(c, "GetLatestPageContent", RequireAuthAPI))
http.Handle("POST /api/admin/pages/{id}/code", app.Method(c, "UpdatePageCode", RequireAuthAPI))
http.Handle("POST /api/admin/pages/{id}/publish", app.Method(c, "PublishPage", RequireAuthAPI))
http.Handle("POST /api/admin/pages/{id}/restore/{contentId}", app.Method(c, "RestorePageVersion", RequireAuthAPI))
// Collections API
http.Handle("GET /api/admin/collections", app.Method(c, "ListCollections", RequireAuthAPI))
http.Handle("GET /api/admin/collections/{id}", app.Method(c, "GetCollection", RequireAuthAPI))
http.Handle("POST /api/admin/collections", app.Method(c, "CreateCollection", RequireAuthAPI))
http.Handle("PUT /api/admin/collections/{id}", app.Method(c, "UpdateCollection", RequireAuthAPI))
http.Handle("PATCH /api/admin/collections/{id}", app.Method(c, "UpdateCollection", RequireAuthAPI))
http.Handle("DELETE /api/admin/collections/{id}", app.Method(c, "DeleteCollection", RequireAuthAPI))
// Collection documents API
http.Handle("GET /api/admin/collections/{id}/documents", app.Method(c, "ListDocuments", RequireAuthAPI))
http.Handle("GET /api/admin/collections/{id}/documents/{docId}", app.Method(c, "GetDocument", RequireAuthAPI))
http.Handle("POST /api/admin/collections/{id}/documents", app.Method(c, "CreateDocument", RequireAuthAPI))
http.Handle("PUT /api/admin/collections/{id}/documents/{docId}", app.Method(c, "UpdateDocument", RequireAuthAPI))
http.Handle("PATCH /api/admin/collections/{id}/documents/{docId}", app.Method(c, "UpdateDocument", RequireAuthAPI))
http.Handle("DELETE /api/admin/collections/{id}/documents/{docId}", app.Method(c, "DeleteDocument", RequireAuthAPI))
// Partials API
http.Handle("GET /api/admin/partials", app.Method(c, "ListPartials", RequireAuthAPI))
http.Handle("GET /api/admin/partials/{id}", app.Method(c, "GetPartial", RequireAuthAPI))
http.Handle("POST /api/admin/partials", app.Method(c, "CreatePartial", RequireAuthAPI))
http.Handle("PUT /api/admin/partials/{id}", app.Method(c, "UpdatePartial", RequireAuthAPI))
http.Handle("PATCH /api/admin/partials/{id}", app.Method(c, "UpdatePartial", RequireAuthAPI))
http.Handle("DELETE /api/admin/partials/{id}", app.Method(c, "DeletePartial", RequireAuthAPI))
// Settings API
http.Handle("GET /api/admin/settings", app.Method(c, "GetSettings", RequireAuthAPI))
http.Handle("POST /api/admin/settings", app.Method(c, "UpdateSettings", RequireAuthAPI))
http.Handle("PATCH /api/admin/settings", app.Method(c, "UpdateSettings", RequireAuthAPI))
// Backup API (file download, not JSON)
http.Handle("GET /admin/backup", app.Method(c, "DownloadBackup", RequireAuth))
// Audit logs API
http.Handle("GET /api/admin/audit", app.Method(c, "ListAuditLogs", RequireAuthAPI))
// Admin events SSE (real-time updates)
http.Handle("GET /api/admin/events/subscribe", app.Method(c, "SubscribeEvents", RequireAuthAPI))
// Tour API
http.Handle("POST /api/admin/tour/complete", app.Method(c, "CompleteTour", RequireAuthAPI))
// Chat/Conversations API (use RequireAuthAPI to return JSON 401 instead of redirect)
http.Handle("GET /api/admin/conversations", app.Method(c, "ListConversations", RequireAuthAPI))
http.Handle("GET /api/admin/conversations/{id}", app.Method(c, "GetConversation", RequireAuthAPI))
http.Handle("POST /api/admin/conversations", app.Method(c, "CreateConversation", RequireAuthAPI))
http.Handle("PATCH /api/admin/conversations/{id}", app.Method(c, "UpdateConversation", RequireAuthAPI))
http.Handle("POST /api/admin/conversations/{id}/messages", app.Method(c, "SendMessage", RequireAuthAPI))
http.Handle("GET /api/admin/conversations/{id}/stream", app.Method(c, "StreamConversation", RequireAuthAPI))
http.Handle("DELETE /api/admin/conversations/{id}", app.Method(c, "DeleteConversation", RequireAuthAPI))
http.Handle("POST /api/admin/conversations/{id}/undo", app.Method(c, "UndoConversation", RequireAuthAPI))
http.Handle("POST /api/admin/conversations/{id}/redo", app.Method(c, "RedoConversation", RequireAuthAPI))
}
// Handle implements Controller interface.
func (c AdminAPIController) Handle(r *http.Request) application.Controller {
c.Request = r
return &c
}
// --- Pages API ---
// ListPages returns all pages.
func (c *AdminAPIController) ListPages(w http.ResponseWriter, r *http.Request) {
pages, err := models.Pages.Search("ORDER BY Position")
if err != nil {
c.jsonError(w, "Failed to list pages", http.StatusInternalServerError)
return
}
items := make([]map[string]any, len(pages))
for i, p := range pages {
items[i] = pageToJSON(p)
}
c.json(w, map[string]any{
"items": items,
"totalItems": len(items),
})
}
// GetPage returns a single page.
func (c *AdminAPIController) GetPage(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
page, err := models.Pages.Get(id)
if err != nil {
c.jsonError(w, "Page not found", http.StatusNotFound)
return
}
c.json(w, pageToJSON(page))
}
// CreatePage creates a new page.
func (c *AdminAPIController) CreatePage(w http.ResponseWriter, r *http.Request) {
user := access.GetUserFromJWT(r)
if user == nil {
c.jsonError(w, "Unauthorized", http.StatusUnauthorized)
return
}
var req struct {
Title string `json:"title"`
HTML string `json:"html"`
Description string `json:"description"`
ParentID string `json:"parentId"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
c.jsonError(w, "Invalid JSON", http.StatusBadRequest)
return
}
// Create the page
page := &models.Page{
ParentID: req.ParentID,
}
id, err := models.Pages.Insert(page)
if err != nil {
c.jsonError(w, "Failed to create page", http.StatusInternalServerError)
return
}
// Create initial content version
content := &models.PageContent{
PageID: id,
Title: req.Title,
Description: req.Description,
HTML: req.HTML,
CreatedBy: user.ID,
Status: models.StatusDraft,
}
if _, err := models.PageContents.Insert(content); err != nil {
c.jsonError(w, "Failed to create page content", http.StatusInternalServerError)
return
}
page, _ = models.Pages.Get(id)
c.json(w, pageToJSON(page), http.StatusCreated)
}
// UpdatePage updates a page.
func (c *AdminAPIController) UpdatePage(w http.ResponseWriter, r *http.Request) {
user := access.GetUserFromJWT(r)
if user == nil {
c.jsonError(w, "Unauthorized", http.StatusUnauthorized)
return
}
id := r.PathValue("id")
page, err := models.Pages.Get(id)
if err != nil {
c.jsonError(w, "Page not found", http.StatusNotFound)
return
}
var req map[string]any
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
c.jsonError(w, "Invalid JSON", http.StatusBadRequest)
return
}
// Update page fields
if v, ok := req["parentId"].(string); ok {
page.ParentID = v
if err := models.Pages.Update(page); err != nil {
c.jsonError(w, "Failed to update page", http.StatusInternalServerError)
return
}
}
// Create new content version if content fields changed
needsNewContent := false
title := page.Title()
description := page.Description()
html := page.HTML()
status := models.StatusDraft
if v, ok := req["title"].(string); ok {
title = v
needsNewContent = true
}
if v, ok := req["description"].(string); ok {
description = v
needsNewContent = true
}
if v, ok := req["html"].(string); ok {
html = v
needsNewContent = true
}
if v, ok := req["published"].(bool); ok {
if v {
status = models.StatusPublished
}
needsNewContent = true
}
if needsNewContent {
content := &models.PageContent{
PageID: page.ID,
Title: title,
Description: description,
HTML: html,
CreatedBy: user.ID,
Status: status,
}
if _, err := models.PageContents.Insert(content); err != nil {
c.jsonError(w, "Failed to save content", http.StatusInternalServerError)
return
}
}
c.json(w, pageToJSON(page))
}
// DeletePage deletes a page.
func (c *AdminAPIController) DeletePage(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
page, err := models.Pages.Get(id)
if err != nil {
c.jsonError(w, "Page not found", http.StatusNotFound)
return
}
// Delete all content versions first
contents, _ := page.Contents()
for _, content := range contents {
models.PageContents.Delete(content)
}
if err := models.Pages.Delete(page); err != nil {
c.jsonError(w, "Failed to delete page", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// --- Page Versioning API ---
// ListPageVersions returns all content versions for a page.
func (c *AdminAPIController) ListPageVersions(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
page, err := models.Pages.Get(id)
if err != nil {
c.jsonError(w, "Page not found", http.StatusNotFound)
return
}
contents, err := page.Contents()
if err != nil {
c.jsonError(w, "Failed to get versions", http.StatusInternalServerError)
return
}
items := make([]map[string]any, len(contents))
for i, pc := range contents {
creator, _ := pc.Creator()
creatorName := ""
if creator != nil {
creatorName = creator.Name
}
items[i] = map[string]any{
"id": pc.ID,
"title": pc.Title,
"status": pc.Status,
"creatorName": creatorName,
"created": pc.CreatedAt.Format("2006-01-02T15:04:05Z"),
}
}
c.json(w, map[string]any{
"items": items,
"totalItems": len(items),
})
}
// GetPageContent returns a specific content version.
func (c *AdminAPIController) GetPageContent(w http.ResponseWriter, r *http.Request) {
contentId := r.PathValue("contentId")
content, err := models.PageContents.Get(contentId)
if err != nil {
c.jsonError(w, "Content not found", http.StatusNotFound)
return
}
c.json(w, map[string]any{
"id": content.ID,
"title": content.Title,
"description": content.Description,
"html": content.HTML,
"status": content.Status,
"created": content.CreatedAt.Format("2006-01-02T15:04:05Z"),
})
}
// GetLatestPageContent returns the latest content version for a page.
func (c *AdminAPIController) GetLatestPageContent(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
page, err := models.Pages.Get(id)
if err != nil {
c.jsonError(w, "Page not found", http.StatusNotFound)
return
}
content := page.LatestContent()
if content == nil {
c.jsonError(w, "No content found", http.StatusNotFound)
return
}
c.json(w, map[string]any{
"id": content.ID,
"title": content.Title,
"description": content.Description,
"html": content.HTML,
"status": content.Status,
"created": content.CreatedAt.Format("2006-01-02T15:04:05Z"),
})
}
// UpdatePageCode updates the HTML code for a page.
func (c *AdminAPIController) UpdatePageCode(w http.ResponseWriter, r *http.Request) {
user := access.GetUserFromJWT(r)
if user == nil {
c.jsonError(w, "Unauthorized", http.StatusUnauthorized)
return
}
id := r.PathValue("id")
page, err := models.Pages.Get(id)
if err != nil {
c.jsonError(w, "Page not found", http.StatusNotFound)
return
}
var req struct {
HTML string `json:"html"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
c.jsonError(w, "Invalid JSON", http.StatusBadRequest)
return
}
// Create new content version with updated HTML
content := &models.PageContent{
PageID: page.ID,
Title: page.Title(),
Description: page.Description(),
HTML: req.HTML,
CreatedBy: user.ID,
Status: models.StatusDraft,
}
if _, err := models.PageContents.Insert(content); err != nil {
c.jsonError(w, "Failed to save content", http.StatusInternalServerError)
return
}
c.json(w, map[string]any{"ok": true})
}
// PublishPage toggles the publish status of a page.
func (c *AdminAPIController) PublishPage(w http.ResponseWriter, r *http.Request) {
user := access.GetUserFromJWT(r)
if user == nil {
c.jsonError(w, "Unauthorized", http.StatusUnauthorized)
return
}
id := r.PathValue("id")
page, err := models.Pages.Get(id)
if err != nil {
c.jsonError(w, "Page not found", http.StatusNotFound)
return
}
// Toggle publish status
newStatus := models.StatusPublished
if page.IsPublished() {
newStatus = models.StatusDraft
}
content := &models.PageContent{
PageID: page.ID,
Title: page.Title(),
Description: page.Description(),
HTML: page.HTML(),
CreatedBy: user.ID,
Status: newStatus,
}
if _, err := models.PageContents.Insert(content); err != nil {
c.jsonError(w, "Failed to update status", http.StatusInternalServerError)
return
}
c.json(w, pageToJSON(page))
}
// RestorePageVersion restores a page to a previous version.
func (c *AdminAPIController) RestorePageVersion(w http.ResponseWriter, r *http.Request) {
user := access.GetUserFromJWT(r)
if user == nil {
c.jsonError(w, "Unauthorized", http.StatusUnauthorized)
return
}
id := r.PathValue("id")
contentId := r.PathValue("contentId")
page, err := models.Pages.Get(id)
if err != nil {
c.jsonError(w, "Page not found", http.StatusNotFound)
return
}
oldContent, err := models.PageContents.Get(contentId)
if err != nil {
c.jsonError(w, "Content version not found", http.StatusNotFound)
return
}
// Create new version with old content
content := &models.PageContent{
PageID: page.ID,
Title: oldContent.Title,
Description: oldContent.Description,
HTML: oldContent.HTML,
CreatedBy: user.ID,
Status: models.StatusDraft,
}
if _, err := models.PageContents.Insert(content); err != nil {
c.jsonError(w, "Failed to restore version", http.StatusInternalServerError)
return
}
c.json(w, map[string]any{"ok": true})
}
// --- Collections API ---
// ListCollections returns all collections.
func (c *AdminAPIController) ListCollections(w http.ResponseWriter, r *http.Request) {
collections, err := models.Collections.Search("")
if err != nil {
c.jsonError(w, "Failed to list collections", http.StatusInternalServerError)
return
}
items := make([]map[string]any, len(collections))
for i, col := range collections {
items[i] = collectionToJSON(col)
}
c.json(w, items)
}
// GetCollection returns a single collection with its schema.
func (c *AdminAPIController) GetCollection(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
col, err := models.Collections.Get(id)
if err != nil {
c.jsonError(w, "Collection not found", http.StatusNotFound)
return
}
c.json(w, collectionToJSON(col))
}
// CreateCollection creates a new collection.
func (c *AdminAPIController) CreateCollection(w http.ResponseWriter, r *http.Request) {
var req struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Schema string `json:"schema"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
c.jsonError(w, "Invalid JSON", http.StatusBadRequest)
return
}
col := &models.Collection{
Name: req.Name,
Description: req.Description,
Schema: req.Schema,
Type: models.CollectionTypeBase,
}
if req.ID != "" {
col.ID = req.ID
}
id, err := models.Collections.Insert(col)
if err != nil {
c.jsonError(w, "Failed to create collection", http.StatusInternalServerError)
return
}
col, _ = models.Collections.Get(id)
c.json(w, collectionToJSON(col), http.StatusCreated)
}
// UpdateCollection updates a collection.
func (c *AdminAPIController) UpdateCollection(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
col, err := models.Collections.Get(id)
if err != nil {
c.jsonError(w, "Collection not found", http.StatusNotFound)
return
}
if col.System {
c.jsonError(w, "Cannot modify system collection", http.StatusForbidden)
return
}
var req map[string]any
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
c.jsonError(w, "Invalid JSON", http.StatusBadRequest)
return
}
if v, ok := req["name"].(string); ok {
col.Name = v
}
if v, ok := req["description"].(string); ok {
col.Description = v
}
if v, ok := req["schema"].(string); ok {
col.Schema = v
}
if v, ok := req["listRule"].(string); ok {
col.ListRule = v
}
if v, ok := req["viewRule"].(string); ok {
col.ViewRule = v
}
if v, ok := req["createRule"].(string); ok {
col.CreateRule = v
}
if v, ok := req["updateRule"].(string); ok {
col.UpdateRule = v
}
if v, ok := req["deleteRule"].(string); ok {
col.DeleteRule = v
}
if err := models.Collections.Update(col); err != nil {
c.jsonError(w, "Failed to update collection", http.StatusInternalServerError)
return
}
c.json(w, collectionToJSON(col))
}
// DeleteCollection deletes a collection and all its documents.
func (c *AdminAPIController) DeleteCollection(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
col, err := models.Collections.Get(id)
if err != nil {
c.jsonError(w, "Collection not found", http.StatusNotFound)
return
}
if col.System {
c.jsonError(w, "Cannot delete system collection", http.StatusForbidden)
return
}
// Delete all documents first
docs, _ := col.Documents()
for _, doc := range docs {
models.Documents.Delete(doc)
}
if err := models.Collections.Delete(col); err != nil {
c.jsonError(w, "Failed to delete collection", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// --- Documents API ---
// ListDocuments returns all documents in a collection.
func (c *AdminAPIController) ListDocuments(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
col, err := models.Collections.Get(id)
if err != nil {
c.jsonError(w, "Collection not found", http.StatusNotFound)
return
}
docs, err := col.Documents()
if err != nil {
c.jsonError(w, "Failed to list documents", http.StatusInternalServerError)
return
}
items := make([]map[string]any, len(docs))
for i, doc := range docs {
items[i] = documentToJSON(doc)
}
c.json(w, map[string]any{
"items": items,
"totalItems": len(items),
})
}
// GetDocument returns a single document.
func (c *AdminAPIController) GetDocument(w http.ResponseWriter, r *http.Request) {
docId := r.PathValue("docId")
doc, err := models.Documents.Get(docId)
if err != nil {
c.jsonError(w, "Document not found", http.StatusNotFound)
return
}
c.json(w, documentToJSON(doc))
}
// CreateDocument creates a new document in a collection.
func (c *AdminAPIController) CreateDocument(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
col, err := models.Collections.Get(id)
if err != nil {
c.jsonError(w, "Collection not found", http.StatusNotFound)
return
}
var req struct {
ID string `json:"id"`
Data map[string]any `json:"data"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
c.jsonError(w, "Invalid JSON", http.StatusBadRequest)
return
}
dataBytes, _ := json.Marshal(req.Data)
doc := &models.Document{
CollectionID: col.ID,
Data: string(dataBytes),
}
if req.ID != "" {
doc.ID = req.ID
}
docId, err := models.Documents.Insert(doc)
if err != nil {
c.jsonError(w, "Failed to create document", http.StatusInternalServerError)
return
}
doc, _ = models.Documents.Get(docId)
c.json(w, documentToJSON(doc), http.StatusCreated)
}
// UpdateDocument updates a document.
func (c *AdminAPIController) UpdateDocument(w http.ResponseWriter, r *http.Request) {
docId := r.PathValue("docId")
doc, err := models.Documents.Get(docId)
if err != nil {
c.jsonError(w, "Document not found", http.StatusNotFound)
return
}
var req struct {
Data map[string]any `json:"data"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
c.jsonError(w, "Invalid JSON", http.StatusBadRequest)
return
}
if err := doc.SetAll(req.Data); err != nil {
c.jsonError(w, "Failed to set data", http.StatusBadRequest)
return
}
if err := models.Documents.Update(doc); err != nil {
c.jsonError(w, "Failed to update document", http.StatusInternalServerError)
return
}
c.json(w, documentToJSON(doc))
}
// DeleteDocument deletes a document.
func (c *AdminAPIController) DeleteDocument(w http.ResponseWriter, r *http.Request) {
docId := r.PathValue("docId")
doc, err := models.Documents.Get(docId)
if err != nil {
c.jsonError(w, "Document not found", http.StatusNotFound)
return
}
if err := models.Documents.Delete(doc); err != nil {
c.jsonError(w, "Failed to delete document", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// --- Partials API ---
// ListPartials returns all partials.
func (c *AdminAPIController) ListPartials(w http.ResponseWriter, r *http.Request) {
partials, err := models.Partials.Search("ORDER BY Name")
if err != nil {
c.jsonError(w, "Failed to list partials", http.StatusInternalServerError)
return
}
items := make([]map[string]any, len(partials))
for i, p := range partials {
items[i] = partialToJSON(p)
}
c.json(w, map[string]any{
"items": items,
"totalItems": len(items),
})
}
// GetPartial returns a single partial.
func (c *AdminAPIController) GetPartial(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
partial, err := models.Partials.Get(id)
if err != nil {
c.jsonError(w, "Partial not found", http.StatusNotFound)
return
}
c.json(w, partialToJSON(partial))
}
// CreatePartial creates a new partial.
func (c *AdminAPIController) CreatePartial(w http.ResponseWriter, r *http.Request) {
var req struct {
Slug string `json:"slug"`
Name string `json:"name"`
Description string `json:"description"`
HTML string `json:"html"`
Published bool `json:"published"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
c.jsonError(w, "Invalid JSON", http.StatusBadRequest)
return
}
partial := &models.Partial{
Name: req.Name,
Description: req.Description,
HTML: req.HTML,
Published: req.Published,
}
if req.Slug != "" {
partial.ID = req.Slug
}
id, err := models.Partials.Insert(partial)
if err != nil {
c.jsonError(w, "Failed to create partial", http.StatusInternalServerError)
return
}
partial, _ = models.Partials.Get(id)
c.json(w, partialToJSON(partial), http.StatusCreated)
}
// UpdatePartial updates a partial.
func (c *AdminAPIController) UpdatePartial(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
partial, err := models.Partials.Get(id)
if err != nil {
c.jsonError(w, "Partial not found", http.StatusNotFound)
return
}
var req map[string]any
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
c.jsonError(w, "Invalid JSON", http.StatusBadRequest)
return
}
if v, ok := req["name"].(string); ok {
partial.Name = v
}
if v, ok := req["description"].(string); ok {
partial.Description = v
}
if v, ok := req["html"].(string); ok {
partial.HTML = v
}
if v, ok := req["published"].(bool); ok {
partial.Published = v
}
if err := models.Partials.Update(partial); err != nil {
c.jsonError(w, "Failed to update partial", http.StatusInternalServerError)
return
}
c.json(w, partialToJSON(partial))
}
// DeletePartial deletes a partial.
func (c *AdminAPIController) DeletePartial(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
partial, err := models.Partials.Get(id)
if err != nil {
c.jsonError(w, "Partial not found", http.StatusNotFound)
return
}
if err := models.Partials.Delete(partial); err != nil {
c.jsonError(w, "Failed to delete partial", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// --- Settings API ---
// GetSettings returns site settings.
func (c *AdminAPIController) GetSettings(w http.ResponseWriter, r *http.Request) {
c.json(w, map[string]any{
"siteName": helpers.GetSetting(models.SettingSiteName),
"siteDescription": helpers.GetSetting(models.SettingSiteDescription),
"aiProvider": helpers.GetSetting(models.SettingAIProvider),
"aiModel": helpers.GetSetting(models.SettingAIModel),
"signupEnabled": helpers.GetSetting(models.SettingSignupEnabled) == "true",
"hasApiKey": helpers.GetSetting(models.SettingAIAPIKey) != "",
})
}
// UpdateSettings updates site settings.
func (c *AdminAPIController) UpdateSettings(w http.ResponseWriter, r *http.Request) {
var req map[string]any
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
c.jsonError(w, "Invalid JSON", http.StatusBadRequest)
return
}
if v, ok := req["siteName"].(string); ok {
helpers.SetSetting(models.SettingSiteName, v)
}
if v, ok := req["siteDescription"].(string); ok {
helpers.SetSetting(models.SettingSiteDescription, v)
}
if v, ok := req["aiProvider"].(string); ok {
helpers.SetSetting(models.SettingAIProvider, v)
}
if v, ok := req["aiModel"].(string); ok {
helpers.SetSetting(models.SettingAIModel, v)
}
if v, ok := req["aiApiKey"].(string); ok && v != "" {
helpers.SetSetting(models.SettingAIAPIKey, v)
}
if v, ok := req["signupEnabled"].(bool); ok {
val := "false"
if v {
val = "true"
}
helpers.SetSetting(models.SettingSignupEnabled, val)
}
c.json(w, map[string]any{"ok": true})
}
// DownloadBackup exports all site data as a JSON file.
// GET /admin/backup
func (c *AdminAPIController) DownloadBackup(w http.ResponseWriter, r *http.Request) {
user := access.GetUserFromJWT(r)
if user == nil || user.Role != "admin" {
http.Error(w, "Admin access required", http.StatusForbidden)
return
}
// Build backup structure
backup := map[string]any{
"version": "1.0",
"exported": time.Now().Format(time.RFC3339),
"exportedBy": user.Email,
}
// Settings
backup["settings"] = map[string]any{
"siteName": helpers.GetSetting(models.SettingSiteName),
"siteDescription": helpers.GetSetting(models.SettingSiteDescription),
"aiProvider": helpers.GetSetting(models.SettingAIProvider),
"aiModel": helpers.GetSetting(models.SettingAIModel),
}
// Pages with content
pages, _ := models.Pages.All()
pagesData := make([]map[string]any, 0, len(pages))
for _, p := range pages {
pageData := map[string]any{
"id": p.ID,
"parentId": p.ParentID,
"position": p.Position,
"published": p.IsPublished(),
"created": p.CreatedAt,
"updated": p.UpdatedAt,
}
// Get latest content
contents, _ := models.PageContents.Search("WHERE PageID = ? ORDER BY Version DESC LIMIT 1", p.ID)
if len(contents) > 0 {
pageData["html"] = contents[0].HTML
}
pagesData = append(pagesData, pageData)
}
backup["pages"] = pagesData
// Partials
partials, _ := models.Partials.All()
partialsData := make([]map[string]any, 0, len(partials))
for _, p := range partials {
partialsData = append(partialsData, map[string]any{
"id": p.ID,
"name": p.Name,
"description": p.Description,
"html": p.HTML,
"published": p.Published,
"created": p.CreatedAt,
"updated": p.UpdatedAt,
})
}
backup["partials"] = partialsData
// Collections and documents
collections, _ := models.Collections.All()
collectionsData := make([]map[string]any, 0, len(collections))
for _, col := range collections {
docs, _ := models.Documents.Search("WHERE CollectionID = ?", col.ID)
docsData := make([]map[string]any, 0, len(docs))
for _, d := range docs {
docsData = append(docsData, map[string]any{
"id": d.ID,
"data": d.Data,
"created": d.CreatedAt,
"updated": d.UpdatedAt,
})
}
collectionsData = append(collectionsData, map[string]any{
"id": col.ID,
"name": col.Name,
"description": col.Description,
"schema": col.Schema,
"system": col.System,
"created": col.CreatedAt,
"updated": col.UpdatedAt,
"documents": docsData,
})
}
backup["collections"] = collectionsData
// Files metadata (not the actual file contents)
files, _ := models.Files.All()
filesData := make([]map[string]any, 0, len(files))
for _, f := range files {
filesData = append(filesData, map[string]any{
"id": f.ID,
"name": f.Name,
"path": f.Path,
"mimeType": f.MimeType,
"size": f.Size,
"published": f.Published,
"created": f.CreatedAt,
})
}
backup["files"] = filesData
// Users (without passwords)
users, _ := models.Users.All()
usersData := make([]map[string]any, 0, len(users))
for _, u := range users {
usersData = append(usersData, map[string]any{
"id": u.ID,
"email": u.Email,
"name": u.Name,
"role": u.Role,
"verified": u.Verified,
"created": u.CreatedAt,
})
}
backup["users"] = usersData
// Set headers for file download
filename := fmt.Sprintf("site-backup-%s.json", time.Now().Format("2006-01-02"))
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
// Log the backup
helpers.AuditRead(r, user.ID, "backup", "site", "Full site backup")
// Encode and send
encoder := json.NewEncoder(w)
encoder.SetIndent("", " ")
encoder.Encode(backup)
}
// --- Admin Events SSE ---
// SubscribeEvents returns an SSE stream of admin events (page/collection changes).
func (c *AdminAPIController) SubscribeEvents(w http.ResponseWriter, r *http.Request) {
stream := c.BaseController.Stream(w)
// Subscribe to all events
sub := content.CollectionEvents.SubscribeAll()
defer content.CollectionEvents.Unsubscribe(sub)
ctx := r.Context()
heartbeat := time.NewTicker(30 * time.Second)
defer heartbeat.Stop()
for {
select {
case <-ctx.Done():
return
case <-heartbeat.C:
stream.Send("heartbeat", "{}")
case event, ok := <-sub.Events:
if !ok {
return
}
// Format event type: entity_action (e.g., "page_create", "collection_update")
eventType := fmt.Sprintf("%s_%s", event.EntityType, event.Type)
// Build event data
data := map[string]any{}
if event.PageID != "" {
data["id"] = event.PageID
}
if event.CollectionID != "" {
data["collectionId"] = event.CollectionID
}
if event.RecordID != "" {
data["recordId"] = event.RecordID
}
if event.Record != nil {
for k, v := range event.Record {
data[k] = v
}
}
jsonData, _ := json.Marshal(data)
stream.Send(eventType, string(jsonData))
}
}
}
// --- Audit Logs API ---
// ListAuditLogs returns paginated audit logs.
func (c *AdminAPIController) ListAuditLogs(w http.ResponseWriter, r *http.Request) {
page := 1
perPage := 50
if v := r.URL.Query().Get("page"); v != "" {
if p, err := parseInt(v); err == nil && p > 0 {
page = p
}
}
if v := r.URL.Query().Get("perPage"); v != "" {
if p, err := parseInt(v); err == nil && p > 0 && p <= 100 {
perPage = p
}
}
// Build filter
var conditions []string
var args []any
if action := r.URL.Query().Get("action"); action != "" {
conditions = append(conditions, "Action = ?")
args = append(args, action)
}
if resourceType := r.URL.Query().Get("resourceType"); resourceType != "" {
conditions = append(conditions, "ResourceType = ?")
args = append(args, resourceType)
}
whereClause := ""
if len(conditions) > 0 {
whereClause = "WHERE " + conditions[0]
for i := 1; i < len(conditions); i++ {
whereClause += " AND " + conditions[i]
}
}
totalItems := models.AuditLogs.Count(whereClause, args...)
totalPages := (totalItems + perPage - 1) / perPage
if totalPages < 1 {
totalPages = 1
}
offset := (page - 1) * perPage
query := whereClause + " ORDER BY CreatedAt DESC LIMIT ? OFFSET ?"
args = append(args, perPage, offset)
logs, err := models.AuditLogs.Search(query, args...)
if err != nil {
c.jsonError(w, "Failed to fetch audit logs", http.StatusInternalServerError)
return
}
items := make([]map[string]any, len(logs))
for i, log := range logs {
var userName, userEmail string
if user := log.User(); user != nil {
userName = user.Name
userEmail = user.Email
}
items[i] = map[string]any{
"id": log.ID,
"userId": log.UserID,
"userName": userName,
"userEmail": userEmail,
"action": log.Action,
"resourceType": log.ResourceType,
"resourceId": log.ResourceID,
"resourceName": log.ResourceName,
"ip": log.IP,
"created": log.CreatedAt.Format("2006-01-02T15:04:05Z"),
}
}
c.json(w, map[string]any{
"items": items,
"page": page,
"perPage": perPage,
"totalItems": totalItems,
"totalPages": totalPages,
})
}
// parseInt is a helper to parse integer strings.
func parseInt(s string) (int, error) {
var i int
_, err := fmt.Sscanf(s, "%d", &i)
return i, err
}
// --- Conversations API ---
// ListConversations returns all conversations for the current user.
func (c *AdminAPIController) ListConversations(w http.ResponseWriter, r *http.Request) {
user := access.GetUserFromJWT(r)
if user == nil {
c.jsonError(w, "Unauthorized", http.StatusUnauthorized)
return
}
conversations := assist.GetUserConversations(user.ID)
items := make([]map[string]any, len(conversations))
for i, conv := range conversations {
items[i] = conversationToJSON(conv)
}
c.json(w, map[string]any{
"items": items,
"totalItems": len(items),
})
}
// GetConversation returns a single conversation with its messages.
func (c *AdminAPIController) GetConversation(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
conv, err := models.Conversations.Get(id)
if err != nil {
c.jsonError(w, "Conversation not found", http.StatusNotFound)
return
}
messages := assist.GetMessages(conv)
msgItems := make([]map[string]any, len(messages))
for i, msg := range messages {
msgItems[i] = messageToJSON(msg)
}
result := conversationToJSON(conv)
result["messages"] = msgItems
result["canUndo"] = assist.ConvCanUndo(conv)
result["canRedo"] = assist.ConvCanRedo(conv)
result["streamingMessageId"] = assist.StreamingMessageID(conv)
c.json(w, result)
}
// CreateConversation creates a new conversation with an optional initial message.
func (c *AdminAPIController) CreateConversation(w http.ResponseWriter, r *http.Request) {
user := access.GetUserFromJWT(r)
if user == nil {
c.jsonError(w, "Unauthorized", http.StatusUnauthorized)
return
}
var req struct {
Content string `json:"content"`
FileIds []string `json:"fileIds"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
c.jsonError(w, "Invalid JSON", http.StatusBadRequest)
return
}
// Create conversation
title := "New Conversation"
if req.Content != "" {
title = helpers.TruncateTitle(req.Content, 50)
}
conv := &models.Conversation{
UserID: user.ID,
Title: title,
}
convID, err := models.Conversations.Insert(conv)
if err != nil {
c.jsonError(w, "Failed to create conversation", http.StatusInternalServerError)
return
}
// If content provided, create user message and pending assistant message
if req.Content != "" {
userMsg := &models.Message{
ConversationID: convID,
Role: assist.RoleUser,
Content: req.Content,
Status: "complete",
}
msgID, err := models.Messages.Insert(userMsg)
if err != nil {
c.jsonError(w, "Failed to save message", http.StatusInternalServerError)
return
}
// Re-fetch to get the full message with ID, then attach files
userMsg, _ = models.Messages.Get(msgID)
for _, fileID := range req.FileIds {
if err := userMsg.AttachFile(fileID); err != nil {
fmt.Printf("[CHAT] Failed to attach file %s to message %s: %v\n", fileID, userMsg.ID, err)
}
}
assistantMsg := &models.Message{
ConversationID: convID,
Role: assist.RoleAssistant,
Content: "",
Status: "pending",
}
if _, err := models.Messages.Insert(assistantMsg); err != nil {
c.jsonError(w, "Failed to create response", http.StatusInternalServerError)
return
}
}
conv, _ = models.Conversations.Get(convID)
c.json(w, conversationToJSON(conv), http.StatusCreated)
}
// UpdateConversation updates a conversation's settings (e.g., model).
func (c *AdminAPIController) UpdateConversation(w http.ResponseWriter, r *http.Request) {
convID := r.PathValue("id")
conv, err := models.Conversations.Get(convID)
if err != nil {
c.jsonError(w, "Conversation not found", http.StatusNotFound)
return
}
var req struct {
Model string `json:"model"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
c.jsonError(w, "Invalid JSON", http.StatusBadRequest)
return
}
// Update model if provided
if req.Model != "" {
if err := assist.SetConversationModel(conv, req.Model); err != nil {
c.jsonError(w, "Failed to update model", http.StatusInternalServerError)
return
}
}
c.json(w, map[string]any{
"ok": true,
"model": assist.GetConversationModel(conv),
})
}
// SendMessage adds a user message to an existing conversation.
func (c *AdminAPIController) SendMessage(w http.ResponseWriter, r *http.Request) {
user := access.GetUserFromJWT(r)
if user == nil {
c.jsonError(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Rate limit
key := r.RemoteAddr
if ip := r.Header.Get("X-Forwarded-For"); ip != "" {
key = ip
}
if !access.ChatLimiter.Allow(key) {
c.jsonError(w, "Too many messages. Please wait a moment.", http.StatusTooManyRequests)
return
}
convID := r.PathValue("id")
conv, err := models.Conversations.Get(convID)
if err != nil {
c.jsonError(w, "Conversation not found", http.StatusNotFound)
return
}
var req struct {
Content string `json:"content"`
FileIds []string `json:"fileIds"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
c.jsonError(w, "Invalid JSON", http.StatusBadRequest)
return
}
if req.Content == "" {
c.jsonError(w, "Message content required", http.StatusBadRequest)
return
}
// Save user message
userMsg := &models.Message{
ConversationID: convID,
Role: assist.RoleUser,
Content: req.Content,
Status: "complete",
}
msgID, err := models.Messages.Insert(userMsg)
if err != nil {
c.jsonError(w, "Failed to save message", http.StatusInternalServerError)
return
}
// Re-fetch to get the full message with ID
userMsg, _ = models.Messages.Get(msgID)
// Attach files to the message
for _, fileID := range req.FileIds {
if err := userMsg.AttachFile(fileID); err != nil {
// Log error but continue - partial attachment is better than none
fmt.Printf("[CHAT] Failed to attach file %s to message %s: %v\n", fileID, userMsg.ID, err)
}
}
// Create pending assistant message
assistantMsg := &models.Message{
ConversationID: convID,
Role: assist.RoleAssistant,
Content: "",
Status: "pending",
}
assistantMsgID, err := models.Messages.Insert(assistantMsg)
if err != nil {
c.jsonError(w, "Failed to create response", http.StatusInternalServerError)
return
}
// Update conversation title if needed
if conv.Title == "New Conversation" {
conv.Title = helpers.TruncateTitle(req.Content, 50)
}
models.Conversations.Update(conv)
c.json(w, map[string]any{
"userMessage": messageToJSON(userMsg),
"assistantMessage": map[string]any{
"id": assistantMsgID,
"conversationId": convID,
"role": assist.RoleAssistant,
"content": "",
"status": "pending",
},
})
}
// StreamConversation handles SSE streaming of AI response.
// This proxies to the existing chat controller's Stream method.
func (c *AdminAPIController) StreamConversation(w http.ResponseWriter, r *http.Request) {
// The streaming logic is complex and already implemented in chat.go
// We'll delegate to the chat controller (properly initialized without calling Setup)
chatCtrl := &ChatController{}
chatCtrl.App = c.App
chatCtrl.Request = r
chatCtrl.Stream(w, r)
}
// DeleteConversation deletes a conversation and all its messages.
func (c *AdminAPIController) DeleteConversation(w http.ResponseWriter, r *http.Request) {
convID := r.PathValue("id")
conv, err := models.Conversations.Get(convID)
if err != nil {
c.jsonError(w, "Conversation not found", http.StatusNotFound)
return
}
// Delete all messages
messages, _ := conv.Messages()
for _, msg := range messages {
// Delete mutations for each message
mutations, _ := msg.Mutations()
for _, m := range mutations {
models.Mutations.Delete(m)
}
models.Messages.Delete(msg)
}
models.Conversations.Delete(conv)
w.WriteHeader(http.StatusNoContent)
}
// UndoConversation undoes the last AI action.
func (c *AdminAPIController) UndoConversation(w http.ResponseWriter, r *http.Request) {
convID := r.PathValue("id")
conv, err := models.Conversations.Get(convID)
if err != nil {
c.jsonError(w, "Conversation not found", http.StatusNotFound)
return
}
assist.UndoConversation(conv)
c.json(w, map[string]any{
"ok": true,
"canUndo": assist.ConvCanUndo(conv),
"canRedo": assist.ConvCanRedo(conv),
})
}
// RedoConversation redoes an undone action.
func (c *AdminAPIController) RedoConversation(w http.ResponseWriter, r *http.Request) {
convID := r.PathValue("id")
conv, err := models.Conversations.Get(convID)
if err != nil {
c.jsonError(w, "Conversation not found", http.StatusNotFound)
return
}
assist.RedoConversation(conv)
c.json(w, map[string]any{
"ok": true,
"canUndo": assist.ConvCanUndo(conv),
"canRedo": assist.ConvCanRedo(conv),
})
}
// --- Helpers ---
func conversationToJSON(conv *models.Conversation) map[string]any {
return map[string]any{
"id": conv.ID,
"userId": conv.UserID,
"title": conv.Title,
"model": assist.GetConversationModel(conv),
"created": conv.CreatedAt.Format("2006-01-02T15:04:05Z"),
"updated": conv.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
}
func messageToJSON(msg *models.Message) map[string]any {
result := map[string]any{
"id": msg.ID,
"conversationId": msg.ConversationID,
"role": msg.Role,
"content": msg.Content,
"status": msg.Status,
"created": msg.CreatedAt.Format("2006-01-02T15:04:05Z"),
}
// Include tool calls if present
if msg.ToolCalls != "" {
var toolCalls []any
if err := json.Unmarshal([]byte(msg.ToolCalls), &toolCalls); err == nil {
result["toolCalls"] = toolCalls
}
}
// Include attached files
if files, err := msg.Files(); err == nil && len(files) > 0 {
fileList := make([]map[string]any, len(files))
for i, f := range files {
fileList[i] = map[string]any{
"id": f.ID,
"name": f.Name,
"mime": f.MimeType,
"size": f.Size,
}
}
result["files"] = fileList
}
return result
}
func pageToJSON(p *models.Page) map[string]any {
return map[string]any{
"id": p.ID,
"title": p.Title(),
"html": p.HTML(),
"description": p.Description(),
"parentId": p.ParentID,
"position": p.Position,
"published": p.IsPublished(),
"path": p.Path(),
"created": p.CreatedAt.Format("2006-01-02T15:04:05Z"),
"updated": p.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
}
func collectionToJSON(col *models.Collection) map[string]any {
return map[string]any{
"id": col.ID,
"name": col.Name,
"description": col.Description,
"type": col.Type,
"schema": col.Schema,
"listRule": col.ListRule,
"viewRule": col.ViewRule,
"createRule": col.CreateRule,
"updateRule": col.UpdateRule,
"deleteRule": col.DeleteRule,
"system": col.System,
"documentCount": col.DocumentCount(),
"created": col.CreatedAt.Format("2006-01-02T15:04:05Z"),
"updated": col.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
}
func documentToJSON(doc *models.Document) map[string]any {
var data map[string]any
if doc.Data != "" {
json.Unmarshal([]byte(doc.Data), &data)
}
return map[string]any{
"id": doc.ID,
"collectionId": doc.CollectionID,
"data": data,
"created": doc.CreatedAt.Format("2006-01-02T15:04:05Z"),
"updated": doc.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
}
func partialToJSON(p *models.Partial) map[string]any {
return map[string]any{
"id": p.ID,
"name": p.Name,
"description": p.Description,
"html": p.HTML,
"published": p.Published,
"created": p.CreatedAt.Format("2006-01-02T15:04:05Z"),
"updated": p.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
}
// --- Tour API ---
// CompleteTour marks the onboarding tour as completed for the current user.
func (c *AdminAPIController) CompleteTour(w http.ResponseWriter, r *http.Request) {
user := access.GetUserFromJWT(r)
if user == nil {
c.jsonError(w, "Unauthorized", http.StatusUnauthorized)
return
}
if err := helpers.SetSetting(models.SettingTourCompleted+":"+user.ID, "true"); err != nil {
c.jsonError(w, "Failed to save tour status", http.StatusInternalServerError)
return
}
c.json(w, map[string]any{"ok": true})
}
func (c *AdminAPIController) json(w http.ResponseWriter, data any, status ...int) {
w.Header().Set("Content-Type", "application/json")
if len(status) > 0 {
w.WriteHeader(status[0])
}
json.NewEncoder(w).Encode(data)
}
func (c *AdminAPIController) jsonError(w http.ResponseWriter, msg string, status int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]any{"error": msg})
}