完善初始化更新

This commit is contained in:
2026-03-20 20:42:33 +08:00
parent 568ccb08fa
commit e6866feb29
39 changed files with 6986 additions and 2379 deletions

View File

@@ -0,0 +1,204 @@
package handlers
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"sproutgate-backend/internal/models"
)
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.OwnerPublic())
}
c.JSON(http.StatusOK, gin.H{"total": len(publicUsers), "users": publicUsers})
}
func (h *Handler) GetPublicUser(c *gin.Context) {
account := strings.TrimSpace(c.Param("account"))
if account == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "account is required"})
return
}
users, err := h.store.ListUsers()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load users"})
return
}
for _, user := range users {
if strings.EqualFold(strings.TrimSpace(user.Account), account) {
if user.Banned {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
c.JSON(http.StatusOK, gin.H{"user": user.PublicProfile()})
return
}
}
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
}
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
}
wu, err := normalizePublicWebsiteURL(req.WebsiteURL)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
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,
WebsiteURL: wu,
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.OwnerPublic()})
}
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.WebsiteURL != nil {
wu, err := normalizePublicWebsiteURL(*req.WebsiteURL)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
user.WebsiteURL = wu
}
if req.Bio != nil {
user.Bio = *req.Bio
}
if req.Banned != nil {
user.Banned = *req.Banned
if !user.Banned {
user.BanReason = ""
user.BannedAt = ""
} else if strings.TrimSpace(user.BannedAt) == "" {
user.BannedAt = models.NowISO()
}
}
if req.BanReason != nil {
r := strings.TrimSpace(*req.BanReason)
if len(r) > maxBanReasonLen {
c.JSON(http.StatusBadRequest, gin.H{"error": "ban reason is too long"})
return
}
if r != "" && !user.Banned {
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot set ban reason while user is not banned"})
return
}
user.BanReason = r
}
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.OwnerPublic()})
}
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()
}
}

View File

@@ -0,0 +1,18 @@
package handlers
import (
"strings"
"github.com/gin-gonic/gin"
"sproutgate-backend/internal/models"
)
func authClientFromHeaders(c *gin.Context) (id string, name string, ok bool) {
id, ok = models.NormalizeAuthClientID(strings.TrimSpace(c.GetHeader("X-Auth-Client")))
if !ok {
return "", "", false
}
name = models.ClampAuthClientName(c.GetHeader("X-Auth-Client-Name"))
return id, name, true
}

View File

@@ -0,0 +1,173 @@
package handlers
import (
"errors"
"net/http"
"os"
"strings"
"time"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"sproutgate-backend/internal/auth"
"sproutgate-backend/internal/clientgeo"
"sproutgate-backend/internal/models"
)
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
}
if user.Banned {
writeBanJSON(c, user.BanReason)
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
}
if cid, ok := models.NormalizeAuthClientID(req.ClientID); ok {
name := models.ClampAuthClientName(req.ClientName)
if rec, err := h.store.RecordAuthClient(req.Account, cid, name); err == nil {
user = rec
}
}
c.JSON(http.StatusOK, gin.H{
"token": token,
"expiresAt": expiresAt.Format(time.RFC3339),
"user": user.OwnerPublic(),
})
}
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
}
if user.Banned {
h := gin.H{"valid": false, "error": "account is banned"}
if r := strings.TrimSpace(user.BanReason); r != "" {
h["banReason"] = r
}
c.JSON(http.StatusOK, h)
return
}
if cid, cname, ok := authClientFromHeaders(c); ok {
_, _ = h.store.RecordAuthClient(claims.Account, cid, cname)
}
c.JSON(http.StatusOK, gin.H{"valid": 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
}
if abortIfUserBanned(c, user) {
return
}
if cid, cname, ok := authClientFromHeaders(c); ok {
if rec, err := h.store.RecordAuthClient(claims.Account, cid, cname); err == nil {
user = rec
}
}
today := models.CurrentActivityDate()
nowAt := models.CurrentActivityTime()
user, _, err = h.store.RecordVisit(claims.Account, today, nowAt)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save visit"})
return
}
visitIP := strings.TrimSpace(c.GetHeader("X-Visit-Ip"))
visitLoc := strings.TrimSpace(c.GetHeader("X-Visit-Location"))
if visitIP == "" {
visitIP = strings.TrimSpace(c.ClientIP())
}
lookupURL := strings.TrimSpace(os.Getenv("GEO_LOOKUP_URL"))
if lookupURL == "" {
lookupURL = clientgeo.DefaultLookupURL
}
if visitLoc == "" && visitIP != "" {
if loc, geoErr := clientgeo.FetchDisplayLocation(c.Request.Context(), lookupURL, visitIP); geoErr == nil {
visitLoc = loc
}
}
if visitIP != "" || visitLoc != "" {
user, err = h.store.UpdateLastVisitMeta(claims.Account, visitIP, visitLoc)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save visit meta"})
return
}
}
checkInConfig := h.store.CheckInConfig()
c.JSON(http.StatusOK, gin.H{
"user": user.OwnerPublic(),
"checkIn": gin.H{
"rewardCoins": checkInConfig.RewardCoins,
"checkedInToday": user.LastCheckInDate == today,
"lastCheckInDate": user.LastCheckInDate,
"lastCheckInAt": user.LastCheckInAt,
"today": today,
},
})
}

View File

@@ -0,0 +1,252 @@
package handlers
import (
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"sproutgate-backend/internal/email"
"sproutgate-backend/internal/models"
)
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)
inviteTrim := strings.TrimSpace(req.InviteCode)
if req.Account == "" || strings.TrimSpace(req.Password) == "" || req.Email == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "account, password and email are required"})
return
}
requireInv := h.store.RegistrationRequireInvite()
if requireInv && inviteTrim == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invite code is required"})
return
}
if inviteTrim != "" {
if err := h.store.ValidateInviteForRegister(inviteTrim); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
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 inviteTrim != "" {
pending.InviteCode = strings.ToUpper(inviteTrim)
}
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
}
if strings.TrimSpace(pending.InviteCode) != "" {
if err := h.store.RedeemInvite(pending.InviteCode); err != nil {
_ = h.store.DeleteUser(record.Account)
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.OwnerPublic()})
}
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
}
if user.Banned {
writeBanJSON(c, user.BanReason)
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
}
if user.Banned {
writeBanJSON(c, user.BanReason)
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})
}

View File

@@ -0,0 +1,91 @@
package handlers
import (
"errors"
"net/http"
"os"
"github.com/gin-gonic/gin"
"sproutgate-backend/internal/auth"
"sproutgate-backend/internal/models"
"sproutgate-backend/internal/storage"
)
func (h *Handler) CheckIn(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
}
userPre, foundPre, err := h.store.GetUser(claims.Account)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
return
}
if !foundPre {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
return
}
if abortIfUserBanned(c, userPre) {
return
}
today := models.CurrentActivityDate()
nowAt := models.CurrentActivityTime()
user, reward, alreadyCheckedIn, err := h.store.CheckIn(claims.Account, today, nowAt)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save check-in"})
return
}
checkInConfig := h.store.CheckInConfig()
message := "签到成功"
if alreadyCheckedIn {
message = "今日已签到"
}
c.JSON(http.StatusOK, gin.H{
"checkedIn": !alreadyCheckedIn,
"alreadyCheckedIn": alreadyCheckedIn,
"rewardCoins": h.store.CheckInConfig().RewardCoins,
"awardedCoins": reward,
"message": message,
"user": user.OwnerPublic(),
"checkIn": gin.H{
"rewardCoins": checkInConfig.RewardCoins,
"checkedInToday": user.LastCheckInDate == today,
"lastCheckInDate": user.LastCheckInDate,
"lastCheckInAt": user.LastCheckInAt,
"today": today,
},
})
}
func (h *Handler) GetCheckInConfig(c *gin.Context) {
cfg := h.store.CheckInConfig()
c.JSON(http.StatusOK, gin.H{"rewardCoins": cfg.RewardCoins})
}
func (h *Handler) UpdateCheckInConfig(c *gin.Context) {
var req updateCheckInConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
if req.RewardCoins <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "rewardCoins must be greater than 0"})
return
}
if err := h.store.UpdateCheckInConfig(storage.CheckInConfig{RewardCoins: req.RewardCoins}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save check-in config"})
return
}
c.JSON(http.StatusOK, gin.H{"rewardCoins": req.RewardCoins})
}

View File

@@ -0,0 +1,11 @@
package handlers
import "sproutgate-backend/internal/storage"
type Handler struct {
store *storage.Store
}
func NewHandler(store *storage.Store) *Handler {
return &Handler{store: store}
}

View File

@@ -1,751 +0,0 @@
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
}

View File

@@ -0,0 +1,70 @@
package handlers
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"sproutgate-backend/internal/models"
)
func bearerToken(header string) string {
if header == "" {
return ""
}
if strings.HasPrefix(strings.ToLower(header), "bearer ") {
return strings.TrimSpace(header[7:])
}
return ""
}
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 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
}
func writeBanJSON(c *gin.Context, reason string) {
h := gin.H{"error": "account is banned"}
if r := strings.TrimSpace(reason); r != "" {
h["banReason"] = r
}
c.JSON(http.StatusForbidden, h)
}
func abortIfUserBanned(c *gin.Context, u models.UserRecord) bool {
if !u.Banned {
return false
}
writeBanJSON(c, u.BanReason)
return true
}

View File

@@ -0,0 +1,74 @@
package handlers
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"sproutgate-backend/internal/auth"
)
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 abortIfUserBanned(c, user) {
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.WebsiteURL != nil {
wu, err := normalizePublicWebsiteURL(*req.WebsiteURL)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
user.WebsiteURL = wu
}
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.OwnerPublic()})
}

View File

@@ -0,0 +1,14 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
)
// GetPublicRegistrationPolicy 公开:是否必须邀请码(不含具体邀请码)。
func (h *Handler) GetPublicRegistrationPolicy(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"requireInviteCode": h.store.RegistrationRequireInvite(),
})
}

View File

@@ -0,0 +1,56 @@
package handlers
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
func (h *Handler) GetAdminRegistration(c *gin.Context) {
cfg := h.store.GetRegistrationConfig()
c.JSON(http.StatusOK, gin.H{
"requireInviteCode": cfg.RequireInviteCode,
"invites": cfg.Invites,
})
}
func (h *Handler) PutAdminRegistrationPolicy(c *gin.Context) {
var req updateRegistrationPolicyRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
if err := h.store.SetRegistrationRequireInvite(req.RequireInviteCode); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save registration policy"})
return
}
c.JSON(http.StatusOK, gin.H{"requireInviteCode": req.RequireInviteCode})
}
func (h *Handler) PostAdminInvite(c *gin.Context) {
var req createInviteRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
entry, err := h.store.AddInviteEntry(req.Note, req.MaxUses, strings.TrimSpace(req.ExpiresAt))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"invite": entry})
}
func (h *Handler) DeleteAdminInvite(c *gin.Context) {
code := strings.TrimSpace(c.Param("code"))
if err := h.store.DeleteInviteEntry(code); err != nil {
if strings.Contains(err.Error(), "not found") {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": true})
}

View File

@@ -0,0 +1,135 @@
package handlers
import (
"errors"
"net/url"
"strings"
)
type loginRequest struct {
Account string `json:"account"`
Password string `json:"password"`
ClientID string `json:"clientId"`
ClientName string `json:"clientName"`
}
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"`
InviteCode string `json:"inviteCode"`
}
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"`
WebsiteURL *string `json:"websiteUrl"`
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 updateCheckInConfigRequest struct {
RewardCoins int `json:"rewardCoins"`
}
type updateRegistrationPolicyRequest struct {
RequireInviteCode bool `json:"requireInviteCode"`
}
type createInviteRequest struct {
Note string `json:"note"`
MaxUses int `json:"maxUses"`
ExpiresAt string `json:"expiresAt"`
}
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"`
WebsiteURL string `json:"websiteUrl"`
Bio string `json:"bio"`
}
const maxBanReasonLen = 500
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"`
WebsiteURL *string `json:"websiteUrl"`
Bio *string `json:"bio"`
Banned *bool `json:"banned"`
BanReason *string `json:"banReason"`
}
const maxWebsiteURLLen = 2048
func normalizePublicWebsiteURL(raw string) (string, error) {
s := strings.TrimSpace(raw)
if s == "" {
return "", nil
}
if len(s) > maxWebsiteURLLen {
return "", errors.New("website url is too long")
}
lower := strings.ToLower(s)
if strings.HasPrefix(lower, "javascript:") || strings.HasPrefix(lower, "data:") {
return "", errors.New("invalid website url")
}
candidate := s
if !strings.Contains(candidate, "://") {
candidate = "https://" + candidate
}
u, err := url.Parse(candidate)
if err != nil || u.Host == "" {
return "", errors.New("invalid website url")
}
scheme := strings.ToLower(u.Scheme)
if scheme != "http" && scheme != "https" {
return "", errors.New("only http and https urls are allowed")
}
u.Scheme = scheme
return u.String(), nil
}

View File

@@ -0,0 +1,154 @@
package handlers
import (
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"sproutgate-backend/internal/auth"
"sproutgate-backend/internal/email"
"sproutgate-backend/internal/models"
)
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 abortIfUserBanned(c, user) {
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
}
if abortIfUserBanned(c, user) {
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.OwnerPublic()})
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.OwnerPublic()})
}