From e4f97babf701481b55cc10fb3448feab5f97c867 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Thu, 9 Nov 2017 17:51:58 +0100 Subject: Begin activitypub --- server/models/account/account-follow-interface.ts | 23 ++ server/models/account/account-follow.ts | 56 +++ server/models/account/account-interface.ts | 74 ++++ .../models/account/account-video-rate-interface.ts | 26 ++ server/models/account/account-video-rate.ts | 78 ++++ server/models/account/account.ts | 444 +++++++++++++++++++++ server/models/account/index.ts | 4 + server/models/account/user-interface.ts | 69 ++++ server/models/account/user.ts | 311 +++++++++++++++ 9 files changed, 1085 insertions(+) create mode 100644 server/models/account/account-follow-interface.ts create mode 100644 server/models/account/account-follow.ts create mode 100644 server/models/account/account-interface.ts create mode 100644 server/models/account/account-video-rate-interface.ts create mode 100644 server/models/account/account-video-rate.ts create mode 100644 server/models/account/account.ts create mode 100644 server/models/account/index.ts create mode 100644 server/models/account/user-interface.ts create mode 100644 server/models/account/user.ts (limited to 'server/models/account') diff --git a/server/models/account/account-follow-interface.ts b/server/models/account/account-follow-interface.ts new file mode 100644 index 000000000..3be383649 --- /dev/null +++ b/server/models/account/account-follow-interface.ts @@ -0,0 +1,23 @@ +import * as Sequelize from 'sequelize' +import * as Promise from 'bluebird' + +import { VideoRateType } from '../../../shared/models/videos/video-rate.type' + +export namespace AccountFollowMethods { +} + +export interface AccountFollowClass { +} + +export interface AccountFollowAttributes { + accountId: number + targetAccountId: number +} + +export interface AccountFollowInstance extends AccountFollowClass, AccountFollowAttributes, Sequelize.Instance { + id: number + createdAt: Date + updatedAt: Date +} + +export interface AccountFollowModel extends AccountFollowClass, Sequelize.Model {} diff --git a/server/models/account/account-follow.ts b/server/models/account/account-follow.ts new file mode 100644 index 000000000..9bf03b253 --- /dev/null +++ b/server/models/account/account-follow.ts @@ -0,0 +1,56 @@ +import * as Sequelize from 'sequelize' + +import { addMethodsToModel } from '../utils' +import { + AccountFollowInstance, + AccountFollowAttributes, + + AccountFollowMethods +} from './account-follow-interface' + +let AccountFollow: Sequelize.Model + +export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.DataTypes) { + AccountFollow = sequelize.define('AccountFollow', + { }, + { + indexes: [ + { + fields: [ 'accountId' ], + unique: true + }, + { + fields: [ 'targetAccountId' ], + unique: true + } + ] + } + ) + + const classMethods = [ + associate + ] + addMethodsToModel(AccountFollow, classMethods) + + return AccountFollow +} + +// ------------------------------ STATICS ------------------------------ + +function associate (models) { + AccountFollow.belongsTo(models.Account, { + foreignKey: { + name: 'accountId', + allowNull: false + }, + onDelete: 'CASCADE' + }) + + AccountFollow.belongsTo(models.Account, { + foreignKey: { + name: 'targetAccountId', + allowNull: false + }, + onDelete: 'CASCADE' + }) +} diff --git a/server/models/account/account-interface.ts b/server/models/account/account-interface.ts new file mode 100644 index 000000000..2ef3e2246 --- /dev/null +++ b/server/models/account/account-interface.ts @@ -0,0 +1,74 @@ +import * as Sequelize from 'sequelize' +import * as Bluebird from 'bluebird' + +import { PodInstance } from '../pod/pod-interface' +import { VideoChannelInstance } from '../video/video-channel-interface' +import { ActivityPubActor } from '../../../shared' +import { ResultList } from '../../../shared/models/result-list.model' + +export namespace AccountMethods { + export type Load = (id: number) => Bluebird + export type LoadByUUID = (uuid: string) => Bluebird + export type LoadByUrl = (url: string) => Bluebird + export type LoadAccountByPodAndUUID = (uuid: string, podId: number, transaction: Sequelize.Transaction) => Bluebird + export type LoadLocalAccountByName = (name: string) => Bluebird + export type ListOwned = () => Bluebird + export type ListFollowerUrlsForApi = (name: string, start: number, count: number) => Promise< ResultList > + export type ListFollowingUrlsForApi = (name: string, start: number, count: number) => Promise< ResultList > + + export type ToActivityPubObject = (this: AccountInstance) => ActivityPubActor + export type IsOwned = (this: AccountInstance) => boolean + export type GetFollowerSharedInboxUrls = (this: AccountInstance) => Bluebird + export type GetFollowingUrl = (this: AccountInstance) => string + export type GetFollowersUrl = (this: AccountInstance) => string + export type GetPublicKeyUrl = (this: AccountInstance) => string +} + +export interface AccountClass { + loadAccountByPodAndUUID: AccountMethods.LoadAccountByPodAndUUID + load: AccountMethods.Load + loadByUUID: AccountMethods.LoadByUUID + loadByUrl: AccountMethods.LoadByUrl + loadLocalAccountByName: AccountMethods.LoadLocalAccountByName + listOwned: AccountMethods.ListOwned + listFollowerUrlsForApi: AccountMethods.ListFollowerUrlsForApi + listFollowingUrlsForApi: AccountMethods.ListFollowingUrlsForApi +} + +export interface AccountAttributes { + name: string + url: string + publicKey: string + privateKey: string + followersCount: number + followingCount: number + inboxUrl: string + outboxUrl: string + sharedInboxUrl: string + followersUrl: string + followingUrl: string + + uuid?: string + + podId?: number + userId?: number + applicationId?: number +} + +export interface AccountInstance extends AccountClass, AccountAttributes, Sequelize.Instance { + isOwned: AccountMethods.IsOwned + toActivityPubObject: AccountMethods.ToActivityPubObject + getFollowerSharedInboxUrls: AccountMethods.GetFollowerSharedInboxUrls + getFollowingUrl: AccountMethods.GetFollowingUrl + getFollowersUrl: AccountMethods.GetFollowersUrl + getPublicKeyUrl: AccountMethods.GetPublicKeyUrl + + id: number + createdAt: Date + updatedAt: Date + + Pod: PodInstance + VideoChannels: VideoChannelInstance[] +} + +export interface AccountModel extends AccountClass, Sequelize.Model {} diff --git a/server/models/account/account-video-rate-interface.ts b/server/models/account/account-video-rate-interface.ts new file mode 100644 index 000000000..82cbe38cc --- /dev/null +++ b/server/models/account/account-video-rate-interface.ts @@ -0,0 +1,26 @@ +import * as Sequelize from 'sequelize' +import * as Promise from 'bluebird' + +import { VideoRateType } from '../../../shared/models/videos/video-rate.type' + +export namespace AccountVideoRateMethods { + export type Load = (accountId: number, videoId: number, transaction: Sequelize.Transaction) => Promise +} + +export interface AccountVideoRateClass { + load: AccountVideoRateMethods.Load +} + +export interface AccountVideoRateAttributes { + type: VideoRateType + accountId: number + videoId: number +} + +export interface AccountVideoRateInstance extends AccountVideoRateClass, AccountVideoRateAttributes, Sequelize.Instance { + id: number + createdAt: Date + updatedAt: Date +} + +export interface AccountVideoRateModel extends AccountVideoRateClass, Sequelize.Model {} diff --git a/server/models/account/account-video-rate.ts b/server/models/account/account-video-rate.ts new file mode 100644 index 000000000..7f7c97606 --- /dev/null +++ b/server/models/account/account-video-rate.ts @@ -0,0 +1,78 @@ +/* + Account rates per video. +*/ +import { values } from 'lodash' +import * as Sequelize from 'sequelize' + +import { VIDEO_RATE_TYPES } from '../../initializers' + +import { addMethodsToModel } from '../utils' +import { + AccountVideoRateInstance, + AccountVideoRateAttributes, + + AccountVideoRateMethods +} from './account-video-rate-interface' + +let AccountVideoRate: Sequelize.Model +let load: AccountVideoRateMethods.Load + +export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.DataTypes) { + AccountVideoRate = sequelize.define('AccountVideoRate', + { + type: { + type: DataTypes.ENUM(values(VIDEO_RATE_TYPES)), + allowNull: false + } + }, + { + indexes: [ + { + fields: [ 'videoId', 'accountId', 'type' ], + unique: true + } + ] + } + ) + + const classMethods = [ + associate, + + load + ] + addMethodsToModel(AccountVideoRate, classMethods) + + return AccountVideoRate +} + +// ------------------------------ STATICS ------------------------------ + +function associate (models) { + AccountVideoRate.belongsTo(models.Video, { + foreignKey: { + name: 'videoId', + allowNull: false + }, + onDelete: 'CASCADE' + }) + + AccountVideoRate.belongsTo(models.Account, { + foreignKey: { + name: 'accountId', + allowNull: false + }, + onDelete: 'CASCADE' + }) +} + +load = function (accountId: number, videoId: number, transaction: Sequelize.Transaction) { + const options: Sequelize.FindOptions = { + where: { + accountId, + videoId + } + } + if (transaction) options.transaction = transaction + + return AccountVideoRate.findOne(options) +} diff --git a/server/models/account/account.ts b/server/models/account/account.ts new file mode 100644 index 000000000..00c0aefd4 --- /dev/null +++ b/server/models/account/account.ts @@ -0,0 +1,444 @@ +import * as Sequelize from 'sequelize' + +import { + isUserUsernameValid, + isAccountPublicKeyValid, + isAccountUrlValid, + isAccountPrivateKeyValid, + isAccountFollowersCountValid, + isAccountFollowingCountValid, + isAccountInboxValid, + isAccountOutboxValid, + isAccountSharedInboxValid, + isAccountFollowersValid, + isAccountFollowingValid, + activityPubContextify +} from '../../helpers' + +import { addMethodsToModel } from '../utils' +import { + AccountInstance, + AccountAttributes, + + AccountMethods +} from './account-interface' + +let Account: Sequelize.Model +let loadAccountByPodAndUUID: AccountMethods.LoadAccountByPodAndUUID +let load: AccountMethods.Load +let loadByUUID: AccountMethods.LoadByUUID +let loadByUrl: AccountMethods.LoadByUrl +let loadLocalAccountByName: AccountMethods.LoadLocalAccountByName +let listOwned: AccountMethods.ListOwned +let listFollowerUrlsForApi: AccountMethods.ListFollowerUrlsForApi +let listFollowingUrlsForApi: AccountMethods.ListFollowingUrlsForApi +let isOwned: AccountMethods.IsOwned +let toActivityPubObject: AccountMethods.ToActivityPubObject +let getFollowerSharedInboxUrls: AccountMethods.GetFollowerSharedInboxUrls +let getFollowingUrl: AccountMethods.GetFollowingUrl +let getFollowersUrl: AccountMethods.GetFollowersUrl +let getPublicKeyUrl: AccountMethods.GetPublicKeyUrl + +export default function defineAccount (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.DataTypes) { + Account = sequelize.define('Account', + { + uuid: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + allowNull: false, + validate: { + isUUID: 4 + } + }, + name: { + type: DataTypes.STRING, + allowNull: false, + validate: { + usernameValid: value => { + const res = isUserUsernameValid(value) + if (res === false) throw new Error('Username is not valid.') + } + } + }, + url: { + type: DataTypes.STRING, + allowNull: false, + validate: { + urlValid: value => { + const res = isAccountUrlValid(value) + if (res === false) throw new Error('URL is not valid.') + } + } + }, + publicKey: { + type: DataTypes.STRING, + allowNull: false, + validate: { + publicKeyValid: value => { + const res = isAccountPublicKeyValid(value) + if (res === false) throw new Error('Public key is not valid.') + } + } + }, + privateKey: { + type: DataTypes.STRING, + allowNull: false, + validate: { + privateKeyValid: value => { + const res = isAccountPrivateKeyValid(value) + if (res === false) throw new Error('Private key is not valid.') + } + } + }, + followersCount: { + type: DataTypes.INTEGER, + allowNull: false, + validate: { + followersCountValid: value => { + const res = isAccountFollowersCountValid(value) + if (res === false) throw new Error('Followers count is not valid.') + } + } + }, + followingCount: { + type: DataTypes.INTEGER, + allowNull: false, + validate: { + followersCountValid: value => { + const res = isAccountFollowingCountValid(value) + if (res === false) throw new Error('Following count is not valid.') + } + } + }, + inboxUrl: { + type: DataTypes.STRING, + allowNull: false, + validate: { + inboxUrlValid: value => { + const res = isAccountInboxValid(value) + if (res === false) throw new Error('Inbox URL is not valid.') + } + } + }, + outboxUrl: { + type: DataTypes.STRING, + allowNull: false, + validate: { + outboxUrlValid: value => { + const res = isAccountOutboxValid(value) + if (res === false) throw new Error('Outbox URL is not valid.') + } + } + }, + sharedInboxUrl: { + type: DataTypes.STRING, + allowNull: false, + validate: { + sharedInboxUrlValid: value => { + const res = isAccountSharedInboxValid(value) + if (res === false) throw new Error('Shared inbox URL is not valid.') + } + } + }, + followersUrl: { + type: DataTypes.STRING, + allowNull: false, + validate: { + followersUrlValid: value => { + const res = isAccountFollowersValid(value) + if (res === false) throw new Error('Followers URL is not valid.') + } + } + }, + followingUrl: { + type: DataTypes.STRING, + allowNull: false, + validate: { + followingUrlValid: value => { + const res = isAccountFollowingValid(value) + if (res === false) throw new Error('Following URL is not valid.') + } + } + } + }, + { + indexes: [ + { + fields: [ 'name' ] + }, + { + fields: [ 'podId' ] + }, + { + fields: [ 'userId' ], + unique: true + }, + { + fields: [ 'applicationId' ], + unique: true + }, + { + fields: [ 'name', 'podId' ], + unique: true + } + ], + hooks: { afterDestroy } + } + ) + + const classMethods = [ + associate, + loadAccountByPodAndUUID, + load, + loadByUUID, + loadLocalAccountByName, + listOwned, + listFollowerUrlsForApi, + listFollowingUrlsForApi + ] + const instanceMethods = [ + isOwned, + toActivityPubObject, + getFollowerSharedInboxUrls, + getFollowingUrl, + getFollowersUrl, + getPublicKeyUrl + ] + addMethodsToModel(Account, classMethods, instanceMethods) + + return Account +} + +// --------------------------------------------------------------------------- + +function associate (models) { + Account.belongsTo(models.Pod, { + foreignKey: { + name: 'podId', + allowNull: true + }, + onDelete: 'cascade' + }) + + Account.belongsTo(models.User, { + foreignKey: { + name: 'userId', + allowNull: true + }, + onDelete: 'cascade' + }) + + Account.belongsTo(models.Application, { + foreignKey: { + name: 'userId', + allowNull: true + }, + onDelete: 'cascade' + }) + + Account.hasMany(models.VideoChannel, { + foreignKey: { + name: 'accountId', + allowNull: false + }, + onDelete: 'cascade', + hooks: true + }) + + Account.hasMany(models.AccountFollower, { + foreignKey: { + name: 'accountId', + allowNull: false + }, + onDelete: 'cascade' + }) + + Account.hasMany(models.AccountFollower, { + foreignKey: { + name: 'targetAccountId', + allowNull: false + }, + onDelete: 'cascade' + }) +} + +function afterDestroy (account: AccountInstance) { + if (account.isOwned()) { + const removeVideoAccountToFriendsParams = { + uuid: account.uuid + } + + return removeVideoAccountToFriends(removeVideoAccountToFriendsParams) + } + + return undefined +} + +toActivityPubObject = function (this: AccountInstance) { + const type = this.podId ? 'Application' : 'Person' + + const json = { + type, + id: this.url, + following: this.getFollowingUrl(), + followers: this.getFollowersUrl(), + inbox: this.inboxUrl, + outbox: this.outboxUrl, + preferredUsername: this.name, + url: this.url, + name: this.name, + endpoints: { + sharedInbox: this.sharedInboxUrl + }, + uuid: this.uuid, + publicKey: { + id: this.getPublicKeyUrl(), + owner: this.url, + publicKeyPem: this.publicKey + } + } + + return activityPubContextify(json) +} + +isOwned = function (this: AccountInstance) { + return this.podId === null +} + +getFollowerSharedInboxUrls = function (this: AccountInstance) { + const query: Sequelize.FindOptions = { + attributes: [ 'sharedInboxUrl' ], + include: [ + { + model: Account['sequelize'].models.AccountFollower, + where: { + targetAccountId: this.id + } + } + ] + } + + return Account.findAll(query) + .then(accounts => accounts.map(a => a.sharedInboxUrl)) +} + +getFollowingUrl = function (this: AccountInstance) { + return this.url + '/followers' +} + +getFollowersUrl = function (this: AccountInstance) { + return this.url + '/followers' +} + +getPublicKeyUrl = function (this: AccountInstance) { + return this.url + '#main-key' +} + +// ------------------------------ STATICS ------------------------------ + +listOwned = function () { + const query: Sequelize.FindOptions = { + where: { + podId: null + } + } + + return Account.findAll(query) +} + +listFollowerUrlsForApi = function (name: string, start: number, count: number) { + return createListFollowForApiQuery('followers', name, start, count) +} + +listFollowingUrlsForApi = function (name: string, start: number, count: number) { + return createListFollowForApiQuery('following', name, start, count) +} + +load = function (id: number) { + return Account.findById(id) +} + +loadByUUID = function (uuid: string) { + const query: Sequelize.FindOptions = { + where: { + uuid + } + } + + return Account.findOne(query) +} + +loadLocalAccountByName = function (name: string) { + const query: Sequelize.FindOptions = { + where: { + name, + userId: { + [Sequelize.Op.ne]: null + } + } + } + + return Account.findOne(query) +} + +loadByUrl = function (url: string) { + const query: Sequelize.FindOptions = { + where: { + url + } + } + + return Account.findOne(query) +} + +loadAccountByPodAndUUID = function (uuid: string, podId: number, transaction: Sequelize.Transaction) { + const query: Sequelize.FindOptions = { + where: { + podId, + uuid + }, + transaction + } + + return Account.find(query) +} + +// ------------------------------ UTILS ------------------------------ + +async function createListFollowForApiQuery (type: 'followers' | 'following', name: string, start: number, count: number) { + let firstJoin: string + let secondJoin: string + + if (type === 'followers') { + firstJoin = 'targetAccountId' + secondJoin = 'accountId' + } else { + firstJoin = 'accountId' + secondJoin = 'targetAccountId' + } + + const selections = [ '"Followers"."url" AS "url"', 'COUNT(*) AS "total"' ] + const tasks: Promise[] = [] + + for (const selection of selections) { + const query = 'SELECT ' + selection + ' FROM "Account" ' + + 'INNER JOIN "AccountFollower" ON "AccountFollower"."' + firstJoin + '" = "Account"."id" ' + + 'INNER JOIN "Account" AS "Followers" ON "Followers"."id" = "AccountFollower"."' + secondJoin + '" ' + + 'WHERE "Account"."name" = \'$name\' ' + + 'LIMIT ' + start + ', ' + count + + const options = { + bind: { name }, + type: Sequelize.QueryTypes.SELECT + } + tasks.push(Account['sequelize'].query(query, options)) + } + + const [ followers, [ { total } ]] = await Promise.all(tasks) + const urls: string[] = followers.map(f => f.url) + + return { + data: urls, + total: parseInt(total, 10) + } +} diff --git a/server/models/account/index.ts b/server/models/account/index.ts new file mode 100644 index 000000000..179f66974 --- /dev/null +++ b/server/models/account/index.ts @@ -0,0 +1,4 @@ +export * from './account-interface' +export * from './account-follow-interface' +export * from './account-video-rate-interface' +export * from './user-interface' diff --git a/server/models/account/user-interface.ts b/server/models/account/user-interface.ts new file mode 100644 index 000000000..1a04fb750 --- /dev/null +++ b/server/models/account/user-interface.ts @@ -0,0 +1,69 @@ +import * as Sequelize from 'sequelize' +import * as Bluebird from 'bluebird' + +// Don't use barrel, import just what we need +import { AccountInstance } from './account-interface' +import { User as FormattedUser } from '../../../shared/models/users/user.model' +import { ResultList } from '../../../shared/models/result-list.model' +import { UserRight } from '../../../shared/models/users/user-right.enum' +import { UserRole } from '../../../shared/models/users/user-role' + +export namespace UserMethods { + export type HasRight = (this: UserInstance, right: UserRight) => boolean + export type IsPasswordMatch = (this: UserInstance, password: string) => Promise + + export type ToFormattedJSON = (this: UserInstance) => FormattedUser + export type IsAbleToUploadVideo = (this: UserInstance, videoFile: Express.Multer.File) => Promise + + export type CountTotal = () => Bluebird + + export type GetByUsername = (username: string) => Bluebird + + export type ListForApi = (start: number, count: number, sort: string) => Bluebird< ResultList > + + export type LoadById = (id: number) => Bluebird + + export type LoadByUsername = (username: string) => Bluebird + export type LoadByUsernameAndPopulateChannels = (username: string) => Bluebird + + export type LoadByUsernameOrEmail = (username: string, email: string) => Bluebird +} + +export interface UserClass { + isPasswordMatch: UserMethods.IsPasswordMatch, + toFormattedJSON: UserMethods.ToFormattedJSON, + hasRight: UserMethods.HasRight, + isAbleToUploadVideo: UserMethods.IsAbleToUploadVideo, + + countTotal: UserMethods.CountTotal, + getByUsername: UserMethods.GetByUsername, + listForApi: UserMethods.ListForApi, + loadById: UserMethods.LoadById, + loadByUsername: UserMethods.LoadByUsername, + loadByUsernameAndPopulateChannels: UserMethods.LoadByUsernameAndPopulateChannels, + loadByUsernameOrEmail: UserMethods.LoadByUsernameOrEmail +} + +export interface UserAttributes { + id?: number + password: string + username: string + email: string + displayNSFW?: boolean + role: UserRole + videoQuota: number + + Account?: AccountInstance +} + +export interface UserInstance extends UserClass, UserAttributes, Sequelize.Instance { + id: number + createdAt: Date + updatedAt: Date + + isPasswordMatch: UserMethods.IsPasswordMatch + toFormattedJSON: UserMethods.ToFormattedJSON + hasRight: UserMethods.HasRight +} + +export interface UserModel extends UserClass, Sequelize.Model {} diff --git a/server/models/account/user.ts b/server/models/account/user.ts new file mode 100644 index 000000000..1401762c5 --- /dev/null +++ b/server/models/account/user.ts @@ -0,0 +1,311 @@ +import * as Sequelize from 'sequelize' + +import { getSort, addMethodsToModel } from '../utils' +import { + cryptPassword, + comparePassword, + isUserPasswordValid, + isUserUsernameValid, + isUserDisplayNSFWValid, + isUserVideoQuotaValid, + isUserRoleValid +} from '../../helpers' +import { UserRight, USER_ROLE_LABELS, hasUserRight } from '../../../shared' + +import { + UserInstance, + UserAttributes, + + UserMethods +} from './user-interface' + +let User: Sequelize.Model +let isPasswordMatch: UserMethods.IsPasswordMatch +let hasRight: UserMethods.HasRight +let toFormattedJSON: UserMethods.ToFormattedJSON +let countTotal: UserMethods.CountTotal +let getByUsername: UserMethods.GetByUsername +let listForApi: UserMethods.ListForApi +let loadById: UserMethods.LoadById +let loadByUsername: UserMethods.LoadByUsername +let loadByUsernameAndPopulateChannels: UserMethods.LoadByUsernameAndPopulateChannels +let loadByUsernameOrEmail: UserMethods.LoadByUsernameOrEmail +let isAbleToUploadVideo: UserMethods.IsAbleToUploadVideo + +export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.DataTypes) { + User = sequelize.define('User', + { + password: { + type: DataTypes.STRING, + allowNull: false, + validate: { + passwordValid: value => { + const res = isUserPasswordValid(value) + if (res === false) throw new Error('Password not valid.') + } + } + }, + username: { + type: DataTypes.STRING, + allowNull: false, + validate: { + usernameValid: value => { + const res = isUserUsernameValid(value) + if (res === false) throw new Error('Username not valid.') + } + } + }, + email: { + type: DataTypes.STRING(400), + allowNull: false, + validate: { + isEmail: true + } + }, + displayNSFW: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + validate: { + nsfwValid: value => { + const res = isUserDisplayNSFWValid(value) + if (res === false) throw new Error('Display NSFW is not valid.') + } + } + }, + role: { + type: DataTypes.INTEGER, + allowNull: false, + validate: { + roleValid: value => { + const res = isUserRoleValid(value) + if (res === false) throw new Error('Role is not valid.') + } + } + }, + videoQuota: { + type: DataTypes.BIGINT, + allowNull: false, + validate: { + videoQuotaValid: value => { + const res = isUserVideoQuotaValid(value) + if (res === false) throw new Error('Video quota is not valid.') + } + } + } + }, + { + indexes: [ + { + fields: [ 'username' ], + unique: true + }, + { + fields: [ 'email' ], + unique: true + } + ], + hooks: { + beforeCreate: beforeCreateOrUpdate, + beforeUpdate: beforeCreateOrUpdate + } + } + ) + + const classMethods = [ + associate, + + countTotal, + getByUsername, + listForApi, + loadById, + loadByUsername, + loadByUsernameAndPopulateChannels, + loadByUsernameOrEmail + ] + const instanceMethods = [ + hasRight, + isPasswordMatch, + toFormattedJSON, + isAbleToUploadVideo + ] + addMethodsToModel(User, classMethods, instanceMethods) + + return User +} + +function beforeCreateOrUpdate (user: UserInstance) { + if (user.changed('password')) { + return cryptPassword(user.password) + .then(hash => { + user.password = hash + return undefined + }) + } +} + +// ------------------------------ METHODS ------------------------------ + +hasRight = function (this: UserInstance, right: UserRight) { + return hasUserRight(this.role, right) +} + +isPasswordMatch = function (this: UserInstance, password: string) { + return comparePassword(password, this.password) +} + +toFormattedJSON = function (this: UserInstance) { + const json = { + id: this.id, + username: this.username, + email: this.email, + displayNSFW: this.displayNSFW, + role: this.role, + roleLabel: USER_ROLE_LABELS[this.role], + videoQuota: this.videoQuota, + createdAt: this.createdAt, + author: { + id: this.Account.id, + uuid: this.Account.uuid + } + } + + if (Array.isArray(this.Account.VideoChannels) === true) { + const videoChannels = this.Account.VideoChannels + .map(c => c.toFormattedJSON()) + .sort((v1, v2) => { + if (v1.createdAt < v2.createdAt) return -1 + if (v1.createdAt === v2.createdAt) return 0 + + return 1 + }) + + json['videoChannels'] = videoChannels + } + + return json +} + +isAbleToUploadVideo = function (this: UserInstance, videoFile: Express.Multer.File) { + if (this.videoQuota === -1) return Promise.resolve(true) + + return getOriginalVideoFileTotalFromUser(this).then(totalBytes => { + return (videoFile.size + totalBytes) < this.videoQuota + }) +} + +// ------------------------------ STATICS ------------------------------ + +function associate (models) { + User.hasOne(models.Account, { + foreignKey: 'userId', + onDelete: 'cascade' + }) + + User.hasMany(models.OAuthToken, { + foreignKey: 'userId', + onDelete: 'cascade' + }) +} + +countTotal = function () { + return this.count() +} + +getByUsername = function (username: string) { + const query = { + where: { + username: username + }, + include: [ { model: User['sequelize'].models.Account, required: true } ] + } + + return User.findOne(query) +} + +listForApi = function (start: number, count: number, sort: string) { + const query = { + offset: start, + limit: count, + order: [ getSort(sort) ], + include: [ { model: User['sequelize'].models.Account, required: true } ] + } + + return User.findAndCountAll(query).then(({ rows, count }) => { + return { + data: rows, + total: count + } + }) +} + +loadById = function (id: number) { + const options = { + include: [ { model: User['sequelize'].models.Account, required: true } ] + } + + return User.findById(id, options) +} + +loadByUsername = function (username: string) { + const query = { + where: { + username + }, + include: [ { model: User['sequelize'].models.Account, required: true } ] + } + + return User.findOne(query) +} + +loadByUsernameAndPopulateChannels = function (username: string) { + const query = { + where: { + username + }, + include: [ + { + model: User['sequelize'].models.Account, + required: true, + include: [ User['sequelize'].models.VideoChannel ] + } + ] + } + + return User.findOne(query) +} + +loadByUsernameOrEmail = function (username: string, email: string) { + const query = { + include: [ { model: User['sequelize'].models.Account, required: true } ], + where: { + [Sequelize.Op.or]: [ { username }, { email } ] + } + } + + // FIXME: https://github.com/DefinitelyTyped/DefinitelyTyped/issues/18387 + return (User as any).findOne(query) +} + +// --------------------------------------------------------------------------- + +function getOriginalVideoFileTotalFromUser (user: UserInstance) { + // Don't use sequelize because we need to use a sub query + const query = 'SELECT SUM("size") AS "total" FROM ' + + '(SELECT MAX("VideoFiles"."size") AS "size" FROM "VideoFiles" ' + + 'INNER JOIN "Videos" ON "VideoFiles"."videoId" = "Videos"."id" ' + + 'INNER JOIN "VideoChannels" ON "VideoChannels"."id" = "Videos"."channelId" ' + + 'INNER JOIN "Accounts" ON "VideoChannels"."authorId" = "Accounts"."id" ' + + 'INNER JOIN "Users" ON "Accounts"."userId" = "Users"."id" ' + + 'WHERE "Users"."id" = $userId GROUP BY "Videos"."id") t' + + const options = { + bind: { userId: user.id }, + type: Sequelize.QueryTypes.SELECT + } + return User['sequelize'].query(query, options).then(([ { total } ]) => { + if (total === null) return 0 + + return parseInt(total, 10) + }) +} -- cgit v1.2.3