From afff310e50f2fa8419bb4242470cbde46ab54463 Mon Sep 17 00:00:00 2001 From: Rigel Kent Date: Thu, 13 Aug 2020 15:07:23 +0200 Subject: allow private syndication feeds via a user feedToken --- server/controllers/api/users/token.ts | 31 ++++++++ server/controllers/feeds.ts | 32 ++++++-- server/helpers/middlewares/accounts.ts | 20 ++++- .../migrations/0530-user-feed-token.ts | 40 ++++++++++ server/middlewares/validators/feeds.ts | 21 ++++- server/models/account/user.ts | 9 ++- server/tests/feeds/feeds.ts | 91 +++++++++++++++++++++- 7 files changed, 235 insertions(+), 9 deletions(-) create mode 100644 server/initializers/migrations/0530-user-feed-token.ts (limited to 'server') 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' import * as express from 'express' import { Hooks } from '@server/lib/plugins/hooks' import { asyncMiddleware, authenticate } from '@server/middlewares' +import { ScopedToken } from '@shared/models/users/user-scoped-token' +import { v4 as uuidv4 } from 'uuid' const tokensRouter = express.Router() @@ -23,6 +25,16 @@ tokensRouter.post('/revoke-token', asyncMiddleware(handleTokenRevocation) ) +tokensRouter.get('/scoped-tokens', + authenticate, + getScopedTokens +) + +tokensRouter.post('/scoped-tokens', + authenticate, + asyncMiddleware(renewScopedTokens) +) + // --------------------------------------------------------------------------- export { @@ -35,3 +47,22 @@ function tokenSuccess (req: express.Request) { Hooks.runAction('action:api.user.oauth2-got-token', { username, ip: req.ip }) } + +function getScopedTokens (req: express.Request, res: express.Response) { + const user = res.locals.oauth.token.user + + return res.json({ + feedToken: user.feedToken + } as ScopedToken) +} + +async function renewScopedTokens (req: express.Request, res: express.Response) { + const user = res.locals.oauth.token.user + + user.feedToken = uuidv4() + await user.save() + + return res.json({ + feedToken: user.feedToken + } as ScopedToken) +} 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 { setFeedFormatContentType, videoCommentsFeedsValidator, videoFeedsValidator, - videosSortValidator + videosSortValidator, + videoSubscriptonFeedsValidator } from '../middlewares' import { cacheRoute } from '../middlewares/cache' import { VideoModel } from '../models/video/video' import { VideoCommentModel } from '../models/video/video-comment' +import { VideoFilter } from '../../shared/models/videos/video-query.type' +import { logger } from '../helpers/logger' const feedsRouter = express.Router() @@ -44,6 +47,7 @@ feedsRouter.get('/feeds/videos.:format', })(ROUTE_CACHE_LIFETIME.FEEDS)), commonVideosFiltersValidator, asyncMiddleware(videoFeedsValidator), + asyncMiddleware(videoSubscriptonFeedsValidator), asyncMiddleware(generateVideoFeed) ) @@ -124,6 +128,7 @@ async function generateVideoFeed (req: express.Request, res: express.Response) { const account = res.locals.account const videoChannel = res.locals.videoChannel + const token = req.query.token const nsfw = buildNSFWFilter(res, req.query.nsfw) let name: string @@ -147,19 +152,36 @@ async function generateVideoFeed (req: express.Request, res: express.Response) { queryString: new URL(WEBSERVER.URL + req.url).search }) + /** + * We have two ways to query video results: + * - one with account and token -> get subscription videos + * - one with either account, channel, or nothing: just videos with these filters + */ + const options = token && token !== '' && res.locals.user + ? { + followerActorId: res.locals.user.Account.Actor.id, + user: res.locals.user, + includeLocalVideos: false + } + : { + accountId: account ? account.id : null, + videoChannelId: videoChannel ? videoChannel.id : null + } + const resultList = await VideoModel.listForApi({ start, count: FEEDS.COUNT, sort: req.query.sort, includeLocalVideos: true, nsfw, - filter: req.query.filter, + filter: req.query.filter as VideoFilter, withFiles: true, - accountId: account ? account.id : null, - videoChannelId: videoChannel ? videoChannel.id : null + ...options }) - // Adding video items to the feed, one at a time + /** + * Adding video items to the feed object, one at a time + */ resultList.data.forEach(video => { const formattedVideoFiles = video.getFormattedVideoFilesJSON() 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' import { AccountModel } from '../../models/account/account' import * as Bluebird from 'bluebird' import { MAccountDefault } from '../../types/models' +import { UserModel } from '@server/models/account/user' function doesAccountIdExist (id: number | string, res: Response, sendNotFound = true) { const promise = AccountModel.load(parseInt(id + '', 10)) @@ -39,11 +40,28 @@ async function doesAccountExist (p: Bluebird, res: Response, se return true } +async function doesUserFeedTokenCorrespond (id: number | string, token: string, res: Response) { + const user = await UserModel.loadById(parseInt(id + '', 10)) + + if (token !== user.feedToken) { + res.status(401) + .send({ error: 'User and token mismatch' }) + .end() + + return false + } + + res.locals.user = user + + return true +} + // --------------------------------------------------------------------------- export { doesAccountIdExist, doesLocalAccountNameExist, doesAccountNameWithHostExist, - doesAccountExist + doesAccountExist, + doesUserFeedTokenCorrespond } 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 @@ +import * as Sequelize from 'sequelize' +import { v4 as uuidv4 } from 'uuid' + +async function up (utils: { + transaction: Sequelize.Transaction + queryInterface: Sequelize.QueryInterface + sequelize: Sequelize.Sequelize + db: any +}): Promise { + const q = utils.queryInterface + + // Create uuid column for users + const userFeedTokenUUID = { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + allowNull: true + } + await q.addColumn('user', 'feedToken', userFeedTokenUUID) + + // Set UUID to previous users + { + const query = 'SELECT * FROM "user" WHERE "feedToken" IS NULL' + const options = { type: Sequelize.QueryTypes.SELECT as Sequelize.QueryTypes.SELECT } + const users = await utils.sequelize.query(query, options) + + for (const user of users) { + const queryUpdate = `UPDATE "user" SET "feedToken" = '${uuidv4()}' WHERE id = ${user.id}` + await utils.sequelize.query(queryUpdate) + } + } +} + +function down (options) { + throw new Error('Not implemented.') +} + +export { + up, + down +} 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 { doesAccountIdExist, doesAccountNameWithHostExist, doesVideoChannelIdExist, - doesVideoChannelNameWithHostExist + doesVideoChannelNameWithHostExist, + doesUserFeedTokenCorrespond } from '../../helpers/middlewares' const feedsFormatValidator = [ @@ -62,6 +63,23 @@ const videoFeedsValidator = [ } ] +const videoSubscriptonFeedsValidator = [ + query('accountId').optional().custom(isIdValid), + query('token').optional(), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking feeds parameters', { parameters: req.query }) + + if (areValidationErrors(req, res)) return + + // a token alone is erroneous + if (req.query.token && !req.query.accountId) return + if (req.query.token && !await doesUserFeedTokenCorrespond(res.locals.account.userId, req.query.token, res)) return + + return next() + } +] + const videoCommentsFeedsValidator = [ query('videoId').optional().custom(isIdOrUUIDValid), @@ -88,5 +106,6 @@ export { feedsFormatValidator, setFeedFormatContentType, videoFeedsValidator, + videoSubscriptonFeedsValidator, videoCommentsFeedsValidator } 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 { Model, Scopes, Table, - UpdatedAt + UpdatedAt, + IsUUID } from 'sequelize-typescript' import { MMyUserFormattable, @@ -353,6 +354,12 @@ export class UserModel extends Model { @Column pluginAuth: string + @AllowNull(false) + @Default(DataType.UUIDV4) + @IsUUID(4) + @Column(DataType.UUID) + feedToken: string + @AllowNull(true) @Default(null) @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 { uploadVideo, uploadVideoAndGetId, userLogin, - flushAndRunServer + flushAndRunServer, + getUserScopedTokens } from '../../../shared/extra-utils' import { waitJobs } from '../../../shared/extra-utils/server/jobs' import { addVideoCommentThread } from '../../../shared/extra-utils/videos/video-comments' import { User } from '../../../shared/models/users' +import { ScopedToken } from '@shared/models/users/user-scoped-token' +import { listUserSubscriptionVideos, addUserSubscription } from '@shared/extra-utils/users/user-subscriptions' chai.use(require('chai-xml')) chai.use(require('chai-json-schema')) @@ -41,6 +44,7 @@ describe('Test syndication feeds', () => { let rootChannelId: number let userAccountId: number let userChannelId: number + let userFeedToken: string before(async function () { this.timeout(120000) @@ -74,6 +78,10 @@ describe('Test syndication feeds', () => { const user: User = res.body userAccountId = user.account.id userChannelId = user.videoChannels[0].id + + const res2 = await getUserScopedTokens(servers[0].url, userAccessToken) + const token: ScopedToken = res2.body + userFeedToken = token.feedToken } { @@ -289,6 +297,87 @@ describe('Test syndication feeds', () => { }) }) + describe('Video feed from my subscriptions', function () { + /** + * use the 'version' query parameter to bust cache between tests + */ + + it('Should list no videos for a user with no videos and no subscriptions', async function () { + let feeduserAccountId: number + let feeduserFeedToken: string + + const attr = { username: 'feeduser', password: 'password' } + await createUser({ url: servers[0].url, accessToken: servers[0].accessToken, username: attr.username, password: attr.password }) + const feeduserAccessToken = await userLogin(servers[0], attr) + + { + const res = await getMyUserInformation(servers[0].url, feeduserAccessToken) + const user: User = res.body + feeduserAccountId = user.account.id + } + + { + const res = await getUserScopedTokens(servers[0].url, feeduserAccessToken) + const token: ScopedToken = res.body + feeduserFeedToken = token.feedToken + } + + { + const res = await listUserSubscriptionVideos(servers[0].url, feeduserAccessToken) + expect(res.body.total).to.equal(0) + + const json = await getJSONfeed(servers[0].url, 'videos', { accountId: feeduserAccountId, token: feeduserFeedToken }) + const jsonObj = JSON.parse(json.text) + expect(jsonObj.items.length).to.be.equal(0) // no subscription, it should not list the instance's videos but list 0 videos + } + }) + + it('Should list no videos for a user with videos but no subscriptions', async function () { + { + const res = await listUserSubscriptionVideos(servers[0].url, userAccessToken) + expect(res.body.total).to.equal(0) + + const json = await getJSONfeed(servers[0].url, 'videos', { accountId: userAccountId, token: userFeedToken }) + const jsonObj = JSON.parse(json.text) + expect(jsonObj.items.length).to.be.equal(0) // no subscription, it should not list the instance's videos but list 0 videos + } + }) + + it('Should list self videos for a user with a subscription to themselves', async function () { + this.timeout(30000) + + await addUserSubscription(servers[0].url, userAccessToken, 'john_channel@localhost:' + servers[0].port) + await waitJobs(servers) + + { + const res = await listUserSubscriptionVideos(servers[0].url, userAccessToken) + expect(res.body.total).to.equal(1) + expect(res.body.data[0].name).to.equal('user video') + + const json = await getJSONfeed(servers[0].url, 'videos', { accountId: userAccountId, token: userFeedToken, version: 1 }) + const jsonObj = JSON.parse(json.text) + expect(jsonObj.items.length).to.be.equal(1) // subscribed to self, it should not list the instance's videos but list john's + } + }) + + it('Should list videos of a user\'s subscription', async function () { + this.timeout(30000) + + await addUserSubscription(servers[0].url, userAccessToken, 'root_channel@localhost:' + servers[0].port) + await waitJobs(servers) + + { + const res = await listUserSubscriptionVideos(servers[0].url, userAccessToken) + expect(res.body.total).to.equal(2, "there should be 2 videos part of the subscription") + + const json = await getJSONfeed(servers[0].url, 'videos', { accountId: userAccountId, token: userFeedToken, version: 2 }) + const jsonObj = JSON.parse(json.text) + expect(jsonObj.items.length).to.be.equal(2) // subscribed to root, it should not list the instance's videos but list root/john's + } + }) + + }) + after(async function () { await cleanupTests([ ...servers, serverHLSOnly ]) }) -- cgit v1.2.3