Server: add database field validations
[oweals/peertube.git] / server / models / video.js
1 'use strict'
2
3 const createTorrent = require('create-torrent')
4 const ffmpeg = require('fluent-ffmpeg')
5 const fs = require('fs')
6 const magnetUtil = require('magnet-uri')
7 const map = require('lodash/map')
8 const parallel = require('async/parallel')
9 const parseTorrent = require('parse-torrent')
10 const pathUtils = require('path')
11 const values = require('lodash/values')
12
13 const constants = require('../initializers/constants')
14 const logger = require('../helpers/logger')
15 const modelUtils = require('./utils')
16 const customVideosValidators = require('../helpers/custom-validators').videos
17
18 // ---------------------------------------------------------------------------
19
20 module.exports = function (sequelize, DataTypes) {
21   // TODO: add indexes on searchable columns
22   const Video = sequelize.define('Video',
23     {
24       id: {
25         type: DataTypes.UUID,
26         defaultValue: DataTypes.UUIDV4,
27         primaryKey: true,
28         validate: {
29           isUUID: 4
30         }
31       },
32       name: {
33         type: DataTypes.STRING,
34         allowNull: false,
35         validate: {
36           nameValid: function (value) {
37             const res = customVideosValidators.isVideoNameValid(value)
38             if (res === false) throw new Error('Video name is not valid.')
39           }
40         }
41       },
42       extname: {
43         type: DataTypes.ENUM(values(constants.CONSTRAINTS_FIELDS.VIDEOS.EXTNAME)),
44         allowNull: false
45       },
46       remoteId: {
47         type: DataTypes.UUID,
48         allowNull: true,
49         validate: {
50           isUUID: 4
51         }
52       },
53       description: {
54         type: DataTypes.STRING,
55         allowNull: false,
56         validate: {
57           descriptionValid: function (value) {
58             const res = customVideosValidators.isVideoDescriptionValid(value)
59             if (res === false) throw new Error('Video description is not valid.')
60           }
61         }
62       },
63       infoHash: {
64         type: DataTypes.STRING,
65         allowNull: false,
66         validate: {
67           infoHashValid: function (value) {
68             const res = customVideosValidators.isVideoInfoHashValid(value)
69             if (res === false) throw new Error('Video info hash is not valid.')
70           }
71         }
72       },
73       duration: {
74         type: DataTypes.INTEGER,
75         allowNull: false,
76         validate: {
77           durationValid: function (value) {
78             const res = customVideosValidators.isVideoDurationValid(value)
79             if (res === false) throw new Error('Video duration is not valid.')
80           }
81         }
82       }
83     },
84     {
85       classMethods: {
86         associate,
87
88         generateThumbnailFromBase64,
89         getDurationFromFile,
90         list,
91         listForApi,
92         listByHostAndRemoteId,
93         listOwnedAndPopulateAuthorAndTags,
94         listOwnedByAuthor,
95         load,
96         loadAndPopulateAuthor,
97         loadAndPopulateAuthorAndPodAndTags,
98         searchAndPopulateAuthorAndPodAndTags
99       },
100       instanceMethods: {
101         generateMagnetUri,
102         getVideoFilename,
103         getThumbnailName,
104         getPreviewName,
105         getTorrentName,
106         isOwned,
107         toFormatedJSON,
108         toRemoteJSON
109       },
110       hooks: {
111         beforeValidate,
112         beforeCreate,
113         afterDestroy
114       }
115     }
116   )
117
118   return Video
119 }
120
121 function beforeValidate (video, options, next) {
122   if (video.isOwned()) {
123     // 40 hexa length
124     video.infoHash = '0123456789abcdef0123456789abcdef01234567'
125   }
126
127   return next(null)
128 }
129
130 function beforeCreate (video, options, next) {
131   const tasks = []
132
133   if (video.isOwned()) {
134     const videoPath = pathUtils.join(constants.CONFIG.STORAGE.VIDEOS_DIR, video.getVideoFilename())
135
136     tasks.push(
137       // TODO: refractoring
138       function (callback) {
139         const options = {
140           announceList: [
141             [ constants.CONFIG.WEBSERVER.WS + '://' + constants.CONFIG.WEBSERVER.HOSTNAME + ':' + constants.CONFIG.WEBSERVER.PORT + '/tracker/socket' ]
142           ],
143           urlList: [
144             constants.CONFIG.WEBSERVER.URL + constants.STATIC_PATHS.WEBSEED + video.getVideoFilename()
145           ]
146         }
147
148         createTorrent(videoPath, options, function (err, torrent) {
149           if (err) return callback(err)
150
151           fs.writeFile(constants.CONFIG.STORAGE.TORRENTS_DIR + video.getTorrentName(), torrent, function (err) {
152             if (err) return callback(err)
153
154             const parsedTorrent = parseTorrent(torrent)
155             video.set('infoHash', parsedTorrent.infoHash)
156             video.validate().asCallback(callback)
157           })
158         })
159       },
160       function (callback) {
161         createThumbnail(video, videoPath, callback)
162       },
163       function (callback) {
164         createPreview(video, videoPath, callback)
165       }
166     )
167
168     return parallel(tasks, next)
169   }
170
171   return next()
172 }
173
174 function afterDestroy (video, options, next) {
175   const tasks = []
176
177   tasks.push(
178     function (callback) {
179       removeThumbnail(video, callback)
180     }
181   )
182
183   if (video.isOwned()) {
184     tasks.push(
185       function (callback) {
186         removeFile(video, callback)
187       },
188       function (callback) {
189         removeTorrent(video, callback)
190       },
191       function (callback) {
192         removePreview(video, callback)
193       }
194     )
195   }
196
197   parallel(tasks, next)
198 }
199
200 // ------------------------------ METHODS ------------------------------
201
202 function associate (models) {
203   this.belongsTo(models.Author, {
204     foreignKey: {
205       name: 'authorId',
206       allowNull: false
207     },
208     onDelete: 'cascade'
209   })
210
211   this.belongsToMany(models.Tag, {
212     foreignKey: 'videoId',
213     through: models.VideoTag,
214     onDelete: 'cascade'
215   })
216 }
217
218 function generateMagnetUri () {
219   let baseUrlHttp, baseUrlWs
220
221   if (this.isOwned()) {
222     baseUrlHttp = constants.CONFIG.WEBSERVER.URL
223     baseUrlWs = constants.CONFIG.WEBSERVER.WS + '://' + constants.CONFIG.WEBSERVER.HOSTNAME + ':' + constants.CONFIG.WEBSERVER.PORT
224   } else {
225     baseUrlHttp = constants.REMOTE_SCHEME.HTTP + '://' + this.Author.Pod.host
226     baseUrlWs = constants.REMOTE_SCHEME.WS + '://' + this.Author.Pod.host
227   }
228
229   const xs = baseUrlHttp + constants.STATIC_PATHS.TORRENTS + this.getTorrentName()
230   const announce = baseUrlWs + '/tracker/socket'
231   const urlList = [ baseUrlHttp + constants.STATIC_PATHS.WEBSEED + this.getVideoFilename() ]
232
233   const magnetHash = {
234     xs,
235     announce,
236     urlList,
237     infoHash: this.infoHash,
238     name: this.name
239   }
240
241   return magnetUtil.encode(magnetHash)
242 }
243
244 function getVideoFilename () {
245   if (this.isOwned()) return this.id + this.extname
246
247   return this.remoteId + this.extname
248 }
249
250 function getThumbnailName () {
251   // We always have a copy of the thumbnail
252   return this.id + '.jpg'
253 }
254
255 function getPreviewName () {
256   const extension = '.jpg'
257
258   if (this.isOwned()) return this.id + extension
259
260   return this.remoteId + extension
261 }
262
263 function getTorrentName () {
264   const extension = '.torrent'
265
266   if (this.isOwned()) return this.id + extension
267
268   return this.remoteId + extension
269 }
270
271 function isOwned () {
272   return this.remoteId === null
273 }
274
275 function toFormatedJSON () {
276   let podHost
277
278   if (this.Author.Pod) {
279     podHost = this.Author.Pod.host
280   } else {
281     // It means it's our video
282     podHost = constants.CONFIG.WEBSERVER.HOST
283   }
284
285   const json = {
286     id: this.id,
287     name: this.name,
288     description: this.description,
289     podHost,
290     isLocal: this.isOwned(),
291     magnetUri: this.generateMagnetUri(),
292     author: this.Author.name,
293     duration: this.duration,
294     tags: map(this.Tags, 'name'),
295     thumbnailPath: constants.STATIC_PATHS.THUMBNAILS + '/' + this.getThumbnailName(),
296     createdAt: this.createdAt
297   }
298
299   return json
300 }
301
302 function toRemoteJSON (callback) {
303   const self = this
304
305   // Convert thumbnail to base64
306   const thumbnailPath = pathUtils.join(constants.CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
307   fs.readFile(thumbnailPath, function (err, thumbnailData) {
308     if (err) {
309       logger.error('Cannot read the thumbnail of the video')
310       return callback(err)
311     }
312
313     const remoteVideo = {
314       name: self.name,
315       description: self.description,
316       infoHash: self.infoHash,
317       remoteId: self.id,
318       author: self.Author.name,
319       duration: self.duration,
320       thumbnailBase64: new Buffer(thumbnailData).toString('base64'),
321       tags: map(self.Tags, 'name'),
322       createdAt: self.createdAt,
323       extname: self.extname
324     }
325
326     return callback(null, remoteVideo)
327   })
328 }
329
330 // ------------------------------ STATICS ------------------------------
331
332 function generateThumbnailFromBase64 (video, thumbnailData, callback) {
333   // Creating the thumbnail for a remote video
334
335   const thumbnailName = video.getThumbnailName()
336   const thumbnailPath = constants.CONFIG.STORAGE.THUMBNAILS_DIR + thumbnailName
337   fs.writeFile(thumbnailPath, thumbnailData, { encoding: 'base64' }, function (err) {
338     if (err) return callback(err)
339
340     return callback(null, thumbnailName)
341   })
342 }
343
344 function getDurationFromFile (videoPath, callback) {
345   ffmpeg.ffprobe(videoPath, function (err, metadata) {
346     if (err) return callback(err)
347
348     return callback(null, Math.floor(metadata.format.duration))
349   })
350 }
351
352 function list (callback) {
353   return this.find().asCallback()
354 }
355
356 function listForApi (start, count, sort, callback) {
357   const query = {
358     offset: start,
359     limit: count,
360     distinct: true, // For the count, a video can have many tags
361     order: [ modelUtils.getSort(sort), [ this.sequelize.models.Tag, 'name', 'ASC' ] ],
362     include: [
363       {
364         model: this.sequelize.models.Author,
365         include: [ { model: this.sequelize.models.Pod, required: false } ]
366       },
367
368       this.sequelize.models.Tag
369     ]
370   }
371
372   return this.findAndCountAll(query).asCallback(function (err, result) {
373     if (err) return callback(err)
374
375     return callback(null, result.rows, result.count)
376   })
377 }
378
379 function listByHostAndRemoteId (fromHost, remoteId, callback) {
380   const query = {
381     where: {
382       remoteId: remoteId
383     },
384     include: [
385       {
386         model: this.sequelize.models.Author,
387         include: [
388           {
389             model: this.sequelize.models.Pod,
390             required: true,
391             where: {
392               host: fromHost
393             }
394           }
395         ]
396       }
397     ]
398   }
399
400   return this.findAll(query).asCallback(callback)
401 }
402
403 function listOwnedAndPopulateAuthorAndTags (callback) {
404   // If remoteId is null this is *our* video
405   const query = {
406     where: {
407       remoteId: null
408     },
409     include: [ this.sequelize.models.Author, this.sequelize.models.Tag ]
410   }
411
412   return this.findAll(query).asCallback(callback)
413 }
414
415 function listOwnedByAuthor (author, callback) {
416   const query = {
417     where: {
418       remoteId: null
419     },
420     include: [
421       {
422         model: this.sequelize.models.Author,
423         where: {
424           name: author
425         }
426       }
427     ]
428   }
429
430   return this.findAll(query).asCallback(callback)
431 }
432
433 function load (id, callback) {
434   return this.findById(id).asCallback(callback)
435 }
436
437 function loadAndPopulateAuthor (id, callback) {
438   const options = {
439     include: [ this.sequelize.models.Author ]
440   }
441
442   return this.findById(id, options).asCallback(callback)
443 }
444
445 function loadAndPopulateAuthorAndPodAndTags (id, callback) {
446   const options = {
447     include: [
448       {
449         model: this.sequelize.models.Author,
450         include: [ { model: this.sequelize.models.Pod, required: false } ]
451       },
452       this.sequelize.models.Tag
453     ]
454   }
455
456   return this.findById(id, options).asCallback(callback)
457 }
458
459 function searchAndPopulateAuthorAndPodAndTags (value, field, start, count, sort, callback) {
460   const podInclude = {
461     model: this.sequelize.models.Pod,
462     required: false
463   }
464
465   const authorInclude = {
466     model: this.sequelize.models.Author,
467     include: [
468       podInclude
469     ]
470   }
471
472   const tagInclude = {
473     model: this.sequelize.models.Tag
474   }
475
476   const query = {
477     where: {},
478     offset: start,
479     limit: count,
480     distinct: true, // For the count, a video can have many tags
481     order: [ modelUtils.getSort(sort), [ this.sequelize.models.Tag, 'name', 'ASC' ] ]
482   }
483
484   // Make an exact search with the magnet
485   if (field === 'magnetUri') {
486     const infoHash = magnetUtil.decode(value).infoHash
487     query.where.infoHash = infoHash
488   } else if (field === 'tags') {
489     const escapedValue = this.sequelize.escape('%' + value + '%')
490     query.where = {
491       id: {
492         $in: this.sequelize.literal(
493           '(SELECT "VideoTags"."videoId" FROM "Tags" INNER JOIN "VideoTags" ON "Tags"."id" = "VideoTags"."tagId" WHERE name LIKE ' + escapedValue + ')'
494         )
495       }
496     }
497   } else if (field === 'host') {
498     // FIXME: Include our pod? (not stored in the database)
499     podInclude.where = {
500       host: {
501         $like: '%' + value + '%'
502       }
503     }
504     podInclude.required = true
505   } else if (field === 'author') {
506     authorInclude.where = {
507       name: {
508         $like: '%' + value + '%'
509       }
510     }
511
512     // authorInclude.or = true
513   } else {
514     query.where[field] = {
515       $like: '%' + value + '%'
516     }
517   }
518
519   query.include = [
520     authorInclude, tagInclude
521   ]
522
523   if (tagInclude.where) {
524     // query.include.push([ this.sequelize.models.Tag ])
525   }
526
527   return this.findAndCountAll(query).asCallback(function (err, result) {
528     if (err) return callback(err)
529
530     return callback(null, result.rows, result.count)
531   })
532 }
533
534 // ---------------------------------------------------------------------------
535
536 function removeThumbnail (video, callback) {
537   fs.unlink(constants.CONFIG.STORAGE.THUMBNAILS_DIR + video.getThumbnailName(), callback)
538 }
539
540 function removeFile (video, callback) {
541   fs.unlink(constants.CONFIG.STORAGE.VIDEOS_DIR + video.getVideoFilename(), callback)
542 }
543
544 function removeTorrent (video, callback) {
545   fs.unlink(constants.CONFIG.STORAGE.TORRENTS_DIR + video.getTorrentName(), callback)
546 }
547
548 function removePreview (video, callback) {
549   // Same name than video thumnail
550   fs.unlink(constants.CONFIG.STORAGE.PREVIEWS_DIR + video.getPreviewName(), callback)
551 }
552
553 function createPreview (video, videoPath, callback) {
554   generateImage(video, videoPath, constants.CONFIG.STORAGE.PREVIEWS_DIR, video.getPreviewName(), callback)
555 }
556
557 function createThumbnail (video, videoPath, callback) {
558   generateImage(video, videoPath, constants.CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnailName(), constants.THUMBNAILS_SIZE, callback)
559 }
560
561 function generateImage (video, videoPath, folder, imageName, size, callback) {
562   const options = {
563     filename: imageName,
564     count: 1,
565     folder
566   }
567
568   if (!callback) {
569     callback = size
570   } else {
571     options.size = size
572   }
573
574   ffmpeg(videoPath)
575     .on('error', callback)
576     .on('end', function () {
577       callback(null, imageName)
578     })
579     .thumbnail(options)
580 }