From 54141398354e6e7b94aa3065a705a1251390111c Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Mon, 20 Nov 2017 09:43:39 +0100 Subject: Refractor activity pub lib/helpers --- server/controllers/activitypub/inbox.ts | 15 +- server/controllers/api/server/follows.ts | 30 ++- server/controllers/api/videos/channel.ts | 6 +- server/controllers/api/videos/index.ts | 11 +- server/helpers/activitypub.ts | 65 ++++- .../custom-validators/activitypub/activity.ts | 73 ++++-- .../custom-validators/activitypub/announce.ts | 15 ++ .../helpers/custom-validators/activitypub/index.ts | 4 +- .../helpers/custom-validators/activitypub/undo.ts | 13 + .../activitypub/video-channels.ts | 36 +++ .../custom-validators/activitypub/videos.ts | 54 +--- server/lib/activitypub/index.ts | 10 +- server/lib/activitypub/misc.ts | 101 -------- server/lib/activitypub/process-accept.ts | 27 -- server/lib/activitypub/process-add.ts | 87 ------- server/lib/activitypub/process-announce.ts | 45 ---- server/lib/activitypub/process-create.ts | 88 ------- server/lib/activitypub/process-delete.ts | 105 -------- server/lib/activitypub/process-follow.ts | 57 ----- server/lib/activitypub/process-update.ts | 135 ---------- server/lib/activitypub/process/index.ts | 8 + server/lib/activitypub/process/misc.ts | 101 ++++++++ server/lib/activitypub/process/process-accept.ts | 27 ++ server/lib/activitypub/process/process-add.ts | 87 +++++++ server/lib/activitypub/process/process-announce.ts | 46 ++++ server/lib/activitypub/process/process-create.ts | 88 +++++++ server/lib/activitypub/process/process-delete.ts | 105 ++++++++ server/lib/activitypub/process/process-follow.ts | 59 +++++ server/lib/activitypub/process/process-undo.ts | 31 +++ server/lib/activitypub/process/process-update.ts | 135 ++++++++++ server/lib/activitypub/send-request.ts | 275 --------------------- server/lib/activitypub/send/index.ts | 7 + server/lib/activitypub/send/misc.ts | 58 +++++ server/lib/activitypub/send/send-accept.ts | 34 +++ server/lib/activitypub/send/send-add.ts | 38 +++ server/lib/activitypub/send/send-announce.ts | 45 ++++ server/lib/activitypub/send/send-create.ts | 44 ++++ server/lib/activitypub/send/send-delete.ts | 53 ++++ server/lib/activitypub/send/send-follow.ts | 34 +++ server/lib/activitypub/send/send-undo.ts | 39 +++ server/lib/activitypub/send/send-update.ts | 55 +++++ .../video-file-optimizer-handler.ts | 3 +- .../video-file-transcoder-handler.ts | 2 +- server/lib/user.ts | 4 +- server/lib/video-channel.ts | 4 +- server/middlewares/validators/follows.ts | 62 +++++ server/middlewares/validators/index.ts | 2 +- server/middlewares/validators/servers.ts | 32 --- server/models/account/account-follow.ts | 14 +- server/models/account/account-interface.ts | 2 +- server/models/account/account.ts | 3 +- server/models/video/video-channel.ts | 16 +- server/models/video/video.ts | 7 +- server/tests/api/multiple-servers.ts | 6 +- shared/models/activitypub/activity.ts | 11 +- 55 files changed, 1430 insertions(+), 1084 deletions(-) create mode 100644 server/helpers/custom-validators/activitypub/announce.ts create mode 100644 server/helpers/custom-validators/activitypub/undo.ts create mode 100644 server/helpers/custom-validators/activitypub/video-channels.ts delete mode 100644 server/lib/activitypub/misc.ts delete mode 100644 server/lib/activitypub/process-accept.ts delete mode 100644 server/lib/activitypub/process-add.ts delete mode 100644 server/lib/activitypub/process-announce.ts delete mode 100644 server/lib/activitypub/process-create.ts delete mode 100644 server/lib/activitypub/process-delete.ts delete mode 100644 server/lib/activitypub/process-follow.ts delete mode 100644 server/lib/activitypub/process-update.ts create mode 100644 server/lib/activitypub/process/index.ts create mode 100644 server/lib/activitypub/process/misc.ts create mode 100644 server/lib/activitypub/process/process-accept.ts create mode 100644 server/lib/activitypub/process/process-add.ts create mode 100644 server/lib/activitypub/process/process-announce.ts create mode 100644 server/lib/activitypub/process/process-create.ts create mode 100644 server/lib/activitypub/process/process-delete.ts create mode 100644 server/lib/activitypub/process/process-follow.ts create mode 100644 server/lib/activitypub/process/process-undo.ts create mode 100644 server/lib/activitypub/process/process-update.ts delete mode 100644 server/lib/activitypub/send-request.ts create mode 100644 server/lib/activitypub/send/index.ts create mode 100644 server/lib/activitypub/send/misc.ts create mode 100644 server/lib/activitypub/send/send-accept.ts create mode 100644 server/lib/activitypub/send/send-add.ts create mode 100644 server/lib/activitypub/send/send-announce.ts create mode 100644 server/lib/activitypub/send/send-create.ts create mode 100644 server/lib/activitypub/send/send-delete.ts create mode 100644 server/lib/activitypub/send/send-follow.ts create mode 100644 server/lib/activitypub/send/send-undo.ts create mode 100644 server/lib/activitypub/send/send-update.ts create mode 100644 server/middlewares/validators/follows.ts delete mode 100644 server/middlewares/validators/servers.ts diff --git a/server/controllers/activitypub/inbox.ts b/server/controllers/activitypub/inbox.ts index fd3695886..807d0bdf4 100644 --- a/server/controllers/activitypub/inbox.ts +++ b/server/controllers/activitypub/inbox.ts @@ -2,12 +2,12 @@ import * as express from 'express' import { Activity, ActivityPubCollection, ActivityPubOrderedCollection, ActivityType, RootActivity } from '../../../shared' import { logger } from '../../helpers' import { isActivityValid } from '../../helpers/custom-validators/activitypub/activity' -import { processCreateActivity, processUpdateActivity } from '../../lib' -import { processAcceptActivity } from '../../lib/activitypub/process-accept' -import { processAddActivity } from '../../lib/activitypub/process-add' -import { processAnnounceActivity } from '../../lib/activitypub/process-announce' -import { processDeleteActivity } from '../../lib/activitypub/process-delete' -import { processFollowActivity } from '../../lib/activitypub/process-follow' +import { processCreateActivity, processUpdateActivity, processUndoActivity } from '../../lib' +import { processAcceptActivity } from '../../lib/activitypub/process/process-accept' +import { processAddActivity } from '../../lib/activitypub/process/process-add' +import { processAnnounceActivity } from '../../lib/activitypub/process/process-announce' +import { processDeleteActivity } from '../../lib/activitypub/process/process-delete' +import { processFollowActivity } from '../../lib/activitypub/process/process-follow' import { asyncMiddleware, checkSignature, localAccountValidator, signatureValidator } from '../../middlewares' import { activityPubValidator } from '../../middlewares/validators/activitypub/activity' import { AccountInstance } from '../../models/account/account-interface' @@ -19,7 +19,8 @@ const processActivity: { [ P in ActivityType ]: (activity: Activity, inboxAccoun Delete: processDeleteActivity, Follow: processFollowActivity, Accept: processAcceptActivity, - Announce: processAnnounceActivity + Announce: processAnnounceActivity, + Undo: processUndoActivity } const inboxRouter = express.Router() diff --git a/server/controllers/api/server/follows.ts b/server/controllers/api/server/follows.ts index 3d184ec1f..8fc70f34f 100644 --- a/server/controllers/api/server/follows.ts +++ b/server/controllers/api/server/follows.ts @@ -6,14 +6,16 @@ import { getServerAccount } from '../../../helpers/utils' import { getAccountFromWebfinger } from '../../../helpers/webfinger' import { SERVER_ACCOUNT_NAME } from '../../../initializers/constants' import { database as db } from '../../../initializers/database' -import { sendFollow } from '../../../lib/activitypub/send-request' -import { asyncMiddleware, paginationValidator, setFollowersSort, setPagination } from '../../../middlewares' +import { asyncMiddleware, paginationValidator, removeFollowingValidator, setFollowersSort, setPagination } from '../../../middlewares' import { authenticate } from '../../../middlewares/oauth' import { setBodyHostsPort } from '../../../middlewares/servers' import { setFollowingSort } from '../../../middlewares/sort' import { ensureUserHasRight } from '../../../middlewares/user-right' -import { followValidator } from '../../../middlewares/validators/servers' +import { followValidator } from '../../../middlewares/validators/follows' import { followersSortValidator, followingSortValidator } from '../../../middlewares/validators/sort' +import { AccountFollowInstance } from '../../../models/index' +import { sendFollow } from '../../../lib/index' +import { sendUndoFollow } from '../../../lib/activitypub/send/send-undo' const serverFollowsRouter = express.Router() @@ -33,6 +35,13 @@ serverFollowsRouter.post('/following', asyncMiddleware(follow) ) +serverFollowsRouter.delete('/following/:accountId', + authenticate, + ensureUserHasRight(UserRight.MANAGE_SERVER_FOLLOW), + removeFollowingValidator, + asyncMiddleware(removeFollow) +) + serverFollowsRouter.get('/followers', paginationValidator, followersSortValidator, @@ -96,10 +105,12 @@ async function follow (req: express.Request, res: express.Response, next: expres }, transaction: t }) + accountFollow.AccountFollowing = targetAccount + accountFollow.AccountFollower = fromAccount // Send a notification to remote server if (accountFollow.state === 'pending') { - await sendFollow(fromAccount, targetAccount, t) + await sendFollow(accountFollow, t) } }) }) @@ -117,6 +128,17 @@ async function follow (req: express.Request, res: express.Response, next: expres return res.status(204).end() } +async function removeFollow (req: express.Request, res: express.Response, next: express.NextFunction) { + const following: AccountFollowInstance = res.locals.following + + await db.sequelize.transaction(async t => { + await sendUndoFollow(following, t) + await following.destroy({ transaction: t }) + }) + + return res.status(204).end() +} + async function loadLocalOrGetAccountFromWebfinger (name: string, host: string) { let loadedFromDB = true let account = await db.Account.loadByNameAndHost(name, host) diff --git a/server/controllers/api/videos/channel.ts b/server/controllers/api/videos/channel.ts index 8f3df2550..ce2656e71 100644 --- a/server/controllers/api/videos/channel.ts +++ b/server/controllers/api/videos/channel.ts @@ -17,7 +17,7 @@ import { videoChannelsUpdateValidator } from '../../../middlewares' import { AccountInstance, VideoChannelInstance } from '../../../models' -import { sendUpdateVideoChannel } from '../../../lib/activitypub/send-request' +import { sendUpdateVideoChannel } from '../../../lib/activitypub/send/send-update' const videoChannelRouter = express.Router() @@ -128,9 +128,9 @@ async function updateVideoChannel (req: express.Request, res: express.Response) if (videoChannelInfoToUpdate.name !== undefined) videoChannelInstance.set('name', videoChannelInfoToUpdate.name) if (videoChannelInfoToUpdate.description !== undefined) videoChannelInstance.set('description', videoChannelInfoToUpdate.description) - await videoChannelInstance.save(sequelizeOptions) + const videoChannelInstanceUpdated = await videoChannelInstance.save(sequelizeOptions) - await sendUpdateVideoChannel(videoChannelInstance, t) + await sendUpdateVideoChannel(videoChannelInstanceUpdated, t) }) logger.info('Video channel with name %s and uuid %s updated.', videoChannelInstance.name, videoChannelInstance.uuid) diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts index 22a88620a..8c9b0aa50 100644 --- a/server/controllers/api/videos/index.ts +++ b/server/controllers/api/videos/index.ts @@ -12,10 +12,11 @@ import { resetSequelizeInstance, retryTransactionWrapper } from '../../../helpers' -import { getActivityPubUrl, shareVideoByServer } from '../../../helpers/activitypub' +import { getVideoActivityPubUrl, shareVideoByServer } from '../../../helpers/activitypub' import { CONFIG, VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES, VIDEO_MIMETYPE_EXT, VIDEO_PRIVACIES } from '../../../initializers' import { database as db } from '../../../initializers/database' -import { sendAddVideo, sendUpdateVideo } from '../../../lib/activitypub/send-request' +import { sendAddVideo } from '../../../lib/activitypub/send/send-add' +import { sendUpdateVideo } from '../../../lib/activitypub/send/send-update' import { transcodingJobScheduler } from '../../../lib/jobs/transcoding-job-scheduler/transcoding-job-scheduler' import { asyncMiddleware, @@ -175,7 +176,7 @@ async function addVideo (req: express.Request, res: express.Response, videoPhysi channelId: res.locals.videoChannel.id } const video = db.Video.build(videoData) - video.url = getActivityPubUrl('video', video.uuid) + video.url = getVideoActivityPubUrl(video) const videoFilePath = join(CONFIG.STORAGE.VIDEOS_DIR, videoPhysicalFile.filename) const videoFileHeight = await getVideoFileHeight(videoFilePath) @@ -274,7 +275,7 @@ async function updateVideo (req: express.Request, res: express.Response) { if (videoInfoToUpdate.privacy !== undefined) videoInstance.set('privacy', videoInfoToUpdate.privacy) if (videoInfoToUpdate.description !== undefined) videoInstance.set('description', videoInfoToUpdate.description) - await videoInstance.save(sequelizeOptions) + const videoInstanceUpdated = await videoInstance.save(sequelizeOptions) if (videoInfoToUpdate.tags) { const tagInstances = await db.Tag.findOrCreateTags(videoInfoToUpdate.tags, t) @@ -285,7 +286,7 @@ async function updateVideo (req: express.Request, res: express.Response) { // Now we'll update the video's meta data to our friends if (wasPrivateVideo === false) { - await sendUpdateVideo(videoInstance, t) + await sendUpdateVideo(videoInstanceUpdated, t) } // Video is not private anymore, send a create action to remote servers diff --git a/server/helpers/activitypub.ts b/server/helpers/activitypub.ts index aff58515a..9622a1801 100644 --- a/server/helpers/activitypub.ts +++ b/server/helpers/activitypub.ts @@ -9,18 +9,20 @@ import { VideoChannelObject } from '../../shared/models/activitypub/objects/vide import { ResultList } from '../../shared/models/result-list.model' import { database as db, REMOTE_SCHEME } from '../initializers' import { ACTIVITY_PUB, CONFIG, STATIC_PATHS } from '../initializers/constants' -import { videoChannelActivityObjectToDBAttributes } from '../lib/activitypub/misc' -import { sendVideoAnnounce } from '../lib/activitypub/send-request' +import { videoChannelActivityObjectToDBAttributes } from '../lib/activitypub/process/misc' +import { sendVideoAnnounce } from '../lib/activitypub/send/send-announce' import { sendVideoChannelAnnounce } from '../lib/index' +import { AccountFollowInstance } from '../models/account/account-follow-interface' import { AccountInstance } from '../models/account/account-interface' +import { VideoAbuseInstance } from '../models/video/video-abuse-interface' import { VideoChannelInstance } from '../models/video/video-channel-interface' import { VideoInstance } from '../models/video/video-interface' import { isRemoteAccountValid } from './custom-validators' -import { isVideoChannelObjectValid } from './custom-validators/activitypub/videos' import { logger } from './logger' import { signObject } from './peertube-crypto' import { doRequest, doRequestAndSaveToFile } from './requests' import { getServerAccount } from './utils' +import { isVideoChannelObjectValid } from './custom-validators/activitypub/video-channels' function generateThumbnailFromUrl (video: VideoInstance, icon: ActivityIconObject) { const thumbnailName = video.getThumbnailName() @@ -55,13 +57,46 @@ async function shareVideoByServer (video: VideoInstance, t: Sequelize.Transactio return sendVideoAnnounce(serverAccount, video, t) } -function getActivityPubUrl (type: 'video' | 'videoChannel' | 'account' | 'videoAbuse', id: string) { - if (type === 'video') return CONFIG.WEBSERVER.URL + '/videos/watch/' + id - else if (type === 'videoChannel') return CONFIG.WEBSERVER.URL + '/video-channels/' + id - else if (type === 'account') return CONFIG.WEBSERVER.URL + '/account/' + id - else if (type === 'videoAbuse') return CONFIG.WEBSERVER.URL + '/admin/video-abuses/' + id +function getVideoActivityPubUrl (video: VideoInstance) { + return CONFIG.WEBSERVER.URL + '/videos/watch/' + video.uuid +} + +function getVideoChannelActivityPubUrl (videoChannel: VideoChannelInstance) { + return CONFIG.WEBSERVER.URL + '/video-channels/' + videoChannel.uuid +} + +function getAccountActivityPubUrl (accountName: string) { + return CONFIG.WEBSERVER.URL + '/account/' + accountName +} + +function getVideoAbuseActivityPubUrl (videoAbuse: VideoAbuseInstance) { + return CONFIG.WEBSERVER.URL + '/admin/video-abuses/' + videoAbuse.id +} + +function getAccountFollowActivityPubUrl (accountFollow: AccountFollowInstance) { + const me = accountFollow.AccountFollower + const following = accountFollow.AccountFollowing + + return me.url + '#follows/' + following.id +} + +function getAccountFollowAcceptActivityPubUrl (accountFollow: AccountFollowInstance) { + const follower = accountFollow.AccountFollower + const me = accountFollow.AccountFollowing + + return follower.url + '#accepts/follows/' + me.id +} + +function getAnnounceActivityPubUrl (originalUrl: string, byAccount: AccountInstance) { + return originalUrl + '#announces/' + byAccount.id +} + +function getUpdateActivityPubUrl (originalUrl: string, updatedAt: string) { + return originalUrl + '#updates/' + updatedAt +} - return '' +function getUndoActivityPubUrl (originalUrl: string) { + return originalUrl + '/undo' } async function getOrCreateAccount (accountUrl: string) { @@ -257,7 +292,6 @@ export { fetchRemoteAccountAndCreateServer, activityPubContextify, activityPubCollectionPagination, - getActivityPubUrl, generateThumbnailFromUrl, getOrCreateAccount, fetchRemoteVideoPreview, @@ -265,7 +299,16 @@ export { shareVideoChannelByServer, shareVideoByServer, getOrCreateVideoChannel, - buildSignedActivity + buildSignedActivity, + getVideoActivityPubUrl, + getVideoChannelActivityPubUrl, + getAccountActivityPubUrl, + getVideoAbuseActivityPubUrl, + getAccountFollowActivityPubUrl, + getAccountFollowAcceptActivityPubUrl, + getAnnounceActivityPubUrl, + getUpdateActivityPubUrl, + getUndoActivityPubUrl } // --------------------------------------------------------------------------- diff --git a/server/helpers/custom-validators/activitypub/activity.ts b/server/helpers/custom-validators/activitypub/activity.ts index 8084cf7b0..9305e092c 100644 --- a/server/helpers/custom-validators/activitypub/activity.ts +++ b/server/helpers/custom-validators/activitypub/activity.ts @@ -1,11 +1,11 @@ import * as validator from 'validator' +import { Activity, ActivityType } from '../../../../shared/models/activitypub/activity' import { isAccountAcceptActivityValid, isAccountDeleteActivityValid, isAccountFollowActivityValid } from './account' +import { isAnnounceValid } from './announce' import { isActivityPubUrlValid } from './misc' +import { isUndoValid } from './undo' +import { isVideoChannelCreateActivityValid, isVideoChannelDeleteActivityValid, isVideoChannelUpdateActivityValid } from './video-channels' import { - isAnnounceValid, - isVideoChannelCreateActivityValid, - isVideoChannelDeleteActivityValid, - isVideoChannelUpdateActivityValid, isVideoFlagValid, isVideoTorrentAddActivityValid, isVideoTorrentDeleteActivityValid, @@ -25,18 +25,23 @@ function isRootActivityValid (activity: any) { ) } +const activityCheckers: { [ P in ActivityType ]: (activity: Activity) => boolean } = { + Create: checkCreateActivity, + Add: checkAddActivity, + Update: checkUpdateActivity, + Delete: checkDeleteActivity, + Follow: checkFollowActivity, + Accept: checkAcceptActivity, + Announce: checkAnnounceActivity, + Undo: checkUndoActivity +} + function isActivityValid (activity: any) { - return isVideoTorrentAddActivityValid(activity) || - isVideoChannelCreateActivityValid(activity) || - isVideoTorrentUpdateActivityValid(activity) || - isVideoChannelUpdateActivityValid(activity) || - isVideoTorrentDeleteActivityValid(activity) || - isVideoChannelDeleteActivityValid(activity) || - isAccountDeleteActivityValid(activity) || - isAccountFollowActivityValid(activity) || - isAccountAcceptActivityValid(activity) || - isVideoFlagValid(activity) || - isAnnounceValid(activity) + const checker = activityCheckers[activity.type] + // Unknown activity type + if (!checker) return false + + return checker(activity) } // --------------------------------------------------------------------------- @@ -45,3 +50,41 @@ export { isRootActivityValid, isActivityValid } + +// --------------------------------------------------------------------------- + +function checkCreateActivity (activity: any) { + return isVideoChannelCreateActivityValid(activity) || + isVideoFlagValid(activity) +} + +function checkAddActivity (activity: any) { + return isVideoTorrentAddActivityValid(activity) +} + +function checkUpdateActivity (activity: any) { + return isVideoTorrentUpdateActivityValid(activity) || + isVideoChannelUpdateActivityValid(activity) +} + +function checkDeleteActivity (activity: any) { + return isVideoTorrentDeleteActivityValid(activity) || + isVideoChannelDeleteActivityValid(activity) || + isAccountDeleteActivityValid(activity) +} + +function checkFollowActivity (activity: any) { + return isAccountFollowActivityValid(activity) +} + +function checkAcceptActivity (activity: any) { + return isAccountAcceptActivityValid(activity) +} + +function checkAnnounceActivity (activity: any) { + return isAnnounceValid(activity) +} + +function checkUndoActivity (activity: any) { + return isUndoValid(activity) +} diff --git a/server/helpers/custom-validators/activitypub/announce.ts b/server/helpers/custom-validators/activitypub/announce.ts new file mode 100644 index 000000000..4ba99d1ea --- /dev/null +++ b/server/helpers/custom-validators/activitypub/announce.ts @@ -0,0 +1,15 @@ +import { isBaseActivityValid } from './misc' +import { isVideoTorrentAddActivityValid } from './videos' +import { isVideoChannelCreateActivityValid } from './video-channels' + +function isAnnounceValid (activity: any) { + return isBaseActivityValid(activity, 'Announce') && + ( + isVideoChannelCreateActivityValid(activity.object) || + isVideoTorrentAddActivityValid(activity.object) + ) +} + +export { + isAnnounceValid +} diff --git a/server/helpers/custom-validators/activitypub/index.ts b/server/helpers/custom-validators/activitypub/index.ts index 0eba06a7b..6685b269f 100644 --- a/server/helpers/custom-validators/activitypub/index.ts +++ b/server/helpers/custom-validators/activitypub/index.ts @@ -1,5 +1,7 @@ export * from './account' export * from './activity' -export * from './signature' export * from './misc' +export * from './signature' +export * from './undo' +export * from './video-channels' export * from './videos' diff --git a/server/helpers/custom-validators/activitypub/undo.ts b/server/helpers/custom-validators/activitypub/undo.ts new file mode 100644 index 000000000..a9a2a3a41 --- /dev/null +++ b/server/helpers/custom-validators/activitypub/undo.ts @@ -0,0 +1,13 @@ +import { isAccountFollowActivityValid } from './account' +import { isBaseActivityValid } from './misc' + +function isUndoValid (activity: any) { + return isBaseActivityValid(activity, 'Undo') && + ( + isAccountFollowActivityValid(activity.object) + ) +} + +export { + isUndoValid +} diff --git a/server/helpers/custom-validators/activitypub/video-channels.ts b/server/helpers/custom-validators/activitypub/video-channels.ts new file mode 100644 index 000000000..9fd3bb149 --- /dev/null +++ b/server/helpers/custom-validators/activitypub/video-channels.ts @@ -0,0 +1,36 @@ +import { isDateValid, isUUIDValid } from '../misc' +import { isVideoChannelDescriptionValid, isVideoChannelNameValid } from '../video-channels' +import { isActivityPubUrlValid, isBaseActivityValid } from './misc' + +function isVideoChannelCreateActivityValid (activity: any) { + return isBaseActivityValid(activity, 'Create') && + isVideoChannelObjectValid(activity.object) +} + +function isVideoChannelUpdateActivityValid (activity: any) { + return isBaseActivityValid(activity, 'Update') && + isVideoChannelObjectValid(activity.object) +} + +function isVideoChannelDeleteActivityValid (activity: any) { + return isBaseActivityValid(activity, 'Delete') +} + +function isVideoChannelObjectValid (videoChannel: any) { + return videoChannel.type === 'VideoChannel' && + isActivityPubUrlValid(videoChannel.id) && + isVideoChannelNameValid(videoChannel.name) && + isVideoChannelDescriptionValid(videoChannel.content) && + isDateValid(videoChannel.published) && + isDateValid(videoChannel.updated) && + isUUIDValid(videoChannel.uuid) +} + +// --------------------------------------------------------------------------- + +export { + isVideoChannelCreateActivityValid, + isVideoChannelUpdateActivityValid, + isVideoChannelDeleteActivityValid, + isVideoChannelObjectValid +} diff --git a/server/helpers/custom-validators/activitypub/videos.ts b/server/helpers/custom-validators/activitypub/videos.ts index 728511e3d..faeedd3df 100644 --- a/server/helpers/custom-validators/activitypub/videos.ts +++ b/server/helpers/custom-validators/activitypub/videos.ts @@ -1,7 +1,6 @@ import * as validator from 'validator' import { ACTIVITY_PUB } from '../../../initializers' import { exists, isDateValid, isUUIDValid } from '../misc' -import { isVideoChannelDescriptionValid, isVideoChannelNameValid } from '../video-channels' import { isVideoAbuseReasonValid, isVideoDurationValid, @@ -28,6 +27,13 @@ function isVideoTorrentDeleteActivityValid (activity: any) { return isBaseActivityValid(activity, 'Delete') } +function isVideoFlagValid (activity: any) { + return isBaseActivityValid(activity, 'Create') && + activity.object.type === 'Flag' && + isVideoAbuseReasonValid(activity.object.content) && + isActivityPubUrlValid(activity.object.object) +} + function isActivityPubVideoDurationValid (value: string) { // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration return exists(value) && @@ -57,57 +63,13 @@ function isVideoTorrentObjectValid (video: any) { video.url.length !== 0 } -function isVideoFlagValid (activity: any) { - return isBaseActivityValid(activity, 'Create') && - activity.object.type === 'Flag' && - isVideoAbuseReasonValid(activity.object.content) && - isActivityPubUrlValid(activity.object.object) -} - -function isAnnounceValid (activity: any) { - return isBaseActivityValid(activity, 'Announce') && - ( - isVideoChannelCreateActivityValid(activity.object) || - isVideoTorrentAddActivityValid(activity.object) - ) -} - -function isVideoChannelCreateActivityValid (activity: any) { - return isBaseActivityValid(activity, 'Create') && - isVideoChannelObjectValid(activity.object) -} - -function isVideoChannelUpdateActivityValid (activity: any) { - return isBaseActivityValid(activity, 'Update') && - isVideoChannelObjectValid(activity.object) -} - -function isVideoChannelDeleteActivityValid (activity: any) { - return isBaseActivityValid(activity, 'Delete') -} - -function isVideoChannelObjectValid (videoChannel: any) { - return videoChannel.type === 'VideoChannel' && - isActivityPubUrlValid(videoChannel.id) && - isVideoChannelNameValid(videoChannel.name) && - isVideoChannelDescriptionValid(videoChannel.content) && - isDateValid(videoChannel.published) && - isDateValid(videoChannel.updated) && - isUUIDValid(videoChannel.uuid) -} - // --------------------------------------------------------------------------- export { isVideoTorrentAddActivityValid, - isVideoChannelCreateActivityValid, isVideoTorrentUpdateActivityValid, - isVideoChannelUpdateActivityValid, - isVideoChannelDeleteActivityValid, isVideoTorrentDeleteActivityValid, - isVideoFlagValid, - isAnnounceValid, - isVideoChannelObjectValid + isVideoFlagValid } // --------------------------------------------------------------------------- diff --git a/server/lib/activitypub/index.ts b/server/lib/activitypub/index.ts index e08108aac..1bea0a412 100644 --- a/server/lib/activitypub/index.ts +++ b/server/lib/activitypub/index.ts @@ -1,8 +1,2 @@ -export * from './process-accept' -export * from './process-add' -export * from './process-announce' -export * from './process-create' -export * from './process-delete' -export * from './process-follow' -export * from './process-update' -export * from './send-request' +export * from './process' +export * from './send' diff --git a/server/lib/activitypub/misc.ts b/server/lib/activitypub/misc.ts deleted file mode 100644 index 4c210eb10..000000000 --- a/server/lib/activitypub/misc.ts +++ /dev/null @@ -1,101 +0,0 @@ -import * as magnetUtil from 'magnet-uri' -import { VideoTorrentObject } from '../../../shared' -import { VideoChannelObject } from '../../../shared/models/activitypub/objects/video-channel-object' -import { isVideoFileInfoHashValid } from '../../helpers/custom-validators/videos' -import { ACTIVITY_PUB, VIDEO_MIMETYPE_EXT } from '../../initializers/constants' -import { AccountInstance } from '../../models/account/account-interface' -import { VideoChannelInstance } from '../../models/video/video-channel-interface' -import { VideoFileAttributes } from '../../models/video/video-file-interface' -import { VideoAttributes, VideoInstance } from '../../models/video/video-interface' -import { VideoPrivacy } from '../../../shared/models/videos/video-privacy.enum' - -function videoChannelActivityObjectToDBAttributes (videoChannelObject: VideoChannelObject, account: AccountInstance) { - return { - name: videoChannelObject.name, - description: videoChannelObject.content, - uuid: videoChannelObject.uuid, - url: videoChannelObject.id, - createdAt: new Date(videoChannelObject.published), - updatedAt: new Date(videoChannelObject.updated), - remote: true, - accountId: account.id - } -} - -async function videoActivityObjectToDBAttributes ( - videoChannel: VideoChannelInstance, - videoObject: VideoTorrentObject, - to: string[] = [], - cc: string[] = [] -) { - let privacy = VideoPrivacy.PRIVATE - if (to.indexOf(ACTIVITY_PUB.PUBLIC) !== -1) privacy = VideoPrivacy.PUBLIC - else if (cc.indexOf(ACTIVITY_PUB.PUBLIC) !== -1) privacy = VideoPrivacy.UNLISTED - - const duration = videoObject.duration.replace(/[^\d]+/, '') - 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), - nsfw: videoObject.nsfw, - description: videoObject.content, - channelId: videoChannel.id, - duration: parseInt(duration, 10), - createdAt: new Date(videoObject.published), - // FIXME: updatedAt does not seems to be considered by Sequelize - updatedAt: new Date(videoObject.updated), - views: videoObject.views, - likes: 0, - dislikes: 0, - remote: true, - privacy - } - - return videoData -} - -function videoFileActivityUrlToDBAttributes (videoCreated: VideoInstance, videoObject: VideoTorrentObject) { - const mimeTypes = Object.keys(VIDEO_MIMETYPE_EXT) - const fileUrls = videoObject.url.filter(u => { - return mimeTypes.indexOf(u.mimeType) !== -1 && u.mimeType.startsWith('video/') - }) - - if (fileUrls.length === 0) { - throw new Error('Cannot find video files for ' + videoCreated.url) - } - - const attributes: VideoFileAttributes[] = [] - for (const fileUrl of fileUrls) { - // Fetch associated magnet uri - const magnet = videoObject.url.find(u => { - return u.mimeType === 'application/x-bittorrent;x-scheme-handler/magnet' && u.width === fileUrl.width - }) - - if (!magnet) throw new Error('Cannot find associated magnet uri for file ' + fileUrl.url) - - const parsed = magnetUtil.decode(magnet.url) - if (!parsed || isVideoFileInfoHashValid(parsed.infoHash) === false) throw new Error('Cannot parse magnet URI ' + magnet.url) - - const attribute = { - extname: VIDEO_MIMETYPE_EXT[fileUrl.mimeType], - infoHash: parsed.infoHash, - resolution: fileUrl.width, - size: fileUrl.size, - videoId: videoCreated.id - } - attributes.push(attribute) - } - - return attributes -} - -// --------------------------------------------------------------------------- - -export { - videoFileActivityUrlToDBAttributes, - videoActivityObjectToDBAttributes, - videoChannelActivityObjectToDBAttributes -} diff --git a/server/lib/activitypub/process-accept.ts b/server/lib/activitypub/process-accept.ts deleted file mode 100644 index 9e0cd4032..000000000 --- a/server/lib/activitypub/process-accept.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { ActivityAccept } from '../../../shared/models/activitypub/activity' -import { database as db } from '../../initializers' -import { AccountInstance } from '../../models/account/account-interface' - -async function processAcceptActivity (activity: ActivityAccept, inboxAccount?: AccountInstance) { - if (inboxAccount === undefined) throw new Error('Need to accept on explicit inbox.') - - const targetAccount = await db.Account.loadByUrl(activity.actor) - - return processAccept(inboxAccount, targetAccount) -} - -// --------------------------------------------------------------------------- - -export { - processAcceptActivity -} - -// --------------------------------------------------------------------------- - -async function processAccept (account: AccountInstance, targetAccount: AccountInstance) { - const follow = await db.AccountFollow.loadByAccountAndTarget(account.id, targetAccount.id) - if (!follow) throw new Error('Cannot find associated follow.') - - follow.set('state', 'accepted') - await follow.save() -} diff --git a/server/lib/activitypub/process-add.ts b/server/lib/activitypub/process-add.ts deleted file mode 100644 index e1769bee8..000000000 --- a/server/lib/activitypub/process-add.ts +++ /dev/null @@ -1,87 +0,0 @@ -import * as Bluebird from 'bluebird' -import { VideoTorrentObject } from '../../../shared' -import { ActivityAdd } from '../../../shared/models/activitypub/activity' -import { generateThumbnailFromUrl, getOrCreateAccount, logger, retryTransactionWrapper } from '../../helpers' -import { getOrCreateVideoChannel } from '../../helpers/activitypub' -import { database as db } from '../../initializers' -import { AccountInstance } from '../../models/account/account-interface' -import { VideoChannelInstance } from '../../models/video/video-channel-interface' -import { videoActivityObjectToDBAttributes, videoFileActivityUrlToDBAttributes } from './misc' - -async function processAddActivity (activity: ActivityAdd) { - const activityObject = activity.object - const activityType = activityObject.type - const account = await getOrCreateAccount(activity.actor) - - if (activityType === 'Video') { - const videoChannelUrl = activity.target - const videoChannel = await getOrCreateVideoChannel(account, videoChannelUrl) - - return processAddVideo(account, activity, videoChannel, activityObject) - } - - logger.warn('Unknown activity object type %s when creating activity.', activityType, { activity: activity.id }) - return Promise.resolve(undefined) -} - -// --------------------------------------------------------------------------- - -export { - processAddActivity -} - -// --------------------------------------------------------------------------- - -function processAddVideo (account: AccountInstance, activity: ActivityAdd, videoChannel: VideoChannelInstance, video: VideoTorrentObject) { - const options = { - arguments: [ account, activity, videoChannel, video ], - errorMessage: 'Cannot insert the remote video with many retries.' - } - - return retryTransactionWrapper(addRemoteVideo, options) -} - -function addRemoteVideo ( - account: AccountInstance, - activity: ActivityAdd, - videoChannel: VideoChannelInstance, - videoToCreateData: VideoTorrentObject -) { - logger.debug('Adding remote video %s.', videoToCreateData.url) - - return db.sequelize.transaction(async t => { - const sequelizeOptions = { - transaction: t - } - - if (videoChannel.Account.id !== account.id) throw new Error('Video channel is not owned by this account.') - - const videoFromDatabase = await db.Video.loadByUUIDOrURL(videoToCreateData.uuid, videoToCreateData.id, t) - if (videoFromDatabase) throw new Error('Video with this UUID/Url already exists.') - - const videoData = await videoActivityObjectToDBAttributes(videoChannel, videoToCreateData, activity.to, activity.cc) - const video = db.Video.build(videoData) - - // Don't block on request - generateThumbnailFromUrl(video, videoToCreateData.icon) - .catch(err => logger.warn('Cannot generate thumbnail of %s.', videoToCreateData.id, err)) - - const videoCreated = await video.save(sequelizeOptions) - - const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoCreated, videoToCreateData) - if (videoFileAttributes.length === 0) { - throw new Error('Cannot find valid files for video %s ' + videoToCreateData.url) - } - - const tasks: Bluebird[] = videoFileAttributes.map(f => db.VideoFile.create(f, { transaction: t })) - await Promise.all(tasks) - - const tags = videoToCreateData.tag.map(t => t.name) - const tagInstances = await db.Tag.findOrCreateTags(tags, t) - await videoCreated.setTags(tagInstances, sequelizeOptions) - - logger.info('Remote video with uuid %s inserted.', videoToCreateData.uuid) - - return videoCreated - }) -} diff --git a/server/lib/activitypub/process-announce.ts b/server/lib/activitypub/process-announce.ts deleted file mode 100644 index eb38aecca..000000000 --- a/server/lib/activitypub/process-announce.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { ActivityAnnounce } from '../../../shared/models/activitypub/activity' -import { getOrCreateAccount } from '../../helpers/activitypub' -import { logger } from '../../helpers/logger' -import { database as db } from '../../initializers/index' -import { VideoInstance } from '../../models/index' -import { VideoChannelInstance } from '../../models/video/video-channel-interface' -import { processAddActivity } from './process-add' -import { processCreateActivity } from './process-create' - -async function processAnnounceActivity (activity: ActivityAnnounce) { - const announcedActivity = activity.object - const accountAnnouncer = await getOrCreateAccount(activity.actor) - - if (announcedActivity.type === 'Create' && announcedActivity.object.type === 'VideoChannel') { - // Add share entry - const videoChannel: VideoChannelInstance = await processCreateActivity(announcedActivity) - await db.VideoChannelShare.create({ - accountId: accountAnnouncer.id, - videoChannelId: videoChannel.id - }) - - return undefined - } else if (announcedActivity.type === 'Add' && announcedActivity.object.type === 'Video') { - // Add share entry - const video: VideoInstance = await processAddActivity(announcedActivity) - await db.VideoShare.create({ - accountId: accountAnnouncer.id, - videoId: video.id - }) - - return undefined - } - - logger.warn( - 'Unknown activity object type %s -> %s when announcing activity.', announcedActivity.type, announcedActivity.object.type, - { activity: activity.id } - ) - return Promise.resolve(undefined) -} - -// --------------------------------------------------------------------------- - -export { - processAnnounceActivity -} diff --git a/server/lib/activitypub/process-create.ts b/server/lib/activitypub/process-create.ts deleted file mode 100644 index de8e09adf..000000000 --- a/server/lib/activitypub/process-create.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { ActivityCreate, VideoChannelObject } from '../../../shared' -import { VideoAbuseObject } from '../../../shared/models/activitypub/objects/video-abuse-object' -import { logger, retryTransactionWrapper } from '../../helpers' -import { getActivityPubUrl, getOrCreateAccount } from '../../helpers/activitypub' -import { database as db } from '../../initializers' -import { AccountInstance } from '../../models/account/account-interface' -import { videoChannelActivityObjectToDBAttributes } from './misc' - -async function processCreateActivity (activity: ActivityCreate) { - const activityObject = activity.object - const activityType = activityObject.type - const account = await getOrCreateAccount(activity.actor) - - if (activityType === 'VideoChannel') { - return processCreateVideoChannel(account, activityObject as VideoChannelObject) - } else if (activityType === 'Flag') { - return processCreateVideoAbuse(account, activityObject as VideoAbuseObject) - } - - logger.warn('Unknown activity object type %s when creating activity.', activityType, { activity: activity.id }) - return Promise.resolve(undefined) -} - -// --------------------------------------------------------------------------- - -export { - processCreateActivity -} - -// --------------------------------------------------------------------------- - -function processCreateVideoChannel (account: AccountInstance, videoChannelToCreateData: VideoChannelObject) { - const options = { - arguments: [ account, videoChannelToCreateData ], - errorMessage: 'Cannot insert the remote video channel with many retries.' - } - - return retryTransactionWrapper(addRemoteVideoChannel, options) -} - -function addRemoteVideoChannel (account: AccountInstance, videoChannelToCreateData: VideoChannelObject) { - logger.debug('Adding remote video channel "%s".', videoChannelToCreateData.uuid) - - return db.sequelize.transaction(async t => { - let videoChannel = await db.VideoChannel.loadByUUIDOrUrl(videoChannelToCreateData.uuid, videoChannelToCreateData.id, t) - if (videoChannel) throw new Error('Video channel with this URL/UUID already exists.') - - const videoChannelData = videoChannelActivityObjectToDBAttributes(videoChannelToCreateData, account) - videoChannel = db.VideoChannel.build(videoChannelData) - videoChannel.url = getActivityPubUrl('videoChannel', videoChannel.uuid) - - videoChannel = await videoChannel.save({ transaction: t }) - logger.info('Remote video channel with uuid %s inserted.', videoChannelToCreateData.uuid) - - return videoChannel - }) -} - -function processCreateVideoAbuse (account: AccountInstance, videoAbuseToCreateData: VideoAbuseObject) { - const options = { - arguments: [ account, videoAbuseToCreateData ], - errorMessage: 'Cannot insert the remote video abuse with many retries.' - } - - return retryTransactionWrapper(addRemoteVideoAbuse, options) -} - -function addRemoteVideoAbuse (account: AccountInstance, videoAbuseToCreateData: VideoAbuseObject) { - logger.debug('Reporting remote abuse for video %s.', videoAbuseToCreateData.object) - - return db.sequelize.transaction(async t => { - const video = await db.Video.loadByUrlAndPopulateAccount(videoAbuseToCreateData.object, t) - if (!video) { - logger.warn('Unknown video %s for remote video abuse.', videoAbuseToCreateData.object) - return undefined - } - - const videoAbuseData = { - reporterAccountId: account.id, - reason: videoAbuseToCreateData.content, - videoId: video.id - } - - await db.VideoAbuse.create(videoAbuseData) - - logger.info('Remote abuse for video uuid %s created', videoAbuseToCreateData.object) - }) -} diff --git a/server/lib/activitypub/process-delete.ts b/server/lib/activitypub/process-delete.ts deleted file mode 100644 index 0d5756e9c..000000000 --- a/server/lib/activitypub/process-delete.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { ActivityDelete } from '../../../shared/models/activitypub/activity' -import { getOrCreateAccount } from '../../helpers/activitypub' -import { retryTransactionWrapper } from '../../helpers/database-utils' -import { logger } from '../../helpers/logger' -import { database as db } from '../../initializers' -import { AccountInstance } from '../../models/account/account-interface' -import { VideoChannelInstance } from '../../models/video/video-channel-interface' -import { VideoInstance } from '../../models/video/video-interface' - -async function processDeleteActivity (activity: ActivityDelete) { - const account = await getOrCreateAccount(activity.actor) - - if (account.url === activity.id) { - return processDeleteAccount(account) - } - - { - let videoObject = await db.Video.loadByUrlAndPopulateAccount(activity.id) - if (videoObject !== undefined) { - return processDeleteVideo(account, videoObject) - } - } - - { - let videoChannelObject = await db.VideoChannel.loadByUrl(activity.id) - if (videoChannelObject !== undefined) { - return processDeleteVideoChannel(account, videoChannelObject) - } - } - - return -} - -// --------------------------------------------------------------------------- - -export { - processDeleteActivity -} - -// --------------------------------------------------------------------------- - -async function processDeleteVideo (account: AccountInstance, videoToDelete: VideoInstance) { - const options = { - arguments: [ account, videoToDelete ], - errorMessage: 'Cannot remove the remote video with many retries.' - } - - await retryTransactionWrapper(deleteRemoteVideo, options) -} - -async function deleteRemoteVideo (account: AccountInstance, videoToDelete: VideoInstance) { - logger.debug('Removing remote video "%s".', videoToDelete.uuid) - - await db.sequelize.transaction(async t => { - if (videoToDelete.VideoChannel.Account.id !== account.id) { - throw new Error('Account ' + account.url + ' does not own video channel ' + videoToDelete.VideoChannel.url) - } - - await videoToDelete.destroy({ transaction: t }) - }) - - logger.info('Remote video with uuid %s removed.', videoToDelete.uuid) -} - -async function processDeleteVideoChannel (account: AccountInstance, videoChannelToRemove: VideoChannelInstance) { - const options = { - arguments: [ account, videoChannelToRemove ], - errorMessage: 'Cannot remove the remote video channel with many retries.' - } - - await retryTransactionWrapper(deleteRemoteVideoChannel, options) -} - -async function deleteRemoteVideoChannel (account: AccountInstance, videoChannelToRemove: VideoChannelInstance) { - logger.debug('Removing remote video channel "%s".', videoChannelToRemove.uuid) - - await db.sequelize.transaction(async t => { - if (videoChannelToRemove.Account.id !== account.id) { - throw new Error('Account ' + account.url + ' does not own video channel ' + videoChannelToRemove.url) - } - - await videoChannelToRemove.destroy({ transaction: t }) - }) - - logger.info('Remote video channel with uuid %s removed.', videoChannelToRemove.uuid) -} - -async function processDeleteAccount (accountToRemove: AccountInstance) { - const options = { - arguments: [ accountToRemove ], - errorMessage: 'Cannot remove the remote account with many retries.' - } - - await retryTransactionWrapper(deleteRemoteAccount, options) -} - -async function deleteRemoteAccount (accountToRemove: AccountInstance) { - logger.debug('Removing remote account "%s".', accountToRemove.uuid) - - await db.sequelize.transaction(async t => { - await accountToRemove.destroy({ transaction: t }) - }) - - logger.info('Remote account with uuid %s removed.', accountToRemove.uuid) -} diff --git a/server/lib/activitypub/process-follow.ts b/server/lib/activitypub/process-follow.ts deleted file mode 100644 index a805c0757..000000000 --- a/server/lib/activitypub/process-follow.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { ActivityFollow } from '../../../shared/models/activitypub/activity' -import { getOrCreateAccount, retryTransactionWrapper } from '../../helpers' -import { database as db } from '../../initializers' -import { AccountInstance } from '../../models/account/account-interface' -import { sendAccept } from './send-request' -import { logger } from '../../helpers/logger' - -async function processFollowActivity (activity: ActivityFollow) { - const activityObject = activity.object - const account = await getOrCreateAccount(activity.actor) - - return processFollow(account, activityObject) -} - -// --------------------------------------------------------------------------- - -export { - processFollowActivity -} - -// --------------------------------------------------------------------------- - -function processFollow (account: AccountInstance, targetAccountURL: string) { - const options = { - arguments: [ account, targetAccountURL ], - errorMessage: 'Cannot follow with many retries.' - } - - return retryTransactionWrapper(follow, options) -} - -async function follow (account: AccountInstance, targetAccountURL: string) { - await db.sequelize.transaction(async t => { - const targetAccount = await db.Account.loadByUrl(targetAccountURL, t) - - if (targetAccount === undefined) throw new Error('Unknown account') - if (targetAccount.isOwned() === false) throw new Error('This is not a local account.') - - await db.AccountFollow.findOrCreate({ - where: { - accountId: account.id, - targetAccountId: targetAccount.id - }, - defaults: { - accountId: account.id, - targetAccountId: targetAccount.id, - state: 'accepted' - }, - transaction: t - }) - - // Target sends to account he accepted the follow request - return sendAccept(targetAccount, account, t) - }) - - logger.info('Account uuid %s is followed by account %s.', account.url, targetAccountURL) -} diff --git a/server/lib/activitypub/process-update.ts b/server/lib/activitypub/process-update.ts deleted file mode 100644 index a9aa5eeb4..000000000 --- a/server/lib/activitypub/process-update.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { VideoChannelObject, VideoTorrentObject } from '../../../shared' -import { ActivityUpdate } from '../../../shared/models/activitypub/activity' -import { getOrCreateAccount } from '../../helpers/activitypub' -import { retryTransactionWrapper } from '../../helpers/database-utils' -import { logger } from '../../helpers/logger' -import { resetSequelizeInstance } from '../../helpers/utils' -import { database as db } from '../../initializers' -import { AccountInstance } from '../../models/account/account-interface' -import { VideoInstance } from '../../models/video/video-interface' -import { videoActivityObjectToDBAttributes, videoFileActivityUrlToDBAttributes } from './misc' -import Bluebird = require('bluebird') - -async function processUpdateActivity (activity: ActivityUpdate) { - const account = await getOrCreateAccount(activity.actor) - - if (activity.object.type === 'Video') { - return processUpdateVideo(account, activity.object) - } else if (activity.object.type === 'VideoChannel') { - return processUpdateVideoChannel(account, activity.object) - } - - return undefined -} - -// --------------------------------------------------------------------------- - -export { - processUpdateActivity -} - -// --------------------------------------------------------------------------- - -function processUpdateVideo (account: AccountInstance, video: VideoTorrentObject) { - const options = { - arguments: [ account, video ], - errorMessage: 'Cannot update the remote video with many retries' - } - - return retryTransactionWrapper(updateRemoteVideo, options) -} - -async function updateRemoteVideo (account: AccountInstance, videoAttributesToUpdate: VideoTorrentObject) { - logger.debug('Updating remote video "%s".', videoAttributesToUpdate.uuid) - let videoInstance: VideoInstance - let videoFieldsSave: object - - try { - await db.sequelize.transaction(async t => { - const sequelizeOptions = { - transaction: t - } - - const videoInstance = await db.Video.loadByUrlAndPopulateAccount(videoAttributesToUpdate.id, t) - if (!videoInstance) throw new Error('Video ' + videoAttributesToUpdate.id + ' not found.') - - if (videoInstance.VideoChannel.Account.id !== account.id) { - throw new Error('Account ' + account.url + ' does not own video channel ' + videoInstance.VideoChannel.url) - } - - const videoData = await videoActivityObjectToDBAttributes(videoInstance.VideoChannel, videoAttributesToUpdate) - videoInstance.set('name', videoData.name) - videoInstance.set('category', videoData.category) - videoInstance.set('licence', videoData.licence) - videoInstance.set('language', videoData.language) - videoInstance.set('nsfw', videoData.nsfw) - videoInstance.set('description', videoData.description) - videoInstance.set('duration', videoData.duration) - videoInstance.set('createdAt', videoData.createdAt) - videoInstance.set('updatedAt', videoData.updatedAt) - videoInstance.set('views', videoData.views) - // videoInstance.set('likes', videoData.likes) - // videoInstance.set('dislikes', videoData.dislikes) - - await videoInstance.save(sequelizeOptions) - - // Remove old video files - const videoFileDestroyTasks: Bluebird[] = [] - for (const videoFile of videoInstance.VideoFiles) { - videoFileDestroyTasks.push(videoFile.destroy(sequelizeOptions)) - } - await Promise.all(videoFileDestroyTasks) - - const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoInstance, videoAttributesToUpdate) - const tasks: Bluebird[] = videoFileAttributes.map(f => db.VideoFile.create(f)) - await Promise.all(tasks) - - const tags = videoAttributesToUpdate.tag.map(t => t.name) - const tagInstances = await db.Tag.findOrCreateTags(tags, t) - await videoInstance.setTags(tagInstances, sequelizeOptions) - }) - - logger.info('Remote video with uuid %s updated', videoAttributesToUpdate.uuid) - } catch (err) { - if (videoInstance !== undefined && videoFieldsSave !== undefined) { - resetSequelizeInstance(videoInstance, videoFieldsSave) - } - - // This is just a debug because we will retry the insert - logger.debug('Cannot update the remote video.', err) - throw err - } -} - -async function processUpdateVideoChannel (account: AccountInstance, videoChannel: VideoChannelObject) { - const options = { - arguments: [ account, videoChannel ], - errorMessage: 'Cannot update the remote video channel with many retries.' - } - - await retryTransactionWrapper(updateRemoteVideoChannel, options) -} - -async function updateRemoteVideoChannel (account: AccountInstance, videoChannel: VideoChannelObject) { - logger.debug('Updating remote video channel "%s".', videoChannel.uuid) - - await db.sequelize.transaction(async t => { - const sequelizeOptions = { transaction: t } - - const videoChannelInstance = await db.VideoChannel.loadByUrl(videoChannel.id) - if (!videoChannelInstance) throw new Error('Video ' + videoChannel.id + ' not found.') - - if (videoChannelInstance.Account.id !== account.id) { - throw new Error('Account ' + account.id + ' does not own video channel ' + videoChannelInstance.url) - } - - videoChannelInstance.set('name', videoChannel.name) - videoChannelInstance.set('description', videoChannel.content) - videoChannelInstance.set('createdAt', videoChannel.published) - videoChannelInstance.set('updatedAt', videoChannel.updated) - - await videoChannelInstance.save(sequelizeOptions) - }) - - logger.info('Remote video channel with uuid %s updated', videoChannel.uuid) -} diff --git a/server/lib/activitypub/process/index.ts b/server/lib/activitypub/process/index.ts new file mode 100644 index 000000000..e80b46b6f --- /dev/null +++ b/server/lib/activitypub/process/index.ts @@ -0,0 +1,8 @@ +export * from './process-accept' +export * from './process-add' +export * from './process-announce' +export * from './process-create' +export * from './process-delete' +export * from './process-follow' +export * from './process-undo' +export * from './process-update' diff --git a/server/lib/activitypub/process/misc.ts b/server/lib/activitypub/process/misc.ts new file mode 100644 index 000000000..e90a793fc --- /dev/null +++ b/server/lib/activitypub/process/misc.ts @@ -0,0 +1,101 @@ +import * as magnetUtil from 'magnet-uri' +import { VideoTorrentObject } from '../../../../shared' +import { VideoChannelObject } from '../../../../shared/models/activitypub/objects/video-channel-object' +import { isVideoFileInfoHashValid } from '../../../helpers/custom-validators/videos' +import { ACTIVITY_PUB, VIDEO_MIMETYPE_EXT } from '../../../initializers/constants' +import { AccountInstance } from '../../../models/account/account-interface' +import { VideoChannelInstance } from '../../../models/video/video-channel-interface' +import { VideoFileAttributes } from '../../../models/video/video-file-interface' +import { VideoAttributes, VideoInstance } from '../../../models/video/video-interface' +import { VideoPrivacy } from '../../../../shared/models/videos/video-privacy.enum' + +function videoChannelActivityObjectToDBAttributes (videoChannelObject: VideoChannelObject, account: AccountInstance) { + return { + name: videoChannelObject.name, + description: videoChannelObject.content, + uuid: videoChannelObject.uuid, + url: videoChannelObject.id, + createdAt: new Date(videoChannelObject.published), + updatedAt: new Date(videoChannelObject.updated), + remote: true, + accountId: account.id + } +} + +async function videoActivityObjectToDBAttributes ( + videoChannel: VideoChannelInstance, + videoObject: VideoTorrentObject, + to: string[] = [], + cc: string[] = [] +) { + let privacy = VideoPrivacy.PRIVATE + if (to.indexOf(ACTIVITY_PUB.PUBLIC) !== -1) privacy = VideoPrivacy.PUBLIC + else if (cc.indexOf(ACTIVITY_PUB.PUBLIC) !== -1) privacy = VideoPrivacy.UNLISTED + + const duration = videoObject.duration.replace(/[^\d]+/, '') + 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), + nsfw: videoObject.nsfw, + description: videoObject.content, + channelId: videoChannel.id, + duration: parseInt(duration, 10), + createdAt: new Date(videoObject.published), + // FIXME: updatedAt does not seems to be considered by Sequelize + updatedAt: new Date(videoObject.updated), + views: videoObject.views, + likes: 0, + dislikes: 0, + remote: true, + privacy + } + + return videoData +} + +function videoFileActivityUrlToDBAttributes (videoCreated: VideoInstance, videoObject: VideoTorrentObject) { + const mimeTypes = Object.keys(VIDEO_MIMETYPE_EXT) + const fileUrls = videoObject.url.filter(u => { + return mimeTypes.indexOf(u.mimeType) !== -1 && u.mimeType.startsWith('video/') + }) + + if (fileUrls.length === 0) { + throw new Error('Cannot find video files for ' + videoCreated.url) + } + + const attributes: VideoFileAttributes[] = [] + for (const fileUrl of fileUrls) { + // Fetch associated magnet uri + const magnet = videoObject.url.find(u => { + return u.mimeType === 'application/x-bittorrent;x-scheme-handler/magnet' && u.width === fileUrl.width + }) + + if (!magnet) throw new Error('Cannot find associated magnet uri for file ' + fileUrl.url) + + const parsed = magnetUtil.decode(magnet.url) + if (!parsed || isVideoFileInfoHashValid(parsed.infoHash) === false) throw new Error('Cannot parse magnet URI ' + magnet.url) + + const attribute = { + extname: VIDEO_MIMETYPE_EXT[fileUrl.mimeType], + infoHash: parsed.infoHash, + resolution: fileUrl.width, + size: fileUrl.size, + videoId: videoCreated.id + } + attributes.push(attribute) + } + + return attributes +} + +// --------------------------------------------------------------------------- + +export { + videoFileActivityUrlToDBAttributes, + videoActivityObjectToDBAttributes, + videoChannelActivityObjectToDBAttributes +} diff --git a/server/lib/activitypub/process/process-accept.ts b/server/lib/activitypub/process/process-accept.ts new file mode 100644 index 000000000..e159c41b5 --- /dev/null +++ b/server/lib/activitypub/process/process-accept.ts @@ -0,0 +1,27 @@ +import { ActivityAccept } from '../../../../shared/models/activitypub/activity' +import { database as db } from '../../../initializers' +import { AccountInstance } from '../../../models/account/account-interface' + +async function processAcceptActivity (activity: ActivityAccept, inboxAccount?: AccountInstance) { + if (inboxAccount === undefined) throw new Error('Need to accept on explicit inbox.') + + const targetAccount = await db.Account.loadByUrl(activity.actor) + + return processAccept(inboxAccount, targetAccount) +} + +// --------------------------------------------------------------------------- + +export { + processAcceptActivity +} + +// --------------------------------------------------------------------------- + +async function processAccept (account: AccountInstance, targetAccount: AccountInstance) { + const follow = await db.AccountFollow.loadByAccountAndTarget(account.id, targetAccount.id) + if (!follow) throw new Error('Cannot find associated follow.') + + follow.set('state', 'accepted') + await follow.save() +} diff --git a/server/lib/activitypub/process/process-add.ts b/server/lib/activitypub/process/process-add.ts new file mode 100644 index 000000000..f064c1ab6 --- /dev/null +++ b/server/lib/activitypub/process/process-add.ts @@ -0,0 +1,87 @@ +import * as Bluebird from 'bluebird' +import { VideoTorrentObject } from '../../../../shared' +import { ActivityAdd } from '../../../../shared/models/activitypub/activity' +import { generateThumbnailFromUrl, getOrCreateAccount, logger, retryTransactionWrapper } from '../../../helpers' +import { getOrCreateVideoChannel } from '../../../helpers/activitypub' +import { database as db } from '../../../initializers' +import { AccountInstance } from '../../../models/account/account-interface' +import { VideoChannelInstance } from '../../../models/video/video-channel-interface' +import { videoActivityObjectToDBAttributes, videoFileActivityUrlToDBAttributes } from './misc' + +async function processAddActivity (activity: ActivityAdd) { + const activityObject = activity.object + const activityType = activityObject.type + const account = await getOrCreateAccount(activity.actor) + + if (activityType === 'Video') { + const videoChannelUrl = activity.target + const videoChannel = await getOrCreateVideoChannel(account, videoChannelUrl) + + return processAddVideo(account, activity, videoChannel, activityObject) + } + + logger.warn('Unknown activity object type %s when creating activity.', activityType, { activity: activity.id }) + return Promise.resolve(undefined) +} + +// --------------------------------------------------------------------------- + +export { + processAddActivity +} + +// --------------------------------------------------------------------------- + +function processAddVideo (account: AccountInstance, activity: ActivityAdd, videoChannel: VideoChannelInstance, video: VideoTorrentObject) { + const options = { + arguments: [ account, activity, videoChannel, video ], + errorMessage: 'Cannot insert the remote video with many retries.' + } + + return retryTransactionWrapper(addRemoteVideo, options) +} + +function addRemoteVideo ( + account: AccountInstance, + activity: ActivityAdd, + videoChannel: VideoChannelInstance, + videoToCreateData: VideoTorrentObject +) { + logger.debug('Adding remote video %s.', videoToCreateData.url) + + return db.sequelize.transaction(async t => { + const sequelizeOptions = { + transaction: t + } + + if (videoChannel.Account.id !== account.id) throw new Error('Video channel is not owned by this account.') + + const videoFromDatabase = await db.Video.loadByUUIDOrURL(videoToCreateData.uuid, videoToCreateData.id, t) + if (videoFromDatabase) throw new Error('Video with this UUID/Url already exists.') + + const videoData = await videoActivityObjectToDBAttributes(videoChannel, videoToCreateData, activity.to, activity.cc) + const video = db.Video.build(videoData) + + // Don't block on request + generateThumbnailFromUrl(video, videoToCreateData.icon) + .catch(err => logger.warn('Cannot generate thumbnail of %s.', videoToCreateData.id, err)) + + const videoCreated = await video.save(sequelizeOptions) + + const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoCreated, videoToCreateData) + if (videoFileAttributes.length === 0) { + throw new Error('Cannot find valid files for video %s ' + videoToCreateData.url) + } + + const tasks: Bluebird[] = videoFileAttributes.map(f => db.VideoFile.create(f, { transaction: t })) + await Promise.all(tasks) + + const tags = videoToCreateData.tag.map(t => t.name) + const tagInstances = await db.Tag.findOrCreateTags(tags, t) + await videoCreated.setTags(tagInstances, sequelizeOptions) + + logger.info('Remote video with uuid %s inserted.', videoToCreateData.uuid) + + return videoCreated + }) +} diff --git a/server/lib/activitypub/process/process-announce.ts b/server/lib/activitypub/process/process-announce.ts new file mode 100644 index 000000000..656db08a9 --- /dev/null +++ b/server/lib/activitypub/process/process-announce.ts @@ -0,0 +1,46 @@ +import { ActivityAnnounce } from '../../../../shared/models/activitypub/activity' +import { getOrCreateAccount } from '../../../helpers/activitypub' +import { logger } from '../../../helpers/logger' +import { database as db } from '../../../initializers/index' +import { VideoInstance } from '../../../models/index' +import { VideoChannelInstance } from '../../../models/video/video-channel-interface' +import { processAddActivity } from './process-add' +import { processCreateActivity } from './process-create' + +async function processAnnounceActivity (activity: ActivityAnnounce) { + const announcedActivity = activity.object + const accountAnnouncer = await getOrCreateAccount(activity.actor) + + if (announcedActivity.type === 'Create' && announcedActivity.object.type === 'VideoChannel') { + // Add share entry + const videoChannel: VideoChannelInstance = await processCreateActivity(announcedActivity) + await db.VideoChannelShare.create({ + accountId: accountAnnouncer.id, + videoChannelId: videoChannel.id + }) + + return undefined + } else if (announcedActivity.type === 'Add' && announcedActivity.object.type === 'Video') { + // Add share entry + const video: VideoInstance = await processAddActivity(announcedActivity) + await db.VideoShare.create({ + accountId: accountAnnouncer.id, + videoId: video.id + }) + + return undefined + } + + logger.warn( + 'Unknown activity object type %s -> %s when announcing activity.', announcedActivity.type, announcedActivity.object.type, + { activity: activity.id } + ) + + return undefined +} + +// --------------------------------------------------------------------------- + +export { + processAnnounceActivity +} diff --git a/server/lib/activitypub/process/process-create.ts b/server/lib/activitypub/process/process-create.ts new file mode 100644 index 000000000..aac941a6c --- /dev/null +++ b/server/lib/activitypub/process/process-create.ts @@ -0,0 +1,88 @@ +import { ActivityCreate, VideoChannelObject } from '../../../../shared' +import { VideoAbuseObject } from '../../../../shared/models/activitypub/objects/video-abuse-object' +import { logger, retryTransactionWrapper } from '../../../helpers' +import { getOrCreateAccount, getVideoChannelActivityPubUrl } from '../../../helpers/activitypub' +import { database as db } from '../../../initializers' +import { AccountInstance } from '../../../models/account/account-interface' +import { videoChannelActivityObjectToDBAttributes } from './misc' + +async function processCreateActivity (activity: ActivityCreate) { + const activityObject = activity.object + const activityType = activityObject.type + const account = await getOrCreateAccount(activity.actor) + + if (activityType === 'VideoChannel') { + return processCreateVideoChannel(account, activityObject as VideoChannelObject) + } else if (activityType === 'Flag') { + return processCreateVideoAbuse(account, activityObject as VideoAbuseObject) + } + + logger.warn('Unknown activity object type %s when creating activity.', activityType, { activity: activity.id }) + return Promise.resolve(undefined) +} + +// --------------------------------------------------------------------------- + +export { + processCreateActivity +} + +// --------------------------------------------------------------------------- + +function processCreateVideoChannel (account: AccountInstance, videoChannelToCreateData: VideoChannelObject) { + const options = { + arguments: [ account, videoChannelToCreateData ], + errorMessage: 'Cannot insert the remote video channel with many retries.' + } + + return retryTransactionWrapper(addRemoteVideoChannel, options) +} + +function addRemoteVideoChannel (account: AccountInstance, videoChannelToCreateData: VideoChannelObject) { + logger.debug('Adding remote video channel "%s".', videoChannelToCreateData.uuid) + + return db.sequelize.transaction(async t => { + let videoChannel = await db.VideoChannel.loadByUUIDOrUrl(videoChannelToCreateData.uuid, videoChannelToCreateData.id, t) + if (videoChannel) throw new Error('Video channel with this URL/UUID already exists.') + + const videoChannelData = videoChannelActivityObjectToDBAttributes(videoChannelToCreateData, account) + videoChannel = db.VideoChannel.build(videoChannelData) + videoChannel.url = getVideoChannelActivityPubUrl(videoChannel) + + videoChannel = await videoChannel.save({ transaction: t }) + logger.info('Remote video channel with uuid %s inserted.', videoChannelToCreateData.uuid) + + return videoChannel + }) +} + +function processCreateVideoAbuse (account: AccountInstance, videoAbuseToCreateData: VideoAbuseObject) { + const options = { + arguments: [ account, videoAbuseToCreateData ], + errorMessage: 'Cannot insert the remote video abuse with many retries.' + } + + return retryTransactionWrapper(addRemoteVideoAbuse, options) +} + +function addRemoteVideoAbuse (account: AccountInstance, videoAbuseToCreateData: VideoAbuseObject) { + logger.debug('Reporting remote abuse for video %s.', videoAbuseToCreateData.object) + + return db.sequelize.transaction(async t => { + const video = await db.Video.loadByUrlAndPopulateAccount(videoAbuseToCreateData.object, t) + if (!video) { + logger.warn('Unknown video %s for remote video abuse.', videoAbuseToCreateData.object) + return undefined + } + + const videoAbuseData = { + reporterAccountId: account.id, + reason: videoAbuseToCreateData.content, + videoId: video.id + } + + await db.VideoAbuse.create(videoAbuseData) + + logger.info('Remote abuse for video uuid %s created', videoAbuseToCreateData.object) + }) +} diff --git a/server/lib/activitypub/process/process-delete.ts b/server/lib/activitypub/process/process-delete.ts new file mode 100644 index 000000000..af5d964d4 --- /dev/null +++ b/server/lib/activitypub/process/process-delete.ts @@ -0,0 +1,105 @@ +import { ActivityDelete } from '../../../../shared/models/activitypub/activity' +import { getOrCreateAccount } from '../../../helpers/activitypub' +import { retryTransactionWrapper } from '../../../helpers/database-utils' +import { logger } from '../../../helpers/logger' +import { database as db } from '../../../initializers' +import { AccountInstance } from '../../../models/account/account-interface' +import { VideoChannelInstance } from '../../../models/video/video-channel-interface' +import { VideoInstance } from '../../../models/video/video-interface' + +async function processDeleteActivity (activity: ActivityDelete) { + const account = await getOrCreateAccount(activity.actor) + + if (account.url === activity.id) { + return processDeleteAccount(account) + } + + { + let videoObject = await db.Video.loadByUrlAndPopulateAccount(activity.id) + if (videoObject !== undefined) { + return processDeleteVideo(account, videoObject) + } + } + + { + let videoChannelObject = await db.VideoChannel.loadByUrl(activity.id) + if (videoChannelObject !== undefined) { + return processDeleteVideoChannel(account, videoChannelObject) + } + } + + return +} + +// --------------------------------------------------------------------------- + +export { + processDeleteActivity +} + +// --------------------------------------------------------------------------- + +async function processDeleteVideo (account: AccountInstance, videoToDelete: VideoInstance) { + const options = { + arguments: [ account, videoToDelete ], + errorMessage: 'Cannot remove the remote video with many retries.' + } + + await retryTransactionWrapper(deleteRemoteVideo, options) +} + +async function deleteRemoteVideo (account: AccountInstance, videoToDelete: VideoInstance) { + logger.debug('Removing remote video "%s".', videoToDelete.uuid) + + await db.sequelize.transaction(async t => { + if (videoToDelete.VideoChannel.Account.id !== account.id) { + throw new Error('Account ' + account.url + ' does not own video channel ' + videoToDelete.VideoChannel.url) + } + + await videoToDelete.destroy({ transaction: t }) + }) + + logger.info('Remote video with uuid %s removed.', videoToDelete.uuid) +} + +async function processDeleteVideoChannel (account: AccountInstance, videoChannelToRemove: VideoChannelInstance) { + const options = { + arguments: [ account, videoChannelToRemove ], + errorMessage: 'Cannot remove the remote video channel with many retries.' + } + + await retryTransactionWrapper(deleteRemoteVideoChannel, options) +} + +async function deleteRemoteVideoChannel (account: AccountInstance, videoChannelToRemove: VideoChannelInstance) { + logger.debug('Removing remote video channel "%s".', videoChannelToRemove.uuid) + + await db.sequelize.transaction(async t => { + if (videoChannelToRemove.Account.id !== account.id) { + throw new Error('Account ' + account.url + ' does not own video channel ' + videoChannelToRemove.url) + } + + await videoChannelToRemove.destroy({ transaction: t }) + }) + + logger.info('Remote video channel with uuid %s removed.', videoChannelToRemove.uuid) +} + +async function processDeleteAccount (accountToRemove: AccountInstance) { + const options = { + arguments: [ accountToRemove ], + errorMessage: 'Cannot remove the remote account with many retries.' + } + + await retryTransactionWrapper(deleteRemoteAccount, options) +} + +async function deleteRemoteAccount (accountToRemove: AccountInstance) { + logger.debug('Removing remote account "%s".', accountToRemove.uuid) + + await db.sequelize.transaction(async t => { + await accountToRemove.destroy({ transaction: t }) + }) + + logger.info('Remote account with uuid %s removed.', accountToRemove.uuid) +} diff --git a/server/lib/activitypub/process/process-follow.ts b/server/lib/activitypub/process/process-follow.ts new file mode 100644 index 000000000..553639580 --- /dev/null +++ b/server/lib/activitypub/process/process-follow.ts @@ -0,0 +1,59 @@ +import { ActivityFollow } from '../../../../shared/models/activitypub/activity' +import { getOrCreateAccount, retryTransactionWrapper } from '../../../helpers' +import { database as db } from '../../../initializers' +import { AccountInstance } from '../../../models/account/account-interface' +import { logger } from '../../../helpers/logger' +import { sendAccept } from '../send/send-accept' + +async function processFollowActivity (activity: ActivityFollow) { + const activityObject = activity.object + const account = await getOrCreateAccount(activity.actor) + + return processFollow(account, activityObject) +} + +// --------------------------------------------------------------------------- + +export { + processFollowActivity +} + +// --------------------------------------------------------------------------- + +function processFollow (account: AccountInstance, targetAccountURL: string) { + const options = { + arguments: [ account, targetAccountURL ], + errorMessage: 'Cannot follow with many retries.' + } + + return retryTransactionWrapper(follow, options) +} + +async function follow (account: AccountInstance, targetAccountURL: string) { + await db.sequelize.transaction(async t => { + const targetAccount = await db.Account.loadByUrl(targetAccountURL, t) + + if (!targetAccount) throw new Error('Unknown account') + if (targetAccount.isOwned() === false) throw new Error('This is not a local account.') + + const [ accountFollow ] = await db.AccountFollow.findOrCreate({ + where: { + accountId: account.id, + targetAccountId: targetAccount.id + }, + defaults: { + accountId: account.id, + targetAccountId: targetAccount.id, + state: 'accepted' + }, + transaction: t + }) + accountFollow.AccountFollower = account + accountFollow.AccountFollowing = targetAccount + + // Target sends to account he accepted the follow request + return sendAccept(accountFollow, t) + }) + + logger.info('Account uuid %s is followed by account %s.', account.url, targetAccountURL) +} diff --git a/server/lib/activitypub/process/process-undo.ts b/server/lib/activitypub/process/process-undo.ts new file mode 100644 index 000000000..5d09423e1 --- /dev/null +++ b/server/lib/activitypub/process/process-undo.ts @@ -0,0 +1,31 @@ +import { ActivityUndo } from '../../../../shared/models/activitypub/activity' +import { logger } from '../../../helpers/logger' +import { database as db } from '../../../initializers' + +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 + } + + logger.warn('Unknown activity object type %s -> %s when undo activity.', activityToUndo.type, { activity: activity.id }) + + return undefined +} + +// --------------------------------------------------------------------------- + +export { + processUndoActivity +} + +// --------------------------------------------------------------------------- diff --git a/server/lib/activitypub/process/process-update.ts b/server/lib/activitypub/process/process-update.ts new file mode 100644 index 000000000..a3bfb1baf --- /dev/null +++ b/server/lib/activitypub/process/process-update.ts @@ -0,0 +1,135 @@ +import { VideoChannelObject, VideoTorrentObject } from '../../../../shared' +import { ActivityUpdate } from '../../../../shared/models/activitypub/activity' +import { getOrCreateAccount } from '../../../helpers/activitypub' +import { retryTransactionWrapper } from '../../../helpers/database-utils' +import { logger } from '../../../helpers/logger' +import { resetSequelizeInstance } from '../../../helpers/utils' +import { database as db } from '../../../initializers' +import { AccountInstance } from '../../../models/account/account-interface' +import { VideoInstance } from '../../../models/video/video-interface' +import { videoActivityObjectToDBAttributes, videoFileActivityUrlToDBAttributes } from './misc' +import Bluebird = require('bluebird') + +async function processUpdateActivity (activity: ActivityUpdate) { + const account = await getOrCreateAccount(activity.actor) + + if (activity.object.type === 'Video') { + return processUpdateVideo(account, activity.object) + } else if (activity.object.type === 'VideoChannel') { + return processUpdateVideoChannel(account, activity.object) + } + + return undefined +} + +// --------------------------------------------------------------------------- + +export { + processUpdateActivity +} + +// --------------------------------------------------------------------------- + +function processUpdateVideo (account: AccountInstance, video: VideoTorrentObject) { + const options = { + arguments: [ account, video ], + errorMessage: 'Cannot update the remote video with many retries' + } + + return retryTransactionWrapper(updateRemoteVideo, options) +} + +async function updateRemoteVideo (account: AccountInstance, videoAttributesToUpdate: VideoTorrentObject) { + logger.debug('Updating remote video "%s".', videoAttributesToUpdate.uuid) + let videoInstance: VideoInstance + let videoFieldsSave: object + + try { + await db.sequelize.transaction(async t => { + const sequelizeOptions = { + transaction: t + } + + const videoInstance = await db.Video.loadByUrlAndPopulateAccount(videoAttributesToUpdate.id, t) + if (!videoInstance) throw new Error('Video ' + videoAttributesToUpdate.id + ' not found.') + + if (videoInstance.VideoChannel.Account.id !== account.id) { + throw new Error('Account ' + account.url + ' does not own video channel ' + videoInstance.VideoChannel.url) + } + + const videoData = await videoActivityObjectToDBAttributes(videoInstance.VideoChannel, videoAttributesToUpdate) + videoInstance.set('name', videoData.name) + videoInstance.set('category', videoData.category) + videoInstance.set('licence', videoData.licence) + videoInstance.set('language', videoData.language) + videoInstance.set('nsfw', videoData.nsfw) + videoInstance.set('description', videoData.description) + videoInstance.set('duration', videoData.duration) + videoInstance.set('createdAt', videoData.createdAt) + videoInstance.set('updatedAt', videoData.updatedAt) + videoInstance.set('views', videoData.views) + // videoInstance.set('likes', videoData.likes) + // videoInstance.set('dislikes', videoData.dislikes) + + await videoInstance.save(sequelizeOptions) + + // Remove old video files + const videoFileDestroyTasks: Bluebird[] = [] + for (const videoFile of videoInstance.VideoFiles) { + videoFileDestroyTasks.push(videoFile.destroy(sequelizeOptions)) + } + await Promise.all(videoFileDestroyTasks) + + const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoInstance, videoAttributesToUpdate) + const tasks: Bluebird[] = videoFileAttributes.map(f => db.VideoFile.create(f)) + await Promise.all(tasks) + + const tags = videoAttributesToUpdate.tag.map(t => t.name) + const tagInstances = await db.Tag.findOrCreateTags(tags, t) + await videoInstance.setTags(tagInstances, sequelizeOptions) + }) + + logger.info('Remote video with uuid %s updated', videoAttributesToUpdate.uuid) + } catch (err) { + if (videoInstance !== undefined && videoFieldsSave !== undefined) { + resetSequelizeInstance(videoInstance, videoFieldsSave) + } + + // This is just a debug because we will retry the insert + logger.debug('Cannot update the remote video.', err) + throw err + } +} + +async function processUpdateVideoChannel (account: AccountInstance, videoChannel: VideoChannelObject) { + const options = { + arguments: [ account, videoChannel ], + errorMessage: 'Cannot update the remote video channel with many retries.' + } + + await retryTransactionWrapper(updateRemoteVideoChannel, options) +} + +async function updateRemoteVideoChannel (account: AccountInstance, videoChannel: VideoChannelObject) { + logger.debug('Updating remote video channel "%s".', videoChannel.uuid) + + await db.sequelize.transaction(async t => { + const sequelizeOptions = { transaction: t } + + const videoChannelInstance = await db.VideoChannel.loadByUrl(videoChannel.id) + if (!videoChannelInstance) throw new Error('Video ' + videoChannel.id + ' not found.') + + if (videoChannelInstance.Account.id !== account.id) { + throw new Error('Account ' + account.id + ' does not own video channel ' + videoChannelInstance.url) + } + + videoChannelInstance.set('name', videoChannel.name) + videoChannelInstance.set('description', videoChannel.content) + videoChannelInstance.set('createdAt', videoChannel.published) + videoChannelInstance.set('updatedAt', videoChannel.updated) + + await videoChannelInstance.save(sequelizeOptions) + }) + + logger.info('Remote video channel with uuid %s updated', videoChannel.uuid) +} diff --git a/server/lib/activitypub/send-request.ts b/server/lib/activitypub/send-request.ts deleted file mode 100644 index 261ff04ab..000000000 --- a/server/lib/activitypub/send-request.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { Transaction } from 'sequelize' -import { - ActivityAccept, - ActivityAdd, - ActivityCreate, - ActivityDelete, - ActivityFollow, - ActivityUpdate -} from '../../../shared/models/activitypub/activity' -import { getActivityPubUrl } from '../../helpers/activitypub' -import { logger } from '../../helpers/logger' -import { database as db } from '../../initializers' -import { AccountInstance, VideoChannelInstance, VideoInstance } from '../../models' -import { VideoAbuseInstance } from '../../models/video/video-abuse-interface' -import { activitypubHttpJobScheduler } from '../jobs' -import { ACTIVITY_PUB } from '../../initializers/constants' -import { VideoPrivacy } from '../../../shared/models/videos/video-privacy.enum' - -async function sendCreateVideoChannel (videoChannel: VideoChannelInstance, t: Transaction) { - const byAccount = videoChannel.Account - - const videoChannelObject = videoChannel.toActivityPubObject() - const data = await createActivityData(videoChannel.url, byAccount, videoChannelObject) - - return broadcastToFollowers(data, byAccount, [ byAccount ], t) -} - -async function sendUpdateVideoChannel (videoChannel: VideoChannelInstance, t: Transaction) { - const byAccount = videoChannel.Account - - const videoChannelObject = videoChannel.toActivityPubObject() - const data = await updateActivityData(videoChannel.url, byAccount, videoChannelObject) - - const accountsInvolved = await db.VideoChannelShare.loadAccountsByShare(videoChannel.id) - accountsInvolved.push(byAccount) - - return broadcastToFollowers(data, byAccount, accountsInvolved, t) -} - -async function sendDeleteVideoChannel (videoChannel: VideoChannelInstance, t: Transaction) { - const byAccount = videoChannel.Account - - const data = await deleteActivityData(videoChannel.url, byAccount) - - const accountsInvolved = await db.VideoChannelShare.loadAccountsByShare(videoChannel.id) - accountsInvolved.push(byAccount) - - return broadcastToFollowers(data, byAccount, accountsInvolved, t) -} - -async function sendAddVideo (video: VideoInstance, t: Transaction) { - const byAccount = video.VideoChannel.Account - - const videoObject = video.toActivityPubObject() - const data = await addActivityData(video.url, byAccount, video, video.VideoChannel.url, videoObject) - - return broadcastToFollowers(data, byAccount, [ byAccount ], t) -} - -async function sendUpdateVideo (video: VideoInstance, t: Transaction) { - const byAccount = video.VideoChannel.Account - - const videoObject = video.toActivityPubObject() - const data = await updateActivityData(video.url, byAccount, videoObject) - - const accountsInvolved = await db.VideoShare.loadAccountsByShare(video.id) - accountsInvolved.push(byAccount) - - return broadcastToFollowers(data, byAccount, accountsInvolved, t) -} - -async function sendDeleteVideo (video: VideoInstance, t: Transaction) { - const byAccount = video.VideoChannel.Account - - const data = await deleteActivityData(video.url, byAccount) - - const accountsInvolved = await db.VideoShare.loadAccountsByShare(video.id) - accountsInvolved.push(byAccount) - - return broadcastToFollowers(data, byAccount, accountsInvolved, t) -} - -async function sendDeleteAccount (account: AccountInstance, t: Transaction) { - const data = await deleteActivityData(account.url, account) - - return broadcastToFollowers(data, account, [ account ], t) -} - -async function sendVideoChannelAnnounce (byAccount: AccountInstance, videoChannel: VideoChannelInstance, t: Transaction) { - const url = getActivityPubUrl('videoChannel', videoChannel.uuid) + '#announce' - const announcedActivity = await createActivityData(url, videoChannel.Account, videoChannel.toActivityPubObject()) - - const data = await announceActivityData(url, byAccount, announcedActivity) - return broadcastToFollowers(data, byAccount, [ byAccount ], t) -} - -async function sendVideoAnnounce (byAccount: AccountInstance, video: VideoInstance, t: Transaction) { - const url = getActivityPubUrl('video', video.uuid) + '#announce' - - const videoChannel = video.VideoChannel - const announcedActivity = await addActivityData(url, videoChannel.Account, video, videoChannel.url, video.toActivityPubObject()) - - const data = await announceActivityData(url, byAccount, announcedActivity) - return broadcastToFollowers(data, byAccount, [ byAccount ], t) -} - -async function sendVideoAbuse (byAccount: AccountInstance, videoAbuse: VideoAbuseInstance, video: VideoInstance, t: Transaction) { - const url = getActivityPubUrl('videoAbuse', videoAbuse.id.toString()) - const data = await createActivityData(url, byAccount, videoAbuse.toActivityPubObject()) - - return unicastTo(data, byAccount, video.VideoChannel.Account.sharedInboxUrl, t) -} - -async function sendAccept (byAccount: AccountInstance, toAccount: AccountInstance, t: Transaction) { - const data = await acceptActivityData(byAccount) - - return unicastTo(data, byAccount, toAccount.inboxUrl, t) -} - -async function sendFollow (byAccount: AccountInstance, toAccount: AccountInstance, t: Transaction) { - const data = await followActivityData(toAccount.url, byAccount) - - return unicastTo(data, byAccount, toAccount.inboxUrl, t) -} - -// --------------------------------------------------------------------------- - -export { - sendCreateVideoChannel, - sendUpdateVideoChannel, - sendDeleteVideoChannel, - sendAddVideo, - sendUpdateVideo, - sendDeleteVideo, - sendDeleteAccount, - sendAccept, - sendFollow, - sendVideoAbuse, - sendVideoChannelAnnounce, - sendVideoAnnounce -} - -// --------------------------------------------------------------------------- - -async function broadcastToFollowers (data: any, byAccount: AccountInstance, toAccountFollowers: AccountInstance[], t: Transaction) { - 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 jobPayload = { - uris: result.data, - signatureAccountId: byAccount.id, - body: data - } - - return activitypubHttpJobScheduler.createJob(t, 'activitypubHttpBroadcastHandler', jobPayload) -} - -async function unicastTo (data: any, byAccount: AccountInstance, toAccountUrl: string, t: Transaction) { - const jobPayload = { - uris: [ toAccountUrl ], - signatureAccountId: byAccount.id, - body: data - } - - return activitypubHttpJobScheduler.createJob(t, 'activitypubHttpUnicastHandler', jobPayload) -} - -async function getAudience (accountSender: AccountInstance, isPublic = true) { - const followerInboxUrls = await accountSender.getFollowerSharedInboxUrls() - - // Thanks Mastodon: https://github.com/tootsuite/mastodon/blob/master/app/lib/activitypub/tag_manager.rb#L47 - let to = [] - let cc = [] - - if (isPublic) { - to = [ ACTIVITY_PUB.PUBLIC ] - cc = followerInboxUrls - } else { // Unlisted - to = followerInboxUrls - cc = [ ACTIVITY_PUB.PUBLIC ] - } - - return { to, cc } -} - -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, - object - } - - return activity -} - -async function updateActivityData (url: string, byAccount: AccountInstance, object: any) { - const { to, cc } = await getAudience(byAccount) - const activity: ActivityUpdate = { - type: 'Update', - id: url, - actor: byAccount.url, - to, - cc, - object - } - - return activity -} - -async function deleteActivityData (url: string, byAccount: AccountInstance) { - const activity: ActivityDelete = { - type: 'Delete', - id: url, - actor: byAccount.url - } - - return activity -} - -async function addActivityData (url: string, byAccount: AccountInstance, video: VideoInstance, target: string, object: any) { - const videoPublic = video.privacy === VideoPrivacy.PUBLIC - - const { to, cc } = await getAudience(byAccount, videoPublic) - const activity: ActivityAdd = { - type: 'Add', - id: url, - actor: byAccount.url, - to, - cc, - object, - target - } - - return activity -} - -async function announceActivityData (url: string, byAccount: AccountInstance, object: any) { - const activity = { - type: 'Announce', - id: url, - actor: byAccount.url, - object - } - - return activity -} - -async function followActivityData (url: string, byAccount: AccountInstance) { - const activity: ActivityFollow = { - type: 'Follow', - id: byAccount.url, - actor: byAccount.url, - object: url - } - - return activity -} - -async function acceptActivityData (byAccount: AccountInstance) { - const activity: ActivityAccept = { - type: 'Accept', - id: byAccount.url, - actor: byAccount.url - } - - return activity -} diff --git a/server/lib/activitypub/send/index.ts b/server/lib/activitypub/send/index.ts new file mode 100644 index 000000000..5f15dd4b5 --- /dev/null +++ b/server/lib/activitypub/send/index.ts @@ -0,0 +1,7 @@ +export * from './send-accept' +export * from './send-add' +export * from './send-announce' +export * from './send-create' +export * from './send-delete' +export * from './send-follow' +export * from './send-update' diff --git a/server/lib/activitypub/send/misc.ts b/server/lib/activitypub/send/misc.ts new file mode 100644 index 000000000..bea955b67 --- /dev/null +++ b/server/lib/activitypub/send/misc.ts @@ -0,0 +1,58 @@ +import { Transaction } from 'sequelize' +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' + +async function broadcastToFollowers (data: any, byAccount: AccountInstance, toAccountFollowers: AccountInstance[], t: Transaction) { + 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 jobPayload = { + uris: result.data, + signatureAccountId: byAccount.id, + body: data + } + + return activitypubHttpJobScheduler.createJob(t, 'activitypubHttpBroadcastHandler', jobPayload) +} + +async function unicastTo (data: any, byAccount: AccountInstance, toAccountUrl: string, t: Transaction) { + const jobPayload = { + uris: [ toAccountUrl ], + signatureAccountId: byAccount.id, + body: data + } + + return activitypubHttpJobScheduler.createJob(t, 'activitypubHttpUnicastHandler', jobPayload) +} + +async function getAudience (accountSender: AccountInstance, isPublic = true) { + const followerInboxUrls = await accountSender.getFollowerSharedInboxUrls() + + // Thanks Mastodon: https://github.com/tootsuite/mastodon/blob/master/app/lib/activitypub/tag_manager.rb#L47 + let to = [] + let cc = [] + + if (isPublic) { + to = [ ACTIVITY_PUB.PUBLIC ] + cc = followerInboxUrls + } else { // Unlisted + to = followerInboxUrls + cc = [ ACTIVITY_PUB.PUBLIC ] + } + + return { to, cc } +} + +// --------------------------------------------------------------------------- + +export { + broadcastToFollowers, + unicastTo, + getAudience +} diff --git a/server/lib/activitypub/send/send-accept.ts b/server/lib/activitypub/send/send-accept.ts new file mode 100644 index 000000000..0324a30fa --- /dev/null +++ b/server/lib/activitypub/send/send-accept.ts @@ -0,0 +1,34 @@ +import { Transaction } from 'sequelize' +import { ActivityAccept } from '../../../../shared/models/activitypub/activity' +import { AccountInstance } from '../../../models' +import { AccountFollowInstance } from '../../../models/account/account-follow-interface' +import { unicastTo } from './misc' +import { getAccountFollowAcceptActivityPubUrl } from '../../../helpers/activitypub' + +async function sendAccept (accountFollow: AccountFollowInstance, t: Transaction) { + const follower = accountFollow.AccountFollower + const me = accountFollow.AccountFollowing + + const url = getAccountFollowAcceptActivityPubUrl(accountFollow) + const data = await acceptActivityData(url, me) + + return unicastTo(data, me, follower.inboxUrl, t) +} + +// --------------------------------------------------------------------------- + +export { + sendAccept +} + +// --------------------------------------------------------------------------- + +async function acceptActivityData (url: string, byAccount: AccountInstance) { + const activity: ActivityAccept = { + type: 'Accept', + id: url, + actor: byAccount.url + } + + return activity +} diff --git a/server/lib/activitypub/send/send-add.ts b/server/lib/activitypub/send/send-add.ts new file mode 100644 index 000000000..3012b7533 --- /dev/null +++ b/server/lib/activitypub/send/send-add.ts @@ -0,0 +1,38 @@ +import { Transaction } from 'sequelize' +import { ActivityAdd } from '../../../../shared/models/activitypub/activity' +import { VideoPrivacy } from '../../../../shared/models/videos/video-privacy.enum' +import { AccountInstance, VideoInstance } from '../../../models' +import { broadcastToFollowers, getAudience } from './misc' + +async function sendAddVideo (video: VideoInstance, t: Transaction) { + const byAccount = video.VideoChannel.Account + + const videoObject = video.toActivityPubObject() + const data = await addActivityData(video.url, byAccount, video, video.VideoChannel.url, videoObject) + + return broadcastToFollowers(data, byAccount, [ byAccount ], t) +} + +async function addActivityData (url: string, byAccount: AccountInstance, video: VideoInstance, target: string, object: any) { + const videoPublic = video.privacy === VideoPrivacy.PUBLIC + + const { to, cc } = await getAudience(byAccount, videoPublic) + const activity: ActivityAdd = { + type: 'Add', + id: url, + actor: byAccount.url, + to, + cc, + object, + target + } + + return activity +} + +// --------------------------------------------------------------------------- + +export { + addActivityData, + sendAddVideo +} diff --git a/server/lib/activitypub/send/send-announce.ts b/server/lib/activitypub/send/send-announce.ts new file mode 100644 index 000000000..b9217e4f6 --- /dev/null +++ b/server/lib/activitypub/send/send-announce.ts @@ -0,0 +1,45 @@ +import { Transaction } from 'sequelize' +import { AccountInstance, VideoInstance } from '../../../models' +import { VideoChannelInstance } from '../../../models/video/video-channel-interface' +import { broadcastToFollowers } from './misc' +import { addActivityData } from './send-add' +import { createActivityData } from './send-create' +import { getAnnounceActivityPubUrl } from '../../../helpers/activitypub' + +async function sendVideoAnnounce (byAccount: AccountInstance, video: VideoInstance, t: Transaction) { + const url = getAnnounceActivityPubUrl(video.url, byAccount) + + const videoChannel = video.VideoChannel + const announcedActivity = await addActivityData(url, videoChannel.Account, video, videoChannel.url, video.toActivityPubObject()) + + const data = await announceActivityData(url, byAccount, announcedActivity) + return broadcastToFollowers(data, byAccount, [ byAccount ], t) +} + +async function sendVideoChannelAnnounce (byAccount: AccountInstance, videoChannel: VideoChannelInstance, t: Transaction) { + const url = getAnnounceActivityPubUrl(videoChannel.url, byAccount) + const announcedActivity = await createActivityData(url, videoChannel.Account, videoChannel.toActivityPubObject()) + + const data = await announceActivityData(url, byAccount, announcedActivity) + return broadcastToFollowers(data, byAccount, [ byAccount ], t) +} + +// --------------------------------------------------------------------------- + +export { + sendVideoAnnounce, + sendVideoChannelAnnounce +} + +// --------------------------------------------------------------------------- + +async function announceActivityData (url: string, byAccount: AccountInstance, object: any) { + const activity = { + type: 'Announce', + id: url, + actor: byAccount.url, + object + } + + return activity +} diff --git a/server/lib/activitypub/send/send-create.ts b/server/lib/activitypub/send/send-create.ts new file mode 100644 index 000000000..66bfeee89 --- /dev/null +++ b/server/lib/activitypub/send/send-create.ts @@ -0,0 +1,44 @@ +import { Transaction } from 'sequelize' +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 '../../../helpers/activitypub' + +async function sendCreateVideoChannel (videoChannel: VideoChannelInstance, t: Transaction) { + const byAccount = videoChannel.Account + + const videoChannelObject = videoChannel.toActivityPubObject() + const data = await createActivityData(videoChannel.url, byAccount, videoChannelObject) + + return broadcastToFollowers(data, byAccount, [ byAccount ], t) +} + +async function sendVideoAbuse (byAccount: AccountInstance, videoAbuse: VideoAbuseInstance, video: VideoInstance, t: Transaction) { + const url = getVideoAbuseActivityPubUrl(videoAbuse) + const data = await createActivityData(url, byAccount, videoAbuse.toActivityPubObject()) + + return unicastTo(data, byAccount, video.VideoChannel.Account.sharedInboxUrl, t) +} + +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, + object + } + + return activity +} + +// --------------------------------------------------------------------------- + +export { + sendCreateVideoChannel, + sendVideoAbuse, + createActivityData +} diff --git a/server/lib/activitypub/send/send-delete.ts b/server/lib/activitypub/send/send-delete.ts new file mode 100644 index 000000000..5be0e2d24 --- /dev/null +++ b/server/lib/activitypub/send/send-delete.ts @@ -0,0 +1,53 @@ +import { Transaction } from 'sequelize' +import { ActivityDelete } from '../../../../shared/models/activitypub/activity' +import { database as db } from '../../../initializers' +import { AccountInstance, VideoChannelInstance, VideoInstance } from '../../../models' +import { broadcastToFollowers } from './misc' + +async function sendDeleteVideoChannel (videoChannel: VideoChannelInstance, t: Transaction) { + const byAccount = videoChannel.Account + + const data = await deleteActivityData(videoChannel.url, byAccount) + + const accountsInvolved = await db.VideoChannelShare.loadAccountsByShare(videoChannel.id) + accountsInvolved.push(byAccount) + + return broadcastToFollowers(data, byAccount, accountsInvolved, t) +} + +async function sendDeleteVideo (video: VideoInstance, t: Transaction) { + const byAccount = video.VideoChannel.Account + + const data = await deleteActivityData(video.url, byAccount) + + const accountsInvolved = await db.VideoShare.loadAccountsByShare(video.id) + accountsInvolved.push(byAccount) + + return broadcastToFollowers(data, byAccount, accountsInvolved, t) +} + +async function sendDeleteAccount (account: AccountInstance, t: Transaction) { + const data = await deleteActivityData(account.url, account) + + return broadcastToFollowers(data, account, [ account ], t) +} + +// --------------------------------------------------------------------------- + +export { + sendDeleteVideoChannel, + sendDeleteVideo, + sendDeleteAccount +} + +// --------------------------------------------------------------------------- + +async function deleteActivityData (url: string, byAccount: AccountInstance) { + const activity: ActivityDelete = { + type: 'Delete', + id: url, + actor: byAccount.url + } + + return activity +} diff --git a/server/lib/activitypub/send/send-follow.ts b/server/lib/activitypub/send/send-follow.ts new file mode 100644 index 000000000..48d641c22 --- /dev/null +++ b/server/lib/activitypub/send/send-follow.ts @@ -0,0 +1,34 @@ +import { Transaction } from 'sequelize' +import { ActivityFollow } from '../../../../shared/models/activitypub/activity' +import { AccountInstance } from '../../../models' +import { AccountFollowInstance } from '../../../models/account/account-follow-interface' +import { unicastTo } from './misc' +import { getAccountFollowActivityPubUrl } from '../../../helpers/activitypub' + +async function sendFollow (accountFollow: AccountFollowInstance, t: Transaction) { + const me = accountFollow.AccountFollower + const following = accountFollow.AccountFollowing + + const url = getAccountFollowActivityPubUrl(accountFollow) + const data = await followActivityData(url, me, following) + + return unicastTo(data, me, following.inboxUrl, t) +} + +async function followActivityData (url: string, byAccount: AccountInstance, targetAccount: AccountInstance) { + const activity: ActivityFollow = { + type: 'Follow', + id: url, + actor: byAccount.url, + object: targetAccount.url + } + + return activity +} + +// --------------------------------------------------------------------------- + +export { + sendFollow, + followActivityData +} diff --git a/server/lib/activitypub/send/send-undo.ts b/server/lib/activitypub/send/send-undo.ts new file mode 100644 index 000000000..39da824f3 --- /dev/null +++ b/server/lib/activitypub/send/send-undo.ts @@ -0,0 +1,39 @@ +import { Transaction } from 'sequelize' +import { ActivityFollow, ActivityUndo } from '../../../../shared/models/activitypub/activity' +import { AccountInstance } from '../../../models' +import { AccountFollowInstance } from '../../../models/account/account-follow-interface' +import { unicastTo } from './misc' +import { getAccountFollowActivityPubUrl, getUndoActivityPubUrl } from '../../../helpers/activitypub' +import { followActivityData } from './send-follow' + +async function sendUndoFollow (accountFollow: AccountFollowInstance, t: Transaction) { + const me = accountFollow.AccountFollower + const following = accountFollow.AccountFollowing + + const followUrl = getAccountFollowActivityPubUrl(accountFollow) + const undoUrl = getUndoActivityPubUrl(followUrl) + + const object = await followActivityData(followUrl, me, following) + const data = await undoActivityData(undoUrl, me, object) + + return unicastTo(data, me, following.inboxUrl, t) +} + +// --------------------------------------------------------------------------- + +export { + sendUndoFollow +} + +// --------------------------------------------------------------------------- + +async function undoActivityData (url: string, byAccount: AccountInstance, object: ActivityFollow) { + const activity: ActivityUndo = { + type: 'Undo', + id: url, + actor: byAccount.url, + object + } + + return activity +} diff --git a/server/lib/activitypub/send/send-update.ts b/server/lib/activitypub/send/send-update.ts new file mode 100644 index 000000000..42738f973 --- /dev/null +++ b/server/lib/activitypub/send/send-update.ts @@ -0,0 +1,55 @@ +import { Transaction } from 'sequelize' +import { ActivityUpdate } from '../../../../shared/models/activitypub/activity' +import { getUpdateActivityPubUrl } from '../../../helpers/activitypub' +import { database as db } from '../../../initializers' +import { AccountInstance, VideoChannelInstance, VideoInstance } from '../../../models' +import { broadcastToFollowers, getAudience } from './misc' + +async function sendUpdateVideoChannel (videoChannel: VideoChannelInstance, t: Transaction) { + const byAccount = videoChannel.Account + + const url = getUpdateActivityPubUrl(videoChannel.url, videoChannel.updatedAt.toISOString()) + const videoChannelObject = videoChannel.toActivityPubObject() + const data = await updateActivityData(url, byAccount, videoChannelObject) + + const accountsInvolved = await db.VideoChannelShare.loadAccountsByShare(videoChannel.id) + accountsInvolved.push(byAccount) + + return broadcastToFollowers(data, byAccount, accountsInvolved, t) +} + +async function sendUpdateVideo (video: VideoInstance, t: Transaction) { + const byAccount = video.VideoChannel.Account + + const url = getUpdateActivityPubUrl(video.url, video.updatedAt.toISOString()) + const videoObject = video.toActivityPubObject() + const data = await updateActivityData(url, byAccount, videoObject) + + const accountsInvolved = await db.VideoShare.loadAccountsByShare(video.id) + accountsInvolved.push(byAccount) + + return broadcastToFollowers(data, byAccount, accountsInvolved, t) +} + +// --------------------------------------------------------------------------- + +export { + sendUpdateVideoChannel, + sendUpdateVideo +} + +// --------------------------------------------------------------------------- + +async function updateActivityData (url: string, byAccount: AccountInstance, object: any) { + const { to, cc } = await getAudience(byAccount) + const activity: ActivityUpdate = { + type: 'Update', + id: url, + actor: byAccount.url, + to, + cc, + object + } + + return activity +} diff --git a/server/lib/jobs/transcoding-job-scheduler/video-file-optimizer-handler.ts b/server/lib/jobs/transcoding-job-scheduler/video-file-optimizer-handler.ts index 6443899d3..f26110973 100644 --- a/server/lib/jobs/transcoding-job-scheduler/video-file-optimizer-handler.ts +++ b/server/lib/jobs/transcoding-job-scheduler/video-file-optimizer-handler.ts @@ -3,10 +3,11 @@ import { computeResolutionsToTranscode, logger } from '../../../helpers' import { database as db } from '../../../initializers/database' import { VideoInstance } from '../../../models' -import { sendAddVideo } from '../../activitypub/send-request' + import { JobScheduler } from '../job-scheduler' import { TranscodingJobPayload } from './transcoding-job-scheduler' import { shareVideoByServer } from '../../../helpers/activitypub' +import { sendAddVideo } from '../../activitypub/send/send-add' async function process (data: TranscodingJobPayload, jobId: number) { const video = await db.Video.loadByUUIDAndPopulateAccountAndServerAndTags(data.videoUUID) diff --git a/server/lib/jobs/transcoding-job-scheduler/video-file-transcoder-handler.ts b/server/lib/jobs/transcoding-job-scheduler/video-file-transcoder-handler.ts index 4f2ce3d24..867580200 100644 --- a/server/lib/jobs/transcoding-job-scheduler/video-file-transcoder-handler.ts +++ b/server/lib/jobs/transcoding-job-scheduler/video-file-transcoder-handler.ts @@ -2,7 +2,7 @@ import { VideoResolution } from '../../../../shared' import { logger } from '../../../helpers' import { database as db } from '../../../initializers/database' import { VideoInstance } from '../../../models' -import { sendUpdateVideo } from '../../activitypub/send-request' +import { sendUpdateVideo } from '../../activitypub/send/send-update' async function process (data: { videoUUID: string, resolution: VideoResolution }, jobId: number) { const video = await db.Video.loadByUUIDAndPopulateAccountAndServerAndTags(data.videoUUID) diff --git a/server/lib/user.ts b/server/lib/user.ts index 2d7b36b4f..d54ffc916 100644 --- a/server/lib/user.ts +++ b/server/lib/user.ts @@ -1,11 +1,11 @@ import * as Sequelize from 'sequelize' -import { getActivityPubUrl } from '../helpers/activitypub' import { createPrivateAndPublicKeys } from '../helpers/peertube-crypto' import { database as db } from '../initializers' import { CONFIG } from '../initializers/constants' import { UserInstance } from '../models' import { createVideoChannel } from './video-channel' import { logger } from '../helpers/logger' +import { getAccountActivityPubUrl } from '../helpers/activitypub' async function createUserAccountAndChannel (user: UserInstance, validateUser = true) { const { account, videoChannel } = await db.sequelize.transaction(async t => { @@ -36,7 +36,7 @@ async function createUserAccountAndChannel (user: UserInstance, validateUser = t } async function createLocalAccountWithoutKeys (name: string, userId: number, applicationId: number, t: Sequelize.Transaction) { - const url = getActivityPubUrl('account', name) + const url = getAccountActivityPubUrl(name) const accountInstance = db.Account.build({ name, diff --git a/server/lib/video-channel.ts b/server/lib/video-channel.ts index 5bb1814ea..5235d9cb5 100644 --- a/server/lib/video-channel.ts +++ b/server/lib/video-channel.ts @@ -1,9 +1,9 @@ import * as Sequelize from 'sequelize' import { VideoChannelCreate } from '../../shared/models' import { logger } from '../helpers' -import { getActivityPubUrl } from '../helpers/activitypub' import { database as db } from '../initializers' import { AccountInstance } from '../models' +import { getVideoChannelActivityPubUrl } from '../helpers/activitypub' async function createVideoChannel (videoChannelInfo: VideoChannelCreate, account: AccountInstance, t: Sequelize.Transaction) { const videoChannelData = { @@ -14,7 +14,7 @@ async function createVideoChannel (videoChannelInfo: VideoChannelCreate, account } const videoChannel = db.VideoChannel.build(videoChannelData) - videoChannel.set('url', getActivityPubUrl('videoChannel', videoChannel.uuid)) + videoChannel.set('url', getVideoChannelActivityPubUrl(videoChannel)) const options = { transaction: t } diff --git a/server/middlewares/validators/follows.ts b/server/middlewares/validators/follows.ts new file mode 100644 index 000000000..e22349726 --- /dev/null +++ b/server/middlewares/validators/follows.ts @@ -0,0 +1,62 @@ +import * as express from 'express' +import { body } from 'express-validator/check' +import { isTestInstance } from '../../helpers/core-utils' +import { isAccountIdValid } from '../../helpers/custom-validators/activitypub/account' +import { isEachUniqueHostValid } from '../../helpers/custom-validators/servers' +import { logger } from '../../helpers/logger' +import { CONFIG, database as db } from '../../initializers' +import { checkErrors } from './utils' +import { getServerAccount } from '../../helpers/utils' + +const followValidator = [ + body('hosts').custom(isEachUniqueHostValid).withMessage('Should have an array of unique hosts'), + + (req: express.Request, res: express.Response, next: express.NextFunction) => { + // Force https if the administrator wants to make friends + if (isTestInstance() === false && CONFIG.WEBSERVER.SCHEME === 'http') { + return res.status(400) + .json({ + error: 'Cannot follow non HTTPS web server.' + }) + .end() + } + + logger.debug('Checking follow parameters', { parameters: req.body }) + + checkErrors(req, res, next) + } +] + +const removeFollowingValidator = [ + body('accountId').custom(isAccountIdValid).withMessage('Should have a valid account id'), + + (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking follow parameters', { parameters: req.body }) + + checkErrors(req, res, async () => { + try { + const serverAccount = await getServerAccount() + const following = await db.AccountFollow.loadByAccountAndTarget(serverAccount.id, req.params.accountId) + + if (!following) { + return res.status(404) + .end() + } + + res.locals.following = following + + return next() + } catch (err) { + logger.error('Error in remove following validator.', err) + return res.sendStatus(500) + } + }) + } +] + +// --------------------------------------------------------------------------- + +export { + followValidator, + removeFollowingValidator +} diff --git a/server/middlewares/validators/index.ts b/server/middlewares/validators/index.ts index 3f5afe5b3..9840e8f65 100644 --- a/server/middlewares/validators/index.ts +++ b/server/middlewares/validators/index.ts @@ -2,7 +2,7 @@ export * from './account' export * from './oembed' export * from './activitypub' export * from './pagination' -export * from './servers' +export * from './follows' export * from './sort' export * from './users' export * from './videos' diff --git a/server/middlewares/validators/servers.ts b/server/middlewares/validators/servers.ts deleted file mode 100644 index 95b69b789..000000000 --- a/server/middlewares/validators/servers.ts +++ /dev/null @@ -1,32 +0,0 @@ -import * as express from 'express' -import { body } from 'express-validator/check' -import { isEachUniqueHostValid } from '../../helpers/custom-validators/servers' -import { isTestInstance } from '../../helpers/core-utils' -import { CONFIG } from '../../initializers/constants' -import { logger } from '../../helpers/logger' -import { checkErrors } from './utils' - -const followValidator = [ - body('hosts').custom(isEachUniqueHostValid).withMessage('Should have an array of unique hosts'), - - (req: express.Request, res: express.Response, next: express.NextFunction) => { - // Force https if the administrator wants to make friends - if (isTestInstance() === false && CONFIG.WEBSERVER.SCHEME === 'http') { - return res.status(400) - .json({ - error: 'Cannot follow non HTTPS web server.' - }) - .end() - } - - logger.debug('Checking follow parameters', { parameters: req.body }) - - checkErrors(req, res, next) - } -] - -// --------------------------------------------------------------------------- - -export { - followValidator -} diff --git a/server/models/account/account-follow.ts b/server/models/account/account-follow.ts index cc9b7c42b..f00c7dcd9 100644 --- a/server/models/account/account-follow.ts +++ b/server/models/account/account-follow.ts @@ -78,7 +78,19 @@ loadByAccountAndTarget = function (accountId: number, targetAccountId: number) { where: { accountId, targetAccountId - } + }, + include: [ + { + model: AccountFollow[ 'sequelize' ].models.Account, + required: true, + as: 'AccountFollower' + }, + { + model: AccountFollow['sequelize'].models.Account, + required: true, + as: 'AccountFollowing' + } + ] } return AccountFollow.findOne(query) diff --git a/server/models/account/account-interface.ts b/server/models/account/account-interface.ts index 1a567fb7a..e30260f76 100644 --- a/server/models/account/account-interface.ts +++ b/server/models/account/account-interface.ts @@ -37,7 +37,7 @@ export interface AccountClass { export interface AccountAttributes { name: string - url: string + url?: string publicKey: string privateKey: string followersCount: number diff --git a/server/models/account/account.ts b/server/models/account/account.ts index faf5fa841..9a2921501 100644 --- a/server/models/account/account.ts +++ b/server/models/account/account.ts @@ -1,5 +1,4 @@ import * as Sequelize from 'sequelize' - import { activityPubContextify, isAccountFollowersCountValid, @@ -15,7 +14,7 @@ import { isUserUsernameValid } from '../../helpers' import { CONFIG, CONSTRAINTS_FIELDS } from '../../initializers/constants' -import { sendDeleteAccount } from '../../lib/activitypub/send-request' +import { sendDeleteAccount } from '../../lib/activitypub/send/send-delete' import { addMethodsToModel } from '../utils' import { AccountAttributes, AccountInstance, AccountMethods } from './account-interface' diff --git a/server/models/video/video-channel.ts b/server/models/video/video-channel.ts index f8414d4a8..93566a5c6 100644 --- a/server/models/video/video-channel.ts +++ b/server/models/video/video-channel.ts @@ -1,17 +1,11 @@ import * as Sequelize from 'sequelize' - -import { isVideoChannelNameValid, isVideoChannelDescriptionValid } from '../../helpers' - -import { addMethodsToModel, getSort } from '../utils' -import { - VideoChannelInstance, - VideoChannelAttributes, - - VideoChannelMethods -} from './video-channel-interface' -import { sendDeleteVideoChannel } from '../../lib/activitypub/send-request' +import { isVideoChannelDescriptionValid, isVideoChannelNameValid } from '../../helpers' import { isVideoChannelUrlValid } from '../../helpers/custom-validators/video-channels' import { CONSTRAINTS_FIELDS } from '../../initializers/constants' +import { sendDeleteVideoChannel } from '../../lib/activitypub/send/send-delete' + +import { addMethodsToModel, getSort } from '../utils' +import { VideoChannelAttributes, VideoChannelInstance, VideoChannelMethods } from './video-channel-interface' let VideoChannel: Sequelize.Model let toFormattedJSON: VideoChannelMethods.ToFormattedJSON diff --git a/server/models/video/video.ts b/server/models/video/video.ts index dc10aca1a..e2069eb0c 100644 --- a/server/models/video/video.ts +++ b/server/models/video/video.ts @@ -9,7 +9,6 @@ import { VideoTorrentObject } from '../../../shared/models/activitypub/objects/v import { createTorrentPromise, generateImageFromVideoFile, - getActivityPubUrl, getVideoFileHeight, isVideoCategoryValid, isVideoDescriptionValid, @@ -40,13 +39,13 @@ import { VIDEO_LICENCES, VIDEO_PRIVACIES } from '../../initializers' -import { sendDeleteVideo } from '../../lib/activitypub/send-request' import { addMethodsToModel, getSort } from '../utils' import { TagInstance } from './tag-interface' import { VideoFileInstance, VideoFileModel } from './video-file-interface' import { VideoAttributes, VideoInstance, VideoMethods } from './video-interface' +import { sendDeleteVideo } from '../../lib/index' const Buffer = safeBuffer.Buffer @@ -584,7 +583,7 @@ toActivityPubObject = function (this: VideoInstance) { const videoObject: VideoTorrentObject = { type: 'Video' as 'Video', - id: getActivityPubUrl('video', this.uuid), + id: this.url, name: this.name, // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration duration: 'PT' + this.duration + 'S', @@ -615,7 +614,7 @@ toActivityPubObject = function (this: VideoInstance) { width: THUMBNAILS_SIZE.width, height: THUMBNAILS_SIZE.height }, - url + url // FIXME: needed? } return videoObject diff --git a/server/tests/api/multiple-servers.ts b/server/tests/api/multiple-servers.ts index b6a57ab6d..cdbd24f56 100644 --- a/server/tests/api/multiple-servers.ts +++ b/server/tests/api/multiple-servers.ts @@ -316,7 +316,7 @@ describe('Test multiple servers', function () { expect(video1.serverHost).to.equal('localhost:9003') expect(video1.duration).to.equal(5) expect(video1.tags).to.deep.equal([ 'tag1p3' ]) - expect(video1.author).to.equal('root') + expect(video1.account).to.equal('root') expect(dateIsValid(video1.createdAt)).to.be.true expect(dateIsValid(video1.updatedAt)).to.be.true @@ -342,7 +342,7 @@ describe('Test multiple servers', function () { expect(video2.serverHost).to.equal('localhost:9003') expect(video2.duration).to.equal(5) expect(video2.tags).to.deep.equal([ 'tag2p3', 'tag3p3', 'tag4p3' ]) - expect(video2.author).to.equal('root') + expect(video2.account).to.equal('root') expect(dateIsValid(video2.createdAt)).to.be.true expect(dateIsValid(video2.updatedAt)).to.be.true @@ -690,7 +690,7 @@ describe('Test multiple servers', function () { expect(baseVideo.licence).to.equal(video.licence) expect(baseVideo.category).to.equal(video.category) expect(baseVideo.nsfw).to.equal(video.nsfw) - expect(baseVideo.author).to.equal(video.account) + expect(baseVideo.account).to.equal(video.account) expect(baseVideo.tags).to.deep.equal(video.tags) } }) diff --git a/shared/models/activitypub/activity.ts b/shared/models/activitypub/activity.ts index 255cdd43c..3d035d7d7 100644 --- a/shared/models/activitypub/activity.ts +++ b/shared/models/activitypub/activity.ts @@ -3,10 +3,10 @@ import { ActivityPubSignature } from './activitypub-signature' import { VideoAbuseObject } from './objects/video-abuse-object' export type Activity = ActivityCreate | ActivityAdd | ActivityUpdate | - ActivityDelete | ActivityFollow | ActivityAccept | ActivityAnnounce + ActivityDelete | ActivityFollow | ActivityAccept | ActivityAnnounce | + ActivityUndo -// Flag -> report abuse -export type ActivityType = 'Create' | 'Add' | 'Update' | 'Delete' | 'Follow' | 'Accept' | 'Announce' +export type ActivityType = 'Create' | 'Add' | 'Update' | 'Delete' | 'Follow' | 'Accept' | 'Announce' | 'Undo' export interface BaseActivity { '@context'?: any[] @@ -51,3 +51,8 @@ export interface ActivityAnnounce extends BaseActivity { type: 'Announce' object: ActivityCreate | ActivityAdd } + +export interface ActivityUndo extends BaseActivity { + type: 'Undo', + object: ActivityFollow +} -- cgit v1.2.3