add auth
This commit is contained in:
+56
-19
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
@@ -28,33 +29,46 @@ type Config struct {
|
||||
// DragonflyDB / Redis
|
||||
RedisURI string
|
||||
|
||||
// AWS S3
|
||||
S3Region string
|
||||
S3Bucket string
|
||||
S3AccessKey string
|
||||
S3SecretKey string
|
||||
S3Endpoint string // optional — for S3-compatible services (MinIO, etc.)
|
||||
// Cloudflare R2 (S3-compatible) — for avatar uploads via presigned URLs.
|
||||
R2AccountID string
|
||||
R2AccessKeyID string
|
||||
R2SecretAccessKey string
|
||||
R2Bucket string
|
||||
R2PublicURL string // Public base URL for objects, e.g. https://cdn.example.com or the R2 public bucket URL
|
||||
|
||||
// Push Notifications
|
||||
FCMCredentialsFile string // path to Firebase service account JSON
|
||||
APNsKeyFile string // path to APNs auth key
|
||||
// SMTP — used for password-reset and email-verification mails.
|
||||
SMTPHost string
|
||||
SMTPPort int
|
||||
SMTPUsername string
|
||||
SMTPPassword string
|
||||
SMTPFrom string // "Prabhupāda Says <noreply@prabhupadasays.org>"
|
||||
|
||||
// Push Notifications (existing — unchanged).
|
||||
FCMCredentialsFile string
|
||||
APNsKeyFile string
|
||||
APNsKeyID string
|
||||
APNsTeamID string
|
||||
APNsBundleID string
|
||||
|
||||
// JWT Auth
|
||||
JWTSecret string
|
||||
JWTExpiration string // e.g., "24h"
|
||||
JWTExpiration time.Duration // parsed at load time
|
||||
CookieDomain string // empty in dev; ".prabhupadasays.org" in prod
|
||||
CookieSecure bool // true in production
|
||||
|
||||
// Frontend
|
||||
FrontendURL string // for CORS
|
||||
FrontendURL string // for CORS + reset/verify links in emails
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables (with .env fallback).
|
||||
func Load() (*Config, error) {
|
||||
// Load .env file if present (ignored in production)
|
||||
_ = godotenv.Load()
|
||||
|
||||
jwtExp, err := time.ParseDuration(getEnv("JWT_EXPIRATION", "168h"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid JWT_EXPIRATION: %w", err)
|
||||
}
|
||||
|
||||
cfg := &Config{
|
||||
Env: getEnv("APP_ENV", "development"),
|
||||
Port: getEnvInt("PORT", 8080),
|
||||
@@ -69,11 +83,17 @@ func Load() (*Config, error) {
|
||||
|
||||
RedisURI: getEnv("REDIS_URI", "redis://localhost:6379"),
|
||||
|
||||
S3Region: getEnv("S3_REGION", ""),
|
||||
S3Bucket: getEnv("S3_BUCKET", ""),
|
||||
S3AccessKey: getEnv("S3_ACCESS_KEY", ""),
|
||||
S3SecretKey: getEnv("S3_SECRET_KEY", ""),
|
||||
S3Endpoint: getEnv("S3_ENDPOINT", ""),
|
||||
R2AccountID: getEnv("R2_ACCOUNT_ID", ""),
|
||||
R2AccessKeyID: getEnv("R2_ACCESS_KEY_ID", ""),
|
||||
R2SecretAccessKey: getEnv("R2_SECRET_ACCESS_KEY", ""),
|
||||
R2Bucket: getEnv("R2_BUCKET", ""),
|
||||
R2PublicURL: getEnv("R2_PUBLIC_URL", ""),
|
||||
|
||||
SMTPHost: getEnv("SMTP_HOST", ""),
|
||||
SMTPPort: getEnvInt("SMTP_PORT", 587),
|
||||
SMTPUsername: getEnv("SMTP_USERNAME", ""),
|
||||
SMTPPassword: getEnv("SMTP_PASSWORD", ""),
|
||||
SMTPFrom: getEnv("SMTP_FROM", "noreply@localhost"),
|
||||
|
||||
FCMCredentialsFile: getEnv("FCM_CREDENTIALS_FILE", ""),
|
||||
APNsKeyFile: getEnv("APNS_KEY_FILE", ""),
|
||||
@@ -82,15 +102,23 @@ func Load() (*Config, error) {
|
||||
APNsBundleID: getEnv("APNS_BUNDLE_ID", ""),
|
||||
|
||||
JWTSecret: getEnv("JWT_SECRET", ""),
|
||||
JWTExpiration: getEnv("JWT_EXPIRATION", "24h"),
|
||||
JWTExpiration: jwtExp,
|
||||
CookieDomain: getEnv("COOKIE_DOMAIN", ""),
|
||||
CookieSecure: getEnvBool("COOKIE_SECURE", false),
|
||||
|
||||
FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if cfg.MongoURI == "" {
|
||||
return nil, fmt.Errorf("MONGO_URI is required")
|
||||
}
|
||||
if cfg.Env == "production" && cfg.JWTSecret == "" {
|
||||
return nil, fmt.Errorf("JWT_SECRET is required in production")
|
||||
}
|
||||
if cfg.JWTSecret == "" {
|
||||
// Dev fallback so the server can boot without secrets configured.
|
||||
cfg.JWTSecret = "dev-only-jwt-secret-do-not-use-in-production"
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
@@ -110,3 +138,12 @@ func getEnvInt(key string, fallback int) int {
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func getEnvBool(key string, fallback bool) bool {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if b, err := strconv.ParseBool(v); err == nil {
|
||||
return b
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
+48
-26
@@ -2,56 +2,78 @@ package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/auth"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
type ctxKey string
|
||||
|
||||
const UserIDKey contextKey = "user_id"
|
||||
const (
|
||||
userIDKey ctxKey = "user_id"
|
||||
tokenVersionKey ctxKey = "tv"
|
||||
)
|
||||
|
||||
// Auth is a placeholder middleware for JWT authentication.
|
||||
// It extracts the Bearer token from the Authorization header,
|
||||
// validates it, and sets the user ID in the request context.
|
||||
func Auth(jwtSecret string) func(next http.Handler) http.Handler {
|
||||
// RequireAuth is the middleware applied to every protected route.
|
||||
//
|
||||
// It pulls the JWT from the cookie, verifies signature + expiry, then asks
|
||||
// the UserRepo for the current token_version and rejects if the JWT carries
|
||||
// a stale version (password-change or forced logout invalidates older JWTs).
|
||||
//
|
||||
// On success it stuffs (user_id, token_version) into the request context;
|
||||
// handlers read them via UserID() / TokenVersion().
|
||||
func RequireAuth(issuer *auth.Issuer, users *repository.UserRepo) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
response.Error(w, http.StatusUnauthorized, "missing authorization header")
|
||||
raw := auth.Read(r)
|
||||
if raw == "" {
|
||||
response.Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
|
||||
response.Error(w, http.StatusUnauthorized, "invalid authorization format")
|
||||
claims, err := issuer.Parse(raw)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusUnauthorized, "invalid or expired session")
|
||||
return
|
||||
}
|
||||
|
||||
token := parts[1]
|
||||
|
||||
// TODO: validate JWT token and extract user ID
|
||||
_ = token
|
||||
_ = jwtSecret
|
||||
userID := "" // placeholder
|
||||
|
||||
if userID == "" {
|
||||
response.Error(w, http.StatusUnauthorized, "invalid or expired token")
|
||||
u, err := users.FindByID(r.Context(), claims.UserID)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrUserNotFound) {
|
||||
response.Error(w, http.StatusUnauthorized, "account no longer exists")
|
||||
return
|
||||
}
|
||||
response.Error(w, http.StatusInternalServerError, "auth check failed")
|
||||
return
|
||||
}
|
||||
if u.TokenVersion != claims.TokenVersion {
|
||||
response.Error(w, http.StatusUnauthorized, "session expired — please log in again")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), UserIDKey, userID)
|
||||
ctx := context.WithValue(r.Context(), userIDKey, claims.UserID)
|
||||
ctx = context.WithValue(ctx, tokenVersionKey, claims.TokenVersion)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// GetUserID extracts the user ID from the request context.
|
||||
func GetUserID(ctx context.Context) string {
|
||||
if v, ok := ctx.Value(UserIDKey).(string); ok {
|
||||
// UserID returns the authenticated user's id from the request context.
|
||||
// Returns "" if RequireAuth has not run.
|
||||
func UserID(ctx context.Context) string {
|
||||
if v, ok := ctx.Value(userIDKey).(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// TokenVersion returns the JWT's token_version claim from context.
|
||||
func TokenVersion(ctx context.Context) int {
|
||||
if v, ok := ctx.Value(tokenVersionKey).(int); ok {
|
||||
return v
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
+37
-10
@@ -2,24 +2,51 @@ package model
|
||||
|
||||
import "time"
|
||||
|
||||
// User represents a user in PostgreSQL.
|
||||
// User represents a row in the `users` table.
|
||||
//
|
||||
// PasswordHash is tagged `json:"-"` so it never leaks via JSON marshalling
|
||||
// even if a caller accidentally passes a User through response.JSON.
|
||||
//
|
||||
// EmailVerifiedAt is a pointer so JSON-encoded users carry an explicit `null`
|
||||
// when the user has not yet verified, instead of an omitted field — the
|
||||
// frontend uses this to decide whether to show the "verify your email" ribbon.
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Avatar *string `json:"avatar,omitempty"`
|
||||
Role string `json:"role"` // "user" | "admin" | "moderator"
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
Role string `json:"role"`
|
||||
EmailVerifiedAt *time.Time `json:"email_verified_at"`
|
||||
PasswordHash string `json:"-"`
|
||||
TokenVersion int `json:"-"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// AuthToken represents a row in the `auth_tokens` table.
|
||||
// Used for both password reset and email verification flows.
|
||||
type AuthToken struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Purpose string `json:"purpose"` // "password_reset" | "email_verification"
|
||||
TokenHash string `json:"-"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
UsedAt *time.Time `json:"used_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
const (
|
||||
PurposePasswordReset = "password_reset"
|
||||
PurposeEmailVerification = "email_verification"
|
||||
)
|
||||
|
||||
// Bookmark represents a user's bookmarked content.
|
||||
type Bookmark struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
ContentType string `json:"content_type"` // "verse" | "letter" | "essay" | "transcription" | etc.
|
||||
ContentType string `json:"content_type"`
|
||||
ContentID string `json:"content_id"`
|
||||
ContentRef string `json:"content_ref"` // human-readable reference (e.g., "BG 1.1")
|
||||
ContentRef string `json:"content_ref"`
|
||||
SelectedText *string `json:"selected_text,omitempty"`
|
||||
Note *string `json:"note,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
)
|
||||
|
||||
// R2 wraps an S3 client pointed at Cloudflare's R2 endpoint.
|
||||
//
|
||||
// We use R2 for avatar uploads through presigned PUT URLs:
|
||||
//
|
||||
// frontend asks BFF for a presigned URL ─► BFF returns URL + final public URL
|
||||
// frontend PUTs the file directly to R2 ─► R2 stores it
|
||||
// frontend POSTs the public URL back to BFF profile-update endpoint
|
||||
//
|
||||
// The BFF never proxies the file bytes; bandwidth stays browser↔R2.
|
||||
type R2 struct {
|
||||
client *s3.Client
|
||||
bucket string
|
||||
publicBase string // R2_PUBLIC_URL, no trailing slash
|
||||
}
|
||||
|
||||
// R2Config captures the env vars needed to talk to R2.
|
||||
type R2Config struct {
|
||||
AccountID string
|
||||
AccessKeyID string
|
||||
SecretAccessKey string
|
||||
Bucket string
|
||||
PublicURL string
|
||||
}
|
||||
|
||||
// Configured returns true if every required field is present.
|
||||
func (c R2Config) Configured() bool {
|
||||
return c.AccountID != "" && c.AccessKeyID != "" && c.SecretAccessKey != "" &&
|
||||
c.Bucket != "" && c.PublicURL != ""
|
||||
}
|
||||
|
||||
// NewR2 builds an S3 client pointed at the per-account R2 endpoint.
|
||||
// Returns (nil, nil) if cfg is not Configured — the avatar route will then
|
||||
// return 503 instead of panicking. That keeps the BFF bootable without R2.
|
||||
func NewR2(cfg R2Config) (*R2, error) {
|
||||
if !cfg.Configured() {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("https://%s.r2.cloudflarestorage.com", cfg.AccountID)
|
||||
|
||||
creds := credentials.NewStaticCredentialsProvider(cfg.AccessKeyID, cfg.SecretAccessKey, "")
|
||||
client := s3.New(s3.Options{
|
||||
Region: "auto", // R2 ignores region but the SDK requires one
|
||||
Credentials: creds,
|
||||
BaseEndpoint: aws.String(endpoint),
|
||||
UsePathStyle: true,
|
||||
})
|
||||
|
||||
return &R2{
|
||||
client: client,
|
||||
bucket: cfg.Bucket,
|
||||
publicBase: trimTrailingSlash(cfg.PublicURL),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PresignPut returns a one-shot PUT URL the browser can use to upload a file
|
||||
// directly to R2, plus the final public URL where the object will be served.
|
||||
//
|
||||
// expiry caps how long the upload URL is valid; 5 minutes is plenty and
|
||||
// limits replay if the URL leaks.
|
||||
func (r *R2) PresignPut(ctx context.Context, key, contentType string, expiry time.Duration) (uploadURL, publicURL string, err error) {
|
||||
if r == nil {
|
||||
return "", "", errors.New("R2 not configured")
|
||||
}
|
||||
if key == "" {
|
||||
return "", "", errors.New("empty key")
|
||||
}
|
||||
|
||||
presigner := s3.NewPresignClient(r.client)
|
||||
req, err := presigner.PresignPutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(r.bucket),
|
||||
Key: aws.String(key),
|
||||
ContentType: aws.String(contentType),
|
||||
}, s3.WithPresignExpires(expiry))
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("presign put: %w", err)
|
||||
}
|
||||
|
||||
return req.URL, fmt.Sprintf("%s/%s", r.publicBase, key), nil
|
||||
}
|
||||
|
||||
// PublicURL builds the public URL for an arbitrary key. Useful when reading
|
||||
// existing user.avatar_url values back.
|
||||
func (r *R2) PublicURL(key string) string {
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%s/%s", r.publicBase, key)
|
||||
}
|
||||
|
||||
func trimTrailingSlash(s string) string {
|
||||
for len(s) > 0 && s[len(s)-1] == '/' {
|
||||
s = s[:len(s)-1]
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
)
|
||||
|
||||
// ErrUserNotFound is returned by lookup methods when no row matches.
|
||||
var ErrUserNotFound = errors.New("user not found")
|
||||
|
||||
// ErrEmailTaken is returned by Create when the email already exists.
|
||||
var ErrEmailTaken = errors.New("email already registered")
|
||||
|
||||
// UserRepo provides CRUD on the users table.
|
||||
type UserRepo struct {
|
||||
pg *Postgres
|
||||
}
|
||||
|
||||
func NewUserRepo(pg *Postgres) *UserRepo {
|
||||
return &UserRepo{pg: pg}
|
||||
}
|
||||
|
||||
// Create inserts a new user. Returns ErrEmailTaken on unique-violation.
|
||||
// `email` is lowercased before insert; the column is CITEXT so case doesn't
|
||||
// matter for lookups, but lower-casing keeps stored values tidy.
|
||||
func (r *UserRepo) Create(ctx context.Context, email, passwordHash, name string) (*model.User, error) {
|
||||
const q = `
|
||||
INSERT INTO users (email, password_hash, name)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, email, password_hash, name, avatar_url, role,
|
||||
email_verified_at, token_version, created_at, updated_at`
|
||||
|
||||
u := &model.User{}
|
||||
err := r.pg.Pool.QueryRow(ctx, q, strings.ToLower(strings.TrimSpace(email)), passwordHash, name).
|
||||
Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.Role,
|
||||
&u.EmailVerifiedAt, &u.TokenVersion, &u.CreatedAt, &u.UpdatedAt)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err, "users_email_key") {
|
||||
return nil, ErrEmailTaken
|
||||
}
|
||||
return nil, fmt.Errorf("create user: %w", err)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// FindByEmail returns the user with the given email, or ErrUserNotFound.
|
||||
func (r *UserRepo) FindByEmail(ctx context.Context, email string) (*model.User, error) {
|
||||
return r.findOne(ctx,
|
||||
`WHERE email = $1`, strings.ToLower(strings.TrimSpace(email)))
|
||||
}
|
||||
|
||||
// FindByID returns the user with the given id, or ErrUserNotFound.
|
||||
func (r *UserRepo) FindByID(ctx context.Context, id string) (*model.User, error) {
|
||||
return r.findOne(ctx, `WHERE id = $1`, id)
|
||||
}
|
||||
|
||||
func (r *UserRepo) findOne(ctx context.Context, where string, args ...any) (*model.User, error) {
|
||||
q := `
|
||||
SELECT id, email, password_hash, name, avatar_url, role,
|
||||
email_verified_at, token_version, created_at, updated_at
|
||||
FROM users ` + where + ` LIMIT 1`
|
||||
|
||||
u := &model.User{}
|
||||
err := r.pg.Pool.QueryRow(ctx, q, args...).
|
||||
Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.Role,
|
||||
&u.EmailVerifiedAt, &u.TokenVersion, &u.CreatedAt, &u.UpdatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("find user: %w", err)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// UpdateProfile updates the user's name and/or avatar. Pass nil to leave a field unchanged.
|
||||
// Returns the updated user.
|
||||
func (r *UserRepo) UpdateProfile(ctx context.Context, id string, name *string, avatarURL *string) (*model.User, error) {
|
||||
const q = `
|
||||
UPDATE users
|
||||
SET name = COALESCE($2, name),
|
||||
avatar_url = CASE WHEN $3::boolean THEN $4 ELSE avatar_url END,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, email, password_hash, name, avatar_url, role,
|
||||
email_verified_at, token_version, created_at, updated_at`
|
||||
|
||||
// $3 is a flag: "did the caller intend to set avatar_url (including to NULL)?"
|
||||
// If avatarURL is nil we leave it alone; otherwise we apply *avatarURL (which
|
||||
// may itself be the empty string — caller decides if "" should map to NULL).
|
||||
updateAvatar := avatarURL != nil
|
||||
var avatarVal *string
|
||||
if updateAvatar && *avatarURL != "" {
|
||||
avatarVal = avatarURL
|
||||
}
|
||||
|
||||
u := &model.User{}
|
||||
err := r.pg.Pool.QueryRow(ctx, q, id, name, updateAvatar, avatarVal).
|
||||
Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.Role,
|
||||
&u.EmailVerifiedAt, &u.TokenVersion, &u.CreatedAt, &u.UpdatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("update profile: %w", err)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// UpdatePassword sets a new password hash and bumps token_version so any
|
||||
// existing JWTs immediately become invalid.
|
||||
func (r *UserRepo) UpdatePassword(ctx context.Context, id, newHash string) error {
|
||||
const q = `
|
||||
UPDATE users
|
||||
SET password_hash = $2,
|
||||
token_version = token_version + 1,
|
||||
updated_at = now()
|
||||
WHERE id = $1`
|
||||
tag, err := r.pg.Pool.Exec(ctx, q, id, newHash)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update password: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkEmailVerified sets email_verified_at = now() if not already set.
|
||||
func (r *UserRepo) MarkEmailVerified(ctx context.Context, id string) error {
|
||||
const q = `UPDATE users SET email_verified_at = now(), updated_at = now()
|
||||
WHERE id = $1 AND email_verified_at IS NULL`
|
||||
_, err := r.pg.Pool.Exec(ctx, q, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark verified: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper for unique-violation detection. Avoids importing pgconn just for
|
||||
// the SQLSTATE; we string-match on the message which contains the constraint
|
||||
// name. Good enough for the one constraint we care about.
|
||||
func isUniqueViolation(err error, constraint string) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "23505") || strings.Contains(msg, constraint)
|
||||
}
|
||||
|
||||
// AuthTokenRepo handles password_reset + email_verification tokens.
|
||||
type AuthTokenRepo struct {
|
||||
pg *Postgres
|
||||
}
|
||||
|
||||
func NewAuthTokenRepo(pg *Postgres) *AuthTokenRepo {
|
||||
return &AuthTokenRepo{pg: pg}
|
||||
}
|
||||
|
||||
// Create inserts a new token row. `tokenHash` must be the SHA-256 hex of the
|
||||
// raw token; only the hash is stored, never the plaintext.
|
||||
func (r *AuthTokenRepo) Create(ctx context.Context, userID, purpose, tokenHash string, ttl time.Duration) (*model.AuthToken, error) {
|
||||
const q = `
|
||||
INSERT INTO auth_tokens (user_id, purpose, token_hash, expires_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, user_id, purpose, token_hash, expires_at, used_at, created_at`
|
||||
|
||||
t := &model.AuthToken{}
|
||||
err := r.pg.Pool.QueryRow(ctx, q, userID, purpose, tokenHash, time.Now().Add(ttl)).
|
||||
Scan(&t.ID, &t.UserID, &t.Purpose, &t.TokenHash, &t.ExpiresAt, &t.UsedAt, &t.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create auth_token: %w", err)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// FindValid returns a non-used, non-expired token by hash + purpose.
|
||||
func (r *AuthTokenRepo) FindValid(ctx context.Context, tokenHash, purpose string) (*model.AuthToken, error) {
|
||||
const q = `
|
||||
SELECT id, user_id, purpose, token_hash, expires_at, used_at, created_at
|
||||
FROM auth_tokens
|
||||
WHERE token_hash = $1
|
||||
AND purpose = $2
|
||||
AND used_at IS NULL
|
||||
AND expires_at > now()
|
||||
LIMIT 1`
|
||||
|
||||
t := &model.AuthToken{}
|
||||
err := r.pg.Pool.QueryRow(ctx, q, tokenHash, purpose).
|
||||
Scan(&t.ID, &t.UserID, &t.Purpose, &t.TokenHash, &t.ExpiresAt, &t.UsedAt, &t.CreatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("find token: %w", err)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// Consume marks a token as used. After this call the token can never be
|
||||
// honoured again, even if it has not yet expired.
|
||||
func (r *AuthTokenRepo) Consume(ctx context.Context, id string) error {
|
||||
const q = `UPDATE auth_tokens SET used_at = now() WHERE id = $1 AND used_at IS NULL`
|
||||
_, err := r.pg.Pool.Exec(ctx, q, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("consume token: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// InvalidatePending marks all not-yet-used tokens for a user+purpose as used.
|
||||
// Called when a fresh token is issued so old links are immediately dead.
|
||||
func (r *AuthTokenRepo) InvalidatePending(ctx context.Context, userID, purpose string) error {
|
||||
const q = `UPDATE auth_tokens SET used_at = now()
|
||||
WHERE user_id = $1 AND purpose = $2 AND used_at IS NULL`
|
||||
_, err := r.pg.Pool.Exec(ctx, q, userID, purpose)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalidate pending tokens: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+100
-3
@@ -12,7 +12,10 @@ import (
|
||||
"github.com/prabhupadasays/spsays-bff/internal/handler"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/worker"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/auth"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/shorturl"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -41,6 +44,12 @@ type Server struct {
|
||||
transcriptionH *handler.TranscriptionHandler
|
||||
searchH *handler.SearchHandler
|
||||
shortURLH *handler.ShortURLHandler
|
||||
authH *handler.AuthHandler
|
||||
userH *handler.UserHandler
|
||||
|
||||
// Auth state (nil when Postgres is unavailable — /auth and /users return 503)
|
||||
authIssuer *auth.Issuer
|
||||
userRepo *repository.UserRepo
|
||||
}
|
||||
|
||||
// New creates a new Server with all routes registered.
|
||||
@@ -86,10 +95,61 @@ func New(cfg *config.Config, logger *zap.Logger) *Server {
|
||||
// Connection failures degrade gracefully — the BFF stays up; only /share returns 503.
|
||||
s.initShortener(cfg, logger)
|
||||
|
||||
// Auth: depends on Postgres being up. initShortener already opened the pool,
|
||||
// so we reuse s.pg here. On a fresh DB the users + auth_tokens tables get
|
||||
// created by the migration runner that initShortener already invoked.
|
||||
s.initAuth(cfg, logger)
|
||||
|
||||
s.setupRouter()
|
||||
return s
|
||||
}
|
||||
|
||||
// initAuth wires the user repo, JWT issuer, mailer, R2 client, and the auth
|
||||
// + user handlers. If Postgres is unavailable, leaves the handlers nil and
|
||||
// the routes 503-out gracefully.
|
||||
func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
|
||||
if s.pg == nil {
|
||||
logger.Warn("auth: Postgres unavailable — /auth and /users will return 503")
|
||||
return
|
||||
}
|
||||
|
||||
userRepo := repository.NewUserRepo(s.pg)
|
||||
tokenRepo := repository.NewAuthTokenRepo(s.pg)
|
||||
|
||||
r2, err := repository.NewR2(repository.R2Config{
|
||||
AccountID: cfg.R2AccountID,
|
||||
AccessKeyID: cfg.R2AccessKeyID,
|
||||
SecretAccessKey: cfg.R2SecretAccessKey,
|
||||
Bucket: cfg.R2Bucket,
|
||||
PublicURL: cfg.R2PublicURL,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warn("auth: R2 unavailable — avatar uploads will return 503", zap.Error(err))
|
||||
}
|
||||
if r2 == nil {
|
||||
logger.Info("auth: R2 not configured — avatar presign endpoint will return 503")
|
||||
}
|
||||
|
||||
issuer := auth.NewIssuer(cfg.JWTSecret, cfg.JWTExpiration)
|
||||
mail := mailer.New(mailer.Config{
|
||||
Host: cfg.SMTPHost,
|
||||
Port: cfg.SMTPPort,
|
||||
Username: cfg.SMTPUsername,
|
||||
Password: cfg.SMTPPassword,
|
||||
From: cfg.SMTPFrom,
|
||||
}, logger)
|
||||
|
||||
authSvc := service.NewAuthService(userRepo, tokenRepo, issuer, mail, logger, cfg.FrontendURL)
|
||||
userSvc := service.NewUserService(userRepo, r2)
|
||||
|
||||
cookieOpts := auth.CookieOptions{Domain: cfg.CookieDomain, Secure: cfg.CookieSecure}
|
||||
|
||||
s.authIssuer = issuer
|
||||
s.userRepo = userRepo
|
||||
s.authH = handler.NewAuthHandler(authSvc, cookieOpts)
|
||||
s.userH = handler.NewUserHandler(userSvc)
|
||||
}
|
||||
|
||||
// initShortener wires Postgres + Redis + the shorturl handler/workers.
|
||||
// On connection failure it logs and leaves the components nil — the handler
|
||||
// short-circuits to 503 so the rest of the BFF keeps working.
|
||||
@@ -152,6 +212,15 @@ func (s *Server) Close() {
|
||||
s.logger.Info("all connections closed")
|
||||
}
|
||||
|
||||
// authUnavailable is the 503 fallback used when Postgres is down and the
|
||||
// auth handlers couldn't be wired up. Keeps every /auth/* and /users/* URL
|
||||
// returning a structured error rather than 404.
|
||||
func (s *Server) authUnavailable(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = w.Write([]byte(`{"success":false,"error":{"code":503,"message":"auth service unavailable"}}`))
|
||||
}
|
||||
|
||||
// injectBookCode is a middleware that sets "code" in chi's URL params
|
||||
// for explicit sub-routes like /books/sb and /books/cc where {code}
|
||||
// isn't a URL param but a fixed path prefix.
|
||||
@@ -367,12 +436,40 @@ func (s *Server) setupRouter() {
|
||||
// Search (Typesense)
|
||||
r.Get("/search", s.searchH.Search)
|
||||
|
||||
// User routes (PostgreSQL)
|
||||
// --- Auth ---
|
||||
// Public endpoints (no JWT required) for sign-up, log-in, password reset,
|
||||
// and email verification. The /auth/me + /auth/resend-verification routes
|
||||
// require a session and live under the protected block below.
|
||||
r.Route("/auth", func(r chi.Router) {
|
||||
// TODO: register auth handlers (login, register, refresh)
|
||||
if s.authH == nil {
|
||||
r.HandleFunc("/*", s.authUnavailable)
|
||||
return
|
||||
}
|
||||
r.Post("/signup", s.authH.SignUp)
|
||||
r.Post("/login", s.authH.LogIn)
|
||||
r.Post("/logout", s.authH.LogOut)
|
||||
r.Post("/forgot-password", s.authH.ForgotPassword)
|
||||
r.Post("/reset-password", s.authH.ResetPassword)
|
||||
r.Post("/verify-email", s.authH.VerifyEmail)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
||||
r.Get("/me", s.authH.Me)
|
||||
r.Post("/resend-verification", s.authH.ResendVerification)
|
||||
})
|
||||
})
|
||||
|
||||
// User profile + avatar — all protected.
|
||||
r.Route("/users", func(r chi.Router) {
|
||||
// TODO: register user handlers
|
||||
if s.userH == nil {
|
||||
r.HandleFunc("/*", s.authUnavailable)
|
||||
return
|
||||
}
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
||||
r.Patch("/me", s.userH.UpdateProfile)
|
||||
r.Post("/me/avatar-presign", s.userH.AvatarPresign)
|
||||
})
|
||||
})
|
||||
r.Route("/collections", func(r chi.Router) {
|
||||
// TODO: register collection/bookmark handlers
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
// Package service orchestrates the auth flows on top of the repositories.
|
||||
//
|
||||
// The handler layer is intentionally dumb: it parses the request, calls one
|
||||
// AuthService method, and renders the result. All branching (already exists?
|
||||
// wrong password? expired token?) lives here, behind a small set of named
|
||||
// error sentinels.
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/auth"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// Sentinel errors. Handlers map these to HTTP status codes.
|
||||
var (
|
||||
ErrInvalidCredentials = errors.New("invalid email or password")
|
||||
ErrEmailAlreadyTaken = errors.New("email already registered")
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrInvalidOrExpiredLink = errors.New("link is invalid or has expired")
|
||||
ErrEmailAlreadyVerified = errors.New("email already verified")
|
||||
ErrWeakPassword = errors.New("password must be at least 8 characters")
|
||||
)
|
||||
|
||||
// AuthService bundles every flow that mutates auth state.
|
||||
type AuthService struct {
|
||||
users *repository.UserRepo
|
||||
tokens *repository.AuthTokenRepo
|
||||
jwt *auth.Issuer
|
||||
mail mailer.Mailer
|
||||
logger *zap.Logger
|
||||
frontend string // FrontendURL — used to build reset / verify links
|
||||
|
||||
// TTLs — surfaced as fields so tests can shrink them.
|
||||
PasswordResetTTL time.Duration
|
||||
EmailVerifyTTL time.Duration
|
||||
}
|
||||
|
||||
// NewAuthService wires the dependencies.
|
||||
func NewAuthService(
|
||||
users *repository.UserRepo,
|
||||
tokens *repository.AuthTokenRepo,
|
||||
jwt *auth.Issuer,
|
||||
mail mailer.Mailer,
|
||||
logger *zap.Logger,
|
||||
frontendURL string,
|
||||
) *AuthService {
|
||||
return &AuthService{
|
||||
users: users,
|
||||
tokens: tokens,
|
||||
jwt: jwt,
|
||||
mail: mail,
|
||||
logger: logger,
|
||||
frontend: strings.TrimRight(frontendURL, "/"),
|
||||
PasswordResetTTL: 1 * time.Hour,
|
||||
EmailVerifyTTL: 24 * time.Hour,
|
||||
}
|
||||
}
|
||||
|
||||
// AuthResult is the value handlers return on signup/login. Token is the raw
|
||||
// JWT the handler will plant in the response cookie; the User is the public
|
||||
// profile to ship in the response body.
|
||||
type AuthResult struct {
|
||||
User *model.User
|
||||
Token string
|
||||
Expires time.Time
|
||||
}
|
||||
|
||||
// SignUp creates a new user, issues a session JWT, and fires off a
|
||||
// verification email (best-effort — failure to send does not block signup,
|
||||
// since the user can hit resend later).
|
||||
func (s *AuthService) SignUp(ctx context.Context, email, password, name string) (*AuthResult, error) {
|
||||
if len(password) < 8 {
|
||||
return nil, ErrWeakPassword
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hash password: %w", err)
|
||||
}
|
||||
|
||||
u, err := s.users.Create(ctx, email, string(hash), strings.TrimSpace(name))
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrEmailTaken) {
|
||||
return nil, ErrEmailAlreadyTaken
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.sendVerificationEmail(ctx, u); err != nil {
|
||||
// Log and keep going — signup itself succeeded.
|
||||
s.logger.Warn("verification email failed", zap.String("user", u.ID), zap.Error(err))
|
||||
}
|
||||
|
||||
return s.issueSession(u)
|
||||
}
|
||||
|
||||
// LogIn authenticates a user by email + password. The error returned for an
|
||||
// unknown email is intentionally identical to a wrong password — never leak
|
||||
// which one was wrong.
|
||||
func (s *AuthService) LogIn(ctx context.Context, email, password string) (*AuthResult, error) {
|
||||
u, err := s.users.FindByEmail(ctx, email)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrUserNotFound) {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
return s.issueSession(u)
|
||||
}
|
||||
|
||||
// Me looks up a user by id and re-verifies the JWT's token_version against
|
||||
// the stored value. Mismatch means the user has logged-out-everywhere or
|
||||
// changed password since this JWT was issued → reject.
|
||||
func (s *AuthService) Me(ctx context.Context, userID string, tokenVersion int) (*model.User, error) {
|
||||
u, err := s.users.FindByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
if u.TokenVersion != tokenVersion {
|
||||
return nil, auth.ErrInvalidToken
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// RequestPasswordReset always returns nil to callers — we don't want to
|
||||
// reveal whether the email is registered. If the email exists we mint a
|
||||
// token and email a link; otherwise we silently no-op.
|
||||
func (s *AuthService) RequestPasswordReset(ctx context.Context, email string) error {
|
||||
u, err := s.users.FindByEmail(ctx, email)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrUserNotFound) {
|
||||
return nil // do not reveal
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Invalidate any previous reset tokens for this user.
|
||||
_ = s.tokens.InvalidatePending(ctx, u.ID, model.PurposePasswordReset)
|
||||
|
||||
raw, hash, err := newToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.tokens.Create(ctx, u.ID, model.PurposePasswordReset, hash, s.PasswordResetTTL); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
link := fmt.Sprintf("%s/reset-password?token=%s", s.frontend, raw)
|
||||
body := passwordResetEmail(u.Name, link)
|
||||
if err := s.mail.Send(u.Email, "Reset your Prabhupāda Says password", body); err != nil {
|
||||
s.logger.Warn("password reset email failed", zap.String("user", u.ID), zap.Error(err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetPassword consumes a reset token and sets a new password.
|
||||
// On success the user's token_version is bumped, invalidating every
|
||||
// outstanding JWT (so a stolen token can't be used after the legit owner
|
||||
// changes their password).
|
||||
func (s *AuthService) ResetPassword(ctx context.Context, rawToken, newPassword string) error {
|
||||
if len(newPassword) < 8 {
|
||||
return ErrWeakPassword
|
||||
}
|
||||
hash := hashToken(rawToken)
|
||||
|
||||
t, err := s.tokens.FindValid(ctx, hash, model.PurposePasswordReset)
|
||||
if err != nil {
|
||||
return ErrInvalidOrExpiredLink
|
||||
}
|
||||
|
||||
bcryptHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hash password: %w", err)
|
||||
}
|
||||
if err := s.users.UpdatePassword(ctx, t.UserID, string(bcryptHash)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.tokens.Consume(ctx, t.ID); err != nil {
|
||||
s.logger.Warn("consume reset token", zap.String("token_id", t.ID), zap.Error(err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyEmail consumes an email-verification token and flips the user's
|
||||
// email_verified_at timestamp.
|
||||
func (s *AuthService) VerifyEmail(ctx context.Context, rawToken string) error {
|
||||
hash := hashToken(rawToken)
|
||||
t, err := s.tokens.FindValid(ctx, hash, model.PurposeEmailVerification)
|
||||
if err != nil {
|
||||
return ErrInvalidOrExpiredLink
|
||||
}
|
||||
if err := s.users.MarkEmailVerified(ctx, t.UserID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.tokens.Consume(ctx, t.ID); err != nil {
|
||||
s.logger.Warn("consume verify token", zap.String("token_id", t.ID), zap.Error(err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResendVerification re-fires the verification email for the currently-logged-in
|
||||
// user. No-ops with no error if the user is already verified.
|
||||
func (s *AuthService) ResendVerification(ctx context.Context, userID string) error {
|
||||
u, err := s.users.FindByID(ctx, userID)
|
||||
if err != nil {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
if u.EmailVerifiedAt != nil {
|
||||
return ErrEmailAlreadyVerified
|
||||
}
|
||||
return s.sendVerificationEmail(ctx, u)
|
||||
}
|
||||
|
||||
// --- internal helpers ---
|
||||
|
||||
func (s *AuthService) issueSession(u *model.User) (*AuthResult, error) {
|
||||
tok, exp, err := s.jwt.Issue(u.ID, u.TokenVersion)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &AuthResult{User: u, Token: tok, Expires: exp}, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) sendVerificationEmail(ctx context.Context, u *model.User) error {
|
||||
_ = s.tokens.InvalidatePending(ctx, u.ID, model.PurposeEmailVerification)
|
||||
|
||||
raw, hash, err := newToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.tokens.Create(ctx, u.ID, model.PurposeEmailVerification, hash, s.EmailVerifyTTL); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
link := fmt.Sprintf("%s/verify-email?token=%s", s.frontend, raw)
|
||||
body := verifyEmailMessage(u.Name, link)
|
||||
return s.mail.Send(u.Email, "Verify your email — Prabhupāda Says", body)
|
||||
}
|
||||
|
||||
// newToken returns (rawHex, sha256HexHash). The raw value goes in the
|
||||
// emailed link; only the hash is stored in the DB.
|
||||
func newToken() (raw, hash string, err error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
raw = hex.EncodeToString(b)
|
||||
hash = hashToken(raw)
|
||||
return raw, hash, nil
|
||||
}
|
||||
|
||||
func hashToken(raw string) string {
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// --- email bodies (intentionally minimal HTML; auth mails should be plain) ---
|
||||
|
||||
func passwordResetEmail(name, link string) string {
|
||||
return fmt.Sprintf(`<p>Hari Bol %s,</p>
|
||||
<p>You (or someone using your email) asked to reset your password on
|
||||
<strong>Prabhupāda Says</strong>. Click the link below to set a new one:</p>
|
||||
<p><a href="%s">%s</a></p>
|
||||
<p>This link expires in 1 hour. If you didn't request a reset, just ignore
|
||||
this email — your password stays the same.</p>`, htmlEscape(name), link, link)
|
||||
}
|
||||
|
||||
func verifyEmailMessage(name, link string) string {
|
||||
return fmt.Sprintf(`<p>Hari Bol %s,</p>
|
||||
<p>Welcome to <strong>Prabhupāda Says</strong>. Please confirm your email by
|
||||
clicking the link below:</p>
|
||||
<p><a href="%s">%s</a></p>
|
||||
<p>This link expires in 24 hours. You can request a new one from the banner
|
||||
shown at the top of the site after you log in.</p>`, htmlEscape(name), link, link)
|
||||
}
|
||||
|
||||
func htmlEscape(s string) string {
|
||||
r := strings.NewReplacer(
|
||||
"&", "&",
|
||||
"<", "<",
|
||||
">", ">",
|
||||
"\"", """,
|
||||
)
|
||||
return r.Replace(s)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
)
|
||||
|
||||
// UserService handles profile-edit + avatar-upload flows.
|
||||
type UserService struct {
|
||||
users *repository.UserRepo
|
||||
r2 *repository.R2
|
||||
}
|
||||
|
||||
func NewUserService(users *repository.UserRepo, r2 *repository.R2) *UserService {
|
||||
return &UserService{users: users, r2: r2}
|
||||
}
|
||||
|
||||
// UpdateProfile updates the user's name and/or avatar URL.
|
||||
//
|
||||
// `avatarURL` semantics:
|
||||
//
|
||||
// nil → don't touch
|
||||
// "" → clear (remove avatar)
|
||||
// "x" → set to "x"
|
||||
//
|
||||
// The avatar URL is expected to point at our R2 bucket — we don't validate
|
||||
// the host because we don't want to break tests / local setups, but in
|
||||
// production you could add a check that it starts with R2_PUBLIC_URL.
|
||||
func (s *UserService) UpdateProfile(ctx context.Context, id string, name *string, avatarURL *string) (*model.User, error) {
|
||||
if name != nil {
|
||||
trimmed := strings.TrimSpace(*name)
|
||||
if trimmed == "" {
|
||||
return nil, errors.New("name cannot be empty")
|
||||
}
|
||||
if len(trimmed) > 120 {
|
||||
return nil, errors.New("name too long (max 120)")
|
||||
}
|
||||
name = &trimmed
|
||||
}
|
||||
return s.users.UpdateProfile(ctx, id, name, avatarURL)
|
||||
}
|
||||
|
||||
// AvatarPresign issues a one-shot presigned PUT URL the frontend uses to
|
||||
// upload an avatar directly to R2.
|
||||
//
|
||||
// The key is namespaced under `avatars/<user_id>/<random>.<ext>` so:
|
||||
// - we can list / clean up a user's avatars with one prefix scan
|
||||
// - the random suffix forces cache invalidation when the avatar changes
|
||||
// - a malicious user can't overwrite someone else's avatar (their JWT
|
||||
// scopes the user_id portion of the key)
|
||||
//
|
||||
// Returns (uploadURL, publicURL, error). The frontend then PUTs to uploadURL
|
||||
// and POSTs publicURL back as the new avatar.
|
||||
func (s *UserService) AvatarPresign(ctx context.Context, userID, contentType string) (uploadURL, publicURL string, err error) {
|
||||
if s.r2 == nil {
|
||||
return "", "", errors.New("avatar uploads are not configured")
|
||||
}
|
||||
ext, ok := extensionFor(contentType)
|
||||
if !ok {
|
||||
return "", "", fmt.Errorf("unsupported content type: %s", contentType)
|
||||
}
|
||||
|
||||
suffix := make([]byte, 8)
|
||||
if _, err := rand.Read(suffix); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
key := path.Join("avatars", userID, hex.EncodeToString(suffix)+ext)
|
||||
|
||||
return s.r2.PresignPut(ctx, key, contentType, 5*time.Minute)
|
||||
}
|
||||
|
||||
// extensionFor whitelists the image content types we accept. Restricting
|
||||
// here is the cheap-but-effective defence against users uploading arbitrary
|
||||
// binaries to our R2 bucket.
|
||||
func extensionFor(ct string) (string, bool) {
|
||||
switch ct {
|
||||
case "image/png":
|
||||
return ".png", true
|
||||
case "image/jpeg":
|
||||
return ".jpg", true
|
||||
case "image/webp":
|
||||
return ".webp", true
|
||||
case "image/gif":
|
||||
return ".gif", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user