From 06a05d5f4784a7cbb27aa1188385b5679845dad8 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Thu, 16 Aug 2018 15:25:20 +0200 Subject: [PATCH] Add subscriptions endpoints to REST API --- server/controllers/activitypub/inbox.ts | 20 +- server/controllers/activitypub/outbox.ts | 14 +- server/controllers/api/accounts.ts | 1 + server/controllers/api/search.ts | 5 +- server/controllers/api/server/follows.ts | 17 +- server/controllers/api/users/index.ts | 3 +- server/controllers/api/users/me.ts | 114 ++++++- server/controllers/api/video-channel.ts | 1 + server/controllers/api/videos/index.ts | 1 + server/controllers/feeds.ts | 1 + .../custom-validators/activitypub/actor.ts | 13 +- .../custom-validators/video-channels.ts | 32 +- server/helpers/custom-validators/webfinger.ts | 4 +- server/helpers/webfinger.ts | 10 +- server/initializers/constants.ts | 1 + server/lib/activitypub/actor.ts | 2 +- server/lib/activitypub/send/send-accept.ts | 5 + server/lib/activitypub/send/send-create.ts | 2 + server/lib/activitypub/send/send-follow.ts | 3 + server/lib/activitypub/send/send-undo.ts | 3 + server/lib/activitypub/send/utils.ts | 21 +- .../job-queue/handlers/activitypub-follow.ts | 13 +- server/middlewares/validators/follows.ts | 4 +- server/middlewares/validators/index.ts | 1 + server/middlewares/validators/sort.ts | 5 +- .../validators/user-subscriptions.ts | 58 ++++ .../middlewares/validators/video-channels.ts | 17 +- server/middlewares/validators/webfinger.ts | 4 +- server/models/activitypub/actor-follow.ts | 93 +++++- server/models/video/video-channel.ts | 41 ++- server/models/video/video.ts | 31 +- server/tests/api/check-params/index.ts | 1 + .../api/check-params/user-subscriptions.ts | 220 ++++++++++++ server/tests/api/index-slow.ts | 1 + server/tests/api/users/user-subscriptions.ts | 312 ++++++++++++++++++ .../tests/utils/users/user-subscriptions.ts | 57 ++++ 36 files changed, 1038 insertions(+), 93 deletions(-) create mode 100644 server/middlewares/validators/user-subscriptions.ts create mode 100644 server/tests/api/check-params/user-subscriptions.ts create mode 100644 server/tests/api/users/user-subscriptions.ts create mode 100644 server/tests/utils/users/user-subscriptions.ts diff --git a/server/controllers/activitypub/inbox.ts b/server/controllers/activitypub/inbox.ts index a106df717..20bd20ed4 100644 --- a/server/controllers/activitypub/inbox.ts +++ b/server/controllers/activitypub/inbox.ts @@ -3,9 +3,10 @@ import { Activity, ActivityPubCollection, ActivityPubOrderedCollection, RootActi import { isActivityValid } from '../../helpers/custom-validators/activitypub/activity' import { logger } from '../../helpers/logger' import { processActivities } from '../../lib/activitypub/process/process' -import { asyncMiddleware, checkSignature, localAccountValidator, signatureValidator } from '../../middlewares' +import { asyncMiddleware, checkSignature, localAccountValidator, localVideoChannelValidator, signatureValidator } from '../../middlewares' import { activityPubValidator } from '../../middlewares/validators/activitypub/activity' -import { ActorModel } from '../../models/activitypub/actor' +import { VideoChannelModel } from '../../models/video/video-channel' +import { AccountModel } from '../../models/account/account' const inboxRouter = express.Router() @@ -23,6 +24,13 @@ inboxRouter.post('/accounts/:name/inbox', asyncMiddleware(activityPubValidator), asyncMiddleware(inboxController) ) +inboxRouter.post('/video-channels/:name/inbox', + signatureValidator, + asyncMiddleware(checkSignature), + asyncMiddleware(localVideoChannelValidator), + asyncMiddleware(activityPubValidator), + asyncMiddleware(inboxController) +) // --------------------------------------------------------------------------- @@ -49,16 +57,16 @@ async function inboxController (req: express.Request, res: express.Response, nex activities = activities.filter(a => isActivityValid(a)) logger.debug('We keep %d activities.', activities.length, { activities }) - let specificActor: ActorModel = undefined + let accountOrChannel: VideoChannelModel | AccountModel if (res.locals.account) { - specificActor = res.locals.account + accountOrChannel = res.locals.account } else if (res.locals.videoChannel) { - specificActor = res.locals.videoChannel + accountOrChannel = res.locals.videoChannel } logger.info('Receiving inbox requests for %d activities by %s.', activities.length, res.locals.signature.actor.url) - await processActivities(activities, res.locals.signature.actor, specificActor) + await processActivities(activities, res.locals.signature.actor, accountOrChannel ? accountOrChannel.Actor : undefined) res.status(204).end() } diff --git a/server/controllers/activitypub/outbox.ts b/server/controllers/activitypub/outbox.ts index ae7adcd4c..db69ae54b 100644 --- a/server/controllers/activitypub/outbox.ts +++ b/server/controllers/activitypub/outbox.ts @@ -5,11 +5,12 @@ import { activityPubCollectionPagination, activityPubContextify } from '../../he import { logger } from '../../helpers/logger' import { announceActivityData, createActivityData } from '../../lib/activitypub/send' import { buildAudience } from '../../lib/activitypub/audience' -import { asyncMiddleware, localAccountValidator } from '../../middlewares' +import { asyncMiddleware, localAccountValidator, localVideoChannelValidator } from '../../middlewares' import { AccountModel } from '../../models/account/account' import { ActorModel } from '../../models/activitypub/actor' import { VideoModel } from '../../models/video/video' import { activityPubResponse } from './utils' +import { VideoChannelModel } from '../../models/video/video-channel' const outboxRouter = express.Router() @@ -18,6 +19,11 @@ outboxRouter.get('/accounts/:name/outbox', asyncMiddleware(outboxController) ) +outboxRouter.get('/video-channels/:name/outbox', + localVideoChannelValidator, + asyncMiddleware(outboxController) +) + // --------------------------------------------------------------------------- export { @@ -27,9 +33,9 @@ export { // --------------------------------------------------------------------------- async function outboxController (req: express.Request, res: express.Response, next: express.NextFunction) { - const account: AccountModel = res.locals.account - const actor = account.Actor - const actorOutboxUrl = account.Actor.url + '/outbox' + const accountOrVideoChannel: AccountModel | VideoChannelModel = res.locals.account || res.locals.videoChannel + const actor = accountOrVideoChannel.Actor + const actorOutboxUrl = actor.url + '/outbox' logger.info('Receiving outbox request for %s.', actorOutboxUrl) diff --git a/server/controllers/api/accounts.ts b/server/controllers/api/accounts.ts index 0117fc8c6..308970abc 100644 --- a/server/controllers/api/accounts.ts +++ b/server/controllers/api/accounts.ts @@ -78,6 +78,7 @@ async function listAccountVideos (req: express.Request, res: express.Response, n start: req.query.start, count: req.query.count, sort: req.query.sort, + includeLocalVideos: false, categoryOneOf: req.query.categoryOneOf, licenceOneOf: req.query.licenceOneOf, languageOneOf: req.query.languageOneOf, diff --git a/server/controllers/api/search.ts b/server/controllers/api/search.ts index f810c7452..7a7504b7d 100644 --- a/server/controllers/api/search.ts +++ b/server/controllers/api/search.ts @@ -36,7 +36,10 @@ export { searchRouter } async function searchVideos (req: express.Request, res: express.Response) { const query: VideosSearchQuery = req.query - const options = Object.assign(query, { nsfw: buildNSFWFilter(res, query.nsfw) }) + const options = Object.assign(query, { + includeLocalVideos: true, + nsfw: buildNSFWFilter(res, query.nsfw) + }) const resultList = await VideoModel.searchAndPopulateAccountAndServer(options) return res.json(getFormattedObjects(resultList.data, resultList.total)) diff --git a/server/controllers/api/server/follows.ts b/server/controllers/api/server/follows.ts index e78361c9a..23308445f 100644 --- a/server/controllers/api/server/follows.ts +++ b/server/controllers/api/server/follows.ts @@ -2,7 +2,7 @@ import * as express from 'express' import { UserRight } from '../../../../shared/models/users' import { logger } from '../../../helpers/logger' import { getFormattedObjects, getServerActor } from '../../../helpers/utils' -import { sequelizeTypescript } from '../../../initializers' +import { sequelizeTypescript, SERVER_ACTOR_NAME } from '../../../initializers' import { sendUndoFollow } from '../../../lib/activitypub/send' import { asyncMiddleware, @@ -74,9 +74,16 @@ async function listFollowers (req: express.Request, res: express.Response, next: async function followInstance (req: express.Request, res: express.Response, next: express.NextFunction) { const hosts = req.body.hosts as string[] + const follower = await getServerActor() for (const host of hosts) { - JobQueue.Instance.createJob({ type: 'activitypub-follow', payload: { host } }) + const payload = { + host, + name: SERVER_ACTOR_NAME, + followerActorId: follower.id + } + + JobQueue.Instance.createJob({ type: 'activitypub-follow', payload }) .catch(err => logger.error('Cannot create follow job for %s.', host, err)) } @@ -92,11 +99,5 @@ async function removeFollow (req: express.Request, res: express.Response, next: await follow.destroy({ transaction: t }) }) - // Destroy the actor that will destroy video channels, videos and video files too - // This could be long so don't wait this task - const following = follow.ActorFollowing - following.destroy() - .catch(err => logger.error('Cannot destroy actor that we do not follow anymore %s.', following.url, { err })) - return res.status(204).end() } diff --git a/server/controllers/api/users/index.ts b/server/controllers/api/users/index.ts index 105244ddd..608d439ac 100644 --- a/server/controllers/api/users/index.ts +++ b/server/controllers/api/users/index.ts @@ -29,7 +29,6 @@ import { usersAskResetPasswordValidator, usersBlockingValidator, usersResetPassw import { UserModel } from '../../../models/account/user' import { OAuthTokenModel } from '../../../models/oauth/oauth-token' import { auditLoggerFactory, UserAuditView } from '../../../helpers/audit-logger' -import { videosRouter } from '../videos' import { meRouter } from './me' const auditLogger = auditLoggerFactory('users') @@ -41,7 +40,7 @@ const loginRateLimiter = new RateLimit({ }) const usersRouter = express.Router() -videosRouter.use('/', meRouter) +usersRouter.use('/', meRouter) usersRouter.get('/', authenticate, diff --git a/server/controllers/api/users/me.ts b/server/controllers/api/users/me.ts index 1e096a35d..403842163 100644 --- a/server/controllers/api/users/me.ts +++ b/server/controllers/api/users/me.ts @@ -7,23 +7,35 @@ import { sendUpdateActor } from '../../../lib/activitypub/send' import { asyncMiddleware, authenticate, + commonVideosFiltersValidator, paginationValidator, setDefaultPagination, setDefaultSort, + userSubscriptionAddValidator, + userSubscriptionRemoveValidator, usersUpdateMeValidator, usersVideoRatingValidator } from '../../../middlewares' -import { deleteMeValidator, videoImportsSortValidator, videosSortValidator } from '../../../middlewares/validators' +import { + deleteMeValidator, + userSubscriptionsSortValidator, + videoImportsSortValidator, + videosSortValidator +} from '../../../middlewares/validators' import { AccountVideoRateModel } from '../../../models/account/account-video-rate' import { UserModel } from '../../../models/account/user' import { VideoModel } from '../../../models/video/video' import { VideoSortField } from '../../../../client/src/app/shared/video/sort-field.type' -import { createReqFiles } from '../../../helpers/express-utils' +import { buildNSFWFilter, createReqFiles } from '../../../helpers/express-utils' import { UserVideoQuota } from '../../../../shared/models/users/user-video-quota.model' import { updateAvatarValidator } from '../../../middlewares/validators/avatar' import { updateActorAvatarFile } from '../../../lib/avatar' import { auditLoggerFactory, UserAuditView } from '../../../helpers/audit-logger' import { VideoImportModel } from '../../../models/video/video-import' +import { VideoFilter } from '../../../../shared/models/videos/video-query.type' +import { ActorFollowModel } from '../../../models/activitypub/actor-follow' +import { JobQueue } from '../../../lib/job-queue' +import { logger } from '../../../helpers/logger' const auditLogger = auditLoggerFactory('users-me') @@ -83,6 +95,40 @@ meRouter.post('/me/avatar/pick', asyncMiddleware(updateMyAvatar) ) +// ##### Subscriptions part ##### + +meRouter.get('/me/subscriptions', + authenticate, + paginationValidator, + userSubscriptionsSortValidator, + setDefaultSort, + setDefaultPagination, + asyncMiddleware(getUserSubscriptions) +) + +meRouter.post('/me/subscriptions', + authenticate, + userSubscriptionAddValidator, + asyncMiddleware(addUserSubscription) +) + +meRouter.delete('/me/subscriptions/:uri', + authenticate, + userSubscriptionRemoveValidator, + asyncMiddleware(deleteUserSubscription) +) + +meRouter.get('/me/subscriptions/videos', + authenticate, + authenticate, + paginationValidator, + videosSortValidator, + setDefaultSort, + setDefaultPagination, + commonVideosFiltersValidator, + asyncMiddleware(getUserSubscriptionVideos) +) + // --------------------------------------------------------------------------- export { @@ -91,6 +137,62 @@ export { // --------------------------------------------------------------------------- +async function addUserSubscription (req: express.Request, res: express.Response) { + const user = res.locals.oauth.token.User as UserModel + const [ name, host ] = req.body.uri.split('@') + + const payload = { + name, + host, + followerActorId: user.Account.Actor.id + } + + JobQueue.Instance.createJob({ type: 'activitypub-follow', payload }) + .catch(err => logger.error('Cannot create follow job for subscription %s.', req.body.uri, err)) + + return res.status(204).end() +} + +async function deleteUserSubscription (req: express.Request, res: express.Response) { + const subscription: ActorFollowModel = res.locals.subscription + + await sequelizeTypescript.transaction(async t => { + return subscription.destroy({ transaction: t }) + }) + + return res.type('json').status(204).end() +} + +async function getUserSubscriptions (req: express.Request, res: express.Response) { + const user = res.locals.oauth.token.User as UserModel + const actorId = user.Account.Actor.id + + const resultList = await ActorFollowModel.listSubscriptionsForApi(actorId, req.query.start, req.query.count, req.query.sort) + + return res.json(getFormattedObjects(resultList.data, resultList.total)) +} + +async function getUserSubscriptionVideos (req: express.Request, res: express.Response, next: express.NextFunction) { + const user = res.locals.oauth.token.User as UserModel + const resultList = await VideoModel.listForApi({ + start: req.query.start, + count: req.query.count, + sort: req.query.sort, + includeLocalVideos: false, + categoryOneOf: req.query.categoryOneOf, + licenceOneOf: req.query.licenceOneOf, + languageOneOf: req.query.languageOneOf, + tagsOneOf: req.query.tagsOneOf, + tagsAllOf: req.query.tagsAllOf, + nsfw: buildNSFWFilter(res, req.query.nsfw), + filter: req.query.filter as VideoFilter, + withFiles: false, + actorId: user.Account.Actor.id + }) + + return res.json(getFormattedObjects(resultList.data, resultList.total)) +} + async function getUserVideos (req: express.Request, res: express.Response, next: express.NextFunction) { const user = res.locals.oauth.token.User as UserModel const resultList = await VideoModel.listUserVideosForApi( @@ -150,7 +252,7 @@ async function getUserVideoRating (req: express.Request, res: express.Response, videoId, rating } - res.json(json) + return res.json(json) } async function deleteMe (req: express.Request, res: express.Response) { @@ -207,9 +309,5 @@ async function updateMyAvatar (req: express.Request, res: express.Response, next oldUserAuditView ) - return res - .json({ - avatar: avatar.toFormattedJSON() - }) - .end() + return res.json({ avatar: avatar.toFormattedJSON() }) } diff --git a/server/controllers/api/video-channel.ts b/server/controllers/api/video-channel.ts index 023ebbedf..6ffc09f87 100644 --- a/server/controllers/api/video-channel.ts +++ b/server/controllers/api/video-channel.ts @@ -215,6 +215,7 @@ async function listVideoChannelVideos (req: express.Request, res: express.Respon start: req.query.start, count: req.query.count, sort: req.query.sort, + includeLocalVideos: false, categoryOneOf: req.query.categoryOneOf, licenceOneOf: req.query.licenceOneOf, languageOneOf: req.query.languageOneOf, diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts index 92c6ee697..e973aa43f 100644 --- a/server/controllers/api/videos/index.ts +++ b/server/controllers/api/videos/index.ts @@ -414,6 +414,7 @@ async function listVideos (req: express.Request, res: express.Response, next: ex start: req.query.start, count: req.query.count, sort: req.query.sort, + includeLocalVideos: true, categoryOneOf: req.query.categoryOneOf, licenceOneOf: req.query.licenceOneOf, languageOneOf: req.query.languageOneOf, diff --git a/server/controllers/feeds.ts b/server/controllers/feeds.ts index 682f4abda..b30ad8e8d 100644 --- a/server/controllers/feeds.ts +++ b/server/controllers/feeds.ts @@ -96,6 +96,7 @@ async function generateVideoFeed (req: express.Request, res: express.Response, n start, count: FEEDS.COUNT, sort: req.query.sort, + includeLocalVideos: true, nsfw, filter: req.query.filter, withFiles: true, diff --git a/server/helpers/custom-validators/activitypub/actor.ts b/server/helpers/custom-validators/activitypub/actor.ts index c7a64e24d..ae5014f8f 100644 --- a/server/helpers/custom-validators/activitypub/actor.ts +++ b/server/helpers/custom-validators/activitypub/actor.ts @@ -3,6 +3,7 @@ import { CONSTRAINTS_FIELDS } from '../../../initializers' import { exists } from '../misc' import { truncate } from 'lodash' import { isActivityPubUrlValid, isBaseActivityValid, setValidAttributedTo } from './misc' +import { isHostValid } from '../servers' function isActorEndpointsObjectValid (endpointObject: any) { return isActivityPubUrlValid(endpointObject.sharedInbox) @@ -109,6 +110,15 @@ function normalizeActor (actor: any) { return } +function isValidActorHandle (handle: string) { + if (!exists(handle)) return false + + const parts = handle.split('@') + if (parts.length !== 2) return false + + return isHostValid(parts[1]) +} + // --------------------------------------------------------------------------- export { @@ -126,5 +136,6 @@ export { isActorAcceptActivityValid, isActorRejectActivityValid, isActorDeleteActivityValid, - isActorUpdateActivityValid + isActorUpdateActivityValid, + isValidActorHandle } diff --git a/server/helpers/custom-validators/video-channels.ts b/server/helpers/custom-validators/video-channels.ts index 2a6f56840..32faf36f7 100644 --- a/server/helpers/custom-validators/video-channels.ts +++ b/server/helpers/custom-validators/video-channels.ts @@ -5,6 +5,7 @@ import * as validator from 'validator' import { CONSTRAINTS_FIELDS } from '../../initializers' import { VideoChannelModel } from '../../models/video/video-channel' import { exists } from './misc' +import { Response } from 'express' const VIDEO_CHANNELS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEO_CHANNELS @@ -20,6 +21,12 @@ function isVideoChannelSupportValid (value: string) { return value === null || (exists(value) && validator.isLength(value, VIDEO_CHANNELS_CONSTRAINTS_FIELDS.SUPPORT)) } +async function isLocalVideoChannelNameExist (name: string, res: Response) { + const videoChannel = await VideoChannelModel.loadLocalByName(name) + + return processVideoChannelExist(videoChannel, res) +} + async function isVideoChannelExist (id: string, res: express.Response) { let videoChannel: VideoChannelModel if (validator.isInt(id)) { @@ -28,23 +35,28 @@ async function isVideoChannelExist (id: string, res: express.Response) { videoChannel = await VideoChannelModel.loadByUUIDAndPopulateAccount(id) } - if (!videoChannel) { - res.status(404) - .json({ error: 'Video channel not found' }) - .end() - - return false - } - - res.locals.videoChannel = videoChannel - return true + return processVideoChannelExist(videoChannel, res) } // --------------------------------------------------------------------------- export { + isLocalVideoChannelNameExist, isVideoChannelDescriptionValid, isVideoChannelNameValid, isVideoChannelSupportValid, isVideoChannelExist } + +function processVideoChannelExist (videoChannel: VideoChannelModel, res: express.Response) { + if (!videoChannel) { + res.status(404) + .json({ error: 'Video channel not found' }) + .end() + + return false + } + + res.locals.videoChannel = videoChannel + return true +} diff --git a/server/helpers/custom-validators/webfinger.ts b/server/helpers/custom-validators/webfinger.ts index d8c1232ce..80a7e4a9d 100644 --- a/server/helpers/custom-validators/webfinger.ts +++ b/server/helpers/custom-validators/webfinger.ts @@ -2,7 +2,7 @@ import { CONFIG, REMOTE_SCHEME } from '../../initializers' import { sanitizeHost } from '../core-utils' import { exists } from './misc' -function isWebfingerResourceValid (value: string) { +function isWebfingerLocalResourceValid (value: string) { if (!exists(value)) return false if (value.startsWith('acct:') === false) return false @@ -17,5 +17,5 @@ function isWebfingerResourceValid (value: string) { // --------------------------------------------------------------------------- export { - isWebfingerResourceValid + isWebfingerLocalResourceValid } diff --git a/server/helpers/webfinger.ts b/server/helpers/webfinger.ts index 688bf2bab..5c60de10c 100644 --- a/server/helpers/webfinger.ts +++ b/server/helpers/webfinger.ts @@ -11,15 +11,17 @@ const webfinger = new WebFinger({ request_timeout: 3000 }) -async function loadActorUrlOrGetFromWebfinger (name: string, host: string) { +async function loadActorUrlOrGetFromWebfinger (uri: string) { + const [ name, host ] = uri.split('@') + const actor = await ActorModel.loadByNameAndHost(name, host) if (actor) return actor.url - return getUrlFromWebfinger(name, host) + return getUrlFromWebfinger(uri) } -async function getUrlFromWebfinger (name: string, host: string) { - const webfingerData: WebFingerData = await webfingerLookup(name + '@' + host) +async function getUrlFromWebfinger (uri: string) { + const webfingerData: WebFingerData = await webfingerLookup(uri) return getLinkOrThrow(webfingerData) } diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index bce797159..7e865fe3b 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts @@ -31,6 +31,7 @@ const PAGINATION = { // Sortable columns per schema const SORTABLE_COLUMNS = { USERS: [ 'id', 'username', 'createdAt' ], + USER_SUBSCRIPTIONS: [ 'id', 'createdAt' ], ACCOUNTS: [ 'createdAt' ], JOBS: [ 'createdAt' ], VIDEO_ABUSES: [ 'id', 'createdAt', 'state' ], diff --git a/server/lib/activitypub/actor.ts b/server/lib/activitypub/actor.ts index b67d9f08b..d84b465b2 100644 --- a/server/lib/activitypub/actor.ts +++ b/server/lib/activitypub/actor.ts @@ -352,7 +352,7 @@ async function refreshActorIfNeeded (actor: ActorModel): Promise { if (!actor.isOutdated()) return actor try { - const actorUrl = await getUrlFromWebfinger(actor.preferredUsername, actor.getHost()) + const actorUrl = await getUrlFromWebfinger(actor.preferredUsername + '@' + actor.getHost()) const result = await fetchRemoteActor(actorUrl) if (result === undefined) { logger.warn('Cannot fetch remote actor in refresh actor.') diff --git a/server/lib/activitypub/send/send-accept.ts b/server/lib/activitypub/send/send-accept.ts index dfee1ec3e..ef679707b 100644 --- a/server/lib/activitypub/send/send-accept.ts +++ b/server/lib/activitypub/send/send-accept.ts @@ -10,6 +10,11 @@ async function sendAccept (actorFollow: ActorFollowModel) { const follower = actorFollow.ActorFollower const me = actorFollow.ActorFollowing + if (!follower.serverId) { // This should never happen + logger.warn('Do not sending accept to local follower.') + return + } + logger.info('Creating job to accept follower %s.', follower.url) const followUrl = getActorFollowActivityPubUrl(actorFollow) diff --git a/server/lib/activitypub/send/send-create.ts b/server/lib/activitypub/send/send-create.ts index f7a8cf0b3..fc76cdd8a 100644 --- a/server/lib/activitypub/send/send-create.ts +++ b/server/lib/activitypub/send/send-create.ts @@ -33,6 +33,8 @@ async function sendCreateVideo (video: VideoModel, t: Transaction) { } async function sendVideoAbuse (byActor: ActorModel, videoAbuse: VideoAbuseModel, video: VideoModel, t: Transaction) { + if (!video.VideoChannel.Account.Actor.serverId) return // Local + const url = getVideoAbuseActivityPubUrl(videoAbuse) logger.info('Creating job to send video abuse %s.', url) diff --git a/server/lib/activitypub/send/send-follow.ts b/server/lib/activitypub/send/send-follow.ts index 2faffe6e7..46d08c17b 100644 --- a/server/lib/activitypub/send/send-follow.ts +++ b/server/lib/activitypub/send/send-follow.ts @@ -9,6 +9,9 @@ function sendFollow (actorFollow: ActorFollowModel) { const me = actorFollow.ActorFollower const following = actorFollow.ActorFollowing + // Same server as ours + if (!following.serverId) return + logger.info('Creating job to send follow request to %s.', following.url) const url = getActorFollowActivityPubUrl(actorFollow) diff --git a/server/lib/activitypub/send/send-undo.ts b/server/lib/activitypub/send/send-undo.ts index 4e5dd3973..30d0fd98b 100644 --- a/server/lib/activitypub/send/send-undo.ts +++ b/server/lib/activitypub/send/send-undo.ts @@ -24,6 +24,9 @@ async function sendUndoFollow (actorFollow: ActorFollowModel, t: Transaction) { const me = actorFollow.ActorFollower const following = actorFollow.ActorFollowing + // Same server as ours + if (!following.serverId) return + logger.info('Creating job to send an unfollow request to %s.', following.url) const followUrl = getActorFollowActivityPubUrl(actorFollow) diff --git a/server/lib/activitypub/send/utils.ts b/server/lib/activitypub/send/utils.ts index 0d28444ec..da437292e 100644 --- a/server/lib/activitypub/send/utils.ts +++ b/server/lib/activitypub/send/utils.ts @@ -6,6 +6,7 @@ import { ActorFollowModel } from '../../../models/activitypub/actor-follow' import { JobQueue } from '../../job-queue' import { VideoModel } from '../../../models/video/video' import { getActorsInvolvedInVideo } from '../audience' +import { getServerActor } from '../../../helpers/utils' async function forwardVideoRelatedActivity ( activity: Activity, @@ -118,14 +119,28 @@ async function computeFollowerUris (toActorFollower: ActorModel[], actorsExcepti const toActorFollowerIds = toActorFollower.map(a => a.id) const result = await ActorFollowModel.listAcceptedFollowerSharedInboxUrls(toActorFollowerIds, t) - const sharedInboxesException = actorsException.map(f => f.sharedInboxUrl || f.inboxUrl) + const sharedInboxesException = await buildSharedInboxesException(actorsException) + return result.data.filter(sharedInbox => sharedInboxesException.indexOf(sharedInbox) === -1) } async function computeUris (toActors: ActorModel[], actorsException: ActorModel[] = []) { - const toActorSharedInboxesSet = new Set(toActors.map(a => a.sharedInboxUrl || a.inboxUrl)) + const serverActor = await getServerActor() + const targetUrls = toActors + .filter(a => a.id !== serverActor.id) // Don't send to ourselves + .map(a => a.sharedInboxUrl || a.inboxUrl) + + const toActorSharedInboxesSet = new Set(targetUrls) - const sharedInboxesException = actorsException.map(f => f.sharedInboxUrl || f.inboxUrl) + const sharedInboxesException = await buildSharedInboxesException(actorsException) return Array.from(toActorSharedInboxesSet) .filter(sharedInbox => sharedInboxesException.indexOf(sharedInbox) === -1) } + +async function buildSharedInboxesException (actorsException: ActorModel[]) { + const serverActor = await getServerActor() + + return actorsException + .map(f => f.sharedInboxUrl || f.inboxUrl) + .concat([ serverActor.sharedInboxUrl ]) +} diff --git a/server/lib/job-queue/handlers/activitypub-follow.ts b/server/lib/job-queue/handlers/activitypub-follow.ts index 393c6936c..282dde268 100644 --- a/server/lib/job-queue/handlers/activitypub-follow.ts +++ b/server/lib/job-queue/handlers/activitypub-follow.ts @@ -1,7 +1,7 @@ import * as Bull from 'bull' import { logger } from '../../../helpers/logger' import { getServerActor } from '../../../helpers/utils' -import { REMOTE_SCHEME, sequelizeTypescript, SERVER_ACTOR_NAME } from '../../../initializers' +import { REMOTE_SCHEME, sequelizeTypescript } from '../../../initializers' import { sendFollow } from '../../activitypub/send' import { sanitizeHost } from '../../../helpers/core-utils' import { loadActorUrlOrGetFromWebfinger } from '../../../helpers/webfinger' @@ -11,6 +11,8 @@ import { ActorFollowModel } from '../../../models/activitypub/actor-follow' import { ActorModel } from '../../../models/activitypub/actor' export type ActivitypubFollowPayload = { + followerActorId: number + name: string host: string } @@ -22,10 +24,10 @@ async function processActivityPubFollow (job: Bull.Job) { const sanitizedHost = sanitizeHost(host, REMOTE_SCHEME.HTTP) - const actorUrl = await loadActorUrlOrGetFromWebfinger(SERVER_ACTOR_NAME, sanitizedHost) + const actorUrl = await loadActorUrlOrGetFromWebfinger(payload.name + '@' + sanitizedHost) const targetActor = await getOrCreateActorAndServerAndModel(actorUrl) - const fromActor = await getServerActor() + const fromActor = await ActorModel.load(payload.followerActorId) return retryTransactionWrapper(follow, fromActor, targetActor) } @@ -42,6 +44,9 @@ function follow (fromActor: ActorModel, targetActor: ActorModel) { throw new Error('Follower is the same than target actor.') } + // Same server, direct accept + const state = !fromActor.serverId && !targetActor.serverId ? 'accepted' : 'pending' + return sequelizeTypescript.transaction(async t => { const [ actorFollow ] = await ActorFollowModel.findOrCreate({ where: { @@ -49,7 +54,7 @@ function follow (fromActor: ActorModel, targetActor: ActorModel) { targetActorId: targetActor.id }, defaults: { - state: 'pending', + state, actorId: fromActor.id, targetActorId: targetActor.id }, diff --git a/server/middlewares/validators/follows.ts b/server/middlewares/validators/follows.ts index 040ee1f21..faefc1179 100644 --- a/server/middlewares/validators/follows.ts +++ b/server/middlewares/validators/follows.ts @@ -4,7 +4,7 @@ import { isTestInstance } from '../../helpers/core-utils' import { isEachUniqueHostValid, isHostValid } from '../../helpers/custom-validators/servers' import { logger } from '../../helpers/logger' import { getServerActor } from '../../helpers/utils' -import { CONFIG } from '../../initializers' +import { CONFIG, SERVER_ACTOR_NAME } from '../../initializers' import { ActorFollowModel } from '../../models/activitypub/actor-follow' import { areValidationErrors } from './utils' @@ -38,7 +38,7 @@ const removeFollowingValidator = [ if (areValidationErrors(req, res)) return const serverActor = await getServerActor() - const follow = await ActorFollowModel.loadByActorAndTargetHost(serverActor.id, req.params.host) + const follow = await ActorFollowModel.loadByActorAndTargetNameAndHost(serverActor.id, SERVER_ACTOR_NAME, req.params.host) if (!follow) { return res diff --git a/server/middlewares/validators/index.ts b/server/middlewares/validators/index.ts index ccbedd57d..940547a3e 100644 --- a/server/middlewares/validators/index.ts +++ b/server/middlewares/validators/index.ts @@ -6,6 +6,7 @@ export * from './follows' export * from './feeds' export * from './sort' export * from './users' +export * from './user-subscriptions' export * from './videos' export * from './video-abuses' export * from './video-blacklist' diff --git a/server/middlewares/validators/sort.ts b/server/middlewares/validators/sort.ts index d85611773..b30e97e61 100644 --- a/server/middlewares/validators/sort.ts +++ b/server/middlewares/validators/sort.ts @@ -14,6 +14,7 @@ const SORTABLE_BLACKLISTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.BLACK const SORTABLE_VIDEO_CHANNELS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_CHANNELS) const SORTABLE_FOLLOWERS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.FOLLOWERS) const SORTABLE_FOLLOWING_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.FOLLOWING) +const SORTABLE_USER_SUBSCRIPTIONS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.USER_SUBSCRIPTIONS) const usersSortValidator = checkSort(SORTABLE_USERS_COLUMNS) const accountsSortValidator = checkSort(SORTABLE_ACCOUNTS_COLUMNS) @@ -27,6 +28,7 @@ const blacklistSortValidator = checkSort(SORTABLE_BLACKLISTS_COLUMNS) const videoChannelsSortValidator = checkSort(SORTABLE_VIDEO_CHANNELS_COLUMNS) const followersSortValidator = checkSort(SORTABLE_FOLLOWERS_COLUMNS) const followingSortValidator = checkSort(SORTABLE_FOLLOWING_COLUMNS) +const userSubscriptionsSortValidator = checkSort(SORTABLE_USER_SUBSCRIPTIONS_COLUMNS) // --------------------------------------------------------------------------- @@ -42,5 +44,6 @@ export { followersSortValidator, followingSortValidator, jobsSortValidator, - videoCommentThreadsSortValidator + videoCommentThreadsSortValidator, + userSubscriptionsSortValidator } diff --git a/server/middlewares/validators/user-subscriptions.ts b/server/middlewares/validators/user-subscriptions.ts new file mode 100644 index 000000000..f331b6c34 --- /dev/null +++ b/server/middlewares/validators/user-subscriptions.ts @@ -0,0 +1,58 @@ +import * as express from 'express' +import 'express-validator' +import { body, param } from 'express-validator/check' +import { logger } from '../../helpers/logger' +import { areValidationErrors } from './utils' +import { ActorFollowModel } from '../../models/activitypub/actor-follow' +import { isValidActorHandle } from '../../helpers/custom-validators/activitypub/actor' +import { UserModel } from '../../models/account/user' +import { CONFIG } from '../../initializers' + +const userSubscriptionAddValidator = [ + body('uri').custom(isValidActorHandle).withMessage('Should have a valid URI to follow (username@domain)'), + + (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking userSubscriptionAddValidator parameters', { parameters: req.body }) + + if (areValidationErrors(req, res)) return + + return next() + } +] + +const userSubscriptionRemoveValidator = [ + param('uri').custom(isValidActorHandle).withMessage('Should have a valid URI to unfollow'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking unfollow parameters', { parameters: req.params }) + + if (areValidationErrors(req, res)) return + + let [ name, host ] = req.params.uri.split('@') + if (host === CONFIG.WEBSERVER.HOST) host = null + + const user: UserModel = res.locals.oauth.token.User + const subscription = await ActorFollowModel.loadByActorAndTargetNameAndHost(user.Account.Actor.id, name, host) + + if (!subscription) { + return res + .status(404) + .json({ + error: `Subscription ${req.params.uri} not found.` + }) + .end() + } + + res.locals.subscription = subscription + return next() + } +] + +// --------------------------------------------------------------------------- + +export { + userSubscriptionAddValidator, + userSubscriptionRemoveValidator +} + +// --------------------------------------------------------------------------- diff --git a/server/middlewares/validators/video-channels.ts b/server/middlewares/validators/video-channels.ts index 143ce9582..d354c7e05 100644 --- a/server/middlewares/validators/video-channels.ts +++ b/server/middlewares/validators/video-channels.ts @@ -4,6 +4,7 @@ import { UserRight } from '../../../shared' import { isAccountNameWithHostExist } from '../../helpers/custom-validators/accounts' import { isIdOrUUIDValid } from '../../helpers/custom-validators/misc' import { + isLocalVideoChannelNameExist, isVideoChannelDescriptionValid, isVideoChannelExist, isVideoChannelNameValid, @@ -100,6 +101,19 @@ const videoChannelsGetValidator = [ } ] +const localVideoChannelValidator = [ + param('name').custom(isVideoChannelNameValid).withMessage('Should have a valid video channel name'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking localVideoChannelValidator parameters', { parameters: req.params }) + + if (areValidationErrors(req, res)) return + if (!await isLocalVideoChannelNameExist(req.params.name, res)) return + + return next() + } +] + // --------------------------------------------------------------------------- export { @@ -107,7 +121,8 @@ export { videoChannelsAddValidator, videoChannelsUpdateValidator, videoChannelsRemoveValidator, - videoChannelsGetValidator + videoChannelsGetValidator, + localVideoChannelValidator } // --------------------------------------------------------------------------- diff --git a/server/middlewares/validators/webfinger.ts b/server/middlewares/validators/webfinger.ts index 3b9645048..63a1678ec 100644 --- a/server/middlewares/validators/webfinger.ts +++ b/server/middlewares/validators/webfinger.ts @@ -1,13 +1,13 @@ import * as express from 'express' import { query } from 'express-validator/check' -import { isWebfingerResourceValid } from '../../helpers/custom-validators/webfinger' +import { isWebfingerLocalResourceValid } from '../../helpers/custom-validators/webfinger' import { logger } from '../../helpers/logger' import { ActorModel } from '../../models/activitypub/actor' import { areValidationErrors } from './utils' import { getHostWithPort } from '../../helpers/express-utils' const webfingerValidator = [ - query('resource').custom(isWebfingerResourceValid).withMessage('Should have a valid webfinger resource'), + query('resource').custom(isWebfingerLocalResourceValid).withMessage('Should have a valid webfinger resource'), async (req: express.Request, res: express.Response, next: express.NextFunction) => { logger.debug('Checking webfinger parameters', { parameters: req.query }) diff --git a/server/models/activitypub/actor-follow.ts b/server/models/activitypub/actor-follow.ts index adec5e92b..90a8ac43c 100644 --- a/server/models/activitypub/actor-follow.ts +++ b/server/models/activitypub/actor-follow.ts @@ -2,8 +2,21 @@ import * as Bluebird from 'bluebird' import { values } from 'lodash' import * as Sequelize from 'sequelize' import { - AfterCreate, AfterDestroy, AfterUpdate, AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, IsInt, Max, Model, - Table, UpdatedAt + AfterCreate, + AfterDestroy, + AfterUpdate, + AllowNull, + BelongsTo, + Column, + CreatedAt, + DataType, + Default, + ForeignKey, + IsInt, + Max, + Model, + Table, + UpdatedAt } from 'sequelize-typescript' import { FollowState } from '../../../shared/models/actors' import { AccountFollow } from '../../../shared/models/actors/follow.model' @@ -14,6 +27,7 @@ import { FOLLOW_STATES } from '../../initializers/constants' import { ServerModel } from '../server/server' import { getSort } from '../utils' import { ActorModel } from './actor' +import { VideoChannelModel } from '../video/video-channel' @Table({ tableName: 'actorFollow', @@ -151,7 +165,32 @@ export class ActorFollowModel extends Model { return ActorFollowModel.findOne(query) } - static loadByActorAndTargetHost (actorId: number, targetHost: string, t?: Sequelize.Transaction) { + static loadByActorAndTargetNameAndHost (actorId: number, targetName: string, targetHost: string, t?: Sequelize.Transaction) { + const actorFollowingPartInclude = { + model: ActorModel, + required: true, + as: 'ActorFollowing', + where: { + preferredUsername: targetName + } + } + + if (targetHost === null) { + actorFollowingPartInclude.where['serverId'] = null + } else { + Object.assign(actorFollowingPartInclude, { + include: [ + { + model: ServerModel, + required: true, + where: { + host: targetHost + } + } + ] + }) + } + const query = { where: { actorId @@ -162,20 +201,7 @@ export class ActorFollowModel extends Model { required: true, as: 'ActorFollower' }, - { - model: ActorModel, - required: true, - as: 'ActorFollowing', - include: [ - { - model: ServerModel, - required: true, - where: { - host: targetHost - } - } - ] - } + actorFollowingPartInclude ], transaction: t } @@ -216,6 +242,39 @@ export class ActorFollowModel extends Model { }) } + static listSubscriptionsForApi (id: number, start: number, count: number, sort: string) { + const query = { + distinct: true, + offset: start, + limit: count, + order: getSort(sort), + where: { + actorId: id + }, + include: [ + { + model: ActorModel, + as: 'ActorFollowing', + required: true, + include: [ + { + model: VideoChannelModel, + required: true + } + ] + } + ] + } + + return ActorFollowModel.findAndCountAll(query) + .then(({ rows, count }) => { + return { + data: rows.map(r => r.ActorFollowing.VideoChannel), + total: count + } + }) + } + static listFollowersForApi (id: number, start: number, count: number, sort: string) { const query = { distinct: true, diff --git a/server/models/video/video-channel.ts b/server/models/video/video-channel.ts index d0dba18d5..0273fab13 100644 --- a/server/models/video/video-channel.ts +++ b/server/models/video/video-channel.ts @@ -1,14 +1,27 @@ import { - AllowNull, BeforeDestroy, BelongsTo, Column, CreatedAt, DefaultScope, ForeignKey, HasMany, Is, Model, Scopes, Table, - UpdatedAt, Default, DataType + AllowNull, + BeforeDestroy, + BelongsTo, + Column, + CreatedAt, + DataType, + Default, + DefaultScope, + ForeignKey, + HasMany, + Is, + Model, + Scopes, + Table, + UpdatedAt } from 'sequelize-typescript' import { ActivityPubActor } from '../../../shared/models/activitypub' import { VideoChannel } from '../../../shared/models/videos' import { - isVideoChannelDescriptionValid, isVideoChannelNameValid, + isVideoChannelDescriptionValid, + isVideoChannelNameValid, isVideoChannelSupportValid } from '../../helpers/custom-validators/video-channels' -import { logger } from '../../helpers/logger' import { sendDeleteActor } from '../../lib/activitypub/send' import { AccountModel } from '../account/account' import { ActorModel } from '../activitypub/actor' @@ -241,6 +254,23 @@ export class VideoChannelModel extends Model { .findById(id, options) } + static loadLocalByName (name: string) { + const query = { + include: [ + { + model: ActorModel, + required: true, + where: { + preferredUsername: name, + serverId: null + } + } + ] + } + + return VideoChannelModel.findOne(query) + } + toFormattedJSON (): VideoChannel { const actor = this.Actor.toFormattedJSON() const videoChannel = { @@ -251,8 +281,7 @@ export class VideoChannelModel extends Model { isLocal: this.Actor.isOwned(), createdAt: this.createdAt, updatedAt: this.updatedAt, - ownerAccount: undefined, - videos: undefined + ownerAccount: undefined } if (this.Account) videoChannel.ownerAccount = this.Account.toFormattedJSON() diff --git a/server/models/video/video.ts b/server/models/video/video.ts index b13dee403..5db718061 100644 --- a/server/models/video/video.ts +++ b/server/models/video/video.ts @@ -133,6 +133,7 @@ export enum ScopeNames { type AvailableForListOptions = { actorId: number, + includeLocalVideos: boolean, filter?: VideoFilter, categoryOneOf?: number[], nsfw?: boolean, @@ -201,6 +202,15 @@ type AvailableForListOptions = { // Force actorId to be a number to avoid SQL injections const actorIdNumber = parseInt(options.actorId.toString(), 10) + let localVideosReq = '' + if (options.includeLocalVideos === true) { + localVideosReq = ' UNION ALL ' + + 'SELECT "video"."id" AS "id" FROM "video" ' + + 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' + + 'INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId" ' + + 'INNER JOIN "actor" ON "account"."actorId" = "actor"."id" ' + + 'WHERE "actor"."serverId" IS NULL' + } // FIXME: It would be more efficient to use a CTE so we join AFTER the filters, but sequelize does not support it... const query: IFindOptions = { @@ -214,12 +224,6 @@ type AvailableForListOptions = { 'SELECT "videoShare"."videoId" AS "id" FROM "videoShare" ' + 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' + 'WHERE "actorFollow"."actorId" = ' + actorIdNumber + - ' UNION ' + - 'SELECT "video"."id" AS "id" FROM "video" ' + - 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' + - 'INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId" ' + - 'INNER JOIN "actor" ON "account"."actorId" = "actor"."id" ' + - 'WHERE "actor"."serverId" IS NULL ' + ' UNION ALL ' + 'SELECT "video"."id" AS "id" FROM "video" ' + 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' + @@ -227,6 +231,7 @@ type AvailableForListOptions = { 'INNER JOIN "actor" ON "account"."actorId" = "actor"."id" ' + 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "actor"."id" ' + 'WHERE "actorFollow"."actorId" = ' + actorIdNumber + + localVideosReq + ')' ) }, @@ -825,6 +830,7 @@ export class VideoModel extends Model { count: number, sort: string, nsfw: boolean, + includeLocalVideos: boolean, withFiles: boolean, categoryOneOf?: number[], licenceOneOf?: number[], @@ -833,7 +839,8 @@ export class VideoModel extends Model { tagsAllOf?: string[], filter?: VideoFilter, accountId?: number, - videoChannelId?: number + videoChannelId?: number, + actorId?: number }) { const query = { offset: options.start, @@ -841,11 +848,12 @@ export class VideoModel extends Model { order: getSort(options.sort) } - const serverActor = await getServerActor() + const actorId = options.actorId || (await getServerActor()).id + const scopes = { method: [ ScopeNames.AVAILABLE_FOR_LIST, { - actorId: serverActor.id, + actorId, nsfw: options.nsfw, categoryOneOf: options.categoryOneOf, licenceOneOf: options.licenceOneOf, @@ -855,7 +863,8 @@ export class VideoModel extends Model { filter: options.filter, withFiles: options.withFiles, accountId: options.accountId, - videoChannelId: options.videoChannelId + videoChannelId: options.videoChannelId, + includeLocalVideos: options.includeLocalVideos } as AvailableForListOptions ] } @@ -871,6 +880,7 @@ export class VideoModel extends Model { } static async searchAndPopulateAccountAndServer (options: { + includeLocalVideos: boolean search?: string start?: number count?: number @@ -955,6 +965,7 @@ export class VideoModel extends Model { method: [ ScopeNames.AVAILABLE_FOR_LIST, { actorId: serverActor.id, + includeLocalVideos: options.includeLocalVideos, nsfw: options.nsfw, categoryOneOf: options.categoryOneOf, licenceOneOf: options.licenceOneOf, diff --git a/server/tests/api/check-params/index.ts b/server/tests/api/check-params/index.ts index 03fdd5c4e..777acbb0f 100644 --- a/server/tests/api/check-params/index.ts +++ b/server/tests/api/check-params/index.ts @@ -12,3 +12,4 @@ import './video-comments' import './videos' import './video-imports' import './search' +import './user-subscriptions' diff --git a/server/tests/api/check-params/user-subscriptions.ts b/server/tests/api/check-params/user-subscriptions.ts new file mode 100644 index 000000000..9f7d15b27 --- /dev/null +++ b/server/tests/api/check-params/user-subscriptions.ts @@ -0,0 +1,220 @@ +/* tslint:disable:no-unused-expression */ + +import 'mocha' + +import { + createUser, + flushTests, + getMyUserInformation, + killallServers, + makeDeleteRequest, + makeGetRequest, + makePostBodyRequest, + runServer, + ServerInfo, + setAccessTokensToServers, + userLogin +} from '../../utils' +import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '../../utils/requests/check-api-params' + +describe('Test user subscriptions API validators', function () { + const path = '/api/v1/users/me/subscriptions' + let server: ServerInfo + let userAccessToken = '' + let userChannelUUID: string + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(30000) + + await flushTests() + + server = await runServer(1) + + await setAccessTokensToServers([ server ]) + + const user = { + username: 'user1', + password: 'my super password' + } + await createUser(server.url, server.accessToken, user.username, user.password) + userAccessToken = await userLogin(server, user) + + { + const res = await getMyUserInformation(server.url, server.accessToken) + userChannelUUID = res.body.videoChannels[ 0 ].uuid + } + }) + + describe('When listing my subscriptions', function () { + it('Should fail with a bad start pagination', async function () { + await checkBadStartPagination(server.url, path, server.accessToken) + }) + + it('Should fail with a bad count pagination', async function () { + await checkBadCountPagination(server.url, path, server.accessToken) + }) + + it('Should fail with an incorrect sort', async function () { + await checkBadSortPagination(server.url, path, server.accessToken) + }) + + it('Should fail with a non authenticated user', async function () { + await makeGetRequest({ + url: server.url, + path, + statusCodeExpected: 401 + }) + }) + + it('Should success with the correct parameters', async function () { + await await makeGetRequest({ + url: server.url, + path, + token: userAccessToken, + statusCodeExpected: 200 + }) + }) + }) + + describe('When listing my subscriptions videos', function () { + const path = '/api/v1/users/me/subscriptions/videos' + + it('Should fail with a bad start pagination', async function () { + await checkBadStartPagination(server.url, path, server.accessToken) + }) + + it('Should fail with a bad count pagination', async function () { + await checkBadCountPagination(server.url, path, server.accessToken) + }) + + it('Should fail with an incorrect sort', async function () { + await checkBadSortPagination(server.url, path, server.accessToken) + }) + + it('Should fail with a non authenticated user', async function () { + await makeGetRequest({ + url: server.url, + path, + statusCodeExpected: 401 + }) + }) + + it('Should success with the correct parameters', async function () { + await await makeGetRequest({ + url: server.url, + path, + token: userAccessToken, + statusCodeExpected: 200 + }) + }) + }) + + describe('When adding a subscription', function () { + it('Should fail with a non authenticated user', async function () { + await makePostBodyRequest({ + url: server.url, + path, + fields: { uri: userChannelUUID + '@localhost:9001' }, + statusCodeExpected: 401 + }) + }) + + it('Should fail with bad URIs', async function () { + await makePostBodyRequest({ + url: server.url, + path, + token: server.accessToken, + fields: { uri: 'root' }, + statusCodeExpected: 400 + }) + + await makePostBodyRequest({ + url: server.url, + path, + token: server.accessToken, + fields: { uri: 'root@' }, + statusCodeExpected: 400 + }) + + await makePostBodyRequest({ + url: server.url, + path, + token: server.accessToken, + fields: { uri: 'root@hello@' }, + statusCodeExpected: 400 + }) + }) + + it('Should success with the correct parameters', async function () { + await makePostBodyRequest({ + url: server.url, + path, + token: server.accessToken, + fields: { uri: userChannelUUID + '@localhost:9001' }, + statusCodeExpected: 204 + }) + }) + }) + + describe('When removing a subscription', function () { + it('Should fail with a non authenticated user', async function () { + await makeDeleteRequest({ + url: server.url, + path: path + '/' + userChannelUUID + '@localhost:9001', + statusCodeExpected: 401 + }) + }) + + it('Should fail with bad URIs', async function () { + await makeDeleteRequest({ + url: server.url, + path: path + '/root', + token: server.accessToken, + statusCodeExpected: 400 + }) + + await makeDeleteRequest({ + url: server.url, + path: path + '/root@', + token: server.accessToken, + statusCodeExpected: 400 + }) + + await makeDeleteRequest({ + url: server.url, + path: path + '/root@hello@', + token: server.accessToken, + statusCodeExpected: 400 + }) + }) + + it('Should fail with an unknown subscription', async function () { + await makeDeleteRequest({ + url: server.url, + path: path + '/root1@localhost:9001', + token: server.accessToken, + statusCodeExpected: 404 + }) + }) + + it('Should success with the correct parameters', async function () { + await makeDeleteRequest({ + url: server.url, + path: path + '/' + userChannelUUID + '@localhost:9001', + token: server.accessToken, + statusCodeExpected: 204 + }) + }) + }) + + after(async function () { + killallServers([ server ]) + + // Keep the logs if the test failed + if (this['ok']) { + await flushTests() + } + }) +}) diff --git a/server/tests/api/index-slow.ts b/server/tests/api/index-slow.ts index 243c941cb..e24a7b664 100644 --- a/server/tests/api/index-slow.ts +++ b/server/tests/api/index-slow.ts @@ -6,6 +6,7 @@ import './server/follows' import './server/jobs' import './videos/video-comments' import './users/users-multiple-servers' +import './users/user-subscriptions' import './server/handle-down' import './videos/video-schedule-update' import './videos/video-imports' diff --git a/server/tests/api/users/user-subscriptions.ts b/server/tests/api/users/user-subscriptions.ts new file mode 100644 index 000000000..2ba6cdfaf --- /dev/null +++ b/server/tests/api/users/user-subscriptions.ts @@ -0,0 +1,312 @@ +/* tslint:disable:no-unused-expression */ + +import * as chai from 'chai' +import 'mocha' +import { createUser, doubleFollow, flushAndRunMultipleServers, follow, getVideosList, unfollow, userLogin } from '../../utils' +import { getMyUserInformation, killallServers, ServerInfo, uploadVideo } from '../../utils/index' +import { setAccessTokensToServers } from '../../utils/users/login' +import { Video, VideoChannel } from '../../../../shared/models/videos' +import { waitJobs } from '../../utils/server/jobs' +import { + addUserSubscription, + listUserSubscriptions, + listUserSubscriptionVideos, + removeUserSubscription +} from '../../utils/users/user-subscriptions' + +const expect = chai.expect + +describe('Test users subscriptions', function () { + let servers: ServerInfo[] = [] + const users: { accessToken: string, videoChannelName: string }[] = [] + let rootChannelNameServer1: string + + before(async function () { + this.timeout(120000) + + servers = await flushAndRunMultipleServers(3) + + // Get the access tokens + await setAccessTokensToServers(servers) + + // Server 1 and server 2 follow each other + await doubleFollow(servers[0], servers[1]) + + const res = await getMyUserInformation(servers[0].url, servers[0].accessToken) + rootChannelNameServer1 = res.body.videoChannels[0].name + + { + for (const server of servers) { + const user = { username: 'user' + server.serverNumber, password: 'password' } + await createUser(server.url, server.accessToken, user.username, user.password) + + const accessToken = await userLogin(server, user) + const res = await getMyUserInformation(server.url, accessToken) + const videoChannels: VideoChannel[] = res.body.videoChannels + + users.push({ accessToken, videoChannelName: videoChannels[0].name }) + + const videoName1 = 'video 1-' + server.serverNumber + await uploadVideo(server.url, accessToken, { name: videoName1 }) + + const videoName2 = 'video 2-' + server.serverNumber + await uploadVideo(server.url, accessToken, { name: videoName2 }) + } + } + + await waitJobs(servers) + }) + + it('Should display videos of server 2 on server 1', async function () { + const res = await getVideosList(servers[0].url) + + expect(res.body.total).to.equal(4) + }) + + it('User of server 1 should follow user of server 3 and root of server 1', async function () { + this.timeout(30000) + + await addUserSubscription(servers[0].url, users[0].accessToken, users[2].videoChannelName + '@localhost:9003') + await addUserSubscription(servers[0].url, users[0].accessToken, rootChannelNameServer1 + '@localhost:9001') + + await waitJobs(servers) + + await uploadVideo(servers[2].url, users[2].accessToken, { name: 'video server 3 added after follow' }) + + await waitJobs(servers) + }) + + it('Should not display videos of server 3 on server 1', async function () { + const res = await getVideosList(servers[0].url) + + expect(res.body.total).to.equal(4) + for (const video of res.body.data) { + expect(video.name).to.not.contain('1-3') + expect(video.name).to.not.contain('2-3') + expect(video.name).to.not.contain('video server 3 added after follow') + } + }) + + it('Should list subscriptions', async function () { + { + const res = await listUserSubscriptions(servers[0].url, servers[0].accessToken) + expect(res.body.total).to.equal(0) + expect(res.body.data).to.be.an('array') + expect(res.body.data).to.have.lengthOf(0) + } + + { + const res = await listUserSubscriptions(servers[0].url, users[0].accessToken) + expect(res.body.total).to.equal(2) + + const subscriptions: VideoChannel[] = res.body.data + expect(subscriptions).to.be.an('array') + expect(subscriptions).to.have.lengthOf(2) + + expect(subscriptions[0].name).to.equal(users[2].videoChannelName) + expect(subscriptions[1].name).to.equal(rootChannelNameServer1) + } + }) + + it('Should list subscription videos', async function () { + { + const res = await listUserSubscriptionVideos(servers[0].url, servers[0].accessToken) + expect(res.body.total).to.equal(0) + expect(res.body.data).to.be.an('array') + expect(res.body.data).to.have.lengthOf(0) + } + + { + const res = await listUserSubscriptionVideos(servers[0].url, users[0].accessToken, 'createdAt') + expect(res.body.total).to.equal(3) + + const videos: Video[] = res.body.data + expect(videos).to.be.an('array') + expect(videos).to.have.lengthOf(3) + + expect(videos[0].name).to.equal('video 1-3') + expect(videos[1].name).to.equal('video 2-3') + expect(videos[2].name).to.equal('video server 3 added after follow') + } + }) + + it('Should upload a video by root on server 1 and see it in the subscription videos', async function () { + this.timeout(30000) + + const videoName = 'video server 1 added after follow' + await uploadVideo(servers[0].url, servers[0].accessToken, { name: videoName }) + + await waitJobs(servers) + + { + const res = await listUserSubscriptionVideos(servers[0].url, servers[0].accessToken) + expect(res.body.total).to.equal(0) + expect(res.body.data).to.be.an('array') + expect(res.body.data).to.have.lengthOf(0) + } + + { + const res = await listUserSubscriptionVideos(servers[0].url, users[0].accessToken, 'createdAt') + expect(res.body.total).to.equal(4) + + const videos: Video[] = res.body.data + expect(videos).to.be.an('array') + expect(videos).to.have.lengthOf(4) + + expect(videos[0].name).to.equal('video 1-3') + expect(videos[1].name).to.equal('video 2-3') + expect(videos[2].name).to.equal('video server 3 added after follow') + expect(videos[3].name).to.equal('video server 1 added after follow') + } + + { + const res = await getVideosList(servers[0].url) + + expect(res.body.total).to.equal(5) + for (const video of res.body.data) { + expect(video.name).to.not.contain('1-3') + expect(video.name).to.not.contain('2-3') + expect(video.name).to.not.contain('video server 3 added after follow') + } + } + }) + + it('Should have server 1 follow server 3 and display server 3 videos', async function () { + this.timeout(30000) + + await follow(servers[0].url, [ servers[2].url ], servers[0].accessToken) + + await waitJobs(servers) + + const res = await getVideosList(servers[0].url) + + expect(res.body.total).to.equal(8) + + const names = [ '1-3', '2-3', 'video server 3 added after follow' ] + for (const name of names) { + const video = res.body.data.find(v => v.name.indexOf(name) === -1) + expect(video).to.not.be.undefined + } + }) + + it('Should remove follow server 1 -> server 3 and hide server 3 videos', async function () { + this.timeout(30000) + + await unfollow(servers[0].url, servers[0].accessToken, servers[2]) + + await waitJobs(servers) + + const res = await getVideosList(servers[0].url) + + expect(res.body.total).to.equal(5) + for (const video of res.body.data) { + expect(video.name).to.not.contain('1-3') + expect(video.name).to.not.contain('2-3') + expect(video.name).to.not.contain('video server 3 added after follow') + } + }) + + it('Should still list subscription videos', async function () { + { + const res = await listUserSubscriptionVideos(servers[0].url, servers[0].accessToken) + expect(res.body.total).to.equal(0) + expect(res.body.data).to.be.an('array') + expect(res.body.data).to.have.lengthOf(0) + } + + { + const res = await listUserSubscriptionVideos(servers[0].url, users[0].accessToken, 'createdAt') + expect(res.body.total).to.equal(4) + + const videos: Video[] = res.body.data + expect(videos).to.be.an('array') + expect(videos).to.have.lengthOf(4) + + expect(videos[0].name).to.equal('video 1-3') + expect(videos[1].name).to.equal('video 2-3') + expect(videos[2].name).to.equal('video server 3 added after follow') + expect(videos[3].name).to.equal('video server 1 added after follow') + } + }) + + it('Should remove user of server 3 subscription', async function () { + await removeUserSubscription(servers[0].url, users[0].accessToken, users[2].videoChannelName + '@localhost:9003') + + await waitJobs(servers) + }) + + it('Should not display its videos anymore', async function () { + { + const res = await listUserSubscriptionVideos(servers[0].url, users[0].accessToken, 'createdAt') + expect(res.body.total).to.equal(1) + + const videos: Video[] = res.body.data + expect(videos).to.be.an('array') + expect(videos).to.have.lengthOf(1) + + expect(videos[0].name).to.equal('video server 1 added after follow') + } + }) + + it('Should remove the root subscription and not display the videos anymore', async function () { + await removeUserSubscription(servers[0].url, users[0].accessToken, rootChannelNameServer1 + '@localhost:9001') + + await waitJobs(servers) + + { + const res = await listUserSubscriptionVideos(servers[0].url, users[0].accessToken, 'createdAt') + expect(res.body.total).to.equal(0) + + const videos: Video[] = res.body.data + expect(videos).to.be.an('array') + expect(videos).to.have.lengthOf(0) + } + }) + + it('Should correctly display public videos on server 1', async function () { + const res = await getVideosList(servers[0].url) + + expect(res.body.total).to.equal(5) + for (const video of res.body.data) { + expect(video.name).to.not.contain('1-3') + expect(video.name).to.not.contain('2-3') + expect(video.name).to.not.contain('video server 3 added after follow') + } + }) + + it('Should follow user of server 3 again', async function () { + this.timeout(30000) + + await addUserSubscription(servers[0].url, users[0].accessToken, users[2].videoChannelName + '@localhost:9003') + + await waitJobs(servers) + + { + const res = await listUserSubscriptionVideos(servers[0].url, users[0].accessToken, 'createdAt') + expect(res.body.total).to.equal(3) + + const videos: Video[] = res.body.data + expect(videos).to.be.an('array') + expect(videos).to.have.lengthOf(3) + + expect(videos[0].name).to.equal('video 1-3') + expect(videos[1].name).to.equal('video 2-3') + expect(videos[2].name).to.equal('video server 3 added after follow') + } + + { + const res = await getVideosList(servers[0].url) + + expect(res.body.total).to.equal(5) + for (const video of res.body.data) { + expect(video.name).to.not.contain('1-3') + expect(video.name).to.not.contain('2-3') + expect(video.name).to.not.contain('video server 3 added after follow') + } + } + }) + + after(async function () { + killallServers(servers) + }) +}) diff --git a/server/tests/utils/users/user-subscriptions.ts b/server/tests/utils/users/user-subscriptions.ts new file mode 100644 index 000000000..323e5de58 --- /dev/null +++ b/server/tests/utils/users/user-subscriptions.ts @@ -0,0 +1,57 @@ +import { makeDeleteRequest, makeGetRequest, makePostBodyRequest } from '../' + +function addUserSubscription (url: string, token: string, targetUri: string, statusCodeExpected = 204) { + const path = '/api/v1/users/me/subscriptions' + + return makePostBodyRequest({ + url, + path, + token, + statusCodeExpected, + fields: { uri: targetUri } + }) +} + +function listUserSubscriptions (url: string, token: string, sort = '-createdAt', statusCodeExpected = 200) { + const path = '/api/v1/users/me/subscriptions' + + return makeGetRequest({ + url, + path, + token, + statusCodeExpected, + query: { sort } + }) +} + +function listUserSubscriptionVideos (url: string, token: string, sort = '-createdAt', statusCodeExpected = 200) { + const path = '/api/v1/users/me/subscriptions/videos' + + return makeGetRequest({ + url, + path, + token, + statusCodeExpected, + query: { sort } + }) +} + +function removeUserSubscription (url: string, token: string, uri: string, statusCodeExpected = 204) { + const path = '/api/v1/users/me/subscriptions/' + uri + + return makeDeleteRequest({ + url, + path, + token, + statusCodeExpected + }) +} + +// --------------------------------------------------------------------------- + +export { + addUserSubscription, + listUserSubscriptions, + listUserSubscriptionVideos, + removeUserSubscription +} -- 2.41.0