package auth import ( "context" "errors" "pipu/api/internal/platform/apperr" ) const MaxArchivedUsers = 10000 const MaxUserArchiveBytes = 16 << 20 // ArchivedUser is deliberately limited to identity, credentials and access rights. // IDs, sessions and personal music data never cross the transfer boundary. type ArchivedUser struct { Username string DisplayName *string Role Role PasswordHash string IsBlocked bool MustChangePassword bool MustChangeCredentials bool ReviewsUploads bool ManagesUpdates bool } func archiveInvalid() error { return apperr.Validation("file", "validation.user_archive_invalid", "invalid user archive", nil) } func (s *Service) ExportUsers(ctx context.Context, p Principal) ([]ArchivedUser, error) { if p.Role != RoleAdmin { return nil, forbidden() } out := make([]ArchivedUser, 0) err := s.store.WithTx(ctx, func(tx Store) error { after := "" for { users, err := tx.ListUsers(ctx, UserFilter{After: after, Limit: 200}) if err != nil { return err } if len(users) == 0 { break } for _, u := range users { if len(out) == MaxArchivedUsers { return archiveInvalid() } out = append(out, ArchivedUser{ Username: u.Username, DisplayName: u.DisplayName, Role: u.Role, PasswordHash: u.PasswordHash, IsBlocked: u.IsBlocked, MustChangePassword: u.MustChangePassword, MustChangeCredentials: u.MustChangeCredentials, ReviewsUploads: u.ReviewsUploads, ManagesUpdates: u.ManagesUpdates, }) } after = users[len(users)-1].Username } return tx.InsertAudit(ctx, s.ids.New(), AuditEntry{ ActorID: &p.UserID, Action: "users.export", TargetType: "users", Payload: map[string]any{"count": len(out)}, }) }) return out, err } // ImportUsers creates all missing accounts in one transaction. Existing accounts // retain their passwords, roles, sessions and personal music data. func (s *Service) ImportUsers(ctx context.Context, p Principal, version int, entries []ArchivedUser) (int, []string, error) { if p.Role != RoleAdmin { return 0, nil, forbidden() } if version != 1 || len(entries) > MaxArchivedUsers { return 0, nil, archiveInvalid() } seen := make(map[string]bool, len(entries)) for _, a := range entries { username := NormalizeUsername(a.Username) if username != a.Username || ValidateUsername("username", username) != nil || seen[username] || !a.Role.Valid() { return 0, nil, archiveInvalid() } seen[username] = true if a.DisplayName != nil { if _, err := displayName(*a.DisplayName); err != nil { return 0, nil, archiveInvalid() } } if len(a.PasswordHash) > 512 { return 0, nil, archiveInvalid() } params, salt, key, err := decodePHC(a.PasswordHash) // Imported PHC parameters are used on the next login; bound CPU and RAM // before accepting them, without performing expensive password hashing. if err != nil || params.Threads == 0 || params.Threads > 16 || params.Time == 0 || params.Time > 10 || params.MemoryKiB < 8*uint32(params.Threads) || params.MemoryKiB > 256*1024 || len(salt) < 8 || len(salt) > 64 || len(key) < 16 || len(key) > 64 { return 0, nil, archiveInvalid() } } created := 0 skipped := make([]string, 0) err := s.store.WithTx(ctx, func(tx Store) error { for _, a := range entries { _, err := tx.GetUserByUsername(ctx, a.Username) if err == nil { skipped = append(skipped, a.Username) continue } if !errors.Is(err, ErrNotFound) { return err } locale := "ru" if len(s.cfg.SupportedLocales) > 0 { locale = s.cfg.SupportedLocales[0] } u := User{ ID: s.ids.New(), Username: a.Username, DisplayName: a.DisplayName, PasswordHash: a.PasswordHash, Role: a.Role, Locale: locale, CreatedAt: s.clock.Now(), MustChangePassword: a.MustChangePassword, MustChangeCredentials: a.MustChangeCredentials, ReviewsUploads: a.ReviewsUploads, ManagesUpdates: a.ManagesUpdates, } if err := tx.CreateUser(ctx, u, &p.UserID); err != nil { return err } if a.IsBlocked { blocked := true if _, err := tx.UpdateUser(ctx, u.ID, UserUpdate{IsBlocked: &blocked}); err != nil { return err } } created++ } return tx.InsertAudit(ctx, s.ids.New(), AuditEntry{ ActorID: &p.UserID, Action: "users.import", TargetType: "users", Payload: map[string]any{"created": created, "skipped": len(skipped)}, }) }) if err != nil { return 0, nil, err } return created, skipped, nil }