diff options
Diffstat (limited to 'server/models/account/user.ts')
-rw-r--r-- | server/models/account/user.ts | 460 |
1 files changed, 205 insertions, 255 deletions
diff --git a/server/models/account/user.ts b/server/models/account/user.ts index 3705947c0..84adad96e 100644 --- a/server/models/account/user.ts +++ b/server/models/account/user.ts | |||
@@ -1,301 +1,251 @@ | |||
1 | import * as Sequelize from 'sequelize' | 1 | import * as Sequelize from 'sequelize' |
2 | import { | ||
3 | AllowNull, | ||
4 | BeforeCreate, | ||
5 | BeforeUpdate, | ||
6 | Column, CreatedAt, | ||
7 | DataType, | ||
8 | Default, | ||
9 | HasMany, | ||
10 | HasOne, | ||
11 | Is, | ||
12 | IsEmail, | ||
13 | Model, | ||
14 | Table, UpdatedAt | ||
15 | } from 'sequelize-typescript' | ||
2 | import { hasUserRight, USER_ROLE_LABELS, UserRight } from '../../../shared' | 16 | import { hasUserRight, USER_ROLE_LABELS, UserRight } from '../../../shared' |
3 | import { | 17 | import { |
4 | comparePassword, | 18 | comparePassword, |
5 | cryptPassword, | 19 | cryptPassword |
6 | isUserDisplayNSFWValid, | ||
7 | isUserPasswordValid, | ||
8 | isUserRoleValid, | ||
9 | isUserUsernameValid, | ||
10 | isUserVideoQuotaValid | ||
11 | } from '../../helpers' | 20 | } from '../../helpers' |
12 | import { addMethodsToModel, getSort } from '../utils' | 21 | import { |
13 | import { UserAttributes, UserInstance, UserMethods } from './user-interface' | 22 | isUserDisplayNSFWValid, isUserPasswordValid, isUserRoleValid, isUserUsernameValid, |
14 | 23 | isUserVideoQuotaValid | |
15 | let User: Sequelize.Model<UserInstance, UserAttributes> | 24 | } from '../../helpers/custom-validators/users' |
16 | let isPasswordMatch: UserMethods.IsPasswordMatch | 25 | import { OAuthTokenModel } from '../oauth/oauth-token' |
17 | let hasRight: UserMethods.HasRight | 26 | import { getSort, throwIfNotValid } from '../utils' |
18 | let toFormattedJSON: UserMethods.ToFormattedJSON | 27 | import { VideoChannelModel } from '../video/video-channel' |
19 | let countTotal: UserMethods.CountTotal | 28 | import { AccountModel } from './account' |
20 | let getByUsername: UserMethods.GetByUsername | 29 | |
21 | let listForApi: UserMethods.ListForApi | 30 | @Table({ |
22 | let loadById: UserMethods.LoadById | 31 | tableName: 'user', |
23 | let loadByUsername: UserMethods.LoadByUsername | 32 | indexes: [ |
24 | let loadByUsernameAndPopulateChannels: UserMethods.LoadByUsernameAndPopulateChannels | ||
25 | let loadByUsernameOrEmail: UserMethods.LoadByUsernameOrEmail | ||
26 | let isAbleToUploadVideo: UserMethods.IsAbleToUploadVideo | ||
27 | |||
28 | export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.DataTypes) { | ||
29 | User = sequelize.define<UserInstance, UserAttributes>('User', | ||
30 | { | 33 | { |
31 | password: { | 34 | fields: [ 'username' ], |
32 | type: DataTypes.STRING, | 35 | unique: true |
33 | allowNull: false, | ||
34 | validate: { | ||
35 | passwordValid: value => { | ||
36 | const res = isUserPasswordValid(value) | ||
37 | if (res === false) throw new Error('Password not valid.') | ||
38 | } | ||
39 | } | ||
40 | }, | ||
41 | username: { | ||
42 | type: DataTypes.STRING, | ||
43 | allowNull: false, | ||
44 | validate: { | ||
45 | usernameValid: value => { | ||
46 | const res = isUserUsernameValid(value) | ||
47 | if (res === false) throw new Error('Username not valid.') | ||
48 | } | ||
49 | } | ||
50 | }, | ||
51 | email: { | ||
52 | type: DataTypes.STRING(400), | ||
53 | allowNull: false, | ||
54 | validate: { | ||
55 | isEmail: true | ||
56 | } | ||
57 | }, | ||
58 | displayNSFW: { | ||
59 | type: DataTypes.BOOLEAN, | ||
60 | allowNull: false, | ||
61 | defaultValue: false, | ||
62 | validate: { | ||
63 | nsfwValid: value => { | ||
64 | const res = isUserDisplayNSFWValid(value) | ||
65 | if (res === false) throw new Error('Display NSFW is not valid.') | ||
66 | } | ||
67 | } | ||
68 | }, | ||
69 | role: { | ||
70 | type: DataTypes.INTEGER, | ||
71 | allowNull: false, | ||
72 | validate: { | ||
73 | roleValid: value => { | ||
74 | const res = isUserRoleValid(value) | ||
75 | if (res === false) throw new Error('Role is not valid.') | ||
76 | } | ||
77 | } | ||
78 | }, | ||
79 | videoQuota: { | ||
80 | type: DataTypes.BIGINT, | ||
81 | allowNull: false, | ||
82 | validate: { | ||
83 | videoQuotaValid: value => { | ||
84 | const res = isUserVideoQuotaValid(value) | ||
85 | if (res === false) throw new Error('Video quota is not valid.') | ||
86 | } | ||
87 | } | ||
88 | } | ||
89 | }, | 36 | }, |
90 | { | 37 | { |
91 | indexes: [ | 38 | fields: [ 'email' ], |
92 | { | 39 | unique: true |
93 | fields: [ 'username' ], | ||
94 | unique: true | ||
95 | }, | ||
96 | { | ||
97 | fields: [ 'email' ], | ||
98 | unique: true | ||
99 | } | ||
100 | ], | ||
101 | hooks: { | ||
102 | beforeCreate: beforeCreateOrUpdate, | ||
103 | beforeUpdate: beforeCreateOrUpdate | ||
104 | } | ||
105 | } | 40 | } |
106 | ) | ||
107 | |||
108 | const classMethods = [ | ||
109 | associate, | ||
110 | |||
111 | countTotal, | ||
112 | getByUsername, | ||
113 | listForApi, | ||
114 | loadById, | ||
115 | loadByUsername, | ||
116 | loadByUsernameAndPopulateChannels, | ||
117 | loadByUsernameOrEmail | ||
118 | ] | 41 | ] |
119 | const instanceMethods = [ | 42 | }) |
120 | hasRight, | 43 | export class UserModel extends Model<UserModel> { |
121 | isPasswordMatch, | 44 | |
122 | toFormattedJSON, | 45 | @AllowNull(false) |
123 | isAbleToUploadVideo | 46 | @Is('UserPassword', value => throwIfNotValid(value, isUserPasswordValid, 'user password')) |
124 | ] | 47 | @Column |
125 | addMethodsToModel(User, classMethods, instanceMethods) | 48 | password: string |
126 | 49 | ||
127 | return User | 50 | @AllowNull(false) |
128 | } | 51 | @Is('UserPassword', value => throwIfNotValid(value, isUserUsernameValid, 'user name')) |
52 | @Column | ||
53 | username: string | ||
54 | |||
55 | @AllowNull(false) | ||
56 | @IsEmail | ||
57 | @Column(DataType.STRING(400)) | ||
58 | email: string | ||
59 | |||
60 | @AllowNull(false) | ||
61 | @Default(false) | ||
62 | @Is('UserDisplayNSFW', value => throwIfNotValid(value, isUserDisplayNSFWValid, 'display NSFW boolean')) | ||
63 | @Column | ||
64 | displayNSFW: boolean | ||
65 | |||
66 | @AllowNull(false) | ||
67 | @Is('UserRole', value => throwIfNotValid(value, isUserRoleValid, 'role')) | ||
68 | @Column | ||
69 | role: number | ||
70 | |||
71 | @AllowNull(false) | ||
72 | @Is('UserVideoQuota', value => throwIfNotValid(value, isUserVideoQuotaValid, 'video quota')) | ||
73 | @Column(DataType.BIGINT) | ||
74 | videoQuota: number | ||
75 | |||
76 | @CreatedAt | ||
77 | createdAt: Date | ||
78 | |||
79 | @UpdatedAt | ||
80 | updatedAt: Date | ||
81 | |||
82 | @HasOne(() => AccountModel, { | ||
83 | foreignKey: 'userId', | ||
84 | onDelete: 'cascade' | ||
85 | }) | ||
86 | Account: AccountModel | ||
129 | 87 | ||
130 | function beforeCreateOrUpdate (user: UserInstance) { | 88 | @HasMany(() => OAuthTokenModel, { |
131 | if (user.changed('password')) { | 89 | foreignKey: 'userId', |
132 | return cryptPassword(user.password) | 90 | onDelete: 'cascade' |
133 | .then(hash => { | 91 | }) |
134 | user.password = hash | 92 | OAuthTokens: OAuthTokenModel[] |
135 | return undefined | 93 | |
136 | }) | 94 | @BeforeCreate |
95 | @BeforeUpdate | ||
96 | static cryptPasswordIfNeeded (instance: UserModel) { | ||
97 | if (instance.changed('password')) { | ||
98 | return cryptPassword(instance.password) | ||
99 | .then(hash => { | ||
100 | instance.password = hash | ||
101 | return undefined | ||
102 | }) | ||
103 | } | ||
137 | } | 104 | } |
138 | } | ||
139 | |||
140 | // ------------------------------ METHODS ------------------------------ | ||
141 | 105 | ||
142 | hasRight = function (this: UserInstance, right: UserRight) { | 106 | static countTotal () { |
143 | return hasUserRight(this.role, right) | 107 | return this.count() |
144 | } | 108 | } |
145 | 109 | ||
146 | isPasswordMatch = function (this: UserInstance, password: string) { | 110 | static getByUsername (username: string) { |
147 | return comparePassword(password, this.password) | 111 | const query = { |
148 | } | 112 | where: { |
113 | username: username | ||
114 | }, | ||
115 | include: [ { model: AccountModel, required: true } ] | ||
116 | } | ||
149 | 117 | ||
150 | toFormattedJSON = function (this: UserInstance) { | 118 | return UserModel.findOne(query) |
151 | const json = { | ||
152 | id: this.id, | ||
153 | username: this.username, | ||
154 | email: this.email, | ||
155 | displayNSFW: this.displayNSFW, | ||
156 | role: this.role, | ||
157 | roleLabel: USER_ROLE_LABELS[this.role], | ||
158 | videoQuota: this.videoQuota, | ||
159 | createdAt: this.createdAt, | ||
160 | account: this.Account.toFormattedJSON() | ||
161 | } | 119 | } |
162 | 120 | ||
163 | if (Array.isArray(this.Account.VideoChannels) === true) { | 121 | static listForApi (start: number, count: number, sort: string) { |
164 | const videoChannels = this.Account.VideoChannels | 122 | const query = { |
165 | .map(c => c.toFormattedJSON()) | 123 | offset: start, |
166 | .sort((v1, v2) => { | 124 | limit: count, |
167 | if (v1.createdAt < v2.createdAt) return -1 | 125 | order: [ getSort(sort) ], |
168 | if (v1.createdAt === v2.createdAt) return 0 | 126 | include: [ { model: AccountModel, required: true } ] |
127 | } | ||
169 | 128 | ||
170 | return 1 | 129 | return UserModel.findAndCountAll(query) |
130 | .then(({ rows, count }) => { | ||
131 | return { | ||
132 | data: rows, | ||
133 | total: count | ||
134 | } | ||
171 | }) | 135 | }) |
172 | |||
173 | json['videoChannels'] = videoChannels | ||
174 | } | 136 | } |
175 | 137 | ||
176 | return json | 138 | static loadById (id: number) { |
177 | } | 139 | const options = { |
178 | 140 | include: [ { model: AccountModel, required: true } ] | |
179 | isAbleToUploadVideo = function (this: UserInstance, videoFile: Express.Multer.File) { | 141 | } |
180 | if (this.videoQuota === -1) return Promise.resolve(true) | ||
181 | |||
182 | return getOriginalVideoFileTotalFromUser(this).then(totalBytes => { | ||
183 | return (videoFile.size + totalBytes) < this.videoQuota | ||
184 | }) | ||
185 | } | ||
186 | |||
187 | // ------------------------------ STATICS ------------------------------ | ||
188 | |||
189 | function associate (models) { | ||
190 | User.hasOne(models.Account, { | ||
191 | foreignKey: 'userId', | ||
192 | onDelete: 'cascade' | ||
193 | }) | ||
194 | 142 | ||
195 | User.hasMany(models.OAuthToken, { | 143 | return UserModel.findById(id, options) |
196 | foreignKey: 'userId', | 144 | } |
197 | onDelete: 'cascade' | ||
198 | }) | ||
199 | } | ||
200 | 145 | ||
201 | countTotal = function () { | 146 | static loadByUsername (username: string) { |
202 | return this.count() | 147 | const query = { |
203 | } | 148 | where: { |
149 | username | ||
150 | }, | ||
151 | include: [ { model: AccountModel, required: true } ] | ||
152 | } | ||
204 | 153 | ||
205 | getByUsername = function (username: string) { | 154 | return UserModel.findOne(query) |
206 | const query = { | ||
207 | where: { | ||
208 | username: username | ||
209 | }, | ||
210 | include: [ { model: User['sequelize'].models.Account, required: true } ] | ||
211 | } | 155 | } |
212 | 156 | ||
213 | return User.findOne(query) | 157 | static loadByUsernameAndPopulateChannels (username: string) { |
214 | } | 158 | const query = { |
159 | where: { | ||
160 | username | ||
161 | }, | ||
162 | include: [ | ||
163 | { | ||
164 | model: AccountModel, | ||
165 | required: true, | ||
166 | include: [ VideoChannelModel ] | ||
167 | } | ||
168 | ] | ||
169 | } | ||
215 | 170 | ||
216 | listForApi = function (start: number, count: number, sort: string) { | 171 | return UserModel.findOne(query) |
217 | const query = { | ||
218 | offset: start, | ||
219 | limit: count, | ||
220 | order: [ getSort(sort) ], | ||
221 | include: [ { model: User['sequelize'].models.Account, required: true } ] | ||
222 | } | 172 | } |
223 | 173 | ||
224 | return User.findAndCountAll(query).then(({ rows, count }) => { | 174 | static loadByUsernameOrEmail (username: string, email: string) { |
225 | return { | 175 | const query = { |
226 | data: rows, | 176 | include: [ { model: AccountModel, required: true } ], |
227 | total: count | 177 | where: { |
178 | [ Sequelize.Op.or ]: [ { username }, { email } ] | ||
179 | } | ||
228 | } | 180 | } |
229 | }) | ||
230 | } | ||
231 | 181 | ||
232 | loadById = function (id: number) { | 182 | // FIXME: https://github.com/DefinitelyTyped/DefinitelyTyped/issues/18387 |
233 | const options = { | 183 | return (UserModel as any).findOne(query) |
234 | include: [ { model: User['sequelize'].models.Account, required: true } ] | ||
235 | } | 184 | } |
236 | 185 | ||
237 | return User.findById(id, options) | 186 | private static getOriginalVideoFileTotalFromUser (user: UserModel) { |
238 | } | 187 | // Don't use sequelize because we need to use a sub query |
188 | const query = 'SELECT SUM("size") AS "total" FROM ' + | ||
189 | '(SELECT MAX("videoFile"."size") AS "size" FROM "videoFile" ' + | ||
190 | 'INNER JOIN "video" ON "videoFile"."videoId" = "video"."id" ' + | ||
191 | 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' + | ||
192 | 'INNER JOIN "account" ON "videoChannel"."accountId" = "account"."id" ' + | ||
193 | 'INNER JOIN "user" ON "account"."userId" = "user"."id" ' + | ||
194 | 'WHERE "user"."id" = $userId GROUP BY "video"."id") t' | ||
195 | |||
196 | const options = { | ||
197 | bind: { userId: user.id }, | ||
198 | type: Sequelize.QueryTypes.SELECT | ||
199 | } | ||
200 | return UserModel.sequelize.query(query, options) | ||
201 | .then(([ { total } ]) => { | ||
202 | if (total === null) return 0 | ||
239 | 203 | ||
240 | loadByUsername = function (username: string) { | 204 | return parseInt(total, 10) |
241 | const query = { | 205 | }) |
242 | where: { | ||
243 | username | ||
244 | }, | ||
245 | include: [ { model: User['sequelize'].models.Account, required: true } ] | ||
246 | } | 206 | } |
247 | 207 | ||
248 | return User.findOne(query) | 208 | hasRight (right: UserRight) { |
249 | } | 209 | return hasUserRight(this.role, right) |
210 | } | ||
250 | 211 | ||
251 | loadByUsernameAndPopulateChannels = function (username: string) { | 212 | isPasswordMatch (password: string) { |
252 | const query = { | 213 | return comparePassword(password, this.password) |
253 | where: { | ||
254 | username | ||
255 | }, | ||
256 | include: [ | ||
257 | { | ||
258 | model: User['sequelize'].models.Account, | ||
259 | required: true, | ||
260 | include: [ User['sequelize'].models.VideoChannel ] | ||
261 | } | ||
262 | ] | ||
263 | } | 214 | } |
264 | 215 | ||
265 | return User.findOne(query) | 216 | toFormattedJSON () { |
266 | } | 217 | const json = { |
218 | id: this.id, | ||
219 | username: this.username, | ||
220 | email: this.email, | ||
221 | displayNSFW: this.displayNSFW, | ||
222 | role: this.role, | ||
223 | roleLabel: USER_ROLE_LABELS[ this.role ], | ||
224 | videoQuota: this.videoQuota, | ||
225 | createdAt: this.createdAt, | ||
226 | account: this.Account.toFormattedJSON() | ||
227 | } | ||
228 | |||
229 | if (Array.isArray(this.Account.VideoChannels) === true) { | ||
230 | json['videoChannels'] = this.Account.VideoChannels | ||
231 | .map(c => c.toFormattedJSON()) | ||
232 | .sort((v1, v2) => { | ||
233 | if (v1.createdAt < v2.createdAt) return -1 | ||
234 | if (v1.createdAt === v2.createdAt) return 0 | ||
267 | 235 | ||
268 | loadByUsernameOrEmail = function (username: string, email: string) { | 236 | return 1 |
269 | const query = { | 237 | }) |
270 | include: [ { model: User['sequelize'].models.Account, required: true } ], | ||
271 | where: { | ||
272 | [Sequelize.Op.or]: [ { username }, { email } ] | ||
273 | } | 238 | } |
239 | |||
240 | return json | ||
274 | } | 241 | } |
275 | 242 | ||
276 | // FIXME: https://github.com/DefinitelyTyped/DefinitelyTyped/issues/18387 | 243 | isAbleToUploadVideo (videoFile: Express.Multer.File) { |
277 | return (User as any).findOne(query) | 244 | if (this.videoQuota === -1) return Promise.resolve(true) |
278 | } | ||
279 | 245 | ||
280 | // --------------------------------------------------------------------------- | 246 | return UserModel.getOriginalVideoFileTotalFromUser(this) |
281 | 247 | .then(totalBytes => { | |
282 | function getOriginalVideoFileTotalFromUser (user: UserInstance) { | 248 | return (videoFile.size + totalBytes) < this.videoQuota |
283 | // Don't use sequelize because we need to use a sub query | 249 | }) |
284 | const query = 'SELECT SUM("size") AS "total" FROM ' + | ||
285 | '(SELECT MAX("VideoFiles"."size") AS "size" FROM "VideoFiles" ' + | ||
286 | 'INNER JOIN "Videos" ON "VideoFiles"."videoId" = "Videos"."id" ' + | ||
287 | 'INNER JOIN "VideoChannels" ON "VideoChannels"."id" = "Videos"."channelId" ' + | ||
288 | 'INNER JOIN "Accounts" ON "VideoChannels"."accountId" = "Accounts"."id" ' + | ||
289 | 'INNER JOIN "Users" ON "Accounts"."userId" = "Users"."id" ' + | ||
290 | 'WHERE "Users"."id" = $userId GROUP BY "Videos"."id") t' | ||
291 | |||
292 | const options = { | ||
293 | bind: { userId: user.id }, | ||
294 | type: Sequelize.QueryTypes.SELECT | ||
295 | } | 250 | } |
296 | return User['sequelize'].query(query, options).then(([ { total } ]) => { | ||
297 | if (total === null) return 0 | ||
298 | |||
299 | return parseInt(total, 10) | ||
300 | }) | ||
301 | } | 251 | } |