2053 lines
54 KiB
Go
2053 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))
|
||
|
|
}
|