427 lines
12 KiB
Go
427 lines
12 KiB
Go
package handler
|
|
|
|
import (
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"xboard-go/internal/database"
|
|
"xboard-go/internal/model"
|
|
"xboard-go/internal/service"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
const unverifiedExpiration = int64(946684800)
|
|
|
|
func PluginRealNameStatus(c *gin.Context) {
|
|
if !service.IsPluginEnabled(service.PluginRealNameVerification) {
|
|
Fail(c, 400, "plugin is not enabled")
|
|
return
|
|
}
|
|
|
|
user, ok := currentUser(c)
|
|
if !ok {
|
|
Fail(c, 401, "unauthorized")
|
|
return
|
|
}
|
|
|
|
auth := realNameRecordForUser(user)
|
|
status := "unverified"
|
|
var realName, masked, rejectReason string
|
|
var submittedAt, reviewedAt int64
|
|
if auth != nil {
|
|
status = auth.Status
|
|
realName = auth.RealName
|
|
masked = auth.IdentityMasked
|
|
rejectReason = auth.RejectReason
|
|
submittedAt = auth.SubmittedAt
|
|
reviewedAt = auth.ReviewedAt
|
|
}
|
|
canSubmit := status == "unverified" || (status == "rejected" && service.GetPluginConfigBool(service.PluginRealNameVerification, "allow_resubmit_after_reject", true))
|
|
|
|
Success(c, gin.H{
|
|
"status": status,
|
|
"status_label": realNameStatusLabel(status),
|
|
"is_inherited": strings.Contains(user.Email, "-ipv6@"),
|
|
"can_submit": canSubmit,
|
|
"real_name": realName,
|
|
"identity_no_masked": masked,
|
|
"notice": service.GetPluginConfigString(service.PluginRealNameVerification, "verification_notice", "Please submit real-name information."),
|
|
"reject_reason": rejectReason,
|
|
"submitted_at": submittedAt,
|
|
"reviewed_at": reviewedAt,
|
|
})
|
|
}
|
|
|
|
func PluginRealNameSubmit(c *gin.Context) {
|
|
if !service.IsPluginEnabled(service.PluginRealNameVerification) {
|
|
Fail(c, 400, "plugin is not enabled")
|
|
return
|
|
}
|
|
|
|
user, ok := currentUser(c)
|
|
if !ok {
|
|
Fail(c, 401, "unauthorized")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
RealName string `json:"real_name" binding:"required"`
|
|
IdentityNo string `json:"identity_no" binding:"required"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
Fail(c, 422, "missing identity information")
|
|
return
|
|
}
|
|
|
|
now := time.Now().Unix()
|
|
record := model.RealNameAuth{
|
|
UserID: uint64(user.ID),
|
|
RealName: req.RealName,
|
|
IdentityMasked: maskIdentity(req.IdentityNo),
|
|
IdentityEncrypted: req.IdentityNo,
|
|
Status: "pending",
|
|
SubmittedAt: now,
|
|
ReviewedAt: 0,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
var existing model.RealNameAuth
|
|
status := "pending"
|
|
reviewedAt := int64(0)
|
|
if service.GetPluginConfigBool(service.PluginRealNameVerification, "auto_approve", false) {
|
|
status = "approved"
|
|
reviewedAt = now
|
|
}
|
|
|
|
if err := database.DB.Where("user_id = ?", user.ID).First(&existing).Error; err == nil {
|
|
if existing.Status == "approved" {
|
|
Fail(c, 400, "verification already approved")
|
|
return
|
|
}
|
|
if existing.Status == "pending" {
|
|
Fail(c, 400, "verification is pending review")
|
|
return
|
|
}
|
|
if existing.Status == "rejected" && !service.GetPluginConfigBool(service.PluginRealNameVerification, "allow_resubmit_after_reject", true) {
|
|
Fail(c, 400, "rejected records cannot be resubmitted")
|
|
return
|
|
}
|
|
|
|
record.ID = existing.ID
|
|
record.CreatedAt = existing.CreatedAt
|
|
record.Status = status
|
|
record.ReviewedAt = reviewedAt
|
|
record.RejectReason = ""
|
|
database.DB.Model(&existing).Updates(record)
|
|
} else {
|
|
record.Status = status
|
|
record.ReviewedAt = reviewedAt
|
|
database.DB.Create(&record)
|
|
}
|
|
|
|
syncRealNameExpiration(user.ID, status)
|
|
SuccessMessage(c, "verification submitted", gin.H{"status": status})
|
|
}
|
|
|
|
func PluginRealNameRecords(c *gin.Context) {
|
|
if !service.IsPluginEnabled(service.PluginRealNameVerification) {
|
|
Fail(c, 400, "plugin is not enabled")
|
|
return
|
|
}
|
|
|
|
page := parsePositiveInt(c.DefaultQuery("page", "1"), 1)
|
|
perPage := parsePositiveInt(c.DefaultQuery("per_page", "20"), 20)
|
|
keyword := strings.TrimSpace(c.Query("keyword"))
|
|
|
|
query := database.DB.Table("v2_user AS u").
|
|
Select("u.id, u.email, r.status, r.real_name, r.identity_masked, r.submitted_at, r.reviewed_at").
|
|
Joins("LEFT JOIN v2_realname_auth AS r ON u.id = r.user_id").
|
|
Where("u.email NOT LIKE ?", "%-ipv6@%")
|
|
if keyword != "" {
|
|
query = query.Where("u.email LIKE ?", "%"+keyword+"%")
|
|
}
|
|
|
|
var total int64
|
|
query.Count(&total)
|
|
|
|
type recordRow struct {
|
|
ID int
|
|
Email string
|
|
Status *string
|
|
RealName *string
|
|
IdentityMasked *string
|
|
SubmittedAt *int64
|
|
ReviewedAt *int64
|
|
}
|
|
|
|
var rows []recordRow
|
|
if err := query.Offset((page - 1) * perPage).Limit(perPage).Scan(&rows).Error; err != nil {
|
|
Fail(c, 500, "failed to fetch records")
|
|
return
|
|
}
|
|
|
|
items := make([]gin.H, 0, len(rows))
|
|
for _, row := range rows {
|
|
status := "unverified"
|
|
if row.Status != nil && *row.Status != "" {
|
|
status = *row.Status
|
|
}
|
|
items = append(items, gin.H{
|
|
"id": row.ID,
|
|
"email": row.Email,
|
|
"status": status,
|
|
"status_label": realNameStatusLabel(status),
|
|
"real_name": stringPointerValue(row.RealName),
|
|
"identity_no_masked": stringPointerValue(row.IdentityMasked),
|
|
"submitted_at": int64PointerValue(row.SubmittedAt),
|
|
"reviewed_at": int64PointerValue(row.ReviewedAt),
|
|
})
|
|
}
|
|
|
|
Success(c, gin.H{
|
|
"status": "success",
|
|
"data": items,
|
|
"pagination": gin.H{
|
|
"total": total,
|
|
"current": page,
|
|
"last_page": calculateLastPage(total, perPage),
|
|
},
|
|
})
|
|
}
|
|
|
|
func PluginRealNameReview(c *gin.Context) {
|
|
if !service.IsPluginEnabled(service.PluginRealNameVerification) {
|
|
Fail(c, 400, "plugin is not enabled")
|
|
return
|
|
}
|
|
|
|
userID := parsePositiveInt(c.Param("userId"), 0)
|
|
if userID == 0 {
|
|
Fail(c, 400, "invalid user id")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Status string `json:"status"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
_ = c.ShouldBindJSON(&req)
|
|
if req.Status == "" {
|
|
req.Status = "approved"
|
|
}
|
|
|
|
now := time.Now().Unix()
|
|
var record model.RealNameAuth
|
|
if err := database.DB.Where("user_id = ?", userID).First(&record).Error; err == nil {
|
|
record.Status = req.Status
|
|
record.RejectReason = req.Reason
|
|
record.ReviewedAt = now
|
|
record.UpdatedAt = now
|
|
database.DB.Save(&record)
|
|
} else {
|
|
database.DB.Create(&model.RealNameAuth{
|
|
UserID: uint64(userID),
|
|
Status: req.Status,
|
|
RealName: "admin approved",
|
|
IdentityMasked: "admin approved",
|
|
SubmittedAt: now,
|
|
ReviewedAt: now,
|
|
RejectReason: req.Reason,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
})
|
|
}
|
|
|
|
syncRealNameExpiration(userID, req.Status)
|
|
SuccessMessage(c, "review updated", true)
|
|
}
|
|
|
|
func PluginRealNameReset(c *gin.Context) {
|
|
if !service.IsPluginEnabled(service.PluginRealNameVerification) {
|
|
Fail(c, 400, "plugin is not enabled")
|
|
return
|
|
}
|
|
|
|
userID := parsePositiveInt(c.Param("userId"), 0)
|
|
if userID == 0 {
|
|
Fail(c, 400, "invalid user id")
|
|
return
|
|
}
|
|
|
|
database.DB.Where("user_id = ?", userID).Delete(&model.RealNameAuth{})
|
|
syncRealNameExpiration(userID, "unverified")
|
|
SuccessMessage(c, "record reset", true)
|
|
}
|
|
|
|
func PluginRealNameSyncAll(c *gin.Context) {
|
|
if !service.IsPluginEnabled(service.PluginRealNameVerification) {
|
|
Fail(c, 400, "plugin is not enabled")
|
|
return
|
|
}
|
|
SuccessMessage(c, "sync completed", performGlobalRealNameSync())
|
|
}
|
|
|
|
func PluginRealNameApproveAll(c *gin.Context) {
|
|
if !service.IsPluginEnabled(service.PluginRealNameVerification) {
|
|
Fail(c, 400, "plugin is not enabled")
|
|
return
|
|
}
|
|
|
|
var users []model.User
|
|
database.DB.Where("email NOT LIKE ?", "%-ipv6@%").Find(&users)
|
|
now := time.Now().Unix()
|
|
for _, user := range users {
|
|
var record model.RealNameAuth
|
|
if err := database.DB.Where("user_id = ?", user.ID).First(&record).Error; err == nil {
|
|
record.Status = "approved"
|
|
record.RealName = "admin approved"
|
|
record.IdentityMasked = "admin approved"
|
|
record.SubmittedAt = now
|
|
record.ReviewedAt = now
|
|
record.UpdatedAt = now
|
|
database.DB.Save(&record)
|
|
} else {
|
|
database.DB.Create(&model.RealNameAuth{
|
|
UserID: uint64(user.ID),
|
|
Status: "approved",
|
|
RealName: "admin approved",
|
|
IdentityMasked: "admin approved",
|
|
SubmittedAt: now,
|
|
ReviewedAt: now,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
})
|
|
}
|
|
}
|
|
|
|
SuccessMessage(c, "all users approved", performGlobalRealNameSync())
|
|
}
|
|
|
|
func PluginRealNameClearCache(c *gin.Context) {
|
|
_ = database.CacheDelete("realname:sync")
|
|
SuccessMessage(c, "cache cleared", true)
|
|
}
|
|
|
|
func realNameRecordForUser(user *model.User) *model.RealNameAuth {
|
|
if user == nil {
|
|
return nil
|
|
}
|
|
var record model.RealNameAuth
|
|
if err := database.DB.Where("user_id = ?", user.ID).First(&record).Error; err == nil {
|
|
return &record
|
|
}
|
|
|
|
if strings.Contains(user.Email, "-ipv6@") {
|
|
mainEmail := strings.Replace(user.Email, "-ipv6@", "@", 1)
|
|
var mainUser model.User
|
|
if err := database.DB.Where("email = ?", mainEmail).First(&mainUser).Error; err == nil {
|
|
if err := database.DB.Where("user_id = ?", mainUser.ID).First(&record).Error; err == nil {
|
|
return &record
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func syncRealNameExpiration(userID int, status string) {
|
|
if status == "approved" {
|
|
database.DB.Model(&model.User{}).Where("id = ?", userID).Updates(map[string]any{
|
|
"expired_at": parseExpirationSetting(),
|
|
"updated_at": time.Now().Unix(),
|
|
})
|
|
return
|
|
}
|
|
if !service.GetPluginConfigBool(service.PluginRealNameVerification, "enforce_real_name", false) {
|
|
database.DB.Model(&model.User{}).Where("id = ?", userID).Update("updated_at", time.Now().Unix())
|
|
return
|
|
}
|
|
database.DB.Model(&model.User{}).Where("id = ?", userID).Updates(map[string]any{
|
|
"expired_at": unverifiedExpiration,
|
|
"updated_at": time.Now().Unix(),
|
|
})
|
|
}
|
|
|
|
func parseExpirationSetting() int64 {
|
|
raw := service.GetPluginConfigString(service.PluginRealNameVerification, "verified_expiration_date", "2099-12-31")
|
|
if value, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
|
return value
|
|
}
|
|
if parsed, err := time.Parse("2006-01-02", raw); err == nil {
|
|
return parsed.Unix()
|
|
}
|
|
return time.Date(2099, 12, 31, 23, 59, 59, 0, time.UTC).Unix()
|
|
}
|
|
|
|
func performGlobalRealNameSync() gin.H {
|
|
expireApproved := parseExpirationSetting()
|
|
now := time.Now().Unix()
|
|
enforce := service.GetPluginConfigBool(service.PluginRealNameVerification, "enforce_real_name", false)
|
|
|
|
type approvedRow struct {
|
|
UserID int
|
|
}
|
|
var approvedRows []approvedRow
|
|
database.DB.Table("v2_realname_auth").Select("user_id").Where("status = ?", "approved").Scan(&approvedRows)
|
|
approvedUsers := make([]int, 0, len(approvedRows))
|
|
for _, row := range approvedRows {
|
|
approvedUsers = append(approvedUsers, row.UserID)
|
|
}
|
|
|
|
if len(approvedUsers) > 0 {
|
|
database.DB.Model(&model.User{}).Where("id IN ?", approvedUsers).
|
|
Updates(map[string]any{"expired_at": expireApproved, "updated_at": now})
|
|
if enforce {
|
|
database.DB.Model(&model.User{}).Where("id NOT IN ?", approvedUsers).
|
|
Updates(map[string]any{"expired_at": unverifiedExpiration, "updated_at": now})
|
|
}
|
|
} else {
|
|
if enforce {
|
|
database.DB.Model(&model.User{}).
|
|
Updates(map[string]any{"expired_at": unverifiedExpiration, "updated_at": now})
|
|
}
|
|
}
|
|
|
|
return gin.H{
|
|
"enforce_real_name": enforce,
|
|
"total_verified": len(approvedUsers),
|
|
"actual_synced": len(approvedUsers),
|
|
}
|
|
}
|
|
|
|
func realNameStatusLabel(status string) string {
|
|
switch status {
|
|
case "approved":
|
|
return "approved"
|
|
case "pending":
|
|
return "pending"
|
|
case "rejected":
|
|
return "rejected"
|
|
default:
|
|
return "unverified"
|
|
}
|
|
}
|
|
|
|
func maskIdentity(identity string) string {
|
|
if len(identity) <= 8 {
|
|
return identity
|
|
}
|
|
return identity[:4] + "**********" + identity[len(identity)-4:]
|
|
}
|
|
|
|
func stringPointerValue(value *string) string {
|
|
if value == nil {
|
|
return ""
|
|
}
|
|
return *value
|
|
}
|
|
|
|
func int64PointerValue(value *int64) int64 {
|
|
if value == nil {
|
|
return 0
|
|
}
|
|
return *value
|
|
}
|