add auth
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/auth"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/validator"
|
||||
)
|
||||
|
||||
// AuthHandler exposes the auth-related HTTP endpoints.
|
||||
//
|
||||
// Every handler is a 5-line shim: parse → validate → service → render.
|
||||
// All branching (already exists? wrong password? expired link?) lives in
|
||||
// AuthService and surfaces here only as sentinel errors mapped to status
|
||||
// codes via mapAuthErr.
|
||||
type AuthHandler struct {
|
||||
svc *service.AuthService
|
||||
cookies auth.CookieOptions
|
||||
}
|
||||
|
||||
func NewAuthHandler(svc *service.AuthService, cookies auth.CookieOptions) *AuthHandler {
|
||||
return &AuthHandler{svc: svc, cookies: cookies}
|
||||
}
|
||||
|
||||
// --- request shapes ---
|
||||
|
||||
type signUpReq struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type logInReq struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type forgotReq struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type resetReq struct {
|
||||
Token string `json:"token"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
|
||||
type verifyReq struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// --- handlers ---
|
||||
|
||||
// SignUp creates a user, plants the session cookie, returns the user.
|
||||
func (h *AuthHandler) SignUp(w http.ResponseWriter, r *http.Request) {
|
||||
var req signUpReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
v := validator.New()
|
||||
validator.Required(v, "email", req.Email)
|
||||
validator.Email(v, "email", req.Email)
|
||||
validator.Required(v, "password", req.Password)
|
||||
validator.MinLength(v, "password", req.Password, 8)
|
||||
validator.Required(v, "name", req.Name)
|
||||
validator.MaxLength(v, "name", req.Name, 120)
|
||||
if !v.Valid() {
|
||||
response.Error(w, http.StatusBadRequest, v.Error())
|
||||
return
|
||||
}
|
||||
|
||||
res, err := h.svc.SignUp(r.Context(), req.Email, req.Password, req.Name)
|
||||
if err != nil {
|
||||
h.renderAuthErr(w, err)
|
||||
return
|
||||
}
|
||||
auth.Set(w, res.Token, res.Expires, h.cookies)
|
||||
response.JSON(w, http.StatusCreated, res.User)
|
||||
}
|
||||
|
||||
// LogIn authenticates an existing user.
|
||||
func (h *AuthHandler) LogIn(w http.ResponseWriter, r *http.Request) {
|
||||
var req logInReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
v := validator.New()
|
||||
validator.Required(v, "email", req.Email)
|
||||
validator.Required(v, "password", req.Password)
|
||||
if !v.Valid() {
|
||||
response.Error(w, http.StatusBadRequest, v.Error())
|
||||
return
|
||||
}
|
||||
|
||||
res, err := h.svc.LogIn(r.Context(), req.Email, req.Password)
|
||||
if err != nil {
|
||||
h.renderAuthErr(w, err)
|
||||
return
|
||||
}
|
||||
auth.Set(w, res.Token, res.Expires, h.cookies)
|
||||
response.JSON(w, http.StatusOK, res.User)
|
||||
}
|
||||
|
||||
// LogOut just clears the cookie. JWTs remain technically valid until expiry,
|
||||
// but with the cookie gone the browser can't present them.
|
||||
func (h *AuthHandler) LogOut(w http.ResponseWriter, r *http.Request) {
|
||||
auth.Clear(w, h.cookies)
|
||||
response.JSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// Me returns the authenticated user. Protected by middleware.RequireAuth.
|
||||
func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
tv := middleware.TokenVersion(r.Context())
|
||||
u, err := h.svc.Me(r.Context(), uid, tv)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, u)
|
||||
}
|
||||
|
||||
// ForgotPassword emails a reset link if the address exists. We always return
|
||||
// 200 to avoid revealing whether the email is registered.
|
||||
func (h *AuthHandler) ForgotPassword(w http.ResponseWriter, r *http.Request) {
|
||||
var req forgotReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
v := validator.New()
|
||||
validator.Required(v, "email", req.Email)
|
||||
validator.Email(v, "email", req.Email)
|
||||
if !v.Valid() {
|
||||
response.Error(w, http.StatusBadRequest, v.Error())
|
||||
return
|
||||
}
|
||||
if err := h.svc.RequestPasswordReset(r.Context(), req.Email); err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not process request")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// ResetPassword consumes a reset token and sets a new password.
|
||||
func (h *AuthHandler) ResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
var req resetReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
v := validator.New()
|
||||
validator.Required(v, "token", req.Token)
|
||||
validator.Required(v, "new_password", req.NewPassword)
|
||||
validator.MinLength(v, "new_password", req.NewPassword, 8)
|
||||
if !v.Valid() {
|
||||
response.Error(w, http.StatusBadRequest, v.Error())
|
||||
return
|
||||
}
|
||||
if err := h.svc.ResetPassword(r.Context(), req.Token, req.NewPassword); err != nil {
|
||||
h.renderAuthErr(w, err)
|
||||
return
|
||||
}
|
||||
// Force the user to log in again after a reset (their token_version is now stale).
|
||||
auth.Clear(w, h.cookies)
|
||||
response.JSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// VerifyEmail flips email_verified_at after the user clicks the link.
|
||||
func (h *AuthHandler) VerifyEmail(w http.ResponseWriter, r *http.Request) {
|
||||
var req verifyReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
if req.Token == "" {
|
||||
response.Error(w, http.StatusBadRequest, "token is required")
|
||||
return
|
||||
}
|
||||
if err := h.svc.VerifyEmail(r.Context(), req.Token); err != nil {
|
||||
h.renderAuthErr(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// ResendVerification — protected — refires the verification email for the
|
||||
// currently-logged-in user.
|
||||
func (h *AuthHandler) ResendVerification(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
if err := h.svc.ResendVerification(r.Context(), uid); err != nil {
|
||||
h.renderAuthErr(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// --- error mapping ---
|
||||
|
||||
func (h *AuthHandler) renderAuthErr(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrInvalidCredentials),
|
||||
errors.Is(err, service.ErrInvalidOrExpiredLink):
|
||||
response.Error(w, http.StatusUnauthorized, err.Error())
|
||||
case errors.Is(err, service.ErrEmailAlreadyTaken):
|
||||
response.Error(w, http.StatusConflict, err.Error())
|
||||
case errors.Is(err, service.ErrEmailAlreadyVerified),
|
||||
errors.Is(err, service.ErrWeakPassword):
|
||||
response.Error(w, http.StatusBadRequest, err.Error())
|
||||
case errors.Is(err, service.ErrUserNotFound):
|
||||
response.Error(w, http.StatusNotFound, err.Error())
|
||||
default:
|
||||
response.Error(w, http.StatusInternalServerError, "something went wrong")
|
||||
}
|
||||
}
|
||||
+44
-10
@@ -147,6 +147,12 @@ func (h *SearchHandler) Search(w http.ResponseWriter, r *http.Request) {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
// `exact=1` (or "true") disables typo tolerance + prefix expansion so the
|
||||
// query only matches literal keywords. Stopword filtering still applies —
|
||||
// "letter to mukunda" with exact=1 still drops "letter"/"to" so the search
|
||||
// is effectively the literal word "mukunda" with no fuzzy variants.
|
||||
exact := parseBoolParam(r.URL.Query().Get("exact"))
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
var results []searchResultItem
|
||||
@@ -154,13 +160,13 @@ func (h *SearchHandler) Search(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if searchType == "all" {
|
||||
// Search all 9 collections
|
||||
results, totalFound = h.multiSearch(ctx, nil, query, page, limit)
|
||||
results, totalFound = h.multiSearch(ctx, nil, query, page, limit, exact)
|
||||
} else if len(requestedTypes) == 1 {
|
||||
// Single collection — use singleSearch for efficiency
|
||||
results, totalFound = h.singleSearch(ctx, requestedTypes[0], query, page, limit)
|
||||
results, totalFound = h.singleSearch(ctx, requestedTypes[0], query, page, limit, exact)
|
||||
} else {
|
||||
// Multiple collections — use multiSearch with a subset
|
||||
results, totalFound = h.multiSearch(ctx, requestedTypes, query, page, limit)
|
||||
results, totalFound = h.multiSearch(ctx, requestedTypes, query, page, limit, exact)
|
||||
}
|
||||
|
||||
totalPages := int(math.Ceil(float64(totalFound) / float64(limit)))
|
||||
@@ -181,8 +187,10 @@ func (h *SearchHandler) Search(w http.ResponseWriter, r *http.Request) {
|
||||
// multiSearch searches across all 9 collections using Typesense multi_search,
|
||||
// merges results by text_match score, and returns a single paginated list.
|
||||
// multiSearch searches across multiple collections using Typesense multi_search.
|
||||
// If collections is nil, searches all 9 collections.
|
||||
func (h *SearchHandler) multiSearch(ctx context.Context, collections []string, query string, page, limit int) ([]searchResultItem, int) {
|
||||
// If collections is nil, searches all 9 collections. When `exact` is true,
|
||||
// typo tolerance and prefix expansion are turned off so only literal-keyword
|
||||
// matches come back.
|
||||
func (h *SearchHandler) multiSearch(ctx context.Context, collections []string, query string, page, limit int, exact bool) ([]searchResultItem, int) {
|
||||
if collections == nil {
|
||||
collections = []string{
|
||||
"verses", "prose", "letters", "transcriptions", "glossary",
|
||||
@@ -193,6 +201,7 @@ func (h *SearchHandler) multiSearch(ctx context.Context, collections []string, q
|
||||
exhaustive := true
|
||||
prioritizeExact := true
|
||||
prioritizeTokenPos := true
|
||||
prefix, numTypos := fuzzinessKnobs(exact)
|
||||
searches := make([]api.MultiSearchCollectionParameters, 0, len(collections))
|
||||
for _, col := range collections {
|
||||
queryBy := collectionQueryByFields[col]
|
||||
@@ -205,8 +214,8 @@ func (h *SearchHandler) multiSearch(ctx context.Context, collections []string, q
|
||||
QueryBy: stringPtr(queryBy),
|
||||
PerPage: &perPage,
|
||||
Page: &pg,
|
||||
Prefix: stringPtr("true"),
|
||||
NumTypos: stringPtr("2"),
|
||||
Prefix: stringPtr(prefix),
|
||||
NumTypos: stringPtr(numTypos),
|
||||
ExhaustiveSearch: &exhaustive,
|
||||
Stopwords: stringPtr(searchStopwordsSet),
|
||||
// Boost docs whose tokens are exact matches and appear early in the field
|
||||
@@ -264,7 +273,7 @@ func (h *SearchHandler) multiSearch(ctx context.Context, collections []string, q
|
||||
}
|
||||
|
||||
// singleSearch searches a single collection.
|
||||
func (h *SearchHandler) singleSearch(ctx context.Context, collection, query string, page, limit int) ([]searchResultItem, int) {
|
||||
func (h *SearchHandler) singleSearch(ctx context.Context, collection, query string, page, limit int, exact bool) ([]searchResultItem, int) {
|
||||
queryBy, ok := collectionQueryByFields[collection]
|
||||
if !ok {
|
||||
return []searchResultItem{}, 0
|
||||
@@ -273,13 +282,14 @@ func (h *SearchHandler) singleSearch(ctx context.Context, collection, query stri
|
||||
exhaustive := true
|
||||
prioritizeExact := true
|
||||
prioritizeTokenPos := true
|
||||
prefix, numTypos := fuzzinessKnobs(exact)
|
||||
params := &api.SearchCollectionParams{
|
||||
Q: stringPtr(query),
|
||||
QueryBy: stringPtr(queryBy),
|
||||
Page: &page,
|
||||
PerPage: &limit,
|
||||
Prefix: stringPtr("true"),
|
||||
NumTypos: stringPtr("2"),
|
||||
Prefix: stringPtr(prefix),
|
||||
NumTypos: stringPtr(numTypos),
|
||||
ExhaustiveSearch: &exhaustive,
|
||||
Stopwords: stringPtr(searchStopwordsSet),
|
||||
// Boost docs whose tokens are exact matches and appear early in the field
|
||||
@@ -534,3 +544,27 @@ func parseIntParam(s string, defaultVal int) int {
|
||||
func stringPtr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
// parseBoolParam returns true for "1", "true", "yes", "on" (case-insensitive).
|
||||
// Everything else (including the empty string) is false.
|
||||
func parseBoolParam(s string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// fuzzinessKnobs maps the high-level `exact` flag to the two Typesense params
|
||||
// that actually disable fuzzy matching:
|
||||
//
|
||||
// exact=false → prefix=true, num_typos=2 (default forgiving search)
|
||||
// exact=true → prefix=false, num_typos=0 (literal-keyword only)
|
||||
//
|
||||
// Stopword filtering is left unchanged in both modes.
|
||||
func fuzzinessKnobs(exact bool) (prefix, numTypos string) {
|
||||
if exact {
|
||||
return "false", "0"
|
||||
}
|
||||
return "true", "2"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||
)
|
||||
|
||||
// UserHandler exposes profile-edit + avatar-presign endpoints.
|
||||
// All routes here sit behind middleware.RequireAuth.
|
||||
type UserHandler struct {
|
||||
svc *service.UserService
|
||||
}
|
||||
|
||||
func NewUserHandler(svc *service.UserService) *UserHandler {
|
||||
return &UserHandler{svc: svc}
|
||||
}
|
||||
|
||||
// updateProfileReq accepts partial updates. Use pointers so the JSON decoder
|
||||
// can tell "client explicitly sent null" from "field not present" — the
|
||||
// service treats nil-name as "leave alone" and ""-name as an error.
|
||||
type updateProfileReq struct {
|
||||
Name *string `json:"name"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
}
|
||||
|
||||
// UpdateProfile patches name and/or avatar.
|
||||
func (h *UserHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
|
||||
var req updateProfileReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
|
||||
u, err := h.svc.UpdateProfile(r.Context(), uid, req.Name, req.AvatarURL)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, u)
|
||||
}
|
||||
|
||||
type presignReq struct {
|
||||
ContentType string `json:"content_type"`
|
||||
}
|
||||
|
||||
type presignResp struct {
|
||||
UploadURL string `json:"upload_url"`
|
||||
PublicURL string `json:"public_url"`
|
||||
}
|
||||
|
||||
// AvatarPresign returns a one-shot R2 PUT URL the browser uses to upload.
|
||||
// The frontend then PATCHes /users/me with the returned public_url to
|
||||
// persist the new avatar on the user row.
|
||||
func (h *UserHandler) AvatarPresign(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
|
||||
var req presignReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
ct := strings.TrimSpace(req.ContentType)
|
||||
if ct == "" {
|
||||
response.Error(w, http.StatusBadRequest, "content_type is required")
|
||||
return
|
||||
}
|
||||
|
||||
upload, public, err := h.svc.AvatarPresign(r.Context(), uid, ct)
|
||||
if err != nil {
|
||||
// Differentiate "feature off" from "bad content type" so the frontend
|
||||
// can show a useful message.
|
||||
if strings.Contains(err.Error(), "not configured") {
|
||||
response.Error(w, http.StatusServiceUnavailable, err.Error())
|
||||
return
|
||||
}
|
||||
response.Error(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, presignResp{UploadURL: upload, PublicURL: public})
|
||||
}
|
||||
Reference in New Issue
Block a user