writekit/internal/server/reader.go

635 lines
15 KiB
Go
Raw Permalink Normal View History

2026-01-09 00:16:46 +02:00
package server
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/writekitapp/writekit/internal/tenant"
)
func (s *Server) readerRoutes() chi.Router {
r := chi.NewRouter()
r.Get("/login/{provider}", s.readerLogin)
r.Get("/auth/callback", s.readerAuthCallback)
r.Get("/auth/providers", s.readerAuthProviders)
r.Group(func(r chi.Router) {
r.Use(s.readerAuthMiddleware)
r.Get("/config", s.getInteractionConfig)
r.Get("/posts/{slug}/comments", s.listComments)
r.Post("/posts/{slug}/comments", s.createComment)
r.Delete("/comments/{id}", s.deleteComment)
r.Get("/posts/{slug}/reactions", s.getReactions)
r.Post("/posts/{slug}/reactions", s.toggleReaction)
r.Get("/me", s.getReaderMe)
r.Post("/logout", s.readerLogout)
r.Get("/search", s.search)
})
return r
}
func (s *Server) getInteractionConfig(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) listComments(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)
comments, err := q.ListComments(r.Context(), slug)
if err != nil {
jsonError(w, http.StatusInternalServerError, "failed to list comments")
return
}
jsonResponse(w, http.StatusOK, comments)
}
func (s *Server) createComment(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)
if q.GetSettingWithDefault(r.Context(), "comments_enabled") != "true" {
jsonError(w, http.StatusForbidden, "comments disabled")
return
}
var req struct {
Content string `json:"content"`
ParentID *int64 `json:"parent_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "invalid request")
return
}
if req.Content == "" {
jsonError(w, http.StatusBadRequest, "content required")
return
}
userID := getReaderUserID(r)
if userID == "" {
jsonError(w, http.StatusUnauthorized, "login required")
return
}
// Get user info for validation
user, _ := q.GetUserByID(r.Context(), userID)
authorName := "Anonymous"
authorEmail := ""
if user != nil {
authorName = user.Name
authorEmail = user.Email
}
// Validate comment via plugins
runner := s.getPluginRunner(tenantID, db)
allowed, reason, _ := runner.TriggerValidation(r.Context(), "comment.validate", map[string]any{
"content": req.Content,
"authorName": authorName,
"authorEmail": authorEmail,
"postSlug": slug,
})
runner.Close()
if !allowed {
msg := "Comment rejected"
if reason != "" {
msg = reason
}
jsonError(w, http.StatusForbidden, msg)
return
}
comment := &tenant.Comment{
UserID: userID,
PostSlug: slug,
Content: req.Content,
ParentID: req.ParentID,
}
if err := q.CreateComment(r.Context(), comment); err != nil {
jsonError(w, http.StatusInternalServerError, "failed to create comment")
return
}
// Trigger comment.created hook
go func() {
runner := s.getPluginRunner(tenantID, db)
defer runner.Close()
// Get post info
post, _ := q.GetPost(r.Context(), slug)
postData := map[string]any{"slug": slug, "title": slug, "url": "/" + slug}
if post != nil {
postData["title"] = post.Title
}
runner.TriggerHook(r.Context(), "comment.created", map[string]any{
"comment": map[string]any{
"id": comment.ID,
"content": comment.Content,
"authorName": authorName,
"authorEmail": authorEmail,
"postSlug": slug,
"createdAt": comment.CreatedAt.Format(time.RFC3339),
},
"post": postData,
})
}()
jsonResponse(w, http.StatusCreated, comment)
}
func (s *Server) deleteComment(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
id := chi.URLParam(r, "id")
userID := getReaderUserID(r)
if userID == "" {
jsonError(w, http.StatusUnauthorized, "login required")
return
}
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
var commentID int64
if _, err := json.Number(id).Int64(); err != nil {
jsonError(w, http.StatusBadRequest, "invalid comment id")
return
}
commentID, _ = json.Number(id).Int64()
comment, err := q.GetComment(r.Context(), commentID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "failed to get comment")
return
}
if comment == nil {
jsonError(w, http.StatusNotFound, "comment not found")
return
}
if comment.UserID != userID {
jsonError(w, http.StatusForbidden, "not your comment")
return
}
if err := q.DeleteComment(r.Context(), commentID); err != nil {
jsonError(w, http.StatusInternalServerError, "failed to delete comment")
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) getReactions(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)
counts, err := q.GetReactionCounts(r.Context(), slug)
if err != nil {
jsonError(w, http.StatusInternalServerError, "failed to get reactions")
return
}
var userReactions []string
userID := getReaderUserID(r)
if userID != "" {
userReactions, _ = q.GetUserReactions(r.Context(), userID, slug)
} else if anonID := getAnonID(r); anonID != "" {
userReactions, _ = q.GetAnonReactions(r.Context(), anonID, slug)
}
jsonResponse(w, http.StatusOK, map[string]any{
"counts": counts,
"user": userReactions,
})
}
func (s *Server) toggleReaction(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)
if q.GetSettingWithDefault(r.Context(), "reactions_enabled") != "true" {
jsonError(w, http.StatusForbidden, "reactions disabled")
return
}
var req struct {
Emoji string `json:"emoji"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "invalid request")
return
}
if req.Emoji == "" {
jsonError(w, http.StatusBadRequest, "emoji required")
return
}
userID := getReaderUserID(r)
anonID := ""
requireAuth := q.GetSettingWithDefault(r.Context(), "reactions_require_auth") == "true"
if userID == "" {
if requireAuth {
jsonError(w, http.StatusUnauthorized, "login required")
return
}
anonID = getOrCreateAnonID(w, r)
}
added, err := q.ToggleReaction(r.Context(), userID, anonID, slug, req.Emoji)
if err != nil {
jsonError(w, http.StatusInternalServerError, "failed to toggle reaction")
return
}
jsonResponse(w, http.StatusOK, map[string]bool{"added": added})
}
func (s *Server) search(w http.ResponseWriter, r *http.Request) {
tenantID := r.Context().Value(tenantIDKey).(string)
query := r.URL.Query().Get("q")
if query == "" {
jsonResponse(w, http.StatusOK, []any{})
return
}
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
results, err := q.Search(r.Context(), query, 20)
if err != nil {
jsonResponse(w, http.StatusOK, []any{})
return
}
jsonResponse(w, http.StatusOK, results)
}
func (s *Server) getReaderMe(w http.ResponseWriter, r *http.Request) {
userID := getReaderUserID(r)
if userID == "" {
jsonResponse(w, http.StatusOK, map[string]any{"logged_in": false})
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)
user, err := q.GetUserByID(r.Context(), userID)
if err != nil || user == nil {
jsonResponse(w, http.StatusOK, map[string]any{"logged_in": false})
return
}
jsonResponse(w, http.StatusOK, map[string]any{
"logged_in": true,
"user": map[string]any{
"id": user.ID,
"email": user.Email,
"name": user.Name,
"avatar_url": user.AvatarURL,
},
})
}
func (s *Server) readerLogout(w http.ResponseWriter, r *http.Request) {
token := extractReaderToken(r)
if token == "" {
w.WriteHeader(http.StatusNoContent)
return
}
tenantID := r.Context().Value(tenantIDKey).(string)
db, err := s.tenantPool.Get(tenantID)
if err == nil {
q := tenant.NewQueries(db)
q.DeleteSession(r.Context(), token)
}
http.SetCookie(w, &http.Cookie{
Name: "reader_session",
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
w.WriteHeader(http.StatusNoContent)
}
func getReaderUserID(r *http.Request) string {
if id, ok := r.Context().Value(readerUserIDKey).(string); ok {
return id
}
return ""
}
type readerCtxKey string
const readerUserIDKey readerCtxKey = "readerUserID"
func (s *Server) readerAuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := extractReaderToken(r)
if token == "" {
next.ServeHTTP(w, r)
return
}
tenantID, ok := r.Context().Value(tenantIDKey).(string)
if !ok || tenantID == "" {
next.ServeHTTP(w, r)
return
}
db, err := s.tenantPool.Get(tenantID)
if err != nil {
next.ServeHTTP(w, r)
return
}
q := tenant.NewQueries(db)
session, err := q.ValidateSession(r.Context(), token)
if err != nil || session == nil {
next.ServeHTTP(w, r)
return
}
ctx := context.WithValue(r.Context(), readerUserIDKey, session.UserID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func extractReaderToken(r *http.Request) string {
if cookie, err := r.Cookie("reader_session"); err == nil && cookie.Value != "" {
return cookie.Value
}
auth := r.Header.Get("Authorization")
if strings.HasPrefix(auth, "Bearer ") {
return strings.TrimPrefix(auth, "Bearer ")
}
return ""
}
func (s *Server) readerLogin(w http.ResponseWriter, r *http.Request) {
provider := chi.URLParam(r, "provider")
if provider != "google" && provider != "github" && provider != "discord" {
jsonError(w, http.StatusBadRequest, "invalid provider")
return
}
tenantID := r.Context().Value(tenantIDKey).(string)
redirect := r.URL.Query().Get("redirect")
baseURL := os.Getenv("BASE_URL")
if baseURL == "" {
baseURL = "https://writekit.dev"
}
tenantInfo, err := s.database.GetTenantByID(r.Context(), tenantID)
if err != nil || tenantInfo == nil {
jsonError(w, http.StatusNotFound, "tenant not found")
return
}
domain := tenantInfo.CustomDomain
if domain == "" {
domain = tenantInfo.Subdomain + "." + s.domain
}
callbackURL := fmt.Sprintf("https://%s/api/reader/auth/callback", domain)
if redirect != "" {
callbackURL += "?redirect=" + redirect
}
authURL := fmt.Sprintf("%s/auth/%s?tenant=%s&callback=%s",
baseURL, provider, tenantID, callbackURL)
http.Redirect(w, r, authURL, http.StatusTemporaryRedirect)
}
func (s *Server) readerAuthCallback(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
redirect := r.URL.Query().Get("redirect")
if token == "" {
jsonError(w, http.StatusBadRequest, "missing token")
return
}
tenantID := r.Context().Value(tenantIDKey).(string)
baseURL := os.Getenv("BASE_URL")
if baseURL == "" {
baseURL = "https://writekit.dev"
}
userURL := baseURL + "/auth/user?token=" + token
resp, err := http.Get(userURL)
if err != nil {
jsonError(w, http.StatusInternalServerError, "auth failed")
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
jsonError(w, http.StatusUnauthorized, "invalid token: "+string(body))
return
}
var platformUser struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
AvatarURL string `json:"avatar_url"`
}
if err := json.NewDecoder(resp.Body).Decode(&platformUser); err != nil {
jsonError(w, http.StatusInternalServerError, "failed to parse user")
return
}
db, err := s.tenantPool.Get(tenantID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
q := tenant.NewQueries(db)
user, err := q.GetUserByID(r.Context(), platformUser.ID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "database error")
return
}
if user == nil {
user = &tenant.User{
ID: platformUser.ID,
Email: platformUser.Email,
Name: platformUser.Name,
AvatarURL: platformUser.AvatarURL,
}
if err := q.CreateUser(r.Context(), user); err != nil {
jsonError(w, http.StatusInternalServerError, "failed to create user")
return
}
}
session, err := q.CreateSession(r.Context(), user.ID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "failed to create session")
return
}
tenantInfo, _ := s.database.GetTenantByID(r.Context(), tenantID)
secure := tenantInfo != nil && !strings.HasPrefix(tenantInfo.Subdomain, "localhost")
http.SetCookie(w, &http.Cookie{
Name: "reader_session",
Value: session.Token,
Path: "/",
Expires: session.ExpiresAt,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
if redirect == "" || !strings.HasPrefix(redirect, "/") || strings.HasPrefix(redirect, "//") {
redirect = "/"
}
http.Redirect(w, r, redirect, http.StatusTemporaryRedirect)
}
func (s *Server) readerAuthProviders(w http.ResponseWriter, r *http.Request) {
baseURL := os.Getenv("BASE_URL")
if baseURL == "" {
baseURL = "https://writekit.dev"
}
resp, err := http.Get(baseURL + "/auth/providers")
if err != nil {
jsonResponse(w, http.StatusOK, map[string]any{"providers": []any{}})
return
}
defer resp.Body.Close()
var result map[string]any
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
jsonResponse(w, http.StatusOK, map[string]any{"providers": []any{}})
return
}
jsonResponse(w, http.StatusOK, result)
}
func getOrCreateAnonID(w http.ResponseWriter, r *http.Request) string {
if cookie, err := r.Cookie("anon_id"); err == nil && cookie.Value != "" {
return cookie.Value
}
b := make([]byte, 16)
rand.Read(b)
anonID := hex.EncodeToString(b)
http.SetCookie(w, &http.Cookie{
Name: "anon_id",
Value: anonID,
Path: "/",
MaxAge: 365 * 24 * 60 * 60,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
return anonID
}
func getAnonID(r *http.Request) string {
if cookie, err := r.Cookie("anon_id"); err == nil {
return cookie.Value
}
return ""
}