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)) }