48.3 KB
api.go
package controllers
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/readysite/readysite/pkg/application"
"github.com/readysite/readysite/website/internal/access"
"github.com/readysite/readysite/website/internal/content"
"github.com/readysite/readysite/website/internal/helpers"
"github.com/readysite/readysite/website/internal/search"
"github.com/readysite/readysite/website/models"
)
// API returns the API controller for public REST endpoints.
func API() (string, *APIController) {
return "api", &APIController{}
}
// APIController handles public REST API endpoints for collections and documents.
type APIController struct {
application.BaseController
}
// Setup registers routes.
// NOTE: These routes must be registered BEFORE the Site catch-all route.
func (c *APIController) Setup(app *application.App) {
c.BaseController.Setup(app)
// Public collection records API
http.Handle("GET /api/collections/{collectionId}/records", app.Method(c, "ListRecords", nil))
http.Handle("GET /api/collections/{collectionId}/records/{recordId}", app.Method(c, "ViewRecord", nil))
http.Handle("POST /api/collections/{collectionId}/records", app.Method(c, "CreateRecord", nil))
http.Handle("PATCH /api/collections/{collectionId}/records/{recordId}", app.Method(c, "UpdateRecord", nil))
http.Handle("DELETE /api/collections/{collectionId}/records/{recordId}", app.Method(c, "DeleteRecord", nil))
// Alias: /documents -> /records (for compatibility)
http.Handle("GET /api/collections/{collectionId}/documents", app.Method(c, "ListRecords", nil))
http.Handle("GET /api/collections/{collectionId}/documents/{recordId}", app.Method(c, "ViewRecord", nil))
http.Handle("POST /api/collections/{collectionId}/documents", app.Method(c, "CreateRecord", nil))
http.Handle("PATCH /api/collections/{collectionId}/documents/{recordId}", app.Method(c, "UpdateRecord", nil))
http.Handle("DELETE /api/collections/{collectionId}/documents/{recordId}", app.Method(c, "DeleteRecord", nil))
// Real-time subscription endpoint
http.Handle("GET /api/collections/{collectionId}/subscribe", app.Method(c, "Subscribe", nil))
// User API endpoints
http.Handle("GET /api/users", app.Method(c, "ListUsers", nil))
http.Handle("POST /api/users", app.Method(c, "CreateUser", nil))
http.Handle("GET /api/users/{id}", app.Method(c, "ViewUser", nil))
http.Handle("PATCH /api/users/{id}", app.Method(c, "UpdateUser", nil))
http.Handle("DELETE /api/users/{id}", app.Method(c, "DeleteUser", nil))
// File API endpoints (for CI/CD and programmatic access)
http.Handle("GET /api/files", app.Method(c, "ListFiles", nil))
http.Handle("POST /api/files", app.Method(c, "UploadFile", nil))
http.Handle("GET /api/files/{id}", app.Method(c, "DownloadFile", nil))
http.Handle("GET /api/files/{id}/metadata", app.Method(c, "FileMetadata", nil))
http.Handle("PATCH /api/files/{id}", app.Method(c, "UpdateFile", nil))
http.Handle("DELETE /api/files/{id}", app.Method(c, "DeleteFile", nil))
// Search endpoint
http.Handle("GET /api/search", app.Method(c, "Search", nil))
// API documentation endpoints
http.Handle("GET /api/docs", app.Method(c, "DocsUI", nil))
http.Handle("GET /api/docs/openapi.json", app.Method(c, "OpenAPISpec", nil))
// CORS preflight
http.Handle("OPTIONS /api/collections/{collectionId}/records", app.Method(c, "CORSPreflight", nil))
http.Handle("OPTIONS /api/collections/{collectionId}/records/{recordId}", app.Method(c, "CORSPreflight", nil))
http.Handle("OPTIONS /api/collections/{collectionId}/documents", app.Method(c, "CORSPreflight", nil))
http.Handle("OPTIONS /api/collections/{collectionId}/documents/{recordId}", app.Method(c, "CORSPreflight", nil))
http.Handle("OPTIONS /api/files", app.Method(c, "CORSPreflight", nil))
http.Handle("OPTIONS /api/files/{id}", app.Method(c, "CORSPreflight", nil))
http.Handle("OPTIONS /api/users", app.Method(c, "CORSPreflight", nil))
http.Handle("OPTIONS /api/users/{id}", app.Method(c, "CORSPreflight", nil))
http.Handle("OPTIONS /api/search", app.Method(c, "CORSPreflight", nil))
}
// Handle implements Controller interface with value receiver for request isolation.
func (c APIController) Handle(r *http.Request) application.Controller {
c.Request = r
return &c
}
// ListRecordsResponse is the response format for listing records.
type ListRecordsResponse struct {
Page int `json:"page"`
PerPage int `json:"perPage"`
TotalItems int `json:"totalItems,omitempty"`
TotalPages int `json:"totalPages,omitempty"`
Items []map[string]any `json:"items"`
}
// ListRecords returns paginated records from a collection.
// GET /api/collections/{collectionId}/records
func (c *APIController) ListRecords(w http.ResponseWriter, r *http.Request) {
collectionID := r.PathValue("collectionId")
collection, err := models.Collections.Get(collectionID)
if err != nil {
c.jsonError(w, "Collection not found", http.StatusNotFound)
return
}
user := access.GetUserFromJWT(r)
if !content.CheckCollectionRateLimit(w, r, collection, user) {
content.SetCORSHeaders(w, r)
c.jsonError(w, "Too many requests", http.StatusTooManyRequests)
return
}
if collection.IsView() {
c.listViewRecords(w, r, collection)
return
}
auth := content.NewAuthorizer(r, collection, nil)
authResult := auth.CanList()
if !authResult.Allowed {
content.SetCORSHeaders(w, r)
c.jsonError(w, authResult.Message, authResult.Status)
return
}
allowedFields := c.getSchemaFieldNames(collection)
filterExpr := r.URL.Query().Get("filter")
sortExpr := r.URL.Query().Get("sort")
fieldsExpr := r.URL.Query().Get("fields")
expandExpr := r.URL.Query().Get("expand")
skipTotal := r.URL.Query().Get("skipTotal") == "true"
page := c.intParam(r, "page", 1)
perPage := c.intParam(r, "perPage", 20)
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 20
}
if perPage > 100 {
perPage = 100
}
offset := (page - 1) * perPage
var whereClause strings.Builder
var params []any
whereClause.WriteString("WHERE CollectionID = ?")
params = append(params, collectionID)
// Apply rule-based filter
if authResult.SQLFilter != "" {
whereClause.WriteString(" AND (")
whereClause.WriteString(authResult.SQLFilter)
whereClause.WriteString(")")
params = append(params, authResult.Params...)
}
if filterExpr != "" {
filterResult, err := content.ParseAPIFilter(filterExpr, allowedFields)
if err != nil {
c.jsonError(w, "Invalid filter: "+err.Error(), http.StatusBadRequest)
return
}
if filterResult.Where != "" {
whereClause.WriteString(" AND (")
whereClause.WriteString(filterResult.Where)
whereClause.WriteString(")")
params = append(params, filterResult.Params...)
}
}
var totalItems, totalPages int
if !skipTotal {
totalItems = models.Documents.Count(whereClause.String(), params...)
totalPages = (totalItems + perPage - 1) / perPage
if totalPages < 1 {
totalPages = 1
}
}
orderBy := "CreatedAt DESC"
if sortExpr != "" {
parsedSort, err := content.ParseSort(sortExpr, allowedFields)
if err != nil {
c.jsonError(w, "Invalid sort: "+err.Error(), http.StatusBadRequest)
return
}
if parsedSort != "" {
orderBy = parsedSort
}
}
query := whereClause.String() + " ORDER BY " + orderBy + " LIMIT ? OFFSET ?"
params = append(params, perPage, offset)
docs, err := models.Documents.Search(query, params...)
if err != nil {
c.jsonError(w, "Failed to fetch records", http.StatusInternalServerError)
return
}
selectedFields := content.ParseFields(fieldsExpr)
expandPaths := content.ParseExpand(expandExpr)
items := make([]map[string]any, 0, len(docs))
expander := content.NewRelationExpander()
for _, doc := range docs {
item := content.DocumentToRecord(doc, collection)
if len(expandPaths) > 0 {
expander.ExpandRecord(item, collection, expandPaths, 1)
}
if len(selectedFields) > 0 {
item = content.FilterFields(item, selectedFields)
}
items = append(items, item)
}
userID := ""
if user != nil {
userID = user.ID
}
helpers.AuditRead(r, userID, helpers.ResourceCollection, collectionID, collection.Name)
content.SetCORSHeaders(w, r)
response := ListRecordsResponse{
Page: page,
PerPage: perPage,
Items: items,
}
if !skipTotal {
response.TotalItems = totalItems
response.TotalPages = totalPages
}
c.jsonResponse(w, response, http.StatusOK)
}
// getSchemaFieldNames returns the field names from a collection's schema.
func (c *APIController) getSchemaFieldNames(collection *models.Collection) []string {
fields, err := content.GetFields(collection)
if err != nil {
return nil
}
names := make([]string, len(fields))
for i, f := range fields {
names[i] = f.Name
}
return names
}
// ViewRecord returns a single record by ID.
// GET /api/collections/{collectionId}/records/{recordId}
func (c *APIController) ViewRecord(w http.ResponseWriter, r *http.Request) {
collectionID := r.PathValue("collectionId")
recordID := r.PathValue("recordId")
collection, err := models.Collections.Get(collectionID)
if err != nil {
c.jsonError(w, "Collection not found", http.StatusNotFound)
return
}
user := access.GetUserFromJWT(r)
if !content.CheckCollectionRateLimit(w, r, collection, user) {
content.SetCORSHeaders(w, r)
c.jsonError(w, "Too many requests", http.StatusTooManyRequests)
return
}
if collection.IsView() {
c.viewViewRecord(w, r, collection, recordID)
return
}
doc, err := models.Documents.Get(recordID)
if err != nil || doc.CollectionID != collectionID {
c.jsonError(w, "Record not found", http.StatusNotFound)
return
}
record := content.DocumentToRecord(doc, collection)
auth := content.NewAuthorizer(r, collection, nil)
authResult := auth.CanView(record)
if !authResult.Allowed {
content.SetCORSHeaders(w, r)
c.jsonError(w, authResult.Message, authResult.Status)
return
}
expandExpr := r.URL.Query().Get("expand")
fieldsExpr := r.URL.Query().Get("fields")
userID := ""
if user != nil {
userID = user.ID
}
helpers.AuditRead(r, userID, helpers.ResourceDocument, recordID, collection.Name+" record")
content.SetCORSHeaders(w, r)
expandPaths := content.ParseExpand(expandExpr)
if len(expandPaths) > 0 {
content.ExpandRecordSimple(record, collection, expandPaths)
}
selectedFields := content.ParseFields(fieldsExpr)
if len(selectedFields) > 0 {
record = content.FilterFields(record, selectedFields)
}
c.jsonResponse(w, record, http.StatusOK)
}
// CreateRecord creates a new record in a collection.
// POST /api/collections/{collectionId}/records
func (c *APIController) CreateRecord(w http.ResponseWriter, r *http.Request) {
collectionID := r.PathValue("collectionId")
collection, err := models.Collections.Get(collectionID)
if err != nil {
c.jsonError(w, "Collection not found", http.StatusNotFound)
return
}
user := access.GetUserFromJWT(r)
if !content.CheckCollectionRateLimit(w, r, collection, user) {
content.SetCORSHeaders(w, r)
c.jsonError(w, "Too many requests", http.StatusTooManyRequests)
return
}
if collection.IsView() {
c.jsonError(w, "Cannot create records in view collections", http.StatusBadRequest)
return
}
var data map[string]any
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
c.jsonError(w, "Invalid JSON body", http.StatusBadRequest)
return
}
auth := content.NewAuthorizer(r, collection, data)
authResult := auth.CanCreate(data)
if !authResult.Allowed {
content.SetCORSHeaders(w, r)
c.jsonError(w, authResult.Message, authResult.Status)
return
}
if err := content.ValidateDocument(collection, data); err != nil {
c.jsonError(w, "Validation failed: "+err.Error(), http.StatusBadRequest)
return
}
if err := content.ProcessAutodate(collection, data, true); err != nil {
c.jsonError(w, "Failed to process autodate fields: "+err.Error(), http.StatusInternalServerError)
return
}
jsonData, err := json.Marshal(data)
if err != nil {
c.jsonError(w, "Failed to encode data", http.StatusInternalServerError)
return
}
doc := &models.Document{
CollectionID: collectionID,
Data: string(jsonData),
}
docID, err := models.Documents.Insert(doc)
if err != nil {
c.jsonError(w, "Failed to create record", http.StatusInternalServerError)
return
}
doc, err = models.Documents.Get(docID)
if err != nil {
doc = &models.Document{CollectionID: collectionID}
doc.ID = docID
}
userID := ""
if user != nil {
userID = user.ID
}
helpers.AuditCreate(r, userID, helpers.ResourceDocument, docID, collection.Name+" record")
content.SetCORSHeaders(w, r)
record := content.DocumentToRecord(doc, collection)
content.CollectionEvents.PublishCreate(collectionID, docID, record)
c.jsonResponse(w, record, http.StatusCreated)
}
// UpdateRecord updates an existing record.
// PATCH /api/collections/{collectionId}/records/{recordId}
func (c *APIController) UpdateRecord(w http.ResponseWriter, r *http.Request) {
collectionID := r.PathValue("collectionId")
recordID := r.PathValue("recordId")
collection, err := models.Collections.Get(collectionID)
if err != nil {
c.jsonError(w, "Collection not found", http.StatusNotFound)
return
}
user := access.GetUserFromJWT(r)
if !content.CheckCollectionRateLimit(w, r, collection, user) {
content.SetCORSHeaders(w, r)
c.jsonError(w, "Too many requests", http.StatusTooManyRequests)
return
}
if collection.IsView() {
c.jsonError(w, "Cannot update records in view collections", http.StatusBadRequest)
return
}
doc, err := models.Documents.Get(recordID)
if err != nil || doc.CollectionID != collectionID {
c.jsonError(w, "Record not found", http.StatusNotFound)
return
}
existingData := content.ParseDocumentData(doc)
currentRecord := content.DocumentToRecord(doc, collection)
var updates map[string]any
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
c.jsonError(w, "Invalid JSON body", http.StatusBadRequest)
return
}
auth := content.NewAuthorizer(r, collection, updates)
authResult := auth.CanUpdate(currentRecord)
if !authResult.Allowed {
content.SetCORSHeaders(w, r)
c.jsonError(w, authResult.Message, authResult.Status)
return
}
ifMatch := r.Header.Get("If-Match")
if ifMatch != "" {
expectedVersion := doc.UpdatedAt.Format("2006-01-02T15:04:05")
if ifMatch != expectedVersion {
c.jsonError(w, "Record was modified by another request. Please refresh and try again.", http.StatusConflict)
return
}
}
processedUpdates := content.ProcessFieldModifiers(updates, existingData)
for k, v := range processedUpdates {
existingData[k] = v
}
if err := content.ValidateDocument(collection, existingData); err != nil {
c.jsonError(w, "Validation failed: "+err.Error(), http.StatusBadRequest)
return
}
if err := content.ProcessAutodate(collection, existingData, false); err != nil {
c.jsonError(w, "Failed to process autodate fields: "+err.Error(), http.StatusInternalServerError)
return
}
jsonData, err := json.Marshal(existingData)
if err != nil {
c.jsonError(w, "Failed to encode data", http.StatusInternalServerError)
return
}
doc.Data = string(jsonData)
if err := models.Documents.Update(doc); err != nil {
c.jsonError(w, "Failed to update record", http.StatusInternalServerError)
return
}
if refetched, err := models.Documents.Get(recordID); err == nil {
doc = refetched
}
userID := ""
if user != nil {
userID = user.ID
}
helpers.AuditUpdate(r, userID, helpers.ResourceDocument, recordID, collection.Name+" record", nil)
content.SetCORSHeaders(w, r)
record := content.DocumentToRecord(doc, collection)
content.CollectionEvents.PublishUpdate(collectionID, recordID, record)
c.jsonResponse(w, record, http.StatusOK)
}
// DeleteRecord deletes a record.
// DELETE /api/collections/{collectionId}/records/{recordId}
func (c *APIController) DeleteRecord(w http.ResponseWriter, r *http.Request) {
collectionID := r.PathValue("collectionId")
recordID := r.PathValue("recordId")
collection, err := models.Collections.Get(collectionID)
if err != nil {
c.jsonError(w, "Collection not found", http.StatusNotFound)
return
}
user := access.GetUserFromJWT(r)
if !content.CheckCollectionRateLimit(w, r, collection, user) {
content.SetCORSHeaders(w, r)
c.jsonError(w, "Too many requests", http.StatusTooManyRequests)
return
}
if collection.IsView() {
c.jsonError(w, "Cannot delete records from view collections", http.StatusBadRequest)
return
}
doc, err := models.Documents.Get(recordID)
if err != nil || doc.CollectionID != collectionID {
c.jsonError(w, "Record not found", http.StatusNotFound)
return
}
record := content.DocumentToRecord(doc, collection)
auth := content.NewAuthorizer(r, collection, nil)
authResult := auth.CanDelete(record)
if !authResult.Allowed {
content.SetCORSHeaders(w, r)
c.jsonError(w, authResult.Message, authResult.Status)
return
}
if err := models.Documents.Delete(doc); err != nil {
c.jsonError(w, "Failed to delete record", http.StatusInternalServerError)
return
}
content.CollectionEvents.PublishDelete(collectionID, recordID)
userID := ""
if user != nil {
userID = user.ID
}
helpers.AuditDelete(r, userID, helpers.ResourceDocument, recordID, collection.Name+" record")
content.SetCORSHeaders(w, r)
w.WriteHeader(http.StatusNoContent)
}
// CORSPreflight handles OPTIONS requests for CORS preflight.
func (c *APIController) CORSPreflight(w http.ResponseWriter, r *http.Request) {
content.SetCORSHeaders(w, r)
w.WriteHeader(http.StatusNoContent)
}
// Search performs full-text search across all content.
// GET /api/search?q=query&type=page&collection=blog&page=1&perPage=20
func (c *APIController) Search(w http.ResponseWriter, r *http.Request) {
content.SetCORSHeaders(w, r)
q := r.URL.Query().Get("q")
if q == "" {
c.jsonError(w, "query parameter 'q' is required", http.StatusBadRequest)
return
}
page := c.intParam(r, "page", 1)
perPage := c.intParam(r, "perPage", 20)
entityType := r.URL.Query().Get("type")
collectionID := r.URL.Query().Get("collection")
results, total, err := search.Search(search.SearchOptions{
Query: q,
EntityType: entityType,
CollectionID: collectionID,
Page: page,
PerPage: perPage,
})
if err != nil {
c.jsonError(w, "Search failed: "+err.Error(), http.StatusInternalServerError)
return
}
totalPages := (total + perPage - 1) / perPage
if totalPages < 1 {
totalPages = 1
}
c.jsonResponse(w, map[string]any{
"page": page,
"perPage": perPage,
"totalItems": total,
"totalPages": totalPages,
"items": results,
}, http.StatusOK)
}
// --- Helper methods ---
// jsonResponse writes a JSON response with the given status code.
func (c *APIController) jsonResponse(w http.ResponseWriter, data any, status int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
// jsonError writes a JSON error response.
func (c *APIController) jsonError(w http.ResponseWriter, message string, status int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]any{
"status": status,
"message": message,
})
}
// intParam parses an integer query parameter with a default value.
func (c *APIController) intParam(r *http.Request, name string, defaultVal int) int {
val := r.URL.Query().Get(name)
if val == "" {
return defaultVal
}
i, err := strconv.Atoi(val)
if err != nil {
return defaultVal
}
return i
}
// --- View collection handlers ---
// listViewRecords handles listing records from a view collection.
func (c *APIController) listViewRecords(w http.ResponseWriter, r *http.Request, collection *models.Collection) {
user := access.GetUserFromJWT(r)
ruleCtx := content.NewContext(r, user, nil)
listRule := collection.ListRule
if listRule != "" {
ruleType := content.ParseRuleType(listRule)
switch ruleType {
case content.RuleTypeLocked:
if user == nil || user.Role != "admin" {
if user == nil {
c.jsonError(w, "Authentication required", http.StatusUnauthorized)
} else {
c.jsonError(w, "Access denied", http.StatusForbidden)
}
return
}
case content.RuleTypePublic:
// Anyone can access
case content.RuleTypeExpression:
allowed, err := content.Evaluate(listRule, ruleCtx, nil)
if err != nil || !allowed {
if user == nil {
c.jsonError(w, "Authentication required", http.StatusUnauthorized)
} else {
c.jsonError(w, "Access denied", http.StatusForbidden)
}
return
}
}
} else {
if !access.CheckAccess(user, access.ResourceCollection, collection.ID, access.PermRead) {
if user == nil {
c.jsonError(w, "Authentication required", http.StatusUnauthorized)
} else {
c.jsonError(w, "Access denied", http.StatusForbidden)
}
return
}
}
page := c.intParam(r, "page", 1)
perPage := c.intParam(r, "perPage", 20)
skipTotal := r.URL.Query().Get("skipTotal") == "true"
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 20
}
if perPage > 100 {
perPage = 100
}
offset := (page - 1) * perPage
filterExpr := r.URL.Query().Get("filter")
sortExpr := r.URL.Query().Get("sort")
fieldsExpr := r.URL.Query().Get("fields")
records, totalCount, err := content.ExecuteView(collection, filterExpr, sortExpr, perPage, offset)
if err != nil {
c.jsonError(w, "Failed to query view: "+err.Error(), http.StatusInternalServerError)
return
}
selectedFields := content.ParseFields(fieldsExpr)
items := make([]map[string]any, 0, len(records))
for _, rec := range records {
item := rec.ToJSON(collection.ID, collection.Name)
if len(selectedFields) > 0 {
item = content.FilterFields(item, selectedFields)
}
items = append(items, item)
}
var totalPages int
if !skipTotal {
totalPages = (totalCount + perPage - 1) / perPage
if totalPages < 1 {
totalPages = 1
}
}
userID := ""
if user != nil {
userID = user.ID
}
helpers.AuditRead(r, userID, helpers.ResourceCollection, collection.ID, collection.Name+" (view)")
content.SetCORSHeaders(w, r)
response := ListRecordsResponse{
Page: page,
PerPage: perPage,
Items: items,
}
if !skipTotal {
response.TotalItems = totalCount
response.TotalPages = totalPages
}
c.jsonResponse(w, response, http.StatusOK)
}
// viewViewRecord handles viewing a single record from a view collection.
func (c *APIController) viewViewRecord(w http.ResponseWriter, r *http.Request, collection *models.Collection, recordID string) {
user := access.GetUserFromJWT(r)
ruleCtx := content.NewContext(r, user, nil)
viewRule := collection.ViewRule
if viewRule != "" {
ruleType := content.ParseRuleType(viewRule)
switch ruleType {
case content.RuleTypeLocked:
if user == nil || user.Role != "admin" {
c.jsonError(w, "Record not found", http.StatusNotFound)
return
}
case content.RuleTypePublic:
// Anyone can access
case content.RuleTypeExpression:
allowed, err := content.Evaluate(viewRule, ruleCtx, nil)
if err != nil || !allowed {
c.jsonError(w, "Record not found", http.StatusNotFound)
return
}
}
} else {
if !access.CheckAccess(user, access.ResourceCollection, collection.ID, access.PermRead) {
c.jsonError(w, "Record not found", http.StatusNotFound)
return
}
}
record, err := content.ExecuteViewSingle(collection, recordID)
if err != nil {
c.jsonError(w, "Record not found", http.StatusNotFound)
return
}
fieldsExpr := r.URL.Query().Get("fields")
selectedFields := content.ParseFields(fieldsExpr)
item := record.ToJSON(collection.ID, collection.Name)
if len(selectedFields) > 0 {
item = content.FilterFields(item, selectedFields)
}
userID := ""
if user != nil {
userID = user.ID
}
helpers.AuditRead(r, userID, helpers.ResourceDocument, recordID, collection.Name+" view record")
content.SetCORSHeaders(w, r)
c.jsonResponse(w, item, http.StatusOK)
}
// Subscribe streams real-time events for a collection.
// GET /api/collections/{collectionId}/subscribe
func (c *APIController) Subscribe(w http.ResponseWriter, r *http.Request) {
collectionID := r.PathValue("collectionId")
collection, err := models.Collections.Get(collectionID)
if err != nil {
c.jsonError(w, "Collection not found", http.StatusNotFound)
return
}
auth := content.NewAuthorizer(r, collection, nil)
authResult := auth.CanList()
if !authResult.Allowed {
content.SetCORSHeaders(w, r)
c.jsonError(w, authResult.Message, authResult.Status)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
content.SetCORSHeaders(w, r)
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
sub := content.CollectionEvents.Subscribe(collectionID)
defer content.CollectionEvents.Unsubscribe(sub)
connectData := map[string]any{
"clientId": sub.ID,
}
connectJSON, _ := json.Marshal(connectData)
fmt.Fprintf(w, "event: connect\ndata: %s\n\n", connectJSON)
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
heartbeat := time.NewTicker(30 * time.Second)
defer heartbeat.Stop()
filterExpr := r.URL.Query().Get("filter")
allowedFields := c.getSchemaFieldNames(collection)
listRule := collection.ListRule
for {
select {
case <-r.Context().Done():
return
case <-heartbeat.C:
fmt.Fprintf(w, "event: heartbeat\ndata: {}\n\n")
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
case event, ok := <-sub.Events:
if !ok {
return
}
if listRule != "" && content.ParseRuleType(listRule) == content.RuleTypeExpression && event.Record != nil {
allowed, err := content.Evaluate(listRule, auth.Context(), event.Record)
if err != nil || !allowed {
continue
}
}
if filterExpr != "" && event.Record != nil {
filterResult, err := content.ParseAPIFilter(filterExpr, allowedFields)
if err == nil && filterResult.Where != "" {
matches := content.MatchesAPIFilter(event.Record, filterResult)
if !matches {
continue
}
}
}
eventData := map[string]any{
"action": string(event.Type),
"recordId": event.RecordID,
}
if event.Record != nil {
eventData["record"] = event.Record
}
eventJSON, err := json.Marshal(eventData)
if err != nil {
continue
}
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event.Type, eventJSON)
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
}
}
}
// --- File API Endpoints ---
// FileResponse is the response format for file metadata.
type FileResponse struct {
ID string `json:"id"`
Name string `json:"name"`
MimeType string `json:"mimeType"`
Size int64 `json:"size"`
Path string `json:"path,omitempty"`
Published bool `json:"published"`
UserID string `json:"userId,omitempty"`
Created string `json:"created"`
Updated string `json:"updated"`
}
// ListFilesResponse is the response format for listing files.
type ListFilesResponse struct {
Page int `json:"page"`
PerPage int `json:"perPage"`
TotalItems int `json:"totalItems"`
TotalPages int `json:"totalPages"`
Items []FileResponse `json:"items"`
}
// fileToResponse converts a File model to API response format.
func (c *APIController) fileToResponse(f *models.File) FileResponse {
return FileResponse{
ID: f.ID,
Name: f.Name,
MimeType: f.MimeType,
Size: f.Size,
Path: f.Path,
Published: f.Published,
UserID: f.UserID,
Created: f.CreatedAt.Format("2006-01-02T15:04:05Z"),
Updated: f.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
}
// ListFiles returns paginated files.
// GET /api/files
func (c *APIController) ListFiles(w http.ResponseWriter, r *http.Request) {
if !content.CheckRateLimit(r) {
content.SetCORSHeaders(w, r)
c.jsonError(w, "Too many requests", http.StatusTooManyRequests)
return
}
user := access.GetUserFromJWT(r)
if user == nil {
c.jsonError(w, "Authentication required", http.StatusUnauthorized)
return
}
page := c.intParam(r, "page", 1)
perPage := c.intParam(r, "perPage", 20)
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 20
}
if perPage > 100 {
perPage = 100
}
offset := (page - 1) * perPage
var whereClause string
var params []any
// Admins see all files, others see only their own
if user.Role != "admin" {
whereClause = "WHERE UserID = ?"
params = append(params, user.ID)
}
totalItems := models.Files.Count(whereClause, params...)
totalPages := (totalItems + perPage - 1) / perPage
if totalPages < 1 {
totalPages = 1
}
query := whereClause + " ORDER BY CreatedAt DESC LIMIT ? OFFSET ?"
params = append(params, perPage, offset)
files, err := models.Files.Search(query, params...)
if err != nil {
c.jsonError(w, "Failed to fetch files", http.StatusInternalServerError)
return
}
items := make([]FileResponse, len(files))
for i, f := range files {
items[i] = c.fileToResponse(f)
}
content.SetCORSHeaders(w, r)
c.jsonResponse(w, ListFilesResponse{
Page: page,
PerPage: perPage,
TotalItems: totalItems,
TotalPages: totalPages,
Items: items,
}, http.StatusOK)
}
// UploadFile uploads a new file.
// POST /api/files
func (c *APIController) UploadFile(w http.ResponseWriter, r *http.Request) {
if !content.CheckRateLimit(r) {
content.SetCORSHeaders(w, r)
c.jsonError(w, "Too many requests", http.StatusTooManyRequests)
return
}
user := access.GetUserFromJWT(r)
if user == nil {
c.jsonError(w, "Authentication required", http.StatusUnauthorized)
return
}
// Limit upload size
r.Body = http.MaxBytesReader(w, r.Body, content.MaxFileSize)
var data []byte
var filename string
var mimeType string
contentType := r.Header.Get("Content-Type")
if strings.HasPrefix(contentType, "multipart/form-data") {
// Multipart form upload
if err := r.ParseMultipartForm(content.MaxFileSize); err != nil {
c.jsonError(w, "File too large (max 10MB)", http.StatusBadRequest)
return
}
file, header, err := r.FormFile("file")
if err != nil {
c.jsonError(w, "No file provided", http.StatusBadRequest)
return
}
defer file.Close()
filename = header.Filename
data, err = io.ReadAll(file)
if err != nil {
c.jsonError(w, "Failed to read file", http.StatusInternalServerError)
return
}
} else {
// Raw binary upload
filename = r.Header.Get("X-Filename")
if filename == "" {
filename = "upload"
}
var err error
data, err = io.ReadAll(r.Body)
if err != nil {
c.jsonError(w, "Failed to read request body", http.StatusBadRequest)
return
}
}
// Validate upload
if err := content.ValidateUpload(filename, int64(len(data)), data); err != nil {
c.jsonError(w, err.Error(), http.StatusBadRequest)
return
}
// Detect MIME type
detectedMime := content.DetectMimeType(data)
mimeType = detectedMime
if mimeType == "application/octet-stream" {
// Use header hint if available
if headerMime := r.Header.Get("Content-Type"); headerMime != "" && headerMime != "application/octet-stream" && !strings.HasPrefix(headerMime, "multipart/") {
mimeType = headerMime
}
}
// Create file record
f := &models.File{
Name: filename,
MimeType: mimeType,
Size: int64(len(data)),
Data: data,
UserID: user.ID,
}
fileID, err := models.Files.Insert(f)
if err != nil {
c.jsonError(w, "Failed to save file", http.StatusInternalServerError)
return
}
f, err = models.Files.Get(fileID)
if err != nil {
c.jsonError(w, "Failed to retrieve file", http.StatusInternalServerError)
return
}
helpers.AuditCreate(r, user.ID, helpers.ResourceFile, fileID, filename)
content.SetCORSHeaders(w, r)
c.jsonResponse(w, c.fileToResponse(f), http.StatusCreated)
}
// DownloadFile downloads a file's content.
// GET /api/files/{id}
func (c *APIController) DownloadFile(w http.ResponseWriter, r *http.Request) {
if !content.CheckRateLimit(r) {
content.SetCORSHeaders(w, r)
c.jsonError(w, "Too many requests", http.StatusTooManyRequests)
return
}
fileID := r.PathValue("id")
file, err := models.Files.Get(fileID)
if err != nil {
c.jsonError(w, "File not found", http.StatusNotFound)
return
}
// Check access: published files are public, others require auth
if !file.Published {
user := access.GetUserFromJWT(r)
if user == nil {
c.jsonError(w, "Authentication required", http.StatusUnauthorized)
return
}
// Only owner or admin can access unpublished files
if user.ID != file.UserID && user.Role != "admin" {
c.jsonError(w, "File not found", http.StatusNotFound)
return
}
}
content.SetCORSHeaders(w, r)
w.Header().Set("Content-Type", file.MimeType)
w.Header().Set("Content-Length", fmt.Sprintf("%d", file.Size))
disposition := "inline"
if r.URL.Query().Get("download") == "1" {
disposition = "attachment"
}
sanitizedName := content.SanitizeFilename(file.Name)
w.Header().Set("Content-Disposition", fmt.Sprintf(`%s; filename="%s"`, disposition, sanitizedName))
w.Header().Set("Cache-Control", "public, max-age=3600")
w.Write(file.Data)
}
// FileMetadata returns file metadata without content.
// GET /api/files/{id}/metadata
func (c *APIController) FileMetadata(w http.ResponseWriter, r *http.Request) {
if !content.CheckRateLimit(r) {
content.SetCORSHeaders(w, r)
c.jsonError(w, "Too many requests", http.StatusTooManyRequests)
return
}
fileID := r.PathValue("id")
file, err := models.Files.Get(fileID)
if err != nil {
c.jsonError(w, "File not found", http.StatusNotFound)
return
}
// Check access
if !file.Published {
user := access.GetUserFromJWT(r)
if user == nil {
c.jsonError(w, "Authentication required", http.StatusUnauthorized)
return
}
if user.ID != file.UserID && user.Role != "admin" {
c.jsonError(w, "File not found", http.StatusNotFound)
return
}
}
content.SetCORSHeaders(w, r)
c.jsonResponse(w, c.fileToResponse(file), http.StatusOK)
}
// UpdateFile updates a file's metadata (path, published).
// PATCH /api/files/{id}
func (c *APIController) UpdateFile(w http.ResponseWriter, r *http.Request) {
if !content.CheckRateLimit(r) {
content.SetCORSHeaders(w, r)
c.jsonError(w, "Too many requests", http.StatusTooManyRequests)
return
}
user := access.GetUserFromJWT(r)
if user == nil {
c.jsonError(w, "Authentication required", http.StatusUnauthorized)
return
}
fileID := r.PathValue("id")
file, err := models.Files.Get(fileID)
if err != nil {
c.jsonError(w, "File not found", http.StatusNotFound)
return
}
// Only owner or admin can update
if user.ID != file.UserID && user.Role != "admin" {
c.jsonError(w, "File not found", http.StatusNotFound)
return
}
var updates struct {
Path *string `json:"path"`
Published *bool `json:"published"`
}
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
c.jsonError(w, "Invalid JSON body", http.StatusBadRequest)
return
}
if updates.Published != nil {
file.Published = *updates.Published
}
if updates.Path != nil {
newPath := strings.TrimSpace(*updates.Path)
newPath = strings.TrimPrefix(newPath, "/")
if err := content.ValidatePath(newPath); err != nil {
c.jsonError(w, err.Error(), http.StatusBadRequest)
return
}
// Check path uniqueness
if newPath != "" && newPath != file.Path {
existing, _ := models.Files.First("WHERE Path = ? AND ID != ?", newPath, file.ID)
if existing != nil {
c.jsonError(w, "Path is already in use by another file", http.StatusConflict)
return
}
}
file.Path = newPath
}
if err := models.Files.Update(file); err != nil {
c.jsonError(w, "Failed to update file", http.StatusInternalServerError)
return
}
helpers.AuditUpdate(r, user.ID, helpers.ResourceFile, fileID, file.Name, map[string]any{
"published": file.Published,
"path": file.Path,
})
content.SetCORSHeaders(w, r)
c.jsonResponse(w, c.fileToResponse(file), http.StatusOK)
}
// DeleteFile deletes a file.
// DELETE /api/files/{id}
func (c *APIController) DeleteFile(w http.ResponseWriter, r *http.Request) {
if !content.CheckRateLimit(r) {
content.SetCORSHeaders(w, r)
c.jsonError(w, "Too many requests", http.StatusTooManyRequests)
return
}
user := access.GetUserFromJWT(r)
if user == nil {
c.jsonError(w, "Authentication required", http.StatusUnauthorized)
return
}
fileID := r.PathValue("id")
file, err := models.Files.Get(fileID)
if err != nil {
c.jsonError(w, "File not found", http.StatusNotFound)
return
}
// Only owner or admin can delete
if user.ID != file.UserID && user.Role != "admin" {
c.jsonError(w, "File not found", http.StatusNotFound)
return
}
// Delete any message attachments first
mfs, _ := models.MessageFiles.Search("WHERE FileID = ?", file.ID)
for _, mf := range mfs {
models.MessageFiles.Delete(mf)
}
if err := models.Files.Delete(file); err != nil {
c.jsonError(w, "Failed to delete file", http.StatusInternalServerError)
return
}
helpers.AuditDelete(r, user.ID, helpers.ResourceFile, fileID, file.Name)
content.SetCORSHeaders(w, r)
w.WriteHeader(http.StatusNoContent)
}
// --- API Documentation ---
// DocsUI serves the Swagger UI for API documentation.
// GET /api/docs
func (c *APIController) DocsUI(w http.ResponseWriter, r *http.Request) {
html := `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ReadySite API Documentation</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.11.0/swagger-ui.css">
<style>
body { margin: 0; padding: 0; }
.swagger-ui .topbar { display: none; }
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.11.0/swagger-ui-bundle.js"></script>
<script>
window.onload = function() {
window.ui = SwaggerUIBundle({
url: "/api/docs/openapi.json",
dom_id: '#swagger-ui',
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIBundle.SwaggerUIStandalonePreset
],
layout: "BaseLayout",
persistAuthorization: true
});
};
</script>
</body>
</html>`
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
w.Write([]byte(html))
}
// OpenAPISpec serves the OpenAPI JSON specification.
// GET /api/docs/openapi.json
func (c *APIController) OpenAPISpec(w http.ResponseWriter, r *http.Request) {
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
scheme = proto
}
baseURL := scheme + "://" + r.Host
spec, err := content.GenerateSpec(baseURL)
if err != nil {
c.jsonError(w, "Failed to generate API documentation", http.StatusInternalServerError)
return
}
data, err := spec.ToJSON()
if err != nil {
c.jsonError(w, "Failed to serialize API documentation", http.StatusInternalServerError)
return
}
content.SetCORSHeaders(w, r)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write(data)
}
// --- User API Endpoints ---
// UserResponse is the response format for user data.
type UserResponse struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Role string `json:"role"`
Verified bool `json:"verified"`
Created string `json:"created"`
Updated string `json:"updated"`
}
// ListUsersResponse is the response format for listing users.
type ListUsersResponse struct {
Page int `json:"page"`
PerPage int `json:"perPage"`
TotalItems int `json:"totalItems"`
TotalPages int `json:"totalPages"`
Items []UserResponse `json:"items"`
}
// userToResponse converts a User model to API response format.
func (c *APIController) userToResponse(u *models.User) UserResponse {
return UserResponse{
ID: u.ID,
Email: u.Email,
Name: u.Name,
Role: u.Role,
Verified: u.Verified,
Created: u.CreatedAt.Format("2006-01-02T15:04:05Z"),
Updated: u.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
}
// ListUsers returns paginated users.
// GET /api/users
func (c *APIController) ListUsers(w http.ResponseWriter, r *http.Request) {
if !content.CheckRateLimit(r) {
content.SetCORSHeaders(w, r)
c.jsonError(w, "Too many requests", http.StatusTooManyRequests)
return
}
user := access.GetUserFromJWT(r)
if user == nil || user.Role != "admin" {
content.SetCORSHeaders(w, r)
c.jsonError(w, "Admin access required", http.StatusForbidden)
return
}
page := c.intParam(r, "page", 1)
perPage := c.intParam(r, "perPage", 20)
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 20
}
if perPage > 100 {
perPage = 100
}
offset := (page - 1) * perPage
totalItems := models.Users.Count("")
totalPages := (totalItems + perPage - 1) / perPage
if totalPages < 1 {
totalPages = 1
}
users, err := models.Users.Search("ORDER BY CreatedAt DESC LIMIT ? OFFSET ?", perPage, offset)
if err != nil {
c.jsonError(w, "Failed to fetch users", http.StatusInternalServerError)
return
}
items := make([]UserResponse, len(users))
for i, u := range users {
items[i] = c.userToResponse(u)
}
content.SetCORSHeaders(w, r)
c.jsonResponse(w, ListUsersResponse{
Page: page,
PerPage: perPage,
TotalItems: totalItems,
TotalPages: totalPages,
Items: items,
}, http.StatusOK)
}
// CreateUser creates a new user.
// POST /api/users
func (c *APIController) CreateUser(w http.ResponseWriter, r *http.Request) {
// Rate limit by IP for signup
key := r.RemoteAddr
if ip := r.Header.Get("X-Forwarded-For"); ip != "" {
key = ip
}
if !access.AuthLimiter.Allow(key) {
content.SetCORSHeaders(w, r)
c.jsonError(w, "Too many requests", http.StatusTooManyRequests)
return
}
user := access.GetUserFromJWT(r)
// Check if public signup is allowed, or if user is admin
signupEnabled := helpers.GetSetting(models.SettingSignupEnabled) == "true"
isAdmin := user != nil && user.Role == "admin"
if !signupEnabled && !isAdmin {
content.SetCORSHeaders(w, r)
c.jsonError(w, "Public signup is not enabled", http.StatusForbidden)
return
}
var req struct {
Email string `json:"email"`
Password string `json:"password"`
Name string `json:"name"`
Role string `json:"role"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
c.jsonError(w, "Invalid JSON body", http.StatusBadRequest)
return
}
if req.Email == "" {
c.jsonError(w, "Email is required", http.StatusBadRequest)
return
}
if req.Password == "" {
c.jsonError(w, "Password is required", http.StatusBadRequest)
return
}
if len(req.Password) < 8 {
c.jsonError(w, "Password must be at least 8 characters", http.StatusBadRequest)
return
}
// Validate email format
if err := content.ValidateEmail(req.Email); err != nil {
c.jsonError(w, "Invalid email format", http.StatusBadRequest)
return
}
// Only admins can set role; public signup gets "user" role
role := "user"
if isAdmin && req.Role != "" {
if req.Role == "admin" || req.Role == "user" || req.Role == "viewer" {
role = req.Role
}
}
// Check if email already exists
existing, _ := models.Users.First("WHERE Email = ?", req.Email)
if existing != nil {
c.jsonError(w, "A user with this email already exists", http.StatusConflict)
return
}
// Hash password
hash, err := access.HashPassword(req.Password)
if err != nil {
c.jsonError(w, "Failed to process password", http.StatusInternalServerError)
return
}
newUser := &models.User{
Email: req.Email,
Name: req.Name,
PasswordHash: hash,
Role: role,
}
userID, err := models.Users.Insert(newUser)
if err != nil {
c.jsonError(w, "Failed to create user", http.StatusInternalServerError)
return
}
newUser, _ = models.Users.Get(userID)
if newUser == nil {
c.jsonError(w, "Failed to retrieve user", http.StatusInternalServerError)
return
}
// Audit log
actorID := helpers.UserAnonymous
if user != nil {
actorID = user.ID
}
helpers.AuditCreate(r, actorID, helpers.ResourceUser, userID, req.Email)
content.SetCORSHeaders(w, r)
c.jsonResponse(w, c.userToResponse(newUser), http.StatusCreated)
}
// ViewUser returns a single user by ID.
// GET /api/users/{id}
func (c *APIController) ViewUser(w http.ResponseWriter, r *http.Request) {
if !content.CheckRateLimit(r) {
content.SetCORSHeaders(w, r)
c.jsonError(w, "Too many requests", http.StatusTooManyRequests)
return
}
userID := r.PathValue("id")
currentUser := access.GetUserFromJWT(r)
if currentUser == nil {
content.SetCORSHeaders(w, r)
c.jsonError(w, "Authentication required", http.StatusUnauthorized)
return
}
// Users can view themselves, admins can view anyone
if currentUser.ID != userID && currentUser.Role != "admin" {
content.SetCORSHeaders(w, r)
c.jsonError(w, "User not found", http.StatusNotFound)
return
}
user, err := models.Users.Get(userID)
if err != nil {
c.jsonError(w, "User not found", http.StatusNotFound)
return
}
content.SetCORSHeaders(w, r)
c.jsonResponse(w, c.userToResponse(user), http.StatusOK)
}
// UpdateUser updates an existing user.
// PATCH /api/users/{id}
func (c *APIController) UpdateUser(w http.ResponseWriter, r *http.Request) {
if !content.CheckRateLimit(r) {
content.SetCORSHeaders(w, r)
c.jsonError(w, "Too many requests", http.StatusTooManyRequests)
return
}
userID := r.PathValue("id")
currentUser := access.GetUserFromJWT(r)
if currentUser == nil {
content.SetCORSHeaders(w, r)
c.jsonError(w, "Authentication required", http.StatusUnauthorized)
return
}
// Users can update themselves, admins can update anyone
if currentUser.ID != userID && currentUser.Role != "admin" {
content.SetCORSHeaders(w, r)
c.jsonError(w, "User not found", http.StatusNotFound)
return
}
user, err := models.Users.Get(userID)
if err != nil {
c.jsonError(w, "User not found", http.StatusNotFound)
return
}
var updates struct {
Email *string `json:"email"`
Name *string `json:"name"`
Password *string `json:"password"`
Role *string `json:"role"`
}
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
c.jsonError(w, "Invalid JSON body", http.StatusBadRequest)
return
}
if updates.Email != nil {
if *updates.Email == "" {
c.jsonError(w, "Email cannot be empty", http.StatusBadRequest)
return
}
if err := content.ValidateEmail(*updates.Email); err != nil {
c.jsonError(w, "Invalid email format", http.StatusBadRequest)
return
}
// Check if email is taken by another user
existing, _ := models.Users.First("WHERE Email = ? AND ID != ?", *updates.Email, userID)
if existing != nil {
c.jsonError(w, "A user with this email already exists", http.StatusConflict)
return
}
user.Email = *updates.Email
}
if updates.Name != nil {
user.Name = *updates.Name
}
if updates.Password != nil && *updates.Password != "" {
if len(*updates.Password) < 8 {
c.jsonError(w, "Password must be at least 8 characters", http.StatusBadRequest)
return
}
hash, err := access.HashPassword(*updates.Password)
if err != nil {
c.jsonError(w, "Failed to process password", http.StatusInternalServerError)
return
}
user.PasswordHash = hash
}
// Only admins can change roles
if updates.Role != nil && currentUser.Role == "admin" {
if *updates.Role == "admin" || *updates.Role == "user" || *updates.Role == "viewer" {
user.Role = *updates.Role
}
}
if err := models.Users.Update(user); err != nil {
c.jsonError(w, "Failed to update user", http.StatusInternalServerError)
return
}
helpers.AuditUpdate(r, currentUser.ID, helpers.ResourceUser, userID, user.Email, nil)
content.SetCORSHeaders(w, r)
c.jsonResponse(w, c.userToResponse(user), http.StatusOK)
}
// DeleteUser deletes a user.
// DELETE /api/users/{id}
func (c *APIController) DeleteUser(w http.ResponseWriter, r *http.Request) {
if !content.CheckRateLimit(r) {
content.SetCORSHeaders(w, r)
c.jsonError(w, "Too many requests", http.StatusTooManyRequests)
return
}
userID := r.PathValue("id")
currentUser := access.GetUserFromJWT(r)
if currentUser == nil || currentUser.Role != "admin" {
content.SetCORSHeaders(w, r)
c.jsonError(w, "Admin access required", http.StatusForbidden)
return
}
// Prevent self-deletion
if currentUser.ID == userID {
c.jsonError(w, "You cannot delete your own account", http.StatusBadRequest)
return
}
user, err := models.Users.Get(userID)
if err != nil {
c.jsonError(w, "User not found", http.StatusNotFound)
return
}
// Prevent deleting the last admin
if user.Role == "admin" {
adminCount := models.Users.Count("WHERE Role = 'admin'")
if adminCount <= 1 {
c.jsonError(w, "Cannot delete the last admin user", http.StatusBadRequest)
return
}
}
if err := models.Users.Delete(user); err != nil {
c.jsonError(w, "Failed to delete user", http.StatusInternalServerError)
return
}
helpers.AuditDelete(r, currentUser.ID, helpers.ResourceUser, userID, user.Email)
content.SetCORSHeaders(w, r)
w.WriteHeader(http.StatusNoContent)
}