aboutsummaryrefslogtreecommitdiffhomepage
path: root/server
diff options
context:
space:
mode:
Diffstat (limited to 'server')
-rw-r--r--server/controllers/api/users/token.ts31
-rw-r--r--server/controllers/feeds.ts32
-rw-r--r--server/helpers/middlewares/accounts.ts20
-rw-r--r--server/initializers/migrations/0530-user-feed-token.ts40
-rw-r--r--server/middlewares/validators/feeds.ts21
-rw-r--r--server/models/account/user.ts9
-rw-r--r--server/tests/feeds/feeds.ts91
7 files changed, 235 insertions, 9 deletions
diff --git a/server/controllers/api/users/token.ts b/server/controllers/api/users/token.ts
index 41aa26769..821429358 100644
--- a/server/controllers/api/users/token.ts
+++ b/server/controllers/api/users/token.ts
@@ -4,6 +4,8 @@ import { CONFIG } from '@server/initializers/config'
4import * as express from 'express' 4import * as express from 'express'
5import { Hooks } from '@server/lib/plugins/hooks' 5import { Hooks } from '@server/lib/plugins/hooks'
6import { asyncMiddleware, authenticate } from '@server/middlewares' 6import { asyncMiddleware, authenticate } from '@server/middlewares'
7import { ScopedToken } from '@shared/models/users/user-scoped-token'
8import { v4 as uuidv4 } from 'uuid'
7 9
8const tokensRouter = express.Router() 10const tokensRouter = express.Router()
9 11
@@ -23,6 +25,16 @@ tokensRouter.post('/revoke-token',
23 asyncMiddleware(handleTokenRevocation) 25 asyncMiddleware(handleTokenRevocation)
24) 26)
25 27
28tokensRouter.get('/scoped-tokens',
29 authenticate,
30 getScopedTokens
31)
32
33tokensRouter.post('/scoped-tokens',
34 authenticate,
35 asyncMiddleware(renewScopedTokens)
36)
37
26// --------------------------------------------------------------------------- 38// ---------------------------------------------------------------------------
27 39
28export { 40export {
@@ -35,3 +47,22 @@ function tokenSuccess (req: express.Request) {
35 47
36 Hooks.runAction('action:api.user.oauth2-got-token', { username, ip: req.ip }) 48 Hooks.runAction('action:api.user.oauth2-got-token', { username, ip: req.ip })
37} 49}
50
51function getScopedTokens (req: express.Request, res: express.Response) {
52 const user = res.locals.oauth.token.user
53
54 return res.json({
55 feedToken: user.feedToken
56 } as ScopedToken)
57}
58
59async function renewScopedTokens (req: express.Request, res: express.Response) {
60 const user = res.locals.oauth.token.user
61
62 user.feedToken = uuidv4()
63 await user.save()
64
65 return res.json({
66 feedToken: user.feedToken
67 } as ScopedToken)
68}
diff --git a/server/controllers/feeds.ts b/server/controllers/feeds.ts
index f14c0d316..6e9f7e60c 100644
--- a/server/controllers/feeds.ts
+++ b/server/controllers/feeds.ts
@@ -11,11 +11,14 @@ import {
11 setFeedFormatContentType, 11 setFeedFormatContentType,
12 videoCommentsFeedsValidator, 12 videoCommentsFeedsValidator,
13 videoFeedsValidator, 13 videoFeedsValidator,
14 videosSortValidator 14 videosSortValidator,
15 videoSubscriptonFeedsValidator
15} from '../middlewares' 16} from '../middlewares'
16import { cacheRoute } from '../middlewares/cache' 17import { cacheRoute } from '../middlewares/cache'
17import { VideoModel } from '../models/video/video' 18import { VideoModel } from '../models/video/video'
18import { VideoCommentModel } from '../models/video/video-comment' 19import { VideoCommentModel } from '../models/video/video-comment'
20import { VideoFilter } from '../../shared/models/videos/video-query.type'
21import { logger } from '../helpers/logger'
19 22
20const feedsRouter = express.Router() 23const feedsRouter = express.Router()
21 24
@@ -44,6 +47,7 @@ feedsRouter.get('/feeds/videos.:format',
44 })(ROUTE_CACHE_LIFETIME.FEEDS)), 47 })(ROUTE_CACHE_LIFETIME.FEEDS)),
45 commonVideosFiltersValidator, 48 commonVideosFiltersValidator,
46 asyncMiddleware(videoFeedsValidator), 49 asyncMiddleware(videoFeedsValidator),
50 asyncMiddleware(videoSubscriptonFeedsValidator),
47 asyncMiddleware(generateVideoFeed) 51 asyncMiddleware(generateVideoFeed)
48) 52)
49 53
@@ -124,6 +128,7 @@ async function generateVideoFeed (req: express.Request, res: express.Response) {
124 128
125 const account = res.locals.account 129 const account = res.locals.account
126 const videoChannel = res.locals.videoChannel 130 const videoChannel = res.locals.videoChannel
131 const token = req.query.token
127 const nsfw = buildNSFWFilter(res, req.query.nsfw) 132 const nsfw = buildNSFWFilter(res, req.query.nsfw)
128 133
129 let name: string 134 let name: string
@@ -147,19 +152,36 @@ async function generateVideoFeed (req: express.Request, res: express.Response) {
147 queryString: new URL(WEBSERVER.URL + req.url).search 152 queryString: new URL(WEBSERVER.URL + req.url).search
148 }) 153 })
149 154
155 /**
156 * We have two ways to query video results:
157 * - one with account and token -> get subscription videos
158 * - one with either account, channel, or nothing: just videos with these filters
159 */
160 const options = token && token !== '' && res.locals.user
161 ? {
162 followerActorId: res.locals.user.Account.Actor.id,
163 user: res.locals.user,
164 includeLocalVideos: false
165 }
166 : {
167 accountId: account ? account.id : null,
168 videoChannelId: videoChannel ? videoChannel.id : null
169 }
170
150 const resultList = await VideoModel.listForApi({ 171 const resultList = await VideoModel.listForApi({
151 start, 172 start,
152 count: FEEDS.COUNT, 173 count: FEEDS.COUNT,
153 sort: req.query.sort, 174 sort: req.query.sort,
154 includeLocalVideos: true, 175 includeLocalVideos: true,
155 nsfw, 176 nsfw,
156 filter: req.query.filter, 177 filter: req.query.filter as VideoFilter,
157 withFiles: true, 178 withFiles: true,
158 accountId: account ? account.id : null, 179 ...options
159 videoChannelId: videoChannel ? videoChannel.id : null
160 }) 180 })
161 181
162 // Adding video items to the feed, one at a time 182 /**
183 * Adding video items to the feed object, one at a time
184 */
163 resultList.data.forEach(video => { 185 resultList.data.forEach(video => {
164 const formattedVideoFiles = video.getFormattedVideoFilesJSON() 186 const formattedVideoFiles = video.getFormattedVideoFilesJSON()
165 187
diff --git a/server/helpers/middlewares/accounts.ts b/server/helpers/middlewares/accounts.ts
index 29b4ed1a6..9be80167c 100644
--- a/server/helpers/middlewares/accounts.ts
+++ b/server/helpers/middlewares/accounts.ts
@@ -2,6 +2,7 @@ import { Response } from 'express'
2import { AccountModel } from '../../models/account/account' 2import { AccountModel } from '../../models/account/account'
3import * as Bluebird from 'bluebird' 3import * as Bluebird from 'bluebird'
4import { MAccountDefault } from '../../types/models' 4import { MAccountDefault } from '../../types/models'
5import { UserModel } from '@server/models/account/user'
5 6
6function doesAccountIdExist (id: number | string, res: Response, sendNotFound = true) { 7function doesAccountIdExist (id: number | string, res: Response, sendNotFound = true) {
7 const promise = AccountModel.load(parseInt(id + '', 10)) 8 const promise = AccountModel.load(parseInt(id + '', 10))
@@ -39,11 +40,28 @@ async function doesAccountExist (p: Bluebird<MAccountDefault>, res: Response, se
39 return true 40 return true
40} 41}
41 42
43async function doesUserFeedTokenCorrespond (id: number | string, token: string, res: Response) {
44 const user = await UserModel.loadById(parseInt(id + '', 10))
45
46 if (token !== user.feedToken) {
47 res.status(401)
48 .send({ error: 'User and token mismatch' })
49 .end()
50
51 return false
52 }
53
54 res.locals.user = user
55
56 return true
57}
58
42// --------------------------------------------------------------------------- 59// ---------------------------------------------------------------------------
43 60
44export { 61export {
45 doesAccountIdExist, 62 doesAccountIdExist,
46 doesLocalAccountNameExist, 63 doesLocalAccountNameExist,
47 doesAccountNameWithHostExist, 64 doesAccountNameWithHostExist,
48 doesAccountExist 65 doesAccountExist,
66 doesUserFeedTokenCorrespond
49} 67}
diff --git a/server/initializers/migrations/0530-user-feed-token.ts b/server/initializers/migrations/0530-user-feed-token.ts
new file mode 100644
index 000000000..421016b11
--- /dev/null
+++ b/server/initializers/migrations/0530-user-feed-token.ts
@@ -0,0 +1,40 @@
1import * as Sequelize from 'sequelize'
2import { v4 as uuidv4 } from 'uuid'
3
4async function up (utils: {
5 transaction: Sequelize.Transaction
6 queryInterface: Sequelize.QueryInterface
7 sequelize: Sequelize.Sequelize
8 db: any
9}): Promise<void> {
10 const q = utils.queryInterface
11
12 // Create uuid column for users
13 const userFeedTokenUUID = {
14 type: Sequelize.UUID,
15 defaultValue: Sequelize.UUIDV4,
16 allowNull: true
17 }
18 await q.addColumn('user', 'feedToken', userFeedTokenUUID)
19
20 // Set UUID to previous users
21 {
22 const query = 'SELECT * FROM "user" WHERE "feedToken" IS NULL'
23 const options = { type: Sequelize.QueryTypes.SELECT as Sequelize.QueryTypes.SELECT }
24 const users = await utils.sequelize.query<any>(query, options)
25
26 for (const user of users) {
27 const queryUpdate = `UPDATE "user" SET "feedToken" = '${uuidv4()}' WHERE id = ${user.id}`
28 await utils.sequelize.query(queryUpdate)
29 }
30 }
31}
32
33function down (options) {
34 throw new Error('Not implemented.')
35}
36
37export {
38 up,
39 down
40}
diff --git a/server/middlewares/validators/feeds.ts b/server/middlewares/validators/feeds.ts
index c3de0f5fe..5c76a679f 100644
--- a/server/middlewares/validators/feeds.ts
+++ b/server/middlewares/validators/feeds.ts
@@ -9,7 +9,8 @@ import {
9 doesAccountIdExist, 9 doesAccountIdExist,
10 doesAccountNameWithHostExist, 10 doesAccountNameWithHostExist,
11 doesVideoChannelIdExist, 11 doesVideoChannelIdExist,
12 doesVideoChannelNameWithHostExist 12 doesVideoChannelNameWithHostExist,
13 doesUserFeedTokenCorrespond
13} from '../../helpers/middlewares' 14} from '../../helpers/middlewares'
14 15
15const feedsFormatValidator = [ 16const feedsFormatValidator = [
@@ -62,6 +63,23 @@ const videoFeedsValidator = [
62 } 63 }
63] 64]
64 65
66const videoSubscriptonFeedsValidator = [
67 query('accountId').optional().custom(isIdValid),
68 query('token').optional(),
69
70 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
71 logger.debug('Checking feeds parameters', { parameters: req.query })
72
73 if (areValidationErrors(req, res)) return
74
75 // a token alone is erroneous
76 if (req.query.token && !req.query.accountId) return
77 if (req.query.token && !await doesUserFeedTokenCorrespond(res.locals.account.userId, req.query.token, res)) return
78
79 return next()
80 }
81]
82
65const videoCommentsFeedsValidator = [ 83const videoCommentsFeedsValidator = [
66 query('videoId').optional().custom(isIdOrUUIDValid), 84 query('videoId').optional().custom(isIdOrUUIDValid),
67 85
@@ -88,5 +106,6 @@ export {
88 feedsFormatValidator, 106 feedsFormatValidator,
89 setFeedFormatContentType, 107 setFeedFormatContentType,
90 videoFeedsValidator, 108 videoFeedsValidator,
109 videoSubscriptonFeedsValidator,
91 videoCommentsFeedsValidator 110 videoCommentsFeedsValidator
92} 111}
diff --git a/server/models/account/user.ts b/server/models/account/user.ts
index 2aa6469fb..10117099b 100644
--- a/server/models/account/user.ts
+++ b/server/models/account/user.ts
@@ -19,7 +19,8 @@ import {
19 Model, 19 Model,
20 Scopes, 20 Scopes,
21 Table, 21 Table,
22 UpdatedAt 22 UpdatedAt,
23 IsUUID
23} from 'sequelize-typescript' 24} from 'sequelize-typescript'
24import { 25import {
25 MMyUserFormattable, 26 MMyUserFormattable,
@@ -353,6 +354,12 @@ export class UserModel extends Model<UserModel> {
353 @Column 354 @Column
354 pluginAuth: string 355 pluginAuth: string
355 356
357 @AllowNull(false)
358 @Default(DataType.UUIDV4)
359 @IsUUID(4)
360 @Column(DataType.UUID)
361 feedToken: string
362
356 @AllowNull(true) 363 @AllowNull(true)
357 @Default(null) 364 @Default(null)
358 @Column 365 @Column
diff --git a/server/tests/feeds/feeds.ts b/server/tests/feeds/feeds.ts
index 0ff690f34..2cd9b2d0a 100644
--- a/server/tests/feeds/feeds.ts
+++ b/server/tests/feeds/feeds.ts
@@ -22,11 +22,14 @@ import {
22 uploadVideo, 22 uploadVideo,
23 uploadVideoAndGetId, 23 uploadVideoAndGetId,
24 userLogin, 24 userLogin,
25 flushAndRunServer 25 flushAndRunServer,
26 getUserScopedTokens
26} from '../../../shared/extra-utils' 27} from '../../../shared/extra-utils'
27import { waitJobs } from '../../../shared/extra-utils/server/jobs' 28import { waitJobs } from '../../../shared/extra-utils/server/jobs'
28import { addVideoCommentThread } from '../../../shared/extra-utils/videos/video-comments' 29import { addVideoCommentThread } from '../../../shared/extra-utils/videos/video-comments'
29import { User } from '../../../shared/models/users' 30import { User } from '../../../shared/models/users'
31import { ScopedToken } from '@shared/models/users/user-scoped-token'
32import { listUserSubscriptionVideos, addUserSubscription } from '@shared/extra-utils/users/user-subscriptions'
30 33
31chai.use(require('chai-xml')) 34chai.use(require('chai-xml'))
32chai.use(require('chai-json-schema')) 35chai.use(require('chai-json-schema'))
@@ -41,6 +44,7 @@ describe('Test syndication feeds', () => {
41 let rootChannelId: number 44 let rootChannelId: number
42 let userAccountId: number 45 let userAccountId: number
43 let userChannelId: number 46 let userChannelId: number
47 let userFeedToken: string
44 48
45 before(async function () { 49 before(async function () {
46 this.timeout(120000) 50 this.timeout(120000)
@@ -74,6 +78,10 @@ describe('Test syndication feeds', () => {
74 const user: User = res.body 78 const user: User = res.body
75 userAccountId = user.account.id 79 userAccountId = user.account.id
76 userChannelId = user.videoChannels[0].id 80 userChannelId = user.videoChannels[0].id
81
82 const res2 = await getUserScopedTokens(servers[0].url, userAccessToken)
83 const token: ScopedToken = res2.body
84 userFeedToken = token.feedToken
77 } 85 }
78 86
79 { 87 {
@@ -289,6 +297,87 @@ describe('Test syndication feeds', () => {
289 }) 297 })
290 }) 298 })
291 299
300 describe('Video feed from my subscriptions', function () {
301 /**
302 * use the 'version' query parameter to bust cache between tests
303 */
304
305 it('Should list no videos for a user with no videos and no subscriptions', async function () {
306 let feeduserAccountId: number
307 let feeduserFeedToken: string
308
309 const attr = { username: 'feeduser', password: 'password' }
310 await createUser({ url: servers[0].url, accessToken: servers[0].accessToken, username: attr.username, password: attr.password })
311 const feeduserAccessToken = await userLogin(servers[0], attr)
312
313 {
314 const res = await getMyUserInformation(servers[0].url, feeduserAccessToken)
315 const user: User = res.body
316 feeduserAccountId = user.account.id
317 }
318
319 {
320 const res = await getUserScopedTokens(servers[0].url, feeduserAccessToken)
321 const token: ScopedToken = res.body
322 feeduserFeedToken = token.feedToken
323 }
324
325 {
326 const res = await listUserSubscriptionVideos(servers[0].url, feeduserAccessToken)
327 expect(res.body.total).to.equal(0)
328
329 const json = await getJSONfeed(servers[0].url, 'videos', { accountId: feeduserAccountId, token: feeduserFeedToken })
330 const jsonObj = JSON.parse(json.text)
331 expect(jsonObj.items.length).to.be.equal(0) // no subscription, it should not list the instance's videos but list 0 videos
332 }
333 })
334
335 it('Should list no videos for a user with videos but no subscriptions', async function () {
336 {
337 const res = await listUserSubscriptionVideos(servers[0].url, userAccessToken)
338 expect(res.body.total).to.equal(0)
339
340 const json = await getJSONfeed(servers[0].url, 'videos', { accountId: userAccountId, token: userFeedToken })
341 const jsonObj = JSON.parse(json.text)
342 expect(jsonObj.items.length).to.be.equal(0) // no subscription, it should not list the instance's videos but list 0 videos
343 }
344 })
345
346 it('Should list self videos for a user with a subscription to themselves', async function () {
347 this.timeout(30000)
348
349 await addUserSubscription(servers[0].url, userAccessToken, 'john_channel@localhost:' + servers[0].port)
350 await waitJobs(servers)
351
352 {
353 const res = await listUserSubscriptionVideos(servers[0].url, userAccessToken)
354 expect(res.body.total).to.equal(1)
355 expect(res.body.data[0].name).to.equal('user video')
356
357 const json = await getJSONfeed(servers[0].url, 'videos', { accountId: userAccountId, token: userFeedToken, version: 1 })
358 const jsonObj = JSON.parse(json.text)
359 expect(jsonObj.items.length).to.be.equal(1) // subscribed to self, it should not list the instance's videos but list john's
360 }
361 })
362
363 it('Should list videos of a user\'s subscription', async function () {
364 this.timeout(30000)
365
366 await addUserSubscription(servers[0].url, userAccessToken, 'root_channel@localhost:' + servers[0].port)
367 await waitJobs(servers)
368
369 {
370 const res = await listUserSubscriptionVideos(servers[0].url, userAccessToken)
371 expect(res.body.total).to.equal(2, "there should be 2 videos part of the subscription")
372
373 const json = await getJSONfeed(servers[0].url, 'videos', { accountId: userAccountId, token: userFeedToken, version: 2 })
374 const jsonObj = JSON.parse(json.text)
375 expect(jsonObj.items.length).to.be.equal(2) // subscribed to root, it should not list the instance's videos but list root/john's
376 }
377 })
378
379 })
380
292 after(async function () { 381 after(async function () {
293 await cleanupTests([ ...servers, serverHLSOnly ]) 382 await cleanupTests([ ...servers, serverHLSOnly ])
294 }) 383 })