From 40ff57078e15d5b86ee6b71e198b95d3feb78eaf Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Wed, 22 Nov 2017 16:25:03 +0100 Subject: [PATCH] Federate video views --- server/controllers/activitypub/outbox.ts | 14 ++-- server/controllers/api/server/follows.ts | 9 ++- server/controllers/api/videos/index.ts | 26 ++++---- .../custom-validators/activitypub/activity.ts | 4 +- .../custom-validators/activitypub/index.ts | 1 + .../custom-validators/activitypub/videos.ts | 2 +- .../custom-validators/activitypub/view.ts | 13 ++++ server/lib/activitypub/process/misc.ts | 7 +- .../lib/activitypub/process/process-create.ts | 19 +++++- .../lib/activitypub/process/process-follow.ts | 6 ++ server/lib/activitypub/send/misc.ts | 14 +++- server/lib/activitypub/send/send-create.ts | 64 ++++++++++++++++--- server/lib/activitypub/url.ts | 7 +- .../activitypub-http-fetcher-handler.ts | 6 +- server/models/video/video-interface.ts | 2 +- server/models/video/video.ts | 27 ++++++-- shared/models/activitypub/activity.ts | 5 +- shared/models/activitypub/objects/index.ts | 1 + .../models/activitypub/objects/view-object.ts | 5 ++ 19 files changed, 188 insertions(+), 44 deletions(-) create mode 100644 server/helpers/custom-validators/activitypub/view.ts create mode 100644 shared/models/activitypub/objects/view-object.ts diff --git a/server/controllers/activitypub/outbox.ts b/server/controllers/activitypub/outbox.ts index 74d399763..8c63eeb2e 100644 --- a/server/controllers/activitypub/outbox.ts +++ b/server/controllers/activitypub/outbox.ts @@ -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) } } diff --git a/server/controllers/api/server/follows.ts b/server/controllers/api/server/follows.ts index 391f8bdca..535d530f7 100644 --- a/server/controllers/api/server/follows.ts +++ b/server/controllers/api/server/follows.ts @@ -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() } diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts index 0d114dcd2..2b5afd632 100644 --- a/server/controllers/api/videos/index.ts +++ b/server/controllers/api/videos/index.ts @@ -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 diff --git a/server/helpers/custom-validators/activitypub/activity.ts b/server/helpers/custom-validators/activitypub/activity.ts index 9305e092c..66e557d39 100644 --- a/server/helpers/custom-validators/activitypub/activity.ts +++ b/server/helpers/custom-validators/activitypub/activity.ts @@ -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) { diff --git a/server/helpers/custom-validators/activitypub/index.ts b/server/helpers/custom-validators/activitypub/index.ts index 6685b269f..f8dfae4ff 100644 --- a/server/helpers/custom-validators/activitypub/index.ts +++ b/server/helpers/custom-validators/activitypub/index.ts @@ -5,3 +5,4 @@ export * from './signature' export * from './undo' export * from './video-channels' export * from './videos' +export * from './view' diff --git a/server/helpers/custom-validators/activitypub/videos.ts b/server/helpers/custom-validators/activitypub/videos.ts index faeedd3df..55e79c4e8 100644 --- a/server/helpers/custom-validators/activitypub/videos.ts +++ b/server/helpers/custom-validators/activitypub/videos.ts @@ -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 index 000000000..7a3aca6f5 --- /dev/null +++ b/server/helpers/custom-validators/activitypub/view.ts @@ -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 +} diff --git a/server/lib/activitypub/process/misc.ts b/server/lib/activitypub/process/misc.ts index e90a793fc..eefbe2884 100644 --- a/server/lib/activitypub/process/misc.ts +++ b/server/lib/activitypub/process/misc.ts @@ -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, diff --git a/server/lib/activitypub/process/process-create.ts b/server/lib/activitypub/process/process-create.ts index ddf7c74f6..1777733a0 100644 --- a/server/lib/activitypub/process/process-create.ts +++ b/server/lib/activitypub/process/process-create.ts @@ -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 ], diff --git a/server/lib/activitypub/process/process-follow.ts b/server/lib/activitypub/process/process-follow.ts index 248004226..320dc1138 100644 --- a/server/lib/activitypub/process/process-follow.ts +++ b/server/lib/activitypub/process/process-follow.ts @@ -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 diff --git a/server/lib/activitypub/send/misc.ts b/server/lib/activitypub/send/misc.ts index bea955b67..f3dc5c148 100644 --- a/server/lib/activitypub/send/misc.ts +++ b/server/lib/activitypub/send/misc.ts @@ -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 } diff --git a/server/lib/activitypub/send/send-create.ts b/server/lib/activitypub/send/send-create.ts index df8e0a642..e5fb212b7 100644 --- a/server/lib/activitypub/send/send-create.ts +++ b/server/lib/activitypub/send/send-create.ts @@ -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 } diff --git a/server/lib/activitypub/url.ts b/server/lib/activitypub/url.ts index 41ac0f9a8..d98561e33 100644 --- a/server/lib/activitypub/url.ts +++ b/server/lib/activitypub/url.ts @@ -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 } diff --git a/server/lib/jobs/activitypub-http-job-scheduler/activitypub-http-fetcher-handler.ts b/server/lib/jobs/activitypub-http-job-scheduler/activitypub-http-fetcher-handler.ts index 09efaa622..bda319592 100644 --- a/server/lib/jobs/activitypub-http-job-scheduler/activitypub-http-fetcher-handler.ts +++ b/server/lib/jobs/activitypub-http-job-scheduler/activitypub-http-fetcher-handler.ts @@ -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) diff --git a/server/models/video/video-interface.ts b/server/models/video/video-interface.ts index 391ecff43..b97f163ab 100644 --- a/server/models/video/video-interface.ts +++ b/server/models/video/video-interface.ts @@ -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 { diff --git a/server/models/video/video.ts b/server/models/video/video.ts index 9b411a92e..052fc0ae8 100644 --- a/server/models/video/video.ts +++ b/server/models/video/video.ts @@ -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, diff --git a/shared/models/activitypub/activity.ts b/shared/models/activitypub/activity.ts index 3d035d7d7..ce150bc12 100644 --- a/shared/models/activitypub/activity.ts +++ b/shared/models/activitypub/activity.ts @@ -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 { diff --git a/shared/models/activitypub/objects/index.ts b/shared/models/activitypub/objects/index.ts index cd772b28d..d92f772e2 100644 --- a/shared/models/activitypub/objects/index.ts +++ b/shared/models/activitypub/objects/index.ts @@ -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 index 000000000..00348116a --- /dev/null +++ b/shared/models/activitypub/objects/view-object.ts @@ -0,0 +1,5 @@ +export interface ViewObject { + type: 'View', + actor: string + object: string +} -- 2.41.0