aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2018-08-16 15:25:20 +0200
committerChocobozzz <me@florianbigard.com>2018-08-27 09:41:54 +0200
commit06a05d5f4784a7cbb27aa1188385b5679845dad8 (patch)
treeac197f3ed0768529456225ad76c912f22bc55e29
parent4bda2e47bbc937c401ddcf14c1be53c70481a294 (diff)
downloadPeerTube-06a05d5f4784a7cbb27aa1188385b5679845dad8.tar.gz
PeerTube-06a05d5f4784a7cbb27aa1188385b5679845dad8.tar.zst
PeerTube-06a05d5f4784a7cbb27aa1188385b5679845dad8.zip
Add subscriptions endpoints to REST API
-rw-r--r--server/controllers/activitypub/inbox.ts20
-rw-r--r--server/controllers/activitypub/outbox.ts14
-rw-r--r--server/controllers/api/accounts.ts1
-rw-r--r--server/controllers/api/search.ts5
-rw-r--r--server/controllers/api/server/follows.ts17
-rw-r--r--server/controllers/api/users/index.ts3
-rw-r--r--server/controllers/api/users/me.ts114
-rw-r--r--server/controllers/api/video-channel.ts1
-rw-r--r--server/controllers/api/videos/index.ts1
-rw-r--r--server/controllers/feeds.ts1
-rw-r--r--server/helpers/custom-validators/activitypub/actor.ts13
-rw-r--r--server/helpers/custom-validators/video-channels.ts32
-rw-r--r--server/helpers/custom-validators/webfinger.ts4
-rw-r--r--server/helpers/webfinger.ts10
-rw-r--r--server/initializers/constants.ts1
-rw-r--r--server/lib/activitypub/actor.ts2
-rw-r--r--server/lib/activitypub/send/send-accept.ts5
-rw-r--r--server/lib/activitypub/send/send-create.ts2
-rw-r--r--server/lib/activitypub/send/send-follow.ts3
-rw-r--r--server/lib/activitypub/send/send-undo.ts3
-rw-r--r--server/lib/activitypub/send/utils.ts21
-rw-r--r--server/lib/job-queue/handlers/activitypub-follow.ts13
-rw-r--r--server/middlewares/validators/follows.ts4
-rw-r--r--server/middlewares/validators/index.ts1
-rw-r--r--server/middlewares/validators/sort.ts5
-rw-r--r--server/middlewares/validators/user-subscriptions.ts58
-rw-r--r--server/middlewares/validators/video-channels.ts17
-rw-r--r--server/middlewares/validators/webfinger.ts4
-rw-r--r--server/models/activitypub/actor-follow.ts93
-rw-r--r--server/models/video/video-channel.ts41
-rw-r--r--server/models/video/video.ts31
-rw-r--r--server/tests/api/check-params/index.ts1
-rw-r--r--server/tests/api/check-params/user-subscriptions.ts220
-rw-r--r--server/tests/api/index-slow.ts1
-rw-r--r--server/tests/api/users/user-subscriptions.ts312
-rw-r--r--server/tests/utils/users/user-subscriptions.ts57
36 files changed, 1038 insertions, 93 deletions
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
3import { isActivityValid } from '../../helpers/custom-validators/activitypub/activity' 3import { isActivityValid } from '../../helpers/custom-validators/activitypub/activity'
4import { logger } from '../../helpers/logger' 4import { logger } from '../../helpers/logger'
5import { processActivities } from '../../lib/activitypub/process/process' 5import { processActivities } from '../../lib/activitypub/process/process'
6import { asyncMiddleware, checkSignature, localAccountValidator, signatureValidator } from '../../middlewares' 6import { asyncMiddleware, checkSignature, localAccountValidator, localVideoChannelValidator, signatureValidator } from '../../middlewares'
7import { activityPubValidator } from '../../middlewares/validators/activitypub/activity' 7import { activityPubValidator } from '../../middlewares/validators/activitypub/activity'
8import { ActorModel } from '../../models/activitypub/actor' 8import { VideoChannelModel } from '../../models/video/video-channel'
9import { AccountModel } from '../../models/account/account'
9 10
10const inboxRouter = express.Router() 11const inboxRouter = express.Router()
11 12
@@ -23,6 +24,13 @@ inboxRouter.post('/accounts/:name/inbox',
23 asyncMiddleware(activityPubValidator), 24 asyncMiddleware(activityPubValidator),
24 asyncMiddleware(inboxController) 25 asyncMiddleware(inboxController)
25) 26)
27inboxRouter.post('/video-channels/:name/inbox',
28 signatureValidator,
29 asyncMiddleware(checkSignature),
30 asyncMiddleware(localVideoChannelValidator),
31 asyncMiddleware(activityPubValidator),
32 asyncMiddleware(inboxController)
33)
26 34
27// --------------------------------------------------------------------------- 35// ---------------------------------------------------------------------------
28 36
@@ -49,16 +57,16 @@ async function inboxController (req: express.Request, res: express.Response, nex
49 activities = activities.filter(a => isActivityValid(a)) 57 activities = activities.filter(a => isActivityValid(a))
50 logger.debug('We keep %d activities.', activities.length, { activities }) 58 logger.debug('We keep %d activities.', activities.length, { activities })
51 59
52 let specificActor: ActorModel = undefined 60 let accountOrChannel: VideoChannelModel | AccountModel
53 if (res.locals.account) { 61 if (res.locals.account) {
54 specificActor = res.locals.account 62 accountOrChannel = res.locals.account
55 } else if (res.locals.videoChannel) { 63 } else if (res.locals.videoChannel) {
56 specificActor = res.locals.videoChannel 64 accountOrChannel = res.locals.videoChannel
57 } 65 }
58 66
59 logger.info('Receiving inbox requests for %d activities by %s.', activities.length, res.locals.signature.actor.url) 67 logger.info('Receiving inbox requests for %d activities by %s.', activities.length, res.locals.signature.actor.url)
60 68
61 await processActivities(activities, res.locals.signature.actor, specificActor) 69 await processActivities(activities, res.locals.signature.actor, accountOrChannel ? accountOrChannel.Actor : undefined)
62 70
63 res.status(204).end() 71 res.status(204).end()
64} 72}
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
5import { logger } from '../../helpers/logger' 5import { logger } from '../../helpers/logger'
6import { announceActivityData, createActivityData } from '../../lib/activitypub/send' 6import { announceActivityData, createActivityData } from '../../lib/activitypub/send'
7import { buildAudience } from '../../lib/activitypub/audience' 7import { buildAudience } from '../../lib/activitypub/audience'
8import { asyncMiddleware, localAccountValidator } from '../../middlewares' 8import { asyncMiddleware, localAccountValidator, localVideoChannelValidator } from '../../middlewares'
9import { AccountModel } from '../../models/account/account' 9import { AccountModel } from '../../models/account/account'
10import { ActorModel } from '../../models/activitypub/actor' 10import { ActorModel } from '../../models/activitypub/actor'
11import { VideoModel } from '../../models/video/video' 11import { VideoModel } from '../../models/video/video'
12import { activityPubResponse } from './utils' 12import { activityPubResponse } from './utils'
13import { VideoChannelModel } from '../../models/video/video-channel'
13 14
14const outboxRouter = express.Router() 15const outboxRouter = express.Router()
15 16
@@ -18,6 +19,11 @@ outboxRouter.get('/accounts/:name/outbox',
18 asyncMiddleware(outboxController) 19 asyncMiddleware(outboxController)
19) 20)
20 21
22outboxRouter.get('/video-channels/:name/outbox',
23 localVideoChannelValidator,
24 asyncMiddleware(outboxController)
25)
26
21// --------------------------------------------------------------------------- 27// ---------------------------------------------------------------------------
22 28
23export { 29export {
@@ -27,9 +33,9 @@ export {
27// --------------------------------------------------------------------------- 33// ---------------------------------------------------------------------------
28 34
29async function outboxController (req: express.Request, res: express.Response, next: express.NextFunction) { 35async function outboxController (req: express.Request, res: express.Response, next: express.NextFunction) {
30 const account: AccountModel = res.locals.account 36 const accountOrVideoChannel: AccountModel | VideoChannelModel = res.locals.account || res.locals.videoChannel
31 const actor = account.Actor 37 const actor = accountOrVideoChannel.Actor
32 const actorOutboxUrl = account.Actor.url + '/outbox' 38 const actorOutboxUrl = actor.url + '/outbox'
33 39
34 logger.info('Receiving outbox request for %s.', actorOutboxUrl) 40 logger.info('Receiving outbox request for %s.', actorOutboxUrl)
35 41
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
78 start: req.query.start, 78 start: req.query.start,
79 count: req.query.count, 79 count: req.query.count,
80 sort: req.query.sort, 80 sort: req.query.sort,
81 includeLocalVideos: false,
81 categoryOneOf: req.query.categoryOneOf, 82 categoryOneOf: req.query.categoryOneOf,
82 licenceOneOf: req.query.licenceOneOf, 83 licenceOneOf: req.query.licenceOneOf,
83 languageOneOf: req.query.languageOneOf, 84 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 }
36async function searchVideos (req: express.Request, res: express.Response) { 36async function searchVideos (req: express.Request, res: express.Response) {
37 const query: VideosSearchQuery = req.query 37 const query: VideosSearchQuery = req.query
38 38
39 const options = Object.assign(query, { nsfw: buildNSFWFilter(res, query.nsfw) }) 39 const options = Object.assign(query, {
40 includeLocalVideos: true,
41 nsfw: buildNSFWFilter(res, query.nsfw)
42 })
40 const resultList = await VideoModel.searchAndPopulateAccountAndServer(options) 43 const resultList = await VideoModel.searchAndPopulateAccountAndServer(options)
41 44
42 return res.json(getFormattedObjects(resultList.data, resultList.total)) 45 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'
2import { UserRight } from '../../../../shared/models/users' 2import { UserRight } from '../../../../shared/models/users'
3import { logger } from '../../../helpers/logger' 3import { logger } from '../../../helpers/logger'
4import { getFormattedObjects, getServerActor } from '../../../helpers/utils' 4import { getFormattedObjects, getServerActor } from '../../../helpers/utils'
5import { sequelizeTypescript } from '../../../initializers' 5import { sequelizeTypescript, SERVER_ACTOR_NAME } from '../../../initializers'
6import { sendUndoFollow } from '../../../lib/activitypub/send' 6import { sendUndoFollow } from '../../../lib/activitypub/send'
7import { 7import {
8 asyncMiddleware, 8 asyncMiddleware,
@@ -74,9 +74,16 @@ async function listFollowers (req: express.Request, res: express.Response, next:
74 74
75async function followInstance (req: express.Request, res: express.Response, next: express.NextFunction) { 75async function followInstance (req: express.Request, res: express.Response, next: express.NextFunction) {
76 const hosts = req.body.hosts as string[] 76 const hosts = req.body.hosts as string[]
77 const follower = await getServerActor()
77 78
78 for (const host of hosts) { 79 for (const host of hosts) {
79 JobQueue.Instance.createJob({ type: 'activitypub-follow', payload: { host } }) 80 const payload = {
81 host,
82 name: SERVER_ACTOR_NAME,
83 followerActorId: follower.id
84 }
85
86 JobQueue.Instance.createJob({ type: 'activitypub-follow', payload })
80 .catch(err => logger.error('Cannot create follow job for %s.', host, err)) 87 .catch(err => logger.error('Cannot create follow job for %s.', host, err))
81 } 88 }
82 89
@@ -92,11 +99,5 @@ async function removeFollow (req: express.Request, res: express.Response, next:
92 await follow.destroy({ transaction: t }) 99 await follow.destroy({ transaction: t })
93 }) 100 })
94 101
95 // Destroy the actor that will destroy video channels, videos and video files too
96 // This could be long so don't wait this task
97 const following = follow.ActorFollowing
98 following.destroy()
99 .catch(err => logger.error('Cannot destroy actor that we do not follow anymore %s.', following.url, { err }))
100
101 return res.status(204).end() 102 return res.status(204).end()
102} 103}
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
29import { UserModel } from '../../../models/account/user' 29import { UserModel } from '../../../models/account/user'
30import { OAuthTokenModel } from '../../../models/oauth/oauth-token' 30import { OAuthTokenModel } from '../../../models/oauth/oauth-token'
31import { auditLoggerFactory, UserAuditView } from '../../../helpers/audit-logger' 31import { auditLoggerFactory, UserAuditView } from '../../../helpers/audit-logger'
32import { videosRouter } from '../videos'
33import { meRouter } from './me' 32import { meRouter } from './me'
34 33
35const auditLogger = auditLoggerFactory('users') 34const auditLogger = auditLoggerFactory('users')
@@ -41,7 +40,7 @@ const loginRateLimiter = new RateLimit({
41}) 40})
42 41
43const usersRouter = express.Router() 42const usersRouter = express.Router()
44videosRouter.use('/', meRouter) 43usersRouter.use('/', meRouter)
45 44
46usersRouter.get('/', 45usersRouter.get('/',
47 authenticate, 46 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'
7import { 7import {
8 asyncMiddleware, 8 asyncMiddleware,
9 authenticate, 9 authenticate,
10 commonVideosFiltersValidator,
10 paginationValidator, 11 paginationValidator,
11 setDefaultPagination, 12 setDefaultPagination,
12 setDefaultSort, 13 setDefaultSort,
14 userSubscriptionAddValidator,
15 userSubscriptionRemoveValidator,
13 usersUpdateMeValidator, 16 usersUpdateMeValidator,
14 usersVideoRatingValidator 17 usersVideoRatingValidator
15} from '../../../middlewares' 18} from '../../../middlewares'
16import { deleteMeValidator, videoImportsSortValidator, videosSortValidator } from '../../../middlewares/validators' 19import {
20 deleteMeValidator,
21 userSubscriptionsSortValidator,
22 videoImportsSortValidator,
23 videosSortValidator
24} from '../../../middlewares/validators'
17import { AccountVideoRateModel } from '../../../models/account/account-video-rate' 25import { AccountVideoRateModel } from '../../../models/account/account-video-rate'
18import { UserModel } from '../../../models/account/user' 26import { UserModel } from '../../../models/account/user'
19import { VideoModel } from '../../../models/video/video' 27import { VideoModel } from '../../../models/video/video'
20import { VideoSortField } from '../../../../client/src/app/shared/video/sort-field.type' 28import { VideoSortField } from '../../../../client/src/app/shared/video/sort-field.type'
21import { createReqFiles } from '../../../helpers/express-utils' 29import { buildNSFWFilter, createReqFiles } from '../../../helpers/express-utils'
22import { UserVideoQuota } from '../../../../shared/models/users/user-video-quota.model' 30import { UserVideoQuota } from '../../../../shared/models/users/user-video-quota.model'
23import { updateAvatarValidator } from '../../../middlewares/validators/avatar' 31import { updateAvatarValidator } from '../../../middlewares/validators/avatar'
24import { updateActorAvatarFile } from '../../../lib/avatar' 32import { updateActorAvatarFile } from '../../../lib/avatar'
25import { auditLoggerFactory, UserAuditView } from '../../../helpers/audit-logger' 33import { auditLoggerFactory, UserAuditView } from '../../../helpers/audit-logger'
26import { VideoImportModel } from '../../../models/video/video-import' 34import { VideoImportModel } from '../../../models/video/video-import'
35import { VideoFilter } from '../../../../shared/models/videos/video-query.type'
36import { ActorFollowModel } from '../../../models/activitypub/actor-follow'
37import { JobQueue } from '../../../lib/job-queue'
38import { logger } from '../../../helpers/logger'
27 39
28const auditLogger = auditLoggerFactory('users-me') 40const auditLogger = auditLoggerFactory('users-me')
29 41
@@ -83,6 +95,40 @@ meRouter.post('/me/avatar/pick',
83 asyncMiddleware(updateMyAvatar) 95 asyncMiddleware(updateMyAvatar)
84) 96)
85 97
98// ##### Subscriptions part #####
99
100meRouter.get('/me/subscriptions',
101 authenticate,
102 paginationValidator,
103 userSubscriptionsSortValidator,
104 setDefaultSort,
105 setDefaultPagination,
106 asyncMiddleware(getUserSubscriptions)
107)
108
109meRouter.post('/me/subscriptions',
110 authenticate,
111 userSubscriptionAddValidator,
112 asyncMiddleware(addUserSubscription)
113)
114
115meRouter.delete('/me/subscriptions/:uri',
116 authenticate,
117 userSubscriptionRemoveValidator,
118 asyncMiddleware(deleteUserSubscription)
119)
120
121meRouter.get('/me/subscriptions/videos',
122 authenticate,
123 authenticate,
124 paginationValidator,
125 videosSortValidator,
126 setDefaultSort,
127 setDefaultPagination,
128 commonVideosFiltersValidator,
129 asyncMiddleware(getUserSubscriptionVideos)
130)
131
86// --------------------------------------------------------------------------- 132// ---------------------------------------------------------------------------
87 133
88export { 134export {
@@ -91,6 +137,62 @@ export {
91 137
92// --------------------------------------------------------------------------- 138// ---------------------------------------------------------------------------
93 139
140async function addUserSubscription (req: express.Request, res: express.Response) {
141 const user = res.locals.oauth.token.User as UserModel
142 const [ name, host ] = req.body.uri.split('@')
143
144 const payload = {
145 name,
146 host,
147 followerActorId: user.Account.Actor.id
148 }
149
150 JobQueue.Instance.createJob({ type: 'activitypub-follow', payload })
151 .catch(err => logger.error('Cannot create follow job for subscription %s.', req.body.uri, err))
152
153 return res.status(204).end()
154}
155
156async function deleteUserSubscription (req: express.Request, res: express.Response) {
157 const subscription: ActorFollowModel = res.locals.subscription
158
159 await sequelizeTypescript.transaction(async t => {
160 return subscription.destroy({ transaction: t })
161 })
162
163 return res.type('json').status(204).end()
164}
165
166async function getUserSubscriptions (req: express.Request, res: express.Response) {
167 const user = res.locals.oauth.token.User as UserModel
168 const actorId = user.Account.Actor.id
169
170 const resultList = await ActorFollowModel.listSubscriptionsForApi(actorId, req.query.start, req.query.count, req.query.sort)
171
172 return res.json(getFormattedObjects(resultList.data, resultList.total))
173}
174
175async function getUserSubscriptionVideos (req: express.Request, res: express.Response, next: express.NextFunction) {
176 const user = res.locals.oauth.token.User as UserModel
177 const resultList = await VideoModel.listForApi({
178 start: req.query.start,
179 count: req.query.count,
180 sort: req.query.sort,
181 includeLocalVideos: false,
182 categoryOneOf: req.query.categoryOneOf,
183 licenceOneOf: req.query.licenceOneOf,
184 languageOneOf: req.query.languageOneOf,
185 tagsOneOf: req.query.tagsOneOf,
186 tagsAllOf: req.query.tagsAllOf,
187 nsfw: buildNSFWFilter(res, req.query.nsfw),
188 filter: req.query.filter as VideoFilter,
189 withFiles: false,
190 actorId: user.Account.Actor.id
191 })
192
193 return res.json(getFormattedObjects(resultList.data, resultList.total))
194}
195
94async function getUserVideos (req: express.Request, res: express.Response, next: express.NextFunction) { 196async function getUserVideos (req: express.Request, res: express.Response, next: express.NextFunction) {
95 const user = res.locals.oauth.token.User as UserModel 197 const user = res.locals.oauth.token.User as UserModel
96 const resultList = await VideoModel.listUserVideosForApi( 198 const resultList = await VideoModel.listUserVideosForApi(
@@ -150,7 +252,7 @@ async function getUserVideoRating (req: express.Request, res: express.Response,
150 videoId, 252 videoId,
151 rating 253 rating
152 } 254 }
153 res.json(json) 255 return res.json(json)
154} 256}
155 257
156async function deleteMe (req: express.Request, res: express.Response) { 258async function deleteMe (req: express.Request, res: express.Response) {
@@ -207,9 +309,5 @@ async function updateMyAvatar (req: express.Request, res: express.Response, next
207 oldUserAuditView 309 oldUserAuditView
208 ) 310 )
209 311
210 return res 312 return res.json({ avatar: avatar.toFormattedJSON() })
211 .json({
212 avatar: avatar.toFormattedJSON()
213 })
214 .end()
215} 313}
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
215 start: req.query.start, 215 start: req.query.start,
216 count: req.query.count, 216 count: req.query.count,
217 sort: req.query.sort, 217 sort: req.query.sort,
218 includeLocalVideos: false,
218 categoryOneOf: req.query.categoryOneOf, 219 categoryOneOf: req.query.categoryOneOf,
219 licenceOneOf: req.query.licenceOneOf, 220 licenceOneOf: req.query.licenceOneOf,
220 languageOneOf: req.query.languageOneOf, 221 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
414 start: req.query.start, 414 start: req.query.start,
415 count: req.query.count, 415 count: req.query.count,
416 sort: req.query.sort, 416 sort: req.query.sort,
417 includeLocalVideos: true,
417 categoryOneOf: req.query.categoryOneOf, 418 categoryOneOf: req.query.categoryOneOf,
418 licenceOneOf: req.query.licenceOneOf, 419 licenceOneOf: req.query.licenceOneOf,
419 languageOneOf: req.query.languageOneOf, 420 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
96 start, 96 start,
97 count: FEEDS.COUNT, 97 count: FEEDS.COUNT,
98 sort: req.query.sort, 98 sort: req.query.sort,
99 includeLocalVideos: true,
99 nsfw, 100 nsfw,
100 filter: req.query.filter, 101 filter: req.query.filter,
101 withFiles: true, 102 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'
3import { exists } from '../misc' 3import { exists } from '../misc'
4import { truncate } from 'lodash' 4import { truncate } from 'lodash'
5import { isActivityPubUrlValid, isBaseActivityValid, setValidAttributedTo } from './misc' 5import { isActivityPubUrlValid, isBaseActivityValid, setValidAttributedTo } from './misc'
6import { isHostValid } from '../servers'
6 7
7function isActorEndpointsObjectValid (endpointObject: any) { 8function isActorEndpointsObjectValid (endpointObject: any) {
8 return isActivityPubUrlValid(endpointObject.sharedInbox) 9 return isActivityPubUrlValid(endpointObject.sharedInbox)
@@ -109,6 +110,15 @@ function normalizeActor (actor: any) {
109 return 110 return
110} 111}
111 112
113function isValidActorHandle (handle: string) {
114 if (!exists(handle)) return false
115
116 const parts = handle.split('@')
117 if (parts.length !== 2) return false
118
119 return isHostValid(parts[1])
120}
121
112// --------------------------------------------------------------------------- 122// ---------------------------------------------------------------------------
113 123
114export { 124export {
@@ -126,5 +136,6 @@ export {
126 isActorAcceptActivityValid, 136 isActorAcceptActivityValid,
127 isActorRejectActivityValid, 137 isActorRejectActivityValid,
128 isActorDeleteActivityValid, 138 isActorDeleteActivityValid,
129 isActorUpdateActivityValid 139 isActorUpdateActivityValid,
140 isValidActorHandle
130} 141}
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'
5import { CONSTRAINTS_FIELDS } from '../../initializers' 5import { CONSTRAINTS_FIELDS } from '../../initializers'
6import { VideoChannelModel } from '../../models/video/video-channel' 6import { VideoChannelModel } from '../../models/video/video-channel'
7import { exists } from './misc' 7import { exists } from './misc'
8import { Response } from 'express'
8 9
9const VIDEO_CHANNELS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEO_CHANNELS 10const VIDEO_CHANNELS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEO_CHANNELS
10 11
@@ -20,6 +21,12 @@ function isVideoChannelSupportValid (value: string) {
20 return value === null || (exists(value) && validator.isLength(value, VIDEO_CHANNELS_CONSTRAINTS_FIELDS.SUPPORT)) 21 return value === null || (exists(value) && validator.isLength(value, VIDEO_CHANNELS_CONSTRAINTS_FIELDS.SUPPORT))
21} 22}
22 23
24async function isLocalVideoChannelNameExist (name: string, res: Response) {
25 const videoChannel = await VideoChannelModel.loadLocalByName(name)
26
27 return processVideoChannelExist(videoChannel, res)
28}
29
23async function isVideoChannelExist (id: string, res: express.Response) { 30async function isVideoChannelExist (id: string, res: express.Response) {
24 let videoChannel: VideoChannelModel 31 let videoChannel: VideoChannelModel
25 if (validator.isInt(id)) { 32 if (validator.isInt(id)) {
@@ -28,23 +35,28 @@ async function isVideoChannelExist (id: string, res: express.Response) {
28 videoChannel = await VideoChannelModel.loadByUUIDAndPopulateAccount(id) 35 videoChannel = await VideoChannelModel.loadByUUIDAndPopulateAccount(id)
29 } 36 }
30 37
31 if (!videoChannel) { 38 return processVideoChannelExist(videoChannel, res)
32 res.status(404)
33 .json({ error: 'Video channel not found' })
34 .end()
35
36 return false
37 }
38
39 res.locals.videoChannel = videoChannel
40 return true
41} 39}
42 40
43// --------------------------------------------------------------------------- 41// ---------------------------------------------------------------------------
44 42
45export { 43export {
44 isLocalVideoChannelNameExist,
46 isVideoChannelDescriptionValid, 45 isVideoChannelDescriptionValid,
47 isVideoChannelNameValid, 46 isVideoChannelNameValid,
48 isVideoChannelSupportValid, 47 isVideoChannelSupportValid,
49 isVideoChannelExist 48 isVideoChannelExist
50} 49}
50
51function processVideoChannelExist (videoChannel: VideoChannelModel, res: express.Response) {
52 if (!videoChannel) {
53 res.status(404)
54 .json({ error: 'Video channel not found' })
55 .end()
56
57 return false
58 }
59
60 res.locals.videoChannel = videoChannel
61 return true
62}
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'
2import { sanitizeHost } from '../core-utils' 2import { sanitizeHost } from '../core-utils'
3import { exists } from './misc' 3import { exists } from './misc'
4 4
5function isWebfingerResourceValid (value: string) { 5function isWebfingerLocalResourceValid (value: string) {
6 if (!exists(value)) return false 6 if (!exists(value)) return false
7 if (value.startsWith('acct:') === false) return false 7 if (value.startsWith('acct:') === false) return false
8 8
@@ -17,5 +17,5 @@ function isWebfingerResourceValid (value: string) {
17// --------------------------------------------------------------------------- 17// ---------------------------------------------------------------------------
18 18
19export { 19export {
20 isWebfingerResourceValid 20 isWebfingerLocalResourceValid
21} 21}
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({
11 request_timeout: 3000 11 request_timeout: 3000
12}) 12})
13 13
14async function loadActorUrlOrGetFromWebfinger (name: string, host: string) { 14async function loadActorUrlOrGetFromWebfinger (uri: string) {
15 const [ name, host ] = uri.split('@')
16
15 const actor = await ActorModel.loadByNameAndHost(name, host) 17 const actor = await ActorModel.loadByNameAndHost(name, host)
16 if (actor) return actor.url 18 if (actor) return actor.url
17 19
18 return getUrlFromWebfinger(name, host) 20 return getUrlFromWebfinger(uri)
19} 21}
20 22
21async function getUrlFromWebfinger (name: string, host: string) { 23async function getUrlFromWebfinger (uri: string) {
22 const webfingerData: WebFingerData = await webfingerLookup(name + '@' + host) 24 const webfingerData: WebFingerData = await webfingerLookup(uri)
23 return getLinkOrThrow(webfingerData) 25 return getLinkOrThrow(webfingerData)
24} 26}
25 27
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 = {
31// Sortable columns per schema 31// Sortable columns per schema
32const SORTABLE_COLUMNS = { 32const SORTABLE_COLUMNS = {
33 USERS: [ 'id', 'username', 'createdAt' ], 33 USERS: [ 'id', 'username', 'createdAt' ],
34 USER_SUBSCRIPTIONS: [ 'id', 'createdAt' ],
34 ACCOUNTS: [ 'createdAt' ], 35 ACCOUNTS: [ 'createdAt' ],
35 JOBS: [ 'createdAt' ], 36 JOBS: [ 'createdAt' ],
36 VIDEO_ABUSES: [ 'id', 'createdAt', 'state' ], 37 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<ActorModel> {
352 if (!actor.isOutdated()) return actor 352 if (!actor.isOutdated()) return actor
353 353
354 try { 354 try {
355 const actorUrl = await getUrlFromWebfinger(actor.preferredUsername, actor.getHost()) 355 const actorUrl = await getUrlFromWebfinger(actor.preferredUsername + '@' + actor.getHost())
356 const result = await fetchRemoteActor(actorUrl) 356 const result = await fetchRemoteActor(actorUrl)
357 if (result === undefined) { 357 if (result === undefined) {
358 logger.warn('Cannot fetch remote actor in refresh actor.') 358 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) {
10 const follower = actorFollow.ActorFollower 10 const follower = actorFollow.ActorFollower
11 const me = actorFollow.ActorFollowing 11 const me = actorFollow.ActorFollowing
12 12
13 if (!follower.serverId) { // This should never happen
14 logger.warn('Do not sending accept to local follower.')
15 return
16 }
17
13 logger.info('Creating job to accept follower %s.', follower.url) 18 logger.info('Creating job to accept follower %s.', follower.url)
14 19
15 const followUrl = getActorFollowActivityPubUrl(actorFollow) 20 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) {
33} 33}
34 34
35async function sendVideoAbuse (byActor: ActorModel, videoAbuse: VideoAbuseModel, video: VideoModel, t: Transaction) { 35async function sendVideoAbuse (byActor: ActorModel, videoAbuse: VideoAbuseModel, video: VideoModel, t: Transaction) {
36 if (!video.VideoChannel.Account.Actor.serverId) return // Local
37
36 const url = getVideoAbuseActivityPubUrl(videoAbuse) 38 const url = getVideoAbuseActivityPubUrl(videoAbuse)
37 39
38 logger.info('Creating job to send video abuse %s.', url) 40 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) {
9 const me = actorFollow.ActorFollower 9 const me = actorFollow.ActorFollower
10 const following = actorFollow.ActorFollowing 10 const following = actorFollow.ActorFollowing
11 11
12 // Same server as ours
13 if (!following.serverId) return
14
12 logger.info('Creating job to send follow request to %s.', following.url) 15 logger.info('Creating job to send follow request to %s.', following.url)
13 16
14 const url = getActorFollowActivityPubUrl(actorFollow) 17 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) {
24 const me = actorFollow.ActorFollower 24 const me = actorFollow.ActorFollower
25 const following = actorFollow.ActorFollowing 25 const following = actorFollow.ActorFollowing
26 26
27 // Same server as ours
28 if (!following.serverId) return
29
27 logger.info('Creating job to send an unfollow request to %s.', following.url) 30 logger.info('Creating job to send an unfollow request to %s.', following.url)
28 31
29 const followUrl = getActorFollowActivityPubUrl(actorFollow) 32 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'
6import { JobQueue } from '../../job-queue' 6import { JobQueue } from '../../job-queue'
7import { VideoModel } from '../../../models/video/video' 7import { VideoModel } from '../../../models/video/video'
8import { getActorsInvolvedInVideo } from '../audience' 8import { getActorsInvolvedInVideo } from '../audience'
9import { getServerActor } from '../../../helpers/utils'
9 10
10async function forwardVideoRelatedActivity ( 11async function forwardVideoRelatedActivity (
11 activity: Activity, 12 activity: Activity,
@@ -118,14 +119,28 @@ async function computeFollowerUris (toActorFollower: ActorModel[], actorsExcepti
118 const toActorFollowerIds = toActorFollower.map(a => a.id) 119 const toActorFollowerIds = toActorFollower.map(a => a.id)
119 120
120 const result = await ActorFollowModel.listAcceptedFollowerSharedInboxUrls(toActorFollowerIds, t) 121 const result = await ActorFollowModel.listAcceptedFollowerSharedInboxUrls(toActorFollowerIds, t)
121 const sharedInboxesException = actorsException.map(f => f.sharedInboxUrl || f.inboxUrl) 122 const sharedInboxesException = await buildSharedInboxesException(actorsException)
123
122 return result.data.filter(sharedInbox => sharedInboxesException.indexOf(sharedInbox) === -1) 124 return result.data.filter(sharedInbox => sharedInboxesException.indexOf(sharedInbox) === -1)
123} 125}
124 126
125async function computeUris (toActors: ActorModel[], actorsException: ActorModel[] = []) { 127async function computeUris (toActors: ActorModel[], actorsException: ActorModel[] = []) {
126 const toActorSharedInboxesSet = new Set(toActors.map(a => a.sharedInboxUrl || a.inboxUrl)) 128 const serverActor = await getServerActor()
129 const targetUrls = toActors
130 .filter(a => a.id !== serverActor.id) // Don't send to ourselves
131 .map(a => a.sharedInboxUrl || a.inboxUrl)
132
133 const toActorSharedInboxesSet = new Set(targetUrls)
127 134
128 const sharedInboxesException = actorsException.map(f => f.sharedInboxUrl || f.inboxUrl) 135 const sharedInboxesException = await buildSharedInboxesException(actorsException)
129 return Array.from(toActorSharedInboxesSet) 136 return Array.from(toActorSharedInboxesSet)
130 .filter(sharedInbox => sharedInboxesException.indexOf(sharedInbox) === -1) 137 .filter(sharedInbox => sharedInboxesException.indexOf(sharedInbox) === -1)
131} 138}
139
140async function buildSharedInboxesException (actorsException: ActorModel[]) {
141 const serverActor = await getServerActor()
142
143 return actorsException
144 .map(f => f.sharedInboxUrl || f.inboxUrl)
145 .concat([ serverActor.sharedInboxUrl ])
146}
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 @@
1import * as Bull from 'bull' 1import * as Bull from 'bull'
2import { logger } from '../../../helpers/logger' 2import { logger } from '../../../helpers/logger'
3import { getServerActor } from '../../../helpers/utils' 3import { getServerActor } from '../../../helpers/utils'
4import { REMOTE_SCHEME, sequelizeTypescript, SERVER_ACTOR_NAME } from '../../../initializers' 4import { REMOTE_SCHEME, sequelizeTypescript } from '../../../initializers'
5import { sendFollow } from '../../activitypub/send' 5import { sendFollow } from '../../activitypub/send'
6import { sanitizeHost } from '../../../helpers/core-utils' 6import { sanitizeHost } from '../../../helpers/core-utils'
7import { loadActorUrlOrGetFromWebfinger } from '../../../helpers/webfinger' 7import { loadActorUrlOrGetFromWebfinger } from '../../../helpers/webfinger'
@@ -11,6 +11,8 @@ import { ActorFollowModel } from '../../../models/activitypub/actor-follow'
11import { ActorModel } from '../../../models/activitypub/actor' 11import { ActorModel } from '../../../models/activitypub/actor'
12 12
13export type ActivitypubFollowPayload = { 13export type ActivitypubFollowPayload = {
14 followerActorId: number
15 name: string
14 host: string 16 host: string
15} 17}
16 18
@@ -22,10 +24,10 @@ async function processActivityPubFollow (job: Bull.Job) {
22 24
23 const sanitizedHost = sanitizeHost(host, REMOTE_SCHEME.HTTP) 25 const sanitizedHost = sanitizeHost(host, REMOTE_SCHEME.HTTP)
24 26
25 const actorUrl = await loadActorUrlOrGetFromWebfinger(SERVER_ACTOR_NAME, sanitizedHost) 27 const actorUrl = await loadActorUrlOrGetFromWebfinger(payload.name + '@' + sanitizedHost)
26 const targetActor = await getOrCreateActorAndServerAndModel(actorUrl) 28 const targetActor = await getOrCreateActorAndServerAndModel(actorUrl)
27 29
28 const fromActor = await getServerActor() 30 const fromActor = await ActorModel.load(payload.followerActorId)
29 31
30 return retryTransactionWrapper(follow, fromActor, targetActor) 32 return retryTransactionWrapper(follow, fromActor, targetActor)
31} 33}
@@ -42,6 +44,9 @@ function follow (fromActor: ActorModel, targetActor: ActorModel) {
42 throw new Error('Follower is the same than target actor.') 44 throw new Error('Follower is the same than target actor.')
43 } 45 }
44 46
47 // Same server, direct accept
48 const state = !fromActor.serverId && !targetActor.serverId ? 'accepted' : 'pending'
49
45 return sequelizeTypescript.transaction(async t => { 50 return sequelizeTypescript.transaction(async t => {
46 const [ actorFollow ] = await ActorFollowModel.findOrCreate({ 51 const [ actorFollow ] = await ActorFollowModel.findOrCreate({
47 where: { 52 where: {
@@ -49,7 +54,7 @@ function follow (fromActor: ActorModel, targetActor: ActorModel) {
49 targetActorId: targetActor.id 54 targetActorId: targetActor.id
50 }, 55 },
51 defaults: { 56 defaults: {
52 state: 'pending', 57 state,
53 actorId: fromActor.id, 58 actorId: fromActor.id,
54 targetActorId: targetActor.id 59 targetActorId: targetActor.id
55 }, 60 },
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'
4import { isEachUniqueHostValid, isHostValid } from '../../helpers/custom-validators/servers' 4import { isEachUniqueHostValid, isHostValid } from '../../helpers/custom-validators/servers'
5import { logger } from '../../helpers/logger' 5import { logger } from '../../helpers/logger'
6import { getServerActor } from '../../helpers/utils' 6import { getServerActor } from '../../helpers/utils'
7import { CONFIG } from '../../initializers' 7import { CONFIG, SERVER_ACTOR_NAME } from '../../initializers'
8import { ActorFollowModel } from '../../models/activitypub/actor-follow' 8import { ActorFollowModel } from '../../models/activitypub/actor-follow'
9import { areValidationErrors } from './utils' 9import { areValidationErrors } from './utils'
10 10
@@ -38,7 +38,7 @@ const removeFollowingValidator = [
38 if (areValidationErrors(req, res)) return 38 if (areValidationErrors(req, res)) return
39 39
40 const serverActor = await getServerActor() 40 const serverActor = await getServerActor()
41 const follow = await ActorFollowModel.loadByActorAndTargetHost(serverActor.id, req.params.host) 41 const follow = await ActorFollowModel.loadByActorAndTargetNameAndHost(serverActor.id, SERVER_ACTOR_NAME, req.params.host)
42 42
43 if (!follow) { 43 if (!follow) {
44 return res 44 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'
6export * from './feeds' 6export * from './feeds'
7export * from './sort' 7export * from './sort'
8export * from './users' 8export * from './users'
9export * from './user-subscriptions'
9export * from './videos' 10export * from './videos'
10export * from './video-abuses' 11export * from './video-abuses'
11export * from './video-blacklist' 12export * 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
14const SORTABLE_VIDEO_CHANNELS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_CHANNELS) 14const SORTABLE_VIDEO_CHANNELS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_CHANNELS)
15const SORTABLE_FOLLOWERS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.FOLLOWERS) 15const SORTABLE_FOLLOWERS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.FOLLOWERS)
16const SORTABLE_FOLLOWING_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.FOLLOWING) 16const SORTABLE_FOLLOWING_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.FOLLOWING)
17const SORTABLE_USER_SUBSCRIPTIONS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.USER_SUBSCRIPTIONS)
17 18
18const usersSortValidator = checkSort(SORTABLE_USERS_COLUMNS) 19const usersSortValidator = checkSort(SORTABLE_USERS_COLUMNS)
19const accountsSortValidator = checkSort(SORTABLE_ACCOUNTS_COLUMNS) 20const accountsSortValidator = checkSort(SORTABLE_ACCOUNTS_COLUMNS)
@@ -27,6 +28,7 @@ const blacklistSortValidator = checkSort(SORTABLE_BLACKLISTS_COLUMNS)
27const videoChannelsSortValidator = checkSort(SORTABLE_VIDEO_CHANNELS_COLUMNS) 28const videoChannelsSortValidator = checkSort(SORTABLE_VIDEO_CHANNELS_COLUMNS)
28const followersSortValidator = checkSort(SORTABLE_FOLLOWERS_COLUMNS) 29const followersSortValidator = checkSort(SORTABLE_FOLLOWERS_COLUMNS)
29const followingSortValidator = checkSort(SORTABLE_FOLLOWING_COLUMNS) 30const followingSortValidator = checkSort(SORTABLE_FOLLOWING_COLUMNS)
31const userSubscriptionsSortValidator = checkSort(SORTABLE_USER_SUBSCRIPTIONS_COLUMNS)
30 32
31// --------------------------------------------------------------------------- 33// ---------------------------------------------------------------------------
32 34
@@ -42,5 +44,6 @@ export {
42 followersSortValidator, 44 followersSortValidator,
43 followingSortValidator, 45 followingSortValidator,
44 jobsSortValidator, 46 jobsSortValidator,
45 videoCommentThreadsSortValidator 47 videoCommentThreadsSortValidator,
48 userSubscriptionsSortValidator
46} 49}
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 @@
1import * as express from 'express'
2import 'express-validator'
3import { body, param } from 'express-validator/check'
4import { logger } from '../../helpers/logger'
5import { areValidationErrors } from './utils'
6import { ActorFollowModel } from '../../models/activitypub/actor-follow'
7import { isValidActorHandle } from '../../helpers/custom-validators/activitypub/actor'
8import { UserModel } from '../../models/account/user'
9import { CONFIG } from '../../initializers'
10
11const userSubscriptionAddValidator = [
12 body('uri').custom(isValidActorHandle).withMessage('Should have a valid URI to follow (username@domain)'),
13
14 (req: express.Request, res: express.Response, next: express.NextFunction) => {
15 logger.debug('Checking userSubscriptionAddValidator parameters', { parameters: req.body })
16
17 if (areValidationErrors(req, res)) return
18
19 return next()
20 }
21]
22
23const userSubscriptionRemoveValidator = [
24 param('uri').custom(isValidActorHandle).withMessage('Should have a valid URI to unfollow'),
25
26 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
27 logger.debug('Checking unfollow parameters', { parameters: req.params })
28
29 if (areValidationErrors(req, res)) return
30
31 let [ name, host ] = req.params.uri.split('@')
32 if (host === CONFIG.WEBSERVER.HOST) host = null
33
34 const user: UserModel = res.locals.oauth.token.User
35 const subscription = await ActorFollowModel.loadByActorAndTargetNameAndHost(user.Account.Actor.id, name, host)
36
37 if (!subscription) {
38 return res
39 .status(404)
40 .json({
41 error: `Subscription ${req.params.uri} not found.`
42 })
43 .end()
44 }
45
46 res.locals.subscription = subscription
47 return next()
48 }
49]
50
51// ---------------------------------------------------------------------------
52
53export {
54 userSubscriptionAddValidator,
55 userSubscriptionRemoveValidator
56}
57
58// ---------------------------------------------------------------------------
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'
4import { isAccountNameWithHostExist } from '../../helpers/custom-validators/accounts' 4import { isAccountNameWithHostExist } from '../../helpers/custom-validators/accounts'
5import { isIdOrUUIDValid } from '../../helpers/custom-validators/misc' 5import { isIdOrUUIDValid } from '../../helpers/custom-validators/misc'
6import { 6import {
7 isLocalVideoChannelNameExist,
7 isVideoChannelDescriptionValid, 8 isVideoChannelDescriptionValid,
8 isVideoChannelExist, 9 isVideoChannelExist,
9 isVideoChannelNameValid, 10 isVideoChannelNameValid,
@@ -100,6 +101,19 @@ const videoChannelsGetValidator = [
100 } 101 }
101] 102]
102 103
104const localVideoChannelValidator = [
105 param('name').custom(isVideoChannelNameValid).withMessage('Should have a valid video channel name'),
106
107 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
108 logger.debug('Checking localVideoChannelValidator parameters', { parameters: req.params })
109
110 if (areValidationErrors(req, res)) return
111 if (!await isLocalVideoChannelNameExist(req.params.name, res)) return
112
113 return next()
114 }
115]
116
103// --------------------------------------------------------------------------- 117// ---------------------------------------------------------------------------
104 118
105export { 119export {
@@ -107,7 +121,8 @@ export {
107 videoChannelsAddValidator, 121 videoChannelsAddValidator,
108 videoChannelsUpdateValidator, 122 videoChannelsUpdateValidator,
109 videoChannelsRemoveValidator, 123 videoChannelsRemoveValidator,
110 videoChannelsGetValidator 124 videoChannelsGetValidator,
125 localVideoChannelValidator
111} 126}
112 127
113// --------------------------------------------------------------------------- 128// ---------------------------------------------------------------------------
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 @@
1import * as express from 'express' 1import * as express from 'express'
2import { query } from 'express-validator/check' 2import { query } from 'express-validator/check'
3import { isWebfingerResourceValid } from '../../helpers/custom-validators/webfinger' 3import { isWebfingerLocalResourceValid } from '../../helpers/custom-validators/webfinger'
4import { logger } from '../../helpers/logger' 4import { logger } from '../../helpers/logger'
5import { ActorModel } from '../../models/activitypub/actor' 5import { ActorModel } from '../../models/activitypub/actor'
6import { areValidationErrors } from './utils' 6import { areValidationErrors } from './utils'
7import { getHostWithPort } from '../../helpers/express-utils' 7import { getHostWithPort } from '../../helpers/express-utils'
8 8
9const webfingerValidator = [ 9const webfingerValidator = [
10 query('resource').custom(isWebfingerResourceValid).withMessage('Should have a valid webfinger resource'), 10 query('resource').custom(isWebfingerLocalResourceValid).withMessage('Should have a valid webfinger resource'),
11 11
12 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 12 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
13 logger.debug('Checking webfinger parameters', { parameters: req.query }) 13 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'
2import { values } from 'lodash' 2import { values } from 'lodash'
3import * as Sequelize from 'sequelize' 3import * as Sequelize from 'sequelize'
4import { 4import {
5 AfterCreate, AfterDestroy, AfterUpdate, AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, IsInt, Max, Model, 5 AfterCreate,
6 Table, UpdatedAt 6 AfterDestroy,
7 AfterUpdate,
8 AllowNull,
9 BelongsTo,
10 Column,
11 CreatedAt,
12 DataType,
13 Default,
14 ForeignKey,
15 IsInt,
16 Max,
17 Model,
18 Table,
19 UpdatedAt
7} from 'sequelize-typescript' 20} from 'sequelize-typescript'
8import { FollowState } from '../../../shared/models/actors' 21import { FollowState } from '../../../shared/models/actors'
9import { AccountFollow } from '../../../shared/models/actors/follow.model' 22import { AccountFollow } from '../../../shared/models/actors/follow.model'
@@ -14,6 +27,7 @@ import { FOLLOW_STATES } from '../../initializers/constants'
14import { ServerModel } from '../server/server' 27import { ServerModel } from '../server/server'
15import { getSort } from '../utils' 28import { getSort } from '../utils'
16import { ActorModel } from './actor' 29import { ActorModel } from './actor'
30import { VideoChannelModel } from '../video/video-channel'
17 31
18@Table({ 32@Table({
19 tableName: 'actorFollow', 33 tableName: 'actorFollow',
@@ -151,7 +165,32 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
151 return ActorFollowModel.findOne(query) 165 return ActorFollowModel.findOne(query)
152 } 166 }
153 167
154 static loadByActorAndTargetHost (actorId: number, targetHost: string, t?: Sequelize.Transaction) { 168 static loadByActorAndTargetNameAndHost (actorId: number, targetName: string, targetHost: string, t?: Sequelize.Transaction) {
169 const actorFollowingPartInclude = {
170 model: ActorModel,
171 required: true,
172 as: 'ActorFollowing',
173 where: {
174 preferredUsername: targetName
175 }
176 }
177
178 if (targetHost === null) {
179 actorFollowingPartInclude.where['serverId'] = null
180 } else {
181 Object.assign(actorFollowingPartInclude, {
182 include: [
183 {
184 model: ServerModel,
185 required: true,
186 where: {
187 host: targetHost
188 }
189 }
190 ]
191 })
192 }
193
155 const query = { 194 const query = {
156 where: { 195 where: {
157 actorId 196 actorId
@@ -162,20 +201,7 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
162 required: true, 201 required: true,
163 as: 'ActorFollower' 202 as: 'ActorFollower'
164 }, 203 },
165 { 204 actorFollowingPartInclude
166 model: ActorModel,
167 required: true,
168 as: 'ActorFollowing',
169 include: [
170 {
171 model: ServerModel,
172 required: true,
173 where: {
174 host: targetHost
175 }
176 }
177 ]
178 }
179 ], 205 ],
180 transaction: t 206 transaction: t
181 } 207 }
@@ -216,6 +242,39 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
216 }) 242 })
217 } 243 }
218 244
245 static listSubscriptionsForApi (id: number, start: number, count: number, sort: string) {
246 const query = {
247 distinct: true,
248 offset: start,
249 limit: count,
250 order: getSort(sort),
251 where: {
252 actorId: id
253 },
254 include: [
255 {
256 model: ActorModel,
257 as: 'ActorFollowing',
258 required: true,
259 include: [
260 {
261 model: VideoChannelModel,
262 required: true
263 }
264 ]
265 }
266 ]
267 }
268
269 return ActorFollowModel.findAndCountAll(query)
270 .then(({ rows, count }) => {
271 return {
272 data: rows.map(r => r.ActorFollowing.VideoChannel),
273 total: count
274 }
275 })
276 }
277
219 static listFollowersForApi (id: number, start: number, count: number, sort: string) { 278 static listFollowersForApi (id: number, start: number, count: number, sort: string) {
220 const query = { 279 const query = {
221 distinct: true, 280 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 @@
1import { 1import {
2 AllowNull, BeforeDestroy, BelongsTo, Column, CreatedAt, DefaultScope, ForeignKey, HasMany, Is, Model, Scopes, Table, 2 AllowNull,
3 UpdatedAt, Default, DataType 3 BeforeDestroy,
4 BelongsTo,
5 Column,
6 CreatedAt,
7 DataType,
8 Default,
9 DefaultScope,
10 ForeignKey,
11 HasMany,
12 Is,
13 Model,
14 Scopes,
15 Table,
16 UpdatedAt
4} from 'sequelize-typescript' 17} from 'sequelize-typescript'
5import { ActivityPubActor } from '../../../shared/models/activitypub' 18import { ActivityPubActor } from '../../../shared/models/activitypub'
6import { VideoChannel } from '../../../shared/models/videos' 19import { VideoChannel } from '../../../shared/models/videos'
7import { 20import {
8 isVideoChannelDescriptionValid, isVideoChannelNameValid, 21 isVideoChannelDescriptionValid,
22 isVideoChannelNameValid,
9 isVideoChannelSupportValid 23 isVideoChannelSupportValid
10} from '../../helpers/custom-validators/video-channels' 24} from '../../helpers/custom-validators/video-channels'
11import { logger } from '../../helpers/logger'
12import { sendDeleteActor } from '../../lib/activitypub/send' 25import { sendDeleteActor } from '../../lib/activitypub/send'
13import { AccountModel } from '../account/account' 26import { AccountModel } from '../account/account'
14import { ActorModel } from '../activitypub/actor' 27import { ActorModel } from '../activitypub/actor'
@@ -241,6 +254,23 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
241 .findById(id, options) 254 .findById(id, options)
242 } 255 }
243 256
257 static loadLocalByName (name: string) {
258 const query = {
259 include: [
260 {
261 model: ActorModel,
262 required: true,
263 where: {
264 preferredUsername: name,
265 serverId: null
266 }
267 }
268 ]
269 }
270
271 return VideoChannelModel.findOne(query)
272 }
273
244 toFormattedJSON (): VideoChannel { 274 toFormattedJSON (): VideoChannel {
245 const actor = this.Actor.toFormattedJSON() 275 const actor = this.Actor.toFormattedJSON()
246 const videoChannel = { 276 const videoChannel = {
@@ -251,8 +281,7 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
251 isLocal: this.Actor.isOwned(), 281 isLocal: this.Actor.isOwned(),
252 createdAt: this.createdAt, 282 createdAt: this.createdAt,
253 updatedAt: this.updatedAt, 283 updatedAt: this.updatedAt,
254 ownerAccount: undefined, 284 ownerAccount: undefined
255 videos: undefined
256 } 285 }
257 286
258 if (this.Account) videoChannel.ownerAccount = this.Account.toFormattedJSON() 287 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 {
133 133
134type AvailableForListOptions = { 134type AvailableForListOptions = {
135 actorId: number, 135 actorId: number,
136 includeLocalVideos: boolean,
136 filter?: VideoFilter, 137 filter?: VideoFilter,
137 categoryOneOf?: number[], 138 categoryOneOf?: number[],
138 nsfw?: boolean, 139 nsfw?: boolean,
@@ -201,6 +202,15 @@ type AvailableForListOptions = {
201 202
202 // Force actorId to be a number to avoid SQL injections 203 // Force actorId to be a number to avoid SQL injections
203 const actorIdNumber = parseInt(options.actorId.toString(), 10) 204 const actorIdNumber = parseInt(options.actorId.toString(), 10)
205 let localVideosReq = ''
206 if (options.includeLocalVideos === true) {
207 localVideosReq = ' UNION ALL ' +
208 'SELECT "video"."id" AS "id" FROM "video" ' +
209 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
210 'INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId" ' +
211 'INNER JOIN "actor" ON "account"."actorId" = "actor"."id" ' +
212 'WHERE "actor"."serverId" IS NULL'
213 }
204 214
205 // FIXME: It would be more efficient to use a CTE so we join AFTER the filters, but sequelize does not support it... 215 // FIXME: It would be more efficient to use a CTE so we join AFTER the filters, but sequelize does not support it...
206 const query: IFindOptions<VideoModel> = { 216 const query: IFindOptions<VideoModel> = {
@@ -214,12 +224,6 @@ type AvailableForListOptions = {
214 'SELECT "videoShare"."videoId" AS "id" FROM "videoShare" ' + 224 'SELECT "videoShare"."videoId" AS "id" FROM "videoShare" ' +
215 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' + 225 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' +
216 'WHERE "actorFollow"."actorId" = ' + actorIdNumber + 226 'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
217 ' UNION ' +
218 'SELECT "video"."id" AS "id" FROM "video" ' +
219 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
220 'INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId" ' +
221 'INNER JOIN "actor" ON "account"."actorId" = "actor"."id" ' +
222 'WHERE "actor"."serverId" IS NULL ' +
223 ' UNION ALL ' + 227 ' UNION ALL ' +
224 'SELECT "video"."id" AS "id" FROM "video" ' + 228 'SELECT "video"."id" AS "id" FROM "video" ' +
225 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' + 229 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
@@ -227,6 +231,7 @@ type AvailableForListOptions = {
227 'INNER JOIN "actor" ON "account"."actorId" = "actor"."id" ' + 231 'INNER JOIN "actor" ON "account"."actorId" = "actor"."id" ' +
228 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "actor"."id" ' + 232 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "actor"."id" ' +
229 'WHERE "actorFollow"."actorId" = ' + actorIdNumber + 233 'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
234 localVideosReq +
230 ')' 235 ')'
231 ) 236 )
232 }, 237 },
@@ -825,6 +830,7 @@ export class VideoModel extends Model<VideoModel> {
825 count: number, 830 count: number,
826 sort: string, 831 sort: string,
827 nsfw: boolean, 832 nsfw: boolean,
833 includeLocalVideos: boolean,
828 withFiles: boolean, 834 withFiles: boolean,
829 categoryOneOf?: number[], 835 categoryOneOf?: number[],
830 licenceOneOf?: number[], 836 licenceOneOf?: number[],
@@ -833,7 +839,8 @@ export class VideoModel extends Model<VideoModel> {
833 tagsAllOf?: string[], 839 tagsAllOf?: string[],
834 filter?: VideoFilter, 840 filter?: VideoFilter,
835 accountId?: number, 841 accountId?: number,
836 videoChannelId?: number 842 videoChannelId?: number,
843 actorId?: number
837 }) { 844 }) {
838 const query = { 845 const query = {
839 offset: options.start, 846 offset: options.start,
@@ -841,11 +848,12 @@ export class VideoModel extends Model<VideoModel> {
841 order: getSort(options.sort) 848 order: getSort(options.sort)
842 } 849 }
843 850
844 const serverActor = await getServerActor() 851 const actorId = options.actorId || (await getServerActor()).id
852
845 const scopes = { 853 const scopes = {
846 method: [ 854 method: [
847 ScopeNames.AVAILABLE_FOR_LIST, { 855 ScopeNames.AVAILABLE_FOR_LIST, {
848 actorId: serverActor.id, 856 actorId,
849 nsfw: options.nsfw, 857 nsfw: options.nsfw,
850 categoryOneOf: options.categoryOneOf, 858 categoryOneOf: options.categoryOneOf,
851 licenceOneOf: options.licenceOneOf, 859 licenceOneOf: options.licenceOneOf,
@@ -855,7 +863,8 @@ export class VideoModel extends Model<VideoModel> {
855 filter: options.filter, 863 filter: options.filter,
856 withFiles: options.withFiles, 864 withFiles: options.withFiles,
857 accountId: options.accountId, 865 accountId: options.accountId,
858 videoChannelId: options.videoChannelId 866 videoChannelId: options.videoChannelId,
867 includeLocalVideos: options.includeLocalVideos
859 } as AvailableForListOptions 868 } as AvailableForListOptions
860 ] 869 ]
861 } 870 }
@@ -871,6 +880,7 @@ export class VideoModel extends Model<VideoModel> {
871 } 880 }
872 881
873 static async searchAndPopulateAccountAndServer (options: { 882 static async searchAndPopulateAccountAndServer (options: {
883 includeLocalVideos: boolean
874 search?: string 884 search?: string
875 start?: number 885 start?: number
876 count?: number 886 count?: number
@@ -955,6 +965,7 @@ export class VideoModel extends Model<VideoModel> {
955 method: [ 965 method: [
956 ScopeNames.AVAILABLE_FOR_LIST, { 966 ScopeNames.AVAILABLE_FOR_LIST, {
957 actorId: serverActor.id, 967 actorId: serverActor.id,
968 includeLocalVideos: options.includeLocalVideos,
958 nsfw: options.nsfw, 969 nsfw: options.nsfw,
959 categoryOneOf: options.categoryOneOf, 970 categoryOneOf: options.categoryOneOf,
960 licenceOneOf: options.licenceOneOf, 971 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'
12import './videos' 12import './videos'
13import './video-imports' 13import './video-imports'
14import './search' 14import './search'
15import './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 @@
1/* tslint:disable:no-unused-expression */
2
3import 'mocha'
4
5import {
6 createUser,
7 flushTests,
8 getMyUserInformation,
9 killallServers,
10 makeDeleteRequest,
11 makeGetRequest,
12 makePostBodyRequest,
13 runServer,
14 ServerInfo,
15 setAccessTokensToServers,
16 userLogin
17} from '../../utils'
18import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '../../utils/requests/check-api-params'
19
20describe('Test user subscriptions API validators', function () {
21 const path = '/api/v1/users/me/subscriptions'
22 let server: ServerInfo
23 let userAccessToken = ''
24 let userChannelUUID: string
25
26 // ---------------------------------------------------------------
27
28 before(async function () {
29 this.timeout(30000)
30
31 await flushTests()
32
33 server = await runServer(1)
34
35 await setAccessTokensToServers([ server ])
36
37 const user = {
38 username: 'user1',
39 password: 'my super password'
40 }
41 await createUser(server.url, server.accessToken, user.username, user.password)
42 userAccessToken = await userLogin(server, user)
43
44 {
45 const res = await getMyUserInformation(server.url, server.accessToken)
46 userChannelUUID = res.body.videoChannels[ 0 ].uuid
47 }
48 })
49
50 describe('When listing my subscriptions', function () {
51 it('Should fail with a bad start pagination', async function () {
52 await checkBadStartPagination(server.url, path, server.accessToken)
53 })
54
55 it('Should fail with a bad count pagination', async function () {
56 await checkBadCountPagination(server.url, path, server.accessToken)
57 })
58
59 it('Should fail with an incorrect sort', async function () {
60 await checkBadSortPagination(server.url, path, server.accessToken)
61 })
62
63 it('Should fail with a non authenticated user', async function () {
64 await makeGetRequest({
65 url: server.url,
66 path,
67 statusCodeExpected: 401
68 })
69 })
70
71 it('Should success with the correct parameters', async function () {
72 await await makeGetRequest({
73 url: server.url,
74 path,
75 token: userAccessToken,
76 statusCodeExpected: 200
77 })
78 })
79 })
80
81 describe('When listing my subscriptions videos', function () {
82 const path = '/api/v1/users/me/subscriptions/videos'
83
84 it('Should fail with a bad start pagination', async function () {
85 await checkBadStartPagination(server.url, path, server.accessToken)
86 })
87
88 it('Should fail with a bad count pagination', async function () {
89 await checkBadCountPagination(server.url, path, server.accessToken)
90 })
91
92 it('Should fail with an incorrect sort', async function () {
93 await checkBadSortPagination(server.url, path, server.accessToken)
94 })
95
96 it('Should fail with a non authenticated user', async function () {
97 await makeGetRequest({
98 url: server.url,
99 path,
100 statusCodeExpected: 401
101 })
102 })
103
104 it('Should success with the correct parameters', async function () {
105 await await makeGetRequest({
106 url: server.url,
107 path,
108 token: userAccessToken,
109 statusCodeExpected: 200
110 })
111 })
112 })
113
114 describe('When adding a subscription', function () {
115 it('Should fail with a non authenticated user', async function () {
116 await makePostBodyRequest({
117 url: server.url,
118 path,
119 fields: { uri: userChannelUUID + '@localhost:9001' },
120 statusCodeExpected: 401
121 })
122 })
123
124 it('Should fail with bad URIs', async function () {
125 await makePostBodyRequest({
126 url: server.url,
127 path,
128 token: server.accessToken,
129 fields: { uri: 'root' },
130 statusCodeExpected: 400
131 })
132
133 await makePostBodyRequest({
134 url: server.url,
135 path,
136 token: server.accessToken,
137 fields: { uri: 'root@' },
138 statusCodeExpected: 400
139 })
140
141 await makePostBodyRequest({
142 url: server.url,
143 path,
144 token: server.accessToken,
145 fields: { uri: 'root@hello@' },
146 statusCodeExpected: 400
147 })
148 })
149
150 it('Should success with the correct parameters', async function () {
151 await makePostBodyRequest({
152 url: server.url,
153 path,
154 token: server.accessToken,
155 fields: { uri: userChannelUUID + '@localhost:9001' },
156 statusCodeExpected: 204
157 })
158 })
159 })
160
161 describe('When removing a subscription', function () {
162 it('Should fail with a non authenticated user', async function () {
163 await makeDeleteRequest({
164 url: server.url,
165 path: path + '/' + userChannelUUID + '@localhost:9001',
166 statusCodeExpected: 401
167 })
168 })
169
170 it('Should fail with bad URIs', async function () {
171 await makeDeleteRequest({
172 url: server.url,
173 path: path + '/root',
174 token: server.accessToken,
175 statusCodeExpected: 400
176 })
177
178 await makeDeleteRequest({
179 url: server.url,
180 path: path + '/root@',
181 token: server.accessToken,
182 statusCodeExpected: 400
183 })
184
185 await makeDeleteRequest({
186 url: server.url,
187 path: path + '/root@hello@',
188 token: server.accessToken,
189 statusCodeExpected: 400
190 })
191 })
192
193 it('Should fail with an unknown subscription', async function () {
194 await makeDeleteRequest({
195 url: server.url,
196 path: path + '/root1@localhost:9001',
197 token: server.accessToken,
198 statusCodeExpected: 404
199 })
200 })
201
202 it('Should success with the correct parameters', async function () {
203 await makeDeleteRequest({
204 url: server.url,
205 path: path + '/' + userChannelUUID + '@localhost:9001',
206 token: server.accessToken,
207 statusCodeExpected: 204
208 })
209 })
210 })
211
212 after(async function () {
213 killallServers([ server ])
214
215 // Keep the logs if the test failed
216 if (this['ok']) {
217 await flushTests()
218 }
219 })
220})
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'
6import './server/jobs' 6import './server/jobs'
7import './videos/video-comments' 7import './videos/video-comments'
8import './users/users-multiple-servers' 8import './users/users-multiple-servers'
9import './users/user-subscriptions'
9import './server/handle-down' 10import './server/handle-down'
10import './videos/video-schedule-update' 11import './videos/video-schedule-update'
11import './videos/video-imports' 12import './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 @@
1/* tslint:disable:no-unused-expression */
2
3import * as chai from 'chai'
4import 'mocha'
5import { createUser, doubleFollow, flushAndRunMultipleServers, follow, getVideosList, unfollow, userLogin } from '../../utils'
6import { getMyUserInformation, killallServers, ServerInfo, uploadVideo } from '../../utils/index'
7import { setAccessTokensToServers } from '../../utils/users/login'
8import { Video, VideoChannel } from '../../../../shared/models/videos'
9import { waitJobs } from '../../utils/server/jobs'
10import {
11 addUserSubscription,
12 listUserSubscriptions,
13 listUserSubscriptionVideos,
14 removeUserSubscription
15} from '../../utils/users/user-subscriptions'
16
17const expect = chai.expect
18
19describe('Test users subscriptions', function () {
20 let servers: ServerInfo[] = []
21 const users: { accessToken: string, videoChannelName: string }[] = []
22 let rootChannelNameServer1: string
23
24 before(async function () {
25 this.timeout(120000)
26
27 servers = await flushAndRunMultipleServers(3)
28
29 // Get the access tokens
30 await setAccessTokensToServers(servers)
31
32 // Server 1 and server 2 follow each other
33 await doubleFollow(servers[0], servers[1])
34
35 const res = await getMyUserInformation(servers[0].url, servers[0].accessToken)
36 rootChannelNameServer1 = res.body.videoChannels[0].name
37
38 {
39 for (const server of servers) {
40 const user = { username: 'user' + server.serverNumber, password: 'password' }
41 await createUser(server.url, server.accessToken, user.username, user.password)
42
43 const accessToken = await userLogin(server, user)
44 const res = await getMyUserInformation(server.url, accessToken)
45 const videoChannels: VideoChannel[] = res.body.videoChannels
46
47 users.push({ accessToken, videoChannelName: videoChannels[0].name })
48
49 const videoName1 = 'video 1-' + server.serverNumber
50 await uploadVideo(server.url, accessToken, { name: videoName1 })
51
52 const videoName2 = 'video 2-' + server.serverNumber
53 await uploadVideo(server.url, accessToken, { name: videoName2 })
54 }
55 }
56
57 await waitJobs(servers)
58 })
59
60 it('Should display videos of server 2 on server 1', async function () {
61 const res = await getVideosList(servers[0].url)
62
63 expect(res.body.total).to.equal(4)
64 })
65
66 it('User of server 1 should follow user of server 3 and root of server 1', async function () {
67 this.timeout(30000)
68
69 await addUserSubscription(servers[0].url, users[0].accessToken, users[2].videoChannelName + '@localhost:9003')
70 await addUserSubscription(servers[0].url, users[0].accessToken, rootChannelNameServer1 + '@localhost:9001')
71
72 await waitJobs(servers)
73
74 await uploadVideo(servers[2].url, users[2].accessToken, { name: 'video server 3 added after follow' })
75
76 await waitJobs(servers)
77 })
78
79 it('Should not display videos of server 3 on server 1', async function () {
80 const res = await getVideosList(servers[0].url)
81
82 expect(res.body.total).to.equal(4)
83 for (const video of res.body.data) {
84 expect(video.name).to.not.contain('1-3')
85 expect(video.name).to.not.contain('2-3')
86 expect(video.name).to.not.contain('video server 3 added after follow')
87 }
88 })
89
90 it('Should list subscriptions', async function () {
91 {
92 const res = await listUserSubscriptions(servers[0].url, servers[0].accessToken)
93 expect(res.body.total).to.equal(0)
94 expect(res.body.data).to.be.an('array')
95 expect(res.body.data).to.have.lengthOf(0)
96 }
97
98 {
99 const res = await listUserSubscriptions(servers[0].url, users[0].accessToken)
100 expect(res.body.total).to.equal(2)
101
102 const subscriptions: VideoChannel[] = res.body.data
103 expect(subscriptions).to.be.an('array')
104 expect(subscriptions).to.have.lengthOf(2)
105
106 expect(subscriptions[0].name).to.equal(users[2].videoChannelName)
107 expect(subscriptions[1].name).to.equal(rootChannelNameServer1)
108 }
109 })
110
111 it('Should list subscription videos', async function () {
112 {
113 const res = await listUserSubscriptionVideos(servers[0].url, servers[0].accessToken)
114 expect(res.body.total).to.equal(0)
115 expect(res.body.data).to.be.an('array')
116 expect(res.body.data).to.have.lengthOf(0)
117 }
118
119 {
120 const res = await listUserSubscriptionVideos(servers[0].url, users[0].accessToken, 'createdAt')
121 expect(res.body.total).to.equal(3)
122
123 const videos: Video[] = res.body.data
124 expect(videos).to.be.an('array')
125 expect(videos).to.have.lengthOf(3)
126
127 expect(videos[0].name).to.equal('video 1-3')
128 expect(videos[1].name).to.equal('video 2-3')
129 expect(videos[2].name).to.equal('video server 3 added after follow')
130 }
131 })
132
133 it('Should upload a video by root on server 1 and see it in the subscription videos', async function () {
134 this.timeout(30000)
135
136 const videoName = 'video server 1 added after follow'
137 await uploadVideo(servers[0].url, servers[0].accessToken, { name: videoName })
138
139 await waitJobs(servers)
140
141 {
142 const res = await listUserSubscriptionVideos(servers[0].url, servers[0].accessToken)
143 expect(res.body.total).to.equal(0)
144 expect(res.body.data).to.be.an('array')
145 expect(res.body.data).to.have.lengthOf(0)
146 }
147
148 {
149 const res = await listUserSubscriptionVideos(servers[0].url, users[0].accessToken, 'createdAt')
150 expect(res.body.total).to.equal(4)
151
152 const videos: Video[] = res.body.data
153 expect(videos).to.be.an('array')
154 expect(videos).to.have.lengthOf(4)
155
156 expect(videos[0].name).to.equal('video 1-3')
157 expect(videos[1].name).to.equal('video 2-3')
158 expect(videos[2].name).to.equal('video server 3 added after follow')
159 expect(videos[3].name).to.equal('video server 1 added after follow')
160 }
161
162 {
163 const res = await getVideosList(servers[0].url)
164
165 expect(res.body.total).to.equal(5)
166 for (const video of res.body.data) {
167 expect(video.name).to.not.contain('1-3')
168 expect(video.name).to.not.contain('2-3')
169 expect(video.name).to.not.contain('video server 3 added after follow')
170 }
171 }
172 })
173
174 it('Should have server 1 follow server 3 and display server 3 videos', async function () {
175 this.timeout(30000)
176
177 await follow(servers[0].url, [ servers[2].url ], servers[0].accessToken)
178
179 await waitJobs(servers)
180
181 const res = await getVideosList(servers[0].url)
182
183 expect(res.body.total).to.equal(8)
184
185 const names = [ '1-3', '2-3', 'video server 3 added after follow' ]
186 for (const name of names) {
187 const video = res.body.data.find(v => v.name.indexOf(name) === -1)
188 expect(video).to.not.be.undefined
189 }
190 })
191
192 it('Should remove follow server 1 -> server 3 and hide server 3 videos', async function () {
193 this.timeout(30000)
194
195 await unfollow(servers[0].url, servers[0].accessToken, servers[2])
196
197 await waitJobs(servers)
198
199 const res = await getVideosList(servers[0].url)
200
201 expect(res.body.total).to.equal(5)
202 for (const video of res.body.data) {
203 expect(video.name).to.not.contain('1-3')
204 expect(video.name).to.not.contain('2-3')
205 expect(video.name).to.not.contain('video server 3 added after follow')
206 }
207 })
208
209 it('Should still list subscription videos', async function () {
210 {
211 const res = await listUserSubscriptionVideos(servers[0].url, servers[0].accessToken)
212 expect(res.body.total).to.equal(0)
213 expect(res.body.data).to.be.an('array')
214 expect(res.body.data).to.have.lengthOf(0)
215 }
216
217 {
218 const res = await listUserSubscriptionVideos(servers[0].url, users[0].accessToken, 'createdAt')
219 expect(res.body.total).to.equal(4)
220
221 const videos: Video[] = res.body.data
222 expect(videos).to.be.an('array')
223 expect(videos).to.have.lengthOf(4)
224
225 expect(videos[0].name).to.equal('video 1-3')
226 expect(videos[1].name).to.equal('video 2-3')
227 expect(videos[2].name).to.equal('video server 3 added after follow')
228 expect(videos[3].name).to.equal('video server 1 added after follow')
229 }
230 })
231
232 it('Should remove user of server 3 subscription', async function () {
233 await removeUserSubscription(servers[0].url, users[0].accessToken, users[2].videoChannelName + '@localhost:9003')
234
235 await waitJobs(servers)
236 })
237
238 it('Should not display its videos anymore', async function () {
239 {
240 const res = await listUserSubscriptionVideos(servers[0].url, users[0].accessToken, 'createdAt')
241 expect(res.body.total).to.equal(1)
242
243 const videos: Video[] = res.body.data
244 expect(videos).to.be.an('array')
245 expect(videos).to.have.lengthOf(1)
246
247 expect(videos[0].name).to.equal('video server 1 added after follow')
248 }
249 })
250
251 it('Should remove the root subscription and not display the videos anymore', async function () {
252 await removeUserSubscription(servers[0].url, users[0].accessToken, rootChannelNameServer1 + '@localhost:9001')
253
254 await waitJobs(servers)
255
256 {
257 const res = await listUserSubscriptionVideos(servers[0].url, users[0].accessToken, 'createdAt')
258 expect(res.body.total).to.equal(0)
259
260 const videos: Video[] = res.body.data
261 expect(videos).to.be.an('array')
262 expect(videos).to.have.lengthOf(0)
263 }
264 })
265
266 it('Should correctly display public videos on server 1', async function () {
267 const res = await getVideosList(servers[0].url)
268
269 expect(res.body.total).to.equal(5)
270 for (const video of res.body.data) {
271 expect(video.name).to.not.contain('1-3')
272 expect(video.name).to.not.contain('2-3')
273 expect(video.name).to.not.contain('video server 3 added after follow')
274 }
275 })
276
277 it('Should follow user of server 3 again', async function () {
278 this.timeout(30000)
279
280 await addUserSubscription(servers[0].url, users[0].accessToken, users[2].videoChannelName + '@localhost:9003')
281
282 await waitJobs(servers)
283
284 {
285 const res = await listUserSubscriptionVideos(servers[0].url, users[0].accessToken, 'createdAt')
286 expect(res.body.total).to.equal(3)
287
288 const videos: Video[] = res.body.data
289 expect(videos).to.be.an('array')
290 expect(videos).to.have.lengthOf(3)
291
292 expect(videos[0].name).to.equal('video 1-3')
293 expect(videos[1].name).to.equal('video 2-3')
294 expect(videos[2].name).to.equal('video server 3 added after follow')
295 }
296
297 {
298 const res = await getVideosList(servers[0].url)
299
300 expect(res.body.total).to.equal(5)
301 for (const video of res.body.data) {
302 expect(video.name).to.not.contain('1-3')
303 expect(video.name).to.not.contain('2-3')
304 expect(video.name).to.not.contain('video server 3 added after follow')
305 }
306 }
307 })
308
309 after(async function () {
310 killallServers(servers)
311 })
312})
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 @@
1import { makeDeleteRequest, makeGetRequest, makePostBodyRequest } from '../'
2
3function addUserSubscription (url: string, token: string, targetUri: string, statusCodeExpected = 204) {
4 const path = '/api/v1/users/me/subscriptions'
5
6 return makePostBodyRequest({
7 url,
8 path,
9 token,
10 statusCodeExpected,
11 fields: { uri: targetUri }
12 })
13}
14
15function listUserSubscriptions (url: string, token: string, sort = '-createdAt', statusCodeExpected = 200) {
16 const path = '/api/v1/users/me/subscriptions'
17
18 return makeGetRequest({
19 url,
20 path,
21 token,
22 statusCodeExpected,
23 query: { sort }
24 })
25}
26
27function listUserSubscriptionVideos (url: string, token: string, sort = '-createdAt', statusCodeExpected = 200) {
28 const path = '/api/v1/users/me/subscriptions/videos'
29
30 return makeGetRequest({
31 url,
32 path,
33 token,
34 statusCodeExpected,
35 query: { sort }
36 })
37}
38
39function removeUserSubscription (url: string, token: string, uri: string, statusCodeExpected = 204) {
40 const path = '/api/v1/users/me/subscriptions/' + uri
41
42 return makeDeleteRequest({
43 url,
44 path,
45 token,
46 statusCodeExpected
47 })
48}
49
50// ---------------------------------------------------------------------------
51
52export {
53 addUserSubscription,
54 listUserSubscriptions,
55 listUserSubscriptionVideos,
56 removeUserSubscription
57}