This commit is contained in:
Krispire Admin
2026-05-30 02:33:57 +05:30
parent 5e0a484239
commit 97422c1ef5
21 changed files with 1720 additions and 82 deletions
+109
View File
@@ -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
}
+227
View File
@@ -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
}