aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--.github/CONTRIBUTING.md6
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html2
-rw-r--r--client/src/app/+my-account/my-account-abuses/my-account-abuses-list.component.ts1
-rw-r--r--client/src/app/+my-account/my-account-applications/my-account-applications.component.html35
-rw-r--r--client/src/app/+my-account/my-account-applications/my-account-applications.component.scss28
-rw-r--r--client/src/app/+my-account/my-account-applications/my-account-applications.component.ts57
-rw-r--r--client/src/app/+my-account/my-account-routing.module.ts10
-rw-r--r--client/src/app/+my-account/my-account.component.ts5
-rw-r--r--client/src/app/+my-account/my-account.module.ts2
-rw-r--r--client/src/app/+videos/video-list/video-user-subscriptions.component.ts27
-rw-r--r--client/src/app/core/auth/auth.service.ts45
-rw-r--r--client/src/app/shared/shared-icons/global-icon.component.ts3
-rw-r--r--client/src/app/shared/shared-main/video/video.service.ts15
-rw-r--r--client/src/app/shared/shared-video-miniature/abstract-video-list.html22
-rw-r--r--client/src/app/shared/shared-video-miniature/abstract-video-list.ts5
-rw-r--r--client/src/assets/images/feather/codesandbox.svg1
-rw-r--r--client/src/assets/player/peertube-player-manager.ts3
-rw-r--r--client/src/assets/player/utils.ts13
-rw-r--r--client/src/root-helpers/utils.ts13
-rw-r--r--client/src/sass/include/_mixins.scss2
-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
-rw-r--r--shared/extra-utils/users/users.ts14
-rw-r--r--shared/models/users/user-scoped-token.ts5
29 files changed, 522 insertions, 36 deletions
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
195$ TS_NODE_FILES=true npm run mocha -- --exit -r ts-node/register -r tsconfig-paths/register --bail server/tests/api/videos/single-server.ts 195$ TS_NODE_FILES=true npm run mocha -- --exit -r ts-node/register -r tsconfig-paths/register --bail server/tests/api/videos/single-server.ts
196``` 196```
197 197
198While testing, you might want to display a server's logs:
199
200```
201NODE_APP_INSTANCE=1 NODE_ENV=test npm run parse-log -- --level debug | less +GF
202```
203
198Instance configurations are in `config/test-{1,2,3,4,5,6}.yaml`. 204Instance configurations are in `config/test-{1,2,3,4,5,6}.yaml`.
199Note that only instance 2 has transcoding enabled. 205Note that only instance 2 has transcoding enabled.
200 206
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 @@
243 <div class="form-row mt-5"> <!-- appearance grid --> 243 <div class="form-row mt-5"> <!-- appearance grid -->
244 <div class="form-group col-12 col-lg-4 col-xl-3"> 244 <div class="form-group col-12 col-lg-4 col-xl-3">
245 <div i18n class="inner-form-title">APPEARANCE</div> 245 <div i18n class="inner-form-title">APPEARANCE</div>
246 <div i18n class="inner-for-description"> 246 <div i18n class="inner-form-description">
247 Use <a routerLink="/admin/plugins">plugins & themes</a> for more involved changes, or <a routerLink="/admin/config/edit-custom" fragment="customizations" (click)="gotoAnchor()">add slight customizations</a>. 247 Use <a routerLink="/admin/plugins">plugins & themes</a> for more involved changes, or <a routerLink="/admin/config/edit-custom" fragment="customizations" (click)="gotoAnchor()">add slight customizations</a>.
248 </div> 248 </div>
249 </div> 249 </div>
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 @@
1
2import { Component } from '@angular/core' 1import { Component } from '@angular/core'
3 2
4@Component({ 3@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 @@
1<h1>
2 <my-global-icon iconName="codesandbox" aria-hidden="true"></my-global-icon>
3 <ng-container i18n>Applications</ng-container>
4</h1>
5
6<div class="form-row"> <!-- built-in token grid -->
7 <div class="form-group col-12 col-lg-4 col-xl-3">
8 <h2 i18n class="applications-title">SUBSCRIPTION FEED</h2>
9 <div i18n class="applications-description">
10 Used to retrieve the list of videos of the creators
11 you subscribed to from outside PeerTube
12 </div>
13 </div>
14
15 <div class="form-group form-group-right col-12 col-lg-8 col-xl-9">
16
17 <div class="form-group">
18 <label i18n for="feed-url">Feed URL</label>
19 <my-input-readonly-copy [value]="feedUrl"></my-input-readonly-copy>
20 </div>
21
22 <div class="form-group">
23 <label i18n for="feed-token">Feed Token</label>
24 <my-input-readonly-copy [value]="feedToken"></my-input-readonly-copy>
25 </div>
26
27 </div>
28</div>
29
30<div class="form-row mt-4"> <!-- submit placement block -->
31 <div class="col-md-7 col-xl-5"></div>
32 <div class="col-md-5 col-xl-5">
33 <input (click)="renewToken()" type="submit" i18n-value value="Renew token">
34 </div>
35</div>
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 @@
1@import '_variables';
2@import '_mixins';
3
4label {
5 font-weight: $font-regular;
6 font-size: 100%;
7}
8
9.applications-title {
10 @include settings-big-title;
11}
12
13.form-group {
14 max-width: 500px;
15}
16
17input[type=submit] {
18 @include peertube-button;
19 @include orange-button;
20
21 display: flex;
22 margin-left: auto;
23
24 & + .form-error {
25 display: inline;
26 margin-left: 5px;
27 }
28}
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 @@
1
2import { Component, OnInit } from '@angular/core'
3import { AuthService, Notifier, ConfirmService } from '@app/core'
4import { VideoService } from '@app/shared/shared-main'
5import { FeedFormat } from '@shared/models'
6import { Subject, merge } from 'rxjs'
7import { debounceTime } from 'rxjs/operators'
8
9@Component({
10 selector: 'my-account-applications',
11 templateUrl: './my-account-applications.component.html',
12 styleUrls: [ './my-account-applications.component.scss' ]
13})
14export class MyAccountApplicationsComponent implements OnInit {
15 feedUrl: string
16 feedToken: string
17
18 private baseURL = window.location.protocol + '//' + window.location.host
19 private tokenStream = new Subject()
20
21 constructor (
22 private authService: AuthService,
23 private videoService: VideoService,
24 private notifier: Notifier,
25 private confirmService: ConfirmService
26 ) {}
27
28 ngOnInit () {
29 this.feedUrl = this.baseURL
30
31 merge(
32 this.tokenStream,
33 this.authService.userInformationLoaded
34 ).pipe(debounceTime(400))
35 .subscribe(
36 _ => {
37 const user = this.authService.getUser()
38 this.videoService.getVideoSubscriptionFeedUrls(user.account.id)
39 .then(feeds => this.feedUrl = this.baseURL + feeds.find(f => f.format === FeedFormat.RSS).url)
40 .then(_ => this.authService.getScopedTokens().then(tokens => this.feedToken = tokens.feedToken))
41 },
42
43 err => {
44 this.notifier.error(err.message)
45 }
46 )
47 }
48
49 async renewToken () {
50 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')
51 if (res === false) return
52
53 await this.authService.renewScopedTokens()
54 this.notifier.success('Token renewed. Update your client configuration accordingly.')
55 this.tokenStream.next()
56 }
57}
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
8import { MyAccountNotificationsComponent } from './my-account-notifications/my-account-notifications.component' 8import { MyAccountNotificationsComponent } from './my-account-notifications/my-account-notifications.component'
9import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component' 9import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component'
10import { MyAccountComponent } from './my-account.component' 10import { MyAccountComponent } from './my-account.component'
11import { MyAccountApplicationsComponent } from './my-account-applications/my-account-applications.component'
11 12
12const myAccountRoutes: Routes = [ 13const myAccountRoutes: Routes = [
13 { 14 {
@@ -117,6 +118,15 @@ const myAccountRoutes: Routes = [
117 title: $localize`My abuse reports` 118 title: $localize`My abuse reports`
118 } 119 }
119 } 120 }
121 },
122 {
123 path: 'applications',
124 component: MyAccountApplicationsComponent,
125 data: {
126 meta: {
127 title: 'Applications'
128 }
129 }
120 } 130 }
121 ] 131 ]
122 } 132 }
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 {
41 label: $localize`Abuse reports`, 41 label: $localize`Abuse reports`,
42 routerLink: '/my-account/abuses', 42 routerLink: '/my-account/abuses',
43 iconName: 'flag' 43 iconName: 'flag'
44 },
45 {
46 label: $localize`Applications`,
47 routerLink: '/my-account/applications',
48 iconName: 'codesandbox'
44 } 49 }
45 ] 50 ]
46 } 51 }
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
21import { MyAccountProfileComponent } from './my-account-settings/my-account-profile/my-account-profile.component' 21import { MyAccountProfileComponent } from './my-account-settings/my-account-profile/my-account-profile.component'
22import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component' 22import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component'
23import { MyAccountComponent } from './my-account.component' 23import { MyAccountComponent } from './my-account.component'
24import { VideoChangeOwnershipComponent } from './my-account-applications/my-account-applications.component'
24 25
25@NgModule({ 26@NgModule({
26 imports: [ 27 imports: [
@@ -51,6 +52,7 @@ import { MyAccountComponent } from './my-account.component'
51 MyAccountAbusesListComponent, 52 MyAccountAbusesListComponent,
52 MyAccountServerBlocklistComponent, 53 MyAccountServerBlocklistComponent,
53 MyAccountNotificationsComponent, 54 MyAccountNotificationsComponent,
55 MyAccountNotificationPreferencesComponent,
54 MyAccountNotificationPreferencesComponent 56 MyAccountNotificationPreferencesComponent
55 ], 57 ],
56 58
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'
3import { AuthService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core' 3import { AuthService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core'
4import { HooksService } from '@app/core/plugins/hooks.service' 4import { HooksService } from '@app/core/plugins/hooks.service'
5import { immutableAssign } from '@app/helpers' 5import { immutableAssign } from '@app/helpers'
6import { VideoService } from '@app/shared/shared-main'
6import { UserSubscriptionService } from '@app/shared/shared-user-subscription' 7import { UserSubscriptionService } from '@app/shared/shared-user-subscription'
7import { AbstractVideoList, OwnerDisplayType } from '@app/shared/shared-video-miniature' 8import { AbstractVideoList, OwnerDisplayType } from '@app/shared/shared-video-miniature'
8import { VideoSortField } from '@shared/models' 9import { VideoSortField, FeedFormat } from '@shared/models'
10import { copyToClipboard } from '../../../root-helpers/utils'
11import { environment } from '../../../environments/environment'
9 12
10@Component({ 13@Component({
11 selector: 'my-videos-user-subscriptions', 14 selector: 'my-videos-user-subscriptions',
@@ -28,11 +31,13 @@ export class VideoUserSubscriptionsComponent extends AbstractVideoList implement
28 protected screenService: ScreenService, 31 protected screenService: ScreenService,
29 protected storageService: LocalStorageService, 32 protected storageService: LocalStorageService,
30 private userSubscription: UserSubscriptionService, 33 private userSubscription: UserSubscriptionService,
31 private hooks: HooksService 34 private hooks: HooksService,
35 private videoService: VideoService
32 ) { 36 ) {
33 super() 37 super()
34 38
35 this.titlePage = $localize`Videos from your subscriptions` 39 this.titlePage = $localize`Videos from your subscriptions`
40
36 this.actions.push({ 41 this.actions.push({
37 routerLink: '/my-library/subscriptions', 42 routerLink: '/my-library/subscriptions',
38 label: $localize`Subscriptions`, 43 label: $localize`Subscriptions`,
@@ -42,6 +47,20 @@ export class VideoUserSubscriptionsComponent extends AbstractVideoList implement
42 47
43 ngOnInit () { 48 ngOnInit () {
44 super.ngOnInit() 49 super.ngOnInit()
50
51 const user = this.authService.getUser()
52 let feedUrl = environment.embedUrl
53 this.videoService.getVideoSubscriptionFeedUrls(user.account.id)
54 .then((feeds: any) => feedUrl = feedUrl + feeds.find((f: any) => f.format === FeedFormat.RSS).url)
55 this.actions.unshift({
56 label: $localize`Feed`,
57 iconName: 'syndication',
58 justIcon: true,
59 click: () => {
60 copyToClipboard(feedUrl)
61 this.activateCopiedMessage()
62 }
63 })
45 } 64 }
46 65
47 ngOnDestroy () { 66 ngOnDestroy () {
@@ -68,4 +87,8 @@ export class VideoUserSubscriptionsComponent extends AbstractVideoList implement
68 generateSyndicationList () { 87 generateSyndicationList () {
69 // not implemented yet 88 // not implemented yet
70 } 89 }
90
91 activateCopiedMessage () {
92 this.notifier.success($localize`Feed URL copied`)
93 }
71} 94}
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'
11import { RestExtractor } from '../rest/rest-extractor.service' 11import { RestExtractor } from '../rest/rest-extractor.service'
12import { AuthStatus } from './auth-status.model' 12import { AuthStatus } from './auth-status.model'
13import { AuthUser } from './auth-user.model' 13import { AuthUser } from './auth-user.model'
14import { ScopedTokenType, ScopedToken } from '@shared/models/users/user-scoped-token'
14 15
15interface UserLoginWithUsername extends UserLogin { 16interface UserLoginWithUsername extends UserLogin {
16 access_token: string 17 access_token: string
@@ -26,6 +27,7 @@ export class AuthService {
26 private static BASE_CLIENT_URL = environment.apiUrl + '/api/v1/oauth-clients/local' 27 private static BASE_CLIENT_URL = environment.apiUrl + '/api/v1/oauth-clients/local'
27 private static BASE_TOKEN_URL = environment.apiUrl + '/api/v1/users/token' 28 private static BASE_TOKEN_URL = environment.apiUrl + '/api/v1/users/token'
28 private static BASE_REVOKE_TOKEN_URL = environment.apiUrl + '/api/v1/users/revoke-token' 29 private static BASE_REVOKE_TOKEN_URL = environment.apiUrl + '/api/v1/users/revoke-token'
30 private static BASE_SCOPED_TOKENS_URL = environment.apiUrl + '/api/v1/users/scoped-tokens'
29 private static BASE_USER_INFORMATION_URL = environment.apiUrl + '/api/v1/users/me' 31 private static BASE_USER_INFORMATION_URL = environment.apiUrl + '/api/v1/users/me'
30 private static LOCAL_STORAGE_OAUTH_CLIENT_KEYS = { 32 private static LOCAL_STORAGE_OAUTH_CLIENT_KEYS = {
31 CLIENT_ID: 'client_id', 33 CLIENT_ID: 'client_id',
@@ -41,6 +43,7 @@ export class AuthService {
41 private loginChanged: Subject<AuthStatus> 43 private loginChanged: Subject<AuthStatus>
42 private user: AuthUser = null 44 private user: AuthUser = null
43 private refreshingTokenObservable: Observable<any> 45 private refreshingTokenObservable: Observable<any>
46 private scopedTokens: ScopedToken
44 47
45 constructor ( 48 constructor (
46 private http: HttpClient, 49 private http: HttpClient,
@@ -244,6 +247,48 @@ Ensure you have correctly configured PeerTube (config/ directory), in particular
244 ) 247 )
245 } 248 }
246 249
250 getScopedTokens (): Promise<ScopedToken> {
251 return new Promise((res, rej) => {
252 if (this.scopedTokens) return res(this.scopedTokens)
253
254 const authHeaderValue = this.getRequestHeaderValue()
255 const headers = new HttpHeaders().set('Authorization', authHeaderValue)
256
257 this.http.get<ScopedToken>(AuthService.BASE_SCOPED_TOKENS_URL, { headers })
258 .subscribe(
259 scopedTokens => {
260 this.scopedTokens = scopedTokens
261 res(this.scopedTokens)
262 },
263
264 err => {
265 console.error(err)
266 rej(err)
267 }
268 )
269 })
270 }
271
272 renewScopedTokens (): Promise<ScopedToken> {
273 return new Promise((res, rej) => {
274 const authHeaderValue = this.getRequestHeaderValue()
275 const headers = new HttpHeaders().set('Authorization', authHeaderValue)
276
277 this.http.post<ScopedToken>(AuthService.BASE_SCOPED_TOKENS_URL, {}, { headers })
278 .subscribe(
279 scopedTokens => {
280 this.scopedTokens = scopedTokens
281 res(this.scopedTokens)
282 },
283
284 err => {
285 console.error(err)
286 rej(err)
287 }
288 )
289 })
290 }
291
247 private mergeUserInformation (obj: UserLoginWithUsername): Observable<UserLoginWithUserInformation> { 292 private mergeUserInformation (obj: UserLoginWithUsername): Observable<UserLoginWithUserInformation> {
248 // User is not loaded yet, set manually auth header 293 // User is not loaded yet, set manually auth header
249 const headers = new HttpHeaders().set('Authorization', `${obj.token_type} ${obj.access_token}`) 294 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 = {
69 'columns': require('!!raw-loader?!../../../assets/images/feather/columns.svg').default, 69 'columns': require('!!raw-loader?!../../../assets/images/feather/columns.svg').default,
70 'live': require('!!raw-loader?!../../../assets/images/feather/live.svg').default, 70 'live': require('!!raw-loader?!../../../assets/images/feather/live.svg').default,
71 'repeat': require('!!raw-loader?!../../../assets/images/feather/repeat.svg').default, 71 'repeat': require('!!raw-loader?!../../../assets/images/feather/repeat.svg').default,
72 'message-circle': require('!!raw-loader?!../../../assets/images/feather/message-circle.svg').default 72 'message-circle': require('!!raw-loader?!../../../assets/images/feather/message-circle.svg').default,
73 'codesandbox': require('!!raw-loader?!../../../assets/images/feather/codesandbox.svg').default
73} 74}
74 75
75export type GlobalIconName = keyof typeof icons 76export 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'
2import { catchError, map, switchMap } from 'rxjs/operators' 2import { catchError, map, switchMap } from 'rxjs/operators'
3import { HttpClient, HttpParams, HttpRequest } from '@angular/common/http' 3import { HttpClient, HttpParams, HttpRequest } from '@angular/common/http'
4import { Injectable } from '@angular/core' 4import { Injectable } from '@angular/core'
5import { ComponentPaginationLight, RestExtractor, RestService, ServerService, UserService } from '@app/core' 5import { ComponentPaginationLight, RestExtractor, RestService, ServerService, UserService, AuthService } from '@app/core'
6import { objectToFormData } from '@app/helpers' 6import { objectToFormData } from '@app/helpers'
7import { 7import {
8 FeedFormat, 8 FeedFormat,
@@ -49,7 +49,8 @@ export class VideoService implements VideosProvider {
49 private authHttp: HttpClient, 49 private authHttp: HttpClient,
50 private restExtractor: RestExtractor, 50 private restExtractor: RestExtractor,
51 private restService: RestService, 51 private restService: RestService,
52 private serverService: ServerService 52 private serverService: ServerService,
53 private authService: AuthService
53 ) {} 54 ) {}
54 55
55 getVideoViewUrl (uuid: string) { 56 getVideoViewUrl (uuid: string) {
@@ -293,6 +294,16 @@ export class VideoService implements VideosProvider {
293 return this.buildBaseFeedUrls(params) 294 return this.buildBaseFeedUrls(params)
294 } 295 }
295 296
297 async getVideoSubscriptionFeedUrls (accountId: number) {
298 let params = this.restService.addRestGetParams(new HttpParams())
299 params = params.set('accountId', accountId.toString())
300
301 const { feedToken } = await this.authService.getScopedTokens()
302 params = params.set('token', feedToken)
303
304 return this.buildBaseFeedUrls(params)
305 }
306
296 getVideoFileMetadata (metadataUrl: string) { 307 getVideoFileMetadata (metadataUrl: string) {
297 return this.authHttp 308 return this.authHttp
298 .get<VideoFileMetadata>(metadataUrl) 309 .get<VideoFileMetadata>(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 @@
8 8
9 <div class="action-block"> 9 <div class="action-block">
10 <my-feed *ngIf="titlePage" [syndicationItems]="syndicationItems"></my-feed> 10 <my-feed *ngIf="titlePage" [syndicationItems]="syndicationItems"></my-feed>
11 <a [routerLink]="action.routerLink" routerLinkActive="active" *ngFor="let action of actions"> 11 <ng-container *ngFor="let action of actions">
12 <my-button [icon]="action.iconName" [label]="action.label"></my-button> 12 <a *ngIf="action.routerLink" class="ml-2" [routerLink]="action.routerLink" routerLinkActive="active">
13 </a> 13 <ng-container *ngTemplateOutlet="actionContent; context:{ $implicit: action }"></ng-container>
14 </a>
15 <a *ngIf="!action.routerLink && action.click && !action.clipboard" class="ml-2" (click)="action.click()" (key.enter)="action.click()">
16 <ng-container *ngTemplateOutlet="actionContent; context:{ $implicit: action }"></ng-container>
17 </a>
18 <a *ngIf="!action.routerLink && !action.click && action.clipboard" class="ml-2" [cdkCopyToClipboard]="action.clipboard">
19 <ng-container *ngTemplateOutlet="actionContent; context:{ $implicit: action }"></ng-container>
20 </a>
21 <a *ngIf="!action.routerLink && action.click && action.clipboard" class="ml-2" (click)="action.click()" (key.enter)="action.click()" [cdkCopyToClipboard]="action.clipboard">
22 <ng-container *ngTemplateOutlet="actionContent; context:{ $implicit: action }"></ng-container>
23 </a>
24
25 <ng-template #actionContent let-action>
26 <my-button *ngIf="!action.justIcon" [icon]="action.iconName" [label]="action.label"></my-button>
27 <my-button *ngIf="action.justIcon" [icon]="action.iconName" [ngbTooltip]="action.label"></my-button>
28 </ng-template>
29 </ng-container>
14 </div> 30 </div>
15 31
16 <div class="moderation-block" *ngIf="displayModerationBlock"> 32 <div class="moderation-block" *ngIf="displayModerationBlock">
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
70 } 70 }
71 71
72 actions: { 72 actions: {
73 routerLink: string
74 iconName: GlobalIconName 73 iconName: GlobalIconName
75 label: string 74 label: string
75 justIcon?: boolean
76 routerLink?: string
77 click?: Function
78 clipboard?: string
76 }[] = [] 79 }[] = []
77 80
78 onDataSubject = new Subject<any[]>() 81 onDataSubject = new Subject<any[]>()
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 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-codesandbox"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path><polyline points="7.5 4.21 12 6.81 16.5 4.21"></polyline><polyline points="7.5 19.79 7.5 14.6 3 12"></polyline><polyline points="21 12 16.5 14.6 16.5 19.79"></polyline><polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline><line x1="12" y1="22.08" x2="12" y2="12"></line></svg> \ 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 {
35 VideoJSPluginOptions 35 VideoJSPluginOptions
36} from './peertube-videojs-typings' 36} from './peertube-videojs-typings'
37import { TranslationsManager } from './translations-manager' 37import { TranslationsManager } from './translations-manager'
38import { buildVideoOrPlaylistEmbed, buildVideoLink, copyToClipboard, getRtcConfig, isSafari, isIOS } from './utils' 38import { buildVideoOrPlaylistEmbed, buildVideoLink, getRtcConfig, isSafari, isIOS } from './utils'
39import { copyToClipboard } from '../../root-helpers/utils'
39 40
40// Change 'Playback Rate' to 'Speed' (smaller for our settings menu) 41// Change 'Playback Rate' to 'Speed' (smaller for our settings menu)
41(videojs.getComponent('PlaybackRateMenuButton') as any).prototype.controlText_ = 'Speed' 42(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) {
176 '</iframe>' 176 '</iframe>'
177} 177}
178 178
179function copyToClipboard (text: string) {
180 const el = document.createElement('textarea')
181 el.value = text
182 el.setAttribute('readonly', '')
183 el.style.position = 'absolute'
184 el.style.left = '-9999px'
185 document.body.appendChild(el)
186 el.select()
187 document.execCommand('copy')
188 document.body.removeChild(el)
189}
190
191function videoFileMaxByResolution (files: VideoFile[]) { 179function videoFileMaxByResolution (files: VideoFile[]) {
192 let max = files[0] 180 let max = files[0]
193 181
@@ -236,7 +224,6 @@ export {
236 buildVideoOrPlaylistEmbed, 224 buildVideoOrPlaylistEmbed,
237 videoFileMaxByResolution, 225 videoFileMaxByResolution,
238 videoFileMinByResolution, 226 videoFileMinByResolution,
239 copyToClipboard,
240 isMobile, 227 isMobile,
241 bytes, 228 bytes,
242 isIOS, 229 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) {
9 return str.join('&') 9 return str.join('&')
10} 10}
11 11
12function copyToClipboard (text: string) {
13 const el = document.createElement('textarea')
14 el.value = text
15 el.setAttribute('readonly', '')
16 el.style.position = 'absolute'
17 el.style.left = '-9999px'
18 document.body.appendChild(el)
19 el.select()
20 document.execCommand('copy')
21 document.body.removeChild(el)
22}
23
12// Thanks: https://github.com/uupaa/dynamic-import-polyfill 24// Thanks: https://github.com/uupaa/dynamic-import-polyfill
13function importModule (path: string) { 25function importModule (path: string) {
14 return new Promise((resolve, reject) => { 26 return new Promise((resolve, reject) => {
@@ -51,6 +63,7 @@ function wait (ms: number) {
51} 63}
52 64
53export { 65export {
66 copyToClipboard,
54 importModule, 67 importModule,
55 objectToUrlEncoded, 68 objectToUrlEncoded,
56 wait 69 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 @@
225 line-height: $button-height; 225 line-height: $button-height;
226 border-radius: 3px; 226 border-radius: 3px;
227 text-align: center; 227 text-align: center;
228 padding: 0 17px 0 13px; 228 padding: 0 13px 0 13px;
229 cursor: pointer; 229 cursor: pointer;
230} 230}
231 231
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 })
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 =
109 .expect('Content-Type', /json/) 109 .expect('Content-Type', /json/)
110} 110}
111 111
112function getUserScopedTokens (url: string, accessToken: string, specialStatus = 200) {
113 const path = '/api/v1/users/scoped-tokens'
114
115 return request(url)
116 .get(path)
117 .set('Accept', 'application/json')
118 .set('Authorization', 'Bearer ' + accessToken)
119 .expect(specialStatus)
120 .expect('Content-Type', /json/)
121}
122
112function deleteMe (url: string, accessToken: string, specialStatus = 204) { 123function deleteMe (url: string, accessToken: string, specialStatus = 204) {
113 const path = '/api/v1/users/me' 124 const path = '/api/v1/users/me'
114 125
@@ -351,5 +362,6 @@ export {
351 updateMyAvatar, 362 updateMyAvatar,
352 askSendVerifyEmail, 363 askSendVerifyEmail,
353 generateUserAccessToken, 364 generateUserAccessToken,
354 verifyEmail 365 verifyEmail,
366 getUserScopedTokens
355} 367}
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 @@
1export type ScopedTokenType = 'feedToken'
2
3export type ScopedToken = {
4 feedToken: string
5}