refactor(*): 平台架构 - 拆分后端领域模块并同步插件协议与模型能力
This commit is contained in:
1 parent
916043fb5a
commit
6e5cbc8b05
644 files changed
+62731
-2320
No files matched your search
@@ -0,0 +1,340 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"infinite-canvas/backend/internal/kernel"
|
||||
"log"
|
||||
"net/mail"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const SessionCookieName = "open_ai_canvas_session"
|
||||
|
||||
const sessionMaxAge = 30 * 24 * time.Hour
|
||||
|
||||
var usernamePattern = regexp.MustCompile(`^[a-zA-Z0-9_-]{3,32}$`)
|
||||
|
||||
// AuthError 保留为兼容别名;跨认证域的新代码应直接使用 AppError。
|
||||
type AuthError = kernel.AppError
|
||||
|
||||
type RegisterRequest struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
EmailCode string `json:"emailCode"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type PublicAuthSettings struct {
|
||||
FirstUser bool `json:"firstUser"`
|
||||
RegistrationEnabled bool `json:"registrationEnabled"`
|
||||
LinuxDOEnabled bool `json:"linuxdoEnabled"`
|
||||
EmailEnabled bool `json:"emailEnabled"`
|
||||
EmailCodeRequired bool `json:"emailCodeRequired"`
|
||||
}
|
||||
|
||||
type AuthSessionResult struct {
|
||||
User AuthUser `json:"user"`
|
||||
Session string `json:"session"`
|
||||
MaxAgeSecs int `json:"maxAgeSecs"`
|
||||
}
|
||||
|
||||
type AuthUser struct {
|
||||
model.User
|
||||
AvatarURL string `json:"avatarUrl,omitempty"`
|
||||
IdentityProvider string `json:"identityProvider,omitempty"`
|
||||
IdentityID string `json:"identityId,omitempty"`
|
||||
IdentityUsername string `json:"identityUsername,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Service) PublicAuthSettings() (*PublicAuthSettings, error) {
|
||||
count, err := s.repo.UserCount()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count == 0 {
|
||||
return &PublicAuthSettings{FirstUser: true, RegistrationEnabled: true, LinuxDOEnabled: false}, nil
|
||||
}
|
||||
registrationEnabled, err := s.RegistrationEnabled()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
emailEnabled, err := s.EmailEnabled()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &PublicAuthSettings{FirstUser: false, RegistrationEnabled: registrationEnabled, LinuxDOEnabled: s.LinuxDOEnabled(), EmailEnabled: emailEnabled, EmailCodeRequired: true}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Register(req RegisterRequest) (*AuthSessionResult, error) {
|
||||
username := NormalizeUsername(req.Username)
|
||||
email := NormalizeEmail(req.Email)
|
||||
displayName := NormalizeDisplayName(req.DisplayName, username)
|
||||
if err := ValidateUsername(username); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ValidatePassword(req.Password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if email != "" {
|
||||
if err := ValidateEmail(email); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
s.registrationMu.Lock()
|
||||
defer s.registrationMu.Unlock()
|
||||
count, err := s.repo.UserCount()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var verifiedCode *model.EmailVerificationCode
|
||||
if count > 0 {
|
||||
registrationEnabled, err := s.RegistrationEnabled()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !registrationEnabled {
|
||||
return nil, kernel.Forbidden("管理员未开放新用户注册")
|
||||
}
|
||||
if email == "" {
|
||||
return nil, kernel.BadAuthRequest("请输入邮箱")
|
||||
}
|
||||
if err := s.validateRegistrationEmailDomain(email); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
verifiedCode, err = s.VerifyRegistrationEmailCode(email, req.EmailCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if _, err := s.repo.UserByUsername(username); err == nil {
|
||||
return nil, kernel.BadAuthRequest("用户名已存在")
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
if email != "" {
|
||||
if _, err := s.repo.UserByEmail(email); err == nil {
|
||||
return nil, kernel.BadAuthRequest("邮箱已被注册")
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
passwordHash, err := HashPassword(req.Password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now()
|
||||
user := model.User{
|
||||
ID: kernel.NewID(),
|
||||
Username: username,
|
||||
Email: email,
|
||||
DisplayName: displayName,
|
||||
Role: model.UserRoleUser,
|
||||
Status: model.UserStatusActive,
|
||||
PasswordHash: passwordHash,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if count == 0 {
|
||||
user.Role = model.UserRoleAdmin
|
||||
}
|
||||
if verifiedCode != nil {
|
||||
if err := s.repo.CreateUserWithEmailVerification(&user, verifiedCode.ID, time.Now()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if err := s.repo.Create(&user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.host.EnsureSignupBonus(user.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.createAuthSession(&user)
|
||||
}
|
||||
|
||||
func (s *Service) Login(req LoginRequest) (*AuthSessionResult, error) {
|
||||
account := strings.TrimSpace(req.Username)
|
||||
user, err := s.repo.UserByAccount(account)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, kernel.Unauthorized("用户名、邮箱或密码不正确")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if user.Status != model.UserStatusActive {
|
||||
return nil, kernel.Forbidden("该账号已被禁用")
|
||||
}
|
||||
if !verifyPassword(req.Password, user.PasswordHash) {
|
||||
return nil, kernel.Unauthorized("用户名、邮箱或密码不正确")
|
||||
}
|
||||
now := time.Now()
|
||||
user.LastLoginAt = &now
|
||||
user.UpdatedAt = now
|
||||
if err := s.repo.Save(user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.host.EnsureSignupBonus(user.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.host.RecordActivity(user.ID, "login", 1)
|
||||
return s.createAuthSession(user)
|
||||
}
|
||||
|
||||
func (s *Service) Logout(cookieValue string) error {
|
||||
sessionID, _ := parseSessionCookie(cookieValue)
|
||||
if sessionID == "" {
|
||||
return nil
|
||||
}
|
||||
return s.repo.DeleteAuthSession(sessionID)
|
||||
}
|
||||
|
||||
func (s *Service) CurrentUser(cookieValue string) (*model.User, error) {
|
||||
sessionID, token := parseSessionCookie(cookieValue)
|
||||
if sessionID == "" || token == "" {
|
||||
return nil, kernel.Unauthorized("请先登录")
|
||||
}
|
||||
session, err := s.repo.AuthSession(sessionID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, kernel.Unauthorized("登录状态已失效")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if time.Now().After(session.ExpiresAt) || session.TokenHash != HashToken(token) {
|
||||
if cleanupErr := s.repo.DeleteAuthSession(sessionID); cleanupErr != nil {
|
||||
log.Printf("expired auth session cleanup failed: session_id=%s error=%v", sessionID, cleanupErr)
|
||||
}
|
||||
return nil, kernel.Unauthorized("登录状态已失效")
|
||||
}
|
||||
user, err := s.repo.User(session.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user.Status != model.UserStatusActive {
|
||||
return nil, kernel.Forbidden("该账号已被禁用")
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// 认证响应只补充当前用户自己的第三方公开身份,不把身份表或密钥字段暴露给其他列表接口。
|
||||
func (s *Service) PublicAuthUser(user *model.User) (AuthUser, error) {
|
||||
result := AuthUser{User: *user}
|
||||
identity, err := s.repo.UserIdentityForUser(user.ID, "linuxdo")
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return result, nil
|
||||
}
|
||||
if err != nil {
|
||||
return AuthUser{}, err
|
||||
}
|
||||
result.AvatarURL = identity.AvatarURL
|
||||
result.IdentityProvider = identity.Provider
|
||||
result.IdentityID = identity.Subject
|
||||
result.IdentityUsername = identity.ProviderUsername
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) createAuthSession(user *model.User) (*AuthSessionResult, error) {
|
||||
publicUser, err := s.PublicAuthUser(user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
token := RandomToken()
|
||||
now := time.Now()
|
||||
session := model.AuthSession{
|
||||
ID: kernel.NewID(),
|
||||
UserID: user.ID,
|
||||
TokenHash: HashToken(token),
|
||||
ExpiresAt: now.Add(sessionMaxAge),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := s.repo.Create(&session); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &AuthSessionResult{User: publicUser, Session: session.ID + "." + token, MaxAgeSecs: int(sessionMaxAge.Seconds())}, nil
|
||||
}
|
||||
|
||||
func HashPassword(password string) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
return string(hash), err
|
||||
}
|
||||
|
||||
func verifyPassword(password string, hash string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||
}
|
||||
|
||||
func HashToken(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func RandomToken() string {
|
||||
var b [32]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return kernel.NewID() + kernel.NewID()
|
||||
}
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
func parseSessionCookie(value string) (string, string) {
|
||||
parts := strings.SplitN(value, ".", 2)
|
||||
if len(parts) != 2 {
|
||||
return "", ""
|
||||
}
|
||||
return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])
|
||||
}
|
||||
|
||||
func NormalizeUsername(value string) string {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func NormalizeEmail(value string) string {
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
}
|
||||
|
||||
func NormalizeDisplayName(value string, fallback string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
value = fallback
|
||||
}
|
||||
runes := []rune(value)
|
||||
if len(runes) > 40 {
|
||||
value = string(runes[:40])
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func ValidateUsername(value string) error {
|
||||
if !usernamePattern.MatchString(value) {
|
||||
return kernel.BadAuthRequest("用户名需为 3-32 位字母、数字、下划线或连字符")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidatePassword(value string) error {
|
||||
if len([]rune(value)) < 8 {
|
||||
return kernel.BadAuthRequest("密码至少 8 位")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateEmail(value string) error {
|
||||
if _, err := mail.ParseAddress(value); err != nil {
|
||||
return kernel.BadAuthRequest("邮箱格式不正确")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestPublicAuthUserIncludesLinuxDOIdentity(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}, &model.UserIdentity{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user := model.User{ID: "user-1", Username: "canvas-user", DisplayName: "Canvas User", Role: model.UserRoleUser, Status: model.UserStatusActive}
|
||||
identity := model.UserIdentity{ID: "identity-1", UserID: user.ID, Provider: "linuxdo", Subject: "123456", ProviderUsername: "linux-user", AvatarURL: "https://example.com/avatar.png"}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&identity).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := (New(repository.New(db), nil, nil)).PublicAuthUser(&user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.AvatarURL != identity.AvatarURL || result.IdentityProvider != "linuxdo" || result.IdentityID != identity.Subject || result.IdentityUsername != identity.ProviderUsername {
|
||||
t.Fatalf("PublicAuthUser() = %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicAuthUserKeepsLocalUserWithoutIdentity(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.UserIdentity{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user := model.User{ID: "user-1", Username: "local-user", DisplayName: "Local User"}
|
||||
|
||||
result, err := (New(repository.New(db), nil, nil)).PublicAuthUser(&user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Username != user.Username || result.AvatarURL != "" || result.IdentityProvider != "" || result.IdentityID != "" {
|
||||
t.Fatalf("PublicAuthUser() = %#v", result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"infinite-canvas/backend/internal/kernel"
|
||||
"log"
|
||||
"math/big"
|
||||
"mime"
|
||||
"net"
|
||||
"net/mail"
|
||||
"net/smtp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const emailSettingKey = "email"
|
||||
const registrationEmailPurpose = "registration"
|
||||
const registrationCodeTTL = 10 * time.Minute
|
||||
|
||||
var defaultRegistrationEmailDomains = []string{
|
||||
"gmail.com",
|
||||
"163.com",
|
||||
"126.com",
|
||||
"qq.com",
|
||||
"outlook.com",
|
||||
"hotmail.com",
|
||||
"icloud.com",
|
||||
"yahoo.com",
|
||||
"foxmail.com",
|
||||
}
|
||||
|
||||
type EmailSettingRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Encryption string `json:"encryption"`
|
||||
FromEmail string `json:"fromEmail"`
|
||||
FromName string `json:"fromName"`
|
||||
RegistrationAllowedDomains []string `json:"registrationAllowedDomains"`
|
||||
}
|
||||
|
||||
type PublicEmailSetting struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
Encryption string `json:"encryption"`
|
||||
FromEmail string `json:"fromEmail"`
|
||||
FromName string `json:"fromName"`
|
||||
FromNameInherited bool `json:"fromNameInherited"`
|
||||
HasPassword bool `json:"hasPassword"`
|
||||
RegistrationAllowedDomains []string `json:"registrationAllowedDomains"`
|
||||
UpdatedBy string `json:"updatedBy"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type EmailSettingValue struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Encryption string `json:"encryption"`
|
||||
FromEmail string `json:"fromEmail"`
|
||||
FromName string `json:"fromName"`
|
||||
RegistrationAllowedDomains []string `json:"registrationAllowedDomains"`
|
||||
}
|
||||
|
||||
func (s *Service) AdminEmailSetting(actor *model.User) (*PublicEmailSetting, error) {
|
||||
if err := s.host.RequireAdmin(actor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setting, value, err := s.readEmailSetting()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.publicEmailSetting(setting, value), nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateEmailSetting(actor *model.User, req EmailSettingRequest) (*PublicEmailSetting, error) {
|
||||
if err := s.host.RequireAdmin(actor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
currentSetting, current, err := s.readEmailSetting()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
allowedDomains := req.RegistrationAllowedDomains
|
||||
if allowedDomains == nil {
|
||||
allowedDomains = current.RegistrationAllowedDomains
|
||||
}
|
||||
next := normalizeEmailSetting(EmailSettingValue{
|
||||
Enabled: req.Enabled, Host: req.Host, Port: req.Port, Username: req.Username, Password: req.Password,
|
||||
Encryption: req.Encryption, FromEmail: req.FromEmail, FromName: req.FromName,
|
||||
RegistrationAllowedDomains: allowedDomains,
|
||||
})
|
||||
if next.Password == "" {
|
||||
next.Password = current.Password
|
||||
}
|
||||
if err := validateEmailSetting(next); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stored := next
|
||||
stored.Password, err = s.host.EncryptSecret(next.Password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
encoded, err := json.Marshal(stored)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setting := model.SystemSetting{Key: emailSettingKey, ValueJSON: string(encoded), UpdatedBy: actor.ID}
|
||||
if currentSetting != nil {
|
||||
setting.CreatedAt = currentSetting.CreatedAt
|
||||
}
|
||||
if err := s.repo.SaveSystemSetting(&setting); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.publicEmailSetting(&setting, next), nil
|
||||
}
|
||||
|
||||
func (s *Service) EmailEnabled() (bool, error) {
|
||||
_, value, err := s.readEmailSetting()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return value.Enabled && value.Host != "" && value.Port > 0 && value.FromEmail != "", nil
|
||||
}
|
||||
|
||||
func (s *Service) SendRegistrationEmailCode(rawEmail string) error {
|
||||
email := NormalizeEmail(rawEmail)
|
||||
if err := ValidateEmail(email); err != nil {
|
||||
return err
|
||||
}
|
||||
count, err := s.repo.UserCount()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return kernel.BadAuthRequest("首个管理员账号不需要邮箱验证码")
|
||||
}
|
||||
registrationEnabled, err := s.RegistrationEnabled()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !registrationEnabled {
|
||||
return kernel.Forbidden("管理员未开放新用户注册")
|
||||
}
|
||||
if _, err := s.repo.UserByEmail(email); err == nil {
|
||||
return kernel.BadAuthRequest("邮箱已被注册")
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
_, setting, err := s.readEmailSetting()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRegistrationEmailDomain(email, setting.RegistrationAllowedDomains); err != nil {
|
||||
return err
|
||||
}
|
||||
if !setting.Enabled {
|
||||
return kernel.Forbidden("平台尚未启用注册邮件,请联系管理员")
|
||||
}
|
||||
s.emailCodeMu.Lock()
|
||||
defer s.emailCodeMu.Unlock()
|
||||
if latest, err := s.repo.LatestEmailVerificationCode(email, registrationEmailPurpose); err == nil && time.Since(latest.CreatedAt) < time.Minute {
|
||||
seconds := max(1, int((time.Until(latest.CreatedAt.Add(time.Minute))+time.Second-1)/time.Second))
|
||||
return &EmailCodeCooldownError{Seconds: seconds}
|
||||
} else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
code, err := randomNumericCode(6)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
codeHash, err := s.emailVerificationCodeHash(registrationEmailPurpose, email, code)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
record := model.EmailVerificationCode{ID: kernel.NewID(), Email: email, CodeHash: codeHash, Purpose: registrationEmailPurpose, ExpiresAt: now.Add(registrationCodeTTL), CreatedAt: now}
|
||||
if err := s.repo.Create(&record); err != nil {
|
||||
return err
|
||||
}
|
||||
setting = resolveEmailSender(setting, s.host.BrandName())
|
||||
if err := s.deliverEmail(setting, email, setting.FromName+"注册验证码", registrationEmailBody(setting.FromName, code)); err != nil {
|
||||
cleanupErr := s.repo.DeleteEmailVerificationCode(record.ID)
|
||||
if cleanupErr != nil {
|
||||
return errors.Join(
|
||||
fmt.Errorf("发送注册邮件失败:%w", err),
|
||||
fmt.Errorf("清理失效验证码失败:%w", cleanupErr),
|
||||
)
|
||||
}
|
||||
return fmt.Errorf("发送注册邮件失败:%w", err)
|
||||
}
|
||||
if cleanupErr := s.repo.DeleteExpiredEmailVerificationCodes(now.Add(-24 * time.Hour)); cleanupErr != nil {
|
||||
log.Printf("expired registration code cleanup failed: error=%v", cleanupErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) VerifyRegistrationEmailCode(email string, rawCode string) (*model.EmailVerificationCode, error) {
|
||||
emailEnabled, err := s.EmailEnabled()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !emailEnabled {
|
||||
return nil, kernel.Forbidden("平台尚未启用注册邮件,请联系管理员")
|
||||
}
|
||||
code := strings.TrimSpace(rawCode)
|
||||
if len(code) != 6 {
|
||||
return nil, kernel.BadAuthRequest("请输入 6 位邮箱验证码")
|
||||
}
|
||||
record, err := s.repo.LatestEmailVerificationCode(email, registrationEmailPurpose)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, kernel.BadAuthRequest("请先获取邮箱验证码")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if time.Now().After(record.ExpiresAt) {
|
||||
return nil, kernel.BadAuthRequest("邮箱验证码已过期,请重新获取")
|
||||
}
|
||||
hash, err := s.emailVerificationCodeHash(registrationEmailPurpose, email, code)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !hmac.Equal([]byte(hash), []byte(record.CodeHash)) {
|
||||
return nil, kernel.BadAuthRequest("邮箱验证码不正确")
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (s *Service) emailVerificationCodeHash(purpose string, email string, code string) (string, error) {
|
||||
key, err := s.host.SettingsEncryptionKey()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
mac := hmac.New(sha256.New, key)
|
||||
_, _ = mac.Write([]byte(strings.TrimSpace(purpose) + ":" + NormalizeEmail(email) + ":" + strings.TrimSpace(code)))
|
||||
return hex.EncodeToString(mac.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func (s *Service) readEmailSetting() (*model.SystemSetting, EmailSettingValue, error) {
|
||||
setting, err := s.repo.SystemSetting(emailSettingKey)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, normalizeEmailSetting(EmailSettingValue{}), nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, EmailSettingValue{}, err
|
||||
}
|
||||
value := EmailSettingValue{}
|
||||
if err := json.Unmarshal([]byte(setting.ValueJSON), &value); err != nil {
|
||||
return nil, EmailSettingValue{}, errors.New("邮件配置格式无效")
|
||||
}
|
||||
value.Password, err = s.host.DecryptSecret(value.Password)
|
||||
if err != nil {
|
||||
return nil, EmailSettingValue{}, err
|
||||
}
|
||||
return setting, normalizeEmailSetting(value), nil
|
||||
}
|
||||
|
||||
func validateEmailSetting(value EmailSettingValue) error {
|
||||
if err := validateRegistrationEmailDomains(value.RegistrationAllowedDomains); err != nil {
|
||||
return err
|
||||
}
|
||||
if !value.Enabled {
|
||||
return nil
|
||||
}
|
||||
if value.Host == "" || value.Port < 1 || value.Port > 65535 || value.FromEmail == "" {
|
||||
return kernel.BadAuthRequest("启用邮件前请完整填写 SMTP 主机、端口和发件邮箱")
|
||||
}
|
||||
if err := ValidateEmail(value.FromEmail); err != nil {
|
||||
return kernel.BadAuthRequest("发件邮箱格式不正确")
|
||||
}
|
||||
if value.Username != "" && value.Password == "" {
|
||||
return kernel.BadAuthRequest("SMTP 用户名已填写,请同时填写密码")
|
||||
}
|
||||
if strings.ContainsAny(value.FromName, "\r\n") {
|
||||
return kernel.BadAuthRequest("发件人名称不能包含换行")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeEmailSetting(value EmailSettingValue) EmailSettingValue {
|
||||
value.Host = strings.TrimSpace(value.Host)
|
||||
value.Username = strings.TrimSpace(value.Username)
|
||||
value.Password = strings.TrimSpace(value.Password)
|
||||
value.FromEmail = NormalizeEmail(value.FromEmail)
|
||||
value.FromName = strings.TrimSpace(value.FromName)
|
||||
if value.RegistrationAllowedDomains == nil {
|
||||
value.RegistrationAllowedDomains = append([]string(nil), defaultRegistrationEmailDomains...)
|
||||
}
|
||||
value.RegistrationAllowedDomains = normalizeEmailDomains(value.RegistrationAllowedDomains)
|
||||
if value.Port == 0 {
|
||||
value.Port = 587
|
||||
}
|
||||
switch value.Encryption {
|
||||
case "tls", "none":
|
||||
default:
|
||||
value.Encryption = "starttls"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func (s *Service) publicEmailSetting(setting *model.SystemSetting, value EmailSettingValue) *PublicEmailSetting {
|
||||
inherited := value.FromName == "" || value.FromName == DefaultBrandName
|
||||
value = resolveEmailSender(value, s.host.BrandName())
|
||||
result := &PublicEmailSetting{
|
||||
Enabled: value.Enabled, Host: value.Host, Port: value.Port, Username: value.Username, Encryption: value.Encryption,
|
||||
FromEmail: value.FromEmail, FromName: value.FromName, FromNameInherited: inherited, HasPassword: value.Password != "",
|
||||
RegistrationAllowedDomains: value.RegistrationAllowedDomains,
|
||||
}
|
||||
if setting != nil {
|
||||
result.UpdatedBy = setting.UpdatedBy
|
||||
result.CreatedAt = setting.CreatedAt
|
||||
result.UpdatedAt = setting.UpdatedAt
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeEmailDomains(values []string) []string {
|
||||
result := make([]string, 0, len(values))
|
||||
seen := make(map[string]bool, len(values))
|
||||
for _, raw := range values {
|
||||
value := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(raw), "@")))
|
||||
value = strings.TrimSuffix(value, ".")
|
||||
if value == "" || seen[value] {
|
||||
continue
|
||||
}
|
||||
seen[value] = true
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func validateRegistrationEmailDomains(allowed []string) error {
|
||||
if len(allowed) > 100 {
|
||||
return kernel.BadAuthRequest("电子邮件域名白名单最多填写 100 项")
|
||||
}
|
||||
for _, domain := range allowed {
|
||||
if !validEmailDomain(domain) {
|
||||
return kernel.BadAuthRequest("电子邮件域名格式不正确:" + domain)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validEmailDomain(value string) bool {
|
||||
if len(value) > 253 || !strings.Contains(value, ".") || strings.HasPrefix(value, ".") || strings.HasSuffix(value, ".") {
|
||||
return false
|
||||
}
|
||||
for _, label := range strings.Split(value, ".") {
|
||||
if label == "" || len(label) > 63 || strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") {
|
||||
return false
|
||||
}
|
||||
for _, char := range label {
|
||||
if (char < 'a' || char > 'z') && (char < '0' || char > '9') && char != '-' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Service) validateRegistrationEmailDomain(email string) error {
|
||||
_, setting, err := s.readEmailSetting()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return validateRegistrationEmailDomain(email, setting.RegistrationAllowedDomains)
|
||||
}
|
||||
|
||||
func validateRegistrationEmailDomain(email string, allowedDomains []string) error {
|
||||
parts := strings.Split(email, "@")
|
||||
if len(parts) != 2 {
|
||||
return kernel.BadAuthRequest("邮箱格式不正确")
|
||||
}
|
||||
domain := strings.ToLower(parts[1])
|
||||
if len(allowedDomains) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, allowed := range allowedDomains {
|
||||
if domain == allowed {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return kernel.BadAuthRequest("该邮箱域名不在管理员设置的白名单内")
|
||||
}
|
||||
|
||||
func resolveEmailSender(value EmailSettingValue, brandName string) EmailSettingValue {
|
||||
if value.FromName == "" || value.FromName == DefaultBrandName {
|
||||
value.FromName = brandName
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func sendSMTPMail(setting EmailSettingValue, recipient string, subject string, body string) error {
|
||||
address := net.JoinHostPort(setting.Host, strconv.Itoa(setting.Port))
|
||||
tlsConfig := &tls.Config{ServerName: setting.Host, MinVersion: tls.VersionTLS12}
|
||||
dialer := &net.Dialer{Timeout: 12 * time.Second}
|
||||
var client *smtp.Client
|
||||
var err error
|
||||
if setting.Encryption == "tls" {
|
||||
connection, dialErr := tls.DialWithDialer(dialer, "tcp", address, tlsConfig)
|
||||
if dialErr != nil {
|
||||
return dialErr
|
||||
}
|
||||
client, err = smtp.NewClient(connection, setting.Host)
|
||||
} else {
|
||||
connection, dialErr := dialer.Dial("tcp", address)
|
||||
if dialErr != nil {
|
||||
return dialErr
|
||||
}
|
||||
client, err = smtp.NewClient(connection, setting.Host)
|
||||
if err == nil && setting.Encryption == "starttls" {
|
||||
err = client.StartTLS(tlsConfig)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
if setting.Username != "" {
|
||||
if err := client.Auth(smtp.PlainAuth("", setting.Username, setting.Password, setting.Host)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := client.Mail(setting.FromEmail); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := client.Rcpt(recipient); err != nil {
|
||||
return err
|
||||
}
|
||||
wc, err := client.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
from := mail.Address{Name: setting.FromName, Address: setting.FromEmail}
|
||||
message := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s", from.String(), recipient, mime.QEncoding.Encode("UTF-8", subject), body)
|
||||
if _, err := wc.Write([]byte(message)); err != nil {
|
||||
_ = wc.Close()
|
||||
return err
|
||||
}
|
||||
if err := wc.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return client.Quit()
|
||||
}
|
||||
|
||||
func (s *Service) deliverEmail(setting EmailSettingValue, recipient string, subject string, body string) error {
|
||||
if s.mailSender != nil {
|
||||
return s.mailSender(setting, recipient, subject, body)
|
||||
}
|
||||
return sendSMTPMail(setting, recipient, subject, body)
|
||||
}
|
||||
|
||||
func randomNumericCode(length int) (string, error) {
|
||||
limit := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(length)), nil)
|
||||
value, err := rand.Int(rand.Reader, limit)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%0*d", length, value.Int64()), nil
|
||||
}
|
||||
|
||||
func registrationEmailBody(brandName string, code string) string {
|
||||
return "你正在注册" + brandName + "。\n\n验证码:" + code + "\n\n验证码 10 分钟内有效。若非本人操作,请忽略本邮件。"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package auth
|
||||
|
||||
import "fmt"
|
||||
|
||||
type EmailCodeCooldownError struct{ Seconds int }
|
||||
|
||||
func (e *EmailCodeCooldownError) Error() string {
|
||||
return fmt.Sprintf("验证码已发送,请查看邮箱;%d 秒后可以重新获取", e.Seconds)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
)
|
||||
|
||||
func TestRegistrationDomainPolicyChecksSendingAndRegistration(t *testing.T) {
|
||||
svc, db := newPasswordResetTestService(t)
|
||||
admin := &model.User{ID: "admin", Username: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
|
||||
if err := db.Create(admin).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&model.SystemSetting{Key: registrationSettingKey, ValueJSON: `{"enabled":true}`}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var deliveredCode string
|
||||
sends := 0
|
||||
svc.SetMailSender(func(_ EmailSettingValue, _, _ string, body string) error {
|
||||
sends++
|
||||
deliveredCode = codeFromEmailBody(body)
|
||||
return nil
|
||||
})
|
||||
if err := svc.SendRegistrationEmailCode("member@blocked.example"); err == nil || !strings.Contains(err.Error(), "白名单") {
|
||||
t.Fatalf("blocked domain must not receive registration code: %v", err)
|
||||
}
|
||||
var codes int64
|
||||
if err := db.Model(&model.EmailVerificationCode{}).Count(&codes).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sends != 0 || codes != 0 {
|
||||
t.Fatalf("blocked send created side effects: sends=%d codes=%d", sends, codes)
|
||||
}
|
||||
if err := svc.SendRegistrationEmailCode("member@example.com"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if deliveredCode == "" || sends != 1 {
|
||||
t.Fatal("allowed domain did not receive a code")
|
||||
}
|
||||
// A code issued before an administrator tightens the policy must not bypass it.
|
||||
if _, err := svc.UpdateEmailSetting(admin, EmailSettingRequest{RegistrationAllowedDomains: []string{"gmail.com"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.Register(RegisterRequest{Username: "member", Email: "member@example.com", Password: "strong-password", EmailCode: deliveredCode}); err == nil || !strings.Contains(err.Error(), "白名单") {
|
||||
t.Fatalf("registration must recheck current domain policy: %v", err)
|
||||
}
|
||||
count, err := svc.repo.UserCount()
|
||||
if err != nil || count != 1 {
|
||||
t.Fatalf("blocked registration created a user: count=%d err=%v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailDomainSettingsRoundTripAndValidation(t *testing.T) {
|
||||
svc, _ := newPasswordResetTestService(t)
|
||||
admin := &model.User{ID: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
|
||||
setting, err := svc.UpdateEmailSetting(admin, EmailSettingRequest{RegistrationAllowedDomains: []string{" @GMAIL.COM. ", "gmail.com"}})
|
||||
if err != nil || setting == nil || len(setting.RegistrationAllowedDomains) != 1 || setting.RegistrationAllowedDomains[0] != "gmail.com" {
|
||||
t.Fatalf("normalization failed: setting=%+v err=%v", setting, err)
|
||||
}
|
||||
if _, err := svc.UpdateEmailSetting(admin, EmailSettingRequest{RegistrationAllowedDomains: []string{"bad_domain"}}); err == nil {
|
||||
t.Fatal("invalid whitelist accepted while SMTP is disabled")
|
||||
}
|
||||
setting, err = svc.AdminEmailSetting(admin)
|
||||
if err != nil || setting == nil || len(setting.RegistrationAllowedDomains) != 1 || setting.RegistrationAllowedDomains[0] != "gmail.com" {
|
||||
t.Fatalf("failed update changed the saved whitelist: setting=%+v err=%v", setting, err)
|
||||
}
|
||||
if _, err := svc.UpdateEmailSetting(admin, EmailSettingRequest{RegistrationAllowedDomains: []string{}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setting, err = svc.AdminEmailSetting(admin)
|
||||
if err != nil || setting == nil || setting.RegistrationAllowedDomains == nil || len(setting.RegistrationAllowedDomains) != 0 {
|
||||
t.Fatalf("explicit empty whitelist must survive persistence: setting=%+v err=%v", setting, err)
|
||||
}
|
||||
if err := svc.validateRegistrationEmailDomain("member@custom.example"); err != nil {
|
||||
t.Fatalf("empty saved whitelist should allow custom domains: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeEmailSettingDefaultsToMainstreamRegistrationDomains(t *testing.T) {
|
||||
setting := normalizeEmailSetting(EmailSettingValue{})
|
||||
for _, domain := range []string{"gmail.com", "163.com", "126.com", "qq.com", "outlook.com", "hotmail.com", "icloud.com", "yahoo.com", "foxmail.com"} {
|
||||
if !containsEmailDomain(setting.RegistrationAllowedDomains, domain) {
|
||||
t.Fatalf("default allowed domains = %#v, missing %q", setting.RegistrationAllowedDomains, domain)
|
||||
}
|
||||
}
|
||||
|
||||
setting = normalizeEmailSetting(EmailSettingValue{RegistrationAllowedDomains: []string{}})
|
||||
if len(setting.RegistrationAllowedDomains) != 0 {
|
||||
t.Fatalf("explicit empty allowed domains = %#v, want unrestricted", setting.RegistrationAllowedDomains)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRegistrationEmailDomainUsesExactWhitelistMatch(t *testing.T) {
|
||||
allowed := []string{"gmail.com", "example.org"}
|
||||
for _, email := range []string{"member@gmail.com", "member@example.org"} {
|
||||
if err := validateRegistrationEmailDomain(email, allowed); err != nil {
|
||||
t.Fatalf("%s should be allowed: %v", email, err)
|
||||
}
|
||||
}
|
||||
for _, test := range []struct {
|
||||
email string
|
||||
want string
|
||||
}{
|
||||
{email: "member@fakegmail.com", want: "不在管理员设置的白名单"},
|
||||
{email: "member@blocked.example", want: "不在管理员设置的白名单"},
|
||||
} {
|
||||
if err := validateRegistrationEmailDomain(test.email, allowed); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("validateRegistrationEmailDomain(%q) error = %v", test.email, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRegistrationEmailDomainsRejectsInvalidDomain(t *testing.T) {
|
||||
if err := validateRegistrationEmailDomains([]string{"invalid_domain"}); err == nil {
|
||||
t.Fatal("invalid domain should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRegistrationEmailDomainAllowsAnyDomainWhenWhitelistIsEmpty(t *testing.T) {
|
||||
if err := validateRegistrationEmailDomain("member@example.org", nil); err != nil {
|
||||
t.Fatalf("empty whitelist should not restrict registration: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func containsEmailDomain(values []string, target string) bool {
|
||||
for _, value := range values {
|
||||
if value == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
)
|
||||
|
||||
const DefaultBrandName = "影策"
|
||||
|
||||
// Host 由组合根注入,避免 auth → service 回环。
|
||||
type Host interface {
|
||||
RequireAdmin(user *model.User) error
|
||||
EncryptSecret(value string) (string, error)
|
||||
DecryptSecret(value string) (string, error)
|
||||
SettingsEncryptionKey() ([]byte, error)
|
||||
BrandName() string
|
||||
EnsureSignupBonus(userID string) error
|
||||
RecordActivity(userID string, event string, count int)
|
||||
AllowRequest(ctx context.Context, key string, limit int, window time.Duration) (bool, error)
|
||||
RequestRetryAfter(ctx context.Context, key string, window time.Duration) time.Duration
|
||||
}
|
||||
|
||||
type nopHost struct{}
|
||||
|
||||
func (nopHost) RequireAdmin(user *model.User) error { return nil }
|
||||
func (nopHost) EncryptSecret(value string) (string, error) {
|
||||
return value, nil
|
||||
}
|
||||
func (nopHost) DecryptSecret(value string) (string, error) {
|
||||
return value, nil
|
||||
}
|
||||
func (nopHost) SettingsEncryptionKey() ([]byte, error) { return nil, nil }
|
||||
func (nopHost) BrandName() string { return DefaultBrandName }
|
||||
func (nopHost) EnsureSignupBonus(string) error { return nil }
|
||||
func (nopHost) RecordActivity(string, string, int) {}
|
||||
func (nopHost) AllowRequest(context.Context, string, int, time.Duration) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
func (nopHost) RequestRetryAfter(context.Context, string, time.Duration) time.Duration {
|
||||
return 0
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repo *repository.Repository
|
||||
host Host
|
||||
mailSender func(EmailSettingValue, string, string, string) error
|
||||
emailCodeMu sync.Mutex
|
||||
registrationMu sync.Mutex
|
||||
}
|
||||
|
||||
func New(repo *repository.Repository, host Host, mailSender func(EmailSettingValue, string, string, string) error) *Service {
|
||||
if host == nil {
|
||||
host = nopHost{}
|
||||
}
|
||||
return &Service{repo: repo, host: host, mailSender: mailSender}
|
||||
}
|
||||
|
||||
func (s *Service) SetMailSender(fn func(EmailSettingValue, string, string, string) error) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.mailSender = fn
|
||||
}
|
||||
|
||||
type brandHost struct {
|
||||
nopHost
|
||||
name string
|
||||
}
|
||||
|
||||
func (h brandHost) BrandName() string {
|
||||
if strings.TrimSpace(h.name) == "" {
|
||||
return DefaultBrandName
|
||||
}
|
||||
return h.name
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"infinite-canvas/backend/internal/kernel"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/outbound"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
libTVSettingKey = "libtv"
|
||||
libTVDetailURL = "https://api.liblib.tv/api/canvas/project/detail"
|
||||
libTVMaxResponseBytes = 8 << 20
|
||||
)
|
||||
|
||||
type LibTVSettingRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Token string `json:"token"`
|
||||
ClearToken bool `json:"clearToken"`
|
||||
}
|
||||
|
||||
type PublicLibTVSetting struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
HasToken bool `json:"hasToken"`
|
||||
UpdatedAt time.Time `json:"updatedAt,omitempty"`
|
||||
}
|
||||
|
||||
type libTVSettingValue struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type LibTVImportRequest struct {
|
||||
UUID string `json:"uuid"`
|
||||
}
|
||||
|
||||
type LibTVImportResult struct {
|
||||
BatchID string `json:"batchId"`
|
||||
BatchCreatedAt time.Time `json:"batchCreatedAt"`
|
||||
ProjectUUID string `json:"projectUuid"`
|
||||
ProjectName string `json:"projectName"`
|
||||
Nodes []LibTVCanvasNode `json:"nodes"`
|
||||
Connections []LibTVCanvasConnection `json:"connections"`
|
||||
ImportedNodeCount int `json:"importedNodeCount"`
|
||||
ImportedConnectionCount int `json:"importedConnectionCount"`
|
||||
SkippedNodes []LibTVImportIssue `json:"skippedNodes"`
|
||||
SkippedConnections []LibTVImportIssue `json:"skippedConnections"`
|
||||
Warnings []LibTVImportWarning `json:"warnings"`
|
||||
MultiResultNodeCount int `json:"multiResultNodeCount"`
|
||||
StaleNodeCount int `json:"staleNodeCount"`
|
||||
ReusedFailedNodeCount int `json:"reusedFailedNodeCount"`
|
||||
PlaceholderNodeCount int `json:"placeholderNodeCount"`
|
||||
ConvertedSpecialCount int `json:"convertedSpecialCount"`
|
||||
}
|
||||
|
||||
type LibTVCanvasNode struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Width float64 `json:"width"`
|
||||
Height float64 `json:"height"`
|
||||
Content string `json:"content"`
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
NaturalWidth int `json:"naturalWidth,omitempty"`
|
||||
NaturalHeight int `json:"naturalHeight,omitempty"`
|
||||
DurationMs int64 `json:"durationMs,omitempty"`
|
||||
MimeType string `json:"mimeType,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
ErrorDetails string `json:"errorDetails,omitempty"`
|
||||
Metadata LibTVImportMetadata `json:"metadata"`
|
||||
}
|
||||
|
||||
type LibTVImportMetadata struct {
|
||||
Provider string `json:"provider"`
|
||||
ProjectUUID string `json:"projectUuid"`
|
||||
NodeKey string `json:"nodeKey"`
|
||||
BatchID string `json:"batchId"`
|
||||
SourceType string `json:"sourceType,omitempty"`
|
||||
StyleAssetUUID string `json:"styleAssetUuid,omitempty"`
|
||||
StyleVersionUUID string `json:"styleVersionUuid,omitempty"`
|
||||
StyleName string `json:"styleName,omitempty"`
|
||||
}
|
||||
|
||||
type LibTVCanvasConnection struct {
|
||||
ID string `json:"id"`
|
||||
FromNodeID string `json:"fromNodeId"`
|
||||
ToNodeID string `json:"toNodeId"`
|
||||
}
|
||||
|
||||
type LibTVImportIssue struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type LibTVImportWarning struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type libTVEnvelope struct {
|
||||
Code int `json:"code"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
|
||||
type libTVDetail struct {
|
||||
ProjectMeta struct {
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Effective struct {
|
||||
CanRead bool `json:"canRead"`
|
||||
CanCopy bool `json:"canCopy"`
|
||||
} `json:"effective"`
|
||||
} `json:"projectMeta"`
|
||||
NodeList []libTVRawNode `json:"nodeList"`
|
||||
ConnectionList []libTVRawConnection `json:"connectionList"`
|
||||
}
|
||||
|
||||
type libTVRawNode struct {
|
||||
NodeKey string `json:"nodeKey"`
|
||||
Name string `json:"name"`
|
||||
Data string `json:"data"`
|
||||
Position struct {
|
||||
X string `json:"positionX"`
|
||||
Y string `json:"positionY"`
|
||||
} `json:"position"`
|
||||
Measured struct {
|
||||
Width string `json:"width"`
|
||||
Height string `json:"height"`
|
||||
} `json:"measured"`
|
||||
TaskInfo struct {
|
||||
Status int `json:"status"`
|
||||
FailedReason string `json:"failedReason"`
|
||||
} `json:"taskInfo"`
|
||||
IsStale bool `json:"isStale"`
|
||||
}
|
||||
|
||||
type libTVRawConnection struct {
|
||||
ConnectionID string `json:"connectionId"`
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target"`
|
||||
}
|
||||
|
||||
type libTVNodeData struct {
|
||||
Type string `json:"type"`
|
||||
URL []string `json:"url"`
|
||||
CoverURL string `json:"coverUrl"`
|
||||
StyleAssetUUID string `json:"styleAssetUuid"`
|
||||
StyleVersionUUID string `json:"styleVersionUuid"`
|
||||
StyleName string `json:"styleName"`
|
||||
Params map[string]any `json:"params"`
|
||||
TaskInfo struct {
|
||||
Status int `json:"status"`
|
||||
FailedReason string `json:"failedReason"`
|
||||
} `json:"taskInfo"`
|
||||
IsStale bool `json:"isStale"`
|
||||
ResourceMeta struct {
|
||||
Items []struct {
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
DurationSec float64 `json:"durationSec"`
|
||||
} `json:"items"`
|
||||
} `json:"_resourceMeta"`
|
||||
}
|
||||
|
||||
func (s *Service) AdminLibTVSetting(actor *model.User) (*PublicLibTVSetting, error) {
|
||||
if err := s.host.RequireAdmin(actor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setting, value, err := s.readLibTVSetting()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return publicLibTVSetting(setting, value), nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateLibTVSetting(actor *model.User, req LibTVSettingRequest) (*PublicLibTVSetting, error) {
|
||||
if err := s.host.RequireAdmin(actor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, current, err := s.readLibTVSetting()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
token := strings.TrimSpace(req.Token)
|
||||
if req.ClearToken {
|
||||
token = ""
|
||||
} else if token == "" {
|
||||
token = current.Token
|
||||
}
|
||||
if req.Enabled && token == "" {
|
||||
return nil, kernel.BadAuthRequest("启用 LibTV 前请先配置 Token")
|
||||
}
|
||||
protected, err := s.host.EncryptSecret(token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
valueJSON, err := json.Marshal(libTVSettingValue{Enabled: req.Enabled, Token: protected})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setting := &model.SystemSetting{Key: libTVSettingKey, ValueJSON: string(valueJSON), UpdatedBy: actor.ID}
|
||||
if existing, lookupErr := s.repo.SystemSetting(libTVSettingKey); lookupErr == nil {
|
||||
setting.CreatedAt = existing.CreatedAt
|
||||
} else if !errors.Is(lookupErr, gorm.ErrRecordNotFound) {
|
||||
return nil, lookupErr
|
||||
}
|
||||
if err := s.repo.SaveSystemSetting(setting); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return publicLibTVSetting(setting, libTVSettingValue{Enabled: req.Enabled, Token: token}), nil
|
||||
}
|
||||
|
||||
func (s *Service) TestLibTV(actor *model.User, projectUUID string) error {
|
||||
if actor == nil {
|
||||
return kernel.Unauthorized("请先登录")
|
||||
}
|
||||
if err := s.host.RequireAdmin(actor); err != nil {
|
||||
return err
|
||||
}
|
||||
_, value, err := s.readLibTVSetting()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if value.Token == "" {
|
||||
return kernel.BadAuthRequest("尚未配置 LibTV Token")
|
||||
}
|
||||
_, err = s.fetchLibTVDetail(strings.TrimSpace(projectUUID), value.Token)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) ImportLibTV(userID, canvasProjectID, projectUUID string) (*LibTVImportResult, error) {
|
||||
userID = strings.TrimSpace(userID)
|
||||
canvasProjectID = strings.TrimSpace(canvasProjectID)
|
||||
if userID == "" || canvasProjectID == "" {
|
||||
return nil, kernel.Unauthorized("请先打开已同步的" + s.host.BrandName() + "画布")
|
||||
}
|
||||
if _, err := s.repo.CanvasProjectForUser(userID, canvasProjectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, value, err := s.readLibTVSetting()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !value.Enabled || value.Token == "" {
|
||||
return nil, kernel.BadAuthRequest("LibTV 尚未启用或未配置 Token")
|
||||
}
|
||||
detail, err := s.fetchLibTVDetail(strings.TrimSpace(projectUUID), value.Token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return adaptLibTVDetail(detail)
|
||||
}
|
||||
|
||||
func (s *Service) readLibTVSetting() (*model.SystemSetting, libTVSettingValue, error) {
|
||||
setting, err := s.repo.SystemSetting(libTVSettingKey)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, libTVSettingValue{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, libTVSettingValue{}, err
|
||||
}
|
||||
value := libTVSettingValue{}
|
||||
if err := json.Unmarshal([]byte(setting.ValueJSON), &value); err != nil {
|
||||
return nil, value, errors.New("LibTV 配置格式无效")
|
||||
}
|
||||
if value.Token != "" {
|
||||
plain, err := s.host.DecryptSecret(value.Token)
|
||||
if err != nil {
|
||||
return nil, value, err
|
||||
}
|
||||
value.Token = plain
|
||||
}
|
||||
return setting, value, nil
|
||||
}
|
||||
|
||||
func publicLibTVSetting(setting *model.SystemSetting, value libTVSettingValue) *PublicLibTVSetting {
|
||||
result := &PublicLibTVSetting{Enabled: value.Enabled, HasToken: strings.TrimSpace(value.Token) != ""}
|
||||
if setting != nil {
|
||||
result.UpdatedAt = setting.UpdatedAt
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Service) fetchLibTVDetail(projectUUID, token string) (*libTVDetail, error) {
|
||||
projectUUID = strings.TrimSpace(projectUUID)
|
||||
if !isLibTVProjectUUID(projectUUID) {
|
||||
return nil, kernel.BadAuthRequest("LibTV 画布 UUID 格式无效")
|
||||
}
|
||||
u, err := url.Parse(libTVDetailURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query := u.Query()
|
||||
query.Set("uuid", projectUUID)
|
||||
u.RawQuery = query.Encode()
|
||||
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("token", token)
|
||||
outbound.ApplyDefaultOutboundHeaders(req)
|
||||
client := outbound.OutboundHTTPClient(20 * time.Second)
|
||||
// Token 是 LibTV 专用凭证,禁止重定向以避免自定义请求头被带到其他主机。
|
||||
client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error {
|
||||
return errors.New("LibTV API 不允许重定向")
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, errors.New("LibTV 请求失败,请检查网络或 Token")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, libTVMaxResponseBytes+1))
|
||||
if err != nil {
|
||||
return nil, errors.New("读取 LibTV 响应失败")
|
||||
}
|
||||
if len(body) > libTVMaxResponseBytes {
|
||||
return nil, errors.New("LibTV 响应过大")
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("LibTV 请求失败(HTTP %d)", resp.StatusCode)
|
||||
}
|
||||
var envelope libTVEnvelope
|
||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||
return nil, errors.New("LibTV 响应格式无效")
|
||||
}
|
||||
if envelope.Code != 0 {
|
||||
msg := strings.TrimSpace(envelope.Msg)
|
||||
if msg == "" {
|
||||
msg = "LibTV 返回业务错误"
|
||||
}
|
||||
return nil, errors.New(msg)
|
||||
}
|
||||
var detail libTVDetail
|
||||
if err := json.Unmarshal(envelope.Data, &detail); err != nil {
|
||||
return nil, errors.New("LibTV 画布数据格式无效")
|
||||
}
|
||||
if strings.TrimSpace(detail.ProjectMeta.UUID) == "" {
|
||||
detail.ProjectMeta.UUID = projectUUID
|
||||
}
|
||||
if !detail.ProjectMeta.Effective.CanRead || !detail.ProjectMeta.Effective.CanCopy {
|
||||
return nil, errors.New("当前 LibTV 画布不允许复制")
|
||||
}
|
||||
return &detail, nil
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"infinite-canvas/backend/internal/kernel"
|
||||
"math"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func adaptLibTVDetail(detail *libTVDetail) (*LibTVImportResult, error) {
|
||||
if detail == nil {
|
||||
return nil, errors.New("LibTV 画布数据为空")
|
||||
}
|
||||
now := time.Now()
|
||||
batchID := strconv.FormatInt(now.UnixMilli(), 36) + "-" + strings.ReplaceAll(uuid.NewString(), "-", "")[:8]
|
||||
result := &LibTVImportResult{
|
||||
BatchID: batchID,
|
||||
BatchCreatedAt: now,
|
||||
ProjectUUID: detail.ProjectMeta.UUID,
|
||||
ProjectName: detail.ProjectMeta.Name,
|
||||
Nodes: make([]LibTVCanvasNode, 0),
|
||||
Connections: make([]LibTVCanvasConnection, 0),
|
||||
SkippedNodes: make([]LibTVImportIssue, 0),
|
||||
SkippedConnections: make([]LibTVImportIssue, 0),
|
||||
Warnings: make([]LibTVImportWarning, 0),
|
||||
}
|
||||
mapping := make(map[string]string, len(detail.NodeList))
|
||||
seenNodeKeys := make(map[string]struct{}, len(detail.NodeList))
|
||||
for _, raw := range detail.NodeList {
|
||||
nodeKey := strings.TrimSpace(raw.NodeKey)
|
||||
if nodeKey == "" {
|
||||
result.SkippedNodes = append(result.SkippedNodes, LibTVImportIssue{Name: raw.Name, Reason: "节点缺少 nodeKey"})
|
||||
continue
|
||||
}
|
||||
if _, exists := seenNodeKeys[nodeKey]; exists {
|
||||
result.SkippedNodes = append(result.SkippedNodes, LibTVImportIssue{ID: nodeKey, Name: raw.Name, Reason: "节点 nodeKey 重复"})
|
||||
continue
|
||||
}
|
||||
seenNodeKeys[nodeKey] = struct{}{}
|
||||
|
||||
var data libTVNodeData
|
||||
if err := json.Unmarshal([]byte(raw.Data), &data); err != nil {
|
||||
result.SkippedNodes = append(result.SkippedNodes, LibTVImportIssue{ID: nodeKey, Name: raw.Name, Reason: "节点数据格式无效"})
|
||||
continue
|
||||
}
|
||||
taskStatus := data.TaskInfo.Status
|
||||
if taskStatus == 0 && raw.TaskInfo.Status != 0 {
|
||||
taskStatus = raw.TaskInfo.Status
|
||||
}
|
||||
sourceType := strings.ToLower(strings.TrimSpace(data.Type))
|
||||
kind := sourceType
|
||||
if sourceType == "material-style" {
|
||||
// 风格素材没有普通生成 URL,但有可复用封面时可以降级为宿主图片参考节点。
|
||||
kind = "image"
|
||||
} else if kind != "image" && kind != "video" {
|
||||
result.SkippedNodes = append(result.SkippedNodes, LibTVImportIssue{ID: nodeKey, Name: raw.Name, Reason: "暂不支持的节点类型"})
|
||||
continue
|
||||
}
|
||||
mediaURL, mediaIndex := firstLibTVMediaURL(data.URL)
|
||||
if mediaURL == "" && sourceType == "material-style" {
|
||||
mediaURL, mediaIndex = firstLibTVMediaURL([]string{data.CoverURL})
|
||||
}
|
||||
|
||||
x, okX := finiteFloat(raw.Position.X)
|
||||
y, okY := finiteFloat(raw.Position.Y)
|
||||
if !okX || !okY {
|
||||
result.SkippedNodes = append(result.SkippedNodes, LibTVImportIssue{ID: nodeKey, Name: raw.Name, Reason: "节点坐标无效"})
|
||||
continue
|
||||
}
|
||||
width, okWidth := positiveFiniteFloat(raw.Measured.Width)
|
||||
height, okHeight := positiveFiniteFloat(raw.Measured.Height)
|
||||
if !okWidth {
|
||||
width = 480
|
||||
}
|
||||
if !okHeight {
|
||||
height = 300
|
||||
}
|
||||
item := LibTVCanvasNode{
|
||||
ID: "libtv-" + batchID + "-" + nodeKey,
|
||||
Type: kind,
|
||||
Title: strings.TrimSpace(raw.Name),
|
||||
X: x,
|
||||
Y: y,
|
||||
Width: width,
|
||||
Height: height,
|
||||
Content: mediaURL,
|
||||
Prompt: mapString(data.Params, "prompt"),
|
||||
Model: mapString(data.Params, "model"),
|
||||
Metadata: LibTVImportMetadata{
|
||||
Provider: "libtv",
|
||||
ProjectUUID: result.ProjectUUID,
|
||||
NodeKey: nodeKey,
|
||||
BatchID: batchID,
|
||||
SourceType: sourceType,
|
||||
StyleAssetUUID: strings.TrimSpace(data.StyleAssetUUID),
|
||||
StyleVersionUUID: strings.TrimSpace(data.StyleVersionUUID),
|
||||
StyleName: strings.TrimSpace(data.StyleName),
|
||||
},
|
||||
}
|
||||
if item.Title == "" {
|
||||
item.Title = kind + " 节点"
|
||||
}
|
||||
if mediaIndex >= 0 && mediaIndex < len(data.ResourceMeta.Items) {
|
||||
resource := data.ResourceMeta.Items[mediaIndex]
|
||||
item.NaturalWidth = resource.Width
|
||||
item.NaturalHeight = resource.Height
|
||||
if resource.DurationSec > 0 && !math.IsNaN(resource.DurationSec) && !math.IsInf(resource.DurationSec, 0) {
|
||||
item.DurationMs = int64(math.Round(resource.DurationSec * 1000))
|
||||
}
|
||||
}
|
||||
item.MimeType = inferMediaMime(kind, mediaURL)
|
||||
if mediaURL == "" {
|
||||
result.PlaceholderNodeCount++
|
||||
if taskStatus == 3 {
|
||||
// 没有历史结果的失败节点仍保留拓扑,并以失败占位状态呈现。
|
||||
item.Status = "error"
|
||||
item.ErrorDetails = kernel.FirstNonEmpty(data.TaskInfo.FailedReason, raw.TaskInfo.FailedReason, "LibTV 生成任务失败")
|
||||
} else {
|
||||
item.Status = "idle"
|
||||
}
|
||||
} else {
|
||||
item.Status = "success"
|
||||
if taskStatus == 3 {
|
||||
// 最近一次任务失败不应覆盖节点中仍可用的历史资源。
|
||||
result.ReusedFailedNodeCount++
|
||||
}
|
||||
}
|
||||
mapping[nodeKey] = item.ID
|
||||
result.Nodes = append(result.Nodes, item)
|
||||
if sourceType == "material-style" {
|
||||
result.ConvertedSpecialCount++
|
||||
}
|
||||
if !okWidth || !okHeight {
|
||||
result.Warnings = append(result.Warnings, LibTVImportWarning{ID: nodeKey, Message: fmt.Sprintf("节点“%s”尺寸无效,已使用默认尺寸。", item.Title)})
|
||||
}
|
||||
if len(data.URL) > 1 && mediaURL != "" {
|
||||
result.MultiResultNodeCount++
|
||||
result.Warnings = append(result.Warnings, LibTVImportWarning{ID: nodeKey, Message: fmt.Sprintf("节点“%s”包含 %d 个结果,已导入第 %d 个。", item.Title, len(data.URL), mediaIndex+1)})
|
||||
}
|
||||
if data.IsStale || raw.IsStale {
|
||||
result.StaleNodeCount++
|
||||
result.Warnings = append(result.Warnings, LibTVImportWarning{ID: nodeKey, Message: fmt.Sprintf("节点“%s”已标记为过期,但现有资源仍已导入。", item.Title)})
|
||||
}
|
||||
}
|
||||
|
||||
seenConnectionIDs := make(map[string]struct{}, len(detail.ConnectionList))
|
||||
for _, raw := range detail.ConnectionList {
|
||||
connectionID := strings.TrimSpace(raw.ConnectionID)
|
||||
if connectionID == "" {
|
||||
result.SkippedConnections = append(result.SkippedConnections, LibTVImportIssue{Reason: "连接缺少 connectionId"})
|
||||
continue
|
||||
}
|
||||
if _, exists := seenConnectionIDs[connectionID]; exists {
|
||||
result.SkippedConnections = append(result.SkippedConnections, LibTVImportIssue{ID: connectionID, Reason: "连接 connectionId 重复"})
|
||||
continue
|
||||
}
|
||||
seenConnectionIDs[connectionID] = struct{}{}
|
||||
from, okFrom := mapping[strings.TrimSpace(raw.Source)]
|
||||
to, okTo := mapping[strings.TrimSpace(raw.Target)]
|
||||
if !okFrom || !okTo {
|
||||
result.SkippedConnections = append(result.SkippedConnections, LibTVImportIssue{ID: connectionID, Reason: "连接端点节点未导入"})
|
||||
continue
|
||||
}
|
||||
result.Connections = append(result.Connections, LibTVCanvasConnection{ID: "libtv-" + batchID + "-" + connectionID, FromNodeID: from, ToNodeID: to})
|
||||
}
|
||||
result.ImportedNodeCount = len(result.Nodes)
|
||||
result.ImportedConnectionCount = len(result.Connections)
|
||||
if result.ImportedNodeCount == 0 {
|
||||
return nil, errors.New("LibTV 画布没有可导入的有效节点")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func mapString(values map[string]any, key string) string {
|
||||
value, _ := values[key].(string)
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func isLibTVProjectUUID(value string) bool {
|
||||
if len(value) != 32 {
|
||||
return false
|
||||
}
|
||||
for _, char := range value {
|
||||
if !((char >= '0' && char <= '9') || (char >= 'a' && char <= 'f') || (char >= 'A' && char <= 'F')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func firstLibTVMediaURL(values []string) (string, int) {
|
||||
for index, value := range values {
|
||||
parsed, err := url.Parse(strings.TrimSpace(value))
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Hostname() == "" || parsed.User != nil {
|
||||
continue
|
||||
}
|
||||
return parsed.String(), index
|
||||
}
|
||||
return "", -1
|
||||
}
|
||||
|
||||
func finiteFloat(value string) (float64, bool) {
|
||||
parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
|
||||
if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) {
|
||||
return 0, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
func positiveFiniteFloat(value string) (float64, bool) {
|
||||
parsed, ok := finiteFloat(value)
|
||||
return parsed, ok && parsed > 0
|
||||
}
|
||||
|
||||
func inferMediaMime(kind, rawURL string) string {
|
||||
lower := strings.ToLower(rawURL)
|
||||
if strings.Contains(lower, ".png") {
|
||||
return "image/png"
|
||||
}
|
||||
if strings.Contains(lower, ".jpg") || strings.Contains(lower, ".jpeg") {
|
||||
return "image/jpeg"
|
||||
}
|
||||
if strings.Contains(lower, ".webp") {
|
||||
return "image/webp"
|
||||
}
|
||||
if kind == "video" {
|
||||
return "video/mp4"
|
||||
}
|
||||
return "image/*"
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAdaptLibTVDetailSelectsFirstMediaAndReportsPartialImport(t *testing.T) {
|
||||
detail := &libTVDetail{}
|
||||
detail.ProjectMeta.UUID = "project-uuid"
|
||||
detail.ProjectMeta.Name = "测试画布"
|
||||
detail.NodeList = []libTVRawNode{
|
||||
{
|
||||
NodeKey: "image-1",
|
||||
Name: "多图节点",
|
||||
Data: `{"type":"image","url":["https://example.com/first.png","https://example.com/second.png"],"params":{"prompt":"prompt","model":"model"},"_resourceMeta":{"items":[{"width":1024,"height":768}]}}`,
|
||||
Position: struct {
|
||||
X string `json:"positionX"`
|
||||
Y string `json:"positionY"`
|
||||
}{X: "12", Y: "34"},
|
||||
Measured: struct {
|
||||
Width string `json:"width"`
|
||||
Height string `json:"height"`
|
||||
}{Width: "640", Height: "360"},
|
||||
},
|
||||
{
|
||||
NodeKey: "failed-video",
|
||||
Name: "失败视频",
|
||||
Data: `{"type":"video","url":[],"taskInfo":{"status":3,"failedReason":"生成失败"}}`,
|
||||
Position: struct {
|
||||
X string `json:"positionX"`
|
||||
Y string `json:"positionY"`
|
||||
}{X: "500", Y: "600"},
|
||||
},
|
||||
{
|
||||
NodeKey: "stale-video",
|
||||
Name: "过期视频",
|
||||
Data: `{"type":"video","url":["https://example.com/video.mp4"],"taskInfo":{"status":2},"isStale":true,"_resourceMeta":{"items":[{"width":1920,"height":1080,"durationSec":5.2506}]}}`,
|
||||
Position: struct {
|
||||
X string `json:"positionX"`
|
||||
Y string `json:"positionY"`
|
||||
}{X: "900", Y: "1200"},
|
||||
Measured: struct {
|
||||
Width string `json:"width"`
|
||||
Height string `json:"height"`
|
||||
}{Width: "640", Height: "360"},
|
||||
},
|
||||
}
|
||||
detail.ConnectionList = []libTVRawConnection{
|
||||
{ConnectionID: "valid", Source: "image-1", Target: "stale-video"},
|
||||
{ConnectionID: "dangling", Source: "image-1", Target: "failed-video"},
|
||||
}
|
||||
|
||||
result, err := adaptLibTVDetail(detail)
|
||||
if err != nil {
|
||||
t.Fatalf("adaptLibTVDetail() error = %v", err)
|
||||
}
|
||||
if result.ImportedNodeCount != 3 || result.ImportedConnectionCount != 2 {
|
||||
t.Fatalf("counts = (%d, %d), want (3, 2)", result.ImportedNodeCount, result.ImportedConnectionCount)
|
||||
}
|
||||
if len(result.SkippedNodes) != 0 || len(result.SkippedConnections) != 0 {
|
||||
t.Fatalf("unexpected skipped items: nodes=%#v connections=%#v", result.SkippedNodes, result.SkippedConnections)
|
||||
}
|
||||
if result.PlaceholderNodeCount != 1 || result.ReusedFailedNodeCount != 0 {
|
||||
t.Fatalf("failure handling counts = (placeholder=%d, reused=%d), want (1, 0)", result.PlaceholderNodeCount, result.ReusedFailedNodeCount)
|
||||
}
|
||||
if result.BatchCreatedAt.IsZero() {
|
||||
t.Fatal("batch created time must not be zero")
|
||||
}
|
||||
if got := result.Nodes[0]; got.Content != "https://example.com/first.png" || got.X != 12 || got.Y != 34 || got.Width != 640 || got.Height != 360 {
|
||||
t.Fatalf("mapped image = %#v", got)
|
||||
}
|
||||
if got := result.Nodes[0]; got.Prompt != "prompt" || got.Model != "model" || got.NaturalWidth != 1024 || got.NaturalHeight != 768 {
|
||||
t.Fatalf("mapped image metadata = %#v", got)
|
||||
}
|
||||
if got := result.Nodes[1]; got.DurationMs != 0 || got.MimeType != "video/mp4" || got.Status != "error" || got.ErrorDetails != "生成失败" {
|
||||
t.Fatalf("mapped video metadata = %#v", got)
|
||||
}
|
||||
if got := result.Nodes[0].Metadata.Provider; got != "libtv" {
|
||||
t.Fatalf("metadata provider = %q, want libtv", got)
|
||||
}
|
||||
if len(result.Warnings) != 3 {
|
||||
t.Fatalf("warnings = %#v", result.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdaptLibTVDetailKeepsFailedNodeWithHistoricalMedia(t *testing.T) {
|
||||
detail := &libTVDetail{NodeList: []libTVRawNode{{
|
||||
NodeKey: "failed-image",
|
||||
Data: `{"type":"image","url":["https://example.com/history.png"],"taskInfo":{"status":3,"failedReason":"本次生成失败"}}`,
|
||||
Position: struct {
|
||||
X string `json:"positionX"`
|
||||
Y string `json:"positionY"`
|
||||
}{X: "10", Y: "20"},
|
||||
}}}
|
||||
|
||||
result, err := adaptLibTVDetail(detail)
|
||||
if err != nil {
|
||||
t.Fatalf("adaptLibTVDetail() error = %v", err)
|
||||
}
|
||||
if len(result.Nodes) != 1 || result.Nodes[0].Content != "https://example.com/history.png" {
|
||||
t.Fatalf("historical media node = %#v", result.Nodes)
|
||||
}
|
||||
if result.Nodes[0].Status != "success" || result.ReusedFailedNodeCount != 1 {
|
||||
t.Fatalf("failure reuse = node=%#v count=%d", result.Nodes[0], result.ReusedFailedNodeCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdaptLibTVDetailConvertsMaterialStyleToImage(t *testing.T) {
|
||||
detail := &libTVDetail{NodeList: []libTVRawNode{{
|
||||
NodeKey: "style-1",
|
||||
Name: "风格素材",
|
||||
Data: `{"type":"material-style","coverUrl":"https://example.com/style.png","styleAssetUuid":"asset-1","styleVersionUuid":"version-1","styleName":"电影感"}`,
|
||||
Position: struct {
|
||||
X string `json:"positionX"`
|
||||
Y string `json:"positionY"`
|
||||
}{X: "1", Y: "2"},
|
||||
}}}
|
||||
|
||||
result, err := adaptLibTVDetail(detail)
|
||||
if err != nil {
|
||||
t.Fatalf("adaptLibTVDetail() error = %v", err)
|
||||
}
|
||||
if len(result.Nodes) != 1 {
|
||||
t.Fatalf("nodes = %#v", result.Nodes)
|
||||
}
|
||||
node := result.Nodes[0]
|
||||
if node.Type != "image" || node.Content != "https://example.com/style.png" || result.ConvertedSpecialCount != 1 {
|
||||
t.Fatalf("converted style node = %#v count=%d", node, result.ConvertedSpecialCount)
|
||||
}
|
||||
if node.Metadata.StyleAssetUUID != "asset-1" || node.Metadata.StyleVersionUUID != "version-1" || node.Metadata.StyleName != "电影感" {
|
||||
t.Fatalf("style metadata = %#v", node.Metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdaptLibTVDetailGeneratesDistinctBatchIDs(t *testing.T) {
|
||||
detail := &libTVDetail{}
|
||||
detail.ProjectMeta.UUID = "project-uuid"
|
||||
detail.NodeList = []libTVRawNode{{
|
||||
NodeKey: "image-1",
|
||||
Data: `{"type":"image","url":["https://example.com/image.png"]}`,
|
||||
Position: struct {
|
||||
X string `json:"positionX"`
|
||||
Y string `json:"positionY"`
|
||||
}{X: "0", Y: "0"},
|
||||
}}
|
||||
|
||||
first, err := adaptLibTVDetail(detail)
|
||||
if err != nil {
|
||||
t.Fatalf("first adaptLibTVDetail() error = %v", err)
|
||||
}
|
||||
second, err := adaptLibTVDetail(detail)
|
||||
if err != nil {
|
||||
t.Fatalf("second adaptLibTVDetail() error = %v", err)
|
||||
}
|
||||
if first.BatchID == second.BatchID {
|
||||
t.Fatalf("batch IDs are not unique: %q", first.BatchID)
|
||||
}
|
||||
if !strings.HasPrefix(first.Nodes[0].ID, "libtv-"+first.BatchID+"-") {
|
||||
t.Fatalf("node ID %q does not contain batch ID %q", first.Nodes[0].ID, first.BatchID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdaptLibTVDetailRejectsCanvasWithoutImportableNodes(t *testing.T) {
|
||||
detail := &libTVDetail{NodeList: []libTVRawNode{{
|
||||
NodeKey: "unsupported",
|
||||
Data: `{"type":"text","url":[]}`,
|
||||
}}}
|
||||
|
||||
if _, err := adaptLibTVDetail(detail); err == nil {
|
||||
t.Fatal("adaptLibTVDetail() error = nil, want an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdaptLibTVDetailKeepsReportListsNonNilAndResourceIndex(t *testing.T) {
|
||||
detail := &libTVDetail{}
|
||||
detail.ProjectMeta.UUID = "project-uuid"
|
||||
detail.NodeList = []libTVRawNode{{
|
||||
NodeKey: "image-1",
|
||||
Data: `{"type":"image","url":["http://example.com/rejected.png","https://example.com/selected.png"],"_resourceMeta":{"items":[{"width":100,"height":100},{"width":200,"height":300}]}}`,
|
||||
Position: struct {
|
||||
X string `json:"positionX"`
|
||||
Y string `json:"positionY"`
|
||||
}{X: "10", Y: "20"},
|
||||
Measured: struct {
|
||||
Width string `json:"width"`
|
||||
Height string `json:"height"`
|
||||
}{Width: "200", Height: "300"},
|
||||
}}
|
||||
|
||||
result, err := adaptLibTVDetail(detail)
|
||||
if err != nil {
|
||||
t.Fatalf("adaptLibTVDetail() error = %v", err)
|
||||
}
|
||||
if result.SkippedNodes == nil || result.SkippedConnections == nil || result.Warnings == nil {
|
||||
t.Fatalf("report slices must not be nil: %#v", result)
|
||||
}
|
||||
if got := result.Nodes[0]; got.Content != "https://example.com/selected.png" || got.NaturalWidth != 200 || got.NaturalHeight != 300 {
|
||||
t.Fatalf("selected media metadata = %#v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"infinite-canvas/backend/internal/kernel"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/outbound"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const linuxDOSettingKey = "linuxdo_oauth"
|
||||
|
||||
var oauthUsernameSanitizer = regexp.MustCompile(`[^a-zA-Z0-9_-]+`)
|
||||
|
||||
type LinuxDOSettingRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
ClientID string `json:"clientId"`
|
||||
ClientSecret string `json:"clientSecret"`
|
||||
AuthorizationURL string `json:"authorizationUrl"`
|
||||
TokenURL string `json:"tokenUrl"`
|
||||
UserInfoURL string `json:"userInfoUrl"`
|
||||
RedirectURL string `json:"redirectUrl"`
|
||||
Scopes []string `json:"scopes"`
|
||||
ClientAuthMethod string `json:"clientAuthMethod"`
|
||||
SubjectField string `json:"subjectField"`
|
||||
UsernameField string `json:"usernameField"`
|
||||
DisplayNameField string `json:"displayNameField"`
|
||||
EmailField string `json:"emailField"`
|
||||
AvatarField string `json:"avatarField"`
|
||||
}
|
||||
|
||||
type PublicLinuxDOSetting struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
ClientID string `json:"clientId"`
|
||||
HasClientSecret bool `json:"hasClientSecret"`
|
||||
AuthorizationURL string `json:"authorizationUrl"`
|
||||
TokenURL string `json:"tokenUrl"`
|
||||
UserInfoURL string `json:"userInfoUrl"`
|
||||
RedirectURL string `json:"redirectUrl"`
|
||||
Scopes []string `json:"scopes"`
|
||||
ClientAuthMethod string `json:"clientAuthMethod"`
|
||||
SubjectField string `json:"subjectField"`
|
||||
UsernameField string `json:"usernameField"`
|
||||
DisplayNameField string `json:"displayNameField"`
|
||||
EmailField string `json:"emailField"`
|
||||
AvatarField string `json:"avatarField"`
|
||||
UpdatedBy string `json:"updatedBy"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type linuxDOSettingValue struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
ClientID string `json:"clientId"`
|
||||
ClientSecret string `json:"clientSecret"`
|
||||
AuthorizationURL string `json:"authorizationUrl"`
|
||||
TokenURL string `json:"tokenUrl"`
|
||||
UserInfoURL string `json:"userInfoUrl"`
|
||||
RedirectURL string `json:"redirectUrl"`
|
||||
Scopes []string `json:"scopes"`
|
||||
ClientAuthMethod string `json:"clientAuthMethod"`
|
||||
SubjectField string `json:"subjectField"`
|
||||
UsernameField string `json:"usernameField"`
|
||||
DisplayNameField string `json:"displayNameField"`
|
||||
EmailField string `json:"emailField"`
|
||||
AvatarField string `json:"avatarField"`
|
||||
}
|
||||
|
||||
type LinuxDOCallbackResult struct {
|
||||
Session *AuthSessionResult
|
||||
Next string
|
||||
}
|
||||
|
||||
func (s *Service) AdminLinuxDOSetting(actor *model.User) (*PublicLinuxDOSetting, error) {
|
||||
if err := s.host.RequireAdmin(actor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setting, value, err := s.readLinuxDOSetting()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return publicLinuxDOSetting(setting, value), nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateLinuxDOSetting(actor *model.User, req LinuxDOSettingRequest) (*PublicLinuxDOSetting, error) {
|
||||
if err := s.host.RequireAdmin(actor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
currentSetting, current, err := s.readLinuxDOSetting()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
next := normalizeLinuxDOSetting(linuxDOSettingValue{
|
||||
Enabled: req.Enabled, ClientID: req.ClientID, ClientSecret: req.ClientSecret,
|
||||
AuthorizationURL: req.AuthorizationURL, TokenURL: req.TokenURL, UserInfoURL: req.UserInfoURL,
|
||||
RedirectURL: req.RedirectURL, Scopes: req.Scopes, ClientAuthMethod: req.ClientAuthMethod,
|
||||
SubjectField: req.SubjectField, UsernameField: req.UsernameField, DisplayNameField: req.DisplayNameField,
|
||||
EmailField: req.EmailField, AvatarField: req.AvatarField,
|
||||
})
|
||||
if next.ClientSecret == "" {
|
||||
next.ClientSecret = current.ClientSecret
|
||||
}
|
||||
if err := validateLinuxDOSetting(next); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stored := next
|
||||
stored.ClientSecret, err = s.host.EncryptSecret(next.ClientSecret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
encoded, err := json.Marshal(stored)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setting := model.SystemSetting{Key: linuxDOSettingKey, ValueJSON: string(encoded), UpdatedBy: actor.ID}
|
||||
if currentSetting != nil {
|
||||
setting.CreatedAt = currentSetting.CreatedAt
|
||||
}
|
||||
if err := s.repo.SaveSystemSetting(&setting); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return publicLinuxDOSetting(&setting, next), nil
|
||||
}
|
||||
|
||||
func (s *Service) LinuxDOEnabled() bool {
|
||||
_, setting, err := s.readLinuxDOSetting()
|
||||
return err == nil && setting.Enabled
|
||||
}
|
||||
|
||||
func (s *Service) BeginLinuxDOLogin(nextPath string) (string, error) {
|
||||
count, err := s.repo.UserCount()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if count == 0 {
|
||||
return "", kernel.Forbidden("请先创建本地管理员账号,再开放 Linux.do 登录")
|
||||
}
|
||||
_, setting, err := s.readLinuxDOSetting()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !setting.Enabled {
|
||||
return "", kernel.Forbidden("Linux.do 登录尚未启用")
|
||||
}
|
||||
state := RandomToken()
|
||||
verifier := RandomToken()
|
||||
challengeBytes := sha256.Sum256([]byte(verifier))
|
||||
challenge := base64.RawURLEncoding.EncodeToString(challengeBytes[:])
|
||||
if err := s.repo.CreateOAuthState(&model.OAuthState{
|
||||
ID: kernel.NewID(), Provider: "linuxdo", StateHash: HashToken(state), CodeVerifier: verifier,
|
||||
NextPath: safeOAuthNext(nextPath), ExpiresAt: time.Now().Add(10 * time.Minute),
|
||||
}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
authorizeURL, err := url.Parse(setting.AuthorizationURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
query := authorizeURL.Query()
|
||||
query.Set("client_id", setting.ClientID)
|
||||
query.Set("redirect_uri", setting.RedirectURL)
|
||||
query.Set("response_type", "code")
|
||||
if len(setting.Scopes) > 0 {
|
||||
query.Set("scope", strings.Join(setting.Scopes, " "))
|
||||
}
|
||||
query.Set("state", state)
|
||||
query.Set("code_challenge", challenge)
|
||||
query.Set("code_challenge_method", "S256")
|
||||
authorizeURL.RawQuery = query.Encode()
|
||||
return authorizeURL.String(), nil
|
||||
}
|
||||
|
||||
func (s *Service) CompleteLinuxDOLogin(stateValue string, code string) (*LinuxDOCallbackResult, error) {
|
||||
if strings.TrimSpace(stateValue) == "" || strings.TrimSpace(code) == "" {
|
||||
return nil, kernel.BadAuthRequest("Linux.do 登录回调缺少必要参数")
|
||||
}
|
||||
state, err := s.repo.ConsumeOAuthState("linuxdo", HashToken(stateValue))
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, kernel.BadAuthRequest("Linux.do 登录状态无效或已过期")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, setting, err := s.readLinuxDOSetting()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
accessToken, err := exchangeLinuxDOCode(setting, code, state.CodeVerifier)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
profile, err := fetchLinuxDOProfile(setting, accessToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
subject := profileString(profile, setting.SubjectField)
|
||||
if subject == "" {
|
||||
return nil, errors.New("Linux.do 用户信息缺少稳定用户 ID")
|
||||
}
|
||||
providerUsername := profileString(profile, setting.UsernameField)
|
||||
displayName := kernel.FirstNonEmpty(profileString(profile, setting.DisplayNameField), providerUsername, "Linux.do 用户")
|
||||
avatarURL := profileString(profile, setting.AvatarField)
|
||||
identity, err := s.repo.UserIdentity("linuxdo", subject)
|
||||
var user *model.User
|
||||
if err == nil {
|
||||
user, err = s.repo.User(identity.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
identity.ProviderUsername = providerUsername
|
||||
identity.AvatarURL = avatarURL
|
||||
identity.UpdatedAt = time.Now()
|
||||
if err := s.repo.Save(identity); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
registrationEnabled, settingErr := s.RegistrationEnabled()
|
||||
if settingErr != nil {
|
||||
return nil, settingErr
|
||||
}
|
||||
if !registrationEnabled {
|
||||
return nil, kernel.Forbidden("管理员未开放新用户注册")
|
||||
}
|
||||
user, identity, err = s.createLinuxDOUser(subject, providerUsername, displayName, profileString(profile, setting.EmailField), avatarURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.repo.CreateOAuthUser(user, identity); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
if user.Status != model.UserStatusActive {
|
||||
return nil, kernel.Forbidden("该账号已被禁用")
|
||||
}
|
||||
if err := s.host.EnsureSignupBonus(user.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now()
|
||||
user.LastLoginAt = &now
|
||||
user.UpdatedAt = now
|
||||
if err := s.repo.Save(user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.host.RecordActivity(user.ID, "login", 1)
|
||||
session, err := s.createAuthSession(user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &LinuxDOCallbackResult{Session: session, Next: safeOAuthNext(state.NextPath)}, nil
|
||||
}
|
||||
|
||||
func (s *Service) createLinuxDOUser(subject string, providerUsername string, displayName string, email string, avatarURL string) (*model.User, *model.UserIdentity, error) {
|
||||
base := oauthUsernameSanitizer.ReplaceAllString(strings.TrimSpace(providerUsername), "_")
|
||||
base = strings.Trim(base, "_-")
|
||||
if len(base) < 3 {
|
||||
base = "linuxdo_" + shortSubject(subject)
|
||||
}
|
||||
if len(base) > 24 {
|
||||
base = base[:24]
|
||||
}
|
||||
username := base
|
||||
if existing, err := s.repo.UserByUsername(username); err == nil && existing != nil {
|
||||
username = kernel.TruncateRunes(base, 23) + "_" + shortSubject(subject)
|
||||
} else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil, err
|
||||
}
|
||||
email = NormalizeEmail(email)
|
||||
if email != "" {
|
||||
if ValidateEmail(email) != nil {
|
||||
email = ""
|
||||
} else if existing, err := s.repo.UserByEmail(email); err == nil && existing != nil {
|
||||
// 相同邮箱不自动合并身份,避免第三方邮箱状态不明导致账号接管。
|
||||
email = ""
|
||||
} else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
user := &model.User{ID: kernel.NewID(), Username: username, Email: email, DisplayName: NormalizeDisplayName(displayName, username), Role: model.UserRoleUser, Status: model.UserStatusActive}
|
||||
identity := &model.UserIdentity{ID: kernel.NewID(), UserID: user.ID, Provider: "linuxdo", Subject: subject, ProviderUsername: providerUsername, AvatarURL: avatarURL}
|
||||
return user, identity, nil
|
||||
}
|
||||
|
||||
func (s *Service) readLinuxDOSetting() (*model.SystemSetting, linuxDOSettingValue, error) {
|
||||
setting, err := s.repo.SystemSetting(linuxDOSettingKey)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, defaultLinuxDOSetting(), nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, linuxDOSettingValue{}, err
|
||||
}
|
||||
value := defaultLinuxDOSetting()
|
||||
if strings.TrimSpace(setting.ValueJSON) != "" {
|
||||
if err := json.Unmarshal([]byte(setting.ValueJSON), &value); err != nil {
|
||||
return nil, linuxDOSettingValue{}, errors.New("Linux.do OAuth 配置格式无效")
|
||||
}
|
||||
}
|
||||
value.ClientSecret, err = s.host.DecryptSecret(value.ClientSecret)
|
||||
if err != nil {
|
||||
return nil, linuxDOSettingValue{}, err
|
||||
}
|
||||
return setting, normalizeLinuxDOSetting(value), nil
|
||||
}
|
||||
|
||||
func validateLinuxDOSetting(value linuxDOSettingValue) error {
|
||||
if !value.Enabled {
|
||||
return nil
|
||||
}
|
||||
if value.ClientID == "" || value.ClientSecret == "" || value.AuthorizationURL == "" || value.TokenURL == "" || value.UserInfoURL == "" || value.RedirectURL == "" {
|
||||
return kernel.BadAuthRequest("启用 Linux.do 登录前请完整填写 Client、端点和回调配置")
|
||||
}
|
||||
for _, rawURL := range []string{value.AuthorizationURL, value.TokenURL, value.UserInfoURL} {
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Host == "" {
|
||||
return kernel.BadAuthRequest("Linux.do 授权、Token 和用户信息地址必须是有效的 HTTPS URL")
|
||||
}
|
||||
}
|
||||
redirectURL, err := url.Parse(value.RedirectURL)
|
||||
if err != nil || redirectURL.Host == "" || (redirectURL.Scheme != "https" && !(redirectURL.Scheme == "http" && isLoopbackOAuthHost(redirectURL.Hostname()))) {
|
||||
return kernel.BadAuthRequest("Linux.do 回调地址必须使用 HTTPS,本地回环地址可使用 HTTP")
|
||||
}
|
||||
if value.ClientAuthMethod != "client_secret_post" && value.ClientAuthMethod != "client_secret_basic" {
|
||||
return kernel.BadAuthRequest("请选择有效的 OAuth 客户端鉴权方式")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isLoopbackOAuthHost(host string) bool {
|
||||
host = strings.ToLower(strings.TrimSpace(host))
|
||||
return host == "localhost" || host == "127.0.0.1" || host == "::1"
|
||||
}
|
||||
|
||||
func normalizeLinuxDOSetting(value linuxDOSettingValue) linuxDOSettingValue {
|
||||
value.ClientID = strings.TrimSpace(value.ClientID)
|
||||
value.ClientSecret = strings.TrimSpace(value.ClientSecret)
|
||||
value.AuthorizationURL = strings.TrimSpace(value.AuthorizationURL)
|
||||
value.TokenURL = strings.TrimSpace(value.TokenURL)
|
||||
value.UserInfoURL = strings.TrimSpace(value.UserInfoURL)
|
||||
value.RedirectURL = strings.TrimSpace(value.RedirectURL)
|
||||
value.Scopes = kernel.UniqueNonEmpty(value.Scopes)
|
||||
value.ClientAuthMethod = strings.TrimSpace(value.ClientAuthMethod)
|
||||
value.SubjectField = strings.TrimSpace(value.SubjectField)
|
||||
value.UsernameField = strings.TrimSpace(value.UsernameField)
|
||||
value.DisplayNameField = strings.TrimSpace(value.DisplayNameField)
|
||||
value.EmailField = strings.TrimSpace(value.EmailField)
|
||||
value.AvatarField = strings.TrimSpace(value.AvatarField)
|
||||
if value.ClientAuthMethod == "" {
|
||||
value.ClientAuthMethod = "client_secret_post"
|
||||
}
|
||||
if value.SubjectField == "" {
|
||||
value.SubjectField = "id"
|
||||
}
|
||||
if value.UsernameField == "" {
|
||||
value.UsernameField = "username"
|
||||
}
|
||||
if value.DisplayNameField == "" {
|
||||
value.DisplayNameField = "name"
|
||||
}
|
||||
if value.EmailField == "" {
|
||||
value.EmailField = "email"
|
||||
}
|
||||
if value.AvatarField == "" {
|
||||
value.AvatarField = "avatar_url"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func defaultLinuxDOSetting() linuxDOSettingValue {
|
||||
return normalizeLinuxDOSetting(linuxDOSettingValue{})
|
||||
}
|
||||
|
||||
func publicLinuxDOSetting(setting *model.SystemSetting, value linuxDOSettingValue) *PublicLinuxDOSetting {
|
||||
result := &PublicLinuxDOSetting{
|
||||
Enabled: value.Enabled, ClientID: value.ClientID, HasClientSecret: value.ClientSecret != "",
|
||||
AuthorizationURL: value.AuthorizationURL, TokenURL: value.TokenURL, UserInfoURL: value.UserInfoURL,
|
||||
RedirectURL: value.RedirectURL, Scopes: value.Scopes, ClientAuthMethod: value.ClientAuthMethod,
|
||||
SubjectField: value.SubjectField, UsernameField: value.UsernameField, DisplayNameField: value.DisplayNameField,
|
||||
EmailField: value.EmailField, AvatarField: value.AvatarField,
|
||||
}
|
||||
if setting != nil {
|
||||
result.UpdatedBy = setting.UpdatedBy
|
||||
result.CreatedAt = setting.CreatedAt
|
||||
result.UpdatedAt = setting.UpdatedAt
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func exchangeLinuxDOCode(setting linuxDOSettingValue, code string, verifier string) (string, error) {
|
||||
if _, err := outbound.ValidateOutboundURL(setting.TokenURL); err != nil {
|
||||
return "", err
|
||||
}
|
||||
form := url.Values{
|
||||
"grant_type": {"authorization_code"},
|
||||
"code": {code},
|
||||
"client_id": {setting.ClientID},
|
||||
"redirect_uri": {setting.RedirectURL},
|
||||
"code_verifier": {verifier},
|
||||
}
|
||||
if setting.ClientAuthMethod == "client_secret_post" {
|
||||
form.Set("client_secret", setting.ClientSecret)
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, setting.TokenURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if setting.ClientAuthMethod == "client_secret_basic" {
|
||||
req.SetBasicAuth(setting.ClientID, setting.ClientSecret)
|
||||
}
|
||||
resp, err := outbound.OutboundHTTPClient(20 * time.Second).Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Linux.do Token 请求失败:%w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("Linux.do Token 请求失败:HTTP %d", resp.StatusCode)
|
||||
}
|
||||
var payload struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil || payload.AccessToken == "" {
|
||||
return "", errors.New("Linux.do Token 响应无效")
|
||||
}
|
||||
return payload.AccessToken, nil
|
||||
}
|
||||
|
||||
func fetchLinuxDOProfile(setting linuxDOSettingValue, accessToken string) (map[string]any, error) {
|
||||
if _, err := outbound.ValidateOutboundURL(setting.UserInfoURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, setting.UserInfoURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := outbound.OutboundHTTPClient(20 * time.Second).Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Linux.do 用户信息请求失败:%w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("Linux.do 用户信息请求失败:HTTP %d", resp.StatusCode)
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(body))
|
||||
decoder.UseNumber()
|
||||
var profile map[string]any
|
||||
if err := decoder.Decode(&profile); err != nil {
|
||||
return nil, errors.New("Linux.do 用户信息响应无效")
|
||||
}
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
func profileString(profile map[string]any, field string) string {
|
||||
var value any = profile
|
||||
for _, segment := range strings.Split(field, ".") {
|
||||
object, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
value, ok = object[segment]
|
||||
if !ok || value == nil {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprint(value))
|
||||
}
|
||||
|
||||
func safeOAuthNext(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
parsed, err := url.Parse(value)
|
||||
if value == "" || err != nil || parsed.IsAbs() || parsed.Host != "" || !strings.HasPrefix(parsed.Path, "/") || strings.HasPrefix(value, "//") || strings.Contains(value, "\\") {
|
||||
return "/create"
|
||||
}
|
||||
return parsed.RequestURI()
|
||||
}
|
||||
|
||||
func shortSubject(value string) string {
|
||||
sum := sha256.Sum256([]byte(value))
|
||||
return fmt.Sprintf("%x", sum[:4])
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"errors"
|
||||
"infinite-canvas/backend/internal/kernel"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const passwordResetEmailPurpose = "password_reset"
|
||||
const passwordResetCodeTTL = 10 * time.Minute
|
||||
|
||||
type PasswordResetRequest struct {
|
||||
Email string `json:"email"`
|
||||
EmailCode string `json:"emailCode"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func (s *Service) SendPasswordResetEmailCode(rawEmail string) error {
|
||||
email := NormalizeEmail(rawEmail)
|
||||
if err := ValidateEmail(email); err != nil {
|
||||
return err
|
||||
}
|
||||
_, setting, err := s.readEmailSetting()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !setting.Enabled || setting.Host == "" || setting.Port < 1 || setting.FromEmail == "" {
|
||||
return kernel.Forbidden("管理员尚未启用密码找回,请联系管理员")
|
||||
}
|
||||
|
||||
s.emailCodeMu.Lock()
|
||||
defer s.emailCodeMu.Unlock()
|
||||
|
||||
user, err := s.repo.UserByEmail(email)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if user.Status != model.UserStatusActive || strings.TrimSpace(user.PasswordHash) == "" {
|
||||
return nil
|
||||
}
|
||||
if latest, latestErr := s.repo.LatestEmailVerificationCode(email, passwordResetEmailPurpose); latestErr == nil && time.Since(latest.CreatedAt) < time.Minute {
|
||||
return nil
|
||||
} else if latestErr != nil && !errors.Is(latestErr, gorm.ErrRecordNotFound) {
|
||||
return latestErr
|
||||
}
|
||||
|
||||
code, err := randomNumericCode(6)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
codeHash, err := s.emailVerificationCodeHash(passwordResetEmailPurpose, email, code)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
record := model.EmailVerificationCode{
|
||||
ID: kernel.NewID(),
|
||||
Email: email,
|
||||
CodeHash: codeHash,
|
||||
Purpose: passwordResetEmailPurpose,
|
||||
ExpiresAt: now.Add(passwordResetCodeTTL),
|
||||
CreatedAt: now,
|
||||
}
|
||||
if err := s.repo.Create(&record); err != nil {
|
||||
return err
|
||||
}
|
||||
setting = resolveEmailSender(setting, s.host.BrandName())
|
||||
if err := s.deliverEmail(setting, email, setting.FromName+"密码重置验证码", passwordResetEmailBody(setting.FromName, code)); err != nil {
|
||||
if cleanupErr := s.repo.DeleteEmailVerificationCode(record.ID); cleanupErr != nil {
|
||||
log.Printf("password reset email cleanup failed: recipient=%s error=%v", maskedEmail(email), cleanupErr)
|
||||
}
|
||||
log.Printf("password reset email delivery failed: recipient=%s error=%v", maskedEmail(email), err)
|
||||
return nil
|
||||
}
|
||||
if cleanupErr := s.repo.DeleteExpiredEmailVerificationCodes(now.Add(-24 * time.Hour)); cleanupErr != nil {
|
||||
log.Printf("expired password reset code cleanup failed: error=%v", cleanupErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) ResetPassword(req PasswordResetRequest) error {
|
||||
email := NormalizeEmail(req.Email)
|
||||
if err := ValidateEmail(email); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ValidatePassword(req.Password); err != nil {
|
||||
return err
|
||||
}
|
||||
code := strings.TrimSpace(req.EmailCode)
|
||||
if len(code) != 6 {
|
||||
return invalidPasswordResetCode()
|
||||
}
|
||||
|
||||
user, err := s.repo.UserByEmail(email)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return invalidPasswordResetCode()
|
||||
}
|
||||
return err
|
||||
}
|
||||
if user.Status != model.UserStatusActive || strings.TrimSpace(user.PasswordHash) == "" {
|
||||
return invalidPasswordResetCode()
|
||||
}
|
||||
record, err := s.repo.LatestEmailVerificationCode(email, passwordResetEmailPurpose)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return invalidPasswordResetCode()
|
||||
}
|
||||
return err
|
||||
}
|
||||
if time.Now().After(record.ExpiresAt) {
|
||||
return invalidPasswordResetCode()
|
||||
}
|
||||
expectedHash, err := s.emailVerificationCodeHash(passwordResetEmailPurpose, email, code)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !hmac.Equal([]byte(expectedHash), []byte(record.CodeHash)) {
|
||||
return invalidPasswordResetCode()
|
||||
}
|
||||
passwordHash, err := HashPassword(req.Password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.repo.ResetUserPasswordWithEmailVerification(user.ID, email, passwordResetEmailPurpose, record.ID, passwordHash, time.Now()); err != nil {
|
||||
if errors.Is(err, repository.ErrEmailVerificationCodeInvalid) {
|
||||
return invalidPasswordResetCode()
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func invalidPasswordResetCode() *AuthError {
|
||||
return kernel.BadAuthRequest("验证码无效或已过期")
|
||||
}
|
||||
|
||||
func passwordResetEmailBody(brandName string, code string) string {
|
||||
return "你正在重置" + brandName + "账号密码。\n\n验证码:" + code + "\n\n验证码 10 分钟内有效。若非本人操作,请忽略本邮件,并确保邮箱账号安全。"
|
||||
}
|
||||
|
||||
func maskedEmail(email string) string {
|
||||
parts := strings.SplitN(email, "@", 2)
|
||||
if len(parts) != 2 || parts[0] == "" {
|
||||
return "***"
|
||||
}
|
||||
return string([]rune(parts[0])[0]) + "***@" + parts[1]
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"infinite-canvas/backend/internal/kernel"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestPasswordResetChangesPasswordConsumesCodeAndRevokesSessions(t *testing.T) {
|
||||
svc, db := newPasswordResetTestService(t)
|
||||
oldHash, err := HashPassword("old-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Existing users can reset passwords even outside the registration whitelist.
|
||||
user := model.User{ID: "user-1", Username: "creator", Email: "creator@external.example", DisplayName: "Creator", Role: model.UserRoleUser, Status: model.UserStatusActive, PasswordHash: oldHash}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sessions := []model.AuthSession{
|
||||
{ID: "session-1", UserID: user.ID, TokenHash: "one", ExpiresAt: time.Now().Add(time.Hour)},
|
||||
{ID: "session-2", UserID: user.ID, TokenHash: "two", ExpiresAt: time.Now().Add(time.Hour)},
|
||||
}
|
||||
if err := db.Create(&sessions).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var deliveredCode string
|
||||
svc.SetMailSender(func(_ EmailSettingValue, recipient string, subject string, body string) error {
|
||||
if recipient != user.Email || subject != "影策密码重置验证码" {
|
||||
t.Fatalf("unexpected reset email: recipient=%q subject=%q", recipient, subject)
|
||||
}
|
||||
deliveredCode = codeFromEmailBody(body)
|
||||
return nil
|
||||
})
|
||||
if err := svc.SendPasswordResetEmailCode(" Creator@Example.com "); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.SendPasswordResetEmailCode(user.Email); err != nil {
|
||||
t.Fatalf("repeat send should keep the public response generic: %v", err)
|
||||
}
|
||||
if len(deliveredCode) != 6 {
|
||||
t.Fatalf("delivered code = %q", deliveredCode)
|
||||
}
|
||||
var storedCode model.EmailVerificationCode
|
||||
if err := db.Where("email = ? AND purpose = ?", user.Email, passwordResetEmailPurpose).First(&storedCode).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if storedCode.CodeHash == deliveredCode || storedCode.CodeHash == "" {
|
||||
t.Fatalf("verification code was not hashed: %#v", storedCode)
|
||||
}
|
||||
|
||||
if err := svc.ResetPassword(PasswordResetRequest{Email: user.Email, EmailCode: deliveredCode, Password: "new-password"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var updated model.User
|
||||
if err := db.First(&updated, "id = ?", user.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(updated.PasswordHash), []byte("new-password")) != nil {
|
||||
t.Fatal("new password hash does not match")
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(updated.PasswordHash), []byte("old-password")) == nil {
|
||||
t.Fatal("old password still matches")
|
||||
}
|
||||
var sessionCount int64
|
||||
if err := db.Model(&model.AuthSession{}).Where("user_id = ?", user.ID).Count(&sessionCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sessionCount != 0 {
|
||||
t.Fatalf("auth session count = %d, want 0", sessionCount)
|
||||
}
|
||||
if err := db.First(&storedCode, "id = ?", storedCode.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if storedCode.UsedAt == nil {
|
||||
t.Fatal("password reset code was not consumed")
|
||||
}
|
||||
if err := svc.ResetPassword(PasswordResetRequest{Email: user.Email, EmailCode: deliveredCode, Password: "another-password"}); !isInvalidPasswordResetError(err) {
|
||||
t.Fatalf("reused code error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordResetSendDoesNotRevealAccountEligibility(t *testing.T) {
|
||||
svc, db := newPasswordResetTestService(t)
|
||||
hash, err := HashPassword("strong-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
users := []model.User{
|
||||
{ID: "oauth-user", Username: "oauth-user", Email: "oauth@example.com", Status: model.UserStatusActive},
|
||||
{ID: "disabled-user", Username: "disabled-user", Email: "disabled@example.com", Status: model.UserStatusDisabled, PasswordHash: hash},
|
||||
}
|
||||
if err := db.Create(&users).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deliveries := 0
|
||||
svc.SetMailSender(func(_ EmailSettingValue, _ string, _ string, _ string) error {
|
||||
deliveries++
|
||||
return nil
|
||||
})
|
||||
for _, email := range []string{"missing@example.com", "oauth@example.com", "disabled@example.com"} {
|
||||
if err := svc.SendPasswordResetEmailCode(email); err != nil {
|
||||
t.Fatalf("SendPasswordResetEmailCode(%q) error = %v", email, err)
|
||||
}
|
||||
}
|
||||
if deliveries != 0 {
|
||||
t.Fatalf("mail deliveries = %d, want 0", deliveries)
|
||||
}
|
||||
var codeCount int64
|
||||
if err := db.Model(&model.EmailVerificationCode{}).Count(&codeCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if codeCount != 0 {
|
||||
t.Fatalf("verification code count = %d, want 0", codeCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordResetRejectsWrongExpiredAndPurposeMismatchedCodes(t *testing.T) {
|
||||
svc, db := newPasswordResetTestService(t)
|
||||
hash, err := HashPassword("old-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user := model.User{ID: "user-1", Username: "creator", Email: "creator@example.com", Status: model.UserStatusActive, PasswordHash: hash}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resetHash, err := svc.emailVerificationCodeHash(passwordResetEmailPurpose, user.Email, "123456")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registrationHash, err := svc.emailVerificationCodeHash(registrationEmailPurpose, user.Email, "123456")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resetHash == registrationHash {
|
||||
t.Fatal("email verification hashes must be separated by purpose")
|
||||
}
|
||||
record := model.EmailVerificationCode{ID: "expired", Email: user.Email, Purpose: passwordResetEmailPurpose, CodeHash: resetHash, ExpiresAt: time.Now().Add(-time.Minute), CreatedAt: time.Now().Add(-11 * time.Minute)}
|
||||
if err := db.Create(&record).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.ResetPassword(PasswordResetRequest{Email: user.Email, EmailCode: "123456", Password: "new-password"}); !isInvalidPasswordResetError(err) {
|
||||
t.Fatalf("expired code error = %v", err)
|
||||
}
|
||||
record.ID = "active"
|
||||
record.ExpiresAt = time.Now().Add(time.Minute)
|
||||
record.CreatedAt = time.Now()
|
||||
if err := db.Create(&record).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.ResetPassword(PasswordResetRequest{Email: user.Email, EmailCode: "654321", Password: "new-password"}); !isInvalidPasswordResetError(err) {
|
||||
t.Fatalf("wrong code error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordResetDeliveryFailureRemovesUnusableCode(t *testing.T) {
|
||||
svc, db := newPasswordResetTestService(t)
|
||||
hash, err := HashPassword("old-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user := model.User{ID: "user-1", Username: "creator", Email: "creator@example.com", Status: model.UserStatusActive, PasswordHash: hash}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc.SetMailSender(func(_ EmailSettingValue, _ string, _ string, _ string) error { return errors.New("smtp unavailable") })
|
||||
if err := svc.SendPasswordResetEmailCode(user.Email); err != nil {
|
||||
t.Fatalf("delivery failure should keep the public response generic: %v", err)
|
||||
}
|
||||
var count int64
|
||||
if err := db.Model(&model.EmailVerificationCode{}).Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("verification code count = %d, want 0", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSenderNameBrandsRegistrationAndPasswordResetEmails(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
fromName string
|
||||
wantName string
|
||||
}{
|
||||
{name: "default inherits appearance", fromName: DefaultBrandName, wantName: "HIMA Studio"},
|
||||
{name: "empty inherits appearance", fromName: "", wantName: "HIMA Studio"},
|
||||
{name: "custom sender overrides appearance", fromName: "Custom Canvas", wantName: "Custom Canvas"},
|
||||
{name: "custom sender is trimmed", fromName: " 自定义工作台 ", wantName: "自定义工作台"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assertRegistrationAndPasswordResetEmailBrand(t, tc.fromName, tc.wantName)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertRegistrationAndPasswordResetEmailBrand(t *testing.T, fromName, wantName string) {
|
||||
t.Helper()
|
||||
svc, db := newPasswordResetTestService(t)
|
||||
_, emailSetting, err := svc.readEmailSetting()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
emailSetting.FromName = fromName
|
||||
settingJSON, err := json.Marshal(emailSetting)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&model.SystemSetting{}).Where("key = ?", emailSettingKey).Update("value_json", string(settingJSON)).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&model.SystemSetting{Key: registrationSettingKey, ValueJSON: `{"enabled":true}`}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc.host = brandHost{name: "HIMA Studio"}
|
||||
passwordHash, err := HashPassword("strong-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
existing := model.User{ID: "brand-admin", Username: "brand-admin", Email: "admin@example.com", Role: model.UserRoleAdmin, Status: model.UserStatusActive, PasswordHash: passwordHash}
|
||||
resetUser := model.User{ID: "brand-user", Username: "brand-user", Email: "member@example.com", Role: model.UserRoleUser, Status: model.UserStatusActive, PasswordHash: passwordHash}
|
||||
if err := db.Create(&[]model.User{existing, resetUser}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
type delivery struct {
|
||||
fromName string
|
||||
recipient string
|
||||
subject string
|
||||
body string
|
||||
}
|
||||
deliveries := make([]delivery, 0, 2)
|
||||
svc.SetMailSender(func(setting EmailSettingValue, recipient string, subject string, body string) error {
|
||||
deliveries = append(deliveries, delivery{fromName: setting.FromName, recipient: recipient, subject: subject, body: body})
|
||||
return nil
|
||||
})
|
||||
if err := svc.SendRegistrationEmailCode("new-member@example.com"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.SendPasswordResetEmailCode(resetUser.Email); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(deliveries) != 2 {
|
||||
t.Fatalf("deliveries = %d, want 2", len(deliveries))
|
||||
}
|
||||
for _, delivered := range deliveries {
|
||||
if delivered.fromName != wantName || !strings.Contains(delivered.subject, wantName) || !strings.Contains(delivered.body, wantName) || strings.Contains(delivered.subject, DefaultBrandName) || strings.Contains(delivered.body, DefaultBrandName) {
|
||||
t.Fatalf("email did not use resolved sender name %q: %#v", wantName, delivered)
|
||||
}
|
||||
}
|
||||
if deliveries[0].subject != wantName+"注册验证码" || deliveries[1].subject != wantName+"密码重置验证码" {
|
||||
t.Fatalf("unexpected branded subjects: %#v", deliveries)
|
||||
}
|
||||
if !strings.Contains(deliveries[0].body, "你正在注册"+wantName+"。") {
|
||||
t.Fatalf("registration body did not use resolved sender name: %q", deliveries[0].body)
|
||||
}
|
||||
}
|
||||
|
||||
func newPasswordResetTestService(t *testing.T) (*Service, *gorm.DB) {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+kernel.NewID()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}, &model.AuthSession{}, &model.EmailVerificationCode{}, &model.SystemSetting{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settingJSON, err := json.Marshal(EmailSettingValue{Enabled: true, Host: "smtp.example.com", Port: 587, Encryption: "starttls", FromEmail: "noreply@example.com", FromName: "影策", RegistrationAllowedDomains: []string{"example.com"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&model.SystemSetting{Key: emailSettingKey, ValueJSON: string(settingJSON)}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return New(repository.New(db), nil, nil), db
|
||||
}
|
||||
|
||||
func codeFromEmailBody(body string) string {
|
||||
marker := "验证码:"
|
||||
start := strings.Index(body, marker)
|
||||
if start < 0 {
|
||||
return ""
|
||||
}
|
||||
value := body[start+len(marker):]
|
||||
if end := strings.IndexByte(value, '\n'); end >= 0 {
|
||||
value = value[:end]
|
||||
}
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func isInvalidPasswordResetError(err error) bool {
|
||||
var appErr *kernel.AppError
|
||||
return errors.As(err, &appErr) && appErr.Status == 400 && appErr.Message == "验证码无效或已过期"
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const registrationSettingKey = "registration"
|
||||
|
||||
type RegistrationSettingRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type PublicRegistrationSetting struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
UpdatedBy string `json:"updatedBy"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type registrationSettingValue struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func (s *Service) AdminRegistrationSetting(actor *model.User) (*PublicRegistrationSetting, error) {
|
||||
if err := s.host.RequireAdmin(actor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setting, value, err := s.readRegistrationSetting()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return publicRegistrationSetting(setting, value), nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateRegistrationSetting(actor *model.User, req RegistrationSettingRequest) (*PublicRegistrationSetting, error) {
|
||||
if err := s.host.RequireAdmin(actor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
encoded, err := json.Marshal(registrationSettingValue{Enabled: req.Enabled})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
current, _, err := s.readRegistrationSetting()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setting := model.SystemSetting{Key: registrationSettingKey, ValueJSON: string(encoded), UpdatedBy: actor.ID}
|
||||
if current != nil {
|
||||
setting.CreatedAt = current.CreatedAt
|
||||
}
|
||||
if err := s.repo.SaveSystemSetting(&setting); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return publicRegistrationSetting(&setting, registrationSettingValue{Enabled: req.Enabled}), nil
|
||||
}
|
||||
|
||||
func (s *Service) RegistrationEnabled() (bool, error) {
|
||||
_, value, err := s.readRegistrationSetting()
|
||||
return value.Enabled, err
|
||||
}
|
||||
|
||||
func (s *Service) readRegistrationSetting() (*model.SystemSetting, registrationSettingValue, error) {
|
||||
setting, err := s.repo.SystemSetting(registrationSettingKey)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, registrationSettingValue{Enabled: registrationEnabledFromEnvironment()}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, registrationSettingValue{}, err
|
||||
}
|
||||
value := registrationSettingValue{}
|
||||
if strings.TrimSpace(setting.ValueJSON) == "" || json.Unmarshal([]byte(setting.ValueJSON), &value) != nil {
|
||||
return nil, registrationSettingValue{}, errors.New("用户注册配置格式无效")
|
||||
}
|
||||
return setting, value, nil
|
||||
}
|
||||
|
||||
func publicRegistrationSetting(setting *model.SystemSetting, value registrationSettingValue) *PublicRegistrationSetting {
|
||||
result := &PublicRegistrationSetting{Enabled: value.Enabled}
|
||||
if setting != nil {
|
||||
result.UpdatedBy = setting.UpdatedBy
|
||||
result.CreatedAt = setting.CreatedAt
|
||||
result.UpdatedAt = setting.UpdatedAt
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func registrationEnabledFromEnvironment() bool {
|
||||
value := strings.ToLower(strings.TrimSpace(os.Getenv("CANVAS_REGISTRATION_ENABLED")))
|
||||
return value == "1" || value == "true" || value == "yes"
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"infinite-canvas/backend/internal/kernel"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/outbound"
|
||||
)
|
||||
|
||||
const (
|
||||
tapNowShareURLPrefix = "https://app.tapnow.media/api/conversation/v1/canvas-share/"
|
||||
tapNowMaxResponseBytes = 16 << 20
|
||||
tapNowMaxNodes = 1000
|
||||
tapNowMaxConnections = 5000
|
||||
)
|
||||
|
||||
type TapNowImportRequest struct {
|
||||
ShareID string `json:"shareId"`
|
||||
}
|
||||
|
||||
type TapNowImportResult struct {
|
||||
BatchID string `json:"batchId"`
|
||||
BatchCreatedAt time.Time `json:"batchCreatedAt"`
|
||||
ShareID string `json:"shareId"`
|
||||
ProjectName string `json:"projectName"`
|
||||
Nodes []TapNowCanvasNode `json:"nodes"`
|
||||
Connections []TapNowCanvasConnection `json:"connections"`
|
||||
ImportedNodeCount int `json:"importedNodeCount"`
|
||||
ImportedConnectionCount int `json:"importedConnectionCount"`
|
||||
SkippedNodes []TapNowImportIssue `json:"skippedNodes"`
|
||||
SkippedConnections []TapNowImportIssue `json:"skippedConnections"`
|
||||
Warnings []TapNowImportWarning `json:"warnings"`
|
||||
MultiResultNodeCount int `json:"multiResultNodeCount"`
|
||||
ReusedFailedNodeCount int `json:"reusedFailedNodeCount"`
|
||||
PlaceholderNodeCount int `json:"placeholderNodeCount"`
|
||||
}
|
||||
|
||||
type TapNowCanvasNode struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Width float64 `json:"width"`
|
||||
Height float64 `json:"height"`
|
||||
Content string `json:"content"`
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Size string `json:"size,omitempty"`
|
||||
Quality string `json:"quality,omitempty"`
|
||||
Seconds string `json:"seconds,omitempty"`
|
||||
VQuality string `json:"vquality,omitempty"`
|
||||
GenerateAudio string `json:"generateAudio,omitempty"`
|
||||
NaturalWidth int `json:"naturalWidth,omitempty"`
|
||||
NaturalHeight int `json:"naturalHeight,omitempty"`
|
||||
DurationMs int64 `json:"durationMs,omitempty"`
|
||||
MimeType string `json:"mimeType,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
ErrorDetails string `json:"errorDetails,omitempty"`
|
||||
Metadata TapNowImportMetadata `json:"metadata"`
|
||||
}
|
||||
|
||||
type TapNowImportMetadata struct {
|
||||
Provider string `json:"provider"`
|
||||
ShareID string `json:"shareId"`
|
||||
NodeID string `json:"nodeId"`
|
||||
BatchID string `json:"batchId"`
|
||||
SourceType string `json:"sourceType,omitempty"`
|
||||
}
|
||||
|
||||
type TapNowCanvasConnection struct {
|
||||
ID string `json:"id"`
|
||||
FromNodeID string `json:"fromNodeId"`
|
||||
ToNodeID string `json:"toNodeId"`
|
||||
FromHandleID string `json:"fromHandleId,omitempty"`
|
||||
ToHandleID string `json:"toHandleId,omitempty"`
|
||||
}
|
||||
|
||||
type TapNowImportIssue struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type TapNowImportWarning struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type tapNowEnvelope struct {
|
||||
Code int `json:"code"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
Msg string `json:"msg"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type tapNowDetail struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Nodes []tapNowRawNode `json:"nodes"`
|
||||
Connections []tapNowRawConnection `json:"connections"`
|
||||
}
|
||||
|
||||
type tapNowRawNode struct {
|
||||
ID string `json:"id"`
|
||||
ShortID int64 `json:"short_id"`
|
||||
Type string `json:"type"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
DeletedAt string `json:"deleted_at"`
|
||||
Position struct {
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
} `json:"position"`
|
||||
Measured struct {
|
||||
Width float64 `json:"width"`
|
||||
Height float64 `json:"height"`
|
||||
} `json:"measured"`
|
||||
}
|
||||
|
||||
type tapNowRawConnection struct {
|
||||
ID string `json:"id"`
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target"`
|
||||
SourceHandle string `json:"sourceHandle"`
|
||||
TargetHandle string `json:"targetHandle"`
|
||||
Data map[string]any `json:"data"`
|
||||
DeletedAt string `json:"deleted_at"`
|
||||
}
|
||||
|
||||
type tapNowNodeData struct {
|
||||
Title string `json:"title"`
|
||||
Prompt string `json:"prompt"`
|
||||
Text string `json:"text"`
|
||||
Src string `json:"src"`
|
||||
Options []string `json:"options"`
|
||||
Type string `json:"type"`
|
||||
Params map[string]any `json:"params"`
|
||||
TaskInfo map[string]any `json:"taskInfo"`
|
||||
Metadata struct {
|
||||
URL string `json:"url"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Duration float64 `json:"duration"`
|
||||
} `json:"__metadata"`
|
||||
Error string `json:"error"`
|
||||
FailedReason string `json:"failedReason"`
|
||||
}
|
||||
|
||||
// 读取公开分享前先校验目标画布归属,避免把导入结果写入他人画布。
|
||||
func (s *Service) ImportTapNow(userID, canvasProjectID, shareID string) (*TapNowImportResult, error) {
|
||||
userID = strings.TrimSpace(userID)
|
||||
canvasProjectID = strings.TrimSpace(canvasProjectID)
|
||||
if userID == "" || canvasProjectID == "" {
|
||||
return nil, kernel.Unauthorized("请先打开已同步的故事创作画布")
|
||||
}
|
||||
if _, err := s.repo.CanvasProjectForUser(userID, canvasProjectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
detail, normalizedShareID, err := fetchTapNowDetail(strings.TrimSpace(shareID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return adaptTapNowDetail(detail, normalizedShareID)
|
||||
}
|
||||
|
||||
func fetchTapNowDetail(shareID string) (*tapNowDetail, string, error) {
|
||||
if !isTapNowShareID(shareID) {
|
||||
return nil, "", kernel.BadAuthRequest("TapNow 分享 ID 格式无效")
|
||||
}
|
||||
endpoint, err := url.Parse(tapNowShareURLPrefix + url.PathEscape(shareID))
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, endpoint.String(), nil)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
outbound.ApplyDefaultOutboundHeaders(req)
|
||||
client := outbound.OutboundHTTPClient(20 * time.Second)
|
||||
client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error {
|
||||
return errors.New("TapNow API 不允许重定向")
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", errors.New("TapNow 请求失败,请检查网络或分享链接")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, tapNowMaxResponseBytes+1))
|
||||
if err != nil {
|
||||
return nil, "", errors.New("读取 TapNow 响应失败")
|
||||
}
|
||||
if len(body) > tapNowMaxResponseBytes {
|
||||
return nil, "", errors.New("TapNow 画布响应过大")
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, "", fmt.Errorf("TapNow 请求失败(HTTP %d)", resp.StatusCode)
|
||||
}
|
||||
var envelope tapNowEnvelope
|
||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||
return nil, "", errors.New("TapNow 响应格式无效")
|
||||
}
|
||||
if envelope.Code != 0 {
|
||||
msg := strings.TrimSpace(envelope.Msg)
|
||||
if msg == "" {
|
||||
msg = strings.TrimSpace(envelope.Message)
|
||||
}
|
||||
if msg == "" {
|
||||
msg = "TapNow 返回业务错误"
|
||||
}
|
||||
return nil, "", errors.New(msg)
|
||||
}
|
||||
var detail tapNowDetail
|
||||
if err := json.Unmarshal(envelope.Data, &detail); err != nil {
|
||||
return nil, "", errors.New("TapNow 画布数据格式无效")
|
||||
}
|
||||
return &detail, shareID, nil
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"infinite-canvas/backend/internal/kernel"
|
||||
"math"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func adaptTapNowDetail(detail *tapNowDetail, shareID string) (*TapNowImportResult, error) {
|
||||
if detail == nil {
|
||||
return nil, errors.New("TapNow 画布数据为空")
|
||||
}
|
||||
shareID = strings.TrimSpace(shareID)
|
||||
if !isTapNowShareID(shareID) {
|
||||
return nil, kernel.BadAuthRequest("TapNow 分享 ID 格式无效")
|
||||
}
|
||||
if len(detail.Nodes) > tapNowMaxNodes {
|
||||
return nil, fmt.Errorf("TapNow 画布节点过多(最多支持 %d 个)", tapNowMaxNodes)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
batchID := "tapnow-" + strconv.FormatInt(now.UnixMilli(), 36) + "-" + strings.ReplaceAll(uuid.NewString(), "-", "")[:8]
|
||||
result := &TapNowImportResult{
|
||||
BatchID: batchID,
|
||||
BatchCreatedAt: now,
|
||||
ShareID: shareID,
|
||||
ProjectName: strings.TrimSpace(detail.Name),
|
||||
Nodes: make([]TapNowCanvasNode, 0, len(detail.Nodes)),
|
||||
Connections: make([]TapNowCanvasConnection, 0, minInt(len(detail.Connections), tapNowMaxConnections)),
|
||||
SkippedNodes: make([]TapNowImportIssue, 0),
|
||||
SkippedConnections: make([]TapNowImportIssue, 0),
|
||||
Warnings: make([]TapNowImportWarning, 0),
|
||||
}
|
||||
if result.ProjectName == "" {
|
||||
result.ProjectName = "TapNow 画布"
|
||||
}
|
||||
|
||||
mapping := make(map[string]string, len(detail.Nodes))
|
||||
seenNodeIDs := make(map[string]struct{}, len(detail.Nodes))
|
||||
for _, raw := range detail.Nodes {
|
||||
if strings.TrimSpace(raw.DeletedAt) != "" {
|
||||
continue
|
||||
}
|
||||
nodeID := strings.TrimSpace(raw.ID)
|
||||
if nodeID == "" && raw.ShortID != 0 {
|
||||
nodeID = strconv.FormatInt(raw.ShortID, 10)
|
||||
}
|
||||
if nodeID == "" {
|
||||
result.SkippedNodes = append(result.SkippedNodes, TapNowImportIssue{Reason: "节点缺少 ID"})
|
||||
continue
|
||||
}
|
||||
if len(nodeID) > 128 {
|
||||
result.SkippedNodes = append(result.SkippedNodes, TapNowImportIssue{ID: nodeID[:128], Reason: "节点 ID 过长"})
|
||||
continue
|
||||
}
|
||||
if _, exists := seenNodeIDs[nodeID]; exists {
|
||||
result.SkippedNodes = append(result.SkippedNodes, TapNowImportIssue{ID: nodeID, Reason: "节点 ID 重复"})
|
||||
continue
|
||||
}
|
||||
seenNodeIDs[nodeID] = struct{}{}
|
||||
|
||||
data, err := decodeTapNowNodeData(raw.Data)
|
||||
if err != nil {
|
||||
result.SkippedNodes = append(result.SkippedNodes, TapNowImportIssue{ID: nodeID, Reason: "节点数据格式无效"})
|
||||
continue
|
||||
}
|
||||
kind := strings.ToLower(strings.TrimSpace(raw.Type))
|
||||
if kind == "" {
|
||||
kind = strings.ToLower(strings.TrimSpace(data.Type))
|
||||
}
|
||||
if kind != "image" && kind != "video" && kind != "audio" && kind != "text" {
|
||||
result.SkippedNodes = append(result.SkippedNodes, TapNowImportIssue{ID: nodeID, Name: strings.TrimSpace(data.Title), Reason: "暂不支持的节点类型"})
|
||||
continue
|
||||
}
|
||||
if !finiteNumber(raw.Position.X) || !finiteNumber(raw.Position.Y) {
|
||||
result.SkippedNodes = append(result.SkippedNodes, TapNowImportIssue{ID: nodeID, Name: strings.TrimSpace(data.Title), Reason: "节点坐标无效"})
|
||||
continue
|
||||
}
|
||||
|
||||
content, mediaIndex := firstTapNowMediaURL(data)
|
||||
if kind == "text" {
|
||||
content = strings.TrimSpace(data.Text)
|
||||
if content == "" {
|
||||
content = strings.TrimSpace(data.Prompt)
|
||||
}
|
||||
mediaIndex = -1
|
||||
}
|
||||
width, height := raw.Measured.Width, raw.Measured.Height
|
||||
if !positiveFiniteNumber(width) {
|
||||
width = 480
|
||||
}
|
||||
if !positiveFiniteNumber(height) {
|
||||
height = 300
|
||||
}
|
||||
item := TapNowCanvasNode{
|
||||
ID: "tapnow-" + batchID + "-" + nodeID,
|
||||
Type: kind,
|
||||
Title: strings.TrimSpace(data.Title),
|
||||
X: raw.Position.X,
|
||||
Y: raw.Position.Y,
|
||||
Width: width,
|
||||
Height: height,
|
||||
Content: content,
|
||||
Prompt: kernel.FirstNonEmpty(strings.TrimSpace(data.Prompt), firstTapNowParam(data.Params, "prompt")),
|
||||
Model: tapNowMapString(data.Params, "model"),
|
||||
Size: firstTapNowParam(data.Params, "aspectRatio", "size"),
|
||||
Quality: tapNowImageQuality(data.Params),
|
||||
Seconds: firstTapNowParam(data.Params, "duration", "seconds"),
|
||||
VQuality: firstTapNowParam(data.Params, "resolution", "vquality"),
|
||||
GenerateAudio: tapNowBoolParam(data.Params, "generateAudio"),
|
||||
Metadata: TapNowImportMetadata{
|
||||
Provider: "tapnow",
|
||||
ShareID: shareID,
|
||||
NodeID: nodeID,
|
||||
BatchID: batchID,
|
||||
SourceType: strings.TrimSpace(data.Type),
|
||||
},
|
||||
}
|
||||
if item.Title == "" {
|
||||
item.Title = kind + " 节点"
|
||||
}
|
||||
item.NaturalWidth = data.Metadata.Width
|
||||
item.NaturalHeight = data.Metadata.Height
|
||||
if data.Metadata.Duration > 0 && !math.IsNaN(data.Metadata.Duration) && !math.IsInf(data.Metadata.Duration, 0) {
|
||||
item.DurationMs = int64(math.Round(data.Metadata.Duration * 1000))
|
||||
}
|
||||
item.MimeType = inferTapNowMime(kind, content, kernel.FirstNonEmpty(data.Prompt, data.Title))
|
||||
taskStatus := tapNowTaskStatus(data.TaskInfo)
|
||||
hasContent := strings.TrimSpace(content) != ""
|
||||
if hasContent {
|
||||
item.Status = "success"
|
||||
if tapNowTaskFailed(taskStatus) {
|
||||
result.ReusedFailedNodeCount++
|
||||
}
|
||||
} else if tapNowTaskFailed(taskStatus) {
|
||||
item.Status = "error"
|
||||
item.ErrorDetails = tapNowErrorDetails(data)
|
||||
if item.ErrorDetails == "" {
|
||||
item.ErrorDetails = "TapNow 生成任务失败"
|
||||
}
|
||||
result.PlaceholderNodeCount++
|
||||
} else {
|
||||
item.Status = "idle"
|
||||
result.PlaceholderNodeCount++
|
||||
}
|
||||
mapping[nodeID] = item.ID
|
||||
if raw.ShortID != 0 {
|
||||
mapping[strconv.FormatInt(raw.ShortID, 10)] = item.ID
|
||||
}
|
||||
result.Nodes = append(result.Nodes, item)
|
||||
if !positiveFiniteNumber(raw.Measured.Width) || !positiveFiniteNumber(raw.Measured.Height) {
|
||||
result.Warnings = append(result.Warnings, TapNowImportWarning{ID: nodeID, Message: fmt.Sprintf("节点“%s”尺寸无效,已使用默认尺寸。", item.Title)})
|
||||
}
|
||||
if mediaIndex >= 0 && len(data.Options) > 1 {
|
||||
result.MultiResultNodeCount++
|
||||
result.Warnings = append(result.Warnings, TapNowImportWarning{ID: nodeID, Message: fmt.Sprintf("节点“%s”包含多个结果,已导入首个结果。", item.Title)})
|
||||
}
|
||||
}
|
||||
|
||||
if len(detail.Connections) > tapNowMaxConnections {
|
||||
result.Warnings = append(result.Warnings, TapNowImportWarning{Message: fmt.Sprintf("连接超过 %d 条,仅处理前 %d 条。", tapNowMaxConnections, tapNowMaxConnections)})
|
||||
}
|
||||
seenConnectionIDs := make(map[string]struct{}, len(detail.Connections))
|
||||
for index, raw := range detail.Connections {
|
||||
if index >= tapNowMaxConnections {
|
||||
break
|
||||
}
|
||||
if strings.TrimSpace(raw.DeletedAt) != "" {
|
||||
continue
|
||||
}
|
||||
connectionID := strings.TrimSpace(raw.ID)
|
||||
if connectionID == "" {
|
||||
result.SkippedConnections = append(result.SkippedConnections, TapNowImportIssue{Reason: "连接缺少 ID"})
|
||||
continue
|
||||
}
|
||||
if _, exists := seenConnectionIDs[connectionID]; exists {
|
||||
result.SkippedConnections = append(result.SkippedConnections, TapNowImportIssue{ID: connectionID, Reason: "连接 ID 重复"})
|
||||
continue
|
||||
}
|
||||
seenConnectionIDs[connectionID] = struct{}{}
|
||||
from, okFrom := mapping[strings.TrimSpace(raw.Source)]
|
||||
to, okTo := mapping[strings.TrimSpace(raw.Target)]
|
||||
if !okFrom || !okTo {
|
||||
result.SkippedConnections = append(result.SkippedConnections, TapNowImportIssue{ID: connectionID, Reason: "连接端点节点未导入"})
|
||||
continue
|
||||
}
|
||||
fromHandleID := strings.TrimSpace(raw.SourceHandle)
|
||||
toHandleID := strings.TrimSpace(raw.TargetHandle)
|
||||
if toHandleID == "" {
|
||||
// TapNow 将图片输入句柄放在边 data.valueKey,而不是 targetHandle。
|
||||
toHandleID = tapNowMapString(raw.Data, "valueKey")
|
||||
}
|
||||
result.Connections = append(result.Connections, TapNowCanvasConnection{
|
||||
ID: "tapnow-" + batchID + "-" + connectionID,
|
||||
FromNodeID: from,
|
||||
ToNodeID: to,
|
||||
FromHandleID: fromHandleID,
|
||||
ToHandleID: toHandleID,
|
||||
})
|
||||
}
|
||||
result.ImportedNodeCount = len(result.Nodes)
|
||||
result.ImportedConnectionCount = len(result.Connections)
|
||||
if result.ImportedNodeCount == 0 {
|
||||
return nil, errors.New("TapNow 画布没有可导入的有效节点")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func decodeTapNowNodeData(raw json.RawMessage) (tapNowNodeData, error) {
|
||||
var data tapNowNodeData
|
||||
trimmed := strings.TrimSpace(string(raw))
|
||||
if strings.HasPrefix(trimmed, `"`) {
|
||||
var encoded string
|
||||
if err := json.Unmarshal(raw, &encoded); err != nil {
|
||||
return data, err
|
||||
}
|
||||
raw = json.RawMessage(encoded)
|
||||
}
|
||||
if err := json.Unmarshal(raw, &data); err != nil {
|
||||
return data, err
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func firstTapNowMediaURL(data tapNowNodeData) (string, int) {
|
||||
values := make([]string, 0, len(data.Options)+2)
|
||||
values = append(values, data.Src)
|
||||
values = append(values, data.Options...)
|
||||
values = append(values, data.Metadata.URL)
|
||||
for index, value := range values {
|
||||
parsed, err := url.Parse(strings.TrimSpace(value))
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Hostname() == "" || parsed.User != nil || !isTapNowMediaHost(parsed.Hostname()) {
|
||||
continue
|
||||
}
|
||||
return parsed.String(), index
|
||||
}
|
||||
return "", -1
|
||||
}
|
||||
|
||||
func isTapNowMediaHost(host string) bool {
|
||||
host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")
|
||||
// 导入后的媒体还可能作为后续生成的参考素材,只接受已核验的官方文件域名,
|
||||
// 不能根据品牌前缀放行未经确认的同名域,避免把外部 URL 带入后端出网链路。
|
||||
return host == "files.tapnow.media"
|
||||
}
|
||||
|
||||
func inferTapNowMime(kind, rawURL, hint string) string {
|
||||
lower := strings.ToLower(rawURL + " " + hint)
|
||||
switch kind {
|
||||
case "image":
|
||||
switch {
|
||||
case strings.Contains(lower, ".png"):
|
||||
return "image/png"
|
||||
case strings.Contains(lower, ".jpg") || strings.Contains(lower, ".jpeg"):
|
||||
return "image/jpeg"
|
||||
case strings.Contains(lower, ".webp"):
|
||||
return "image/webp"
|
||||
case strings.Contains(lower, ".gif"):
|
||||
return "image/gif"
|
||||
default:
|
||||
return "image/*"
|
||||
}
|
||||
case "video":
|
||||
switch {
|
||||
case strings.Contains(lower, ".webm"):
|
||||
return "video/webm"
|
||||
case strings.Contains(lower, ".mov"):
|
||||
return "video/quicktime"
|
||||
default:
|
||||
return "video/mp4"
|
||||
}
|
||||
case "audio":
|
||||
switch {
|
||||
case strings.Contains(lower, ".wav"):
|
||||
return "audio/wav"
|
||||
case strings.Contains(lower, ".flac"):
|
||||
return "audio/flac"
|
||||
case strings.Contains(lower, ".ogg") || strings.Contains(lower, ".oga"):
|
||||
return "audio/ogg"
|
||||
case strings.Contains(lower, ".m4a"):
|
||||
return "audio/mp4"
|
||||
default:
|
||||
return "audio/mpeg"
|
||||
}
|
||||
case "text":
|
||||
return "text/plain"
|
||||
default:
|
||||
return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
func tapNowMapString(values map[string]any, key string) string {
|
||||
if values == nil {
|
||||
return ""
|
||||
}
|
||||
value, ok := values[key]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
if stringValue, ok := value.(string); ok {
|
||||
return strings.TrimSpace(stringValue)
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprint(value))
|
||||
}
|
||||
|
||||
func firstTapNowParam(values map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value := tapNowMapString(values, key); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// TapNow 部分渠道用 imageSize(例如 "2K"),画布合同则使用小写质量档位。
|
||||
// 只迁移已知档位,避免把渠道私有值静默写成无效的本地质量设置。
|
||||
func tapNowImageQuality(values map[string]any) string {
|
||||
value := firstTapNowParam(values, "quality")
|
||||
if value == "" {
|
||||
value = firstTapNowParam(values, "imageSize")
|
||||
}
|
||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||
switch normalized {
|
||||
case "1k", "2k", "4k", "auto", "low", "medium", "high":
|
||||
return normalized
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func tapNowBoolParam(values map[string]any, key string) string {
|
||||
value, ok := values[key]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if boolean, ok := value.(bool); ok {
|
||||
return strconv.FormatBool(boolean)
|
||||
}
|
||||
return tapNowMapString(values, key)
|
||||
}
|
||||
|
||||
func tapNowTaskStatus(values map[string]any) string {
|
||||
status := strings.ToLower(strings.TrimSpace(tapNowMapString(values, "status")))
|
||||
return status
|
||||
}
|
||||
|
||||
func tapNowTaskFailed(status string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case "failed", "error", "failure":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func tapNowErrorDetails(data tapNowNodeData) string {
|
||||
if value := strings.TrimSpace(data.Error); value != "" {
|
||||
return value
|
||||
}
|
||||
if value := strings.TrimSpace(data.FailedReason); value != "" {
|
||||
return value
|
||||
}
|
||||
for _, key := range []string{"error", "failedReason", "message"} {
|
||||
if value := tapNowMapString(data.TaskInfo, key); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isTapNowShareID(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || len(value) > 64 {
|
||||
return false
|
||||
}
|
||||
for _, char := range value {
|
||||
if !((char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '_' || char == '-') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func finiteNumber(value float64) bool {
|
||||
return !math.IsNaN(value) && !math.IsInf(value, 0)
|
||||
}
|
||||
|
||||
func positiveFiniteNumber(value float64) bool {
|
||||
return finiteNumber(value) && value > 0
|
||||
}
|
||||
|
||||
func minInt(left, right int) int {
|
||||
if left < right {
|
||||
return left
|
||||
}
|
||||
return right
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package auth
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAdaptTapNowDetailSupportsMediaAndTextNodes(t *testing.T) {
|
||||
detail := &tapNowDetail{
|
||||
Name: "TapNow 测试画布",
|
||||
Nodes: []tapNowRawNode{
|
||||
{ID: "image-1", Type: "image", Data: []byte(`{"title":"图片","type":"generate","src":"https://files.tapnow.media/api/conversation/storage/uploads/image.png","prompt":"画面提示词","params":{"model":"gpt-image-2","imageSize":"2K"},"__metadata":{"width":1024,"height":768}}`), Position: struct {
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
}{X: 10, Y: 20}, Measured: struct {
|
||||
Width float64 `json:"width"`
|
||||
Height float64 `json:"height"`
|
||||
}{Width: 448, Height: 250}},
|
||||
{ID: "video-1", Type: "video", Data: []byte(`{"title":"视频","type":"generate","src":"","taskInfo":{"status":"failed","failedReason":"上游失败"},"params":{"model":"seedance-2.0","aspectRatio":"16:9","duration":8,"resolution":"720p","generateAudio":true},"__metadata":{"width":1280,"height":720,"duration":4.5}}`), Position: struct {
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
}{X: 500, Y: 20}, Measured: struct {
|
||||
Width float64 `json:"width"`
|
||||
Height float64 `json:"height"`
|
||||
}{Width: 444, Height: 250}},
|
||||
{ID: "audio-1", Type: "audio", Data: []byte(`{"title":"音频","type":"upload","src":"https://files.tapnow.media/api/conversation/storage/uploads/audio-file","prompt":"就这样.wav"}`), Position: struct {
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
}{X: 10, Y: 400}, Measured: struct {
|
||||
Width float64 `json:"width"`
|
||||
Height float64 `json:"height"`
|
||||
}{Width: 444, Height: 250}},
|
||||
{ID: "text-1", Type: "text", Data: []byte(`{"title":"文本","type":"generate","text":"故事文本"}`), Position: struct {
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
}{X: 500, Y: 400}, Measured: struct {
|
||||
Width float64 `json:"width"`
|
||||
Height float64 `json:"height"`
|
||||
}{Width: 300, Height: 200}},
|
||||
},
|
||||
Connections: []tapNowRawConnection{{ID: "edge-1", Source: "image-1", Target: "video-1", SourceHandle: "images", TargetHandle: "prompt"}},
|
||||
}
|
||||
|
||||
result, err := adaptTapNowDetail(detail, "8872a294")
|
||||
if err != nil {
|
||||
t.Fatalf("adaptTapNowDetail() error = %v", err)
|
||||
}
|
||||
if result.ImportedNodeCount != 4 || result.ImportedConnectionCount != 1 {
|
||||
t.Fatalf("counts = (%d, %d), want (4, 1)", result.ImportedNodeCount, result.ImportedConnectionCount)
|
||||
}
|
||||
if result.Nodes[0].Metadata.Provider != "tapnow" || result.Nodes[0].Model != "gpt-image-2" || result.Nodes[0].Quality != "2k" || result.Nodes[0].NaturalWidth != 1024 {
|
||||
t.Fatalf("image mapping = %#v", result.Nodes[0])
|
||||
}
|
||||
if result.Nodes[1].Status != "error" || result.Nodes[1].ErrorDetails != "上游失败" || result.PlaceholderNodeCount != 1 {
|
||||
t.Fatalf("failed video mapping = %#v", result.Nodes[1])
|
||||
}
|
||||
if result.Nodes[1].Size != "16:9" || result.Nodes[1].Seconds != "8" || result.Nodes[1].VQuality != "720p" || result.Nodes[1].GenerateAudio != "true" {
|
||||
t.Fatalf("video generation params = %#v", result.Nodes[1])
|
||||
}
|
||||
if result.Nodes[2].MimeType != "audio/wav" || result.Nodes[3].Type != "text" || result.Nodes[3].Content != "故事文本" {
|
||||
t.Fatalf("audio/text mapping = %#v %#v", result.Nodes[2], result.Nodes[3])
|
||||
}
|
||||
if result.Connections[0].FromHandleID != "images" || result.Connections[0].ToHandleID != "prompt" {
|
||||
t.Fatalf("connection handles = %#v", result.Connections[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdaptTapNowDetailUsesValueKeyAsTargetHandle(t *testing.T) {
|
||||
detail := &tapNowDetail{
|
||||
Nodes: []tapNowRawNode{
|
||||
{ID: "source", Type: "image", Data: []byte(`{"title":"源图","src":"https://files.tapnow.media/source"}`)},
|
||||
{ID: "target", Type: "image", Data: []byte(`{"title":"目标图","src":"https://files.tapnow.media/target"}`)},
|
||||
},
|
||||
Connections: []tapNowRawConnection{{ID: "edge", Source: "source", Target: "target", Data: map[string]any{"valueKey": "images"}}},
|
||||
}
|
||||
result, err := adaptTapNowDetail(detail, "8872a294")
|
||||
if err != nil {
|
||||
t.Fatalf("adaptTapNowDetail() error = %v", err)
|
||||
}
|
||||
if got := result.Connections[0].ToHandleID; got != "images" {
|
||||
t.Fatalf("target handle = %q, want images", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdaptTapNowDetailRejectsUntrustedMediaAndKeepsPlaceholder(t *testing.T) {
|
||||
detail := &tapNowDetail{Nodes: []tapNowRawNode{{ID: "image-1", Type: "image", Data: []byte(`{"title":"图片","type":"generate","src":"https://evil.example/image.png"}`)}}}
|
||||
result, err := adaptTapNowDetail(detail, "share_1")
|
||||
if err != nil {
|
||||
t.Fatalf("adaptTapNowDetail() error = %v", err)
|
||||
}
|
||||
if result.Nodes[0].Content != "" || result.Nodes[0].Status != "idle" || result.PlaceholderNodeCount != 1 {
|
||||
t.Fatalf("untrusted media handling = %#v", result.Nodes[0])
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user