752 lines
22 KiB
Go
752 lines
22 KiB
Go
package handlers
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
"sproutgate-backend/internal/auth"
|
|
"sproutgate-backend/internal/email"
|
|
"sproutgate-backend/internal/models"
|
|
"sproutgate-backend/internal/storage"
|
|
)
|
|
|
|
type Handler struct {
|
|
store *storage.Store
|
|
}
|
|
|
|
func NewHandler(store *storage.Store) *Handler {
|
|
return &Handler{store: store}
|
|
}
|
|
|
|
type loginRequest struct {
|
|
Account string `json:"account"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
type verifyRequest struct {
|
|
Token string `json:"token"`
|
|
}
|
|
|
|
type registerRequest struct {
|
|
Account string `json:"account"`
|
|
Password string `json:"password"`
|
|
Username string `json:"username"`
|
|
Email string `json:"email"`
|
|
}
|
|
|
|
type verifyEmailRequest struct {
|
|
Account string `json:"account"`
|
|
Code string `json:"code"`
|
|
}
|
|
|
|
type updateProfileRequest struct {
|
|
Password *string `json:"password"`
|
|
Username *string `json:"username"`
|
|
Phone *string `json:"phone"`
|
|
AvatarURL *string `json:"avatarUrl"`
|
|
Bio *string `json:"bio"`
|
|
}
|
|
|
|
type forgotPasswordRequest struct {
|
|
Account string `json:"account"`
|
|
Email string `json:"email"`
|
|
}
|
|
|
|
type resetPasswordRequest struct {
|
|
Account string `json:"account"`
|
|
Code string `json:"code"`
|
|
NewPassword string `json:"newPassword"`
|
|
}
|
|
|
|
type secondaryEmailRequest struct {
|
|
Email string `json:"email"`
|
|
}
|
|
|
|
type verifySecondaryEmailRequest struct {
|
|
Email string `json:"email"`
|
|
Code string `json:"code"`
|
|
}
|
|
|
|
type createUserRequest struct {
|
|
Account string `json:"account"`
|
|
Password string `json:"password"`
|
|
Username string `json:"username"`
|
|
Email string `json:"email"`
|
|
Level int `json:"level"`
|
|
SproutCoins int `json:"sproutCoins"`
|
|
SecondaryEmails []string `json:"secondaryEmails"`
|
|
Phone string `json:"phone"`
|
|
AvatarURL string `json:"avatarUrl"`
|
|
Bio string `json:"bio"`
|
|
}
|
|
|
|
type updateUserRequest struct {
|
|
Password *string `json:"password"`
|
|
Username *string `json:"username"`
|
|
Email *string `json:"email"`
|
|
Level *int `json:"level"`
|
|
SproutCoins *int `json:"sproutCoins"`
|
|
SecondaryEmails *[]string `json:"secondaryEmails"`
|
|
Phone *string `json:"phone"`
|
|
AvatarURL *string `json:"avatarUrl"`
|
|
Bio *string `json:"bio"`
|
|
}
|
|
|
|
func (h *Handler) Login(c *gin.Context) {
|
|
var req loginRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
|
return
|
|
}
|
|
req.Account = strings.TrimSpace(req.Account)
|
|
if req.Account == "" || req.Password == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "account and password are required"})
|
|
return
|
|
}
|
|
user, found, err := h.store.GetUser(req.Account)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
|
|
return
|
|
}
|
|
if !found {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
|
return
|
|
}
|
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
|
return
|
|
}
|
|
token, expiresAt, err := auth.GenerateToken(h.store.JWTSecret(), h.store.JWTIssuer(), user.Account, 7*24*time.Hour)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate token"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"token": token,
|
|
"expiresAt": expiresAt.Format(time.RFC3339),
|
|
"user": user.Public(),
|
|
})
|
|
}
|
|
|
|
func (h *Handler) Verify(c *gin.Context) {
|
|
var req verifyRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Token) == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "token is required"})
|
|
return
|
|
}
|
|
claims, err := auth.ParseToken(h.store.JWTSecret(), h.store.JWTIssuer(), req.Token)
|
|
if err != nil {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"valid": false, "error": "invalid token"})
|
|
return
|
|
}
|
|
user, found, err := h.store.GetUser(claims.Account)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
|
|
return
|
|
}
|
|
if !found {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"valid": false, "error": "user not found"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"valid": true, "user": user.Public()})
|
|
}
|
|
|
|
func (h *Handler) Register(c *gin.Context) {
|
|
var req registerRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
|
return
|
|
}
|
|
req.Account = strings.TrimSpace(req.Account)
|
|
req.Email = strings.TrimSpace(req.Email)
|
|
if req.Account == "" || strings.TrimSpace(req.Password) == "" || req.Email == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "account, password and email are required"})
|
|
return
|
|
}
|
|
if _, found, err := h.store.GetUser(req.Account); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
|
|
return
|
|
} else if found {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "account already exists"})
|
|
return
|
|
}
|
|
|
|
code, err := generateVerificationCode()
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate code"})
|
|
return
|
|
}
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
|
|
return
|
|
}
|
|
expiresAt := time.Now().Add(10 * time.Minute)
|
|
pending := models.PendingUser{
|
|
Account: req.Account,
|
|
PasswordHash: string(hash),
|
|
Username: req.Username,
|
|
Email: req.Email,
|
|
CodeHash: hashCode(code),
|
|
CreatedAt: models.NowISO(),
|
|
ExpiresAt: expiresAt.Format(time.RFC3339),
|
|
}
|
|
if err := h.store.SavePending(pending); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save pending user"})
|
|
return
|
|
}
|
|
if err := email.SendVerificationEmail(h.store.EmailConfig(), req.Email, code, 10*time.Minute); err != nil {
|
|
_ = h.store.DeletePending(req.Account)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to send email: %v", err)})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"sent": true,
|
|
"expiresAt": expiresAt.Format(time.RFC3339),
|
|
})
|
|
}
|
|
|
|
func (h *Handler) VerifyEmail(c *gin.Context) {
|
|
var req verifyEmailRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
|
return
|
|
}
|
|
req.Account = strings.TrimSpace(req.Account)
|
|
req.Code = strings.TrimSpace(req.Code)
|
|
if req.Account == "" || req.Code == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "account and code are required"})
|
|
return
|
|
}
|
|
pending, found, err := h.store.GetPending(req.Account)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load pending user"})
|
|
return
|
|
}
|
|
if !found {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "pending registration not found"})
|
|
return
|
|
}
|
|
expiresAt, err := time.Parse(time.RFC3339, pending.ExpiresAt)
|
|
if err != nil || time.Now().After(expiresAt) {
|
|
_ = h.store.DeletePending(req.Account)
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "verification code expired"})
|
|
return
|
|
}
|
|
if !verifyCode(req.Code, pending.CodeHash) {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid verification code"})
|
|
return
|
|
}
|
|
record := models.UserRecord{
|
|
Account: pending.Account,
|
|
PasswordHash: pending.PasswordHash,
|
|
Username: pending.Username,
|
|
Email: pending.Email,
|
|
Level: 0,
|
|
SproutCoins: 0,
|
|
SecondaryEmails: []string{},
|
|
CreatedAt: models.NowISO(),
|
|
UpdatedAt: models.NowISO(),
|
|
}
|
|
if err := h.store.CreateUser(record); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
_ = h.store.DeletePending(req.Account)
|
|
c.JSON(http.StatusCreated, gin.H{"created": true, "user": record.Public()})
|
|
}
|
|
|
|
func (h *Handler) ForgotPassword(c *gin.Context) {
|
|
var req forgotPasswordRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
|
return
|
|
}
|
|
req.Account = strings.TrimSpace(req.Account)
|
|
req.Email = strings.TrimSpace(req.Email)
|
|
if req.Account == "" || req.Email == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "account and email are required"})
|
|
return
|
|
}
|
|
user, found, err := h.store.GetUser(req.Account)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
|
|
return
|
|
}
|
|
if !found || strings.TrimSpace(user.Email) == "" || user.Email != req.Email {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "account or email not matched"})
|
|
return
|
|
}
|
|
code, err := generateVerificationCode()
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate code"})
|
|
return
|
|
}
|
|
expiresAt := time.Now().Add(10 * time.Minute)
|
|
resetRecord := models.ResetPassword{
|
|
Account: user.Account,
|
|
Email: user.Email,
|
|
CodeHash: hashCode(code),
|
|
CreatedAt: models.NowISO(),
|
|
ExpiresAt: expiresAt.Format(time.RFC3339),
|
|
}
|
|
if err := h.store.SaveReset(resetRecord); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save reset token"})
|
|
return
|
|
}
|
|
if err := email.SendResetPasswordEmail(h.store.EmailConfig(), user.Email, code, 10*time.Minute); err != nil {
|
|
_ = h.store.DeleteReset(user.Account)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to send email: %v", err)})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"sent": true,
|
|
"expiresAt": expiresAt.Format(time.RFC3339),
|
|
})
|
|
}
|
|
|
|
func (h *Handler) ResetPassword(c *gin.Context) {
|
|
var req resetPasswordRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
|
return
|
|
}
|
|
req.Account = strings.TrimSpace(req.Account)
|
|
req.Code = strings.TrimSpace(req.Code)
|
|
if req.Account == "" || req.Code == "" || strings.TrimSpace(req.NewPassword) == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "account, code and newPassword are required"})
|
|
return
|
|
}
|
|
resetRecord, found, err := h.store.GetReset(req.Account)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load reset token"})
|
|
return
|
|
}
|
|
if !found {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "reset request not found"})
|
|
return
|
|
}
|
|
expiresAt, err := time.Parse(time.RFC3339, resetRecord.ExpiresAt)
|
|
if err != nil || time.Now().After(expiresAt) {
|
|
_ = h.store.DeleteReset(req.Account)
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "reset code expired"})
|
|
return
|
|
}
|
|
if !verifyCode(req.Code, resetRecord.CodeHash) {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid reset code"})
|
|
return
|
|
}
|
|
user, found, err := h.store.GetUser(req.Account)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
|
|
return
|
|
}
|
|
if !found {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "user not found"})
|
|
return
|
|
}
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
|
|
return
|
|
}
|
|
user.PasswordHash = string(hash)
|
|
if err := h.store.SaveUser(user); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save user"})
|
|
return
|
|
}
|
|
_ = h.store.DeleteReset(req.Account)
|
|
c.JSON(http.StatusOK, gin.H{"reset": true})
|
|
}
|
|
|
|
func (h *Handler) RequestSecondaryEmail(c *gin.Context) {
|
|
token := bearerToken(c.GetHeader("Authorization"))
|
|
if token == "" {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
|
|
return
|
|
}
|
|
claims, err := auth.ParseToken(h.store.JWTSecret(), h.store.JWTIssuer(), token)
|
|
if err != nil {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
|
return
|
|
}
|
|
var req secondaryEmailRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
|
return
|
|
}
|
|
emailAddr := strings.TrimSpace(req.Email)
|
|
if emailAddr == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "email is required"})
|
|
return
|
|
}
|
|
user, found, err := h.store.GetUser(claims.Account)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
|
|
return
|
|
}
|
|
if !found {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
|
|
return
|
|
}
|
|
if strings.TrimSpace(user.Email) == emailAddr {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "email already used as primary"})
|
|
return
|
|
}
|
|
for _, e := range user.SecondaryEmails {
|
|
if e == emailAddr {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "email already verified"})
|
|
return
|
|
}
|
|
}
|
|
code, err := generateVerificationCode()
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate code"})
|
|
return
|
|
}
|
|
expiresAt := time.Now().Add(10 * time.Minute)
|
|
record := models.SecondaryEmailVerification{
|
|
Account: user.Account,
|
|
Email: emailAddr,
|
|
CodeHash: hashCode(code),
|
|
CreatedAt: models.NowISO(),
|
|
ExpiresAt: expiresAt.Format(time.RFC3339),
|
|
}
|
|
if err := h.store.SaveSecondaryVerification(record); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save verification"})
|
|
return
|
|
}
|
|
if err := email.SendVerificationEmail(h.store.EmailConfig(), emailAddr, code, 10*time.Minute); err != nil {
|
|
_ = h.store.DeleteSecondaryVerification(user.Account, emailAddr)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to send email: %v", err)})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"sent": true,
|
|
"expiresAt": expiresAt.Format(time.RFC3339),
|
|
})
|
|
}
|
|
|
|
func (h *Handler) VerifySecondaryEmail(c *gin.Context) {
|
|
token := bearerToken(c.GetHeader("Authorization"))
|
|
if token == "" {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
|
|
return
|
|
}
|
|
claims, err := auth.ParseToken(h.store.JWTSecret(), h.store.JWTIssuer(), token)
|
|
if err != nil {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
|
return
|
|
}
|
|
var req verifySecondaryEmailRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
|
return
|
|
}
|
|
emailAddr := strings.TrimSpace(req.Email)
|
|
code := strings.TrimSpace(req.Code)
|
|
if emailAddr == "" || code == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "email and code are required"})
|
|
return
|
|
}
|
|
record, found, err := h.store.GetSecondaryVerification(claims.Account, emailAddr)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load verification"})
|
|
return
|
|
}
|
|
if !found {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "verification not found"})
|
|
return
|
|
}
|
|
expiresAt, err := time.Parse(time.RFC3339, record.ExpiresAt)
|
|
if err != nil || time.Now().After(expiresAt) {
|
|
_ = h.store.DeleteSecondaryVerification(claims.Account, emailAddr)
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "verification code expired"})
|
|
return
|
|
}
|
|
if !verifyCode(code, record.CodeHash) {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid verification code"})
|
|
return
|
|
}
|
|
user, found, err := h.store.GetUser(claims.Account)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
|
|
return
|
|
}
|
|
if !found {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
|
|
return
|
|
}
|
|
for _, e := range user.SecondaryEmails {
|
|
if e == emailAddr {
|
|
_ = h.store.DeleteSecondaryVerification(claims.Account, emailAddr)
|
|
c.JSON(http.StatusOK, gin.H{"verified": true, "user": user.Public()})
|
|
return
|
|
}
|
|
}
|
|
user.SecondaryEmails = append(user.SecondaryEmails, emailAddr)
|
|
if err := h.store.SaveUser(user); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save user"})
|
|
return
|
|
}
|
|
_ = h.store.DeleteSecondaryVerification(claims.Account, emailAddr)
|
|
c.JSON(http.StatusOK, gin.H{"verified": true, "user": user.Public()})
|
|
}
|
|
|
|
func (h *Handler) Me(c *gin.Context) {
|
|
token := bearerToken(c.GetHeader("Authorization"))
|
|
if token == "" {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
|
|
return
|
|
}
|
|
claims, err := auth.ParseToken(h.store.JWTSecret(), h.store.JWTIssuer(), token)
|
|
if err != nil {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
|
return
|
|
}
|
|
user, found, err := h.store.GetUser(claims.Account)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
|
|
return
|
|
}
|
|
if !found {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"user": user.Public()})
|
|
}
|
|
|
|
func (h *Handler) UpdateProfile(c *gin.Context) {
|
|
token := bearerToken(c.GetHeader("Authorization"))
|
|
if token == "" {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
|
|
return
|
|
}
|
|
claims, err := auth.ParseToken(h.store.JWTSecret(), h.store.JWTIssuer(), token)
|
|
if err != nil {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
|
return
|
|
}
|
|
var req updateProfileRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
|
return
|
|
}
|
|
user, found, err := h.store.GetUser(claims.Account)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
|
|
return
|
|
}
|
|
if !found {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
|
|
return
|
|
}
|
|
if req.Password != nil && strings.TrimSpace(*req.Password) != "" {
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
|
|
return
|
|
}
|
|
user.PasswordHash = string(hash)
|
|
}
|
|
if req.Username != nil {
|
|
user.Username = *req.Username
|
|
}
|
|
if req.Phone != nil {
|
|
user.Phone = *req.Phone
|
|
}
|
|
if req.AvatarURL != nil {
|
|
user.AvatarURL = *req.AvatarURL
|
|
}
|
|
if req.Bio != nil {
|
|
user.Bio = *req.Bio
|
|
}
|
|
if err := h.store.SaveUser(user); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save user"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"user": user.Public()})
|
|
}
|
|
|
|
func (h *Handler) ListUsers(c *gin.Context) {
|
|
users, err := h.store.ListUsers()
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load users"})
|
|
return
|
|
}
|
|
publicUsers := make([]models.UserPublic, 0, len(users))
|
|
for _, u := range users {
|
|
publicUsers = append(publicUsers, u.Public())
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"total": len(publicUsers), "users": publicUsers})
|
|
}
|
|
|
|
func (h *Handler) CreateUser(c *gin.Context) {
|
|
var req createUserRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
|
return
|
|
}
|
|
req.Account = strings.TrimSpace(req.Account)
|
|
if req.Account == "" || strings.TrimSpace(req.Password) == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "account and password are required"})
|
|
return
|
|
}
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
|
|
return
|
|
}
|
|
record := models.UserRecord{
|
|
Account: req.Account,
|
|
PasswordHash: string(hash),
|
|
Username: req.Username,
|
|
Email: req.Email,
|
|
Level: req.Level,
|
|
SproutCoins: req.SproutCoins,
|
|
SecondaryEmails: req.SecondaryEmails,
|
|
Phone: req.Phone,
|
|
AvatarURL: req.AvatarURL,
|
|
Bio: req.Bio,
|
|
CreatedAt: models.NowISO(),
|
|
UpdatedAt: models.NowISO(),
|
|
}
|
|
if err := h.store.CreateUser(record); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusCreated, gin.H{"user": record.Public()})
|
|
}
|
|
|
|
func (h *Handler) UpdateUser(c *gin.Context) {
|
|
account := strings.TrimSpace(c.Param("account"))
|
|
if account == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "account is required"})
|
|
return
|
|
}
|
|
var req updateUserRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
|
return
|
|
}
|
|
user, found, err := h.store.GetUser(account)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
|
|
return
|
|
}
|
|
if !found {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
|
return
|
|
}
|
|
if req.Password != nil && strings.TrimSpace(*req.Password) != "" {
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
|
|
return
|
|
}
|
|
user.PasswordHash = string(hash)
|
|
}
|
|
if req.Username != nil {
|
|
user.Username = *req.Username
|
|
}
|
|
if req.Email != nil {
|
|
user.Email = *req.Email
|
|
}
|
|
if req.Level != nil {
|
|
user.Level = *req.Level
|
|
}
|
|
if req.SproutCoins != nil {
|
|
user.SproutCoins = *req.SproutCoins
|
|
}
|
|
if req.SecondaryEmails != nil {
|
|
user.SecondaryEmails = *req.SecondaryEmails
|
|
}
|
|
if req.Phone != nil {
|
|
user.Phone = *req.Phone
|
|
}
|
|
if req.AvatarURL != nil {
|
|
user.AvatarURL = *req.AvatarURL
|
|
}
|
|
if req.Bio != nil {
|
|
user.Bio = *req.Bio
|
|
}
|
|
if err := h.store.SaveUser(user); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save user"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"user": user.Public()})
|
|
}
|
|
|
|
func (h *Handler) DeleteUser(c *gin.Context) {
|
|
account := strings.TrimSpace(c.Param("account"))
|
|
if account == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "account is required"})
|
|
return
|
|
}
|
|
if err := h.store.DeleteUser(account); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete user"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
|
}
|
|
|
|
func (h *Handler) AdminMiddleware() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
token := adminTokenFromRequest(c)
|
|
if token == "" || token != h.store.AdminToken() {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid admin token"})
|
|
c.Abort()
|
|
return
|
|
}
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
func adminTokenFromRequest(c *gin.Context) string {
|
|
if token := strings.TrimSpace(c.Query("token")); token != "" {
|
|
return token
|
|
}
|
|
if token := strings.TrimSpace(c.GetHeader("X-Admin-Token")); token != "" {
|
|
return token
|
|
}
|
|
authHeader := strings.TrimSpace(c.GetHeader("Authorization"))
|
|
return bearerToken(authHeader)
|
|
}
|
|
|
|
func bearerToken(header string) string {
|
|
if header == "" {
|
|
return ""
|
|
}
|
|
if strings.HasPrefix(strings.ToLower(header), "bearer ") {
|
|
return strings.TrimSpace(header[7:])
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func generateVerificationCode() (string, error) {
|
|
randomBytes := make([]byte, 3)
|
|
if _, err := rand.Read(randomBytes); err != nil {
|
|
return "", err
|
|
}
|
|
number := int(randomBytes[0])<<16 | int(randomBytes[1])<<8 | int(randomBytes[2])
|
|
return fmt.Sprintf("%06d", number%1000000), nil
|
|
}
|
|
|
|
func hashCode(code string) string {
|
|
sum := sha256.Sum256([]byte(code))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func verifyCode(code string, hash string) bool {
|
|
return subtle.ConstantTimeCompare([]byte(hashCode(code)), []byte(hash)) == 1
|
|
}
|