diff options
author | Rigel Kent <sendmemail@rigelk.eu> | 2020-08-13 15:07:23 +0200 |
---|---|---|
committer | Chocobozzz <chocobozzz@cpy.re> | 2020-11-25 11:07:56 +0100 |
commit | afff310e50f2fa8419bb4242470cbde46ab54463 (patch) | |
tree | 34efda2daf8f7cdfd89ef6616a79e2222082f93a /server | |
parent | f619de0e435f7ac3abad2ec772397486358b56e7 (diff) | |
download | PeerTube-afff310e50f2fa8419bb4242470cbde46ab54463.tar.gz PeerTube-afff310e50f2fa8419bb4242470cbde46ab54463.tar.zst PeerTube-afff310e50f2fa8419bb4242470cbde46ab54463.zip |
allow private syndication feeds via a user feedToken
Diffstat (limited to 'server')
-rw-r--r-- | server/controllers/api/users/token.ts | 31 | ||||
-rw-r--r-- | server/controllers/feeds.ts | 32 | ||||
-rw-r--r-- | server/helpers/middlewares/accounts.ts | 20 | ||||
-rw-r--r-- | server/initializers/migrations/0530-user-feed-token.ts | 40 | ||||
-rw-r--r-- | server/middlewares/validators/feeds.ts | 21 | ||||
-rw-r--r-- | server/models/account/user.ts | 9 | ||||
-rw-r--r-- | server/tests/feeds/feeds.ts | 91 |
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' | |||
4 | import * as express from 'express' | 4 | import * as express from 'express' |
5 | import { Hooks } from '@server/lib/plugins/hooks' | 5 | import { Hooks } from '@server/lib/plugins/hooks' |
6 | import { asyncMiddleware, authenticate } from '@server/middlewares' | 6 | import { asyncMiddleware, authenticate } from '@server/middlewares' |
7 | import { ScopedToken } from '@shared/models/users/user-scoped-token' | ||
8 | import { v4 as uuidv4 } from 'uuid' | ||
7 | 9 | ||
8 | const tokensRouter = express.Router() | 10 | const tokensRouter = express.Router() |
9 | 11 | ||
@@ -23,6 +25,16 @@ tokensRouter.post('/revoke-token', | |||
23 | asyncMiddleware(handleTokenRevocation) | 25 | asyncMiddleware(handleTokenRevocation) |
24 | ) | 26 | ) |
25 | 27 | ||
28 | tokensRouter.get('/scoped-tokens', | ||
29 | authenticate, | ||
30 | getScopedTokens | ||
31 | ) | ||
32 | |||
33 | tokensRouter.post('/scoped-tokens', | ||
34 | authenticate, | ||
35 | asyncMiddleware(renewScopedTokens) | ||
36 | ) | ||
37 | |||
26 | // --------------------------------------------------------------------------- | 38 | // --------------------------------------------------------------------------- |
27 | 39 | ||
28 | export { | 40 | export { |
@@ -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 | |||
51 | function 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 | |||
59 | async 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' |
16 | import { cacheRoute } from '../middlewares/cache' | 17 | import { cacheRoute } from '../middlewares/cache' |
17 | import { VideoModel } from '../models/video/video' | 18 | import { VideoModel } from '../models/video/video' |
18 | import { VideoCommentModel } from '../models/video/video-comment' | 19 | import { VideoCommentModel } from '../models/video/video-comment' |
20 | import { VideoFilter } from '../../shared/models/videos/video-query.type' | ||
21 | import { logger } from '../helpers/logger' | ||
19 | 22 | ||
20 | const feedsRouter = express.Router() | 23 | const 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' | |||
2 | import { AccountModel } from '../../models/account/account' | 2 | import { AccountModel } from '../../models/account/account' |
3 | import * as Bluebird from 'bluebird' | 3 | import * as Bluebird from 'bluebird' |
4 | import { MAccountDefault } from '../../types/models' | 4 | import { MAccountDefault } from '../../types/models' |
5 | import { UserModel } from '@server/models/account/user' | ||
5 | 6 | ||
6 | function doesAccountIdExist (id: number | string, res: Response, sendNotFound = true) { | 7 | function 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 | ||
43 | async 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 | ||
44 | export { | 61 | export { |
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 @@ | |||
1 | import * as Sequelize from 'sequelize' | ||
2 | import { v4 as uuidv4 } from 'uuid' | ||
3 | |||
4 | async 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 | |||
33 | function down (options) { | ||
34 | throw new Error('Not implemented.') | ||
35 | } | ||
36 | |||
37 | export { | ||
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 | ||
15 | const feedsFormatValidator = [ | 16 | const feedsFormatValidator = [ |
@@ -62,6 +63,23 @@ const videoFeedsValidator = [ | |||
62 | } | 63 | } |
63 | ] | 64 | ] |
64 | 65 | ||
66 | const 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 | |||
65 | const videoCommentsFeedsValidator = [ | 83 | const 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' |
24 | import { | 25 | import { |
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' |
27 | import { waitJobs } from '../../../shared/extra-utils/server/jobs' | 28 | import { waitJobs } from '../../../shared/extra-utils/server/jobs' |
28 | import { addVideoCommentThread } from '../../../shared/extra-utils/videos/video-comments' | 29 | import { addVideoCommentThread } from '../../../shared/extra-utils/videos/video-comments' |
29 | import { User } from '../../../shared/models/users' | 30 | import { User } from '../../../shared/models/users' |
31 | import { ScopedToken } from '@shared/models/users/user-scoped-token' | ||
32 | import { listUserSubscriptionVideos, addUserSubscription } from '@shared/extra-utils/users/user-subscriptions' | ||
30 | 33 | ||
31 | chai.use(require('chai-xml')) | 34 | chai.use(require('chai-xml')) |
32 | chai.use(require('chai-json-schema')) | 35 | chai.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 | }) |