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 --- .github/CONTRIBUTING.md | 6 ++ .../edit-custom-config.component.html | 2 +- .../my-account-abuses-list.component.ts | 1 - .../my-account-applications.component.html | 35 +++++++++ .../my-account-applications.component.scss | 28 +++++++ .../my-account-applications.component.ts | 57 ++++++++++++++ .../app/+my-account/my-account-routing.module.ts | 10 +++ client/src/app/+my-account/my-account.component.ts | 5 ++ client/src/app/+my-account/my-account.module.ts | 2 + .../video-user-subscriptions.component.ts | 27 ++++++- client/src/app/core/auth/auth.service.ts | 45 +++++++++++ .../shared/shared-icons/global-icon.component.ts | 3 +- .../app/shared/shared-main/video/video.service.ts | 15 +++- .../abstract-video-list.html | 22 +++++- .../shared-video-miniature/abstract-video-list.ts | 5 +- client/src/assets/images/feather/codesandbox.svg | 1 + .../src/assets/player/peertube-player-manager.ts | 3 +- client/src/assets/player/utils.ts | 13 ---- client/src/root-helpers/utils.ts | 13 ++++ client/src/sass/include/_mixins.scss | 2 +- 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 +++++++++++++++++++++- shared/extra-utils/users/users.ts | 14 +++- shared/models/users/user-scoped-token.ts | 5 ++ 29 files changed, 522 insertions(+), 36 deletions(-) create mode 100644 client/src/app/+my-account/my-account-applications/my-account-applications.component.html create mode 100644 client/src/app/+my-account/my-account-applications/my-account-applications.component.scss create mode 100644 client/src/app/+my-account/my-account-applications/my-account-applications.component.ts create mode 100644 client/src/assets/images/feather/codesandbox.svg create mode 100644 server/initializers/migrations/0530-user-feed-token.ts create mode 100644 shared/models/users/user-scoped-token.ts diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 1256a02bd..f53dd6406 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -195,6 +195,12 @@ If you just want to run 1 test (which is what you want to debug a specific test $ TS_NODE_FILES=true npm run mocha -- --exit -r ts-node/register -r tsconfig-paths/register --bail server/tests/api/videos/single-server.ts ``` +While testing, you might want to display a server's logs: + +``` +NODE_APP_INSTANCE=1 NODE_ENV=test npm run parse-log -- --level debug | less +GF +``` + Instance configurations are in `config/test-{1,2,3,4,5,6}.yaml`. Note that only instance 2 has transcoding enabled. diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html index 09539fa92..e73a9f8a8 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html +++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html @@ -243,7 +243,7 @@
APPEARANCE
-
+
Use plugins & themes for more involved changes, or add slight customizations.
diff --git a/client/src/app/+my-account/my-account-abuses/my-account-abuses-list.component.ts b/client/src/app/+my-account/my-account-abuses/my-account-abuses-list.component.ts index e5dd723ff..9316fc0dd 100644 --- a/client/src/app/+my-account/my-account-abuses/my-account-abuses-list.component.ts +++ b/client/src/app/+my-account/my-account-abuses/my-account-abuses-list.component.ts @@ -1,4 +1,3 @@ - import { Component } from '@angular/core' @Component({ diff --git a/client/src/app/+my-account/my-account-applications/my-account-applications.component.html b/client/src/app/+my-account/my-account-applications/my-account-applications.component.html new file mode 100644 index 000000000..62e2cb59b --- /dev/null +++ b/client/src/app/+my-account/my-account-applications/my-account-applications.component.html @@ -0,0 +1,35 @@ +

+ + Applications +

+ +
+
+

SUBSCRIPTION FEED

+
+ Used to retrieve the list of videos of the creators + you subscribed to from outside PeerTube +
+
+ +
+ +
+ + +
+ +
+ + +
+ +
+
+ +
+
+
+ +
+
diff --git a/client/src/app/+my-account/my-account-applications/my-account-applications.component.scss b/client/src/app/+my-account/my-account-applications/my-account-applications.component.scss new file mode 100644 index 000000000..704132c03 --- /dev/null +++ b/client/src/app/+my-account/my-account-applications/my-account-applications.component.scss @@ -0,0 +1,28 @@ +@import '_variables'; +@import '_mixins'; + +label { + font-weight: $font-regular; + font-size: 100%; +} + +.applications-title { + @include settings-big-title; +} + +.form-group { + max-width: 500px; +} + +input[type=submit] { + @include peertube-button; + @include orange-button; + + display: flex; + margin-left: auto; + + & + .form-error { + display: inline; + margin-left: 5px; + } +} diff --git a/client/src/app/+my-account/my-account-applications/my-account-applications.component.ts b/client/src/app/+my-account/my-account-applications/my-account-applications.component.ts new file mode 100644 index 000000000..c3f09dfe3 --- /dev/null +++ b/client/src/app/+my-account/my-account-applications/my-account-applications.component.ts @@ -0,0 +1,57 @@ + +import { Component, OnInit } from '@angular/core' +import { AuthService, Notifier, ConfirmService } from '@app/core' +import { VideoService } from '@app/shared/shared-main' +import { FeedFormat } from '@shared/models' +import { Subject, merge } from 'rxjs' +import { debounceTime } from 'rxjs/operators' + +@Component({ + selector: 'my-account-applications', + templateUrl: './my-account-applications.component.html', + styleUrls: [ './my-account-applications.component.scss' ] +}) +export class MyAccountApplicationsComponent implements OnInit { + feedUrl: string + feedToken: string + + private baseURL = window.location.protocol + '//' + window.location.host + private tokenStream = new Subject() + + constructor ( + private authService: AuthService, + private videoService: VideoService, + private notifier: Notifier, + private confirmService: ConfirmService + ) {} + + ngOnInit () { + this.feedUrl = this.baseURL + + merge( + this.tokenStream, + this.authService.userInformationLoaded + ).pipe(debounceTime(400)) + .subscribe( + _ => { + const user = this.authService.getUser() + this.videoService.getVideoSubscriptionFeedUrls(user.account.id) + .then(feeds => this.feedUrl = this.baseURL + feeds.find(f => f.format === FeedFormat.RSS).url) + .then(_ => this.authService.getScopedTokens().then(tokens => this.feedToken = tokens.feedToken)) + }, + + err => { + this.notifier.error(err.message) + } + ) + } + + async renewToken () { + const res = await this.confirmService.confirm('Renewing the token will disallow previously configured clients from retrieving the feed until they use the new token. Proceed?', 'Renew token') + if (res === false) return + + await this.authService.renewScopedTokens() + this.notifier.success('Token renewed. Update your client configuration accordingly.') + this.tokenStream.next() + } +} diff --git a/client/src/app/+my-account/my-account-routing.module.ts b/client/src/app/+my-account/my-account-routing.module.ts index 81380ec6e..226a4a7be 100644 --- a/client/src/app/+my-account/my-account-routing.module.ts +++ b/client/src/app/+my-account/my-account-routing.module.ts @@ -8,6 +8,7 @@ import { MyAccountServerBlocklistComponent } from './my-account-blocklist/my-acc import { MyAccountNotificationsComponent } from './my-account-notifications/my-account-notifications.component' import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component' import { MyAccountComponent } from './my-account.component' +import { MyAccountApplicationsComponent } from './my-account-applications/my-account-applications.component' const myAccountRoutes: Routes = [ { @@ -117,6 +118,15 @@ const myAccountRoutes: Routes = [ title: $localize`My abuse reports` } } + }, + { + path: 'applications', + component: MyAccountApplicationsComponent, + data: { + meta: { + title: 'Applications' + } + } } ] } diff --git a/client/src/app/+my-account/my-account.component.ts b/client/src/app/+my-account/my-account.component.ts index d6e9d1c15..12966aebb 100644 --- a/client/src/app/+my-account/my-account.component.ts +++ b/client/src/app/+my-account/my-account.component.ts @@ -41,6 +41,11 @@ export class MyAccountComponent implements OnInit { label: $localize`Abuse reports`, routerLink: '/my-account/abuses', iconName: 'flag' + }, + { + label: $localize`Applications`, + routerLink: '/my-account/applications', + iconName: 'codesandbox' } ] } diff --git a/client/src/app/+my-account/my-account.module.ts b/client/src/app/+my-account/my-account.module.ts index 9e3fbcf65..70bf58aae 100644 --- a/client/src/app/+my-account/my-account.module.ts +++ b/client/src/app/+my-account/my-account.module.ts @@ -21,6 +21,7 @@ import { MyAccountNotificationPreferencesComponent } from './my-account-settings import { MyAccountProfileComponent } from './my-account-settings/my-account-profile/my-account-profile.component' import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component' import { MyAccountComponent } from './my-account.component' +import { VideoChangeOwnershipComponent } from './my-account-applications/my-account-applications.component' @NgModule({ imports: [ @@ -51,6 +52,7 @@ import { MyAccountComponent } from './my-account.component' MyAccountAbusesListComponent, MyAccountServerBlocklistComponent, MyAccountNotificationsComponent, + MyAccountNotificationPreferencesComponent, MyAccountNotificationPreferencesComponent ], diff --git a/client/src/app/+videos/video-list/video-user-subscriptions.component.ts b/client/src/app/+videos/video-list/video-user-subscriptions.component.ts index 6988c574b..10031d6cc 100644 --- a/client/src/app/+videos/video-list/video-user-subscriptions.component.ts +++ b/client/src/app/+videos/video-list/video-user-subscriptions.component.ts @@ -3,9 +3,12 @@ import { ActivatedRoute, Router } from '@angular/router' import { AuthService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core' import { HooksService } from '@app/core/plugins/hooks.service' import { immutableAssign } from '@app/helpers' +import { VideoService } from '@app/shared/shared-main' import { UserSubscriptionService } from '@app/shared/shared-user-subscription' import { AbstractVideoList, OwnerDisplayType } from '@app/shared/shared-video-miniature' -import { VideoSortField } from '@shared/models' +import { VideoSortField, FeedFormat } from '@shared/models' +import { copyToClipboard } from '../../../root-helpers/utils' +import { environment } from '../../../environments/environment' @Component({ selector: 'my-videos-user-subscriptions', @@ -28,11 +31,13 @@ export class VideoUserSubscriptionsComponent extends AbstractVideoList implement protected screenService: ScreenService, protected storageService: LocalStorageService, private userSubscription: UserSubscriptionService, - private hooks: HooksService + private hooks: HooksService, + private videoService: VideoService ) { super() this.titlePage = $localize`Videos from your subscriptions` + this.actions.push({ routerLink: '/my-library/subscriptions', label: $localize`Subscriptions`, @@ -42,6 +47,20 @@ export class VideoUserSubscriptionsComponent extends AbstractVideoList implement ngOnInit () { super.ngOnInit() + + const user = this.authService.getUser() + let feedUrl = environment.embedUrl + this.videoService.getVideoSubscriptionFeedUrls(user.account.id) + .then((feeds: any) => feedUrl = feedUrl + feeds.find((f: any) => f.format === FeedFormat.RSS).url) + this.actions.unshift({ + label: $localize`Feed`, + iconName: 'syndication', + justIcon: true, + click: () => { + copyToClipboard(feedUrl) + this.activateCopiedMessage() + } + }) } ngOnDestroy () { @@ -68,4 +87,8 @@ export class VideoUserSubscriptionsComponent extends AbstractVideoList implement generateSyndicationList () { // not implemented yet } + + activateCopiedMessage () { + this.notifier.success($localize`Feed URL copied`) + } } diff --git a/client/src/app/core/auth/auth.service.ts b/client/src/app/core/auth/auth.service.ts index fd6062d3f..224f35f82 100644 --- a/client/src/app/core/auth/auth.service.ts +++ b/client/src/app/core/auth/auth.service.ts @@ -11,6 +11,7 @@ import { environment } from '../../../environments/environment' import { RestExtractor } from '../rest/rest-extractor.service' import { AuthStatus } from './auth-status.model' import { AuthUser } from './auth-user.model' +import { ScopedTokenType, ScopedToken } from '@shared/models/users/user-scoped-token' interface UserLoginWithUsername extends UserLogin { access_token: string @@ -26,6 +27,7 @@ export class AuthService { private static BASE_CLIENT_URL = environment.apiUrl + '/api/v1/oauth-clients/local' private static BASE_TOKEN_URL = environment.apiUrl + '/api/v1/users/token' private static BASE_REVOKE_TOKEN_URL = environment.apiUrl + '/api/v1/users/revoke-token' + private static BASE_SCOPED_TOKENS_URL = environment.apiUrl + '/api/v1/users/scoped-tokens' private static BASE_USER_INFORMATION_URL = environment.apiUrl + '/api/v1/users/me' private static LOCAL_STORAGE_OAUTH_CLIENT_KEYS = { CLIENT_ID: 'client_id', @@ -41,6 +43,7 @@ export class AuthService { private loginChanged: Subject private user: AuthUser = null private refreshingTokenObservable: Observable + private scopedTokens: ScopedToken constructor ( private http: HttpClient, @@ -244,6 +247,48 @@ Ensure you have correctly configured PeerTube (config/ directory), in particular ) } + getScopedTokens (): Promise { + return new Promise((res, rej) => { + if (this.scopedTokens) return res(this.scopedTokens) + + const authHeaderValue = this.getRequestHeaderValue() + const headers = new HttpHeaders().set('Authorization', authHeaderValue) + + this.http.get(AuthService.BASE_SCOPED_TOKENS_URL, { headers }) + .subscribe( + scopedTokens => { + this.scopedTokens = scopedTokens + res(this.scopedTokens) + }, + + err => { + console.error(err) + rej(err) + } + ) + }) + } + + renewScopedTokens (): Promise { + return new Promise((res, rej) => { + const authHeaderValue = this.getRequestHeaderValue() + const headers = new HttpHeaders().set('Authorization', authHeaderValue) + + this.http.post(AuthService.BASE_SCOPED_TOKENS_URL, {}, { headers }) + .subscribe( + scopedTokens => { + this.scopedTokens = scopedTokens + res(this.scopedTokens) + }, + + err => { + console.error(err) + rej(err) + } + ) + }) + } + private mergeUserInformation (obj: UserLoginWithUsername): Observable { // User is not loaded yet, set manually auth header const headers = new HttpHeaders().set('Authorization', `${obj.token_type} ${obj.access_token}`) diff --git a/client/src/app/shared/shared-icons/global-icon.component.ts b/client/src/app/shared/shared-icons/global-icon.component.ts index f3c1fe59b..53a2aee9a 100644 --- a/client/src/app/shared/shared-icons/global-icon.component.ts +++ b/client/src/app/shared/shared-icons/global-icon.component.ts @@ -69,7 +69,8 @@ const icons = { 'columns': require('!!raw-loader?!../../../assets/images/feather/columns.svg').default, 'live': require('!!raw-loader?!../../../assets/images/feather/live.svg').default, 'repeat': require('!!raw-loader?!../../../assets/images/feather/repeat.svg').default, - 'message-circle': require('!!raw-loader?!../../../assets/images/feather/message-circle.svg').default + 'message-circle': require('!!raw-loader?!../../../assets/images/feather/message-circle.svg').default, + 'codesandbox': require('!!raw-loader?!../../../assets/images/feather/codesandbox.svg').default } export type GlobalIconName = keyof typeof icons diff --git a/client/src/app/shared/shared-main/video/video.service.ts b/client/src/app/shared/shared-main/video/video.service.ts index c8a3ec043..b81540e8d 100644 --- a/client/src/app/shared/shared-main/video/video.service.ts +++ b/client/src/app/shared/shared-main/video/video.service.ts @@ -2,7 +2,7 @@ import { Observable } from 'rxjs' import { catchError, map, switchMap } from 'rxjs/operators' import { HttpClient, HttpParams, HttpRequest } from '@angular/common/http' import { Injectable } from '@angular/core' -import { ComponentPaginationLight, RestExtractor, RestService, ServerService, UserService } from '@app/core' +import { ComponentPaginationLight, RestExtractor, RestService, ServerService, UserService, AuthService } from '@app/core' import { objectToFormData } from '@app/helpers' import { FeedFormat, @@ -49,7 +49,8 @@ export class VideoService implements VideosProvider { private authHttp: HttpClient, private restExtractor: RestExtractor, private restService: RestService, - private serverService: ServerService + private serverService: ServerService, + private authService: AuthService ) {} getVideoViewUrl (uuid: string) { @@ -293,6 +294,16 @@ export class VideoService implements VideosProvider { return this.buildBaseFeedUrls(params) } + async getVideoSubscriptionFeedUrls (accountId: number) { + let params = this.restService.addRestGetParams(new HttpParams()) + params = params.set('accountId', accountId.toString()) + + const { feedToken } = await this.authService.getScopedTokens() + params = params.set('token', feedToken) + + return this.buildBaseFeedUrls(params) + } + getVideoFileMetadata (metadataUrl: string) { return this.authHttp .get(metadataUrl) diff --git a/client/src/app/shared/shared-video-miniature/abstract-video-list.html b/client/src/app/shared/shared-video-miniature/abstract-video-list.html index b1ac757db..18294513f 100644 --- a/client/src/app/shared/shared-video-miniature/abstract-video-list.html +++ b/client/src/app/shared/shared-video-miniature/abstract-video-list.html @@ -8,9 +8,25 @@
- - - + + + + + + + + + + + + + + + + + + +
diff --git a/client/src/app/shared/shared-video-miniature/abstract-video-list.ts b/client/src/app/shared/shared-video-miniature/abstract-video-list.ts index 2219ced30..c55e85afe 100644 --- a/client/src/app/shared/shared-video-miniature/abstract-video-list.ts +++ b/client/src/app/shared/shared-video-miniature/abstract-video-list.ts @@ -70,9 +70,12 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy, DisableFor } actions: { - routerLink: string iconName: GlobalIconName label: string + justIcon?: boolean + routerLink?: string + click?: Function + clipboard?: string }[] = [] onDataSubject = new Subject() diff --git a/client/src/assets/images/feather/codesandbox.svg b/client/src/assets/images/feather/codesandbox.svg new file mode 100644 index 000000000..49848f520 --- /dev/null +++ b/client/src/assets/images/feather/codesandbox.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/assets/player/peertube-player-manager.ts b/client/src/assets/player/peertube-player-manager.ts index da23c59a7..9407cf123 100644 --- a/client/src/assets/player/peertube-player-manager.ts +++ b/client/src/assets/player/peertube-player-manager.ts @@ -35,7 +35,8 @@ import { VideoJSPluginOptions } from './peertube-videojs-typings' import { TranslationsManager } from './translations-manager' -import { buildVideoOrPlaylistEmbed, buildVideoLink, copyToClipboard, getRtcConfig, isSafari, isIOS } from './utils' +import { buildVideoOrPlaylistEmbed, buildVideoLink, getRtcConfig, isSafari, isIOS } from './utils' +import { copyToClipboard } from '../../root-helpers/utils' // Change 'Playback Rate' to 'Speed' (smaller for our settings menu) (videojs.getComponent('PlaybackRateMenuButton') as any).prototype.controlText_ = 'Speed' diff --git a/client/src/assets/player/utils.ts b/client/src/assets/player/utils.ts index ce7a7fe6c..280f721bd 100644 --- a/client/src/assets/player/utils.ts +++ b/client/src/assets/player/utils.ts @@ -176,18 +176,6 @@ function buildVideoOrPlaylistEmbed (embedUrl: string) { '' } -function copyToClipboard (text: string) { - const el = document.createElement('textarea') - el.value = text - el.setAttribute('readonly', '') - el.style.position = 'absolute' - el.style.left = '-9999px' - document.body.appendChild(el) - el.select() - document.execCommand('copy') - document.body.removeChild(el) -} - function videoFileMaxByResolution (files: VideoFile[]) { let max = files[0] @@ -236,7 +224,6 @@ export { buildVideoOrPlaylistEmbed, videoFileMaxByResolution, videoFileMinByResolution, - copyToClipboard, isMobile, bytes, isIOS, diff --git a/client/src/root-helpers/utils.ts b/client/src/root-helpers/utils.ts index de4e08bf5..e32187ddb 100644 --- a/client/src/root-helpers/utils.ts +++ b/client/src/root-helpers/utils.ts @@ -9,6 +9,18 @@ function objectToUrlEncoded (obj: any) { return str.join('&') } +function copyToClipboard (text: string) { + const el = document.createElement('textarea') + el.value = text + el.setAttribute('readonly', '') + el.style.position = 'absolute' + el.style.left = '-9999px' + document.body.appendChild(el) + el.select() + document.execCommand('copy') + document.body.removeChild(el) +} + // Thanks: https://github.com/uupaa/dynamic-import-polyfill function importModule (path: string) { return new Promise((resolve, reject) => { @@ -51,6 +63,7 @@ function wait (ms: number) { } export { + copyToClipboard, importModule, objectToUrlEncoded, wait diff --git a/client/src/sass/include/_mixins.scss b/client/src/sass/include/_mixins.scss index e6491b492..4d70110fe 100644 --- a/client/src/sass/include/_mixins.scss +++ b/client/src/sass/include/_mixins.scss @@ -225,7 +225,7 @@ line-height: $button-height; border-radius: 3px; text-align: center; - padding: 0 17px 0 13px; + padding: 0 13px 0 13px; cursor: pointer; } 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 ]) }) diff --git a/shared/extra-utils/users/users.ts b/shared/extra-utils/users/users.ts index 9f193680d..4d0986ce3 100644 --- a/shared/extra-utils/users/users.ts +++ b/shared/extra-utils/users/users.ts @@ -109,6 +109,17 @@ function getMyUserInformation (url: string, accessToken: string, specialStatus = .expect('Content-Type', /json/) } +function getUserScopedTokens (url: string, accessToken: string, specialStatus = 200) { + const path = '/api/v1/users/scoped-tokens' + + return request(url) + .get(path) + .set('Accept', 'application/json') + .set('Authorization', 'Bearer ' + accessToken) + .expect(specialStatus) + .expect('Content-Type', /json/) +} + function deleteMe (url: string, accessToken: string, specialStatus = 204) { const path = '/api/v1/users/me' @@ -351,5 +362,6 @@ export { updateMyAvatar, askSendVerifyEmail, generateUserAccessToken, - verifyEmail + verifyEmail, + getUserScopedTokens } diff --git a/shared/models/users/user-scoped-token.ts b/shared/models/users/user-scoped-token.ts new file mode 100644 index 000000000..f9d9b0a8b --- /dev/null +++ b/shared/models/users/user-scoped-token.ts @@ -0,0 +1,5 @@ +export type ScopedTokenType = 'feedToken' + +export type ScopedToken = { + feedToken: string +} -- cgit v1.2.3