From 0032ebe94aa83fab761c7de3ceb6210ac4532824 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Thu, 23 Nov 2017 14:19:55 +0100 Subject: [PATCH] Federate likes/dislikes --- server/controllers/api/videos/rate.ts | 7 +- .../custom-validators/activitypub/activity.ts | 23 +++-- .../custom-validators/activitypub/announce.ts | 4 +- .../custom-validators/activitypub/rate.ts | 20 ++++ .../custom-validators/activitypub/undo.ts | 9 +- server/initializers/constants.ts | 1 + server/lib/activitypub/process/index.ts | 1 + .../lib/activitypub/process/process-create.ts | 35 ++++++- .../lib/activitypub/process/process-like.ts | 50 ++++++++++ .../lib/activitypub/process/process-undo.ts | 99 ++++++++++++++++--- server/lib/activitypub/process/process.ts | 6 +- server/lib/activitypub/send/index.ts | 2 + server/lib/activitypub/send/misc.ts | 27 ++++- server/lib/activitypub/send/send-create.ts | 62 +++++++++--- server/lib/activitypub/send/send-like.ts | 60 +++++++++++ server/lib/activitypub/send/send-undo.ts | 71 ++++++++++++- server/lib/activitypub/url.ts | 12 ++- server/lib/activitypub/videos.ts | 47 ++++++++- .../activitypub-http-broadcast-handler.ts | 9 +- .../activitypub-http-job-scheduler.ts | 24 ++++- .../activitypub-http-unicast-handler.ts | 9 +- .../account/account-follow-interface.ts | 6 +- server/models/account/account-follow.ts | 5 +- server/tests/api/index-slow.ts | 1 + shared/models/activitypub/activity.ts | 14 ++- .../activitypub/objects/dislike-object.ts | 5 + shared/models/activitypub/objects/index.ts | 1 + 27 files changed, 548 insertions(+), 62 deletions(-) create mode 100644 server/helpers/custom-validators/activitypub/rate.ts create mode 100644 server/lib/activitypub/process/process-like.ts create mode 100644 server/lib/activitypub/send/send-like.ts create mode 100644 shared/models/activitypub/objects/dislike-object.ts diff --git a/server/controllers/api/videos/rate.ts b/server/controllers/api/videos/rate.ts index 8216dffd2..134284df7 100644 --- a/server/controllers/api/videos/rate.ts +++ b/server/controllers/api/videos/rate.ts @@ -3,6 +3,7 @@ import { UserVideoRateUpdate } from '../../../../shared' import { logger, retryTransactionWrapper } from '../../../helpers' import { VIDEO_RATE_TYPES } from '../../../initializers' import { database as db } from '../../../initializers/database' +import { sendVideoRateChangeToFollowers, sendVideoRateChangeToOrigin } from '../../../lib/activitypub/videos' import { asyncMiddleware, authenticate, videoRateValidator } from '../../../middlewares' import { AccountInstance } from '../../../models/account/account-interface' import { VideoInstance } from '../../../models/video/video-interface' @@ -82,10 +83,10 @@ async function rateVideo (req: express.Request, res: express.Response) { // It is useful for the user to have a feedback await videoInstance.increment(incrementQuery, sequelizeOptions) - if (videoInstance.isOwned() === false) { - // TODO: Send a event to original server + if (videoInstance.isOwned()) { + await sendVideoRateChangeToFollowers(accountInstance, videoInstance, likesToIncrement, dislikesToIncrement, t) } else { - // TODO: Send update to followers + await sendVideoRateChangeToOrigin(accountInstance, videoInstance, likesToIncrement, dislikesToIncrement, t) } }) diff --git a/server/helpers/custom-validators/activitypub/activity.ts b/server/helpers/custom-validators/activitypub/activity.ts index 66e557d39..3a0e8197c 100644 --- a/server/helpers/custom-validators/activitypub/activity.ts +++ b/server/helpers/custom-validators/activitypub/activity.ts @@ -1,9 +1,9 @@ import * as validator from 'validator' import { Activity, ActivityType } from '../../../../shared/models/activitypub/activity' import { isAccountAcceptActivityValid, isAccountDeleteActivityValid, isAccountFollowActivityValid } from './account' -import { isAnnounceValid } from './announce' +import { isAnnounceActivityValid } from './announce' import { isActivityPubUrlValid } from './misc' -import { isUndoValid } from './undo' +import { isUndoActivityValid } from './undo' import { isVideoChannelCreateActivityValid, isVideoChannelDeleteActivityValid, isVideoChannelUpdateActivityValid } from './video-channels' import { isVideoFlagValid, @@ -12,6 +12,7 @@ import { isVideoTorrentUpdateActivityValid } from './videos' import { isViewActivityValid } from './view' +import { isDislikeActivityValid, isLikeActivityValid } from './rate' function isRootActivityValid (activity: any) { return Array.isArray(activity['@context']) && @@ -34,7 +35,8 @@ const activityCheckers: { [ P in ActivityType ]: (activity: Activity) => boolean Follow: checkFollowActivity, Accept: checkAcceptActivity, Announce: checkAnnounceActivity, - Undo: checkUndoActivity + Undo: checkUndoActivity, + Like: checkLikeActivity } function isActivityValid (activity: any) { @@ -55,9 +57,10 @@ export { // --------------------------------------------------------------------------- function checkCreateActivity (activity: any) { - return isVideoChannelCreateActivityValid(activity) || - isVideoFlagValid(activity) || - isViewActivityValid(activity) + return isViewActivityValid(activity) || + isDislikeActivityValid(activity) || + isVideoChannelCreateActivityValid(activity) || + isVideoFlagValid(activity) } function checkAddActivity (activity: any) { @@ -84,9 +87,13 @@ function checkAcceptActivity (activity: any) { } function checkAnnounceActivity (activity: any) { - return isAnnounceValid(activity) + return isAnnounceActivityValid(activity) } function checkUndoActivity (activity: any) { - return isUndoValid(activity) + return isUndoActivityValid(activity) +} + +function checkLikeActivity (activity: any) { + return isLikeActivityValid(activity) } diff --git a/server/helpers/custom-validators/activitypub/announce.ts b/server/helpers/custom-validators/activitypub/announce.ts index 4ba99d1ea..45f6b05a0 100644 --- a/server/helpers/custom-validators/activitypub/announce.ts +++ b/server/helpers/custom-validators/activitypub/announce.ts @@ -2,7 +2,7 @@ import { isBaseActivityValid } from './misc' import { isVideoTorrentAddActivityValid } from './videos' import { isVideoChannelCreateActivityValid } from './video-channels' -function isAnnounceValid (activity: any) { +function isAnnounceActivityValid (activity: any) { return isBaseActivityValid(activity, 'Announce') && ( isVideoChannelCreateActivityValid(activity.object) || @@ -11,5 +11,5 @@ function isAnnounceValid (activity: any) { } export { - isAnnounceValid + isAnnounceActivityValid } diff --git a/server/helpers/custom-validators/activitypub/rate.ts b/server/helpers/custom-validators/activitypub/rate.ts new file mode 100644 index 000000000..e70bd94b8 --- /dev/null +++ b/server/helpers/custom-validators/activitypub/rate.ts @@ -0,0 +1,20 @@ +import { isActivityPubUrlValid, isBaseActivityValid } from './misc' + +function isLikeActivityValid (activity: any) { + return isBaseActivityValid(activity, 'Like') && + isActivityPubUrlValid(activity.object) +} + +function isDislikeActivityValid (activity: any) { + return isBaseActivityValid(activity, 'Create') && + activity.object.type === 'Dislike' && + isActivityPubUrlValid(activity.object.actor) && + isActivityPubUrlValid(activity.object.object) +} + +// --------------------------------------------------------------------------- + +export { + isLikeActivityValid, + isDislikeActivityValid +} diff --git a/server/helpers/custom-validators/activitypub/undo.ts b/server/helpers/custom-validators/activitypub/undo.ts index a9a2a3a41..58043f8a1 100644 --- a/server/helpers/custom-validators/activitypub/undo.ts +++ b/server/helpers/custom-validators/activitypub/undo.ts @@ -1,13 +1,16 @@ import { isAccountFollowActivityValid } from './account' import { isBaseActivityValid } from './misc' +import { isDislikeActivityValid, isLikeActivityValid } from './rate' -function isUndoValid (activity: any) { +function isUndoActivityValid (activity: any) { return isBaseActivityValid(activity, 'Undo') && ( - isAccountFollowActivityValid(activity.object) + isAccountFollowActivityValid(activity.object) || + isLikeActivityValid(activity.object) || + isDislikeActivityValid(activity.object) ) } export { - isUndoValid + isUndoActivityValid } diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index 9e61f01aa..e7f668ee4 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts @@ -229,6 +229,7 @@ const ACTIVITY_PUB = { PUBLIC: 'https://www.w3.org/ns/activitystreams#Public', COLLECTION_ITEMS_PER_PAGE: 10, FETCH_PAGE_LIMIT: 100, + MAX_HTTP_ATTEMPT: 5, URL_MIME_TYPES: { VIDEO: [ 'video/mp4', 'video/webm', 'video/ogg' ], // TODO: Merge with VIDEO_MIMETYPE_EXT TORRENT: [ 'application/x-bittorrent' ], diff --git a/server/lib/activitypub/process/index.ts b/server/lib/activitypub/process/index.ts index c68312053..e25c261cc 100644 --- a/server/lib/activitypub/process/index.ts +++ b/server/lib/activitypub/process/index.ts @@ -5,5 +5,6 @@ export * from './process-announce' export * from './process-create' export * from './process-delete' export * from './process-follow' +export * from './process-like' export * from './process-undo' export * from './process-update' diff --git a/server/lib/activitypub/process/process-create.ts b/server/lib/activitypub/process/process-create.ts index 1777733a0..147bbd132 100644 --- a/server/lib/activitypub/process/process-create.ts +++ b/server/lib/activitypub/process/process-create.ts @@ -5,9 +5,10 @@ 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 { sendCreateDislikeToVideoFollowers, sendCreateViewToVideoFollowers } from '../send/send-create' import { getVideoChannelActivityPubUrl } from '../url' import { videoChannelActivityObjectToDBAttributes } from './misc' +import { DislikeObject } from '../../../../shared/models/activitypub/objects/dislike-object' async function processCreateActivity (activity: ActivityCreate) { const activityObject = activity.object @@ -16,6 +17,8 @@ async function processCreateActivity (activity: ActivityCreate) { if (activityType === 'View') { return processCreateView(activityObject as ViewObject) + } else if (activityType === 'Dislike') { + return processCreateDislike(account, activityObject as DislikeObject) } else if (activityType === 'VideoChannel') { return processCreateVideoChannel(account, activityObject as VideoChannelObject) } else if (activityType === 'Flag') { @@ -34,6 +37,36 @@ export { // --------------------------------------------------------------------------- +async function processCreateDislike (byAccount: AccountInstance, dislike: DislikeObject) { + const options = { + arguments: [ byAccount, dislike ], + errorMessage: 'Cannot dislike the video with many retries.' + } + + return retryTransactionWrapper(createVideoDislike, options) +} + +function createVideoDislike (byAccount: AccountInstance, dislike: DislikeObject) { + return db.sequelize.transaction(async t => { + const video = await db.Video.loadByUrlAndPopulateAccount(dislike.object) + + if (!video) throw new Error('Unknown video ' + dislike.object) + + const rate = { + type: 'dislike' as 'dislike', + videoId: video.id, + accountId: byAccount.id + } + const [ , created ] = await db.AccountVideoRate.findOrCreate({ + where: rate, + defaults: rate + }) + await video.increment('dislikes') + + if (video.isOwned() && created === true) await sendCreateDislikeToVideoFollowers(byAccount, video, undefined) + }) +} + async function processCreateView (view: ViewObject) { const video = await db.Video.loadByUrlAndPopulateAccount(view.object) diff --git a/server/lib/activitypub/process/process-like.ts b/server/lib/activitypub/process/process-like.ts new file mode 100644 index 000000000..d77b30f24 --- /dev/null +++ b/server/lib/activitypub/process/process-like.ts @@ -0,0 +1,50 @@ +import { ActivityLike } from '../../../../shared/models/activitypub/activity' +import { database as db } from '../../../initializers' +import { AccountInstance } from '../../../models/account/account-interface' +import { getOrCreateAccountAndServer } from '../account' +import { sendLikeToVideoFollowers } from '../send/send-like' +import { retryTransactionWrapper } from '../../../helpers/database-utils' + +async function processLikeActivity (activity: ActivityLike) { + const account = await getOrCreateAccountAndServer(activity.actor) + + return processLikeVideo(account, activity.object) +} + +// --------------------------------------------------------------------------- + +export { + processLikeActivity +} + +// --------------------------------------------------------------------------- + +async function processLikeVideo (byAccount: AccountInstance, videoUrl: string) { + const options = { + arguments: [ byAccount, videoUrl ], + errorMessage: 'Cannot like the video with many retries.' + } + + return retryTransactionWrapper(createVideoLike, options) +} + +function createVideoLike (byAccount: AccountInstance, videoUrl: string) { + return db.sequelize.transaction(async t => { + const video = await db.Video.loadByUrlAndPopulateAccount(videoUrl) + + if (!video) throw new Error('Unknown video ' + videoUrl) + + const rate = { + type: 'like' as 'like', + videoId: video.id, + accountId: byAccount.id + } + const [ , created ] = await db.AccountVideoRate.findOrCreate({ + where: rate, + defaults: rate + }) + await video.increment('likes') + + if (video.isOwned() && created === true) await sendLikeToVideoFollowers(byAccount, video, undefined) + }) +} diff --git a/server/lib/activitypub/process/process-undo.ts b/server/lib/activitypub/process/process-undo.ts index 610b800fb..caa835714 100644 --- a/server/lib/activitypub/process/process-undo.ts +++ b/server/lib/activitypub/process/process-undo.ts @@ -1,20 +1,20 @@ -import { ActivityUndo } from '../../../../shared/models/activitypub/activity' +import { ActivityCreate, ActivityFollow, ActivityLike, ActivityUndo } from '../../../../shared/models/activitypub/activity' import { logger } from '../../../helpers/logger' import { database as db } from '../../../initializers' +import { retryTransactionWrapper } from '../../../helpers/database-utils' +import { DislikeObject } from '../../../../shared/models/activitypub/objects/dislike-object' +import { sendUndoLikeToVideoFollowers } from '../send/send-undo' +import { sendUndoDislikeToVideoFollowers } from '../index' async function processUndoActivity (activity: ActivityUndo) { const activityToUndo = activity.object - if (activityToUndo.type === 'Follow') { - const follower = await db.Account.loadByUrl(activity.actor) - const following = await db.Account.loadByUrl(activityToUndo.object) - const accountFollow = await db.AccountFollow.loadByAccountAndTarget(follower.id, following.id) - - if (!accountFollow) throw new Error(`'Unknown account follow ${follower.id} -> ${following.id}.`) - - await accountFollow.destroy() - - return undefined + if (activityToUndo.type === 'Like') { + return processUndoLike(activity.actor, activityToUndo) + } else if (activityToUndo.type === 'Create' && activityToUndo.object.type === 'Dislike') { + return processUndoDislike(activity.actor, activityToUndo.object) + } else if (activityToUndo.type === 'Follow') { + return processUndoFollow(activity.actor, activityToUndo) } logger.warn('Unknown activity object type %s -> %s when undo activity.', activityToUndo.type, { activity: activity.id }) @@ -29,3 +29,80 @@ export { } // --------------------------------------------------------------------------- + +function processUndoLike (actor: string, likeActivity: ActivityLike) { + const options = { + arguments: [ actor, likeActivity ], + errorMessage: 'Cannot undo like with many retries.' + } + + return retryTransactionWrapper(undoLike, options) +} + +function undoLike (actor: string, likeActivity: ActivityLike) { + return db.sequelize.transaction(async t => { + const byAccount = await db.Account.loadByUrl(actor, t) + if (!byAccount) throw new Error('Unknown account ' + actor) + + const video = await db.Video.loadByUrlAndPopulateAccount(likeActivity.object) + if (!video) throw new Error('Unknown video ' + likeActivity.actor) + + const rate = await db.AccountVideoRate.load(byAccount.id, video.id, t) + if (!rate) throw new Error(`Unknown rate by account ${byAccount.id} for video ${video.id}.`) + + await rate.destroy({ transaction: t }) + await video.decrement('likes') + + if (video.isOwned()) await sendUndoLikeToVideoFollowers(byAccount, video, t) + }) +} + +function processUndoDislike (actor: string, dislikeCreateActivity: DislikeObject) { + const options = { + arguments: [ actor, dislikeCreateActivity ], + errorMessage: 'Cannot undo dislike with many retries.' + } + + return retryTransactionWrapper(undoDislike, options) +} + +function undoDislike (actor: string, dislike: DislikeObject) { + return db.sequelize.transaction(async t => { + const byAccount = await db.Account.loadByUrl(actor, t) + if (!byAccount) throw new Error('Unknown account ' + actor) + + const video = await db.Video.loadByUrlAndPopulateAccount(dislike.object) + if (!video) throw new Error('Unknown video ' + dislike.actor) + + const rate = await db.AccountVideoRate.load(byAccount.id, video.id, t) + if (!rate) throw new Error(`Unknown rate by account ${byAccount.id} for video ${video.id}.`) + + await rate.destroy({ transaction: t }) + await video.decrement('dislikes') + + if (video.isOwned()) await sendUndoDislikeToVideoFollowers(byAccount, video, t) + }) +} + +function processUndoFollow (actor: string, followActivity: ActivityFollow) { + const options = { + arguments: [ actor, followActivity ], + errorMessage: 'Cannot undo follow with many retries.' + } + + return retryTransactionWrapper(undoFollow, options) +} + +function undoFollow (actor: string, followActivity: ActivityFollow) { + return db.sequelize.transaction(async t => { + const follower = await db.Account.loadByUrl(actor, t) + const following = await db.Account.loadByUrl(followActivity.object, t) + const accountFollow = await db.AccountFollow.loadByAccountAndTarget(follower.id, following.id, t) + + if (!accountFollow) throw new Error(`'Unknown account follow ${follower.id} -> ${following.id}.`) + + await accountFollow.destroy({ transaction: t }) + + return undefined + }) +} diff --git a/server/lib/activitypub/process/process.ts b/server/lib/activitypub/process/process.ts index 613597341..942bce0e6 100644 --- a/server/lib/activitypub/process/process.ts +++ b/server/lib/activitypub/process/process.ts @@ -1,4 +1,5 @@ import { Activity, ActivityType } from '../../../../shared/models/activitypub/activity' +import { logger } from '../../../helpers/logger' import { AccountInstance } from '../../../models/account/account-interface' import { processAcceptActivity } from './process-accept' import { processAddActivity } from './process-add' @@ -6,9 +7,9 @@ import { processAnnounceActivity } from './process-announce' import { processCreateActivity } from './process-create' import { processDeleteActivity } from './process-delete' import { processFollowActivity } from './process-follow' +import { processLikeActivity } from './process-like' import { processUndoActivity } from './process-undo' import { processUpdateActivity } from './process-update' -import { logger } from '../../../helpers/logger' const processActivity: { [ P in ActivityType ]: (activity: Activity, inboxAccount?: AccountInstance) => Promise } = { Create: processCreateActivity, @@ -18,7 +19,8 @@ const processActivity: { [ P in ActivityType ]: (activity: Activity, inboxAccoun Follow: processFollowActivity, Accept: processAcceptActivity, Announce: processAnnounceActivity, - Undo: processUndoActivity + Undo: processUndoActivity, + Like: processLikeActivity } async function processActivities (activities: Activity[], inboxAccount?: AccountInstance) { diff --git a/server/lib/activitypub/send/index.ts b/server/lib/activitypub/send/index.ts index 5f15dd4b5..ee8f3ad7e 100644 --- a/server/lib/activitypub/send/index.ts +++ b/server/lib/activitypub/send/index.ts @@ -4,4 +4,6 @@ export * from './send-announce' export * from './send-create' export * from './send-delete' export * from './send-follow' +export * from './send-like' +export * from './send-undo' export * from './send-update' diff --git a/server/lib/activitypub/send/misc.ts b/server/lib/activitypub/send/misc.ts index f3dc5c148..41a039b19 100644 --- a/server/lib/activitypub/send/misc.ts +++ b/server/lib/activitypub/send/misc.ts @@ -3,6 +3,7 @@ import { logger } from '../../../helpers/logger' 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' +import { VideoInstance } from '../../../models/video/video-interface' async function broadcastToFollowers ( data: any, @@ -41,6 +42,27 @@ async function unicastTo (data: any, byAccount: AccountInstance, toAccountUrl: s return activitypubHttpJobScheduler.createJob(t, 'activitypubHttpUnicastHandler', jobPayload) } +function getOriginVideoAudience (video: VideoInstance) { + return { + to: [ video.VideoChannel.Account.url ], + cc: [ video.VideoChannel.Account.url + '/followers' ] + } +} + +function getVideoFollowersAudience (video: VideoInstance) { + return { + to: [ video.VideoChannel.Account.url + '/followers' ], + cc: [] + } +} + +async function getAccountsToForwardVideoAction (byAccount: AccountInstance, video: VideoInstance) { + const accountsToForwardView = await db.VideoShare.loadAccountsByShare(video.id) + accountsToForwardView.push(video.VideoChannel.Account) + + return accountsToForwardView +} + async function getAudience (accountSender: AccountInstance, isPublic = true) { const followerInboxUrls = await accountSender.getFollowerSharedInboxUrls() @@ -64,5 +86,8 @@ async function getAudience (accountSender: AccountInstance, isPublic = true) { export { broadcastToFollowers, unicastTo, - getAudience + getAudience, + getOriginVideoAudience, + getAccountsToForwardVideoAction, + getVideoFollowersAudience } diff --git a/server/lib/activitypub/send/send-create.ts b/server/lib/activitypub/send/send-create.ts index e5fb212b7..6afe67ee6 100644 --- a/server/lib/activitypub/send/send-create.ts +++ b/server/lib/activitypub/send/send-create.ts @@ -1,11 +1,17 @@ import { Transaction } from 'sequelize' import { ActivityCreate } from '../../../../shared/models/activitypub/activity' +import { getServerAccount } from '../../../helpers/utils' import { AccountInstance, VideoChannelInstance, VideoInstance } from '../../../models' import { VideoAbuseInstance } from '../../../models/video/video-abuse-interface' -import { broadcastToFollowers, getAudience, unicastTo } from './misc' -import { getVideoAbuseActivityPubUrl, getVideoViewActivityPubUrl } from '../url' -import { getServerAccount } from '../../../helpers/utils' -import { database as db } from '../../../initializers' +import { getVideoAbuseActivityPubUrl, getVideoDislikeActivityPubUrl, getVideoViewActivityPubUrl } from '../url' +import { + broadcastToFollowers, + getAccountsToForwardVideoAction, + getAudience, + getOriginVideoAudience, + getVideoFollowersAudience, + unicastTo +} from './misc' async function sendCreateVideoChannel (videoChannel: VideoChannelInstance, t: Transaction) { const byAccount = videoChannel.Account @@ -29,7 +35,7 @@ async function sendCreateViewToOrigin (byAccount: AccountInstance, video: VideoI const url = getVideoViewActivityPubUrl(byAccount, video) const viewActivity = createViewActivityData(byAccount, video) - const audience = { to: [ video.VideoChannel.Account.url ], cc: [ video.VideoChannel.Account.url + '/followers' ] } + const audience = getOriginVideoAudience(video) const data = await createActivityData(url, byAccount, viewActivity, audience) return unicastTo(data, byAccount, video.VideoChannel.Account.sharedInboxUrl, t) @@ -39,16 +45,35 @@ async function sendCreateViewToVideoFollowers (byAccount: AccountInstance, video const url = getVideoViewActivityPubUrl(byAccount, video) const viewActivity = createViewActivityData(byAccount, video) - const audience = { to: [ video.VideoChannel.Account.url + '/followers' ], cc: [] } + const audience = getVideoFollowersAudience(video) 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) + const accountsToForwardView = await getAccountsToForwardVideoAction(byAccount, video) + + const followersException = [ byAccount ] + return broadcastToFollowers(data, serverAccount, accountsToForwardView, t, followersException) +} + +async function sendCreateDislikeToOrigin (byAccount: AccountInstance, video: VideoInstance, t: Transaction) { + const url = getVideoDislikeActivityPubUrl(byAccount, video) + const dislikeActivity = createDislikeActivityData(byAccount, video) + + const audience = getOriginVideoAudience(video) + const data = await createActivityData(url, byAccount, dislikeActivity, audience) + + return unicastTo(data, byAccount, video.VideoChannel.Account.sharedInboxUrl, t) +} - // 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) +async function sendCreateDislikeToVideoFollowers (byAccount: AccountInstance, video: VideoInstance, t: Transaction) { + const url = getVideoDislikeActivityPubUrl(byAccount, video) + const dislikeActivity = createDislikeActivityData(byAccount, video) + + const audience = getVideoFollowersAudience(video) + const data = await createActivityData(url, byAccount, dislikeActivity, audience) + + const accountsToForwardView = await getAccountsToForwardVideoAction(byAccount, video) + const serverAccount = await getServerAccount() const followersException = [ byAccount ] return broadcastToFollowers(data, serverAccount, accountsToForwardView, t, followersException) @@ -71,6 +96,16 @@ async function createActivityData (url: string, byAccount: AccountInstance, obje return activity } +function createDislikeActivityData (byAccount: AccountInstance, video: VideoInstance) { + const obj = { + type: 'Dislike', + actor: byAccount.url, + object: video.url + } + + return obj +} + // --------------------------------------------------------------------------- export { @@ -78,7 +113,10 @@ export { sendVideoAbuse, createActivityData, sendCreateViewToOrigin, - sendCreateViewToVideoFollowers + sendCreateViewToVideoFollowers, + sendCreateDislikeToOrigin, + sendCreateDislikeToVideoFollowers, + createDislikeActivityData } // --------------------------------------------------------------------------- diff --git a/server/lib/activitypub/send/send-like.ts b/server/lib/activitypub/send/send-like.ts new file mode 100644 index 000000000..70a7d886f --- /dev/null +++ b/server/lib/activitypub/send/send-like.ts @@ -0,0 +1,60 @@ +import { Transaction } from 'sequelize' +import { ActivityLike } from '../../../../shared/models/activitypub/activity' +import { getServerAccount } from '../../../helpers/utils' +import { AccountInstance, VideoInstance } from '../../../models' +import { getVideoLikeActivityPubUrl } from '../url' +import { + broadcastToFollowers, + getAccountsToForwardVideoAction, + getAudience, + getOriginVideoAudience, + getVideoFollowersAudience, + unicastTo +} from './misc' + +async function sendLikeToOrigin (byAccount: AccountInstance, video: VideoInstance, t: Transaction) { + const url = getVideoLikeActivityPubUrl(byAccount, video) + + const audience = getOriginVideoAudience(video) + const data = await likeActivityData(url, byAccount, video, audience) + + return unicastTo(data, byAccount, video.VideoChannel.Account.sharedInboxUrl, t) +} + +async function sendLikeToVideoFollowers (byAccount: AccountInstance, video: VideoInstance, t: Transaction) { + const url = getVideoLikeActivityPubUrl(byAccount, video) + + const audience = getVideoFollowersAudience(video) + const data = await likeActivityData(url, byAccount, video, audience) + + const accountsToForwardView = await getAccountsToForwardVideoAction(byAccount, video) + const serverAccount = await getServerAccount() + + const followersException = [ byAccount ] + return broadcastToFollowers(data, serverAccount, accountsToForwardView, t, followersException) +} + +async function likeActivityData (url: string, byAccount: AccountInstance, video: VideoInstance, audience?: { to: string[], cc: string[] }) { + if (!audience) { + audience = await getAudience(byAccount) + } + + const activity: ActivityLike = { + type: 'Like', + id: url, + actor: byAccount.url, + to: audience.to, + cc: audience.cc, + object: video.url + } + + return activity +} + +// --------------------------------------------------------------------------- + +export { + sendLikeToOrigin, + sendLikeToVideoFollowers, + likeActivityData +} diff --git a/server/lib/activitypub/send/send-undo.ts b/server/lib/activitypub/send/send-undo.ts index 77bee6639..53fddd0cb 100644 --- a/server/lib/activitypub/send/send-undo.ts +++ b/server/lib/activitypub/send/send-undo.ts @@ -1,10 +1,14 @@ import { Transaction } from 'sequelize' -import { ActivityFollow, ActivityUndo } from '../../../../shared/models/activitypub/activity' +import { ActivityCreate, ActivityFollow, ActivityLike, ActivityUndo } from '../../../../shared/models/activitypub/activity' import { AccountInstance } from '../../../models' import { AccountFollowInstance } from '../../../models/account/account-follow-interface' -import { unicastTo } from './misc' +import { broadcastToFollowers, getAccountsToForwardVideoAction, unicastTo } from './misc' import { followActivityData } from './send-follow' -import { getAccountFollowActivityPubUrl, getUndoActivityPubUrl } from '../url' +import { getAccountFollowActivityPubUrl, getUndoActivityPubUrl, getVideoDislikeActivityPubUrl, getVideoLikeActivityPubUrl } from '../url' +import { VideoInstance } from '../../../models/video/video-interface' +import { likeActivityData } from './send-like' +import { createActivityData, createDislikeActivityData } from './send-create' +import { getServerAccount } from '../../../helpers/utils' async function sendUndoFollow (accountFollow: AccountFollowInstance, t: Transaction) { const me = accountFollow.AccountFollower @@ -19,15 +23,72 @@ async function sendUndoFollow (accountFollow: AccountFollowInstance, t: Transact return unicastTo(data, me, following.inboxUrl, t) } +async function sendUndoLikeToOrigin (byAccount: AccountInstance, video: VideoInstance, t: Transaction) { + const likeUrl = getVideoLikeActivityPubUrl(byAccount, video) + const undoUrl = getUndoActivityPubUrl(likeUrl) + + const object = await likeActivityData(likeUrl, byAccount, video) + const data = await undoActivityData(undoUrl, byAccount, object) + + return unicastTo(data, byAccount, video.VideoChannel.Account.sharedInboxUrl, t) +} + +async function sendUndoLikeToVideoFollowers (byAccount: AccountInstance, video: VideoInstance, t: Transaction) { + const likeUrl = getVideoLikeActivityPubUrl(byAccount, video) + const undoUrl = getUndoActivityPubUrl(likeUrl) + + const object = await likeActivityData(likeUrl, byAccount, video) + const data = await undoActivityData(undoUrl, byAccount, object) + + const accountsToForwardView = await getAccountsToForwardVideoAction(byAccount, video) + const serverAccount = await getServerAccount() + + const followersException = [ byAccount ] + return broadcastToFollowers(data, serverAccount, accountsToForwardView, t, followersException) +} + +async function sendUndoDislikeToOrigin (byAccount: AccountInstance, video: VideoInstance, t: Transaction) { + const dislikeUrl = getVideoDislikeActivityPubUrl(byAccount, video) + const undoUrl = getUndoActivityPubUrl(dislikeUrl) + + const dislikeActivity = createDislikeActivityData(byAccount, video) + const object = await createActivityData(undoUrl, byAccount, dislikeActivity) + + const data = await undoActivityData(undoUrl, byAccount, object) + + return unicastTo(data, byAccount, video.VideoChannel.Account.sharedInboxUrl, t) +} + +async function sendUndoDislikeToVideoFollowers (byAccount: AccountInstance, video: VideoInstance, t: Transaction) { + const dislikeUrl = getVideoDislikeActivityPubUrl(byAccount, video) + const undoUrl = getUndoActivityPubUrl(dislikeUrl) + + const dislikeActivity = createDislikeActivityData(byAccount, video) + const object = await createActivityData(undoUrl, byAccount, dislikeActivity) + + const data = await undoActivityData(undoUrl, byAccount, object) + + const accountsToForwardView = await getAccountsToForwardVideoAction(byAccount, video) + const serverAccount = await getServerAccount() + + const followersException = [ byAccount ] + return broadcastToFollowers(data, serverAccount, accountsToForwardView, t, followersException) +} + + // --------------------------------------------------------------------------- export { - sendUndoFollow + sendUndoFollow, + sendUndoLikeToOrigin, + sendUndoLikeToVideoFollowers, + sendUndoDislikeToOrigin, + sendUndoDislikeToVideoFollowers } // --------------------------------------------------------------------------- -async function undoActivityData (url: string, byAccount: AccountInstance, object: ActivityFollow) { +async function undoActivityData (url: string, byAccount: AccountInstance, object: ActivityFollow | ActivityLike | ActivityCreate) { const activity: ActivityUndo = { type: 'Undo', id: url, diff --git a/server/lib/activitypub/url.ts b/server/lib/activitypub/url.ts index d98561e33..17395a99b 100644 --- a/server/lib/activitypub/url.ts +++ b/server/lib/activitypub/url.ts @@ -25,6 +25,14 @@ function getVideoViewActivityPubUrl (byAccount: AccountInstance, video: VideoIns return video.url + '#views/' + byAccount.uuid + '/' + new Date().toISOString() } +function getVideoLikeActivityPubUrl (byAccount: AccountInstance, video: VideoInstance) { + return byAccount.url + '#likes/' + video.id +} + +function getVideoDislikeActivityPubUrl (byAccount: AccountInstance, video: VideoInstance) { + return byAccount.url + '#dislikes/' + video.id +} + function getAccountFollowActivityPubUrl (accountFollow: AccountFollowInstance) { const me = accountFollow.AccountFollower const following = accountFollow.AccountFollowing @@ -61,5 +69,7 @@ export { getAnnounceActivityPubUrl, getUpdateActivityPubUrl, getUndoActivityPubUrl, - getVideoViewActivityPubUrl + getVideoViewActivityPubUrl, + getVideoLikeActivityPubUrl, + getVideoDislikeActivityPubUrl } diff --git a/server/lib/activitypub/videos.ts b/server/lib/activitypub/videos.ts index 944244893..acee4fe16 100644 --- a/server/lib/activitypub/videos.ts +++ b/server/lib/activitypub/videos.ts @@ -1,9 +1,20 @@ import { join } from 'path' import * as request from 'request' +import { Transaction } from 'sequelize' import { ActivityIconObject } from '../../../shared/index' import { doRequest, doRequestAndSaveToFile } from '../../helpers/requests' import { CONFIG, REMOTE_SCHEME, STATIC_PATHS } from '../../initializers/constants' +import { AccountInstance } from '../../models/account/account-interface' import { VideoInstance } from '../../models/video/video-interface' +import { sendLikeToOrigin } from './index' +import { sendCreateDislikeToOrigin, sendCreateDislikeToVideoFollowers } from './send/send-create' +import { sendLikeToVideoFollowers } from './send/send-like' +import { + sendUndoDislikeToOrigin, + sendUndoDislikeToVideoFollowers, + sendUndoLikeToOrigin, + sendUndoLikeToVideoFollowers +} from './send/send-undo' function fetchRemoteVideoPreview (video: VideoInstance) { // FIXME: use url @@ -37,8 +48,42 @@ function generateThumbnailFromUrl (video: VideoInstance, icon: ActivityIconObjec return doRequestAndSaveToFile(options, thumbnailPath) } +function sendVideoRateChangeToFollowers (account: AccountInstance, video: VideoInstance, likes: number, dislikes: number, t: Transaction) { + const tasks: Promise[] = [] + + // Undo Like + if (likes < 0) tasks.push(sendUndoLikeToVideoFollowers(account, video, t)) + // Like + if (likes > 0) tasks.push(sendLikeToVideoFollowers(account, video, t)) + + // Undo Dislike + if (dislikes < 0) tasks.push(sendUndoDislikeToVideoFollowers(account, video, t)) + // Dislike + if (dislikes > 0) tasks.push(sendCreateDislikeToVideoFollowers(account, video, t)) + + return Promise.all(tasks) +} + +function sendVideoRateChangeToOrigin (account: AccountInstance, video: VideoInstance, likes: number, dislikes: number, t: Transaction) { + const tasks: Promise[] = [] + + // Undo Like + if (likes < 0) tasks.push(sendUndoLikeToOrigin(account, video, t)) + // Like + if (likes > 0) tasks.push(sendLikeToOrigin(account, video, t)) + + // Undo Dislike + if (dislikes < 0) tasks.push(sendUndoDislikeToOrigin(account, video, t)) + // Dislike + if (dislikes > 0) tasks.push(sendCreateDislikeToOrigin(account, video, t)) + + return Promise.all(tasks) +} + export { fetchRemoteVideoPreview, fetchRemoteVideoDescription, - generateThumbnailFromUrl + generateThumbnailFromUrl, + sendVideoRateChangeToFollowers, + sendVideoRateChangeToOrigin } diff --git a/server/lib/jobs/activitypub-http-job-scheduler/activitypub-http-broadcast-handler.ts b/server/lib/jobs/activitypub-http-job-scheduler/activitypub-http-broadcast-handler.ts index 111fc88a4..5b4c65b81 100644 --- a/server/lib/jobs/activitypub-http-job-scheduler/activitypub-http-broadcast-handler.ts +++ b/server/lib/jobs/activitypub-http-job-scheduler/activitypub-http-broadcast-handler.ts @@ -2,7 +2,7 @@ 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 { ActivityPubHttpPayload, maybeRetryRequestLater } from './activitypub-http-job-scheduler' async function process (payload: ActivityPubHttpPayload, jobId: number) { logger.info('Processing ActivityPub broadcast in job %d.', jobId) @@ -20,7 +20,12 @@ async function process (payload: ActivityPubHttpPayload, jobId: number) { for (const uri of payload.uris) { options.uri = uri - await doRequest(options) + + try { + await doRequest(options) + } catch (err) { + await maybeRetryRequestLater(err, payload, uri) + } } } diff --git a/server/lib/jobs/activitypub-http-job-scheduler/activitypub-http-job-scheduler.ts b/server/lib/jobs/activitypub-http-job-scheduler/activitypub-http-job-scheduler.ts index aef217ce7..ccf109935 100644 --- a/server/lib/jobs/activitypub-http-job-scheduler/activitypub-http-job-scheduler.ts +++ b/server/lib/jobs/activitypub-http-job-scheduler/activitypub-http-job-scheduler.ts @@ -4,12 +4,16 @@ import * as activitypubHttpBroadcastHandler from './activitypub-http-broadcast-h import * as activitypubHttpUnicastHandler from './activitypub-http-unicast-handler' import * as activitypubHttpFetcherHandler from './activitypub-http-fetcher-handler' import { JobCategory } from '../../../../shared' +import { ACTIVITY_PUB } from '../../../initializers/constants' +import { logger } from '../../../helpers/logger' type ActivityPubHttpPayload = { uris: string[] signatureAccountId?: number body?: any + attemptNumber?: number } + const jobHandlers: { [ handlerName: string ]: JobHandler } = { activitypubHttpBroadcastHandler, activitypubHttpUnicastHandler, @@ -19,7 +23,25 @@ const jobCategory: JobCategory = 'activitypub-http' const activitypubHttpJobScheduler = new JobScheduler(jobCategory, jobHandlers) +function maybeRetryRequestLater (err: Error, payload: ActivityPubHttpPayload, uri: string) { + logger.warn('Cannot make request to %s.', uri, err) + + let attemptNumber = payload.attemptNumber || 1 + attemptNumber += 1 + + if (attemptNumber < ACTIVITY_PUB.MAX_HTTP_ATTEMPT) { + logger.debug('Retrying request to %s (attempt %d/%d).', uri, attemptNumber, ACTIVITY_PUB.MAX_HTTP_ATTEMPT, err) + + const newPayload = Object.assign(payload, { + uris: [ uri ], + attemptNumber + }) + return activitypubHttpJobScheduler.createJob(undefined, 'activitypubHttpUnicastHandler', newPayload) + } +} + export { ActivityPubHttpPayload, - activitypubHttpJobScheduler + activitypubHttpJobScheduler, + maybeRetryRequestLater } diff --git a/server/lib/jobs/activitypub-http-job-scheduler/activitypub-http-unicast-handler.ts b/server/lib/jobs/activitypub-http-job-scheduler/activitypub-http-unicast-handler.ts index 8d3b755ad..f7f3dabbd 100644 --- a/server/lib/jobs/activitypub-http-job-scheduler/activitypub-http-unicast-handler.ts +++ b/server/lib/jobs/activitypub-http-job-scheduler/activitypub-http-unicast-handler.ts @@ -1,6 +1,6 @@ import { logger } from '../../../helpers' import { doRequest } from '../../../helpers/requests' -import { ActivityPubHttpPayload } from './activitypub-http-job-scheduler' +import { ActivityPubHttpPayload, maybeRetryRequestLater } from './activitypub-http-job-scheduler' import { database as db } from '../../../initializers/database' import { buildSignedActivity } from '../../../helpers/activitypub' @@ -18,7 +18,12 @@ async function process (payload: ActivityPubHttpPayload, jobId: number) { json: signedBody } - await doRequest(options) + try { + await doRequest(options) + } catch (err) { + await maybeRetryRequestLater(err, payload, uri) + throw err + } } function onError (err: Error, jobId: number) { diff --git a/server/models/account/account-follow-interface.ts b/server/models/account/account-follow-interface.ts index 6f228c790..a0d620dd0 100644 --- a/server/models/account/account-follow-interface.ts +++ b/server/models/account/account-follow-interface.ts @@ -5,7 +5,11 @@ import { ResultList } from '../../../shared/models/result-list.model' import { AccountInstance } from './account-interface' export namespace AccountFollowMethods { - export type LoadByAccountAndTarget = (accountId: number, targetAccountId: number) => Bluebird + export type LoadByAccountAndTarget = ( + accountId: number, + targetAccountId: number, + t?: Sequelize.Transaction + ) => Bluebird export type ListFollowingForApi = (id: number, start: number, count: number, sort: string) => Bluebird< ResultList> export type ListFollowersForApi = (id: number, start: number, count: number, sort: string) => Bluebird< ResultList> diff --git a/server/models/account/account-follow.ts b/server/models/account/account-follow.ts index 578bcda39..8e35c7d20 100644 --- a/server/models/account/account-follow.ts +++ b/server/models/account/account-follow.ts @@ -93,7 +93,7 @@ toFormattedJSON = function (this: AccountFollowInstance) { return json } -loadByAccountAndTarget = function (accountId: number, targetAccountId: number) { +loadByAccountAndTarget = function (accountId: number, targetAccountId: number, t?: Sequelize.Transaction) { const query = { where: { accountId, @@ -110,7 +110,8 @@ loadByAccountAndTarget = function (accountId: number, targetAccountId: number) { required: true, as: 'AccountFollowing' } - ] + ], + transaction: t } return AccountFollow.findOne(query) diff --git a/server/tests/api/index-slow.ts b/server/tests/api/index-slow.ts index da56398b1..2448147d8 100644 --- a/server/tests/api/index-slow.ts +++ b/server/tests/api/index-slow.ts @@ -1,4 +1,5 @@ // Order of the tests we want to execute // import './multiple-servers' import './video-transcoder' +import './multiple-servers' import './follows' diff --git a/shared/models/activitypub/activity.ts b/shared/models/activitypub/activity.ts index ce150bc12..cbfd6157a 100644 --- a/shared/models/activitypub/activity.ts +++ b/shared/models/activitypub/activity.ts @@ -1,13 +1,14 @@ import { ActivityPubSignature } from './activitypub-signature' import { VideoChannelObject, VideoTorrentObject } from './objects' +import { DislikeObject } from './objects/dislike-object' import { VideoAbuseObject } from './objects/video-abuse-object' import { ViewObject } from './objects/view-object' export type Activity = ActivityCreate | ActivityAdd | ActivityUpdate | ActivityDelete | ActivityFollow | ActivityAccept | ActivityAnnounce | - ActivityUndo + ActivityUndo | ActivityLike -export type ActivityType = 'Create' | 'Add' | 'Update' | 'Delete' | 'Follow' | 'Accept' | 'Announce' | 'Undo' +export type ActivityType = 'Create' | 'Add' | 'Update' | 'Delete' | 'Follow' | 'Accept' | 'Announce' | 'Undo' | 'Like' export interface BaseActivity { '@context'?: any[] @@ -21,7 +22,7 @@ export interface BaseActivity { export interface ActivityCreate extends BaseActivity { type: 'Create' - object: VideoChannelObject | VideoAbuseObject | ViewObject + object: VideoChannelObject | VideoAbuseObject | ViewObject | DislikeObject } export interface ActivityAdd extends BaseActivity { @@ -55,5 +56,10 @@ export interface ActivityAnnounce extends BaseActivity { export interface ActivityUndo extends BaseActivity { type: 'Undo', - object: ActivityFollow + object: ActivityFollow | ActivityLike | ActivityCreate +} + +export interface ActivityLike extends BaseActivity { + type: 'Like', + object: string } diff --git a/shared/models/activitypub/objects/dislike-object.ts b/shared/models/activitypub/objects/dislike-object.ts new file mode 100644 index 000000000..295175774 --- /dev/null +++ b/shared/models/activitypub/objects/dislike-object.ts @@ -0,0 +1,5 @@ +export interface DislikeObject { + type: 'Dislike', + actor: string + object: string +} diff --git a/shared/models/activitypub/objects/index.ts b/shared/models/activitypub/objects/index.ts index d92f772e2..f1f761e44 100644 --- a/shared/models/activitypub/objects/index.ts +++ b/shared/models/activitypub/objects/index.ts @@ -3,3 +3,4 @@ export * from './video-abuse-object' export * from './video-channel-object' export * from './video-torrent-object' export * from './view-object' +export * from './dislike-object' -- 2.41.0