Fix issues on server start

pull/128/head
Chocobozzz 2017-11-14 10:57:56 +01:00
parent 1e1265b36c
commit e34c85e527
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
14 changed files with 96 additions and 65 deletions

View File

@ -3,6 +3,7 @@ import * as validator from 'validator'
import { exists, isUUIDValid } from '../misc' import { exists, isUUIDValid } from '../misc'
import { isActivityPubUrlValid } from './misc' import { isActivityPubUrlValid } from './misc'
import { isUserUsernameValid } from '../users' import { isUserUsernameValid } from '../users'
import { CONSTRAINTS_FIELDS } from '../../../initializers/constants'
function isAccountEndpointsObjectValid (endpointObject: any) { function isAccountEndpointsObjectValid (endpointObject: any) {
return isAccountSharedInboxValid(endpointObject.sharedInbox) return isAccountSharedInboxValid(endpointObject.sharedInbox)
@ -34,7 +35,8 @@ function isAccountPublicKeyValid (publicKey: string) {
return exists(publicKey) && return exists(publicKey) &&
typeof publicKey === 'string' && typeof publicKey === 'string' &&
publicKey.startsWith('-----BEGIN PUBLIC KEY-----') && publicKey.startsWith('-----BEGIN PUBLIC KEY-----') &&
publicKey.endsWith('-----END PUBLIC KEY-----') publicKey.endsWith('-----END PUBLIC KEY-----') &&
validator.isLength(publicKey, CONSTRAINTS_FIELDS.ACCOUNTS.PUBLIC_KEY)
} }
function isAccountIdValid (id: string) { function isAccountIdValid (id: string) {
@ -73,7 +75,8 @@ function isAccountPrivateKeyValid (privateKey: string) {
return exists(privateKey) && return exists(privateKey) &&
typeof privateKey === 'string' && typeof privateKey === 'string' &&
privateKey.startsWith('-----BEGIN RSA PRIVATE KEY-----') && privateKey.startsWith('-----BEGIN RSA PRIVATE KEY-----') &&
privateKey.endsWith('-----END RSA PRIVATE KEY-----') privateKey.endsWith('-----END RSA PRIVATE KEY-----') &&
validator.isLength(privateKey, CONSTRAINTS_FIELDS.ACCOUNTS.PRIVATE_KEY)
} }
function isRemoteAccountValid (remoteAccount: any) { function isRemoteAccountValid (remoteAccount: any) {

View File

@ -1,4 +1,7 @@
import * as validator from 'validator'
import { exists } from '../misc' import { exists } from '../misc'
import { isTestInstance } from '../../core-utils'
import { CONSTRAINTS_FIELDS } from '../../../initializers/constants'
function isActivityPubUrlValid (url: string) { function isActivityPubUrlValid (url: string) {
const isURLOptions = { const isURLOptions = {
@ -9,7 +12,12 @@ function isActivityPubUrlValid (url: string) {
protocols: [ 'http', 'https' ] protocols: [ 'http', 'https' ]
} }
return exists(url) && validator.isURL(url, isURLOptions) // We validate 'localhost', so we don't have the top level domain
if (isTestInstance()) {
isURLOptions.require_tld = false
}
return exists(url) && validator.isURL(url, isURLOptions) && validator.isLength(url, CONSTRAINTS_FIELDS.ACCOUNTS.URL)
} }
function isBaseActivityValid (activity: any, type: string) { function isBaseActivityValid (activity: any, type: string) {

View File

@ -10,7 +10,8 @@ import {
isVideoTruncatedDescriptionValid, isVideoTruncatedDescriptionValid,
isVideoDurationValid, isVideoDurationValid,
isVideoNameValid, isVideoNameValid,
isVideoTagValid isVideoTagValid,
isVideoUrlValid
} from '../videos' } from '../videos'
import { isVideoChannelDescriptionValid, isVideoChannelNameValid } from '../video-channels' import { isVideoChannelDescriptionValid, isVideoChannelNameValid } from '../video-channels'
import { isBaseActivityValid } from './misc' import { isBaseActivityValid } from './misc'
@ -93,7 +94,7 @@ function isRemoteVideoContentValid (mediaType: string, content: string) {
function isRemoteVideoIconValid (icon: any) { function isRemoteVideoIconValid (icon: any) {
return icon.type === 'Image' && return icon.type === 'Image' &&
validator.isURL(icon.url) && isVideoUrlValid(icon.url) &&
icon.mediaType === 'image/jpeg' && icon.mediaType === 'image/jpeg' &&
validator.isInt(icon.width, { min: 0 }) && validator.isInt(icon.width, { min: 0 }) &&
validator.isInt(icon.height, { min: 0 }) validator.isInt(icon.height, { min: 0 })
@ -111,7 +112,7 @@ function setValidRemoteVideoUrls (video: any) {
function isRemoteVideoUrlValid (url: any) { function isRemoteVideoUrlValid (url: any) {
return url.type === 'Link' && return url.type === 'Link' &&
ACTIVITY_PUB.VIDEO_URL_MIME_TYPES.indexOf(url.mimeType) !== -1 && ACTIVITY_PUB.VIDEO_URL_MIME_TYPES.indexOf(url.mimeType) !== -1 &&
validator.isURL(url.url) && isVideoUrlValid(url.url) &&
validator.isInt(url.width, { min: 0 }) && validator.isInt(url.width, { min: 0 }) &&
validator.isInt(url.size, { min: 0 }) validator.isInt(url.size, { min: 0 })
} }

View File

@ -8,9 +8,14 @@ import { database as db, CONSTRAINTS_FIELDS } from '../../initializers'
import { VideoChannelInstance } from '../../models' import { VideoChannelInstance } from '../../models'
import { logger } from '../logger' import { logger } from '../logger'
import { exists } from './misc' import { exists } from './misc'
import { isActivityPubUrlValid } from './index'
const VIDEO_CHANNELS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEO_CHANNELS const VIDEO_CHANNELS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEO_CHANNELS
function isVideoChannelUrlValid (value: string) {
return isActivityPubUrlValid(value)
}
function isVideoChannelDescriptionValid (value: string) { function isVideoChannelDescriptionValid (value: string) {
return value === null || validator.isLength(value, VIDEO_CHANNELS_CONSTRAINTS_FIELDS.DESCRIPTION) return value === null || validator.isLength(value, VIDEO_CHANNELS_CONSTRAINTS_FIELDS.DESCRIPTION)
} }
@ -53,5 +58,6 @@ export {
isVideoChannelDescriptionValid, isVideoChannelDescriptionValid,
isVideoChannelNameValid, isVideoChannelNameValid,
isVideoChannelUUIDValid, isVideoChannelUUIDValid,
checkVideoChannelExists checkVideoChannelExists,
isVideoChannelUrlValid
} }

View File

@ -19,6 +19,7 @@ import { isArray, exists } from './misc'
import { VideoInstance } from '../../models' import { VideoInstance } from '../../models'
import { logger } from '../../helpers' import { logger } from '../../helpers'
import { VideoRateType } from '../../../shared' import { VideoRateType } from '../../../shared'
import { isActivityPubUrlValid } from './activitypub/misc'
const VIDEOS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEOS const VIDEOS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEOS
const VIDEO_ABUSES_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEO_ABUSES const VIDEO_ABUSES_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEO_ABUSES
@ -33,6 +34,10 @@ function isRemoteVideoCategoryValid (value: string) {
return validator.isInt('' + value) return validator.isInt('' + value)
} }
function isVideoUrlValid (value: string) {
return isActivityPubUrlValid(value)
}
function isVideoLicenceValid (value: number) { function isVideoLicenceValid (value: number) {
return VIDEO_LICENCES[value] !== undefined return VIDEO_LICENCES[value] !== undefined
} }
@ -219,5 +224,6 @@ export {
isVideoTagValid, isVideoTagValid,
isRemoteVideoCategoryValid, isRemoteVideoCategoryValid,
isRemoteVideoLicenceValid, isRemoteVideoLicenceValid,
isVideoUrlValid,
isRemoteVideoLanguageValid isRemoteVideoLanguageValid
} }

View File

@ -121,7 +121,8 @@ const CONSTRAINTS_FIELDS = {
}, },
VIDEO_CHANNELS: { VIDEO_CHANNELS: {
NAME: { min: 3, max: 120 }, // Length NAME: { min: 3, max: 120 }, // Length
DESCRIPTION: { min: 3, max: 250 } // Length DESCRIPTION: { min: 3, max: 250 }, // Length
URL: { min: 3, max: 2000 } // Length
}, },
VIDEOS: { VIDEOS: {
NAME: { min: 3, max: 120 }, // Length NAME: { min: 3, max: 120 }, // Length
@ -137,7 +138,13 @@ const CONSTRAINTS_FIELDS = {
VIEWS: { min: 0 }, VIEWS: { min: 0 },
LIKES: { min: 0 }, LIKES: { min: 0 },
DISLIKES: { min: 0 }, DISLIKES: { min: 0 },
FILE_SIZE: { min: 10, max: 1024 * 1024 * 1024 * 3 /* 3Go */ } FILE_SIZE: { min: 10, max: 1024 * 1024 * 1024 * 3 /* 3Go */ },
URL: { min: 3, max: 2000 } // Length
},
ACCOUNTS: {
PUBLIC_KEY: { min: 10, max: 5000 }, // Length
PRIVATE_KEY: { min: 10, max: 5000 }, // Length
URL: { min: 3, max: 2000 } // Length
}, },
VIDEO_EVENTS: { VIDEO_EVENTS: {
COUNT: { min: 0 } COUNT: { min: 0 }

View File

@ -1,21 +1,25 @@
import * as passwordGenerator from 'password-generator' import * as passwordGenerator from 'password-generator'
import { UserRole } from '../../shared' import { UserRole } from '../../shared'
import { logger, mkdirpPromise, rimrafPromise } from '../helpers' import { logger, mkdirpPromise, rimrafPromise } from '../helpers'
import { createPrivateAndPublicKeys } from '../helpers/peertube-crypto'
import { createUserAccountAndChannel } from '../lib' import { createUserAccountAndChannel } from '../lib'
import { createLocalAccount } from '../lib/user'
import { clientsExist, usersExist } from './checker' import { clientsExist, usersExist } from './checker'
import { CACHE, CONFIG, LAST_MIGRATION_VERSION } from './constants' import { CACHE, CONFIG, LAST_MIGRATION_VERSION } from './constants'
import { database as db } from './database' import { database as db } from './database'
import { createLocalAccount } from '../lib/user'
async function installApplication () { async function installApplication () {
await db.sequelize.sync() try {
await removeCacheDirectories() await db.sequelize.sync()
await createDirectoriesIfNotExist() await removeCacheDirectories()
await createOAuthClientIfNotExist() await createDirectoriesIfNotExist()
await createOAuthAdminIfNotExist() await createOAuthClientIfNotExist()
await createApplicationIfNotExist() await createOAuthAdminIfNotExist()
await createApplicationIfNotExist()
} catch (err) {
logger.error('Cannot install application.', err)
throw err
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -16,8 +16,9 @@ async function createUserAccountAndChannel (user: UserInstance, validateUser = t
const userCreated = await user.save(userOptions) const userCreated = await user.save(userOptions)
const accountCreated = await createLocalAccount(user.username, user.id, null, t) const accountCreated = await createLocalAccount(user.username, user.id, null, t)
const videoChannelName = `Default ${userCreated.username} channel`
const videoChannelInfo = { const videoChannelInfo = {
name: `Default ${userCreated.username} channel` name: videoChannelName
} }
const videoChannel = await createVideoChannel(videoChannelInfo, accountCreated, t) const videoChannel = await createVideoChannel(videoChannelInfo, accountCreated, t)

View File

@ -5,6 +5,7 @@ import { logger } from '../helpers'
import { AccountInstance } from '../models' import { AccountInstance } from '../models'
import { VideoChannelCreate } from '../../shared/models' import { VideoChannelCreate } from '../../shared/models'
import { sendCreateVideoChannel } from './activitypub/send-request' import { sendCreateVideoChannel } from './activitypub/send-request'
import { getActivityPubUrl } from '../helpers/activitypub'
async function createVideoChannel (videoChannelInfo: VideoChannelCreate, account: AccountInstance, t: Sequelize.Transaction) { async function createVideoChannel (videoChannelInfo: VideoChannelCreate, account: AccountInstance, t: Sequelize.Transaction) {
const videoChannelData = { const videoChannelData = {
@ -15,6 +16,8 @@ async function createVideoChannel (videoChannelInfo: VideoChannelCreate, account
} }
const videoChannel = db.VideoChannel.build(videoChannelData) const videoChannel = db.VideoChannel.build(videoChannelData)
videoChannel.set('url', getActivityPubUrl('videoChannel', videoChannel.uuid))
const options = { transaction: t } const options = { transaction: t }
const videoChannelCreated = await videoChannel.save(options) const videoChannelCreated = await videoChannel.save(options)

View File

@ -22,8 +22,8 @@ import {
AccountMethods AccountMethods
} from './account-interface' } from './account-interface'
import LoadApplication = AccountMethods.LoadApplication
import { sendDeleteAccount } from '../../lib/activitypub/send-request' import { sendDeleteAccount } from '../../lib/activitypub/send-request'
import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
let Account: Sequelize.Model<AccountInstance, AccountAttributes> let Account: Sequelize.Model<AccountInstance, AccountAttributes>
let loadAccountByPodAndUUID: AccountMethods.LoadAccountByPodAndUUID let loadAccountByPodAndUUID: AccountMethods.LoadAccountByPodAndUUID
@ -60,14 +60,14 @@ export default function defineAccount (sequelize: Sequelize.Sequelize, DataTypes
type: DataTypes.STRING, type: DataTypes.STRING,
allowNull: false, allowNull: false,
validate: { validate: {
usernameValid: value => { nameValid: value => {
const res = isUserUsernameValid(value) const res = isUserUsernameValid(value)
if (res === false) throw new Error('Username is not valid.') if (res === false) throw new Error('Name is not valid.')
} }
} }
}, },
url: { url: {
type: DataTypes.STRING, type: DataTypes.STRING(CONSTRAINTS_FIELDS.ACCOUNTS.URL.max),
allowNull: false, allowNull: false,
validate: { validate: {
urlValid: value => { urlValid: value => {
@ -77,7 +77,7 @@ export default function defineAccount (sequelize: Sequelize.Sequelize, DataTypes
} }
}, },
publicKey: { publicKey: {
type: DataTypes.STRING, type: DataTypes.STRING(CONSTRAINTS_FIELDS.ACCOUNTS.PUBLIC_KEY.max),
allowNull: false, allowNull: false,
validate: { validate: {
publicKeyValid: value => { publicKeyValid: value => {
@ -87,7 +87,7 @@ export default function defineAccount (sequelize: Sequelize.Sequelize, DataTypes
} }
}, },
privateKey: { privateKey: {
type: DataTypes.STRING, type: DataTypes.STRING(CONSTRAINTS_FIELDS.ACCOUNTS.PRIVATE_KEY.max),
allowNull: false, allowNull: false,
validate: { validate: {
privateKeyValid: value => { privateKeyValid: value => {
@ -110,14 +110,14 @@ export default function defineAccount (sequelize: Sequelize.Sequelize, DataTypes
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: false, allowNull: false,
validate: { validate: {
followersCountValid: value => { followingCountValid: value => {
const res = isAccountFollowingCountValid(value) const res = isAccountFollowingCountValid(value)
if (res === false) throw new Error('Following count is not valid.') if (res === false) throw new Error('Following count is not valid.')
} }
} }
}, },
inboxUrl: { inboxUrl: {
type: DataTypes.STRING, type: DataTypes.STRING(CONSTRAINTS_FIELDS.ACCOUNTS.URL.max),
allowNull: false, allowNull: false,
validate: { validate: {
inboxUrlValid: value => { inboxUrlValid: value => {
@ -127,7 +127,7 @@ export default function defineAccount (sequelize: Sequelize.Sequelize, DataTypes
} }
}, },
outboxUrl: { outboxUrl: {
type: DataTypes.STRING, type: DataTypes.STRING(CONSTRAINTS_FIELDS.ACCOUNTS.URL.max),
allowNull: false, allowNull: false,
validate: { validate: {
outboxUrlValid: value => { outboxUrlValid: value => {
@ -137,7 +137,7 @@ export default function defineAccount (sequelize: Sequelize.Sequelize, DataTypes
} }
}, },
sharedInboxUrl: { sharedInboxUrl: {
type: DataTypes.STRING, type: DataTypes.STRING(CONSTRAINTS_FIELDS.ACCOUNTS.URL.max),
allowNull: false, allowNull: false,
validate: { validate: {
sharedInboxUrlValid: value => { sharedInboxUrlValid: value => {
@ -147,7 +147,7 @@ export default function defineAccount (sequelize: Sequelize.Sequelize, DataTypes
} }
}, },
followersUrl: { followersUrl: {
type: DataTypes.STRING, type: DataTypes.STRING(CONSTRAINTS_FIELDS.ACCOUNTS.URL.max),
allowNull: false, allowNull: false,
validate: { validate: {
followersUrlValid: value => { followersUrlValid: value => {
@ -157,7 +157,7 @@ export default function defineAccount (sequelize: Sequelize.Sequelize, DataTypes
} }
}, },
followingUrl: { followingUrl: {
type: DataTypes.STRING, type: DataTypes.STRING(CONSTRAINTS_FIELDS.ACCOUNTS.URL.max),
allowNull: false, allowNull: false,
validate: { validate: {
followingUrlValid: value => { followingUrlValid: value => {
@ -241,7 +241,7 @@ function associate (models) {
Account.belongsTo(models.Application, { Account.belongsTo(models.Application, {
foreignKey: { foreignKey: {
name: 'userId', name: 'applicationId',
allowNull: true allowNull: true
}, },
onDelete: 'cascade' onDelete: 'cascade'
@ -256,7 +256,7 @@ function associate (models) {
hooks: true hooks: true
}) })
Account.hasMany(models.AccountFollower, { Account.hasMany(models.AccountFollow, {
foreignKey: { foreignKey: {
name: 'accountId', name: 'accountId',
allowNull: false allowNull: false
@ -265,7 +265,7 @@ function associate (models) {
onDelete: 'cascade' onDelete: 'cascade'
}) })
Account.hasMany(models.AccountFollower, { Account.hasMany(models.AccountFollow, {
foreignKey: { foreignKey: {
name: 'targetAccountId', name: 'targetAccountId',
allowNull: false allowNull: false
@ -329,7 +329,7 @@ getFollowerSharedInboxUrls = function (this: AccountInstance) {
attributes: [ 'sharedInboxUrl' ], attributes: [ 'sharedInboxUrl' ],
include: [ include: [
{ {
model: Account['sequelize'].models.AccountFollower, model: Account['sequelize'].models.AccountFollow,
where: { where: {
targetAccountId: this.id targetAccountId: this.id
} }
@ -523,9 +523,9 @@ async function createListAcceptedFollowForApiQuery (type: 'followers' | 'followi
for (const selection of selections) { for (const selection of selections) {
let query = 'SELECT ' + selection + ' FROM "Account" ' + let query = 'SELECT ' + selection + ' FROM "Account" ' +
'INNER JOIN "AccountFollower" ON "AccountFollower"."' + firstJoin + '" = "Account"."id" ' + 'INNER JOIN "AccountFollow" ON "AccountFollow"."' + firstJoin + '" = "Account"."id" ' +
'INNER JOIN "Account" AS "Follows" ON "Followers"."id" = "Follows"."' + secondJoin + '" ' + 'INNER JOIN "Account" AS "Follows" ON "Followers"."id" = "Follows"."' + secondJoin + '" ' +
'WHERE "Account"."id" = $id AND "AccountFollower"."state" = \'accepted\' ' + 'WHERE "Account"."id" = $id AND "AccountFollow"."state" = \'accepted\' ' +
'LIMIT ' + start 'LIMIT ' + start
if (count !== undefined) query += ', ' + count if (count !== undefined) query += ', ' + count

View File

@ -1,23 +1,16 @@
import * as Sequelize from 'sequelize' import * as Sequelize from 'sequelize'
import { hasUserRight, USER_ROLE_LABELS, UserRight } from '../../../shared'
import { getSort, addMethodsToModel } from '../utils'
import { import {
cryptPassword,
comparePassword, comparePassword,
isUserPasswordValid, cryptPassword,
isUserUsernameValid,
isUserDisplayNSFWValid, isUserDisplayNSFWValid,
isUserVideoQuotaValid, isUserPasswordValid,
isUserRoleValid isUserRoleValid,
isUserUsernameValid,
isUserVideoQuotaValid
} from '../../helpers' } from '../../helpers'
import { UserRight, USER_ROLE_LABELS, hasUserRight } from '../../../shared' import { addMethodsToModel, getSort } from '../utils'
import { UserAttributes, UserInstance, UserMethods } from './user-interface'
import {
UserInstance,
UserAttributes,
UserMethods
} from './user-interface'
let User: Sequelize.Model<UserInstance, UserAttributes> let User: Sequelize.Model<UserInstance, UserAttributes>
let isPasswordMatch: UserMethods.IsPasswordMatch let isPasswordMatch: UserMethods.IsPasswordMatch

View File

@ -63,8 +63,6 @@ export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.Da
) )
const classMethods = [ const classMethods = [
associate,
countAll, countAll,
incrementScores, incrementScores,
list, list,
@ -98,14 +96,6 @@ toFormattedJSON = function (this: PodInstance) {
// ------------------------------ Statics ------------------------------ // ------------------------------ Statics ------------------------------
function associate (models) {
Pod.belongsToMany(models.Request, {
foreignKey: 'podId',
through: models.RequestToPod,
onDelete: 'cascade'
})
}
countAll = function () { countAll = function () {
return Pod.count() return Pod.count()
} }

View File

@ -10,6 +10,8 @@ import {
VideoChannelMethods VideoChannelMethods
} from './video-channel-interface' } from './video-channel-interface'
import { sendDeleteVideoChannel } from '../../lib/activitypub/send-request' import { sendDeleteVideoChannel } from '../../lib/activitypub/send-request'
import { isVideoChannelUrlValid } from '../../helpers/custom-validators/video-channels'
import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
let VideoChannel: Sequelize.Model<VideoChannelInstance, VideoChannelAttributes> let VideoChannel: Sequelize.Model<VideoChannelInstance, VideoChannelAttributes>
let toFormattedJSON: VideoChannelMethods.ToFormattedJSON let toFormattedJSON: VideoChannelMethods.ToFormattedJSON
@ -65,10 +67,13 @@ export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.Da
defaultValue: false defaultValue: false
}, },
url: { url: {
type: DataTypes.STRING, type: DataTypes.STRING(CONSTRAINTS_FIELDS.VIDEO_CHANNELS.URL.max),
allowNull: false, allowNull: false,
validate: { validate: {
isUrl: true urlValid: value => {
const res = isVideoChannelUrlValid(value)
if (res === false) throw new Error('Video channel URL is not valid.')
}
} }
} }
}, },

View File

@ -46,6 +46,7 @@ import { TagInstance } from './tag-interface'
import { VideoFileInstance, VideoFileModel } from './video-file-interface' import { VideoFileInstance, VideoFileModel } from './video-file-interface'
import { VideoAttributes, VideoInstance, VideoMethods } from './video-interface' import { VideoAttributes, VideoInstance, VideoMethods } from './video-interface'
import { sendDeleteVideo } from '../../lib/activitypub/send-request' import { sendDeleteVideo } from '../../lib/activitypub/send-request'
import { isVideoUrlValid } from '../../helpers/custom-validators/videos'
const Buffer = safeBuffer.Buffer const Buffer = safeBuffer.Buffer
@ -220,10 +221,13 @@ export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.Da
defaultValue: false defaultValue: false
}, },
url: { url: {
type: DataTypes.STRING, type: DataTypes.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max),
allowNull: false, allowNull: false,
validate: { validate: {
isUrl: true urlValid: value => {
const res = isVideoUrlValid(value)
if (res === false) throw new Error('Video URL is not valid.')
}
} }
} }
}, },