Chocobozzz/PeerTube

View on GitHub
server/core/models/user/user-registration.ts

Summary

Maintainability
A
0 mins
Test Coverage
import { UserRegistration, UserRegistrationState, type UserRegistrationStateType } from '@peertube/peertube-models'
import {
  isRegistrationModerationResponseValid,
  isRegistrationReasonValid,
  isRegistrationStateValid
} from '@server/helpers/custom-validators/user-registration.js'
import { isVideoChannelDisplayNameValid } from '@server/helpers/custom-validators/video-channels.js'
import { cryptPassword } from '@server/helpers/peertube-crypto.js'
import { USER_REGISTRATION_STATES } from '@server/initializers/constants.js'
import { MRegistration, MRegistrationFormattable } from '@server/types/models/index.js'
import { FindOptions, Op, QueryTypes, WhereOptions } from 'sequelize'
import {
  AllowNull,
  BeforeCreate,
  BelongsTo,
  Column,
  CreatedAt,
  DataType,
  ForeignKey,
  Is,
  IsEmail, Table,
  UpdatedAt
} from 'sequelize-typescript'
import { isUserDisplayNameValid, isUserEmailVerifiedValid, isUserPasswordValid } from '../../helpers/custom-validators/users.js'
import { SequelizeModel, getSort, parseAggregateResult, throwIfNotValid } from '../shared/index.js'
import { UserModel } from './user.js'
import { forceNumber } from '@peertube/peertube-core-utils'

@Table({
  tableName: 'userRegistration',
  indexes: [
    {
      fields: [ 'username' ],
      unique: true
    },
    {
      fields: [ 'email' ],
      unique: true
    },
    {
      fields: [ 'channelHandle' ],
      unique: true
    },
    {
      fields: [ 'userId' ],
      unique: true
    }
  ]
})
export class UserRegistrationModel extends SequelizeModel<UserRegistrationModel> {

  @AllowNull(false)
  @Is('RegistrationState', value => throwIfNotValid(value, isRegistrationStateValid, 'state'))
  @Column
  state: UserRegistrationStateType

  @AllowNull(false)
  @Is('RegistrationReason', value => throwIfNotValid(value, isRegistrationReasonValid, 'registration reason'))
  @Column(DataType.TEXT)
  registrationReason: string

  @AllowNull(true)
  @Is('RegistrationModerationResponse', value => throwIfNotValid(value, isRegistrationModerationResponseValid, 'moderation response', true))
  @Column(DataType.TEXT)
  moderationResponse: string

  @AllowNull(true)
  @Is('RegistrationPassword', value => throwIfNotValid(value, isUserPasswordValid, 'registration password', true))
  @Column
  password: string

  @AllowNull(false)
  @Column
  username: string

  @AllowNull(false)
  @IsEmail
  @Column(DataType.STRING(400))
  email: string

  @AllowNull(true)
  @Is('RegistrationEmailVerified', value => throwIfNotValid(value, isUserEmailVerifiedValid, 'email verified boolean', true))
  @Column
  emailVerified: boolean

  @AllowNull(true)
  @Is('RegistrationAccountDisplayName', value => throwIfNotValid(value, isUserDisplayNameValid, 'account display name', true))
  @Column
  accountDisplayName: string

  @AllowNull(true)
  @Is('ChannelHandle', value => throwIfNotValid(value, isVideoChannelDisplayNameValid, 'channel handle', true))
  @Column
  channelHandle: string

  @AllowNull(true)
  @Is('ChannelDisplayName', value => throwIfNotValid(value, isVideoChannelDisplayNameValid, 'channel display name', true))
  @Column
  channelDisplayName: string

  @AllowNull(true)
  @Column
  processedAt: Date

  @CreatedAt
  createdAt: Date

  @UpdatedAt
  updatedAt: Date

  @ForeignKey(() => UserModel)
  @Column
  userId: number

  @BelongsTo(() => UserModel, {
    foreignKey: {
      allowNull: true
    },
    onDelete: 'SET NULL'
  })
  User: Awaited<UserModel>

  @BeforeCreate
  static async cryptPasswordIfNeeded (instance: UserRegistrationModel) {
    instance.password = await cryptPassword(instance.password)
  }

  static load (id: number): Promise<MRegistration> {
    return UserRegistrationModel.findByPk(id)
  }

  static loadByEmail (email: string): Promise<MRegistration> {
    const query = {
      where: { email }
    }

    return UserRegistrationModel.findOne(query)
  }

  static loadByEmailOrUsername (emailOrUsername: string): Promise<MRegistration> {
    const query = {
      where: {
        [Op.or]: [
          { email: emailOrUsername },
          { username: emailOrUsername }
        ]
      }
    }

    return UserRegistrationModel.findOne(query)
  }

  static loadByEmailOrHandle (options: {
    email: string
    username: string
    channelHandle?: string
  }): Promise<MRegistration> {
    const { email, username, channelHandle } = options

    let or: WhereOptions = [
      { email },
      { channelHandle: username },
      { username }
    ]

    if (channelHandle) {
      or = or.concat([
        { username: channelHandle },
        { channelHandle }
      ])
    }

    const query = {
      where: {
        [Op.or]: or
      }
    }

    return UserRegistrationModel.findOne(query)
  }

  // ---------------------------------------------------------------------------

  static listForApi (options: {
    start: number
    count: number
    sort: string
    search?: string
  }) {
    const { start, count, sort, search } = options

    const where: WhereOptions = {}

    if (search) {
      Object.assign(where, {
        [Op.or]: [
          {
            email: {
              [Op.iLike]: '%' + search + '%'
            }
          },
          {
            username: {
              [Op.iLike]: '%' + search + '%'
            }
          }
        ]
      })
    }

    const query: FindOptions = {
      offset: start,
      limit: count,
      order: getSort(sort),
      where,
      include: [
        {
          model: UserModel.unscoped(),
          required: false
        }
      ]
    }

    return Promise.all([
      UserRegistrationModel.count(query),
      UserRegistrationModel.findAll<MRegistrationFormattable>(query)
    ]).then(([ total, data ]) => ({ total, data }))
  }

  // ---------------------------------------------------------------------------

  static getStats () {
    const query = `SELECT ` +
      `AVG(EXTRACT(EPOCH FROM ("processedAt" - "createdAt") * 1000)) ` +
        `FILTER (WHERE "processedAt" IS NOT NULL AND "createdAt" > CURRENT_DATE - INTERVAL '3 months')` +
        `AS "avgResponseTime", ` +
      // "processedAt" has been introduced in PeerTube 6.1 so also check the abuse state to check processed abuses
      `COUNT(*) FILTER (WHERE "processedAt" IS NOT NULL OR "state" != ${UserRegistrationState.PENDING}) AS "processedRequests", ` +
      `COUNT(*) AS "totalRequests" ` +
      `FROM "userRegistration"`

    return UserRegistrationModel.sequelize.query<any>(query, {
      type: QueryTypes.SELECT,
      raw: true
    }).then(([ row ]) => {
      return {
        totalRegistrationRequests: parseAggregateResult(row.totalRequests),

        totalRegistrationRequestsProcessed: parseAggregateResult(row.processedRequests),

        averageRegistrationRequestResponseTimeMs: row?.avgResponseTime
          ? forceNumber(row.avgResponseTime)
          : null
      }
    })
  }

  // ---------------------------------------------------------------------------

  toFormattedJSON (this: MRegistrationFormattable): UserRegistration {
    return {
      id: this.id,

      state: {
        id: this.state,
        label: USER_REGISTRATION_STATES[this.state]
      },

      registrationReason: this.registrationReason,
      moderationResponse: this.moderationResponse,

      username: this.username,
      email: this.email,
      emailVerified: this.emailVerified,

      accountDisplayName: this.accountDisplayName,

      channelHandle: this.channelHandle,
      channelDisplayName: this.channelDisplayName,

      createdAt: this.createdAt,
      updatedAt: this.updatedAt,

      user: this.User
        ? { id: this.User.id }
        : null
    }
  }
}