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