634 lines
15 KiB
Go
634 lines
15 KiB
Go
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 ""
|
|
}
|