"sequelize-typescript": "0.6.6",
"sharp": "^0.21.0",
"sitemap": "^2.1.0",
+ "socket.io": "^2.2.0",
"srt-to-vtt": "^1.1.2",
"summon-install": "^0.4.3",
"useragent": "^2.3.0",
"@types/redis": "^2.8.5",
"@types/request": "^2.0.3",
"@types/sharp": "^0.21.0",
+ "@types/socket.io": "^2.1.2",
"@types/supertest": "^2.0.3",
"@types/validator": "^9.4.0",
"@types/webtorrent": "^0.98.4",
// Do not use barrels because we don't want to load all modules here (we need to initialize database first)
import { logger } from './server/helpers/logger'
-import { API_VERSION, CONFIG, CACHE, HTTP_SIGNATURE } from './server/initializers/constants'
+import { API_VERSION, CONFIG, CACHE } from './server/initializers/constants'
const missed = checkMissedConfig()
if (missed.length !== 0) {
servicesRouter,
webfingerRouter,
trackerRouter,
- createWebsocketServer, botsRouter
+ createWebsocketTrackerServer, botsRouter
} from './server/controllers'
import { advertiseDoNotTrack } from './server/middlewares/dnt'
import { Redis } from './server/lib/redis'
import { YoutubeDlUpdateScheduler } from './server/lib/schedulers/youtube-dl-update-scheduler'
import { VideosRedundancyScheduler } from './server/lib/schedulers/videos-redundancy-scheduler'
import { isHTTPSignatureDigestValid } from './server/helpers/peertube-crypto'
+import { PeerTubeSocket } from './server/lib/peertube-socket'
// ----------- Command line -----------
app.use(bodyParser.json({
type: [ 'application/json', 'application/*+json' ],
limit: '500kb',
- verify: (req: express.Request, _, buf: Buffer, encoding: string) => {
+ verify: (req: express.Request, _, buf: Buffer) => {
const valid = isHTTPSignatureDigestValid(buf, req)
if (valid !== true) throw new Error('Invalid digest')
}
return res.status(err.status || 500).end()
})
-const server = createWebsocketServer(app)
+const server = createWebsocketTrackerServer(app)
// ----------- Run -----------
// Redis initialization
Redis.Instance.init()
+ PeerTubeSocket.Instance.init(server)
+
// Make server listening
server.listen(port, hostname, () => {
logger.info('Server listening on %s:%d', hostname, port)
import { deleteUserToken } from '../../../lib/oauth-model'
import { myBlocklistRouter } from './my-blocklist'
import { myVideosHistoryRouter } from './my-history'
+import { myNotificationsRouter } from './my-notifications'
const auditLogger = auditLoggerFactory('users')
})
const usersRouter = express.Router()
+usersRouter.use('/', myNotificationsRouter)
usersRouter.use('/', myBlocklistRouter)
usersRouter.use('/', myVideosHistoryRouter)
usersRouter.use('/', meRouter)
--- /dev/null
+import * as express from 'express'
+import 'multer'
+import {
+ asyncMiddleware,
+ asyncRetryTransactionMiddleware,
+ authenticate,
+ paginationValidator,
+ setDefaultPagination,
+ setDefaultSort,
+ userNotificationsSortValidator
+} from '../../../middlewares'
+import { UserModel } from '../../../models/account/user'
+import { getFormattedObjects } from '../../../helpers/utils'
+import { UserNotificationModel } from '../../../models/account/user-notification'
+import { meRouter } from './me'
+import {
+ markAsReadUserNotificationsValidator,
+ updateNotificationSettingsValidator
+} from '../../../middlewares/validators/user-notifications'
+import { UserNotificationSetting } from '../../../../shared/models/users'
+import { UserNotificationSettingModel } from '../../../models/account/user-notification-setting'
+
+const myNotificationsRouter = express.Router()
+
+meRouter.put('/me/notification-settings',
+ authenticate,
+ updateNotificationSettingsValidator,
+ asyncRetryTransactionMiddleware(updateNotificationSettings)
+)
+
+myNotificationsRouter.get('/me/notifications',
+ authenticate,
+ paginationValidator,
+ userNotificationsSortValidator,
+ setDefaultSort,
+ setDefaultPagination,
+ asyncMiddleware(listUserNotifications)
+)
+
+myNotificationsRouter.post('/me/notifications/read',
+ authenticate,
+ markAsReadUserNotificationsValidator,
+ asyncMiddleware(markAsReadUserNotifications)
+)
+
+export {
+ myNotificationsRouter
+}
+
+// ---------------------------------------------------------------------------
+
+async function updateNotificationSettings (req: express.Request, res: express.Response) {
+ const user: UserModel = res.locals.oauth.token.User
+ const body: UserNotificationSetting = req.body
+
+ const query = {
+ where: {
+ userId: user.id
+ }
+ }
+
+ await UserNotificationSettingModel.update({
+ newVideoFromSubscription: body.newVideoFromSubscription,
+ newCommentOnMyVideo: body.newCommentOnMyVideo
+ }, query)
+
+ return res.status(204).end()
+}
+
+async function listUserNotifications (req: express.Request, res: express.Response) {
+ const user: UserModel = res.locals.oauth.token.User
+
+ const resultList = await UserNotificationModel.listForApi(user.id, req.query.start, req.query.count, req.query.sort)
+
+ return res.json(getFormattedObjects(resultList.data, resultList.total))
+}
+
+async function markAsReadUserNotifications (req: express.Request, res: express.Response) {
+ const user: UserModel = res.locals.oauth.token.User
+
+ await UserNotificationModel.markAsRead(user.id, req.body.ids)
+
+ return res.status(204).end()
+}
import { VideoAbuseModel } from '../../../models/video/video-abuse'
import { auditLoggerFactory, VideoAbuseAuditView } from '../../../helpers/audit-logger'
import { UserModel } from '../../../models/account/user'
+import { Notifier } from '../../../lib/notifier'
const auditLogger = auditLoggerFactory('abuse')
const abuseVideoRouter = express.Router()
await sendVideoAbuse(reporterAccount.Actor, videoAbuseInstance, videoInstance)
}
+ Notifier.Instance.notifyOnNewVideoAbuse(videoAbuseInstance)
+
auditLogger.create(reporterAccount.Actor.getIdentifier(), new VideoAbuseAuditView(videoAbuseInstance.toFormattedJSON()))
return videoAbuseInstance
} from '../../../middlewares'
import { VideoBlacklistModel } from '../../../models/video/video-blacklist'
import { sequelizeTypescript } from '../../../initializers'
+import { Notifier } from '../../../lib/notifier'
+import { VideoModel } from '../../../models/video/video'
const blacklistRouter = express.Router()
reason: body.reason
}
- await VideoBlacklistModel.create(toCreate)
+ const blacklist = await VideoBlacklistModel.create(toCreate)
+ blacklist.Video = videoInstance
+
+ Notifier.Instance.notifyOnVideoBlacklist(blacklist)
+
+ logger.info('Video %s blacklisted.', res.locals.video.uuid)
+
return res.type('json').status(204).end()
}
async function updateVideoBlacklistController (req: express.Request, res: express.Response) {
const videoBlacklist = res.locals.videoBlacklist as VideoBlacklistModel
- logger.info(videoBlacklist)
if (req.body.reason !== undefined) videoBlacklist.reason = req.body.reason
async function removeVideoFromBlacklistController (req: express.Request, res: express.Response, next: express.NextFunction) {
const videoBlacklist = res.locals.videoBlacklist as VideoBlacklistModel
+ const video: VideoModel = res.locals.video
await sequelizeTypescript.transaction(t => {
return videoBlacklist.destroy({ transaction: t })
})
+ Notifier.Instance.notifyOnVideoUnblacklist(video)
+
logger.info('Video %s removed from blacklist.', res.locals.video.uuid)
return res.type('json').status(204).end()
import { auditLoggerFactory, CommentAuditView, getAuditIdFromRes } from '../../../helpers/audit-logger'
import { AccountModel } from '../../../models/account/account'
import { UserModel } from '../../../models/account/user'
+import { Notifier } from '../../../lib/notifier'
const auditLogger = auditLoggerFactory('comments')
const videoCommentRouter = express.Router()
}, t)
})
+ Notifier.Instance.notifyOnNewComment(comment)
auditLogger.create(getAuditIdFromRes(res), new CommentAuditView(comment.toFormattedJSON()))
return res.json({
}, t)
})
+ Notifier.Instance.notifyOnNewComment(comment)
auditLogger.create(getAuditIdFromRes(res), new CommentAuditView(comment.toFormattedJSON()))
return res.json({ comment: comment.toFormattedJSON() }).end()
import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
import { getFormattedObjects, getServerActor } from '../../../helpers/utils'
import {
- CONFIG, MIMETYPES,
+ CONFIG,
+ MIMETYPES,
PREVIEWS_SIZE,
sequelizeTypescript,
THUMBNAILS_SIZE,
import { resetSequelizeInstance } from '../../../helpers/database-utils'
import { move } from 'fs-extra'
import { watchingRouter } from './watching'
+import { Notifier } from '../../../lib/notifier'
const auditLogger = auditLoggerFactory('videos')
const videosRouter = express.Router()
}
await federateVideoIfNeeded(video, true, t)
+ Notifier.Instance.notifyOnNewVideo(video)
auditLogger.create(getAuditIdFromRes(res), new VideoAuditView(videoCreated.toFormattedDetailsJSON()))
logger.info('Video with name %s and uuid %s created.', videoInfo.name, videoCreated.uuid)
const oldVideoAuditView = new VideoAuditView(videoInstance.toFormattedDetailsJSON())
const videoInfoToUpdate: VideoUpdate = req.body
const wasPrivateVideo = videoInstance.privacy === VideoPrivacy.PRIVATE
+ const wasUnlistedVideo = videoInstance.privacy === VideoPrivacy.UNLISTED
// Process thumbnail or create it from the video
if (req.files && req.files['thumbnailfile']) {
const isNewVideo = wasPrivateVideo && videoInstanceUpdated.privacy !== VideoPrivacy.PRIVATE
await federateVideoIfNeeded(videoInstanceUpdated, isNewVideo, t)
+ if (wasUnlistedVideo || wasPrivateVideo) {
+ Notifier.Instance.notifyOnNewVideo(videoInstanceUpdated)
+ }
+
auditLogger.update(
getAuditIdFromRes(res),
new VideoAuditView(videoInstanceUpdated.toFormattedDetailsJSON()),
// Adding video items to the feed, one at a time
comments.forEach(comment => {
- const link = CONFIG.WEBSERVER.URL + '/videos/watch/' + comment.Video.uuid + ';threadId=' + comment.getThreadId()
+ const link = CONFIG.WEBSERVER.URL + comment.getCommentStaticPath()
feed.addItem({
title: `${comment.Video.name} - ${comment.Account.getDisplayName()}`,
trackerRouter.get('/tracker/announce', (req, res) => onHttpRequest(req, res, { action: 'announce' }))
trackerRouter.get('/tracker/scrape', (req, res) => onHttpRequest(req, res, { action: 'scrape' }))
-function createWebsocketServer (app: express.Application) {
+function createWebsocketTrackerServer (app: express.Application) {
const server = http.createServer(app)
const wss = new WebSocketServer({ server: server, path: '/tracker/socket' })
wss.on('connection', function (ws, req) {
export {
trackerRouter,
- createWebsocketServer
+ createWebsocketTrackerServer
}
// ---------------------------------------------------------------------------
return Array.isArray(value)
}
+function isIntArray (value: any) {
+ return Array.isArray(value) && value.every(v => validator.isInt('' + v))
+}
+
function isDateValid (value: string) {
return exists(value) && validator.isISO8601(value)
}
export {
exists,
+ isIntArray,
isArray,
isIdValid,
isUUIDValid,
--- /dev/null
+import { exists } from './misc'
+import * as validator from 'validator'
+import { UserNotificationType } from '../../../shared/models/users'
+import { UserNotificationSettingValue } from '../../../shared/models/users/user-notification-setting.model'
+
+function isUserNotificationTypeValid (value: any) {
+ return exists(value) && validator.isInt('' + value) && UserNotificationType[value] !== undefined
+}
+
+function isUserNotificationSettingValid (value: any) {
+ return exists(value) &&
+ validator.isInt('' + value) &&
+ UserNotificationSettingValue[ value ] !== undefined
+}
+
+export {
+ isUserNotificationSettingValid,
+ isUserNotificationTypeValid
+}
VIDEO_CHANNELS_SEARCH: [ 'match', 'displayName', 'createdAt' ],
ACCOUNTS_BLOCKLIST: [ 'createdAt' ],
- SERVERS_BLOCKLIST: [ 'createdAt' ]
+ SERVERS_BLOCKLIST: [ 'createdAt' ],
+
+ USER_NOTIFICATIONS: [ 'createdAt' ]
}
const OAUTH_LIFETIME = {
import { UserVideoHistoryModel } from '../models/account/user-video-history'
import { AccountBlocklistModel } from '../models/account/account-blocklist'
import { ServerBlocklistModel } from '../models/server/server-blocklist'
+import { UserNotificationModel } from '../models/account/user-notification'
+import { UserNotificationSettingModel } from '../models/account/user-notification-setting'
require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string
VideoRedundancyModel,
UserVideoHistoryModel,
AccountBlocklistModel,
- ServerBlocklistModel
+ ServerBlocklistModel,
+ UserNotificationModel,
+ UserNotificationSettingModel
])
// Check extensions exist in the database
import { VideoShareModel } from '../../../models/video/video-share'
import { forwardVideoRelatedActivity } from '../send/utils'
import { getOrCreateVideoAndAccountAndChannel } from '../videos'
+import { VideoPrivacy } from '../../../../shared/models/videos'
+import { Notifier } from '../../notifier'
async function processAnnounceActivity (activity: ActivityAnnounce, actorAnnouncer: ActorModel) {
return retryTransactionWrapper(processVideoShare, actorAnnouncer, activity)
async function processVideoShare (actorAnnouncer: ActorModel, activity: ActivityAnnounce) {
const objectUri = typeof activity.object === 'string' ? activity.object : activity.object.id
- const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: objectUri })
+ const { video, created: videoCreated } = await getOrCreateVideoAndAccountAndChannel({ videoObject: objectUri })
- return sequelizeTypescript.transaction(async t => {
+ await sequelizeTypescript.transaction(async t => {
// Add share entry
const share = {
return undefined
})
+
+ if (videoCreated) Notifier.Instance.notifyOnNewVideo(video)
}
import { Redis } from '../../redis'
import { createOrUpdateCacheFile } from '../cache-file'
import { getVideoDislikeActivityPubUrl } from '../url'
+import { Notifier } from '../../notifier'
async function processCreateActivity (activity: ActivityCreate, byActor: ActorModel) {
const activityObject = activity.object
async function processCreateVideo (activity: ActivityCreate) {
const videoToCreateData = activity.object as VideoTorrentObject
- const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: videoToCreateData })
+ const { video, created } = await getOrCreateVideoAndAccountAndChannel({ videoObject: videoToCreateData })
+
+ if (created) Notifier.Instance.notifyOnNewVideo(video)
return video
}
state: VideoAbuseState.PENDING
}
- await VideoAbuseModel.create(videoAbuseData, { transaction: t })
+ const videoAbuseInstance = await VideoAbuseModel.create(videoAbuseData, { transaction: t })
+ videoAbuseInstance.Video = video
+
+ Notifier.Instance.notifyOnNewVideoAbuse(videoAbuseInstance)
logger.info('Remote abuse for video uuid %s created', videoAbuseToCreateData.object)
})
const { video } = await resolveThread(commentObject.inReplyTo)
- const { created } = await addVideoComment(video, commentObject.id)
+ const { comment, created } = await addVideoComment(video, commentObject.id)
if (video.isOwned() && created === true) {
// Don't resend the activity to the sender
await forwardVideoRelatedActivity(activity, undefined, exceptions, video)
}
+
+ if (created === true) Notifier.Instance.notifyOnNewComment(comment)
}
throw new Error(`Comment url ${commentUrl} host is different from the AP object id ${body.id}`)
}
- const actor = await getOrCreateActorAndServerAndModel(actorUrl)
+ const actor = await getOrCreateActorAndServerAndModel(actorUrl, 'all')
const entry = await videoCommentActivityObjectToDBAttributes(videoInstance, actor, body)
if (!entry) return { created: false }
},
defaults: entry
})
+ comment.Account = actor.Account
+ comment.Video = videoInstance
return { comment, created }
}
import { AccountModel } from '../../models/account/account'
import { fetchVideoByUrl, VideoFetchByUrlType } from '../../helpers/video'
import { checkUrlsSameHost, getAPUrl } from '../../helpers/activitypub'
+import { Notifier } from '../notifier'
async function federateVideoIfNeeded (video: VideoModel, isNewVideo: boolean, transaction?: sequelize.Transaction) {
// If the video is not private and published, we federate it
else await JobQueue.Instance.createJob({ type: 'activitypub-refresher', payload: { type: 'video', videoUrl: videoFromDatabase.url } })
}
- return { video: videoFromDatabase }
+ return { video: videoFromDatabase, created: false }
}
const { videoObject: fetchedVideo } = await fetchRemoteVideo(videoUrl)
await syncVideoExternalAttributes(video, fetchedVideo, syncParam)
- return { video }
+ return { video, created: true }
}
async function updateVideoFromAP (options: {
videoFieldsSave = options.video.toJSON()
+ const wasPrivateVideo = options.video.privacy === VideoPrivacy.PRIVATE
+ const wasUnlistedVideo = options.video.privacy === VideoPrivacy.UNLISTED
+
// Check actor has the right to update the video
const videoChannel = options.video.VideoChannel
if (videoChannel.Account.id !== options.account.id) {
})
options.video.VideoCaptions = await Promise.all(videoCaptionsPromises)
}
+
+ {
+ // Notify our users?
+ if (wasPrivateVideo || wasUnlistedVideo) {
+ Notifier.Instance.notifyOnNewVideo(options.video)
+ }
+ }
})
logger.info('Remote video with uuid %s updated', options.videoObject.uuid)
}
private static addOpenGraphAndOEmbedTags (htmlStringPage: string, video: VideoModel) {
- const previewUrl = CONFIG.WEBSERVER.URL + STATIC_PATHS.PREVIEWS + video.getPreviewName()
- const videoUrl = CONFIG.WEBSERVER.URL + '/videos/watch/' + video.uuid
+ const previewUrl = CONFIG.WEBSERVER.URL + video.getPreviewStaticPath()
+ const videoUrl = CONFIG.WEBSERVER.URL + video.getWatchStaticPath()
const videoNameEscaped = escapeHTML(video.name)
const videoDescriptionEscaped = escapeHTML(video.description)
import { createTransport, Transporter } from 'nodemailer'
-import { UserRight } from '../../shared/models/users'
import { isTestInstance } from '../helpers/core-utils'
import { bunyanLogger, logger } from '../helpers/logger'
import { CONFIG } from '../initializers'
import { JobQueue } from './job-queue'
import { EmailPayload } from './job-queue/handlers/email'
import { readFileSync } from 'fs-extra'
+import { VideoCommentModel } from '../models/video/video-comment'
+import { VideoAbuseModel } from '../models/video/video-abuse'
+import { VideoBlacklistModel } from '../models/video/video-blacklist'
class Emailer {
}
}
- addForgetPasswordEmailJob (to: string, resetPasswordUrl: string) {
+ addNewVideoFromSubscriberNotification (to: string[], video: VideoModel) {
+ const channelName = video.VideoChannel.getDisplayName()
+ const videoUrl = CONFIG.WEBSERVER.URL + video.getWatchStaticPath()
+
const text = `Hi dear user,\n\n` +
- `It seems you forgot your password on ${CONFIG.WEBSERVER.HOST}! ` +
- `Please follow this link to reset it: ${resetPasswordUrl}\n\n` +
- `If you are not the person who initiated this request, please ignore this email.\n\n` +
+ `Your subscription ${channelName} just published a new video: ${video.name}` +
+ `\n\n` +
+ `You can view it on ${videoUrl} ` +
+ `\n\n` +
`Cheers,\n` +
`PeerTube.`
const emailPayload: EmailPayload = {
- to: [ to ],
- subject: 'Reset your PeerTube password',
+ to,
+ subject: channelName + ' just published a new video',
text
}
return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
}
- addVerifyEmailJob (to: string, verifyEmailUrl: string) {
- const text = `Welcome to PeerTube,\n\n` +
- `To start using PeerTube on ${CONFIG.WEBSERVER.HOST} you must verify your email! ` +
- `Please follow this link to verify this email belongs to you: ${verifyEmailUrl}\n\n` +
- `If you are not the person who initiated this request, please ignore this email.\n\n` +
+ addNewCommentOnMyVideoNotification (to: string[], comment: VideoCommentModel) {
+ const accountName = comment.Account.getDisplayName()
+ const video = comment.Video
+ const commentUrl = CONFIG.WEBSERVER.URL + comment.getCommentStaticPath()
+
+ const text = `Hi dear user,\n\n` +
+ `A new comment has been posted by ${accountName} on your video ${video.name}` +
+ `\n\n` +
+ `You can view it on ${commentUrl} ` +
+ `\n\n` +
`Cheers,\n` +
`PeerTube.`
const emailPayload: EmailPayload = {
- to: [ to ],
- subject: 'Verify your PeerTube email',
+ to,
+ subject: 'New comment on your video ' + video.name,
text
}
return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
}
- async addVideoAbuseReportJob (videoId: number) {
- const video = await VideoModel.load(videoId)
- if (!video) throw new Error('Unknown Video id during Abuse report.')
+ async addVideoAbuseModeratorsNotification (to: string[], videoAbuse: VideoAbuseModel) {
+ const videoUrl = CONFIG.WEBSERVER.URL + videoAbuse.Video.getWatchStaticPath()
const text = `Hi,\n\n` +
- `Your instance received an abuse for the following video ${video.url}\n\n` +
+ `${CONFIG.WEBSERVER.HOST} received an abuse for the following video ${videoUrl}\n\n` +
`Cheers,\n` +
`PeerTube.`
- const to = await UserModel.listEmailsWithRight(UserRight.MANAGE_VIDEO_ABUSES)
const emailPayload: EmailPayload = {
to,
subject: '[PeerTube] Received a video abuse',
return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
}
- async addVideoBlacklistReportJob (videoId: number, reason?: string) {
- const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoId)
- if (!video) throw new Error('Unknown Video id during Blacklist report.')
- // It's not our user
- if (video.remote === true) return
+ async addVideoBlacklistNotification (to: string[], videoBlacklist: VideoBlacklistModel) {
+ const videoName = videoBlacklist.Video.name
+ const videoUrl = CONFIG.WEBSERVER.URL + videoBlacklist.Video.getWatchStaticPath()
- const user = await UserModel.loadById(video.VideoChannel.Account.userId)
-
- const reasonString = reason ? ` for the following reason: ${reason}` : ''
- const blockedString = `Your video ${video.name} on ${CONFIG.WEBSERVER.HOST} has been blacklisted${reasonString}.`
+ const reasonString = videoBlacklist.reason ? ` for the following reason: ${videoBlacklist.reason}` : ''
+ const blockedString = `Your video ${videoName} (${videoUrl} on ${CONFIG.WEBSERVER.HOST} has been blacklisted${reasonString}.`
const text = 'Hi,\n\n' +
blockedString +
'Cheers,\n' +
`PeerTube.`
- const to = user.email
const emailPayload: EmailPayload = {
- to: [ to ],
- subject: `[PeerTube] Video ${video.name} blacklisted`,
+ to,
+ subject: `[PeerTube] Video ${videoName} blacklisted`,
text
}
return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
}
- async addVideoUnblacklistReportJob (videoId: number) {
- const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoId)
- if (!video) throw new Error('Unknown Video id during Blacklist report.')
- // It's not our user
- if (video.remote === true) return
-
- const user = await UserModel.loadById(video.VideoChannel.Account.userId)
+ async addVideoUnblacklistNotification (to: string[], video: VideoModel) {
+ const videoUrl = CONFIG.WEBSERVER.URL + video.getWatchStaticPath()
const text = 'Hi,\n\n' +
- `Your video ${video.name} on ${CONFIG.WEBSERVER.HOST} has been unblacklisted.` +
+ `Your video ${video.name} (${videoUrl}) on ${CONFIG.WEBSERVER.HOST} has been unblacklisted.` +
'\n\n' +
'Cheers,\n' +
`PeerTube.`
- const to = user.email
const emailPayload: EmailPayload = {
- to: [ to ],
+ to,
subject: `[PeerTube] Video ${video.name} unblacklisted`,
text
}
return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
}
+ addForgetPasswordEmailJob (to: string, resetPasswordUrl: string) {
+ const text = `Hi dear user,\n\n` +
+ `It seems you forgot your password on ${CONFIG.WEBSERVER.HOST}! ` +
+ `Please follow this link to reset it: ${resetPasswordUrl}\n\n` +
+ `If you are not the person who initiated this request, please ignore this email.\n\n` +
+ `Cheers,\n` +
+ `PeerTube.`
+
+ const emailPayload: EmailPayload = {
+ to: [ to ],
+ subject: 'Reset your PeerTube password',
+ text
+ }
+
+ return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
+ }
+
+ addVerifyEmailJob (to: string, verifyEmailUrl: string) {
+ const text = `Welcome to PeerTube,\n\n` +
+ `To start using PeerTube on ${CONFIG.WEBSERVER.HOST} you must verify your email! ` +
+ `Please follow this link to verify this email belongs to you: ${verifyEmailUrl}\n\n` +
+ `If you are not the person who initiated this request, please ignore this email.\n\n` +
+ `Cheers,\n` +
+ `PeerTube.`
+
+ const emailPayload: EmailPayload = {
+ to: [ to ],
+ subject: 'Verify your PeerTube email',
+ text
+ }
+
+ return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
+ }
+
addUserBlockJob (user: UserModel, blocked: boolean, reason?: string) {
const reasonString = reason ? ` for the following reason: ${reason}` : ''
const blockedWord = blocked ? 'blocked' : 'unblocked'
}
sendMail (to: string[], subject: string, text: string) {
- if (!this.transporter) {
+ if (!this.enabled) {
throw new Error('Cannot send mail because SMTP is not configured.')
}
import * as Bluebird from 'bluebird'
import { computeResolutionsToTranscode } from '../../../helpers/ffmpeg-utils'
import { importVideoFile, transcodeOriginalVideofile, optimizeVideofile } from '../../video-transcoding'
+import { Notifier } from '../../notifier'
export type VideoFilePayload = {
videoUUID: string
// If the video was not published, we consider it is a new one for other instances
await federateVideoIfNeeded(videoDatabase, isNewVideo, t)
+ if (isNewVideo) Notifier.Instance.notifyOnNewVideo(video)
return undefined
})
logger.info('No transcoding jobs created for video %s (no resolutions).', videoDatabase.uuid, { privacy: videoDatabase.privacy })
}
- return federateVideoIfNeeded(videoDatabase, isNewVideo, t)
+ await federateVideoIfNeeded(videoDatabase, isNewVideo, t)
+ if (isNewVideo) Notifier.Instance.notifyOnNewVideo(videoDatabase)
})
}
import { downloadWebTorrentVideo } from '../../../helpers/webtorrent'
import { getSecureTorrentName } from '../../../helpers/utils'
import { remove, move, stat } from 'fs-extra'
+import { Notifier } from '../../notifier'
type VideoImportYoutubeDLPayload = {
type: 'youtube-dl'
// Now we can federate the video (reload from database, we need more attributes)
const videoForFederation = await VideoModel.loadAndPopulateAccountAndServerAndTags(video.uuid, t)
await federateVideoIfNeeded(videoForFederation, true, t)
+ Notifier.Instance.notifyOnNewVideo(videoForFederation)
// Update video import object
videoImport.state = VideoImportState.SUCCESS
--- /dev/null
+import { UserNotificationSettingValue, UserNotificationType, UserRight } from '../../shared/models/users'
+import { logger } from '../helpers/logger'
+import { VideoModel } from '../models/video/video'
+import { Emailer } from './emailer'
+import { UserNotificationModel } from '../models/account/user-notification'
+import { VideoCommentModel } from '../models/video/video-comment'
+import { UserModel } from '../models/account/user'
+import { PeerTubeSocket } from './peertube-socket'
+import { CONFIG } from '../initializers/constants'
+import { VideoPrivacy, VideoState } from '../../shared/models/videos'
+import { VideoAbuseModel } from '../models/video/video-abuse'
+import { VideoBlacklistModel } from '../models/video/video-blacklist'
+import * as Bluebird from 'bluebird'
+
+class Notifier {
+
+ private static instance: Notifier
+
+ private constructor () {}
+
+ notifyOnNewVideo (video: VideoModel): void {
+ // Only notify on public and published videos
+ if (video.privacy !== VideoPrivacy.PUBLIC || video.state !== VideoState.PUBLISHED) return
+
+ this.notifySubscribersOfNewVideo(video)
+ .catch(err => logger.error('Cannot notify subscribers of new video %s.', video.url, { err }))
+ }
+
+ notifyOnNewComment (comment: VideoCommentModel): void {
+ this.notifyVideoOwnerOfNewComment(comment)
+ .catch(err => logger.error('Cannot notify of new comment %s.', comment.url, { err }))
+ }
+
+ notifyOnNewVideoAbuse (videoAbuse: VideoAbuseModel): void {
+ this.notifyModeratorsOfNewVideoAbuse(videoAbuse)
+ .catch(err => logger.error('Cannot notify of new video abuse of video %s.', videoAbuse.Video.url, { err }))
+ }
+
+ notifyOnVideoBlacklist (videoBlacklist: VideoBlacklistModel): void {
+ this.notifyVideoOwnerOfBlacklist(videoBlacklist)
+ .catch(err => logger.error('Cannot notify video owner of new video blacklist of %s.', videoBlacklist.Video.url, { err }))
+ }
+
+ notifyOnVideoUnblacklist (video: VideoModel): void {
+ this.notifyVideoOwnerOfUnblacklist(video)
+ .catch(err => logger.error('Cannot notify video owner of new video blacklist of %s.', video.url, { err }))
+ }
+
+ private async notifySubscribersOfNewVideo (video: VideoModel) {
+ // List all followers that are users
+ const users = await UserModel.listUserSubscribersOf(video.VideoChannel.actorId)
+
+ logger.info('Notifying %d users of new video %s.', users.length, video.url)
+
+ function settingGetter (user: UserModel) {
+ return user.NotificationSetting.newVideoFromSubscription
+ }
+
+ async function notificationCreator (user: UserModel) {
+ const notification = await UserNotificationModel.create({
+ type: UserNotificationType.NEW_VIDEO_FROM_SUBSCRIPTION,
+ userId: user.id,
+ videoId: video.id
+ })
+ notification.Video = video
+
+ return notification
+ }
+
+ function emailSender (emails: string[]) {
+ return Emailer.Instance.addNewVideoFromSubscriberNotification(emails, video)
+ }
+
+ return this.notify({ users, settingGetter, notificationCreator, emailSender })
+ }
+
+ private async notifyVideoOwnerOfNewComment (comment: VideoCommentModel) {
+ const user = await UserModel.loadByVideoId(comment.videoId)
+
+ // Not our user or user comments its own video
+ if (!user || comment.Account.userId === user.id) return
+
+ logger.info('Notifying user %s of new comment %s.', user.username, comment.url)
+
+ function settingGetter (user: UserModel) {
+ return user.NotificationSetting.newCommentOnMyVideo
+ }
+
+ async function notificationCreator (user: UserModel) {
+ const notification = await UserNotificationModel.create({
+ type: UserNotificationType.NEW_COMMENT_ON_MY_VIDEO,
+ userId: user.id,
+ commentId: comment.id
+ })
+ notification.Comment = comment
+
+ return notification
+ }
+
+ function emailSender (emails: string[]) {
+ return Emailer.Instance.addNewCommentOnMyVideoNotification(emails, comment)
+ }
+
+ return this.notify({ users: [ user ], settingGetter, notificationCreator, emailSender })
+ }
+
+ private async notifyModeratorsOfNewVideoAbuse (videoAbuse: VideoAbuseModel) {
+ const users = await UserModel.listWithRight(UserRight.MANAGE_VIDEO_ABUSES)
+ if (users.length === 0) return
+
+ logger.info('Notifying %s user/moderators of new video abuse %s.', users.length, videoAbuse.Video.url)
+
+ function settingGetter (user: UserModel) {
+ return user.NotificationSetting.videoAbuseAsModerator
+ }
+
+ async function notificationCreator (user: UserModel) {
+ const notification = await UserNotificationModel.create({
+ type: UserNotificationType.NEW_VIDEO_ABUSE_FOR_MODERATORS,
+ userId: user.id,
+ videoAbuseId: videoAbuse.id
+ })
+ notification.VideoAbuse = videoAbuse
+
+ return notification
+ }
+
+ function emailSender (emails: string[]) {
+ return Emailer.Instance.addVideoAbuseModeratorsNotification(emails, videoAbuse)
+ }
+
+ return this.notify({ users, settingGetter, notificationCreator, emailSender })
+ }
+
+ private async notifyVideoOwnerOfBlacklist (videoBlacklist: VideoBlacklistModel) {
+ const user = await UserModel.loadByVideoId(videoBlacklist.videoId)
+ if (!user) return
+
+ logger.info('Notifying user %s that its video %s has been blacklisted.', user.username, videoBlacklist.Video.url)
+
+ function settingGetter (user: UserModel) {
+ return user.NotificationSetting.blacklistOnMyVideo
+ }
+
+ async function notificationCreator (user: UserModel) {
+ const notification = await UserNotificationModel.create({
+ type: UserNotificationType.BLACKLIST_ON_MY_VIDEO,
+ userId: user.id,
+ videoBlacklistId: videoBlacklist.id
+ })
+ notification.VideoBlacklist = videoBlacklist
+
+ return notification
+ }
+
+ function emailSender (emails: string[]) {
+ return Emailer.Instance.addVideoBlacklistNotification(emails, videoBlacklist)
+ }
+
+ return this.notify({ users: [ user ], settingGetter, notificationCreator, emailSender })
+ }
+
+ private async notifyVideoOwnerOfUnblacklist (video: VideoModel) {
+ const user = await UserModel.loadByVideoId(video.id)
+ if (!user) return
+
+ logger.info('Notifying user %s that its video %s has been unblacklisted.', user.username, video.url)
+
+ function settingGetter (user: UserModel) {
+ return user.NotificationSetting.blacklistOnMyVideo
+ }
+
+ async function notificationCreator (user: UserModel) {
+ const notification = await UserNotificationModel.create({
+ type: UserNotificationType.UNBLACKLIST_ON_MY_VIDEO,
+ userId: user.id,
+ videoId: video.id
+ })
+ notification.Video = video
+
+ return notification
+ }
+
+ function emailSender (emails: string[]) {
+ return Emailer.Instance.addVideoUnblacklistNotification(emails, video)
+ }
+
+ return this.notify({ users: [ user ], settingGetter, notificationCreator, emailSender })
+ }
+
+ private async notify (options: {
+ users: UserModel[],
+ notificationCreator: (user: UserModel) => Promise<UserNotificationModel>,
+ emailSender: (emails: string[]) => Promise<any> | Bluebird<any>,
+ settingGetter: (user: UserModel) => UserNotificationSettingValue
+ }) {
+ const emails: string[] = []
+
+ for (const user of options.users) {
+ if (this.isWebNotificationEnabled(options.settingGetter(user))) {
+ const notification = await options.notificationCreator(user)
+
+ PeerTubeSocket.Instance.sendNotification(user.id, notification)
+ }
+
+ if (this.isEmailEnabled(user, options.settingGetter(user))) {
+ emails.push(user.email)
+ }
+ }
+
+ if (emails.length !== 0) {
+ await options.emailSender(emails)
+ }
+ }
+
+ private isEmailEnabled (user: UserModel, value: UserNotificationSettingValue) {
+ if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION === true && user.emailVerified !== true) return false
+
+ return value === UserNotificationSettingValue.EMAIL || value === UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL
+ }
+
+ private isWebNotificationEnabled (value: UserNotificationSettingValue) {
+ return value === UserNotificationSettingValue.WEB_NOTIFICATION || value === UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL
+ }
+
+ static get Instance () {
+ return this.instance || (this.instance = new this())
+ }
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+ Notifier
+}
+import * as Bluebird from 'bluebird'
import { AccessDeniedError } from 'oauth2-server'
import { logger } from '../helpers/logger'
import { UserModel } from '../models/account/user'
function getAccessToken (bearerToken: string) {
logger.debug('Getting access token (bearerToken: ' + bearerToken + ').')
- if (accessTokenCache[bearerToken] !== undefined) return accessTokenCache[bearerToken]
+ if (accessTokenCache[bearerToken] !== undefined) return Bluebird.resolve(accessTokenCache[bearerToken])
return OAuthTokenModel.getByTokenAndPopulateUser(bearerToken)
.then(tokenModel => {
--- /dev/null
+import * as SocketIO from 'socket.io'
+import { authenticateSocket } from '../middlewares'
+import { UserNotificationModel } from '../models/account/user-notification'
+import { logger } from '../helpers/logger'
+import { Server } from 'http'
+
+class PeerTubeSocket {
+
+ private static instance: PeerTubeSocket
+
+ private userNotificationSockets: { [ userId: number ]: SocketIO.Socket } = {}
+
+ private constructor () {}
+
+ init (server: Server) {
+ const io = SocketIO(server)
+
+ io.of('/user-notifications')
+ .use(authenticateSocket)
+ .on('connection', socket => {
+ const userId = socket.handshake.query.user.id
+
+ logger.debug('User %d connected on the notification system.', userId)
+
+ this.userNotificationSockets[userId] = socket
+
+ socket.on('disconnect', () => {
+ logger.debug('User %d disconnected from SocketIO notifications.', userId)
+
+ delete this.userNotificationSockets[userId]
+ })
+ })
+ }
+
+ sendNotification (userId: number, notification: UserNotificationModel) {
+ const socket = this.userNotificationSockets[userId]
+
+ if (!socket) return
+
+ socket.emit('new-notification', notification.toFormattedJSON())
+ }
+
+ static get Instance () {
+ return this.instance || (this.instance = new this())
+ }
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+ PeerTubeSocket
+}
import { federateVideoIfNeeded } from '../activitypub'
import { SCHEDULER_INTERVALS_MS, sequelizeTypescript } from '../../initializers'
import { VideoPrivacy } from '../../../shared/models/videos'
+import { Notifier } from '../notifier'
export class UpdateVideosScheduler extends AbstractScheduler {
await video.save({ transaction: t })
await federateVideoIfNeeded(video, isNewVideo, t)
+
+ if (oldPrivacy === VideoPrivacy.UNLISTED || oldPrivacy === VideoPrivacy.PRIVATE) {
+ Notifier.Instance.notifyOnNewVideo(video)
+ }
}
await schedule.destroy({ transaction: t })
import { VideoChannelModel } from '../models/video/video-channel'
import { FilteredModelAttributes } from 'sequelize-typescript/lib/models/Model'
import { ActorModel } from '../models/activitypub/actor'
+import { UserNotificationSettingModel } from '../models/account/user-notification-setting'
+import { UserNotificationSettingValue } from '../../shared/models/users'
async function createUserAccountAndChannel (userToCreate: UserModel, validateUser = true) {
const { user, account, videoChannel } = await sequelizeTypescript.transaction(async t => {
}
const userCreated = await userToCreate.save(userOptions)
+ userCreated.NotificationSetting = await createDefaultUserNotificationSettings(userCreated, t)
+
const accountCreated = await createLocalAccountWithoutKeys(userCreated.username, userCreated.id, null, t)
userCreated.Account = accountCreated
createUserAccountAndChannel,
createLocalAccountWithoutKeys
}
+
+// ---------------------------------------------------------------------------
+
+function createDefaultUserNotificationSettings (user: UserModel, t: Sequelize.Transaction | undefined) {
+ return UserNotificationSettingModel.create({
+ userId: user.id,
+ newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION,
+ newCommentOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION,
+ videoAbuseAsModerator: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
+ blacklistOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL
+ }, { transaction: t })
+}
import 'express-validator'
import { OAUTH_LIFETIME } from '../initializers'
import { logger } from '../helpers/logger'
+import { Socket } from 'socket.io'
+import { getAccessToken } from '../lib/oauth-model'
const oAuthServer = new OAuthServer({
useErrorHandler: true,
})
}
+function authenticateSocket (socket: Socket, next: (err?: any) => void) {
+ const accessToken = socket.handshake.query.accessToken
+
+ logger.debug('Checking socket access token %s.', accessToken)
+
+ getAccessToken(accessToken)
+ .then(tokenDB => {
+ const now = new Date()
+
+ if (!tokenDB || tokenDB.accessTokenExpiresAt < now || tokenDB.refreshTokenExpiresAt < now) {
+ return next(new Error('Invalid access token.'))
+ }
+
+ socket.handshake.query.user = tokenDB.User
+
+ return next()
+ })
+}
+
function authenticatePromiseIfNeeded (req: express.Request, res: express.Response) {
return new Promise(resolve => {
// Already authenticated? (or tried to)
export {
authenticate,
+ authenticateSocket,
authenticatePromiseIfNeeded,
optionalAuthenticate,
token
const SORTABLE_USER_SUBSCRIPTIONS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.USER_SUBSCRIPTIONS)
const SORTABLE_ACCOUNTS_BLOCKLIST_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.ACCOUNTS_BLOCKLIST)
const SORTABLE_SERVERS_BLOCKLIST_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.SERVERS_BLOCKLIST)
+const SORTABLE_USER_NOTIFICATIONS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.USER_NOTIFICATIONS)
const usersSortValidator = checkSort(SORTABLE_USERS_COLUMNS)
const accountsSortValidator = checkSort(SORTABLE_ACCOUNTS_COLUMNS)
const userSubscriptionsSortValidator = checkSort(SORTABLE_USER_SUBSCRIPTIONS_COLUMNS)
const accountsBlocklistSortValidator = checkSort(SORTABLE_ACCOUNTS_BLOCKLIST_COLUMNS)
const serversBlocklistSortValidator = checkSort(SORTABLE_SERVERS_BLOCKLIST_COLUMNS)
+const userNotificationsSortValidator = checkSort(SORTABLE_USER_NOTIFICATIONS_COLUMNS)
// ---------------------------------------------------------------------------
userSubscriptionsSortValidator,
videoChannelsSearchSortValidator,
accountsBlocklistSortValidator,
- serversBlocklistSortValidator
+ serversBlocklistSortValidator,
+ userNotificationsSortValidator
}
import * as express from 'express'
import 'express-validator'
-import { body, param, query } from 'express-validator/check'
+import { body } from 'express-validator/check'
import { logger } from '../../helpers/logger'
import { areValidationErrors } from './utils'
-import { ActorFollowModel } from '../../models/activitypub/actor-follow'
-import { areValidActorHandles, isValidActorHandle } from '../../helpers/custom-validators/activitypub/actor'
-import { UserModel } from '../../models/account/user'
-import { CONFIG } from '../../initializers'
-import { isDateValid, toArray } from '../../helpers/custom-validators/misc'
+import { isDateValid } from '../../helpers/custom-validators/misc'
const userHistoryRemoveValidator = [
body('beforeDate')
--- /dev/null
+import * as express from 'express'
+import 'express-validator'
+import { body } from 'express-validator/check'
+import { logger } from '../../helpers/logger'
+import { areValidationErrors } from './utils'
+import { isUserNotificationSettingValid } from '../../helpers/custom-validators/user-notifications'
+import { isIntArray } from '../../helpers/custom-validators/misc'
+
+const updateNotificationSettingsValidator = [
+ body('newVideoFromSubscription')
+ .custom(isUserNotificationSettingValid).withMessage('Should have a valid new video from subscription notification setting'),
+ body('newCommentOnMyVideo')
+ .custom(isUserNotificationSettingValid).withMessage('Should have a valid new comment on my video notification setting'),
+ body('videoAbuseAsModerator')
+ .custom(isUserNotificationSettingValid).withMessage('Should have a valid new video abuse as moderator notification setting'),
+ body('blacklistOnMyVideo')
+ .custom(isUserNotificationSettingValid).withMessage('Should have a valid new blacklist on my video notification setting'),
+
+ (req: express.Request, res: express.Response, next: express.NextFunction) => {
+ logger.debug('Checking updateNotificationSettingsValidator parameters', { parameters: req.body })
+
+ if (areValidationErrors(req, res)) return
+
+ return next()
+ }
+]
+
+const markAsReadUserNotificationsValidator = [
+ body('ids')
+ .custom(isIntArray).withMessage('Should have a valid notification ids to mark as read'),
+
+ (req: express.Request, res: express.Response, next: express.NextFunction) => {
+ logger.debug('Checking markAsReadUserNotificationsValidator parameters', { parameters: req.body })
+
+ if (areValidationErrors(req, res)) return
+
+ return next()
+ }
+]
+
+// ---------------------------------------------------------------------------
+
+export {
+ updateNotificationSettingsValidator,
+ markAsReadUserNotificationsValidator
+}
--- /dev/null
+import {
+ AfterDestroy,
+ AfterUpdate,
+ AllowNull,
+ BelongsTo,
+ Column,
+ CreatedAt,
+ Default,
+ ForeignKey,
+ Is,
+ Model,
+ Table,
+ UpdatedAt
+} from 'sequelize-typescript'
+import { throwIfNotValid } from '../utils'
+import { UserModel } from './user'
+import { isUserNotificationSettingValid } from '../../helpers/custom-validators/user-notifications'
+import { UserNotificationSetting, UserNotificationSettingValue } from '../../../shared/models/users/user-notification-setting.model'
+import { clearCacheByUserId } from '../../lib/oauth-model'
+
+@Table({
+ tableName: 'userNotificationSetting',
+ indexes: [
+ {
+ fields: [ 'userId' ],
+ unique: true
+ }
+ ]
+})
+export class UserNotificationSettingModel extends Model<UserNotificationSettingModel> {
+
+ @AllowNull(false)
+ @Default(null)
+ @Is(
+ 'UserNotificationSettingNewVideoFromSubscription',
+ value => throwIfNotValid(value, isUserNotificationSettingValid, 'newVideoFromSubscription')
+ )
+ @Column
+ newVideoFromSubscription: UserNotificationSettingValue
+
+ @AllowNull(false)
+ @Default(null)
+ @Is(
+ 'UserNotificationSettingNewCommentOnMyVideo',
+ value => throwIfNotValid(value, isUserNotificationSettingValid, 'newCommentOnMyVideo')
+ )
+ @Column
+ newCommentOnMyVideo: UserNotificationSettingValue
+
+ @AllowNull(false)
+ @Default(null)
+ @Is(
+ 'UserNotificationSettingVideoAbuseAsModerator',
+ value => throwIfNotValid(value, isUserNotificationSettingValid, 'videoAbuseAsModerator')
+ )
+ @Column
+ videoAbuseAsModerator: UserNotificationSettingValue
+
+ @AllowNull(false)
+ @Default(null)
+ @Is(
+ 'UserNotificationSettingBlacklistOnMyVideo',
+ value => throwIfNotValid(value, isUserNotificationSettingValid, 'blacklistOnMyVideo')
+ )
+ @Column
+ blacklistOnMyVideo: UserNotificationSettingValue
+
+ @ForeignKey(() => UserModel)
+ @Column
+ userId: number
+
+ @BelongsTo(() => UserModel, {
+ foreignKey: {
+ allowNull: false
+ },
+ onDelete: 'cascade'
+ })
+ User: UserModel
+
+ @CreatedAt
+ createdAt: Date
+
+ @UpdatedAt
+ updatedAt: Date
+
+ @AfterUpdate
+ @AfterDestroy
+ static removeTokenCache (instance: UserNotificationSettingModel) {
+ return clearCacheByUserId(instance.userId)
+ }
+
+ toFormattedJSON (): UserNotificationSetting {
+ return {
+ newCommentOnMyVideo: this.newCommentOnMyVideo,
+ newVideoFromSubscription: this.newVideoFromSubscription,
+ videoAbuseAsModerator: this.videoAbuseAsModerator,
+ blacklistOnMyVideo: this.blacklistOnMyVideo
+ }
+ }
+}
--- /dev/null
+import { AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Is, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
+import { UserNotification, UserNotificationType } from '../../../shared'
+import { getSort, throwIfNotValid } from '../utils'
+import { isBooleanValid } from '../../helpers/custom-validators/misc'
+import { isUserNotificationTypeValid } from '../../helpers/custom-validators/user-notifications'
+import { UserModel } from './user'
+import { VideoModel } from '../video/video'
+import { VideoCommentModel } from '../video/video-comment'
+import { Op } from 'sequelize'
+import { VideoChannelModel } from '../video/video-channel'
+import { AccountModel } from './account'
+import { VideoAbuseModel } from '../video/video-abuse'
+import { VideoBlacklistModel } from '../video/video-blacklist'
+
+enum ScopeNames {
+ WITH_ALL = 'WITH_ALL'
+}
+
+@Scopes({
+ [ScopeNames.WITH_ALL]: {
+ include: [
+ {
+ attributes: [ 'id', 'uuid', 'name' ],
+ model: () => VideoModel.unscoped(),
+ required: false,
+ include: [
+ {
+ required: true,
+ attributes: [ 'id', 'name' ],
+ model: () => VideoChannelModel.unscoped()
+ }
+ ]
+ },
+ {
+ attributes: [ 'id' ],
+ model: () => VideoCommentModel.unscoped(),
+ required: false,
+ include: [
+ {
+ required: true,
+ attributes: [ 'id', 'name' ],
+ model: () => AccountModel.unscoped()
+ },
+ {
+ required: true,
+ attributes: [ 'id', 'uuid', 'name' ],
+ model: () => VideoModel.unscoped()
+ }
+ ]
+ },
+ {
+ attributes: [ 'id' ],
+ model: () => VideoAbuseModel.unscoped(),
+ required: false,
+ include: [
+ {
+ required: true,
+ attributes: [ 'id', 'uuid', 'name' ],
+ model: () => VideoModel.unscoped()
+ }
+ ]
+ },
+ {
+ attributes: [ 'id' ],
+ model: () => VideoBlacklistModel.unscoped(),
+ required: false,
+ include: [
+ {
+ required: true,
+ attributes: [ 'id', 'uuid', 'name' ],
+ model: () => VideoModel.unscoped()
+ }
+ ]
+ }
+ ]
+ }
+})
+@Table({
+ tableName: 'userNotification',
+ indexes: [
+ {
+ fields: [ 'videoId' ]
+ },
+ {
+ fields: [ 'commentId' ]
+ }
+ ]
+})
+export class UserNotificationModel extends Model<UserNotificationModel> {
+
+ @AllowNull(false)
+ @Default(null)
+ @Is('UserNotificationType', value => throwIfNotValid(value, isUserNotificationTypeValid, 'type'))
+ @Column
+ type: UserNotificationType
+
+ @AllowNull(false)
+ @Default(false)
+ @Is('UserNotificationRead', value => throwIfNotValid(value, isBooleanValid, 'read'))
+ @Column
+ read: boolean
+
+ @CreatedAt
+ createdAt: Date
+
+ @UpdatedAt
+ updatedAt: Date
+
+ @ForeignKey(() => UserModel)
+ @Column
+ userId: number
+
+ @BelongsTo(() => UserModel, {
+ foreignKey: {
+ allowNull: false
+ },
+ onDelete: 'cascade'
+ })
+ User: UserModel
+
+ @ForeignKey(() => VideoModel)
+ @Column
+ videoId: number
+
+ @BelongsTo(() => VideoModel, {
+ foreignKey: {
+ allowNull: true
+ },
+ onDelete: 'cascade'
+ })
+ Video: VideoModel
+
+ @ForeignKey(() => VideoCommentModel)
+ @Column
+ commentId: number
+
+ @BelongsTo(() => VideoCommentModel, {
+ foreignKey: {
+ allowNull: true
+ },
+ onDelete: 'cascade'
+ })
+ Comment: VideoCommentModel
+
+ @ForeignKey(() => VideoAbuseModel)
+ @Column
+ videoAbuseId: number
+
+ @BelongsTo(() => VideoAbuseModel, {
+ foreignKey: {
+ allowNull: true
+ },
+ onDelete: 'cascade'
+ })
+ VideoAbuse: VideoAbuseModel
+
+ @ForeignKey(() => VideoBlacklistModel)
+ @Column
+ videoBlacklistId: number
+
+ @BelongsTo(() => VideoBlacklistModel, {
+ foreignKey: {
+ allowNull: true
+ },
+ onDelete: 'cascade'
+ })
+ VideoBlacklist: VideoBlacklistModel
+
+ static listForApi (userId: number, start: number, count: number, sort: string) {
+ const query = {
+ offset: start,
+ limit: count,
+ order: getSort(sort),
+ where: {
+ userId
+ }
+ }
+
+ return UserNotificationModel.scope(ScopeNames.WITH_ALL)
+ .findAndCountAll(query)
+ .then(({ rows, count }) => {
+ return {
+ data: rows,
+ total: count
+ }
+ })
+ }
+
+ static markAsRead (userId: number, notificationIds: number[]) {
+ const query = {
+ where: {
+ userId,
+ id: {
+ [Op.any]: notificationIds
+ }
+ }
+ }
+
+ return UserNotificationModel.update({ read: true }, query)
+ }
+
+ toFormattedJSON (): UserNotification {
+ const video = this.Video ? {
+ id: this.Video.id,
+ uuid: this.Video.uuid,
+ name: this.Video.name,
+ channel: {
+ id: this.Video.VideoChannel.id,
+ displayName: this.Video.VideoChannel.getDisplayName()
+ }
+ } : undefined
+
+ const comment = this.Comment ? {
+ id: this.Comment.id,
+ account: {
+ id: this.Comment.Account.id,
+ displayName: this.Comment.Account.getDisplayName()
+ },
+ video: {
+ id: this.Comment.Video.id,
+ uuid: this.Comment.Video.uuid,
+ name: this.Comment.Video.name
+ }
+ } : undefined
+
+ const videoAbuse = this.VideoAbuse ? {
+ id: this.VideoAbuse.id,
+ video: {
+ id: this.VideoAbuse.Video.id,
+ uuid: this.VideoAbuse.Video.uuid,
+ name: this.VideoAbuse.Video.name
+ }
+ } : undefined
+
+ const videoBlacklist = this.VideoBlacklist ? {
+ id: this.VideoBlacklist.id,
+ video: {
+ id: this.VideoBlacklist.Video.id,
+ uuid: this.VideoBlacklist.Video.uuid,
+ name: this.VideoBlacklist.Video.name
+ }
+ } : undefined
+
+ return {
+ id: this.id,
+ type: this.type,
+ read: this.read,
+ video,
+ comment,
+ videoAbuse,
+ videoBlacklist,
+ createdAt: this.createdAt.toISOString(),
+ updatedAt: this.updatedAt.toISOString()
+ }
+ }
+}
isUserUsernameValid,
isUserVideoQuotaDailyValid,
isUserVideoQuotaValid,
- isUserWebTorrentEnabledValid,
- isUserVideosHistoryEnabledValid
+ isUserVideosHistoryEnabledValid,
+ isUserWebTorrentEnabledValid
} from '../../helpers/custom-validators/users'
import { comparePassword, cryptPassword } from '../../helpers/peertube-crypto'
import { OAuthTokenModel } from '../oauth/oauth-token'
import { values } from 'lodash'
import { NSFW_POLICY_TYPES } from '../../initializers'
import { clearCacheByUserId } from '../../lib/oauth-model'
+import { UserNotificationSettingModel } from './user-notification-setting'
+import { VideoModel } from '../video/video'
+import { ActorModel } from '../activitypub/actor'
+import { ActorFollowModel } from '../activitypub/actor-follow'
enum ScopeNames {
WITH_VIDEO_CHANNEL = 'WITH_VIDEO_CHANNEL'
{
model: () => AccountModel,
required: true
+ },
+ {
+ model: () => UserNotificationSettingModel,
+ required: true
}
]
})
model: () => AccountModel,
required: true,
include: [ () => VideoChannelModel ]
+ },
+ {
+ model: () => UserNotificationSettingModel,
+ required: true
}
]
}
})
Account: AccountModel
+ @HasOne(() => UserNotificationSettingModel, {
+ foreignKey: 'userId',
+ onDelete: 'cascade',
+ hooks: true
+ })
+ NotificationSetting: UserNotificationSettingModel
+
@HasMany(() => OAuthTokenModel, {
foreignKey: 'userId',
onDelete: 'cascade'
})
}
- static listEmailsWithRight (right: UserRight) {
+ static listWithRight (right: UserRight) {
const roles = Object.keys(USER_ROLE_LABELS)
.map(k => parseInt(k, 10) as UserRole)
.filter(role => hasUserRight(role, right))
const query = {
- attribute: [ 'email' ],
where: {
role: {
[Sequelize.Op.in]: roles
}
}
- return UserModel.unscoped()
- .findAll(query)
- .then(u => u.map(u => u.email))
+ return UserModel.findAll(query)
+ }
+
+ static listUserSubscribersOf (actorId: number) {
+ const query = {
+ include: [
+ {
+ model: UserNotificationSettingModel.unscoped(),
+ required: true
+ },
+ {
+ attributes: [ 'userId' ],
+ model: AccountModel.unscoped(),
+ required: true,
+ include: [
+ {
+ attributes: [ ],
+ model: ActorModel.unscoped(),
+ required: true,
+ where: {
+ serverId: null
+ },
+ include: [
+ {
+ attributes: [ ],
+ as: 'ActorFollowings',
+ model: ActorFollowModel.unscoped(),
+ required: true,
+ where: {
+ targetActorId: actorId
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+
+ return UserModel.unscoped().findAll(query)
}
static loadById (id: number) {
return UserModel.findOne(query)
}
+ static loadByVideoId (videoId: number) {
+ const query = {
+ include: [
+ {
+ required: true,
+ attributes: [ 'id' ],
+ model: AccountModel.unscoped(),
+ include: [
+ {
+ required: true,
+ attributes: [ 'id' ],
+ model: VideoChannelModel.unscoped(),
+ include: [
+ {
+ required: true,
+ attributes: [ 'id' ],
+ model: VideoModel.unscoped(),
+ where: {
+ id: videoId
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+
+ return UserModel.findOne(query)
+ }
+
static getOriginalVideoFileTotalFromUser (user: UserModel) {
// Don't use sequelize because we need to use a sub query
const query = UserModel.generateUserQuotaBaseSQL()
blocked: this.blocked,
blockedReason: this.blockedReason,
account: this.Account.toFormattedJSON(),
+ notificationSettings: this.NotificationSetting ? this.NotificationSetting.toFormattedJSON() : undefined,
videoChannels: [],
videoQuotaUsed: videoQuotaUsed !== undefined
? parseInt(videoQuotaUsed, 10)
})
}
- static listFollowersForApi (id: number, start: number, count: number, sort: string, search?: string) {
+ static listFollowersForApi (actorId: number, start: number, count: number, sort: string, search?: string) {
const query = {
distinct: true,
offset: start,
as: 'ActorFollowing',
required: true,
where: {
- id
+ id: actorId
}
}
]
})
}
- static listSubscriptionsForApi (id: number, start: number, count: number, sort: string) {
+ static listSubscriptionsForApi (actorId: number, start: number, count: number, sort: string) {
const query = {
attributes: [],
distinct: true,
limit: count,
order: getSort(sort),
where: {
- actorId: id
+ actorId: actorId
},
include: [
{
static updateFollowScore (inboxUrl: string, value: number, t?: Sequelize.Transaction) {
const query = `UPDATE "actorFollow" SET "score" = LEAST("score" + ${value}, ${ACTOR_FOLLOW_SCORE.MAX}) ` +
'WHERE id IN (' +
- 'SELECT "actorFollow"."id" FROM "actorFollow" ' +
- 'INNER JOIN "actor" ON "actor"."id" = "actorFollow"."actorId" ' +
- `WHERE "actor"."inboxUrl" = '${inboxUrl}' OR "actor"."sharedInboxUrl" = '${inboxUrl}'` +
+ 'SELECT "actorFollow"."id" FROM "actorFollow" ' +
+ 'INNER JOIN "actor" ON "actor"."id" = "actorFollow"."actorId" ' +
+ `WHERE "actor"."inboxUrl" = '${inboxUrl}' OR "actor"."sharedInboxUrl" = '${inboxUrl}'` +
')'
const options = {
name: 'actorId',
allowNull: false
},
+ as: 'ActorFollowings',
onDelete: 'cascade'
})
ActorFollowing: ActorFollowModel[]
})
Video: VideoModel
- @AfterCreate
- static sendEmailNotification (instance: VideoAbuseModel) {
- return Emailer.Instance.addVideoAbuseReportJob(instance.videoId)
- }
-
static loadByIdAndVideoId (id: number, videoId: number) {
const query = {
where: {
})
Video: VideoModel
- @AfterCreate
- static sendBlacklistEmailNotification (instance: VideoBlacklistModel) {
- return Emailer.Instance.addVideoBlacklistReportJob(instance.videoId, instance.reason)
- }
-
- @AfterDestroy
- static sendUnblacklistEmailNotification (instance: VideoBlacklistModel) {
- return Emailer.Instance.addVideoUnblacklistReportJob(instance.videoId)
- }
-
static listForApi (start: number, count: number, sort: SortType) {
const query = {
offset: start,
}
}
+ getCommentStaticPath () {
+ return this.Video.getWatchStaticPath() + ';threadId=' + this.getThreadId()
+ }
+
getThreadId (): number {
return this.originCommentId || this.id
}
videoFile.infoHash = parsedTorrent.infoHash
}
+ getWatchStaticPath () {
+ return '/videos/watch/' + this.uuid
+ }
+
getEmbedStaticPath () {
return '/videos/embed/' + this.uuid
}
import './redundancy'
import './search'
import './services'
+import './user-notifications'
import './user-subscriptions'
import './users'
import './video-abuses'
--- /dev/null
+/* tslint:disable:no-unused-expression */
+
+import 'mocha'
+import * as io from 'socket.io-client'
+
+import {
+ flushTests,
+ immutableAssign,
+ killallServers,
+ makeGetRequest,
+ makePostBodyRequest,
+ makePutBodyRequest,
+ runServer,
+ ServerInfo,
+ setAccessTokensToServers,
+ wait
+} from '../../../../shared/utils'
+import {
+ checkBadCountPagination,
+ checkBadSortPagination,
+ checkBadStartPagination
+} from '../../../../shared/utils/requests/check-api-params'
+import { UserNotificationSetting, UserNotificationSettingValue } from '../../../../shared/models/users'
+
+describe('Test user notifications API validators', function () {
+ let server: ServerInfo
+
+ // ---------------------------------------------------------------
+
+ before(async function () {
+ this.timeout(30000)
+
+ await flushTests()
+
+ server = await runServer(1)
+
+ await setAccessTokensToServers([ server ])
+ })
+
+ describe('When listing my notifications', function () {
+ const path = '/api/v1/users/me/notifications'
+
+ it('Should fail with a bad start pagination', async function () {
+ await checkBadStartPagination(server.url, path, server.accessToken)
+ })
+
+ it('Should fail with a bad count pagination', async function () {
+ await checkBadCountPagination(server.url, path, server.accessToken)
+ })
+
+ it('Should fail with an incorrect sort', async function () {
+ await checkBadSortPagination(server.url, path, server.accessToken)
+ })
+
+ it('Should fail with a non authenticated user', async function () {
+ await makeGetRequest({
+ url: server.url,
+ path,
+ statusCodeExpected: 401
+ })
+ })
+
+ it('Should succeed with the correct parameters', async function () {
+ await makeGetRequest({
+ url: server.url,
+ path,
+ token: server.accessToken,
+ statusCodeExpected: 200
+ })
+ })
+ })
+
+ describe('When marking as read my notifications', function () {
+ const path = '/api/v1/users/me/notifications/read'
+
+ it('Should fail with wrong ids parameters', async function () {
+ await makePostBodyRequest({
+ url: server.url,
+ path,
+ fields: {
+ ids: [ 'hello' ]
+ },
+ token: server.accessToken,
+ statusCodeExpected: 400
+ })
+
+ await makePostBodyRequest({
+ url: server.url,
+ path,
+ fields: {
+ ids: 5
+ },
+ token: server.accessToken,
+ statusCodeExpected: 400
+ })
+ })
+
+ it('Should fail with a non authenticated user', async function () {
+ await makePostBodyRequest({
+ url: server.url,
+ path,
+ fields: {
+ ids: [ 5 ]
+ },
+ statusCodeExpected: 401
+ })
+ })
+
+ it('Should succeed with the correct parameters', async function () {
+ await makePostBodyRequest({
+ url: server.url,
+ path,
+ fields: {
+ ids: [ 5 ]
+ },
+ token: server.accessToken,
+ statusCodeExpected: 204
+ })
+ })
+ })
+
+ describe('When updating my notification settings', function () {
+ const path = '/api/v1/users/me/notification-settings'
+ const correctFields: UserNotificationSetting = {
+ newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION,
+ newCommentOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION,
+ videoAbuseAsModerator: UserNotificationSettingValue.WEB_NOTIFICATION,
+ blacklistOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION
+ }
+
+ it('Should fail with missing fields', async function () {
+ await makePutBodyRequest({
+ url: server.url,
+ path,
+ token: server.accessToken,
+ fields: { newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION },
+ statusCodeExpected: 400
+ })
+ })
+
+ it('Should fail with incorrect field values', async function () {
+ {
+ const fields = immutableAssign(correctFields, { newCommentOnMyVideo: 15 })
+
+ await makePutBodyRequest({
+ url: server.url,
+ path,
+ token: server.accessToken,
+ fields,
+ statusCodeExpected: 400
+ })
+ }
+
+ {
+ const fields = immutableAssign(correctFields, { newCommentOnMyVideo: 'toto' })
+
+ await makePutBodyRequest({
+ url: server.url,
+ path,
+ fields,
+ token: server.accessToken,
+ statusCodeExpected: 400
+ })
+ }
+ })
+
+ it('Should fail with a non authenticated user', async function () {
+ await makePutBodyRequest({
+ url: server.url,
+ path,
+ fields: correctFields,
+ statusCodeExpected: 401
+ })
+ })
+
+ it('Should succeed with the correct parameters', async function () {
+ await makePutBodyRequest({
+ url: server.url,
+ path,
+ token: server.accessToken,
+ fields: correctFields,
+ statusCodeExpected: 204
+ })
+ })
+ })
+
+ describe('When connecting to my notification socket', function () {
+ it('Should fail with no token', function (next) {
+ const socket = io('http://localhost:9001/user-notifications', { reconnection: false })
+
+ socket.on('error', () => {
+ socket.removeListener('error', this)
+ socket.disconnect()
+ next()
+ })
+
+ socket.on('connect', () => {
+ socket.disconnect()
+ next(new Error('Connected with a missing token.'))
+ })
+ })
+
+ it('Should fail with an invalid token', function (next) {
+ const socket = io('http://localhost:9001/user-notifications', {
+ query: { accessToken: 'bad_access_token' },
+ reconnection: false
+ })
+
+ socket.on('error', () => {
+ socket.removeListener('error', this)
+ socket.disconnect()
+ next()
+ })
+
+ socket.on('connect', () => {
+ socket.disconnect()
+ next(new Error('Connected with an invalid token.'))
+ })
+ })
+
+ it('Should success with the correct token', function (next) {
+ const socket = io('http://localhost:9001/user-notifications', {
+ query: { accessToken: server.accessToken },
+ reconnection: false
+ })
+
+ const errorListener = socket.on('error', err => {
+ next(new Error('Error in connection: ' + err))
+ })
+
+ socket.on('connect', async () => {
+ socket.removeListener('error', errorListener)
+ socket.disconnect()
+
+ await wait(500)
+ next()
+ })
+ })
+ })
+
+ after(async function () {
+ killallServers([ server ])
+
+ // Keep the logs if the test failed
+ if (this['ok']) {
+ await flushTests()
+ }
+ })
+})
import './blocklist'
import './user-subscriptions'
+import './user-notifications'
import './users'
import './users-multiple-servers'
import './users-verification'
--- /dev/null
+/* tslint:disable:no-unused-expression */
+
+import * as chai from 'chai'
+import 'mocha'
+import {
+ addVideoToBlacklist,
+ createUser,
+ doubleFollow,
+ flushAndRunMultipleServers,
+ flushTests,
+ getMyUserInformation,
+ immutableAssign,
+ removeVideoFromBlacklist,
+ reportVideoAbuse,
+ updateVideo,
+ userLogin,
+ wait
+} from '../../../../shared/utils'
+import { killallServers, ServerInfo, uploadVideo } from '../../../../shared/utils/index'
+import { setAccessTokensToServers } from '../../../../shared/utils/users/login'
+import { waitJobs } from '../../../../shared/utils/server/jobs'
+import { getUserNotificationSocket } from '../../../../shared/utils/socket/socket-io'
+import {
+ CheckerBaseParams,
+ checkNewBlacklistOnMyVideo,
+ checkNewCommentOnMyVideo,
+ checkNewVideoAbuseForModerators,
+ checkNewVideoFromSubscription,
+ getLastNotification,
+ getUserNotifications,
+ markAsReadNotifications,
+ updateMyNotificationSettings
+} from '../../../../shared/utils/users/user-notifications'
+import { User, UserNotification, UserNotificationSettingValue } from '../../../../shared/models/users'
+import { MockSmtpServer } from '../../../../shared/utils/miscs/email'
+import { addUserSubscription } from '../../../../shared/utils/users/user-subscriptions'
+import { VideoPrivacy } from '../../../../shared/models/videos'
+import { getYoutubeVideoUrl, importVideo } from '../../../../shared/utils/videos/video-imports'
+import { addVideoCommentReply, addVideoCommentThread } from '../../../../shared/utils/videos/video-comments'
+
+const expect = chai.expect
+
+async function uploadVideoByRemoteAccount (servers: ServerInfo[], videoNameId: number, additionalParams: any = {}) {
+ const data = Object.assign({ name: 'remote video ' + videoNameId }, additionalParams)
+ const res = await uploadVideo(servers[ 1 ].url, servers[ 1 ].accessToken, data)
+
+ await waitJobs(servers)
+
+ return res.body.video.uuid
+}
+
+async function uploadVideoByLocalAccount (servers: ServerInfo[], videoNameId: number, additionalParams: any = {}) {
+ const data = Object.assign({ name: 'local video ' + videoNameId }, additionalParams)
+ const res = await uploadVideo(servers[ 0 ].url, servers[ 0 ].accessToken, data)
+
+ await waitJobs(servers)
+
+ return res.body.video.uuid
+}
+
+describe('Test users notifications', function () {
+ let servers: ServerInfo[] = []
+ let userAccessToken: string
+ let userNotifications: UserNotification[] = []
+ let adminNotifications: UserNotification[] = []
+ const emails: object[] = []
+
+ before(async function () {
+ this.timeout(120000)
+
+ await MockSmtpServer.Instance.collectEmails(emails)
+
+ await flushTests()
+
+ const overrideConfig = {
+ smtp: {
+ hostname: 'localhost'
+ }
+ }
+ servers = await flushAndRunMultipleServers(2, overrideConfig)
+
+ // Get the access tokens
+ await setAccessTokensToServers(servers)
+
+ // Server 1 and server 2 follow each other
+ await doubleFollow(servers[0], servers[1])
+
+ await waitJobs(servers)
+
+ const user = {
+ username: 'user_1',
+ password: 'super password'
+ }
+ await createUser(servers[0].url, servers[0].accessToken, user.username, user.password, 10 * 1000 * 1000)
+ userAccessToken = await userLogin(servers[0], user)
+
+ await updateMyNotificationSettings(servers[0].url, userAccessToken, {
+ newCommentOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
+ newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
+ blacklistOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
+ videoAbuseAsModerator: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL
+ })
+
+ {
+ const socket = getUserNotificationSocket(servers[ 0 ].url, userAccessToken)
+ socket.on('new-notification', n => userNotifications.push(n))
+ }
+ {
+ const socket = getUserNotificationSocket(servers[ 0 ].url, servers[0].accessToken)
+ socket.on('new-notification', n => adminNotifications.push(n))
+ }
+ })
+
+ describe('New video from my subscription notification', function () {
+ let baseParams: CheckerBaseParams
+
+ before(() => {
+ baseParams = {
+ server: servers[0],
+ emails,
+ socketNotifications: userNotifications,
+ token: userAccessToken
+ }
+ })
+
+ it('Should not send notifications if the user does not follow the video publisher', async function () {
+ await uploadVideoByLocalAccount(servers, 1)
+
+ const notification = await getLastNotification(servers[ 0 ].url, userAccessToken)
+ expect(notification).to.be.undefined
+
+ expect(emails).to.have.lengthOf(0)
+ expect(userNotifications).to.have.lengthOf(0)
+ })
+
+ it('Should send a new video notification if the user follows the local video publisher', async function () {
+ await addUserSubscription(servers[0].url, userAccessToken, 'root_channel@localhost:9001')
+
+ const videoNameId = 10
+ const videoName = 'local video ' + videoNameId
+
+ const uuid = await uploadVideoByLocalAccount(servers, videoNameId)
+ await checkNewVideoFromSubscription(baseParams, videoName, uuid, 'presence')
+ })
+
+ it('Should send a new video notification from a remote account', async function () {
+ this.timeout(50000) // Server 2 has transcoding enabled
+
+ await addUserSubscription(servers[0].url, userAccessToken, 'root_channel@localhost:9002')
+
+ const videoNameId = 20
+ const videoName = 'remote video ' + videoNameId
+
+ const uuid = await uploadVideoByRemoteAccount(servers, videoNameId)
+ await checkNewVideoFromSubscription(baseParams, videoName, uuid, 'presence')
+ })
+
+ it('Should send a new video notification on a scheduled publication', async function () {
+ this.timeout(20000)
+
+ const videoNameId = 30
+ const videoName = 'local video ' + videoNameId
+
+ // In 2 seconds
+ let updateAt = new Date(new Date().getTime() + 2000)
+
+ const data = {
+ privacy: VideoPrivacy.PRIVATE,
+ scheduleUpdate: {
+ updateAt: updateAt.toISOString(),
+ privacy: VideoPrivacy.PUBLIC
+ }
+ }
+ const uuid = await uploadVideoByLocalAccount(servers, videoNameId, data)
+
+ await wait(6000)
+ await checkNewVideoFromSubscription(baseParams, videoName, uuid, 'presence')
+ })
+
+ it('Should send a new video notification on a remote scheduled publication', async function () {
+ this.timeout(20000)
+
+ const videoNameId = 40
+ const videoName = 'remote video ' + videoNameId
+
+ // In 2 seconds
+ let updateAt = new Date(new Date().getTime() + 2000)
+
+ const data = {
+ privacy: VideoPrivacy.PRIVATE,
+ scheduleUpdate: {
+ updateAt: updateAt.toISOString(),
+ privacy: VideoPrivacy.PUBLIC
+ }
+ }
+ const uuid = await uploadVideoByRemoteAccount(servers, videoNameId, data)
+
+ await wait(6000)
+ await checkNewVideoFromSubscription(baseParams, videoName, uuid, 'presence')
+ })
+
+ it('Should not send a notification before the video is published', async function () {
+ this.timeout(20000)
+
+ const videoNameId = 50
+ const videoName = 'local video ' + videoNameId
+
+ let updateAt = new Date(new Date().getTime() + 100000)
+
+ const data = {
+ privacy: VideoPrivacy.PRIVATE,
+ scheduleUpdate: {
+ updateAt: updateAt.toISOString(),
+ privacy: VideoPrivacy.PUBLIC
+ }
+ }
+ const uuid = await uploadVideoByLocalAccount(servers, videoNameId, data)
+
+ await wait(6000)
+ await checkNewVideoFromSubscription(baseParams, videoName, uuid, 'absence')
+ })
+
+ it('Should send a new video notification when a video becomes public', async function () {
+ this.timeout(10000)
+
+ const videoNameId = 60
+ const videoName = 'local video ' + videoNameId
+
+ const data = { privacy: VideoPrivacy.PRIVATE }
+ const uuid = await uploadVideoByLocalAccount(servers, videoNameId, data)
+
+ await checkNewVideoFromSubscription(baseParams, videoName, uuid, 'absence')
+
+ await updateVideo(servers[0].url, servers[0].accessToken, uuid, { privacy: VideoPrivacy.PUBLIC })
+
+ await wait(500)
+ await checkNewVideoFromSubscription(baseParams, videoName, uuid, 'presence')
+ })
+
+ it('Should send a new video notification when a remote video becomes public', async function () {
+ this.timeout(20000)
+
+ const videoNameId = 70
+ const videoName = 'remote video ' + videoNameId
+
+ const data = { privacy: VideoPrivacy.PRIVATE }
+ const uuid = await uploadVideoByRemoteAccount(servers, videoNameId, data)
+
+ await checkNewVideoFromSubscription(baseParams, videoName, uuid, 'absence')
+
+ await updateVideo(servers[1].url, servers[1].accessToken, uuid, { privacy: VideoPrivacy.PUBLIC })
+
+ await waitJobs(servers)
+ await checkNewVideoFromSubscription(baseParams, videoName, uuid, 'presence')
+ })
+
+ it('Should not send a new video notification when a video becomes unlisted', async function () {
+ this.timeout(20000)
+
+ const videoNameId = 80
+ const videoName = 'local video ' + videoNameId
+
+ const data = { privacy: VideoPrivacy.PRIVATE }
+ const uuid = await uploadVideoByLocalAccount(servers, videoNameId, data)
+
+ await updateVideo(servers[0].url, servers[0].accessToken, uuid, { privacy: VideoPrivacy.UNLISTED })
+
+ await checkNewVideoFromSubscription(baseParams, videoName, uuid, 'absence')
+ })
+
+ it('Should not send a new video notification when a remote video becomes unlisted', async function () {
+ this.timeout(20000)
+
+ const videoNameId = 90
+ const videoName = 'remote video ' + videoNameId
+
+ const data = { privacy: VideoPrivacy.PRIVATE }
+ const uuid = await uploadVideoByRemoteAccount(servers, videoNameId, data)
+
+ await updateVideo(servers[1].url, servers[1].accessToken, uuid, { privacy: VideoPrivacy.UNLISTED })
+
+ await waitJobs(servers)
+ await checkNewVideoFromSubscription(baseParams, videoName, uuid, 'absence')
+ })
+
+ it('Should send a new video notification after a video import', async function () {
+ this.timeout(30000)
+
+ const resChannel = await getMyUserInformation(servers[0].url, servers[0].accessToken)
+ const channelId = resChannel.body.videoChannels[0].id
+ const videoName = 'local video 100'
+
+ const attributes = {
+ name: videoName,
+ channelId,
+ privacy: VideoPrivacy.PUBLIC,
+ targetUrl: getYoutubeVideoUrl()
+ }
+ const res = await importVideo(servers[0].url, servers[0].accessToken, attributes)
+ const uuid = res.body.video.uuid
+
+ await waitJobs(servers)
+
+ await checkNewVideoFromSubscription(baseParams, videoName, uuid, 'presence')
+ })
+ })
+
+ describe('Comment on my video notifications', function () {
+ let baseParams: CheckerBaseParams
+
+ before(() => {
+ baseParams = {
+ server: servers[0],
+ emails,
+ socketNotifications: userNotifications,
+ token: userAccessToken
+ }
+ })
+
+ it('Should not send a new comment notification after a comment on another video', async function () {
+ this.timeout(10000)
+
+ const resVideo = await uploadVideo(servers[0].url, servers[0].accessToken, { name: 'super video' })
+ const uuid = resVideo.body.video.uuid
+
+ const resComment = await addVideoCommentThread(servers[0].url, servers[0].accessToken, uuid, 'comment')
+ const commentId = resComment.body.comment.id
+
+ await wait(500)
+ await checkNewCommentOnMyVideo(baseParams, uuid, commentId, commentId, 'absence')
+ })
+
+ it('Should not send a new comment notification if I comment my own video', async function () {
+ this.timeout(10000)
+
+ const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name: 'super video' })
+ const uuid = resVideo.body.video.uuid
+
+ const resComment = await addVideoCommentThread(servers[0].url, userAccessToken, uuid, 'comment')
+ const commentId = resComment.body.comment.id
+
+ await wait(500)
+ await checkNewCommentOnMyVideo(baseParams, uuid, commentId, commentId, 'absence')
+ })
+
+ it('Should send a new comment notification after a local comment on my video', async function () {
+ this.timeout(10000)
+
+ const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name: 'super video' })
+ const uuid = resVideo.body.video.uuid
+
+ const resComment = await addVideoCommentThread(servers[0].url, servers[0].accessToken, uuid, 'comment')
+ const commentId = resComment.body.comment.id
+
+ await wait(500)
+ await checkNewCommentOnMyVideo(baseParams, uuid, commentId, commentId, 'presence')
+ })
+
+ it('Should send a new comment notification after a remote comment on my video', async function () {
+ this.timeout(10000)
+
+ const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name: 'super video' })
+ const uuid = resVideo.body.video.uuid
+
+ await waitJobs(servers)
+
+ const resComment = await addVideoCommentThread(servers[1].url, servers[1].accessToken, uuid, 'comment')
+ const commentId = resComment.body.comment.id
+
+ await waitJobs(servers)
+ await checkNewCommentOnMyVideo(baseParams, uuid, commentId, commentId, 'presence')
+ })
+
+ it('Should send a new comment notification after a local reply on my video', async function () {
+ this.timeout(10000)
+
+ const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name: 'super video' })
+ const uuid = resVideo.body.video.uuid
+
+ const resThread = await addVideoCommentThread(servers[0].url, servers[0].accessToken, uuid, 'comment')
+ const threadId = resThread.body.comment.id
+
+ const resComment = await addVideoCommentReply(servers[0].url, servers[0].accessToken, uuid, threadId, 'reply')
+ const commentId = resComment.body.comment.id
+
+ await wait(500)
+ await checkNewCommentOnMyVideo(baseParams, uuid, commentId, threadId, 'presence')
+ })
+
+ it('Should send a new comment notification after a remote reply on my video', async function () {
+ this.timeout(10000)
+
+ const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name: 'super video' })
+ const uuid = resVideo.body.video.uuid
+ await waitJobs(servers)
+
+ const resThread = await addVideoCommentThread(servers[1].url, servers[1].accessToken, uuid, 'comment')
+ const threadId = resThread.body.comment.id
+
+ const resComment = await addVideoCommentReply(servers[1].url, servers[1].accessToken, uuid, threadId, 'reply')
+ const commentId = resComment.body.comment.id
+
+ await waitJobs(servers)
+ await checkNewCommentOnMyVideo(baseParams, uuid, commentId, threadId, 'presence')
+ })
+ })
+
+ describe('Video abuse for moderators notification' , function () {
+ let baseParams: CheckerBaseParams
+
+ before(() => {
+ baseParams = {
+ server: servers[0],
+ emails,
+ socketNotifications: adminNotifications,
+ token: servers[0].accessToken
+ }
+ })
+
+ it('Should send a notification to moderators on local video abuse', async function () {
+ this.timeout(10000)
+
+ const videoName = 'local video 110'
+
+ const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name: videoName })
+ const uuid = resVideo.body.video.uuid
+
+ await reportVideoAbuse(servers[0].url, servers[0].accessToken, uuid, 'super reason')
+
+ await waitJobs(servers)
+ await checkNewVideoAbuseForModerators(baseParams, uuid, videoName, 'presence')
+ })
+
+ it('Should send a notification to moderators on remote video abuse', async function () {
+ this.timeout(10000)
+
+ const videoName = 'remote video 120'
+
+ const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name: videoName })
+ const uuid = resVideo.body.video.uuid
+
+ await waitJobs(servers)
+
+ await reportVideoAbuse(servers[1].url, servers[1].accessToken, uuid, 'super reason')
+
+ await waitJobs(servers)
+ await checkNewVideoAbuseForModerators(baseParams, uuid, videoName, 'presence')
+ })
+ })
+
+ describe('Video blacklist on my video', function () {
+ let baseParams: CheckerBaseParams
+
+ before(() => {
+ baseParams = {
+ server: servers[0],
+ emails,
+ socketNotifications: userNotifications,
+ token: userAccessToken
+ }
+ })
+
+ it('Should send a notification to video owner on blacklist', async function () {
+ this.timeout(10000)
+
+ const videoName = 'local video 130'
+
+ const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name: videoName })
+ const uuid = resVideo.body.video.uuid
+
+ await addVideoToBlacklist(servers[0].url, servers[0].accessToken, uuid)
+
+ await waitJobs(servers)
+ await checkNewBlacklistOnMyVideo(baseParams, uuid, videoName, 'blacklist')
+ })
+
+ it('Should send a notification to video owner on unblacklist', async function () {
+ this.timeout(10000)
+
+ const videoName = 'local video 130'
+
+ const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name: videoName })
+ const uuid = resVideo.body.video.uuid
+
+ await addVideoToBlacklist(servers[0].url, servers[0].accessToken, uuid)
+
+ await waitJobs(servers)
+ await removeVideoFromBlacklist(servers[0].url, servers[0].accessToken, uuid)
+ await waitJobs(servers)
+
+ await wait(500)
+ await checkNewBlacklistOnMyVideo(baseParams, uuid, videoName, 'unblacklist')
+ })
+ })
+
+ describe('Mark as read', function () {
+ it('Should mark as read some notifications', async function () {
+ const res = await getUserNotifications(servers[0].url, userAccessToken, 2, 3)
+ const ids = res.body.data.map(n => n.id)
+
+ await markAsReadNotifications(servers[0].url, userAccessToken, ids)
+ })
+
+ it('Should have the notifications marked as read', async function () {
+ const res = await getUserNotifications(servers[0].url, userAccessToken, 0, 10)
+
+ const notifications = res.body.data as UserNotification[]
+ expect(notifications[0].read).to.be.false
+ expect(notifications[1].read).to.be.false
+ expect(notifications[2].read).to.be.true
+ expect(notifications[3].read).to.be.true
+ expect(notifications[4].read).to.be.true
+ expect(notifications[5].read).to.be.false
+ })
+ })
+
+ describe('Notification settings', function () {
+ const baseUpdateNotificationParams = {
+ newCommentOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
+ newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
+ videoAbuseAsModerator: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL,
+ blacklistOnMyVideo: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL
+ }
+ let baseParams: CheckerBaseParams
+
+ before(() => {
+ baseParams = {
+ server: servers[0],
+ emails,
+ socketNotifications: userNotifications,
+ token: userAccessToken
+ }
+ })
+
+ it('Should not have notifications', async function () {
+ await updateMyNotificationSettings(servers[0].url, userAccessToken, immutableAssign(baseUpdateNotificationParams, {
+ newVideoFromSubscription: UserNotificationSettingValue.NONE
+ }))
+
+ {
+ const res = await getMyUserInformation(servers[0].url, userAccessToken)
+ const info = res.body as User
+ expect(info.notificationSettings.newVideoFromSubscription).to.equal(UserNotificationSettingValue.NONE)
+ }
+
+ const videoNameId = 42
+ const videoName = 'local video ' + videoNameId
+ const uuid = await uploadVideoByLocalAccount(servers, videoNameId)
+
+ const check = { web: true, mail: true }
+ await checkNewVideoFromSubscription(immutableAssign(baseParams, { check }), videoName, uuid, 'absence')
+ })
+
+ it('Should only have web notifications', async function () {
+ await updateMyNotificationSettings(servers[0].url, userAccessToken, immutableAssign(baseUpdateNotificationParams, {
+ newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION
+ }))
+
+ {
+ const res = await getMyUserInformation(servers[0].url, userAccessToken)
+ const info = res.body as User
+ expect(info.notificationSettings.newVideoFromSubscription).to.equal(UserNotificationSettingValue.WEB_NOTIFICATION)
+ }
+
+ const videoNameId = 52
+ const videoName = 'local video ' + videoNameId
+ const uuid = await uploadVideoByLocalAccount(servers, videoNameId)
+
+ {
+ const check = { mail: true, web: false }
+ await checkNewVideoFromSubscription(immutableAssign(baseParams, { check }), videoName, uuid, 'absence')
+ }
+
+ {
+ const check = { mail: false, web: true }
+ await checkNewVideoFromSubscription(immutableAssign(baseParams, { check }), videoName, uuid, 'presence')
+ }
+ })
+
+ it('Should only have mail notifications', async function () {
+ await updateMyNotificationSettings(servers[0].url, userAccessToken, immutableAssign(baseUpdateNotificationParams, {
+ newVideoFromSubscription: UserNotificationSettingValue.EMAIL
+ }))
+
+ {
+ const res = await getMyUserInformation(servers[0].url, userAccessToken)
+ const info = res.body as User
+ expect(info.notificationSettings.newVideoFromSubscription).to.equal(UserNotificationSettingValue.EMAIL)
+ }
+
+ const videoNameId = 62
+ const videoName = 'local video ' + videoNameId
+ const uuid = await uploadVideoByLocalAccount(servers, videoNameId)
+
+ {
+ const check = { mail: false, web: true }
+ await checkNewVideoFromSubscription(immutableAssign(baseParams, { check }), videoName, uuid, 'absence')
+ }
+
+ {
+ const check = { mail: true, web: false }
+ await checkNewVideoFromSubscription(immutableAssign(baseParams, { check }), videoName, uuid, 'presence')
+ }
+ })
+
+ it('Should have email and web notifications', async function () {
+ await updateMyNotificationSettings(servers[0].url, userAccessToken, immutableAssign(baseUpdateNotificationParams, {
+ newVideoFromSubscription: UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL
+ }))
+
+ {
+ const res = await getMyUserInformation(servers[0].url, userAccessToken)
+ const info = res.body as User
+ expect(info.notificationSettings.newVideoFromSubscription).to.equal(UserNotificationSettingValue.WEB_NOTIFICATION_AND_EMAIL)
+ }
+
+ const videoNameId = 72
+ const videoName = 'local video ' + videoNameId
+ const uuid = await uploadVideoByLocalAccount(servers, videoNameId)
+
+ await checkNewVideoFromSubscription(baseParams, videoName, uuid, 'presence')
+ })
+ })
+
+ after(async function () {
+ killallServers(servers)
+ })
+})
export * from './user.model'
export * from './user-create.model'
export * from './user-login.model'
+export * from './user-notification.model'
+export * from './user-notification-setting.model'
export * from './user-refresh-token.model'
export * from './user-update.model'
export * from './user-update-me.model'
--- /dev/null
+export enum UserNotificationSettingValue {
+ NONE = 1,
+ WEB_NOTIFICATION = 2,
+ EMAIL = 3,
+ WEB_NOTIFICATION_AND_EMAIL = 4
+}
+
+export interface UserNotificationSetting {
+ newVideoFromSubscription: UserNotificationSettingValue
+ newCommentOnMyVideo: UserNotificationSettingValue
+ videoAbuseAsModerator: UserNotificationSettingValue
+ blacklistOnMyVideo: UserNotificationSettingValue
+}
--- /dev/null
+export enum UserNotificationType {
+ NEW_VIDEO_FROM_SUBSCRIPTION = 1,
+ NEW_COMMENT_ON_MY_VIDEO = 2,
+ NEW_VIDEO_ABUSE_FOR_MODERATORS = 3,
+ BLACKLIST_ON_MY_VIDEO = 4,
+ UNBLACKLIST_ON_MY_VIDEO = 5
+}
+
+interface VideoInfo {
+ id: number
+ uuid: string
+ name: string
+}
+
+export interface UserNotification {
+ id: number
+ type: UserNotificationType
+ read: boolean
+
+ video?: VideoInfo & {
+ channel: {
+ id: number
+ displayName: string
+ }
+ }
+
+ comment?: {
+ id: number
+ account: {
+ id: number
+ displayName: string
+ }
+ }
+
+ videoAbuse?: {
+ id: number
+ video: VideoInfo
+ }
+
+ videoBlacklist?: {
+ id: number
+ video: VideoInfo
+ }
+
+ createdAt: string
+ updatedAt: string
+}
import { VideoChannel } from '../videos/channel/video-channel.model'
import { UserRole } from './user-role'
import { NSFWPolicyType } from '../videos/nsfw-policy.type'
+import { UserNotificationSetting } from './user-notification-setting.model'
export interface User {
id: number
videoQuotaDaily: number
createdAt: Date
account: Account
+ notificationSettings?: UserNotificationSetting
videoChannels?: VideoChannel[]
blocked: boolean
else servers = serversArg as ServerInfo[]
const states: JobState[] = [ 'waiting', 'active', 'delayed' ]
- const tasks: Promise<any>[] = []
- let pendingRequests: boolean
+ let pendingRequests = false
- do {
+ function tasksBuilder () {
+ const tasks: Promise<any>[] = []
pendingRequests = false
// Check if each server has pending request
}
}
- await Promise.all(tasks)
+ return tasks
+ }
+
+ do {
+ await Promise.all(tasksBuilder())
// Retry, in case of new jobs were created
if (pendingRequests === false) {
await wait(2000)
-
- await Promise.all(tasks)
+ await Promise.all(tasksBuilder())
}
if (pendingRequests) {
--- /dev/null
+import * as io from 'socket.io-client'
+
+function getUserNotificationSocket (serverUrl: string, accessToken: string) {
+ return io(serverUrl + '/user-notifications', {
+ query: { accessToken }
+ })
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+ getUserNotificationSocket
+}
--- /dev/null
+/* tslint:disable:no-unused-expression */
+
+import { makeGetRequest, makePostBodyRequest, makePutBodyRequest } from '../requests/requests'
+import { UserNotification, UserNotificationSetting, UserNotificationType } from '../../models/users'
+import { ServerInfo } from '..'
+import { expect } from 'chai'
+
+function updateMyNotificationSettings (url: string, token: string, settings: UserNotificationSetting, statusCodeExpected = 204) {
+ const path = '/api/v1/users/me/notification-settings'
+
+ return makePutBodyRequest({
+ url,
+ path,
+ token,
+ fields: settings,
+ statusCodeExpected
+ })
+}
+
+function getUserNotifications (url: string, token: string, start: number, count: number, sort = '-createdAt', statusCodeExpected = 200) {
+ const path = '/api/v1/users/me/notifications'
+
+ return makeGetRequest({
+ url,
+ path,
+ token,
+ query: {
+ start,
+ count,
+ sort
+ },
+ statusCodeExpected
+ })
+}
+
+function markAsReadNotifications (url: string, token: string, ids: number[], statusCodeExpected = 204) {
+ const path = '/api/v1/users/me/notifications/read'
+
+ return makePostBodyRequest({
+ url,
+ path,
+ token,
+ fields: { ids },
+ statusCodeExpected
+ })
+}
+
+async function getLastNotification (serverUrl: string, accessToken: string) {
+ const res = await getUserNotifications(serverUrl, accessToken, 0, 1, '-createdAt')
+
+ if (res.body.total === 0) return undefined
+
+ return res.body.data[0] as UserNotification
+}
+
+type CheckerBaseParams = {
+ server: ServerInfo
+ emails: object[]
+ socketNotifications: UserNotification[]
+ token: string,
+ check?: { web: boolean, mail: boolean }
+}
+
+type CheckerType = 'presence' | 'absence'
+
+async function checkNotification (
+ base: CheckerBaseParams,
+ lastNotificationChecker: (notification: UserNotification) => void,
+ socketNotificationFinder: (notification: UserNotification) => boolean,
+ emailNotificationFinder: (email: object) => boolean,
+ checkType: 'presence' | 'absence'
+) {
+ const check = base.check || { web: true, mail: true }
+
+ if (check.web) {
+ const notification = await getLastNotification(base.server.url, base.token)
+ lastNotificationChecker(notification)
+
+ const socketNotification = base.socketNotifications.find(n => socketNotificationFinder(n))
+
+ if (checkType === 'presence') expect(socketNotification, 'The socket notification is absent.').to.not.be.undefined
+ else expect(socketNotification, 'The socket notification is present.').to.be.undefined
+ }
+
+ if (check.mail) {
+ // Last email
+ const email = base.emails
+ .slice()
+ .reverse()
+ .find(e => emailNotificationFinder(e))
+
+ if (checkType === 'presence') expect(email, 'The email is present.').to.not.be.undefined
+ else expect(email, 'The email is absent.').to.be.undefined
+ }
+}
+
+async function checkNewVideoFromSubscription (base: CheckerBaseParams, videoName: string, videoUUID: string, type: CheckerType) {
+ const notificationType = UserNotificationType.NEW_VIDEO_FROM_SUBSCRIPTION
+
+ function lastNotificationChecker (notification: UserNotification) {
+ if (type === 'presence') {
+ expect(notification).to.not.be.undefined
+ expect(notification.type).to.equal(notificationType)
+ expect(notification.video.name).to.equal(videoName)
+ } else {
+ expect(notification.video).to.satisfy(v => v === undefined || v.name !== videoName)
+ }
+ }
+
+ function socketFinder (notification: UserNotification) {
+ return notification.type === notificationType && notification.video.name === videoName
+ }
+
+ function emailFinder (email: object) {
+ return email[ 'text' ].indexOf(videoUUID) !== -1
+ }
+
+ await checkNotification(base, lastNotificationChecker, socketFinder, emailFinder, type)
+}
+
+let lastEmailCount = 0
+async function checkNewCommentOnMyVideo (base: CheckerBaseParams, uuid: string, commentId: number, threadId: number, type: CheckerType) {
+ const notificationType = UserNotificationType.NEW_COMMENT_ON_MY_VIDEO
+
+ function lastNotificationChecker (notification: UserNotification) {
+ if (type === 'presence') {
+ expect(notification).to.not.be.undefined
+ expect(notification.type).to.equal(notificationType)
+ expect(notification.comment.id).to.equal(commentId)
+ expect(notification.comment.account.displayName).to.equal('root')
+ } else {
+ expect(notification).to.satisfy((n: UserNotification) => {
+ return n === undefined || n.comment === undefined || n.comment.id !== commentId
+ })
+ }
+ }
+
+ function socketFinder (notification: UserNotification) {
+ return notification.type === notificationType &&
+ notification.comment.id === commentId &&
+ notification.comment.account.displayName === 'root'
+ }
+
+ const commentUrl = `http://localhost:9001/videos/watch/${uuid};threadId=${threadId}`
+ function emailFinder (email: object) {
+ return email[ 'text' ].indexOf(commentUrl) !== -1
+ }
+
+ await checkNotification(base, lastNotificationChecker, socketFinder, emailFinder, type)
+
+ if (type === 'presence') {
+ // We cannot detect email duplicates, so check we received another email
+ expect(base.emails).to.have.length.above(lastEmailCount)
+ lastEmailCount = base.emails.length
+ }
+}
+
+async function checkNewVideoAbuseForModerators (base: CheckerBaseParams, videoUUID: string, videoName: string, type: CheckerType) {
+ const notificationType = UserNotificationType.NEW_VIDEO_ABUSE_FOR_MODERATORS
+
+ function lastNotificationChecker (notification: UserNotification) {
+ if (type === 'presence') {
+ expect(notification).to.not.be.undefined
+ expect(notification.type).to.equal(notificationType)
+ expect(notification.videoAbuse.video.uuid).to.equal(videoUUID)
+ expect(notification.videoAbuse.video.name).to.equal(videoName)
+ } else {
+ expect(notification).to.satisfy((n: UserNotification) => {
+ return n === undefined || n.videoAbuse === undefined || n.videoAbuse.video.uuid !== videoUUID
+ })
+ }
+ }
+
+ function socketFinder (notification: UserNotification) {
+ return notification.type === notificationType && notification.videoAbuse.video.uuid === videoUUID
+ }
+
+ function emailFinder (email: object) {
+ const text = email[ 'text' ]
+ return text.indexOf(videoUUID) !== -1 && text.indexOf('abuse') !== -1
+ }
+
+ await checkNotification(base, lastNotificationChecker, socketFinder, emailFinder, type)
+}
+
+async function checkNewBlacklistOnMyVideo (
+ base: CheckerBaseParams,
+ videoUUID: string,
+ videoName: string,
+ blacklistType: 'blacklist' | 'unblacklist'
+) {
+ const notificationType = blacklistType === 'blacklist'
+ ? UserNotificationType.BLACKLIST_ON_MY_VIDEO
+ : UserNotificationType.UNBLACKLIST_ON_MY_VIDEO
+
+ function lastNotificationChecker (notification: UserNotification) {
+ expect(notification).to.not.be.undefined
+ expect(notification.type).to.equal(notificationType)
+
+ const video = blacklistType === 'blacklist' ? notification.videoBlacklist.video : notification.video
+
+ expect(video.uuid).to.equal(videoUUID)
+ expect(video.name).to.equal(videoName)
+ }
+
+ function socketFinder (notification: UserNotification) {
+ return notification.type === notificationType && (notification.video || notification.videoBlacklist.video).uuid === videoUUID
+ }
+
+ function emailFinder (email: object) {
+ const text = email[ 'text' ]
+ return text.indexOf(videoUUID) !== -1 && text.indexOf(' ' + blacklistType) !== -1
+ }
+
+ await checkNotification(base, lastNotificationChecker, socketFinder, emailFinder, 'presence')
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+ CheckerBaseParams,
+ CheckerType,
+ checkNotification,
+ checkNewVideoFromSubscription,
+ checkNewCommentOnMyVideo,
+ checkNewBlacklistOnMyVideo,
+ updateMyNotificationSettings,
+ checkNewVideoAbuseForModerators,
+ getUserNotifications,
+ markAsReadNotifications,
+ getLastNotification
+}
dependencies:
"@types/node" "*"
+"@types/socket.io@^2.1.2":
+ version "2.1.2"
+ resolved "https://registry.yarnpkg.com/@types/socket.io/-/socket.io-2.1.2.tgz#7165c2587cc3b86b44aa78e2a0060140551de211"
+ integrity sha512-Ind+4qMNfQ62llyB4IMs1D8znMEBsMKohZBPqfBUIXqLQ9bdtWIbNTBWwtdcBWJKnokMZGcmWOOKslatni5vtA==
+ dependencies:
+ "@types/node" "*"
+
"@types/superagent@*":
version "3.8.4"
resolved "https://registry.yarnpkg.com/@types/superagent/-/superagent-3.8.4.tgz#24a5973c7d1a9c024b4bbda742a79267c33fb86a"
mime-types "~2.1.6"
negotiator "0.5.3"
-accepts@~1.3.5:
+accepts@~1.3.4, accepts@~1.3.5:
version "1.3.5"
resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.5.tgz#eb777df6011723a3b14e8a72c0805c8e86746bd2"
integrity sha1-63d99gEXI6OxTopywIBcjoZ0a9I=
resolved "https://registry.yarnpkg.com/arraybuffer.slice/-/arraybuffer.slice-0.0.6.tgz#f33b2159f0532a3f3107a272c0ccfbd1ad2979ca"
integrity sha1-8zshWfBTKj8xB6JywMz70a0peco=
+arraybuffer.slice@~0.0.7:
+ version "0.0.7"
+ resolved "https://registry.yarnpkg.com/arraybuffer.slice/-/arraybuffer.slice-0.0.7.tgz#3bbc4275dd584cc1b10809b89d4e8b63a69e7675"
+ integrity sha512-wGUIVQXuehL5TCqQun8OW81jGzAWycqzFF8lFp+GOM5BXLYj3bKNsYC4daB7n6XjCqxQA/qgTJ+8ANR3acjrog==
+
arrify@^1.0.0, arrify@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d"
resolved "https://registry.yarnpkg.com/blob/-/blob-0.0.4.tgz#bcf13052ca54463f30f9fc7e95b9a47630a94921"
integrity sha1-vPEwUspURj8w+fx+lbmkdjCpSSE=
+blob@0.0.5:
+ version "0.0.5"
+ resolved "https://registry.yarnpkg.com/blob/-/blob-0.0.5.tgz#d680eeef25f8cd91ad533f5b01eed48e64caf683"
+ integrity sha512-gaqbzQPqOoamawKg0LGVd7SzLgXS+JH61oWprSLH+P+abTczqJbhTR8CmJ2u9/bUYNmHTGJx/UEmn6doAvvuig==
+
block-stream2@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/block-stream2/-/block-stream2-1.1.0.tgz#c738e3a91ba977ebb5e1fef431e13ca11d8639e2"
dependencies:
ms "2.0.0"
-debug@3.1.0:
+debug@3.1.0, debug@~3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261"
integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==
dependencies:
ms "^2.1.1"
+debug@~4.1.0:
+ version "4.1.1"
+ resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791"
+ integrity sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==
+ dependencies:
+ ms "^2.1.1"
+
debuglog@^1.0.0, debuglog@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/debuglog/-/debuglog-1.0.1.tgz#aa24ffb9ac3df9a2351837cfb2d279360cd78492"
xmlhttprequest-ssl "1.5.3"
yeast "0.1.2"
+engine.io-client@~3.3.1:
+ version "3.3.1"
+ resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-3.3.1.tgz#afedb4a07b2ea48b7190c3136bfea98fdd4f0f03"
+ integrity sha512-q66JBFuQcy7CSlfAz9L3jH+v7DTT3i6ZEadYcVj2pOs8/0uJHLxKX3WBkGTvULJMdz0tUCyJag0aKT/dpXL9BQ==
+ dependencies:
+ component-emitter "1.2.1"
+ component-inherit "0.0.3"
+ debug "~3.1.0"
+ engine.io-parser "~2.1.1"
+ has-cors "1.1.0"
+ indexof "0.0.1"
+ parseqs "0.0.5"
+ parseuri "0.0.5"
+ ws "~6.1.0"
+ xmlhttprequest-ssl "~1.5.4"
+ yeast "0.1.2"
+
engine.io-parser@1.3.2:
version "1.3.2"
resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-1.3.2.tgz#937b079f0007d0893ec56d46cb220b8cb435220a"
has-binary "0.1.7"
wtf-8 "1.0.0"
+engine.io-parser@~2.1.0, engine.io-parser@~2.1.1:
+ version "2.1.3"
+ resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-2.1.3.tgz#757ab970fbf2dfb32c7b74b033216d5739ef79a6"
+ integrity sha512-6HXPre2O4Houl7c4g7Ic/XzPnHBvaEmN90vtRO9uLmwtRqQmTOw0QMevL1TOfL2Cpu1VzsaTmMotQgMdkzGkVA==
+ dependencies:
+ after "0.8.2"
+ arraybuffer.slice "~0.0.7"
+ base64-arraybuffer "0.1.5"
+ blob "0.0.5"
+ has-binary2 "~1.0.2"
+
engine.io@1.8.3:
version "1.8.3"
resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-1.8.3.tgz#8de7f97895d20d39b85f88eeee777b2bd42b13d4"
engine.io-parser "1.3.2"
ws "1.1.2"
+engine.io@~3.3.1:
+ version "3.3.2"
+ resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-3.3.2.tgz#18cbc8b6f36e9461c5c0f81df2b830de16058a59"
+ integrity sha512-AsaA9KG7cWPXWHp5FvHdDWY3AMWeZ8x+2pUVLcn71qE5AtAzgGbxuclOytygskw8XGmiQafTmnI9Bix3uihu2w==
+ dependencies:
+ accepts "~1.3.4"
+ base64id "1.0.0"
+ cookie "0.3.1"
+ debug "~3.1.0"
+ engine.io-parser "~2.1.0"
+ ws "~6.1.0"
+
env-variable@0.0.x:
version "0.0.5"
resolved "https://registry.yarnpkg.com/env-variable/-/env-variable-0.0.5.tgz#913dd830bef11e96a039c038d4130604eba37f88"
dependencies:
ansi-regex "^2.0.0"
+has-binary2@~1.0.2:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/has-binary2/-/has-binary2-1.0.3.tgz#7776ac627f3ea77250cfc332dab7ddf5e4f5d11d"
+ integrity sha512-G1LWKhDSvhGeAQ8mPVQlqNcOB2sJdwATtZKl2pDKKHfpf/rYj24lkinxf69blJbnsvtqqNU+L3SL50vzZhXOnw==
+ dependencies:
+ isarray "2.0.1"
+
has-binary@0.1.7:
version "0.1.7"
resolved "https://registry.yarnpkg.com/has-binary/-/has-binary-0.1.7.tgz#68e61eb16210c9545a0a5cce06a873912fe1e68c"
resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=
+isarray@2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.1.tgz#a37d94ed9cda2d59865c9f76fe596ee1f338741e"
+ integrity sha1-o32U7ZzaLVmGXJ92/llu4fM4dB4=
+
isexe@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
debug "2.3.3"
socket.io-parser "2.3.1"
+socket.io-adapter@~1.1.0:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-1.1.1.tgz#2a805e8a14d6372124dd9159ad4502f8cb07f06b"
+ integrity sha1-KoBeihTWNyEk3ZFZrUUC+MsH8Gs=
+
socket.io-client@1.7.3:
version "1.7.3"
resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-1.7.3.tgz#b30e86aa10d5ef3546601c09cde4765e381da377"
socket.io-parser "2.3.1"
to-array "0.1.4"
+socket.io-client@2.2.0:
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-2.2.0.tgz#84e73ee3c43d5020ccc1a258faeeb9aec2723af7"
+ integrity sha512-56ZrkTDbdTLmBIyfFYesgOxsjcLnwAKoN4CiPyTVkMQj3zTUh0QAx3GbvIvLpFEOvQWu92yyWICxB0u7wkVbYA==
+ dependencies:
+ backo2 "1.0.2"
+ base64-arraybuffer "0.1.5"
+ component-bind "1.0.0"
+ component-emitter "1.2.1"
+ debug "~3.1.0"
+ engine.io-client "~3.3.1"
+ has-binary2 "~1.0.2"
+ has-cors "1.1.0"
+ indexof "0.0.1"
+ object-component "0.0.3"
+ parseqs "0.0.5"
+ parseuri "0.0.5"
+ socket.io-parser "~3.3.0"
+ to-array "0.1.4"
+
socket.io-parser@2.3.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-2.3.1.tgz#dd532025103ce429697326befd64005fcfe5b4a0"
isarray "0.0.1"
json3 "3.3.2"
+socket.io-parser@~3.3.0:
+ version "3.3.0"
+ resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-3.3.0.tgz#2b52a96a509fdf31440ba40fed6094c7d4f1262f"
+ integrity sha512-hczmV6bDgdaEbVqhAeVMM/jfUfzuEZHsQg6eOmLgJht6G3mPKMxYm75w2+qhAQZ+4X+1+ATZ+QFKeOZD5riHng==
+ dependencies:
+ component-emitter "1.2.1"
+ debug "~3.1.0"
+ isarray "2.0.1"
+
socket.io@1.7.3:
version "1.7.3"
resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-1.7.3.tgz#b8af9caba00949e568e369f1327ea9be9ea2461b"
socket.io-client "1.7.3"
socket.io-parser "2.3.1"
+socket.io@^2.2.0:
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-2.2.0.tgz#f0f633161ef6712c972b307598ecd08c9b1b4d5b"
+ integrity sha512-wxXrIuZ8AILcn+f1B4ez4hJTPG24iNgxBBDaJfT6MsyOhVYiTXWexGoPkd87ktJG8kQEcL/NBvRi64+9k4Kc0w==
+ dependencies:
+ debug "~4.1.0"
+ engine.io "~3.3.1"
+ has-binary2 "~1.0.2"
+ socket.io-adapter "~1.1.0"
+ socket.io-client "2.2.0"
+ socket.io-parser "~3.3.0"
+
socks-proxy-agent@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/socks-proxy-agent/-/socks-proxy-agent-3.0.1.tgz#2eae7cf8e2a82d34565761539a7f9718c5617659"
options ">=0.0.5"
ultron "1.0.x"
-ws@^6.0.0:
+ws@^6.0.0, ws@~6.1.0:
version "6.1.2"
resolved "https://registry.yarnpkg.com/ws/-/ws-6.1.2.tgz#3cc7462e98792f0ac679424148903ded3b9c3ad8"
integrity sha512-rfUqzvz0WxmSXtJpPMX2EeASXabOrSMk1ruMOV3JBTBjo4ac2lDjGGsbQSyxj8Odhw5fBib8ZKEjDNvgouNKYw==
resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.3.tgz#185a888c04eca46c3e4070d99f7b49de3528992d"
integrity sha1-GFqIjATspGw+QHDZn3tJ3jUomS0=
+xmlhttprequest-ssl@~1.5.4:
+ version "1.5.5"
+ resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.5.tgz#c2876b06168aadc40e57d97e81191ac8f4398b3e"
+ integrity sha1-wodrBhaKrcQOV9l+gRkayPQ5iz4=
+
"xtend@>=4.0.0 <4.1.0-0", xtend@^4.0.0, xtend@^4.0.1, xtend@~4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af"