init
This commit is contained in:
commit
d69342b2e9
160 changed files with 28681 additions and 0 deletions
634
internal/server/reader.go
Normal file
634
internal/server/reader.go
Normal file
|
|
@ -0,0 +1,634 @@
|
|||
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 ""
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue