Federate video views
authorChocobozzz <florian.bigard@gmail.com>
Wed, 22 Nov 2017 15:25:03 +0000 (16:25 +0100)
committerChocobozzz <florian.bigard@gmail.com>
Mon, 27 Nov 2017 18:40:53 +0000 (19:40 +0100)
19 files changed:
server/controllers/activitypub/outbox.ts
server/controllers/api/server/follows.ts
server/controllers/api/videos/index.ts
server/helpers/custom-validators/activitypub/activity.ts
server/helpers/custom-validators/activitypub/index.ts
server/helpers/custom-validators/activitypub/videos.ts
server/helpers/custom-validators/activitypub/view.ts [new file with mode: 0644]
server/lib/activitypub/process/misc.ts
server/lib/activitypub/process/process-create.ts
server/lib/activitypub/process/process-follow.ts
server/lib/activitypub/send/misc.ts
server/lib/activitypub/send/send-create.ts
server/lib/activitypub/url.ts
server/lib/jobs/activitypub-http-job-scheduler/activitypub-http-fetcher-handler.ts
server/models/video/video-interface.ts
server/models/video/video.ts
shared/models/activitypub/activity.ts
shared/models/activitypub/objects/index.ts
shared/models/activitypub/objects/view-object.ts [new file with mode: 0644]

index 74d3997631a7e0d63b5f9b1ca90c3b459f40b519..8c63eeb2ec8ed02b57955fb017fd8aa4c2978c37 100644 (file)
@@ -1,14 +1,14 @@
 import * as express from 'express'
 import { Activity, ActivityAdd } from '../../../shared/models/activitypub/activity'
-import { activityPubCollectionPagination, activityPubContextify } from '../../helpers/activitypub'
+import { activityPubCollectionPagination } from '../../helpers/activitypub'
+import { pageToStartAndCount } from '../../helpers/core-utils'
 import { database as db } from '../../initializers'
+import { ACTIVITY_PUB } from '../../initializers/constants'
 import { addActivityData } from '../../lib/activitypub/send/send-add'
 import { getAnnounceActivityPubUrl } from '../../lib/activitypub/url'
 import { announceActivityData } from '../../lib/index'
 import { asyncMiddleware, localAccountValidator } from '../../middlewares'
 import { AccountInstance } from '../../models/account/account-interface'
-import { pageToStartAndCount } from '../../helpers/core-utils'
-import { ACTIVITY_PUB } from '../../initializers/constants'
 
 const outboxRouter = express.Router()
 
@@ -36,14 +36,18 @@ async function outboxController (req: express.Request, res: express.Response, ne
 
   for (const video of data.data) {
     const videoObject = video.toActivityPubObject()
-    let addActivity: ActivityAdd = await addActivityData(video.url, account, video, video.VideoChannel.url, videoObject)
 
     // This is a shared video
-    if (video.VideoShare !== undefined) {
+    if (video.VideoShares !== undefined && video.VideoShares.length !== 0) {
+      const addActivity = await addActivityData(video.url, video.VideoChannel.Account, video, video.VideoChannel.url, videoObject)
+
       const url = getAnnounceActivityPubUrl(video.url, account)
       const announceActivity = await announceActivityData(url, account, addActivity)
+
       activities.push(announceActivity)
     } else {
+      const addActivity = await addActivityData(video.url, account, video, video.VideoChannel.url, videoObject)
+
       activities.push(addActivity)
     }
   }
index 391f8bdcad5cea5760f0c773a40a6fef52250206..535d530f75a4cbbd7c52f6a001c2696bbb02d407 100644 (file)
@@ -148,10 +148,17 @@ async function removeFollow (req: express.Request, res: express.Response, next:
   const follow: AccountFollowInstance = res.locals.follow
 
   await db.sequelize.transaction(async t => {
-    await sendUndoFollow(follow, t)
+    if (follow.state === 'accepted') await sendUndoFollow(follow, t)
+
     await follow.destroy({ transaction: t })
   })
 
+  // Destroy the account that will destroy video channels, videos and video files too
+  // This could be long so don't wait this task
+  const following = follow.AccountFollowing
+  following.destroy()
+    .catch(err => logger.error('Cannot destroy account that we do not follow anymore %s.', following.url, err))
+
   return res.status(204).end()
 }
 
index 0d114dcd2d37fcb983299faff9824ad96675fdfe..2b5afd632d1f3e92ecfd70972d8db1844bb5affa 100644 (file)
@@ -11,10 +11,15 @@ import {
   resetSequelizeInstance,
   retryTransactionWrapper
 } from '../../../helpers'
+import { getServerAccount } from '../../../helpers/utils'
 import { CONFIG, VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES, VIDEO_MIMETYPE_EXT, VIDEO_PRIVACIES } from '../../../initializers'
 import { database as db } from '../../../initializers/database'
 import { sendAddVideo } from '../../../lib/activitypub/send/send-add'
 import { sendUpdateVideo } from '../../../lib/activitypub/send/send-update'
+import { shareVideoByServer } from '../../../lib/activitypub/share'
+import { getVideoActivityPubUrl } from '../../../lib/activitypub/url'
+import { fetchRemoteVideoDescription } from '../../../lib/activitypub/videos'
+import { sendCreateViewToVideoFollowers } from '../../../lib/index'
 import { transcodingJobScheduler } from '../../../lib/jobs/transcoding-job-scheduler/transcoding-job-scheduler'
 import {
   asyncMiddleware,
@@ -35,9 +40,7 @@ import { abuseVideoRouter } from './abuse'
 import { blacklistRouter } from './blacklist'
 import { videoChannelRouter } from './channel'
 import { rateVideoRouter } from './rate'
-import { getVideoActivityPubUrl } from '../../../lib/activitypub/url'
-import { shareVideoByServer } from '../../../lib/activitypub/share'
-import { fetchRemoteVideoDescription } from '../../../lib/activitypub/videos'
+import { sendCreateViewToOrigin } from '../../../lib/activitypub/send/send-create'
 
 const videosRouter = express.Router()
 
@@ -311,17 +314,18 @@ async function updateVideo (req: express.Request, res: express.Response) {
 async function getVideo (req: express.Request, res: express.Response) {
   const videoInstance = res.locals.video
 
+  const baseIncrementPromise = videoInstance.increment('views')
+    .then(() => getServerAccount())
+
   if (videoInstance.isOwned()) {
     // The increment is done directly in the database, not using the instance value
-    // FIXME: make a real view system
-    // For example, only add a view when a user watch a video during 30s etc
-    videoInstance.increment('views')
-      .then(() => {
-        // TODO: send to followers a notification
-      })
-      .catch(err => logger.error('Cannot add view to video %s.', videoInstance.uuid, err))
+    baseIncrementPromise
+      .then(serverAccount => sendCreateViewToVideoFollowers(serverAccount, videoInstance, undefined))
+      .catch(err => logger.error('Cannot add view to video/send view to followers for %s.', videoInstance.uuid, err))
   } else {
-    // TODO: send view event to followers
+    baseIncrementPromise
+      .then(serverAccount => sendCreateViewToOrigin(serverAccount, videoInstance, undefined))
+      .catch(err => logger.error('Cannot send view to origin server for %s.', videoInstance.uuid, err))
   }
 
   // Do not wait the view system
index 9305e092c91a7bbd43fd730dbc7a1eab9db98b8f..66e557d39cbf164641d3f2a05e4c05bcccf86926 100644 (file)
@@ -11,6 +11,7 @@ import {
   isVideoTorrentDeleteActivityValid,
   isVideoTorrentUpdateActivityValid
 } from './videos'
+import { isViewActivityValid } from './view'
 
 function isRootActivityValid (activity: any) {
   return Array.isArray(activity['@context']) &&
@@ -55,7 +56,8 @@ export {
 
 function checkCreateActivity (activity: any) {
   return isVideoChannelCreateActivityValid(activity) ||
-    isVideoFlagValid(activity)
+    isVideoFlagValid(activity) ||
+    isViewActivityValid(activity)
 }
 
 function checkAddActivity (activity: any) {
index 6685b269fabedc634401f84c009273df642dee33..f8dfae4ffc32936440a2f80e0631008425c404b4 100644 (file)
@@ -5,3 +5,4 @@ export * from './signature'
 export * from './undo'
 export * from './video-channels'
 export * from './videos'
+export * from './view'
index faeedd3dfded34954066af0ba4c446ab1c2d130a..55e79c4e8419de5ca27230f8a0127ff37b1d00a8 100644 (file)
@@ -52,7 +52,7 @@ function isVideoTorrentObjectValid (video: any) {
     setValidRemoteTags(video) &&
     isRemoteIdentifierValid(video.category) &&
     isRemoteIdentifierValid(video.licence) &&
-    isRemoteIdentifierValid(video.language) &&
+    (!video.language || isRemoteIdentifierValid(video.language)) &&
     isVideoViewsValid(video.views) &&
     isVideoNSFWValid(video.nsfw) &&
     isDateValid(video.published) &&
diff --git a/server/helpers/custom-validators/activitypub/view.ts b/server/helpers/custom-validators/activitypub/view.ts
new file mode 100644 (file)
index 0000000..7a3aca6
--- /dev/null
@@ -0,0 +1,13 @@
+import { isActivityPubUrlValid, isBaseActivityValid } from './misc'
+
+function isViewActivityValid (activity: any) {
+  return isBaseActivityValid(activity, 'Create') &&
+    activity.object.type === 'View' &&
+    isActivityPubUrlValid(activity.object.actor) &&
+    isActivityPubUrlValid(activity.object.object)
+}
+// ---------------------------------------------------------------------------
+
+export {
+  isViewActivityValid
+}
index e90a793fc72084942f07ad9fb703bcd28a567202..eefbe28842c429dcea971c79feea0914f973487d 100644 (file)
@@ -33,13 +33,18 @@ async function videoActivityObjectToDBAttributes (
   else if (cc.indexOf(ACTIVITY_PUB.PUBLIC) !== -1) privacy = VideoPrivacy.UNLISTED
 
   const duration = videoObject.duration.replace(/[^\d]+/, '')
+  let language = null
+  if (videoObject.language) {
+    language = parseInt(videoObject.language.identifier, 10)
+  }
+
   const videoData: VideoAttributes = {
     name: videoObject.name,
     uuid: videoObject.uuid,
     url: videoObject.id,
     category: parseInt(videoObject.category.identifier, 10),
     licence: parseInt(videoObject.licence.identifier, 10),
-    language: parseInt(videoObject.language.identifier, 10),
+    language,
     nsfw: videoObject.nsfw,
     description: videoObject.content,
     channelId: videoChannel.id,
index ddf7c74f6ce7f07b2c1f6b32a6327e480d262039..1777733a07dc698e379f9a39e4519a27d315d116 100644 (file)
@@ -1,9 +1,11 @@
 import { ActivityCreate, VideoChannelObject } from '../../../../shared'
 import { VideoAbuseObject } from '../../../../shared/models/activitypub/objects/video-abuse-object'
+import { ViewObject } from '../../../../shared/models/activitypub/objects/view-object'
 import { logger, retryTransactionWrapper } from '../../../helpers'
 import { database as db } from '../../../initializers'
 import { AccountInstance } from '../../../models/account/account-interface'
 import { getOrCreateAccountAndServer } from '../account'
+import { sendCreateViewToVideoFollowers } from '../send/send-create'
 import { getVideoChannelActivityPubUrl } from '../url'
 import { videoChannelActivityObjectToDBAttributes } from './misc'
 
@@ -12,7 +14,9 @@ async function processCreateActivity (activity: ActivityCreate) {
   const activityType = activityObject.type
   const account = await getOrCreateAccountAndServer(activity.actor)
 
-  if (activityType === 'VideoChannel') {
+  if (activityType === 'View') {
+    return processCreateView(activityObject as ViewObject)
+  } else if (activityType === 'VideoChannel') {
     return processCreateVideoChannel(account, activityObject as VideoChannelObject)
   } else if (activityType === 'Flag') {
     return processCreateVideoAbuse(account, activityObject as VideoAbuseObject)
@@ -30,6 +34,19 @@ export {
 
 // ---------------------------------------------------------------------------
 
+async function processCreateView (view: ViewObject) {
+  const video = await db.Video.loadByUrlAndPopulateAccount(view.object)
+
+  if (!video) throw new Error('Unknown video ' + view.object)
+
+  const account = await db.Account.loadByUrl(view.actor)
+  if (!account) throw new Error('Unknown account ' + view.actor)
+
+  await video.increment('views')
+
+  if (video.isOwned()) await sendCreateViewToVideoFollowers(account, video, undefined)
+}
+
 function processCreateVideoChannel (account: AccountInstance, videoChannelToCreateData: VideoChannelObject) {
   const options = {
     arguments: [ account, videoChannelToCreateData ],
index 24800422655022151228e0745169d8762e3f96a0..320dc113814f74757c52f6e5fa753bf8f291ead1 100644 (file)
@@ -49,6 +49,12 @@ async function follow (account: AccountInstance, targetAccountURL: string) {
       },
       transaction: t
     })
+
+    if (accountFollow.state !== 'accepted') {
+      accountFollow.state = 'accepted'
+      await accountFollow.save({ transaction: t })
+    }
+
     accountFollow.AccountFollower = account
     accountFollow.AccountFollowing = targetAccount
 
index bea955b67ac3712e0ce7b70b9851a8d9403ee0fb..f3dc5c148e59dc94fffe76f554b006d0ed215711 100644 (file)
@@ -4,16 +4,26 @@ import { ACTIVITY_PUB, database as db } from '../../../initializers'
 import { AccountInstance } from '../../../models/account/account-interface'
 import { activitypubHttpJobScheduler } from '../../jobs/activitypub-http-job-scheduler/activitypub-http-job-scheduler'
 
-async function broadcastToFollowers (data: any, byAccount: AccountInstance, toAccountFollowers: AccountInstance[], t: Transaction) {
+async function broadcastToFollowers (
+  data: any,
+  byAccount: AccountInstance,
+  toAccountFollowers: AccountInstance[],
+  t: Transaction,
+  followersException: AccountInstance[] = []
+) {
   const toAccountFollowerIds = toAccountFollowers.map(a => a.id)
+
   const result = await db.AccountFollow.listAcceptedFollowerSharedInboxUrls(toAccountFollowerIds)
   if (result.data.length === 0) {
     logger.info('Not broadcast because of 0 followers for %s.', toAccountFollowerIds.join(', '))
     return undefined
   }
 
+  const followersSharedInboxException = followersException.map(f => f.sharedInboxUrl)
+  const uris = result.data.filter(sharedInbox => followersSharedInboxException.indexOf(sharedInbox) === -1)
+
   const jobPayload = {
-    uris: result.data,
+    uris,
     signatureAccountId: byAccount.id,
     body: data
   }
index df8e0a64211021b7c2b2a746e1b2236b2efdfe1a..e5fb212b741f0e4bba0a1b815186b02d18f274d0 100644 (file)
@@ -3,7 +3,9 @@ import { ActivityCreate } from '../../../../shared/models/activitypub/activity'
 import { AccountInstance, VideoChannelInstance, VideoInstance } from '../../../models'
 import { VideoAbuseInstance } from '../../../models/video/video-abuse-interface'
 import { broadcastToFollowers, getAudience, unicastTo } from './misc'
-import { getVideoAbuseActivityPubUrl } from '../url'
+import { getVideoAbuseActivityPubUrl, getVideoViewActivityPubUrl } from '../url'
+import { getServerAccount } from '../../../helpers/utils'
+import { database as db } from '../../../initializers'
 
 async function sendCreateVideoChannel (videoChannel: VideoChannelInstance, t: Transaction) {
   const byAccount = videoChannel.Account
@@ -16,21 +18,53 @@ async function sendCreateVideoChannel (videoChannel: VideoChannelInstance, t: Tr
 
 async function sendVideoAbuse (byAccount: AccountInstance, videoAbuse: VideoAbuseInstance, video: VideoInstance, t: Transaction) {
   const url = getVideoAbuseActivityPubUrl(videoAbuse)
-  const data = await createActivityData(url, byAccount, videoAbuse.toActivityPubObject())
+
+  const audience = { to: [ video.VideoChannel.Account.url ], cc: [] }
+  const data = await createActivityData(url, byAccount, videoAbuse.toActivityPubObject(), audience)
+
+  return unicastTo(data, byAccount, video.VideoChannel.Account.sharedInboxUrl, t)
+}
+
+async function sendCreateViewToOrigin (byAccount: AccountInstance, video: VideoInstance, t: Transaction) {
+  const url = getVideoViewActivityPubUrl(byAccount, video)
+  const viewActivity = createViewActivityData(byAccount, video)
+
+  const audience = { to: [ video.VideoChannel.Account.url ], cc: [ video.VideoChannel.Account.url + '/followers' ] }
+  const data = await createActivityData(url, byAccount, viewActivity, audience)
 
   return unicastTo(data, byAccount, video.VideoChannel.Account.sharedInboxUrl, t)
 }
 
-// async function sendCreateView ()
+async function sendCreateViewToVideoFollowers (byAccount: AccountInstance, video: VideoInstance, t: Transaction) {
+  const url = getVideoViewActivityPubUrl(byAccount, video)
+  const viewActivity = createViewActivityData(byAccount, video)
+
+  const audience = { to: [ video.VideoChannel.Account.url + '/followers' ], cc: [] }
+  const data = await createActivityData(url, byAccount, viewActivity, audience)
+
+  const serverAccount = await getServerAccount()
+  const accountsToForwardView = await db.VideoShare.loadAccountsByShare(video.id)
+  accountsToForwardView.push(video.VideoChannel.Account)
+
+  // Don't forward view to server that sent it to us
+  const index = accountsToForwardView.findIndex(a => a.id === byAccount.id)
+  if (index) accountsToForwardView.splice(index, 1)
+
+  const followersException = [ byAccount ]
+  return broadcastToFollowers(data, serverAccount, accountsToForwardView, t, followersException)
+}
+
+async function createActivityData (url: string, byAccount: AccountInstance, object: any, audience?: { to: string[], cc: string[] }) {
+  if (!audience) {
+    audience = await getAudience(byAccount)
+  }
 
-async function createActivityData (url: string, byAccount: AccountInstance, object: any) {
-  const { to, cc } = await getAudience(byAccount)
   const activity: ActivityCreate = {
     type: 'Create',
     id: url,
     actor: byAccount.url,
-    to,
-    cc,
+    to: audience.to,
+    cc: audience.cc,
     object
   }
 
@@ -42,5 +76,19 @@ async function createActivityData (url: string, byAccount: AccountInstance, obje
 export {
   sendCreateVideoChannel,
   sendVideoAbuse,
-  createActivityData
+  createActivityData,
+  sendCreateViewToOrigin,
+  sendCreateViewToVideoFollowers
+}
+
+// ---------------------------------------------------------------------------
+
+function createViewActivityData (byAccount: AccountInstance, video: VideoInstance) {
+  const obj = {
+    type: 'View',
+    actor: byAccount.url,
+    object: video.url
+  }
+
+  return obj
 }
index 41ac0f9a8c0e9ecc41dc6278614b9cdb86639079..d98561e3328874119c325a5ccb26bcc04885a624 100644 (file)
@@ -21,6 +21,10 @@ function getVideoAbuseActivityPubUrl (videoAbuse: VideoAbuseInstance) {
   return CONFIG.WEBSERVER.URL + '/admin/video-abuses/' + videoAbuse.id
 }
 
+function getVideoViewActivityPubUrl (byAccount: AccountInstance, video: VideoInstance) {
+  return video.url + '#views/' + byAccount.uuid + '/' + new Date().toISOString()
+}
+
 function getAccountFollowActivityPubUrl (accountFollow: AccountFollowInstance) {
   const me = accountFollow.AccountFollower
   const following = accountFollow.AccountFollowing
@@ -56,5 +60,6 @@ export {
   getAccountFollowAcceptActivityPubUrl,
   getAnnounceActivityPubUrl,
   getUpdateActivityPubUrl,
-  getUndoActivityPubUrl
+  getUndoActivityPubUrl,
+  getVideoViewActivityPubUrl
 }
index 09efaa622ccc39cb49220984a7d91f6c14069c36..bda3195924064cf4e8a644926a93f386f7e9c05e 100644 (file)
@@ -1,10 +1,8 @@
 import { logger } from '../../../helpers'
-import { buildSignedActivity } from '../../../helpers/activitypub'
 import { doRequest } from '../../../helpers/requests'
-import { database as db } from '../../../initializers'
-import { ActivityPubHttpPayload } from './activitypub-http-job-scheduler'
-import { processActivities } from '../../activitypub/process/process'
 import { ACTIVITY_PUB } from '../../../initializers/constants'
+import { processActivities } from '../../activitypub/process/process'
+import { ActivityPubHttpPayload } from './activitypub-http-job-scheduler'
 
 async function process (payload: ActivityPubHttpPayload, jobId: number) {
   logger.info('Processing ActivityPub fetcher in job %d.', jobId)
index 391ecff4390c2b7525fb8e29fbe2a1ea43d52707..b97f163ab0d796ae2e931853231cfae6b4a7941c 100644 (file)
@@ -122,7 +122,7 @@ export interface VideoAttributes {
   VideoChannel?: VideoChannelInstance
   Tags?: TagInstance[]
   VideoFiles?: VideoFileInstance[]
-  VideoShare?: VideoShareInstance
+  VideoShares?: VideoShareInstance[]
 }
 
 export interface VideoInstance extends VideoClass, VideoAttributes, Sequelize.Instance<VideoAttributes> {
index 9b411a92ec117e7dd371b88fc9280246f626f166..052fc0ae8e493f1de3b29cb2f0bef1a425f1d47c 100644 (file)
@@ -567,6 +567,14 @@ toActivityPubObject = function (this: VideoInstance) {
     name: t.name
   }))
 
+  let language
+  if (this.language) {
+    language = {
+      identifier: this.language + '',
+      name: this.getLanguageLabel()
+    }
+  }
+
   const url = []
   for (const file of this.VideoFiles) {
     url.push({
@@ -608,10 +616,7 @@ toActivityPubObject = function (this: VideoInstance) {
       identifier: this.licence + '',
       name: this.getLicenceLabel()
     },
-    language: {
-      identifier: this.language + '',
-      name: this.getLanguageLabel()
-    },
+    language,
     views: this.views,
     nsfw: this.nsfw,
     published: this.createdAt.toISOString(),
@@ -816,7 +821,19 @@ listAllAndSharedByAccountForOutbox = function (accountId: number, start: number,
     include: [
       {
         model: Video['sequelize'].models.VideoShare,
-        required: false
+        required: false,
+        where: {
+          [Sequelize.Op.and]: [
+            {
+              id: {
+                [Sequelize.Op.not]: null
+              }
+            },
+            {
+              accountId
+            }
+          ]
+        }
       },
       {
         model: Video['sequelize'].models.VideoChannel,
index 3d035d7d7b4291d15e5d1c09c2421adce19e133d..ce150bc12dab2556bc62c3a57b00f08eba71c5a3 100644 (file)
@@ -1,6 +1,7 @@
-import { VideoChannelObject, VideoTorrentObject } from './objects'
 import { ActivityPubSignature } from './activitypub-signature'
+import { VideoChannelObject, VideoTorrentObject } from './objects'
 import { VideoAbuseObject } from './objects/video-abuse-object'
+import { ViewObject } from './objects/view-object'
 
 export type Activity = ActivityCreate | ActivityAdd | ActivityUpdate |
   ActivityDelete | ActivityFollow | ActivityAccept | ActivityAnnounce |
@@ -20,7 +21,7 @@ export interface BaseActivity {
 
 export interface ActivityCreate extends BaseActivity {
   type: 'Create'
-  object: VideoChannelObject | VideoAbuseObject
+  object: VideoChannelObject | VideoAbuseObject | ViewObject
 }
 
 export interface ActivityAdd extends BaseActivity {
index cd772b28def73367e022d1338930c0d01fccdc1b..d92f772e29a3e3d8c0effce97eeaa9a3a7dca42b 100644 (file)
@@ -2,3 +2,4 @@ export * from './common-objects'
 export * from './video-abuse-object'
 export * from './video-channel-object'
 export * from './video-torrent-object'
+export * from './view-object'
diff --git a/shared/models/activitypub/objects/view-object.ts b/shared/models/activitypub/objects/view-object.ts
new file mode 100644 (file)
index 0000000..0034811
--- /dev/null
@@ -0,0 +1,5 @@
+export interface ViewObject {
+  type: 'View',
+  actor: string
+  object: string
+}