diff options
author | Rigel Kent <sendmemail@rigelk.eu> | 2020-08-13 15:07:23 +0200 |
---|---|---|
committer | Chocobozzz <chocobozzz@cpy.re> | 2020-11-25 11:07:56 +0100 |
commit | afff310e50f2fa8419bb4242470cbde46ab54463 (patch) | |
tree | 34efda2daf8f7cdfd89ef6616a79e2222082f93a | |
parent | f619de0e435f7ac3abad2ec772397486358b56e7 (diff) | |
download | PeerTube-afff310e50f2fa8419bb4242470cbde46ab54463.tar.gz PeerTube-afff310e50f2fa8419bb4242470cbde46ab54463.tar.zst PeerTube-afff310e50f2fa8419bb4242470cbde46ab54463.zip |
allow private syndication feeds via a user feedToken
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 | ||
198 | While testing, you might want to display a server's logs: | ||
199 | |||
200 | ``` | ||
201 | NODE_APP_INSTANCE=1 NODE_ENV=test npm run parse-log -- --level debug | less +GF | ||
202 | ``` | ||
203 | |||
198 | Instance configurations are in `config/test-{1,2,3,4,5,6}.yaml`. | 204 | Instance configurations are in `config/test-{1,2,3,4,5,6}.yaml`. |
199 | Note that only instance 2 has transcoding enabled. | 205 | Note 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 | |||
2 | import { Component } from '@angular/core' | 1 | import { 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 | |||
4 | label { | ||
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 | |||
17 | input[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 | |||
2 | import { Component, OnInit } from '@angular/core' | ||
3 | import { AuthService, Notifier, ConfirmService } from '@app/core' | ||
4 | import { VideoService } from '@app/shared/shared-main' | ||
5 | import { FeedFormat } from '@shared/models' | ||
6 | import { Subject, merge } from 'rxjs' | ||
7 | import { 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 | }) | ||
14 | export 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 | |||
8 | import { MyAccountNotificationsComponent } from './my-account-notifications/my-account-notifications.component' | 8 | import { MyAccountNotificationsComponent } from './my-account-notifications/my-account-notifications.component' |
9 | import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component' | 9 | import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component' |
10 | import { MyAccountComponent } from './my-account.component' | 10 | import { MyAccountComponent } from './my-account.component' |
11 | import { MyAccountApplicationsComponent } from './my-account-applications/my-account-applications.component' | ||
11 | 12 | ||
12 | const myAccountRoutes: Routes = [ | 13 | const 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 | |||
21 | import { MyAccountProfileComponent } from './my-account-settings/my-account-profile/my-account-profile.component' | 21 | import { MyAccountProfileComponent } from './my-account-settings/my-account-profile/my-account-profile.component' |
22 | import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component' | 22 | import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component' |
23 | import { MyAccountComponent } from './my-account.component' | 23 | import { MyAccountComponent } from './my-account.component' |
24 | import { 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' | |||
3 | import { AuthService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core' | 3 | import { AuthService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core' |
4 | import { HooksService } from '@app/core/plugins/hooks.service' | 4 | import { HooksService } from '@app/core/plugins/hooks.service' |
5 | import { immutableAssign } from '@app/helpers' | 5 | import { immutableAssign } from '@app/helpers' |
6 | import { VideoService } from '@app/shared/shared-main' | ||
6 | import { UserSubscriptionService } from '@app/shared/shared-user-subscription' | 7 | import { UserSubscriptionService } from '@app/shared/shared-user-subscription' |
7 | import { AbstractVideoList, OwnerDisplayType } from '@app/shared/shared-video-miniature' | 8 | import { AbstractVideoList, OwnerDisplayType } from '@app/shared/shared-video-miniature' |
8 | import { VideoSortField } from '@shared/models' | 9 | import { VideoSortField, FeedFormat } from '@shared/models' |
10 | import { copyToClipboard } from '../../../root-helpers/utils' | ||
11 | import { 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' | |||
11 | import { RestExtractor } from '../rest/rest-extractor.service' | 11 | import { RestExtractor } from '../rest/rest-extractor.service' |
12 | import { AuthStatus } from './auth-status.model' | 12 | import { AuthStatus } from './auth-status.model' |
13 | import { AuthUser } from './auth-user.model' | 13 | import { AuthUser } from './auth-user.model' |
14 | import { ScopedTokenType, ScopedToken } from '@shared/models/users/user-scoped-token' | ||
14 | 15 | ||
15 | interface UserLoginWithUsername extends UserLogin { | 16 | interface 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 | ||
75 | export type GlobalIconName = keyof typeof icons | 76 | 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' | |||
2 | import { catchError, map, switchMap } from 'rxjs/operators' | 2 | import { catchError, map, switchMap } from 'rxjs/operators' |
3 | import { HttpClient, HttpParams, HttpRequest } from '@angular/common/http' | 3 | import { HttpClient, HttpParams, HttpRequest } from '@angular/common/http' |
4 | import { Injectable } from '@angular/core' | 4 | import { Injectable } from '@angular/core' |
5 | import { ComponentPaginationLight, RestExtractor, RestService, ServerService, UserService } from '@app/core' | 5 | import { ComponentPaginationLight, RestExtractor, RestService, ServerService, UserService, AuthService } from '@app/core' |
6 | import { objectToFormData } from '@app/helpers' | 6 | import { objectToFormData } from '@app/helpers' |
7 | import { | 7 | import { |
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' |
37 | import { TranslationsManager } from './translations-manager' | 37 | import { TranslationsManager } from './translations-manager' |
38 | import { buildVideoOrPlaylistEmbed, buildVideoLink, copyToClipboard, getRtcConfig, isSafari, isIOS } from './utils' | 38 | import { buildVideoOrPlaylistEmbed, buildVideoLink, getRtcConfig, isSafari, isIOS } from './utils' |
39 | import { 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 | ||
179 | function 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 | |||
191 | function videoFileMaxByResolution (files: VideoFile[]) { | 179 | function 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 | ||
12 | function 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 |
13 | function importModule (path: string) { | 25 | function 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 | ||
53 | export { | 65 | export { |
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' | |||
4 | import * as express from 'express' | 4 | import * as express from 'express' |
5 | import { Hooks } from '@server/lib/plugins/hooks' | 5 | import { Hooks } from '@server/lib/plugins/hooks' |
6 | import { asyncMiddleware, authenticate } from '@server/middlewares' | 6 | import { asyncMiddleware, authenticate } from '@server/middlewares' |
7 | import { ScopedToken } from '@shared/models/users/user-scoped-token' | ||
8 | import { v4 as uuidv4 } from 'uuid' | ||
7 | 9 | ||
8 | const tokensRouter = express.Router() | 10 | const tokensRouter = express.Router() |
9 | 11 | ||
@@ -23,6 +25,16 @@ tokensRouter.post('/revoke-token', | |||
23 | asyncMiddleware(handleTokenRevocation) | 25 | asyncMiddleware(handleTokenRevocation) |
24 | ) | 26 | ) |
25 | 27 | ||
28 | tokensRouter.get('/scoped-tokens', | ||
29 | authenticate, | ||
30 | getScopedTokens | ||
31 | ) | ||
32 | |||
33 | tokensRouter.post('/scoped-tokens', | ||
34 | authenticate, | ||
35 | asyncMiddleware(renewScopedTokens) | ||
36 | ) | ||
37 | |||
26 | // --------------------------------------------------------------------------- | 38 | // --------------------------------------------------------------------------- |
27 | 39 | ||
28 | export { | 40 | export { |
@@ -35,3 +47,22 @@ function tokenSuccess (req: express.Request) { | |||
35 | 47 | ||
36 | Hooks.runAction('action:api.user.oauth2-got-token', { username, ip: req.ip }) | 48 | Hooks.runAction('action:api.user.oauth2-got-token', { username, ip: req.ip }) |
37 | } | 49 | } |
50 | |||
51 | function getScopedTokens (req: express.Request, res: express.Response) { | ||
52 | const user = res.locals.oauth.token.user | ||
53 | |||
54 | return res.json({ | ||
55 | feedToken: user.feedToken | ||
56 | } as ScopedToken) | ||
57 | } | ||
58 | |||
59 | async function renewScopedTokens (req: express.Request, res: express.Response) { | ||
60 | const user = res.locals.oauth.token.user | ||
61 | |||
62 | user.feedToken = uuidv4() | ||
63 | await user.save() | ||
64 | |||
65 | return res.json({ | ||
66 | feedToken: user.feedToken | ||
67 | } as ScopedToken) | ||
68 | } | ||
diff --git a/server/controllers/feeds.ts b/server/controllers/feeds.ts index f14c0d316..6e9f7e60c 100644 --- a/server/controllers/feeds.ts +++ b/server/controllers/feeds.ts | |||
@@ -11,11 +11,14 @@ import { | |||
11 | setFeedFormatContentType, | 11 | setFeedFormatContentType, |
12 | videoCommentsFeedsValidator, | 12 | videoCommentsFeedsValidator, |
13 | videoFeedsValidator, | 13 | videoFeedsValidator, |
14 | videosSortValidator | 14 | videosSortValidator, |
15 | videoSubscriptonFeedsValidator | ||
15 | } from '../middlewares' | 16 | } from '../middlewares' |
16 | import { cacheRoute } from '../middlewares/cache' | 17 | import { cacheRoute } from '../middlewares/cache' |
17 | import { VideoModel } from '../models/video/video' | 18 | import { VideoModel } from '../models/video/video' |
18 | import { VideoCommentModel } from '../models/video/video-comment' | 19 | import { VideoCommentModel } from '../models/video/video-comment' |
20 | import { VideoFilter } from '../../shared/models/videos/video-query.type' | ||
21 | import { logger } from '../helpers/logger' | ||
19 | 22 | ||
20 | const feedsRouter = express.Router() | 23 | const feedsRouter = express.Router() |
21 | 24 | ||
@@ -44,6 +47,7 @@ feedsRouter.get('/feeds/videos.:format', | |||
44 | })(ROUTE_CACHE_LIFETIME.FEEDS)), | 47 | })(ROUTE_CACHE_LIFETIME.FEEDS)), |
45 | commonVideosFiltersValidator, | 48 | commonVideosFiltersValidator, |
46 | asyncMiddleware(videoFeedsValidator), | 49 | asyncMiddleware(videoFeedsValidator), |
50 | asyncMiddleware(videoSubscriptonFeedsValidator), | ||
47 | asyncMiddleware(generateVideoFeed) | 51 | asyncMiddleware(generateVideoFeed) |
48 | ) | 52 | ) |
49 | 53 | ||
@@ -124,6 +128,7 @@ async function generateVideoFeed (req: express.Request, res: express.Response) { | |||
124 | 128 | ||
125 | const account = res.locals.account | 129 | const account = res.locals.account |
126 | const videoChannel = res.locals.videoChannel | 130 | const videoChannel = res.locals.videoChannel |
131 | const token = req.query.token | ||
127 | const nsfw = buildNSFWFilter(res, req.query.nsfw) | 132 | const nsfw = buildNSFWFilter(res, req.query.nsfw) |
128 | 133 | ||
129 | let name: string | 134 | let name: string |
@@ -147,19 +152,36 @@ async function generateVideoFeed (req: express.Request, res: express.Response) { | |||
147 | queryString: new URL(WEBSERVER.URL + req.url).search | 152 | queryString: new URL(WEBSERVER.URL + req.url).search |
148 | }) | 153 | }) |
149 | 154 | ||
155 | /** | ||
156 | * We have two ways to query video results: | ||
157 | * - one with account and token -> get subscription videos | ||
158 | * - one with either account, channel, or nothing: just videos with these filters | ||
159 | */ | ||
160 | const options = token && token !== '' && res.locals.user | ||
161 | ? { | ||
162 | followerActorId: res.locals.user.Account.Actor.id, | ||
163 | user: res.locals.user, | ||
164 | includeLocalVideos: false | ||
165 | } | ||
166 | : { | ||
167 | accountId: account ? account.id : null, | ||
168 | videoChannelId: videoChannel ? videoChannel.id : null | ||
169 | } | ||
170 | |||
150 | const resultList = await VideoModel.listForApi({ | 171 | const resultList = await VideoModel.listForApi({ |
151 | start, | 172 | start, |
152 | count: FEEDS.COUNT, | 173 | count: FEEDS.COUNT, |
153 | sort: req.query.sort, | 174 | sort: req.query.sort, |
154 | includeLocalVideos: true, | 175 | includeLocalVideos: true, |
155 | nsfw, | 176 | nsfw, |
156 | filter: req.query.filter, | 177 | filter: req.query.filter as VideoFilter, |
157 | withFiles: true, | 178 | withFiles: true, |
158 | accountId: account ? account.id : null, | 179 | ...options |
159 | videoChannelId: videoChannel ? videoChannel.id : null | ||
160 | }) | 180 | }) |
161 | 181 | ||
162 | // Adding video items to the feed, one at a time | 182 | /** |
183 | * Adding video items to the feed object, one at a time | ||
184 | */ | ||
163 | resultList.data.forEach(video => { | 185 | resultList.data.forEach(video => { |
164 | const formattedVideoFiles = video.getFormattedVideoFilesJSON() | 186 | const formattedVideoFiles = video.getFormattedVideoFilesJSON() |
165 | 187 | ||
diff --git a/server/helpers/middlewares/accounts.ts b/server/helpers/middlewares/accounts.ts index 29b4ed1a6..9be80167c 100644 --- a/server/helpers/middlewares/accounts.ts +++ b/server/helpers/middlewares/accounts.ts | |||
@@ -2,6 +2,7 @@ import { Response } from 'express' | |||
2 | import { AccountModel } from '../../models/account/account' | 2 | import { AccountModel } from '../../models/account/account' |
3 | import * as Bluebird from 'bluebird' | 3 | import * as Bluebird from 'bluebird' |
4 | import { MAccountDefault } from '../../types/models' | 4 | import { MAccountDefault } from '../../types/models' |
5 | import { UserModel } from '@server/models/account/user' | ||
5 | 6 | ||
6 | function doesAccountIdExist (id: number | string, res: Response, sendNotFound = true) { | 7 | function doesAccountIdExist (id: number | string, res: Response, sendNotFound = true) { |
7 | const promise = AccountModel.load(parseInt(id + '', 10)) | 8 | const promise = AccountModel.load(parseInt(id + '', 10)) |
@@ -39,11 +40,28 @@ async function doesAccountExist (p: Bluebird<MAccountDefault>, res: Response, se | |||
39 | return true | 40 | return true |
40 | } | 41 | } |
41 | 42 | ||
43 | async function doesUserFeedTokenCorrespond (id: number | string, token: string, res: Response) { | ||
44 | const user = await UserModel.loadById(parseInt(id + '', 10)) | ||
45 | |||
46 | if (token !== user.feedToken) { | ||
47 | res.status(401) | ||
48 | .send({ error: 'User and token mismatch' }) | ||
49 | .end() | ||
50 | |||
51 | return false | ||
52 | } | ||
53 | |||
54 | res.locals.user = user | ||
55 | |||
56 | return true | ||
57 | } | ||
58 | |||
42 | // --------------------------------------------------------------------------- | 59 | // --------------------------------------------------------------------------- |
43 | 60 | ||
44 | export { | 61 | export { |
45 | doesAccountIdExist, | 62 | doesAccountIdExist, |
46 | doesLocalAccountNameExist, | 63 | doesLocalAccountNameExist, |
47 | doesAccountNameWithHostExist, | 64 | doesAccountNameWithHostExist, |
48 | doesAccountExist | 65 | doesAccountExist, |
66 | doesUserFeedTokenCorrespond | ||
49 | } | 67 | } |
diff --git a/server/initializers/migrations/0530-user-feed-token.ts b/server/initializers/migrations/0530-user-feed-token.ts new file mode 100644 index 000000000..421016b11 --- /dev/null +++ b/server/initializers/migrations/0530-user-feed-token.ts | |||
@@ -0,0 +1,40 @@ | |||
1 | import * as Sequelize from 'sequelize' | ||
2 | import { v4 as uuidv4 } from 'uuid' | ||
3 | |||
4 | async function up (utils: { | ||
5 | transaction: Sequelize.Transaction | ||
6 | queryInterface: Sequelize.QueryInterface | ||
7 | sequelize: Sequelize.Sequelize | ||
8 | db: any | ||
9 | }): Promise<void> { | ||
10 | const q = utils.queryInterface | ||
11 | |||
12 | // Create uuid column for users | ||
13 | const userFeedTokenUUID = { | ||
14 | type: Sequelize.UUID, | ||
15 | defaultValue: Sequelize.UUIDV4, | ||
16 | allowNull: true | ||
17 | } | ||
18 | await q.addColumn('user', 'feedToken', userFeedTokenUUID) | ||
19 | |||
20 | // Set UUID to previous users | ||
21 | { | ||
22 | const query = 'SELECT * FROM "user" WHERE "feedToken" IS NULL' | ||
23 | const options = { type: Sequelize.QueryTypes.SELECT as Sequelize.QueryTypes.SELECT } | ||
24 | const users = await utils.sequelize.query<any>(query, options) | ||
25 | |||
26 | for (const user of users) { | ||
27 | const queryUpdate = `UPDATE "user" SET "feedToken" = '${uuidv4()}' WHERE id = ${user.id}` | ||
28 | await utils.sequelize.query(queryUpdate) | ||
29 | } | ||
30 | } | ||
31 | } | ||
32 | |||
33 | function down (options) { | ||
34 | throw new Error('Not implemented.') | ||
35 | } | ||
36 | |||
37 | export { | ||
38 | up, | ||
39 | down | ||
40 | } | ||
diff --git a/server/middlewares/validators/feeds.ts b/server/middlewares/validators/feeds.ts index c3de0f5fe..5c76a679f 100644 --- a/server/middlewares/validators/feeds.ts +++ b/server/middlewares/validators/feeds.ts | |||
@@ -9,7 +9,8 @@ import { | |||
9 | doesAccountIdExist, | 9 | doesAccountIdExist, |
10 | doesAccountNameWithHostExist, | 10 | doesAccountNameWithHostExist, |
11 | doesVideoChannelIdExist, | 11 | doesVideoChannelIdExist, |
12 | doesVideoChannelNameWithHostExist | 12 | doesVideoChannelNameWithHostExist, |
13 | doesUserFeedTokenCorrespond | ||
13 | } from '../../helpers/middlewares' | 14 | } from '../../helpers/middlewares' |
14 | 15 | ||
15 | const feedsFormatValidator = [ | 16 | const feedsFormatValidator = [ |
@@ -62,6 +63,23 @@ const videoFeedsValidator = [ | |||
62 | } | 63 | } |
63 | ] | 64 | ] |
64 | 65 | ||
66 | const videoSubscriptonFeedsValidator = [ | ||
67 | query('accountId').optional().custom(isIdValid), | ||
68 | query('token').optional(), | ||
69 | |||
70 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { | ||
71 | logger.debug('Checking feeds parameters', { parameters: req.query }) | ||
72 | |||
73 | if (areValidationErrors(req, res)) return | ||
74 | |||
75 | // a token alone is erroneous | ||
76 | if (req.query.token && !req.query.accountId) return | ||
77 | if (req.query.token && !await doesUserFeedTokenCorrespond(res.locals.account.userId, req.query.token, res)) return | ||
78 | |||
79 | return next() | ||
80 | } | ||
81 | ] | ||
82 | |||
65 | const videoCommentsFeedsValidator = [ | 83 | const videoCommentsFeedsValidator = [ |
66 | query('videoId').optional().custom(isIdOrUUIDValid), | 84 | query('videoId').optional().custom(isIdOrUUIDValid), |
67 | 85 | ||
@@ -88,5 +106,6 @@ export { | |||
88 | feedsFormatValidator, | 106 | feedsFormatValidator, |
89 | setFeedFormatContentType, | 107 | setFeedFormatContentType, |
90 | videoFeedsValidator, | 108 | videoFeedsValidator, |
109 | videoSubscriptonFeedsValidator, | ||
91 | videoCommentsFeedsValidator | 110 | videoCommentsFeedsValidator |
92 | } | 111 | } |
diff --git a/server/models/account/user.ts b/server/models/account/user.ts index 2aa6469fb..10117099b 100644 --- a/server/models/account/user.ts +++ b/server/models/account/user.ts | |||
@@ -19,7 +19,8 @@ import { | |||
19 | Model, | 19 | Model, |
20 | Scopes, | 20 | Scopes, |
21 | Table, | 21 | Table, |
22 | UpdatedAt | 22 | UpdatedAt, |
23 | IsUUID | ||
23 | } from 'sequelize-typescript' | 24 | } from 'sequelize-typescript' |
24 | import { | 25 | import { |
25 | MMyUserFormattable, | 26 | MMyUserFormattable, |
@@ -353,6 +354,12 @@ export class UserModel extends Model<UserModel> { | |||
353 | @Column | 354 | @Column |
354 | pluginAuth: string | 355 | pluginAuth: string |
355 | 356 | ||
357 | @AllowNull(false) | ||
358 | @Default(DataType.UUIDV4) | ||
359 | @IsUUID(4) | ||
360 | @Column(DataType.UUID) | ||
361 | feedToken: string | ||
362 | |||
356 | @AllowNull(true) | 363 | @AllowNull(true) |
357 | @Default(null) | 364 | @Default(null) |
358 | @Column | 365 | @Column |
diff --git a/server/tests/feeds/feeds.ts b/server/tests/feeds/feeds.ts index 0ff690f34..2cd9b2d0a 100644 --- a/server/tests/feeds/feeds.ts +++ b/server/tests/feeds/feeds.ts | |||
@@ -22,11 +22,14 @@ import { | |||
22 | uploadVideo, | 22 | uploadVideo, |
23 | uploadVideoAndGetId, | 23 | uploadVideoAndGetId, |
24 | userLogin, | 24 | userLogin, |
25 | flushAndRunServer | 25 | flushAndRunServer, |
26 | getUserScopedTokens | ||
26 | } from '../../../shared/extra-utils' | 27 | } from '../../../shared/extra-utils' |
27 | import { waitJobs } from '../../../shared/extra-utils/server/jobs' | 28 | import { waitJobs } from '../../../shared/extra-utils/server/jobs' |
28 | import { addVideoCommentThread } from '../../../shared/extra-utils/videos/video-comments' | 29 | import { addVideoCommentThread } from '../../../shared/extra-utils/videos/video-comments' |
29 | import { User } from '../../../shared/models/users' | 30 | import { User } from '../../../shared/models/users' |
31 | import { ScopedToken } from '@shared/models/users/user-scoped-token' | ||
32 | import { listUserSubscriptionVideos, addUserSubscription } from '@shared/extra-utils/users/user-subscriptions' | ||
30 | 33 | ||
31 | chai.use(require('chai-xml')) | 34 | chai.use(require('chai-xml')) |
32 | chai.use(require('chai-json-schema')) | 35 | chai.use(require('chai-json-schema')) |
@@ -41,6 +44,7 @@ describe('Test syndication feeds', () => { | |||
41 | let rootChannelId: number | 44 | let rootChannelId: number |
42 | let userAccountId: number | 45 | let userAccountId: number |
43 | let userChannelId: number | 46 | let userChannelId: number |
47 | let userFeedToken: string | ||
44 | 48 | ||
45 | before(async function () { | 49 | before(async function () { |
46 | this.timeout(120000) | 50 | this.timeout(120000) |
@@ -74,6 +78,10 @@ describe('Test syndication feeds', () => { | |||
74 | const user: User = res.body | 78 | const user: User = res.body |
75 | userAccountId = user.account.id | 79 | userAccountId = user.account.id |
76 | userChannelId = user.videoChannels[0].id | 80 | userChannelId = user.videoChannels[0].id |
81 | |||
82 | const res2 = await getUserScopedTokens(servers[0].url, userAccessToken) | ||
83 | const token: ScopedToken = res2.body | ||
84 | userFeedToken = token.feedToken | ||
77 | } | 85 | } |
78 | 86 | ||
79 | { | 87 | { |
@@ -289,6 +297,87 @@ describe('Test syndication feeds', () => { | |||
289 | }) | 297 | }) |
290 | }) | 298 | }) |
291 | 299 | ||
300 | describe('Video feed from my subscriptions', function () { | ||
301 | /** | ||
302 | * use the 'version' query parameter to bust cache between tests | ||
303 | */ | ||
304 | |||
305 | it('Should list no videos for a user with no videos and no subscriptions', async function () { | ||
306 | let feeduserAccountId: number | ||
307 | let feeduserFeedToken: string | ||
308 | |||
309 | const attr = { username: 'feeduser', password: 'password' } | ||
310 | await createUser({ url: servers[0].url, accessToken: servers[0].accessToken, username: attr.username, password: attr.password }) | ||
311 | const feeduserAccessToken = await userLogin(servers[0], attr) | ||
312 | |||
313 | { | ||
314 | const res = await getMyUserInformation(servers[0].url, feeduserAccessToken) | ||
315 | const user: User = res.body | ||
316 | feeduserAccountId = user.account.id | ||
317 | } | ||
318 | |||
319 | { | ||
320 | const res = await getUserScopedTokens(servers[0].url, feeduserAccessToken) | ||
321 | const token: ScopedToken = res.body | ||
322 | feeduserFeedToken = token.feedToken | ||
323 | } | ||
324 | |||
325 | { | ||
326 | const res = await listUserSubscriptionVideos(servers[0].url, feeduserAccessToken) | ||
327 | expect(res.body.total).to.equal(0) | ||
328 | |||
329 | const json = await getJSONfeed(servers[0].url, 'videos', { accountId: feeduserAccountId, token: feeduserFeedToken }) | ||
330 | const jsonObj = JSON.parse(json.text) | ||
331 | expect(jsonObj.items.length).to.be.equal(0) // no subscription, it should not list the instance's videos but list 0 videos | ||
332 | } | ||
333 | }) | ||
334 | |||
335 | it('Should list no videos for a user with videos but no subscriptions', async function () { | ||
336 | { | ||
337 | const res = await listUserSubscriptionVideos(servers[0].url, userAccessToken) | ||
338 | expect(res.body.total).to.equal(0) | ||
339 | |||
340 | const json = await getJSONfeed(servers[0].url, 'videos', { accountId: userAccountId, token: userFeedToken }) | ||
341 | const jsonObj = JSON.parse(json.text) | ||
342 | expect(jsonObj.items.length).to.be.equal(0) // no subscription, it should not list the instance's videos but list 0 videos | ||
343 | } | ||
344 | }) | ||
345 | |||
346 | it('Should list self videos for a user with a subscription to themselves', async function () { | ||
347 | this.timeout(30000) | ||
348 | |||
349 | await addUserSubscription(servers[0].url, userAccessToken, 'john_channel@localhost:' + servers[0].port) | ||
350 | await waitJobs(servers) | ||
351 | |||
352 | { | ||
353 | const res = await listUserSubscriptionVideos(servers[0].url, userAccessToken) | ||
354 | expect(res.body.total).to.equal(1) | ||
355 | expect(res.body.data[0].name).to.equal('user video') | ||
356 | |||
357 | const json = await getJSONfeed(servers[0].url, 'videos', { accountId: userAccountId, token: userFeedToken, version: 1 }) | ||
358 | const jsonObj = JSON.parse(json.text) | ||
359 | expect(jsonObj.items.length).to.be.equal(1) // subscribed to self, it should not list the instance's videos but list john's | ||
360 | } | ||
361 | }) | ||
362 | |||
363 | it('Should list videos of a user\'s subscription', async function () { | ||
364 | this.timeout(30000) | ||
365 | |||
366 | await addUserSubscription(servers[0].url, userAccessToken, 'root_channel@localhost:' + servers[0].port) | ||
367 | await waitJobs(servers) | ||
368 | |||
369 | { | ||
370 | const res = await listUserSubscriptionVideos(servers[0].url, userAccessToken) | ||
371 | expect(res.body.total).to.equal(2, "there should be 2 videos part of the subscription") | ||
372 | |||
373 | const json = await getJSONfeed(servers[0].url, 'videos', { accountId: userAccountId, token: userFeedToken, version: 2 }) | ||
374 | const jsonObj = JSON.parse(json.text) | ||
375 | expect(jsonObj.items.length).to.be.equal(2) // subscribed to root, it should not list the instance's videos but list root/john's | ||
376 | } | ||
377 | }) | ||
378 | |||
379 | }) | ||
380 | |||
292 | after(async function () { | 381 | after(async function () { |
293 | await cleanupTests([ ...servers, serverHLSOnly ]) | 382 | await cleanupTests([ ...servers, serverHLSOnly ]) |
294 | }) | 383 | }) |
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 | ||
112 | function 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 | |||
112 | function deleteMe (url: string, accessToken: string, specialStatus = 204) { | 123 | function 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 @@ | |||
1 | export type ScopedTokenType = 'feedToken' | ||
2 | |||
3 | export type ScopedToken = { | ||
4 | feedToken: string | ||
5 | } | ||