writekit/internal/server/studio.go
2026-01-09 00:16:46 +02:00

2052 lines
54 KiB
Go

package server
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/gorilla/websocket"
"github.com/writekitapp/writekit/internal/config"
"github.com/writekitapp/writekit/internal/db"
"github.com/writekitapp/writekit/internal/imaginary"
"github.com/writekitapp/writekit/internal/markdown"
"github.com/writekitapp/writekit/internal/tenant"
)
func renderContent(q *tenant.Queries, r *http.Request, content string) string {
settings, _ := q.GetSettings(r.Context())
codeTheme := "github"
if t, ok := settings["code_theme"]; ok && t != "" {
codeTheme = t
}
html, _ := markdown.RenderWithTheme(content, codeTheme)
return html
}
func (s *Server) studioRoutes() chi.Router {
r := chi.NewRouter()
r.Get("/posts", s.listPosts)
r.Post("/posts", s.createPost)
r.Get("/posts/{slug}", s.getPost)
r.Put("/posts/{slug}", s.updatePost)
r.Delete("/posts/{slug}", s.deletePost)
r.Post("/posts/{id}/publish", s.publishPost)
r.Post("/posts/{id}/unpublish", s.unpublishPost)
r.Get("/posts/{id}/draft", s.getDraft)
r.Put("/posts/{id}/draft", s.saveDraft)
r.Delete("/posts/{id}/draft", s.discardDraft)
r.Get("/posts/{id}/versions", s.listVersions)
r.Post("/posts/{id}/versions/{versionId}/restore", s.restoreVersion)
r.Post("/posts/{id}/render", s.renderPreview)
r.Get("/settings", s.getSettings)
r.Put("/settings", s.updateSettings)
r.Get("/interaction-config", s.getStudioInteractionConfig)
r.Put("/interaction-config", s.updateInteractionConfig)
r.Get("/assets", s.listAssets)
r.Post("/assets", s.uploadAsset)
r.Delete("/assets/{id}", s.deleteAsset)
r.Get("/api-keys", s.listAPIKeys)
r.Post("/api-keys", s.createAPIKey)
r.Delete("/api-keys/{key}", s.deleteAPIKey)
r.Get("/analytics", s.getAnalytics)
r.Get("/analytics/posts/{slug}", s.getPostAnalytics)
// Plugins
r.Get("/plugins", s.listPlugins)
r.Post("/plugins", s.savePlugin)
r.Delete("/plugins/{id}", s.deletePlugin)
r.Put("/plugins/{id}/toggle", s.togglePlugin)
r.Post("/plugins/compile", s.compilePlugin)
// Secrets
r.Get("/secrets", s.listSecrets)
r.Post("/secrets", s.createSecret)
r.Delete("/secrets/{key}", s.deleteSecret)
// Webhooks
r.Get("/webhooks", s.listWebhooks)
r.Post("/webhooks", s.createWebhook)
r.Put("/webhooks/{id}", s.updateWebhook)
r.Delete("/webhooks/{id}", s.deleteWebhook)
r.Post("/webhooks/{id}/test", s.testWebhook)
r.Get("/webhooks/{id}/deliveries", s.listWebhookDeliveries)
// Jarvis proxy (hooks/templates/lsp/sdk)
r.Get("/hooks", s.getHooks)
r.Get("/template", s.getTemplate)
r.Get("/sdk", s.getSDK)
r.Get("/lsp", s.proxyLSP)
// Code themes
r.Get("/code-theme.css", s.codeThemeCSS)
r.Get("/code-themes", s.listCodeThemes)
// Plugin testing
r.Post("/plugins/test", s.testPlugin)
// Billing
r.Get("/billing", s.getBilling)
return r
}
type postRequest struct {
Slug string `json:"slug"`
Title string `json:"title"`
Description string `json:"description"`
Content string `json:"content"`
Date string `json:"date"`
Draft bool `json:"draft"`
MembersOnly bool `json:"members_only"`
Tags []string `json:"tags"`
}
type postContent struct {
Markdown string `json:"markdown"`
HTML string `json:"html"`
}
type postResponse struct {
ID string `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
Description string `json:"description"`
CoverImage string `json:"cover_image"`
Content *postContent `json:"content,omitempty"`
Date string `json:"date"`
Draft bool `json:"draft"`
MembersOnly bool `json:"members_only"`
Tags []string `json:"tags"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func postToResponse(p *tenant.Post, includeContent bool) postResponse {
dateStr := ""
if p.PublishedAt != nil {
dateStr = p.PublishedAt.Format("2006-01-02")
}
updatedStr := p.ModifiedAt.Format(time.RFC3339)
if p.UpdatedAt != nil {
updatedStr = p.UpdatedAt.Format(time.RFC3339)
}
resp := postResponse{
ID: p.ID,
Slug: p.Slug,
Title: p.Title,
Description: p.Description,
CoverImage: p.CoverImage,
Date: dateStr,
Draft: !p.IsPublished,
MembersOnly: p.MembersOnly,
Tags: p.Tags,
CreatedAt: p.CreatedAt.Format(time.RFC3339),
UpdatedAt: updatedStr,
}
if includeContent {
resp.Content = &postContent{
Markdown: p.ContentMD,
HTML: p.ContentHTML,
}
}
if resp.Tags == nil {
resp.Tags = []string{}
}
return resp
}
func (s *Server) listPosts(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
posts, err := q.ListPosts(r.Context(), true)
if err != nil {
log.Printf("listPosts error for tenant %s: %v", tenantID, err)
jsonError(w, http.StatusInternalServerError, "failed to list posts")
return
}
result := make([]postResponse, len(posts))
for i, p := range posts {
result[i] = postToResponse(&p, false)
}
jsonResponse(w, http.StatusOK, result)
}
func (s *Server) getPost(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
slug := chi.URLParam(r, "slug")
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
post, err := q.GetPost(r.Context(), slug)
if err != nil {
jsonError(w, http.StatusInternalServerError, "failed to get post")
return
}
if post == nil {
jsonError(w, http.StatusNotFound, "post not found")
return
}
jsonResponse(w, http.StatusOK, postToResponse(post, true))
}
func (s *Server) createPost(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
var req postRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "invalid request")
return
}
if req.Title == "" {
jsonError(w, http.StatusBadRequest, "title required")
return
}
if req.Slug == "" {
req.Slug = slugify(req.Title)
}
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
existing, _ := q.GetPost(r.Context(), req.Slug)
if existing != nil {
jsonError(w, http.StatusConflict, "post with this slug already exists")
return
}
var publishedAt *time.Time
if !req.Draft {
now := time.Now()
if req.Date != "" {
if parsed, err := time.Parse("2006-01-02", req.Date); err == nil {
now = parsed
}
}
publishedAt = &now
}
post := &tenant.Post{
Slug: req.Slug,
Title: req.Title,
Description: req.Description,
ContentMD: req.Content,
ContentHTML: renderContent(q, r, req.Content),
IsPublished: !req.Draft,
MembersOnly: req.MembersOnly,
Tags: req.Tags,
PublishedAt: publishedAt,
}
if err := q.CreatePost(r.Context(), post); err != nil {
jsonError(w, http.StatusInternalServerError, "failed to create post")
return
}
if post.IsPublished {
s.rebuildSite(r.Context(), tenantID, db, r.Host)
go func() {
runner := s.getPluginRunner(tenantID, db)
defer runner.Close()
pubAt := ""
if post.PublishedAt != nil {
pubAt = post.PublishedAt.Format(time.RFC3339)
}
runner.TriggerHook(r.Context(), "post.published", map[string]any{
"post": map[string]any{
"slug": post.Slug,
"title": post.Title,
"url": "/" + post.Slug,
"excerpt": post.Description,
"publishedAt": pubAt,
"tags": post.Tags,
},
})
}()
}
jsonResponse(w, http.StatusCreated, postToResponse(post, false))
}
func (s *Server) updatePost(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
slug := chi.URLParam(r, "slug")
var req postRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "invalid request")
return
}
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
post, err := q.GetPost(r.Context(), slug)
if err != nil {
jsonError(w, http.StatusInternalServerError, "failed to get post")
return
}
if post == nil {
jsonError(w, http.StatusNotFound, "post not found")
return
}
wasUnpublished := !post.IsPublished
oldTitle := post.Title
oldTags := post.Tags
oldSlug := post.Slug
if req.Slug != "" && req.Slug != slug {
existing, _ := q.GetPost(r.Context(), req.Slug)
if existing != nil {
jsonError(w, http.StatusConflict, "post with this slug already exists")
return
}
if !contains(post.Aliases, oldSlug) {
post.Aliases = append(post.Aliases, oldSlug)
}
post.Slug = req.Slug
}
if req.Title != "" {
post.Title = req.Title
}
post.Description = req.Description
post.ContentMD = req.Content
post.IsPublished = !req.Draft
post.MembersOnly = req.MembersOnly
post.Tags = req.Tags
post.ContentHTML = renderContent(q, r, req.Content)
if !req.Draft && post.PublishedAt == nil {
now := time.Now()
if req.Date != "" {
if parsed, err := time.Parse("2006-01-02", req.Date); err == nil {
now = parsed
}
}
post.PublishedAt = &now
}
if err := q.UpdatePost(r.Context(), post); err != nil {
jsonError(w, http.StatusInternalServerError, "failed to update post")
return
}
if post.IsPublished {
s.rebuildSite(r.Context(), tenantID, db, r.Host)
go func() {
runner := s.getPluginRunner(tenantID, db)
defer runner.Close()
pubAt := ""
if post.PublishedAt != nil {
pubAt = post.PublishedAt.Format(time.RFC3339)
}
if wasUnpublished {
runner.TriggerHook(r.Context(), "post.published", map[string]any{
"post": map[string]any{
"slug": post.Slug,
"title": post.Title,
"url": "/" + post.Slug,
"excerpt": post.Description,
"publishedAt": pubAt,
"tags": post.Tags,
},
})
} else {
changes := map[string]any{}
if oldTitle != post.Title {
changes["title"] = map[string]any{"old": oldTitle, "new": post.Title}
}
changes["content"] = true
addedTags := []string{}
removedTags := []string{}
oldTagSet := make(map[string]bool)
for _, t := range oldTags {
oldTagSet[t] = true
}
for _, t := range post.Tags {
if !oldTagSet[t] {
addedTags = append(addedTags, t)
}
delete(oldTagSet, t)
}
for t := range oldTagSet {
removedTags = append(removedTags, t)
}
if len(addedTags) > 0 || len(removedTags) > 0 {
changes["tags"] = map[string]any{"added": addedTags, "removed": removedTags}
}
runner.TriggerHook(r.Context(), "post.updated", map[string]any{
"post": map[string]any{
"slug": post.Slug,
"title": post.Title,
"url": "/" + post.Slug,
"excerpt": post.Description,
"publishedAt": pubAt,
"updatedAt": time.Now().Format(time.RFC3339),
"tags": post.Tags,
},
"changes": changes,
})
}
}()
// Trigger webhooks for published posts
if wasUnpublished {
q.TriggerWebhooks(r.Context(), "post.published", map[string]any{
"post": postToResponse(post, false),
}, "https://"+r.Host)
} else {
q.TriggerWebhooks(r.Context(), "post.updated", map[string]any{
"post": postToResponse(post, false),
}, "https://"+r.Host)
}
}
jsonResponse(w, http.StatusOK, postToResponse(post, false))
}
func (s *Server) deletePost(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
slug := chi.URLParam(r, "slug")
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
post, err := q.GetPost(r.Context(), slug)
if err != nil {
jsonError(w, http.StatusInternalServerError, "failed to get post")
return
}
if post == nil {
jsonError(w, http.StatusNotFound, "post not found")
return
}
wasPublished := post.IsPublished
postData := postToResponse(post, false)
if err := q.DeletePost(r.Context(), post.ID); err != nil {
jsonError(w, http.StatusInternalServerError, "failed to delete post")
return
}
if wasPublished {
q.DeletePage(r.Context(), "/posts/"+slug)
s.rebuildSite(r.Context(), tenantID, db, r.Host)
// Trigger webhooks for deleted posts
q.TriggerWebhooks(r.Context(), "post.deleted", map[string]any{
"post": postData,
}, "https://"+r.Host)
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) publishPost(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
postID := chi.URLParam(r, "id")
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
post, err := q.GetPostByID(r.Context(), postID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "failed to get post")
return
}
if post == nil {
jsonError(w, http.StatusNotFound, "post not found")
return
}
if err := q.Publish(r.Context(), postID); err != nil {
jsonError(w, http.StatusInternalServerError, "failed to publish")
return
}
post, _ = q.GetPostByID(r.Context(), postID)
s.rebuildSite(r.Context(), tenantID, db, r.Host)
// Trigger webhooks
q.TriggerWebhooks(r.Context(), "post.published", map[string]any{
"post": postToResponse(post, false),
}, "https://"+r.Host)
jsonResponse(w, http.StatusOK, postToResponse(post, false))
}
func (s *Server) unpublishPost(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
postID := chi.URLParam(r, "id")
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
post, err := q.GetPostByID(r.Context(), postID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "failed to get post")
return
}
if post == nil {
jsonError(w, http.StatusNotFound, "post not found")
return
}
if err := q.Unpublish(r.Context(), postID); err != nil {
jsonError(w, http.StatusInternalServerError, "failed to unpublish")
return
}
q.DeletePage(r.Context(), "/posts/"+post.Slug)
s.rebuildSite(r.Context(), tenantID, db, r.Host)
post, _ = q.GetPostByID(r.Context(), postID)
jsonResponse(w, http.StatusOK, postToResponse(post, false))
}
func (s *Server) getDraft(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
postID := chi.URLParam(r, "id")
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
draft, err := q.GetDraft(r.Context(), postID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "failed to get draft")
return
}
if draft == nil {
jsonResponse(w, http.StatusOK, nil)
return
}
jsonResponse(w, http.StatusOK, map[string]any{
"post_id": draft.PostID,
"slug": draft.Slug,
"title": draft.Title,
"description": draft.Description,
"tags": draft.Tags,
"cover_image": draft.CoverImage,
"members_only": draft.MembersOnly,
"content": draft.ContentMD,
"modified_at": draft.ModifiedAt.Format(time.RFC3339),
})
}
func (s *Server) saveDraft(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
postID := chi.URLParam(r, "id")
var req struct {
Slug string `json:"slug"`
Title string `json:"title"`
Description string `json:"description"`
Tags []string `json:"tags"`
CoverImage string `json:"cover_image"`
MembersOnly bool `json:"members_only"`
Content string `json:"content"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "invalid request")
return
}
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
post, err := q.GetPostByID(r.Context(), postID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "failed to get post")
return
}
if post == nil {
jsonError(w, http.StatusNotFound, "post not found")
return
}
draft := &tenant.PostDraft{
PostID: postID,
Slug: req.Slug,
Title: req.Title,
Description: req.Description,
Tags: req.Tags,
CoverImage: req.CoverImage,
MembersOnly: req.MembersOnly,
ContentMD: req.Content,
ContentHTML: renderContent(q, r, req.Content),
}
if draft.Slug == "" {
draft.Slug = post.Slug
}
if err := q.SaveDraft(r.Context(), draft); err != nil {
jsonError(w, http.StatusInternalServerError, "failed to save draft")
return
}
jsonResponse(w, http.StatusOK, map[string]bool{"success": true})
}
func (s *Server) discardDraft(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
postID := chi.URLParam(r, "id")
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
if err := q.DeleteDraft(r.Context(), postID); err != nil {
jsonError(w, http.StatusInternalServerError, "failed to discard draft")
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) listVersions(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
postID := chi.URLParam(r, "id")
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
versions, err := q.ListVersions(r.Context(), postID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "failed to list versions")
return
}
result := make([]map[string]any, len(versions))
for i, v := range versions {
result[i] = map[string]any{
"id": v.ID,
"title": v.Title,
"created_at": v.CreatedAt.Format(time.RFC3339),
}
}
jsonResponse(w, http.StatusOK, result)
}
func (s *Server) restoreVersion(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
postID := chi.URLParam(r, "id")
versionIDStr := chi.URLParam(r, "versionId")
versionID, err := strconv.ParseInt(versionIDStr, 10, 64)
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid version id")
return
}
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
if err := q.RestoreVersion(r.Context(), postID, versionID); err != nil {
jsonError(w, http.StatusInternalServerError, "failed to restore version")
return
}
jsonResponse(w, http.StatusOK, map[string]bool{"success": true})
}
func (s *Server) renderPreview(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
var req struct {
Markdown string `json:"markdown"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "invalid request")
return
}
q := tenant.NewQueries(db)
settings, _ := q.GetSettings(r.Context())
codeTheme := getSettingOr(settings, "code_theme", "github")
html, err := markdown.RenderWithTheme(req.Markdown, codeTheme)
if err != nil {
jsonError(w, http.StatusInternalServerError, "render failed")
return
}
jsonResponse(w, http.StatusOK, map[string]string{"html": html})
}
func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
settings, err := q.GetSettings(r.Context())
if err != nil {
jsonError(w, http.StatusInternalServerError, "failed to get settings")
return
}
jsonResponse(w, http.StatusOK, settings)
}
func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
var settings map[string]string
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
jsonError(w, http.StatusBadRequest, "invalid request")
return
}
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
if err := q.SetSettings(r.Context(), settings); err != nil {
jsonError(w, http.StatusInternalServerError, "failed to update settings")
return
}
s.rebuildSite(r.Context(), tenantID, db, r.Host)
jsonResponse(w, http.StatusOK, map[string]bool{"success": true})
}
func (s *Server) listAssets(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
assets, err := q.ListAssets(r.Context())
if err != nil {
jsonError(w, http.StatusInternalServerError, "failed to list assets")
return
}
jsonResponse(w, http.StatusOK, assets)
}
func (s *Server) uploadAsset(w http.ResponseWriter, r *http.Request) {
if s.storage == nil {
jsonError(w, http.StatusServiceUnavailable, "storage not configured")
return
}
tenantID := r.Context().Value(tenantIDKey).(string)
if err := r.ParseMultipartForm(15 << 20); err != nil {
jsonError(w, http.StatusBadRequest, "file too large")
return
}
file, header, err := r.FormFile("file")
if err != nil {
jsonError(w, http.StatusBadRequest, "file required")
return
}
defer file.Close()
contentType := header.Header.Get("Content-Type")
isImage := strings.HasPrefix(contentType, "image/")
var uploadData []byte
var finalContentType string
var ext string
if isImage && s.imaginary != nil {
processed, err := s.imaginary.Process(file, header.Filename, imaginary.ProcessOptions{
Width: 2000,
Height: 2000,
Quality: 80,
Type: "webp",
})
if err != nil {
jsonError(w, http.StatusInternalServerError, "image processing failed")
return
}
uploadData = processed
finalContentType = "image/webp"
ext = ".webp"
} else {
data, err := io.ReadAll(file)
if err != nil {
jsonError(w, http.StatusInternalServerError, "read failed")
return
}
uploadData = data
finalContentType = contentType
ext = ""
if idx := strings.LastIndex(header.Filename, "."); idx != -1 {
ext = header.Filename[idx:]
}
}
r2Key := fmt.Sprintf("%s/%s%s", tenantID, uuid.NewString(), ext)
if err := s.storage.Upload(r.Context(), r2Key, bytes.NewReader(uploadData), finalContentType); err != nil {
jsonError(w, http.StatusInternalServerError, "upload failed")
return
}
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
asset := &tenant.Asset{
Filename: header.Filename,
R2Key: r2Key,
ContentType: finalContentType,
Size: int64(len(uploadData)),
}
q := tenant.NewQueries(db)
if err := q.CreateAsset(r.Context(), asset); err != nil {
s.storage.Delete(r.Context(), r2Key)
jsonError(w, http.StatusInternalServerError, "failed to save asset")
return
}
assetURL := s.storage.PublicURL(r2Key)
// Trigger asset.uploaded hook
go func() {
runner := s.getPluginRunner(tenantID, db)
defer runner.Close()
runner.TriggerHook(r.Context(), "asset.uploaded", map[string]any{
"id": asset.ID,
"url": assetURL,
"contentType": asset.ContentType,
"size": asset.Size,
})
}()
jsonResponse(w, http.StatusCreated, map[string]any{
"id": asset.ID,
"filename": asset.Filename,
"url": assetURL,
"content_type": asset.ContentType,
"size": asset.Size,
})
}
func (s *Server) deleteAsset(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
assetID := chi.URLParam(r, "id")
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
asset, err := q.GetAsset(r.Context(), assetID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "failed to get asset")
return
}
if asset == nil {
jsonError(w, http.StatusNotFound, "asset not found")
return
}
if s.storage != nil {
s.storage.Delete(r.Context(), asset.R2Key)
}
if err := q.DeleteAsset(r.Context(), assetID); err != nil {
jsonError(w, http.StatusInternalServerError, "failed to delete asset")
return
}
w.WriteHeader(http.StatusNoContent)
}
func slugify(s string) string {
s = strings.ToLower(s)
s = strings.ReplaceAll(s, " ", "-")
var result strings.Builder
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
result.WriteRune(r)
}
}
return result.String()
}
func (s *Server) listAPIKeys(w http.ResponseWriter, r *http.Request) {
if GetDemoInfo(r).IsDemo {
jsonResponse(w, http.StatusOK, []any{})
return
}
tenantID := r.Context().Value(tenantIDKey).(string)
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
keys, err := q.ListAPIKeys(r.Context())
if err != nil {
jsonError(w, http.StatusInternalServerError, "failed to list API keys")
return
}
result := make([]map[string]any, len(keys))
for i, k := range keys {
result[i] = map[string]any{
"key": maskKey(k.Key),
"name": k.Name,
"created_at": k.CreatedAt.Format(time.RFC3339),
"last_used_at": nil,
}
if k.LastUsedAt != nil {
result[i]["last_used_at"] = k.LastUsedAt.Format(time.RFC3339)
}
}
jsonResponse(w, http.StatusOK, result)
}
func (s *Server) createAPIKey(w http.ResponseWriter, r *http.Request) {
if GetDemoInfo(r).IsDemo {
jsonError(w, http.StatusForbidden, "API key generation is not available in demo mode")
return
}
tenantID := r.Context().Value(tenantIDKey).(string)
var req struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "invalid request")
return
}
if req.Name == "" {
jsonError(w, http.StatusBadRequest, "name required")
return
}
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
key, err := q.CreateAPIKey(r.Context(), req.Name)
if err != nil {
jsonError(w, http.StatusInternalServerError, "failed to create API key")
return
}
jsonResponse(w, http.StatusCreated, map[string]any{
"key": key.Key,
"name": key.Name,
"created_at": key.CreatedAt.Format(time.RFC3339),
})
}
func (s *Server) deleteAPIKey(w http.ResponseWriter, r *http.Request) {
if GetDemoInfo(r).IsDemo {
jsonError(w, http.StatusForbidden, "API key management is not available in demo mode")
return
}
tenantID := r.Context().Value(tenantIDKey).(string)
key := chi.URLParam(r, "key")
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
if err := q.DeleteAPIKey(r.Context(), key); err != nil {
jsonError(w, http.StatusInternalServerError, "failed to delete API key")
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) getStudioInteractionConfig(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
config := q.GetInteractionConfig(r.Context())
jsonResponse(w, http.StatusOK, config)
}
func (s *Server) updateInteractionConfig(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
var req struct {
CommentsEnabled *bool `json:"comments_enabled"`
ReactionsEnabled *bool `json:"reactions_enabled"`
ReactionMode *string `json:"reaction_mode"`
ReactionEmojis *string `json:"reaction_emojis"`
UpvoteIcon *string `json:"upvote_icon"`
ReactionsRequireAuth *bool `json:"reactions_require_auth"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "invalid request")
return
}
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
settings := make(tenant.Settings)
if req.CommentsEnabled != nil {
settings["comments_enabled"] = boolToStr(*req.CommentsEnabled)
}
if req.ReactionsEnabled != nil {
settings["reactions_enabled"] = boolToStr(*req.ReactionsEnabled)
}
if req.ReactionMode != nil {
settings["reaction_mode"] = *req.ReactionMode
}
if req.ReactionEmojis != nil {
settings["reaction_emojis"] = *req.ReactionEmojis
}
if req.UpvoteIcon != nil {
settings["upvote_icon"] = *req.UpvoteIcon
}
if req.ReactionsRequireAuth != nil {
settings["reactions_require_auth"] = boolToStr(*req.ReactionsRequireAuth)
}
if len(settings) > 0 {
if err := q.SetSettings(r.Context(), settings); err != nil {
jsonError(w, http.StatusInternalServerError, "failed to update settings")
return
}
}
config := q.GetInteractionConfig(r.Context())
jsonResponse(w, http.StatusOK, config)
}
func boolToStr(b bool) string {
if b {
return "true"
}
return "false"
}
func maskKey(key string) string {
if len(key) <= 8 {
return key
}
return key[:7] + "..." + key[len(key)-4:]
}
func (s *Server) getAnalytics(w http.ResponseWriter, r *http.Request) {
days := 30
if d := r.URL.Query().Get("days"); d != "" {
if parsed, err := strconv.Atoi(d); err == nil && parsed > 0 && parsed <= 365 {
days = parsed
}
}
if GetDemoInfo(r).IsDemo {
jsonResponse(w, http.StatusOK, generateFakeAnalytics(days))
return
}
tenantID := r.Context().Value(tenantIDKey).(string)
tenantDB, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
t, err := s.database.GetTenantByID(r.Context(), tenantID)
if err != nil || t == nil {
d, _ := s.database.GetDemoByID(r.Context(), tenantID)
if d != nil {
t = &db.Tenant{Subdomain: d.Subdomain}
}
}
// Enforce tier analytics retention limit
tierInfo := config.GetTierInfo(t != nil && t.Premium)
if days > tierInfo.Config.AnalyticsRetention {
days = tierInfo.Config.AnalyticsRetention
}
hostname := ""
if t != nil {
hostname = t.Subdomain + "." + s.domain
}
result := &tenant.AnalyticsSummary{
ViewsByDay: []tenant.DailyStats{},
TopPages: []tenant.PageStats{},
Browsers: []tenant.NamedStat{},
OS: []tenant.NamedStat{},
Devices: []tenant.NamedStat{},
Countries: []tenant.NamedStat{},
}
q := tenant.NewQueries(tenantDB)
if s.cloudflare.IsConfigured() && hostname != "" {
cfDays := days
if cfDays > 14 {
cfDays = 14
}
cfData, err := s.cloudflare.GetAnalytics(r.Context(), cfDays, hostname)
if err == nil && cfData != nil {
result.TotalViews = cfData.TotalRequests
result.TotalPageViews = cfData.TotalPageViews
result.UniqueVisitors = cfData.UniqueVisitors
result.TotalBandwidth = cfData.TotalBandwidth
for _, d := range cfData.Daily {
result.ViewsByDay = append(result.ViewsByDay, tenant.DailyStats{
Date: d.Date,
Views: d.PageViews,
Visitors: d.Visitors,
})
}
for _, p := range cfData.Paths {
result.TopPages = append(result.TopPages, tenant.PageStats{
Path: p.Path,
Views: p.Requests,
})
}
for _, b := range cfData.Browsers {
result.Browsers = append(result.Browsers, tenant.NamedStat{Name: b.Name, Count: b.Count})
}
for _, o := range cfData.OS {
result.OS = append(result.OS, tenant.NamedStat{Name: o.Name, Count: o.Count})
}
for _, d := range cfData.Devices {
result.Devices = append(result.Devices, tenant.NamedStat{Name: d.Name, Count: d.Count})
}
for _, c := range cfData.Countries {
result.Countries = append(result.Countries, tenant.NamedStat{Name: c.Name, Count: c.Count})
}
}
if days > 14 {
archivedSince := time.Now().AddDate(0, 0, -days).Format("2006-01-02")
archivedUntil := time.Now().AddDate(0, 0, -15).Format("2006-01-02")
archived, err := q.GetArchivedAnalytics(r.Context(), archivedSince, archivedUntil)
if err == nil {
for _, a := range archived {
result.TotalViews += a.Requests
result.TotalPageViews += a.PageViews
result.UniqueVisitors += a.UniqueVisitors
result.TotalBandwidth += a.Bandwidth
result.ViewsByDay = append([]tenant.DailyStats{{
Date: a.Date,
Views: a.PageViews,
Visitors: a.UniqueVisitors,
}}, result.ViewsByDay...)
}
}
}
} else {
analytics, err := q.GetAnalytics(r.Context(), days)
if err == nil {
result = analytics
}
}
jsonResponse(w, http.StatusOK, result)
}
func (s *Server) getPostAnalytics(w http.ResponseWriter, r *http.Request) {
days := 30
if d := r.URL.Query().Get("days"); d != "" {
if parsed, err := strconv.Atoi(d); err == nil && parsed > 0 && parsed <= 365 {
days = parsed
}
}
if GetDemoInfo(r).IsDemo {
jsonResponse(w, http.StatusOK, generateFakePostAnalytics(days))
return
}
tenantID := r.Context().Value(tenantIDKey).(string)
slug := chi.URLParam(r, "slug")
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
analytics, err := q.GetPostAnalytics(r.Context(), slug, days)
if err != nil {
jsonError(w, http.StatusInternalServerError, "failed to get post analytics")
return
}
jsonResponse(w, http.StatusOK, analytics)
}
// ═══════════════════════════════════════════════════════════════════════════
// Plugins
// ═══════════════════════════════════════════════════════════════════════════
func (s *Server) listPlugins(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
plugins, err := q.ListPlugins(r.Context())
if err != nil {
jsonError(w, http.StatusInternalServerError, "failed to list plugins")
return
}
result := make([]map[string]any, len(plugins))
for i, p := range plugins {
result[i] = map[string]any{
"id": p.ID,
"name": p.Name,
"language": p.Language,
"source": p.Source,
"hooks": p.Hooks,
"enabled": p.Enabled,
"wasm_size": p.WasmSize,
"created_at": p.CreatedAt.Format(time.RFC3339),
"updated_at": p.UpdatedAt.Format(time.RFC3339),
}
}
jsonResponse(w, http.StatusOK, result)
}
func (s *Server) savePlugin(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
var req struct {
ID string `json:"id"`
Name string `json:"name"`
Language string `json:"language"`
Source string `json:"source"`
Hooks []string `json:"hooks"`
Enabled bool `json:"enabled"`
Wasm []byte `json:"wasm"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "invalid request")
return
}
if req.Name == "" {
jsonError(w, http.StatusBadRequest, "name required")
return
}
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
plugin := &tenant.Plugin{
ID: req.ID,
Name: req.Name,
Language: req.Language,
Source: req.Source,
Hooks: req.Hooks,
Enabled: req.Enabled,
Wasm: req.Wasm,
}
q := tenant.NewQueries(db)
// Check if exists
existing, _ := q.GetPlugin(r.Context(), req.ID)
if existing != nil {
err = q.UpdatePlugin(r.Context(), plugin)
} else {
// Check tier limit for new plugins
t, err := s.database.GetTenantByID(r.Context(), tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
tierInfo := config.GetTierInfo(t != nil && t.Premium)
count, err := q.CountPlugins(r.Context())
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
if count >= tierInfo.Config.MaxPlugins {
jsonError(w, http.StatusForbidden, fmt.Sprintf("plugin limit reached (%d max on %s tier)", tierInfo.Config.MaxPlugins, tierInfo.Config.Name))
return
}
err = q.CreatePlugin(r.Context(), plugin)
}
if err != nil {
jsonError(w, http.StatusInternalServerError, "failed to save plugin")
return
}
jsonResponse(w, http.StatusOK, map[string]any{"id": plugin.ID})
}
func (s *Server) deletePlugin(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
id := chi.URLParam(r, "id")
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
if err := q.DeletePlugin(r.Context(), id); err != nil {
jsonError(w, http.StatusInternalServerError, "failed to delete plugin")
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) togglePlugin(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
id := chi.URLParam(r, "id")
var req struct {
Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "invalid request")
return
}
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
if err := q.TogglePlugin(r.Context(), id, req.Enabled); err != nil {
jsonError(w, http.StatusInternalServerError, "failed to toggle plugin")
return
}
jsonResponse(w, http.StatusOK, map[string]bool{"enabled": req.Enabled})
}
// ═══════════════════════════════════════════════════════════════════════════
// Secrets
// ═══════════════════════════════════════════════════════════════════════════
func (s *Server) listSecrets(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
secrets, err := tenant.ListSecrets(db)
if err != nil {
jsonError(w, http.StatusInternalServerError, "failed to list secrets")
return
}
result := make([]map[string]any, len(secrets))
for i, s := range secrets {
result[i] = map[string]any{
"key": s.Key,
"created_at": s.CreatedAt.Format(time.RFC3339),
}
}
jsonResponse(w, http.StatusOK, result)
}
func (s *Server) createSecret(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
var req struct {
Key string `json:"key"`
Value string `json:"value"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "invalid request")
return
}
if req.Key == "" || req.Value == "" {
jsonError(w, http.StatusBadRequest, "key and value required")
return
}
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
if err := tenant.SetSecret(db, tenantID, req.Key, req.Value); err != nil {
jsonError(w, http.StatusInternalServerError, "failed to create secret")
return
}
jsonResponse(w, http.StatusCreated, map[string]string{"key": req.Key})
}
func (s *Server) deleteSecret(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
key := chi.URLParam(r, "key")
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
if err := tenant.DeleteSecret(db, key); err != nil {
jsonError(w, http.StatusInternalServerError, "failed to delete secret")
return
}
w.WriteHeader(http.StatusNoContent)
}
// ═══════════════════════════════════════════════════════════════════════════
// Jarvis Proxy (compile, hooks, template)
// ═══════════════════════════════════════════════════════════════════════════
func (s *Server) compilePlugin(w http.ResponseWriter, r *http.Request) {
resp, err := http.Post(s.jarvisURL+"/compile", "application/json", r.Body)
if err != nil {
jsonError(w, http.StatusBadGateway, "compiler unavailable")
return
}
defer resp.Body.Close()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
}
func (s *Server) getHooks(w http.ResponseWriter, r *http.Request) {
resp, err := http.Get(s.jarvisURL + "/hooks")
if err != nil {
jsonError(w, http.StatusBadGateway, "compiler unavailable")
return
}
defer resp.Body.Close()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
}
func (s *Server) getTemplate(w http.ResponseWriter, r *http.Request) {
url := s.jarvisURL + "/template?" + r.URL.RawQuery
resp, err := http.Get(url)
if err != nil {
jsonError(w, http.StatusBadGateway, "compiler unavailable")
return
}
defer resp.Body.Close()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
}
var wsUpgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
}
func (s *Server) getSDK(w http.ResponseWriter, r *http.Request) {
url := s.jarvisURL + "/sdk"
if r.URL.RawQuery != "" {
url += "?" + r.URL.RawQuery
}
resp, err := http.Get(url)
if err != nil {
jsonError(w, http.StatusBadGateway, "compiler unavailable")
return
}
defer resp.Body.Close()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
}
func (s *Server) testPlugin(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
var req struct {
Language string `json:"language"`
Source string `json:"source"`
Hook string `json:"hook"`
TestData map[string]any `json:"test_data"`
Secrets map[string]string `json:"secrets"` // Override secrets for testing
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "invalid request")
return
}
if req.Source == "" {
jsonError(w, http.StatusBadRequest, "source required")
return
}
if req.Hook == "" {
req.Hook = "post.published"
}
// Step 1: Compile the plugin via Jarvis
compileReq := map[string]string{
"language": req.Language,
"source": req.Source,
}
compileBody, _ := json.Marshal(compileReq)
resp, err := http.Post(s.jarvisURL+"/compile", "application/json", bytes.NewReader(compileBody))
if err != nil {
jsonError(w, http.StatusBadGateway, "compiler unavailable")
return
}
defer resp.Body.Close()
var compileResult struct {
Success bool `json:"success"`
Wasm []byte `json:"wasm"`
Errors []string `json:"errors"`
TimeMS int64 `json:"time_ms"`
}
if err := json.NewDecoder(resp.Body).Decode(&compileResult); err != nil {
jsonError(w, http.StatusInternalServerError, "invalid compile response")
return
}
if !compileResult.Success {
jsonResponse(w, http.StatusOK, map[string]any{
"success": false,
"phase": "compile",
"errors": compileResult.Errors,
"compile_ms": compileResult.TimeMS,
})
return
}
// Step 2: Get secrets (use overrides if provided, otherwise fetch real ones)
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
secrets := req.Secrets
if secrets == nil {
secrets, _ = tenant.GetSecretsMap(db, tenantID)
}
// Step 3: Run the plugin with test data
runner := tenant.NewTestPluginRunner(db, tenantID, secrets)
result := runner.RunTest(r.Context(), compileResult.Wasm, req.Hook, req.TestData)
jsonResponse(w, http.StatusOK, map[string]any{
"success": result.Success,
"phase": "execute",
"output": result.Output,
"logs": result.Logs,
"error": result.Error,
"compile_ms": compileResult.TimeMS,
"run_ms": result.Duration,
})
}
func (s *Server) proxyLSP(w http.ResponseWriter, r *http.Request) {
language := r.URL.Query().Get("language")
if language == "" {
language = "typescript"
}
clientConn, err := wsUpgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer clientConn.Close()
jarvisWS := strings.Replace(s.jarvisURL, "http://", "ws://", 1)
jarvisWS = strings.Replace(jarvisWS, "https://", "wss://", 1)
jarvisURL := jarvisWS + "/lsp?language=" + url.QueryEscape(language)
serverConn, _, err := websocket.DefaultDialer.Dial(jarvisURL, nil)
if err != nil {
clientConn.WriteMessage(websocket.TextMessage, []byte(`{"error": "LSP server unavailable"}`))
return
}
defer serverConn.Close()
done := make(chan struct{})
go func() {
defer close(done)
for {
msgType, msg, err := serverConn.ReadMessage()
if err != nil {
return
}
if err := clientConn.WriteMessage(msgType, msg); err != nil {
return
}
}
}()
go func() {
for {
msgType, msg, err := clientConn.ReadMessage()
if err != nil {
return
}
if err := serverConn.WriteMessage(msgType, msg); err != nil {
return
}
}
}()
<-done
}
// ═══════════════════════════════════════════════════════════════════════════
// Code Themes
// ═══════════════════════════════════════════════════════════════════════════
func (s *Server) codeThemeCSS(w http.ResponseWriter, r *http.Request) {
theme := r.URL.Query().Get("theme")
if theme == "" {
theme = "github"
}
css, err := markdown.GenerateHljsCSS(theme)
if err != nil {
http.Error(w, "invalid theme", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "text/css")
w.Header().Set("Cache-Control", "public, max-age=31536000")
w.Write([]byte(css))
}
func (s *Server) listCodeThemes(w http.ResponseWriter, r *http.Request) {
themes := markdown.ListThemes()
jsonResponse(w, http.StatusOK, themes)
}
func contains(slice []string, item string) bool {
for _, s := range slice {
if s == item {
return true
}
}
return false
}
// ═══════════════════════════════════════════════════════════════════════════
// Webhooks
// ═══════════════════════════════════════════════════════════════════════════
func (s *Server) listWebhooks(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
webhooks, err := q.ListWebhooks(r.Context())
if err != nil {
jsonError(w, http.StatusInternalServerError, "failed to list webhooks")
return
}
if webhooks == nil {
webhooks = []tenant.Webhook{}
}
jsonResponse(w, http.StatusOK, webhooks)
}
func (s *Server) createWebhook(w http.ResponseWriter, r *http.Request) {
if GetDemoInfo(r).IsDemo {
jsonError(w, http.StatusForbidden, "Webhook management is not available in demo mode")
return
}
tenantID := r.Context().Value(tenantIDKey).(string)
var req struct {
Name string `json:"name"`
URL string `json:"url"`
Events []string `json:"events"`
Secret string `json:"secret"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Name == "" || req.URL == "" || len(req.Events) == 0 {
jsonError(w, http.StatusBadRequest, "name, url, and events are required")
return
}
// Validate URL
if _, err := url.ParseRequestURI(req.URL); err != nil {
jsonError(w, http.StatusBadRequest, "invalid URL")
return
}
// Check tier limit
t, err := s.database.GetTenantByID(r.Context(), tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
tierInfo := config.GetTierInfo(t != nil && t.Premium)
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
count, err := q.CountWebhooks(r.Context())
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
if count >= tierInfo.Config.MaxWebhooks {
jsonError(w, http.StatusForbidden, fmt.Sprintf("webhook limit reached (%d max on %s tier)", tierInfo.Config.MaxWebhooks, tierInfo.Config.Name))
return
}
webhook, err := q.CreateWebhook(r.Context(), req.Name, req.URL, req.Events, req.Secret)
if err != nil {
jsonError(w, http.StatusInternalServerError, "failed to create webhook")
return
}
jsonResponse(w, http.StatusCreated, webhook)
}
func (s *Server) updateWebhook(w http.ResponseWriter, r *http.Request) {
if GetDemoInfo(r).IsDemo {
jsonError(w, http.StatusForbidden, "Webhook management is not available in demo mode")
return
}
tenantID := r.Context().Value(tenantIDKey).(string)
webhookID := chi.URLParam(r, "id")
var req struct {
Name string `json:"name"`
URL string `json:"url"`
Events []string `json:"events"`
Secret string `json:"secret"`
Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "invalid request body")
return
}
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
if err := q.UpdateWebhook(r.Context(), webhookID, req.Name, req.URL, req.Events, req.Secret, req.Enabled); err != nil {
jsonError(w, http.StatusInternalServerError, "failed to update webhook")
return
}
webhook, _ := q.GetWebhook(r.Context(), webhookID)
jsonResponse(w, http.StatusOK, webhook)
}
func (s *Server) deleteWebhook(w http.ResponseWriter, r *http.Request) {
if GetDemoInfo(r).IsDemo {
jsonError(w, http.StatusForbidden, "Webhook management is not available in demo mode")
return
}
tenantID := r.Context().Value(tenantIDKey).(string)
webhookID := chi.URLParam(r, "id")
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
if err := q.DeleteWebhook(r.Context(), webhookID); err != nil {
jsonError(w, http.StatusInternalServerError, "failed to delete webhook")
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) testWebhook(w http.ResponseWriter, r *http.Request) {
if GetDemoInfo(r).IsDemo {
jsonError(w, http.StatusForbidden, "Webhook testing is not available in demo mode")
return
}
tenantID := r.Context().Value(tenantIDKey).(string)
webhookID := chi.URLParam(r, "id")
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
webhook, err := q.GetWebhook(r.Context(), webhookID)
if err != nil || webhook == nil {
jsonError(w, http.StatusNotFound, "webhook not found")
return
}
// Trigger a test event
testData := map[string]any{
"post": map[string]any{
"id": "test-id",
"slug": "test-post",
"title": "Test Post",
"description": "This is a test webhook delivery",
"url": "https://example.com/posts/test-post",
"tags": []string{"test"},
},
}
q.TriggerWebhooks(r.Context(), "post.test", testData, "")
jsonResponse(w, http.StatusOK, map[string]string{"status": "triggered"})
}
func (s *Server) listWebhookDeliveries(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
webhookID := chi.URLParam(r, "id")
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
deliveries, err := q.ListWebhookDeliveries(r.Context(), webhookID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "failed to list deliveries")
return
}
if deliveries == nil {
deliveries = []tenant.WebhookDelivery{}
}
jsonResponse(w, http.StatusOK, deliveries)
}
func (s *Server) getBilling(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
t, err := s.database.GetTenantByID(r.Context(), tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
premium := false
if t != nil {
premium = t.Premium
}
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
webhookCount, _ := q.CountWebhooks(r.Context())
pluginCount, _ := q.CountPlugins(r.Context())
usage := config.Usage{
Webhooks: webhookCount,
Plugins: pluginCount,
}
jsonResponse(w, http.StatusOK, config.GetAllTiers(premium, usage))
}