Add MANAGE_PEERTUBE_FOLLOW right
[oweals/peertube.git] / server / models / account / account.ts
1 import * as Sequelize from 'sequelize'
2
3 import {
4   isUserUsernameValid,
5   isAccountPublicKeyValid,
6   isAccountUrlValid,
7   isAccountPrivateKeyValid,
8   isAccountFollowersCountValid,
9   isAccountFollowingCountValid,
10   isAccountInboxValid,
11   isAccountOutboxValid,
12   isAccountSharedInboxValid,
13   isAccountFollowersValid,
14   isAccountFollowingValid,
15   activityPubContextify
16 } from '../../helpers'
17
18 import { addMethodsToModel, getSort } from '../utils'
19 import {
20   AccountInstance,
21   AccountAttributes,
22
23   AccountMethods
24 } from './account-interface'
25 import LoadApplication = AccountMethods.LoadApplication
26 import { sendDeleteAccount } from '../../lib/activitypub/send-request'
27
28 let Account: Sequelize.Model<AccountInstance, AccountAttributes>
29 let loadAccountByPodAndUUID: AccountMethods.LoadAccountByPodAndUUID
30 let load: AccountMethods.Load
31 let loadApplication: AccountMethods.LoadApplication
32 let loadByUUID: AccountMethods.LoadByUUID
33 let loadByUrl: AccountMethods.LoadByUrl
34 let loadLocalAccountByNameAndPod: AccountMethods.LoadLocalAccountByNameAndPod
35 let listOwned: AccountMethods.ListOwned
36 let listAcceptedFollowerUrlsForApi: AccountMethods.ListAcceptedFollowerUrlsForApi
37 let listAcceptedFollowingUrlsForApi: AccountMethods.ListAcceptedFollowingUrlsForApi
38 let listFollowingForApi: AccountMethods.ListFollowingForApi
39 let listFollowersForApi: AccountMethods.ListFollowersForApi
40 let isOwned: AccountMethods.IsOwned
41 let toActivityPubObject: AccountMethods.ToActivityPubObject
42 let toFormattedJSON: AccountMethods.ToFormattedJSON
43 let getFollowerSharedInboxUrls: AccountMethods.GetFollowerSharedInboxUrls
44 let getFollowingUrl: AccountMethods.GetFollowingUrl
45 let getFollowersUrl: AccountMethods.GetFollowersUrl
46 let getPublicKeyUrl: AccountMethods.GetPublicKeyUrl
47
48 export default function defineAccount (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.DataTypes) {
49   Account = sequelize.define<AccountInstance, AccountAttributes>('Account',
50     {
51       uuid: {
52         type: DataTypes.UUID,
53         defaultValue: DataTypes.UUIDV4,
54         allowNull: false,
55         validate: {
56           isUUID: 4
57         }
58       },
59       name: {
60         type: DataTypes.STRING,
61         allowNull: false,
62         validate: {
63           usernameValid: value => {
64             const res = isUserUsernameValid(value)
65             if (res === false) throw new Error('Username is not valid.')
66           }
67         }
68       },
69       url: {
70         type: DataTypes.STRING,
71         allowNull: false,
72         validate: {
73           urlValid: value => {
74             const res = isAccountUrlValid(value)
75             if (res === false) throw new Error('URL is not valid.')
76           }
77         }
78       },
79       publicKey: {
80         type: DataTypes.STRING,
81         allowNull: false,
82         validate: {
83           publicKeyValid: value => {
84             const res = isAccountPublicKeyValid(value)
85             if (res === false) throw new Error('Public key is not valid.')
86           }
87         }
88       },
89       privateKey: {
90         type: DataTypes.STRING,
91         allowNull: false,
92         validate: {
93           privateKeyValid: value => {
94             const res = isAccountPrivateKeyValid(value)
95             if (res === false) throw new Error('Private key is not valid.')
96           }
97         }
98       },
99       followersCount: {
100         type: DataTypes.INTEGER,
101         allowNull: false,
102         validate: {
103           followersCountValid: value => {
104             const res = isAccountFollowersCountValid(value)
105             if (res === false) throw new Error('Followers count is not valid.')
106           }
107         }
108       },
109       followingCount: {
110         type: DataTypes.INTEGER,
111         allowNull: false,
112         validate: {
113           followersCountValid: value => {
114             const res = isAccountFollowingCountValid(value)
115             if (res === false) throw new Error('Following count is not valid.')
116           }
117         }
118       },
119       inboxUrl: {
120         type: DataTypes.STRING,
121         allowNull: false,
122         validate: {
123           inboxUrlValid: value => {
124             const res = isAccountInboxValid(value)
125             if (res === false) throw new Error('Inbox URL is not valid.')
126           }
127         }
128       },
129       outboxUrl: {
130         type: DataTypes.STRING,
131         allowNull: false,
132         validate: {
133           outboxUrlValid: value => {
134             const res = isAccountOutboxValid(value)
135             if (res === false) throw new Error('Outbox URL is not valid.')
136           }
137         }
138       },
139       sharedInboxUrl: {
140         type: DataTypes.STRING,
141         allowNull: false,
142         validate: {
143           sharedInboxUrlValid: value => {
144             const res = isAccountSharedInboxValid(value)
145             if (res === false) throw new Error('Shared inbox URL is not valid.')
146           }
147         }
148       },
149       followersUrl: {
150         type: DataTypes.STRING,
151         allowNull: false,
152         validate: {
153           followersUrlValid: value => {
154             const res = isAccountFollowersValid(value)
155             if (res === false) throw new Error('Followers URL is not valid.')
156           }
157         }
158       },
159       followingUrl: {
160         type: DataTypes.STRING,
161         allowNull: false,
162         validate: {
163           followingUrlValid: value => {
164             const res = isAccountFollowingValid(value)
165             if (res === false) throw new Error('Following URL is not valid.')
166           }
167         }
168       }
169     },
170     {
171       indexes: [
172         {
173           fields: [ 'name' ]
174         },
175         {
176           fields: [ 'podId' ]
177         },
178         {
179           fields: [ 'userId' ],
180           unique: true
181         },
182         {
183           fields: [ 'applicationId' ],
184           unique: true
185         },
186         {
187           fields: [ 'name', 'podId' ],
188           unique: true
189         }
190       ],
191       hooks: { afterDestroy }
192     }
193   )
194
195   const classMethods = [
196     associate,
197     loadAccountByPodAndUUID,
198     loadApplication,
199     load,
200     loadByUUID,
201     loadByUrl,
202     loadLocalAccountByNameAndPod,
203     listOwned,
204     listAcceptedFollowerUrlsForApi,
205     listAcceptedFollowingUrlsForApi,
206     listFollowingForApi,
207     listFollowersForApi
208   ]
209   const instanceMethods = [
210     isOwned,
211     toActivityPubObject,
212     toFormattedJSON,
213     getFollowerSharedInboxUrls,
214     getFollowingUrl,
215     getFollowersUrl,
216     getPublicKeyUrl
217   ]
218   addMethodsToModel(Account, classMethods, instanceMethods)
219
220   return Account
221 }
222
223 // ---------------------------------------------------------------------------
224
225 function associate (models) {
226   Account.belongsTo(models.Pod, {
227     foreignKey: {
228       name: 'podId',
229       allowNull: true
230     },
231     onDelete: 'cascade'
232   })
233
234   Account.belongsTo(models.User, {
235     foreignKey: {
236       name: 'userId',
237       allowNull: true
238     },
239     onDelete: 'cascade'
240   })
241
242   Account.belongsTo(models.Application, {
243     foreignKey: {
244       name: 'userId',
245       allowNull: true
246     },
247     onDelete: 'cascade'
248   })
249
250   Account.hasMany(models.VideoChannel, {
251     foreignKey: {
252       name: 'accountId',
253       allowNull: false
254     },
255     onDelete: 'cascade',
256     hooks: true
257   })
258
259   Account.hasMany(models.AccountFollower, {
260     foreignKey: {
261       name: 'accountId',
262       allowNull: false
263     },
264     as: 'following',
265     onDelete: 'cascade'
266   })
267
268   Account.hasMany(models.AccountFollower, {
269     foreignKey: {
270       name: 'targetAccountId',
271       allowNull: false
272     },
273     as: 'followers',
274     onDelete: 'cascade'
275   })
276 }
277
278 function afterDestroy (account: AccountInstance) {
279   if (account.isOwned()) {
280     return sendDeleteAccount(account, undefined)
281   }
282
283   return undefined
284 }
285
286 toFormattedJSON = function (this: AccountInstance) {
287   const json = {
288     id: this.id,
289     host: this.Pod.host,
290     name: this.name
291   }
292
293   return json
294 }
295
296 toActivityPubObject = function (this: AccountInstance) {
297   const type = this.podId ? 'Application' as 'Application' : 'Person' as 'Person'
298
299   const json = {
300     type,
301     id: this.url,
302     following: this.getFollowingUrl(),
303     followers: this.getFollowersUrl(),
304     inbox: this.inboxUrl,
305     outbox: this.outboxUrl,
306     preferredUsername: this.name,
307     url: this.url,
308     name: this.name,
309     endpoints: {
310       sharedInbox: this.sharedInboxUrl
311     },
312     uuid: this.uuid,
313     publicKey: {
314       id: this.getPublicKeyUrl(),
315       owner: this.url,
316       publicKeyPem: this.publicKey
317     }
318   }
319
320   return activityPubContextify(json)
321 }
322
323 isOwned = function (this: AccountInstance) {
324   return this.podId === null
325 }
326
327 getFollowerSharedInboxUrls = function (this: AccountInstance) {
328   const query: Sequelize.FindOptions<AccountAttributes> = {
329     attributes: [ 'sharedInboxUrl' ],
330     include: [
331       {
332         model: Account['sequelize'].models.AccountFollower,
333         where: {
334           targetAccountId: this.id
335         }
336       }
337     ]
338   }
339
340   return Account.findAll(query)
341     .then(accounts => accounts.map(a => a.sharedInboxUrl))
342 }
343
344 getFollowingUrl = function (this: AccountInstance) {
345   return this.url + '/followers'
346 }
347
348 getFollowersUrl = function (this: AccountInstance) {
349   return this.url + '/followers'
350 }
351
352 getPublicKeyUrl = function (this: AccountInstance) {
353   return this.url + '#main-key'
354 }
355
356 // ------------------------------ STATICS ------------------------------
357
358 listOwned = function () {
359   const query: Sequelize.FindOptions<AccountAttributes> = {
360     where: {
361       podId: null
362     }
363   }
364
365   return Account.findAll(query)
366 }
367
368 listAcceptedFollowerUrlsForApi = function (id: number, start: number, count?: number) {
369   return createListAcceptedFollowForApiQuery('followers', id, start, count)
370 }
371
372 listAcceptedFollowingUrlsForApi = function (id: number, start: number, count?: number) {
373   return createListAcceptedFollowForApiQuery('following', id, start, count)
374 }
375
376 listFollowingForApi = function (id: number, start: number, count: number, sort: string) {
377   const query = {
378     distinct: true,
379     offset: start,
380     limit: count,
381     order: [ getSort(sort) ],
382     include: [
383       {
384         model: Account['sequelize'].models.AccountFollow,
385         required: true,
386         as: 'following',
387         include: [
388           {
389             model: Account['sequelize'].models.Account,
390             as: 'following',
391             required: true,
392             include: [ Account['sequelize'].models.Pod ]
393           }
394         ]
395       }
396     ]
397   }
398
399   return Account.findAndCountAll(query).then(({ rows, count }) => {
400     return {
401       data: rows,
402       total: count
403     }
404   })
405 }
406
407 listFollowersForApi = function (id: number, start: number, count: number, sort: string) {
408   const query = {
409     distinct: true,
410     offset: start,
411     limit: count,
412     order: [ getSort(sort) ],
413     include: [
414       {
415         model: Account['sequelize'].models.AccountFollow,
416         required: true,
417         as: 'followers',
418         include: [
419           {
420             model: Account['sequelize'].models.Account,
421             as: 'followers',
422             required: true,
423             include: [ Account['sequelize'].models.Pod ]
424           }
425         ]
426       }
427     ]
428   }
429
430   return Account.findAndCountAll(query).then(({ rows, count }) => {
431     return {
432       data: rows,
433       total: count
434     }
435   })
436 }
437
438 loadApplication = function () {
439   return Account.findOne({
440     include: [
441       {
442         model: Account['sequelize'].model.Application,
443         required: true
444       }
445     ]
446   })
447 }
448
449 load = function (id: number) {
450   return Account.findById(id)
451 }
452
453 loadByUUID = function (uuid: string) {
454   const query: Sequelize.FindOptions<AccountAttributes> = {
455     where: {
456       uuid
457     }
458   }
459
460   return Account.findOne(query)
461 }
462
463 loadLocalAccountByNameAndPod = function (name: string, host: string) {
464   const query: Sequelize.FindOptions<AccountAttributes> = {
465     where: {
466       name,
467       userId: {
468         [Sequelize.Op.ne]: null
469       }
470     },
471     include: [
472       {
473         model: Account['sequelize'].models.Pod,
474         where: {
475           host
476         }
477       }
478     ]
479   }
480
481   return Account.findOne(query)
482 }
483
484 loadByUrl = function (url: string, transaction?: Sequelize.Transaction) {
485   const query: Sequelize.FindOptions<AccountAttributes> = {
486     where: {
487       url
488     },
489     transaction
490   }
491
492   return Account.findOne(query)
493 }
494
495 loadAccountByPodAndUUID = function (uuid: string, podId: number, transaction: Sequelize.Transaction) {
496   const query: Sequelize.FindOptions<AccountAttributes> = {
497     where: {
498       podId,
499       uuid
500     },
501     transaction
502   }
503
504   return Account.find(query)
505 }
506
507 // ------------------------------ UTILS ------------------------------
508
509 async function createListAcceptedFollowForApiQuery (type: 'followers' | 'following', id: number, start: number, count?: number) {
510   let firstJoin: string
511   let secondJoin: string
512
513   if (type === 'followers') {
514     firstJoin = 'targetAccountId'
515     secondJoin = 'accountId'
516   } else {
517     firstJoin = 'accountId'
518     secondJoin = 'targetAccountId'
519   }
520
521   const selections = [ '"Followers"."url" AS "url"', 'COUNT(*) AS "total"' ]
522   const tasks: Promise<any>[] = []
523
524   for (const selection of selections) {
525     let query = 'SELECT ' + selection + ' FROM "Account" ' +
526       'INNER JOIN "AccountFollower" ON "AccountFollower"."' + firstJoin + '" = "Account"."id" ' +
527       'INNER JOIN "Account" AS "Follows" ON "Followers"."id" = "Follows"."' + secondJoin + '" ' +
528       'WHERE "Account"."id" = $id AND "AccountFollower"."state" = \'accepted\' ' +
529       'LIMIT ' + start
530
531     if (count !== undefined) query += ', ' + count
532
533     const options = {
534       bind: { id },
535       type: Sequelize.QueryTypes.SELECT
536     }
537     tasks.push(Account['sequelize'].query(query, options))
538   }
539
540   const [ followers, [ { total } ]] = await Promise.all(tasks)
541   const urls: string[] = followers.map(f => f.url)
542
543   return {
544     data: urls,
545     total: parseInt(total, 10)
546   }
547 }