allow private syndication feeds via a user feedToken
This commit is contained in:
parent
f619de0e43
commit
afff310e50
|
@ -195,6 +195,12 @@ If you just want to run 1 test (which is what you want to debug a specific test
|
||||||
$ TS_NODE_FILES=true npm run mocha -- --exit -r ts-node/register -r tsconfig-paths/register --bail server/tests/api/videos/single-server.ts
|
$ TS_NODE_FILES=true npm run mocha -- --exit -r ts-node/register -r tsconfig-paths/register --bail server/tests/api/videos/single-server.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
|
While testing, you might want to display a server's logs:
|
||||||
|
|
||||||
|
```
|
||||||
|
NODE_APP_INSTANCE=1 NODE_ENV=test npm run parse-log -- --level debug | less +GF
|
||||||
|
```
|
||||||
|
|
||||||
Instance configurations are in `config/test-{1,2,3,4,5,6}.yaml`.
|
Instance configurations are in `config/test-{1,2,3,4,5,6}.yaml`.
|
||||||
Note that only instance 2 has transcoding enabled.
|
Note that only instance 2 has transcoding enabled.
|
||||||
|
|
||||||
|
|
|
@ -243,7 +243,7 @@
|
||||||
<div class="form-row mt-5"> <!-- appearance grid -->
|
<div class="form-row mt-5"> <!-- appearance grid -->
|
||||||
<div class="form-group col-12 col-lg-4 col-xl-3">
|
<div class="form-group col-12 col-lg-4 col-xl-3">
|
||||||
<div i18n class="inner-form-title">APPEARANCE</div>
|
<div i18n class="inner-form-title">APPEARANCE</div>
|
||||||
<div i18n class="inner-for-description">
|
<div i18n class="inner-form-description">
|
||||||
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>.
|
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>.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
|
|
||||||
import { Component } from '@angular/core'
|
import { Component } from '@angular/core'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
|
|
@ -0,0 +1,35 @@
|
||||||
|
<h1>
|
||||||
|
<my-global-icon iconName="codesandbox" aria-hidden="true"></my-global-icon>
|
||||||
|
<ng-container i18n>Applications</ng-container>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div class="form-row"> <!-- built-in token grid -->
|
||||||
|
<div class="form-group col-12 col-lg-4 col-xl-3">
|
||||||
|
<h2 i18n class="applications-title">SUBSCRIPTION FEED</h2>
|
||||||
|
<div i18n class="applications-description">
|
||||||
|
Used to retrieve the list of videos of the creators
|
||||||
|
you subscribed to from outside PeerTube
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group form-group-right col-12 col-lg-8 col-xl-9">
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label i18n for="feed-url">Feed URL</label>
|
||||||
|
<my-input-readonly-copy [value]="feedUrl"></my-input-readonly-copy>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label i18n for="feed-token">Feed Token</label>
|
||||||
|
<my-input-readonly-copy [value]="feedToken"></my-input-readonly-copy>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row mt-4"> <!-- submit placement block -->
|
||||||
|
<div class="col-md-7 col-xl-5"></div>
|
||||||
|
<div class="col-md-5 col-xl-5">
|
||||||
|
<input (click)="renewToken()" type="submit" i18n-value value="Renew token">
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -0,0 +1,28 @@
|
||||||
|
@import '_variables';
|
||||||
|
@import '_mixins';
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-weight: $font-regular;
|
||||||
|
font-size: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.applications-title {
|
||||||
|
@include settings-big-title;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type=submit] {
|
||||||
|
@include peertube-button;
|
||||||
|
@include orange-button;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
margin-left: auto;
|
||||||
|
|
||||||
|
& + .form-error {
|
||||||
|
display: inline;
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,57 @@
|
||||||
|
|
||||||
|
import { Component, OnInit } from '@angular/core'
|
||||||
|
import { AuthService, Notifier, ConfirmService } from '@app/core'
|
||||||
|
import { VideoService } from '@app/shared/shared-main'
|
||||||
|
import { FeedFormat } from '@shared/models'
|
||||||
|
import { Subject, merge } from 'rxjs'
|
||||||
|
import { debounceTime } from 'rxjs/operators'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'my-account-applications',
|
||||||
|
templateUrl: './my-account-applications.component.html',
|
||||||
|
styleUrls: [ './my-account-applications.component.scss' ]
|
||||||
|
})
|
||||||
|
export class MyAccountApplicationsComponent implements OnInit {
|
||||||
|
feedUrl: string
|
||||||
|
feedToken: string
|
||||||
|
|
||||||
|
private baseURL = window.location.protocol + '//' + window.location.host
|
||||||
|
private tokenStream = new Subject()
|
||||||
|
|
||||||
|
constructor (
|
||||||
|
private authService: AuthService,
|
||||||
|
private videoService: VideoService,
|
||||||
|
private notifier: Notifier,
|
||||||
|
private confirmService: ConfirmService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit () {
|
||||||
|
this.feedUrl = this.baseURL
|
||||||
|
|
||||||
|
merge(
|
||||||
|
this.tokenStream,
|
||||||
|
this.authService.userInformationLoaded
|
||||||
|
).pipe(debounceTime(400))
|
||||||
|
.subscribe(
|
||||||
|
_ => {
|
||||||
|
const user = this.authService.getUser()
|
||||||
|
this.videoService.getVideoSubscriptionFeedUrls(user.account.id)
|
||||||
|
.then(feeds => this.feedUrl = this.baseURL + feeds.find(f => f.format === FeedFormat.RSS).url)
|
||||||
|
.then(_ => this.authService.getScopedTokens().then(tokens => this.feedToken = tokens.feedToken))
|
||||||
|
},
|
||||||
|
|
||||||
|
err => {
|
||||||
|
this.notifier.error(err.message)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async renewToken () {
|
||||||
|
const res = await this.confirmService.confirm('Renewing the token will disallow previously configured clients from retrieving the feed until they use the new token. Proceed?', 'Renew token')
|
||||||
|
if (res === false) return
|
||||||
|
|
||||||
|
await this.authService.renewScopedTokens()
|
||||||
|
this.notifier.success('Token renewed. Update your client configuration accordingly.')
|
||||||
|
this.tokenStream.next()
|
||||||
|
}
|
||||||
|
}
|
|
@ -8,6 +8,7 @@ import { MyAccountServerBlocklistComponent } from './my-account-blocklist/my-acc
|
||||||
import { MyAccountNotificationsComponent } from './my-account-notifications/my-account-notifications.component'
|
import { MyAccountNotificationsComponent } from './my-account-notifications/my-account-notifications.component'
|
||||||
import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component'
|
import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component'
|
||||||
import { MyAccountComponent } from './my-account.component'
|
import { MyAccountComponent } from './my-account.component'
|
||||||
|
import { MyAccountApplicationsComponent } from './my-account-applications/my-account-applications.component'
|
||||||
|
|
||||||
const myAccountRoutes: Routes = [
|
const myAccountRoutes: Routes = [
|
||||||
{
|
{
|
||||||
|
@ -117,6 +118,15 @@ const myAccountRoutes: Routes = [
|
||||||
title: $localize`My abuse reports`
|
title: $localize`My abuse reports`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'applications',
|
||||||
|
component: MyAccountApplicationsComponent,
|
||||||
|
data: {
|
||||||
|
meta: {
|
||||||
|
title: 'Applications'
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -41,6 +41,11 @@ export class MyAccountComponent implements OnInit {
|
||||||
label: $localize`Abuse reports`,
|
label: $localize`Abuse reports`,
|
||||||
routerLink: '/my-account/abuses',
|
routerLink: '/my-account/abuses',
|
||||||
iconName: 'flag'
|
iconName: 'flag'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: $localize`Applications`,
|
||||||
|
routerLink: '/my-account/applications',
|
||||||
|
iconName: 'codesandbox'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,6 +21,7 @@ import { MyAccountNotificationPreferencesComponent } from './my-account-settings
|
||||||
import { MyAccountProfileComponent } from './my-account-settings/my-account-profile/my-account-profile.component'
|
import { MyAccountProfileComponent } from './my-account-settings/my-account-profile/my-account-profile.component'
|
||||||
import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component'
|
import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component'
|
||||||
import { MyAccountComponent } from './my-account.component'
|
import { MyAccountComponent } from './my-account.component'
|
||||||
|
import { VideoChangeOwnershipComponent } from './my-account-applications/my-account-applications.component'
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [
|
imports: [
|
||||||
|
@ -51,6 +52,7 @@ import { MyAccountComponent } from './my-account.component'
|
||||||
MyAccountAbusesListComponent,
|
MyAccountAbusesListComponent,
|
||||||
MyAccountServerBlocklistComponent,
|
MyAccountServerBlocklistComponent,
|
||||||
MyAccountNotificationsComponent,
|
MyAccountNotificationsComponent,
|
||||||
|
MyAccountNotificationPreferencesComponent,
|
||||||
MyAccountNotificationPreferencesComponent
|
MyAccountNotificationPreferencesComponent
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|
|
@ -3,9 +3,12 @@ import { ActivatedRoute, Router } from '@angular/router'
|
||||||
import { AuthService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core'
|
import { AuthService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core'
|
||||||
import { HooksService } from '@app/core/plugins/hooks.service'
|
import { HooksService } from '@app/core/plugins/hooks.service'
|
||||||
import { immutableAssign } from '@app/helpers'
|
import { immutableAssign } from '@app/helpers'
|
||||||
|
import { VideoService } from '@app/shared/shared-main'
|
||||||
import { UserSubscriptionService } from '@app/shared/shared-user-subscription'
|
import { UserSubscriptionService } from '@app/shared/shared-user-subscription'
|
||||||
import { AbstractVideoList, OwnerDisplayType } from '@app/shared/shared-video-miniature'
|
import { AbstractVideoList, OwnerDisplayType } from '@app/shared/shared-video-miniature'
|
||||||
import { VideoSortField } from '@shared/models'
|
import { VideoSortField, FeedFormat } from '@shared/models'
|
||||||
|
import { copyToClipboard } from '../../../root-helpers/utils'
|
||||||
|
import { environment } from '../../../environments/environment'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'my-videos-user-subscriptions',
|
selector: 'my-videos-user-subscriptions',
|
||||||
|
@ -28,11 +31,13 @@ export class VideoUserSubscriptionsComponent extends AbstractVideoList implement
|
||||||
protected screenService: ScreenService,
|
protected screenService: ScreenService,
|
||||||
protected storageService: LocalStorageService,
|
protected storageService: LocalStorageService,
|
||||||
private userSubscription: UserSubscriptionService,
|
private userSubscription: UserSubscriptionService,
|
||||||
private hooks: HooksService
|
private hooks: HooksService,
|
||||||
|
private videoService: VideoService
|
||||||
) {
|
) {
|
||||||
super()
|
super()
|
||||||
|
|
||||||
this.titlePage = $localize`Videos from your subscriptions`
|
this.titlePage = $localize`Videos from your subscriptions`
|
||||||
|
|
||||||
this.actions.push({
|
this.actions.push({
|
||||||
routerLink: '/my-library/subscriptions',
|
routerLink: '/my-library/subscriptions',
|
||||||
label: $localize`Subscriptions`,
|
label: $localize`Subscriptions`,
|
||||||
|
@ -42,6 +47,20 @@ export class VideoUserSubscriptionsComponent extends AbstractVideoList implement
|
||||||
|
|
||||||
ngOnInit () {
|
ngOnInit () {
|
||||||
super.ngOnInit()
|
super.ngOnInit()
|
||||||
|
|
||||||
|
const user = this.authService.getUser()
|
||||||
|
let feedUrl = environment.embedUrl
|
||||||
|
this.videoService.getVideoSubscriptionFeedUrls(user.account.id)
|
||||||
|
.then((feeds: any) => feedUrl = feedUrl + feeds.find((f: any) => f.format === FeedFormat.RSS).url)
|
||||||
|
this.actions.unshift({
|
||||||
|
label: $localize`Feed`,
|
||||||
|
iconName: 'syndication',
|
||||||
|
justIcon: true,
|
||||||
|
click: () => {
|
||||||
|
copyToClipboard(feedUrl)
|
||||||
|
this.activateCopiedMessage()
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy () {
|
ngOnDestroy () {
|
||||||
|
@ -68,4 +87,8 @@ export class VideoUserSubscriptionsComponent extends AbstractVideoList implement
|
||||||
generateSyndicationList () {
|
generateSyndicationList () {
|
||||||
// not implemented yet
|
// not implemented yet
|
||||||
}
|
}
|
||||||
|
|
||||||
|
activateCopiedMessage () {
|
||||||
|
this.notifier.success($localize`Feed URL copied`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,6 +11,7 @@ import { environment } from '../../../environments/environment'
|
||||||
import { RestExtractor } from '../rest/rest-extractor.service'
|
import { RestExtractor } from '../rest/rest-extractor.service'
|
||||||
import { AuthStatus } from './auth-status.model'
|
import { AuthStatus } from './auth-status.model'
|
||||||
import { AuthUser } from './auth-user.model'
|
import { AuthUser } from './auth-user.model'
|
||||||
|
import { ScopedTokenType, ScopedToken } from '@shared/models/users/user-scoped-token'
|
||||||
|
|
||||||
interface UserLoginWithUsername extends UserLogin {
|
interface UserLoginWithUsername extends UserLogin {
|
||||||
access_token: string
|
access_token: string
|
||||||
|
@ -26,6 +27,7 @@ export class AuthService {
|
||||||
private static BASE_CLIENT_URL = environment.apiUrl + '/api/v1/oauth-clients/local'
|
private static BASE_CLIENT_URL = environment.apiUrl + '/api/v1/oauth-clients/local'
|
||||||
private static BASE_TOKEN_URL = environment.apiUrl + '/api/v1/users/token'
|
private static BASE_TOKEN_URL = environment.apiUrl + '/api/v1/users/token'
|
||||||
private static BASE_REVOKE_TOKEN_URL = environment.apiUrl + '/api/v1/users/revoke-token'
|
private static BASE_REVOKE_TOKEN_URL = environment.apiUrl + '/api/v1/users/revoke-token'
|
||||||
|
private static BASE_SCOPED_TOKENS_URL = environment.apiUrl + '/api/v1/users/scoped-tokens'
|
||||||
private static BASE_USER_INFORMATION_URL = environment.apiUrl + '/api/v1/users/me'
|
private static BASE_USER_INFORMATION_URL = environment.apiUrl + '/api/v1/users/me'
|
||||||
private static LOCAL_STORAGE_OAUTH_CLIENT_KEYS = {
|
private static LOCAL_STORAGE_OAUTH_CLIENT_KEYS = {
|
||||||
CLIENT_ID: 'client_id',
|
CLIENT_ID: 'client_id',
|
||||||
|
@ -41,6 +43,7 @@ export class AuthService {
|
||||||
private loginChanged: Subject<AuthStatus>
|
private loginChanged: Subject<AuthStatus>
|
||||||
private user: AuthUser = null
|
private user: AuthUser = null
|
||||||
private refreshingTokenObservable: Observable<any>
|
private refreshingTokenObservable: Observable<any>
|
||||||
|
private scopedTokens: ScopedToken
|
||||||
|
|
||||||
constructor (
|
constructor (
|
||||||
private http: HttpClient,
|
private http: HttpClient,
|
||||||
|
@ -244,6 +247,48 @@ Ensure you have correctly configured PeerTube (config/ directory), in particular
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getScopedTokens (): Promise<ScopedToken> {
|
||||||
|
return new Promise((res, rej) => {
|
||||||
|
if (this.scopedTokens) return res(this.scopedTokens)
|
||||||
|
|
||||||
|
const authHeaderValue = this.getRequestHeaderValue()
|
||||||
|
const headers = new HttpHeaders().set('Authorization', authHeaderValue)
|
||||||
|
|
||||||
|
this.http.get<ScopedToken>(AuthService.BASE_SCOPED_TOKENS_URL, { headers })
|
||||||
|
.subscribe(
|
||||||
|
scopedTokens => {
|
||||||
|
this.scopedTokens = scopedTokens
|
||||||
|
res(this.scopedTokens)
|
||||||
|
},
|
||||||
|
|
||||||
|
err => {
|
||||||
|
console.error(err)
|
||||||
|
rej(err)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
renewScopedTokens (): Promise<ScopedToken> {
|
||||||
|
return new Promise((res, rej) => {
|
||||||
|
const authHeaderValue = this.getRequestHeaderValue()
|
||||||
|
const headers = new HttpHeaders().set('Authorization', authHeaderValue)
|
||||||
|
|
||||||
|
this.http.post<ScopedToken>(AuthService.BASE_SCOPED_TOKENS_URL, {}, { headers })
|
||||||
|
.subscribe(
|
||||||
|
scopedTokens => {
|
||||||
|
this.scopedTokens = scopedTokens
|
||||||
|
res(this.scopedTokens)
|
||||||
|
},
|
||||||
|
|
||||||
|
err => {
|
||||||
|
console.error(err)
|
||||||
|
rej(err)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
private mergeUserInformation (obj: UserLoginWithUsername): Observable<UserLoginWithUserInformation> {
|
private mergeUserInformation (obj: UserLoginWithUsername): Observable<UserLoginWithUserInformation> {
|
||||||
// User is not loaded yet, set manually auth header
|
// User is not loaded yet, set manually auth header
|
||||||
const headers = new HttpHeaders().set('Authorization', `${obj.token_type} ${obj.access_token}`)
|
const headers = new HttpHeaders().set('Authorization', `${obj.token_type} ${obj.access_token}`)
|
||||||
|
|
|
@ -69,7 +69,8 @@ const icons = {
|
||||||
'columns': require('!!raw-loader?!../../../assets/images/feather/columns.svg').default,
|
'columns': require('!!raw-loader?!../../../assets/images/feather/columns.svg').default,
|
||||||
'live': require('!!raw-loader?!../../../assets/images/feather/live.svg').default,
|
'live': require('!!raw-loader?!../../../assets/images/feather/live.svg').default,
|
||||||
'repeat': require('!!raw-loader?!../../../assets/images/feather/repeat.svg').default,
|
'repeat': require('!!raw-loader?!../../../assets/images/feather/repeat.svg').default,
|
||||||
'message-circle': require('!!raw-loader?!../../../assets/images/feather/message-circle.svg').default
|
'message-circle': require('!!raw-loader?!../../../assets/images/feather/message-circle.svg').default,
|
||||||
|
'codesandbox': require('!!raw-loader?!../../../assets/images/feather/codesandbox.svg').default
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GlobalIconName = keyof typeof icons
|
export type GlobalIconName = keyof typeof icons
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { Observable } from 'rxjs'
|
||||||
import { catchError, map, switchMap } from 'rxjs/operators'
|
import { catchError, map, switchMap } from 'rxjs/operators'
|
||||||
import { HttpClient, HttpParams, HttpRequest } from '@angular/common/http'
|
import { HttpClient, HttpParams, HttpRequest } from '@angular/common/http'
|
||||||
import { Injectable } from '@angular/core'
|
import { Injectable } from '@angular/core'
|
||||||
import { ComponentPaginationLight, RestExtractor, RestService, ServerService, UserService } from '@app/core'
|
import { ComponentPaginationLight, RestExtractor, RestService, ServerService, UserService, AuthService } from '@app/core'
|
||||||
import { objectToFormData } from '@app/helpers'
|
import { objectToFormData } from '@app/helpers'
|
||||||
import {
|
import {
|
||||||
FeedFormat,
|
FeedFormat,
|
||||||
|
@ -49,7 +49,8 @@ export class VideoService implements VideosProvider {
|
||||||
private authHttp: HttpClient,
|
private authHttp: HttpClient,
|
||||||
private restExtractor: RestExtractor,
|
private restExtractor: RestExtractor,
|
||||||
private restService: RestService,
|
private restService: RestService,
|
||||||
private serverService: ServerService
|
private serverService: ServerService,
|
||||||
|
private authService: AuthService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
getVideoViewUrl (uuid: string) {
|
getVideoViewUrl (uuid: string) {
|
||||||
|
@ -293,6 +294,16 @@ export class VideoService implements VideosProvider {
|
||||||
return this.buildBaseFeedUrls(params)
|
return this.buildBaseFeedUrls(params)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getVideoSubscriptionFeedUrls (accountId: number) {
|
||||||
|
let params = this.restService.addRestGetParams(new HttpParams())
|
||||||
|
params = params.set('accountId', accountId.toString())
|
||||||
|
|
||||||
|
const { feedToken } = await this.authService.getScopedTokens()
|
||||||
|
params = params.set('token', feedToken)
|
||||||
|
|
||||||
|
return this.buildBaseFeedUrls(params)
|
||||||
|
}
|
||||||
|
|
||||||
getVideoFileMetadata (metadataUrl: string) {
|
getVideoFileMetadata (metadataUrl: string) {
|
||||||
return this.authHttp
|
return this.authHttp
|
||||||
.get<VideoFileMetadata>(metadataUrl)
|
.get<VideoFileMetadata>(metadataUrl)
|
||||||
|
|
|
@ -8,9 +8,25 @@
|
||||||
|
|
||||||
<div class="action-block">
|
<div class="action-block">
|
||||||
<my-feed *ngIf="titlePage" [syndicationItems]="syndicationItems"></my-feed>
|
<my-feed *ngIf="titlePage" [syndicationItems]="syndicationItems"></my-feed>
|
||||||
<a [routerLink]="action.routerLink" routerLinkActive="active" *ngFor="let action of actions">
|
<ng-container *ngFor="let action of actions">
|
||||||
<my-button [icon]="action.iconName" [label]="action.label"></my-button>
|
<a *ngIf="action.routerLink" class="ml-2" [routerLink]="action.routerLink" routerLinkActive="active">
|
||||||
</a>
|
<ng-container *ngTemplateOutlet="actionContent; context:{ $implicit: action }"></ng-container>
|
||||||
|
</a>
|
||||||
|
<a *ngIf="!action.routerLink && action.click && !action.clipboard" class="ml-2" (click)="action.click()" (key.enter)="action.click()">
|
||||||
|
<ng-container *ngTemplateOutlet="actionContent; context:{ $implicit: action }"></ng-container>
|
||||||
|
</a>
|
||||||
|
<a *ngIf="!action.routerLink && !action.click && action.clipboard" class="ml-2" [cdkCopyToClipboard]="action.clipboard">
|
||||||
|
<ng-container *ngTemplateOutlet="actionContent; context:{ $implicit: action }"></ng-container>
|
||||||
|
</a>
|
||||||
|
<a *ngIf="!action.routerLink && action.click && action.clipboard" class="ml-2" (click)="action.click()" (key.enter)="action.click()" [cdkCopyToClipboard]="action.clipboard">
|
||||||
|
<ng-container *ngTemplateOutlet="actionContent; context:{ $implicit: action }"></ng-container>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<ng-template #actionContent let-action>
|
||||||
|
<my-button *ngIf="!action.justIcon" [icon]="action.iconName" [label]="action.label"></my-button>
|
||||||
|
<my-button *ngIf="action.justIcon" [icon]="action.iconName" [ngbTooltip]="action.label"></my-button>
|
||||||
|
</ng-template>
|
||||||
|
</ng-container>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="moderation-block" *ngIf="displayModerationBlock">
|
<div class="moderation-block" *ngIf="displayModerationBlock">
|
||||||
|
|
|
@ -70,9 +70,12 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy, DisableFor
|
||||||
}
|
}
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
routerLink: string
|
|
||||||
iconName: GlobalIconName
|
iconName: GlobalIconName
|
||||||
label: string
|
label: string
|
||||||
|
justIcon?: boolean
|
||||||
|
routerLink?: string
|
||||||
|
click?: Function
|
||||||
|
clipboard?: string
|
||||||
}[] = []
|
}[] = []
|
||||||
|
|
||||||
onDataSubject = new Subject<any[]>()
|
onDataSubject = new Subject<any[]>()
|
||||||
|
|
|
@ -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>
|
After Width: | Height: | Size: 638 B |
|
@ -35,7 +35,8 @@ import {
|
||||||
VideoJSPluginOptions
|
VideoJSPluginOptions
|
||||||
} from './peertube-videojs-typings'
|
} from './peertube-videojs-typings'
|
||||||
import { TranslationsManager } from './translations-manager'
|
import { TranslationsManager } from './translations-manager'
|
||||||
import { buildVideoOrPlaylistEmbed, buildVideoLink, copyToClipboard, getRtcConfig, isSafari, isIOS } from './utils'
|
import { buildVideoOrPlaylistEmbed, buildVideoLink, getRtcConfig, isSafari, isIOS } from './utils'
|
||||||
|
import { copyToClipboard } from '../../root-helpers/utils'
|
||||||
|
|
||||||
// Change 'Playback Rate' to 'Speed' (smaller for our settings menu)
|
// Change 'Playback Rate' to 'Speed' (smaller for our settings menu)
|
||||||
(videojs.getComponent('PlaybackRateMenuButton') as any).prototype.controlText_ = 'Speed'
|
(videojs.getComponent('PlaybackRateMenuButton') as any).prototype.controlText_ = 'Speed'
|
||||||
|
|
|
@ -176,18 +176,6 @@ function buildVideoOrPlaylistEmbed (embedUrl: string) {
|
||||||
'</iframe>'
|
'</iframe>'
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyToClipboard (text: string) {
|
|
||||||
const el = document.createElement('textarea')
|
|
||||||
el.value = text
|
|
||||||
el.setAttribute('readonly', '')
|
|
||||||
el.style.position = 'absolute'
|
|
||||||
el.style.left = '-9999px'
|
|
||||||
document.body.appendChild(el)
|
|
||||||
el.select()
|
|
||||||
document.execCommand('copy')
|
|
||||||
document.body.removeChild(el)
|
|
||||||
}
|
|
||||||
|
|
||||||
function videoFileMaxByResolution (files: VideoFile[]) {
|
function videoFileMaxByResolution (files: VideoFile[]) {
|
||||||
let max = files[0]
|
let max = files[0]
|
||||||
|
|
||||||
|
@ -236,7 +224,6 @@ export {
|
||||||
buildVideoOrPlaylistEmbed,
|
buildVideoOrPlaylistEmbed,
|
||||||
videoFileMaxByResolution,
|
videoFileMaxByResolution,
|
||||||
videoFileMinByResolution,
|
videoFileMinByResolution,
|
||||||
copyToClipboard,
|
|
||||||
isMobile,
|
isMobile,
|
||||||
bytes,
|
bytes,
|
||||||
isIOS,
|
isIOS,
|
||||||
|
|
|
@ -9,6 +9,18 @@ function objectToUrlEncoded (obj: any) {
|
||||||
return str.join('&')
|
return str.join('&')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function copyToClipboard (text: string) {
|
||||||
|
const el = document.createElement('textarea')
|
||||||
|
el.value = text
|
||||||
|
el.setAttribute('readonly', '')
|
||||||
|
el.style.position = 'absolute'
|
||||||
|
el.style.left = '-9999px'
|
||||||
|
document.body.appendChild(el)
|
||||||
|
el.select()
|
||||||
|
document.execCommand('copy')
|
||||||
|
document.body.removeChild(el)
|
||||||
|
}
|
||||||
|
|
||||||
// Thanks: https://github.com/uupaa/dynamic-import-polyfill
|
// Thanks: https://github.com/uupaa/dynamic-import-polyfill
|
||||||
function importModule (path: string) {
|
function importModule (path: string) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
@ -51,6 +63,7 @@ function wait (ms: number) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
copyToClipboard,
|
||||||
importModule,
|
importModule,
|
||||||
objectToUrlEncoded,
|
objectToUrlEncoded,
|
||||||
wait
|
wait
|
||||||
|
|
|
@ -225,7 +225,7 @@
|
||||||
line-height: $button-height;
|
line-height: $button-height;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 0 17px 0 13px;
|
padding: 0 13px 0 13px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,8 @@ import { CONFIG } from '@server/initializers/config'
|
||||||
import * as express from 'express'
|
import * as express from 'express'
|
||||||
import { Hooks } from '@server/lib/plugins/hooks'
|
import { Hooks } from '@server/lib/plugins/hooks'
|
||||||
import { asyncMiddleware, authenticate } from '@server/middlewares'
|
import { asyncMiddleware, authenticate } from '@server/middlewares'
|
||||||
|
import { ScopedToken } from '@shared/models/users/user-scoped-token'
|
||||||
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
|
|
||||||
const tokensRouter = express.Router()
|
const tokensRouter = express.Router()
|
||||||
|
|
||||||
|
@ -23,6 +25,16 @@ tokensRouter.post('/revoke-token',
|
||||||
asyncMiddleware(handleTokenRevocation)
|
asyncMiddleware(handleTokenRevocation)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
tokensRouter.get('/scoped-tokens',
|
||||||
|
authenticate,
|
||||||
|
getScopedTokens
|
||||||
|
)
|
||||||
|
|
||||||
|
tokensRouter.post('/scoped-tokens',
|
||||||
|
authenticate,
|
||||||
|
asyncMiddleware(renewScopedTokens)
|
||||||
|
)
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
@ -35,3 +47,22 @@ function tokenSuccess (req: express.Request) {
|
||||||
|
|
||||||
Hooks.runAction('action:api.user.oauth2-got-token', { username, ip: req.ip })
|
Hooks.runAction('action:api.user.oauth2-got-token', { username, ip: req.ip })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getScopedTokens (req: express.Request, res: express.Response) {
|
||||||
|
const user = res.locals.oauth.token.user
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
feedToken: user.feedToken
|
||||||
|
} as ScopedToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renewScopedTokens (req: express.Request, res: express.Response) {
|
||||||
|
const user = res.locals.oauth.token.user
|
||||||
|
|
||||||
|
user.feedToken = uuidv4()
|
||||||
|
await user.save()
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
feedToken: user.feedToken
|
||||||
|
} as ScopedToken)
|
||||||
|
}
|
||||||
|
|
|
@ -11,11 +11,14 @@ import {
|
||||||
setFeedFormatContentType,
|
setFeedFormatContentType,
|
||||||
videoCommentsFeedsValidator,
|
videoCommentsFeedsValidator,
|
||||||
videoFeedsValidator,
|
videoFeedsValidator,
|
||||||
videosSortValidator
|
videosSortValidator,
|
||||||
|
videoSubscriptonFeedsValidator
|
||||||
} from '../middlewares'
|
} from '../middlewares'
|
||||||
import { cacheRoute } from '../middlewares/cache'
|
import { cacheRoute } from '../middlewares/cache'
|
||||||
import { VideoModel } from '../models/video/video'
|
import { VideoModel } from '../models/video/video'
|
||||||
import { VideoCommentModel } from '../models/video/video-comment'
|
import { VideoCommentModel } from '../models/video/video-comment'
|
||||||
|
import { VideoFilter } from '../../shared/models/videos/video-query.type'
|
||||||
|
import { logger } from '../helpers/logger'
|
||||||
|
|
||||||
const feedsRouter = express.Router()
|
const feedsRouter = express.Router()
|
||||||
|
|
||||||
|
@ -44,6 +47,7 @@ feedsRouter.get('/feeds/videos.:format',
|
||||||
})(ROUTE_CACHE_LIFETIME.FEEDS)),
|
})(ROUTE_CACHE_LIFETIME.FEEDS)),
|
||||||
commonVideosFiltersValidator,
|
commonVideosFiltersValidator,
|
||||||
asyncMiddleware(videoFeedsValidator),
|
asyncMiddleware(videoFeedsValidator),
|
||||||
|
asyncMiddleware(videoSubscriptonFeedsValidator),
|
||||||
asyncMiddleware(generateVideoFeed)
|
asyncMiddleware(generateVideoFeed)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -124,6 +128,7 @@ async function generateVideoFeed (req: express.Request, res: express.Response) {
|
||||||
|
|
||||||
const account = res.locals.account
|
const account = res.locals.account
|
||||||
const videoChannel = res.locals.videoChannel
|
const videoChannel = res.locals.videoChannel
|
||||||
|
const token = req.query.token
|
||||||
const nsfw = buildNSFWFilter(res, req.query.nsfw)
|
const nsfw = buildNSFWFilter(res, req.query.nsfw)
|
||||||
|
|
||||||
let name: string
|
let name: string
|
||||||
|
@ -147,19 +152,36 @@ async function generateVideoFeed (req: express.Request, res: express.Response) {
|
||||||
queryString: new URL(WEBSERVER.URL + req.url).search
|
queryString: new URL(WEBSERVER.URL + req.url).search
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We have two ways to query video results:
|
||||||
|
* - one with account and token -> get subscription videos
|
||||||
|
* - one with either account, channel, or nothing: just videos with these filters
|
||||||
|
*/
|
||||||
|
const options = token && token !== '' && res.locals.user
|
||||||
|
? {
|
||||||
|
followerActorId: res.locals.user.Account.Actor.id,
|
||||||
|
user: res.locals.user,
|
||||||
|
includeLocalVideos: false
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
accountId: account ? account.id : null,
|
||||||
|
videoChannelId: videoChannel ? videoChannel.id : null
|
||||||
|
}
|
||||||
|
|
||||||
const resultList = await VideoModel.listForApi({
|
const resultList = await VideoModel.listForApi({
|
||||||
start,
|
start,
|
||||||
count: FEEDS.COUNT,
|
count: FEEDS.COUNT,
|
||||||
sort: req.query.sort,
|
sort: req.query.sort,
|
||||||
includeLocalVideos: true,
|
includeLocalVideos: true,
|
||||||
nsfw,
|
nsfw,
|
||||||
filter: req.query.filter,
|
filter: req.query.filter as VideoFilter,
|
||||||
withFiles: true,
|
withFiles: true,
|
||||||
accountId: account ? account.id : null,
|
...options
|
||||||
videoChannelId: videoChannel ? videoChannel.id : null
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Adding video items to the feed, one at a time
|
/**
|
||||||
|
* Adding video items to the feed object, one at a time
|
||||||
|
*/
|
||||||
resultList.data.forEach(video => {
|
resultList.data.forEach(video => {
|
||||||
const formattedVideoFiles = video.getFormattedVideoFilesJSON()
|
const formattedVideoFiles = video.getFormattedVideoFilesJSON()
|
||||||
|
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { Response } from 'express'
|
||||||
import { AccountModel } from '../../models/account/account'
|
import { AccountModel } from '../../models/account/account'
|
||||||
import * as Bluebird from 'bluebird'
|
import * as Bluebird from 'bluebird'
|
||||||
import { MAccountDefault } from '../../types/models'
|
import { MAccountDefault } from '../../types/models'
|
||||||
|
import { UserModel } from '@server/models/account/user'
|
||||||
|
|
||||||
function doesAccountIdExist (id: number | string, res: Response, sendNotFound = true) {
|
function doesAccountIdExist (id: number | string, res: Response, sendNotFound = true) {
|
||||||
const promise = AccountModel.load(parseInt(id + '', 10))
|
const promise = AccountModel.load(parseInt(id + '', 10))
|
||||||
|
@ -39,11 +40,28 @@ async function doesAccountExist (p: Bluebird<MAccountDefault>, res: Response, se
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function doesUserFeedTokenCorrespond (id: number | string, token: string, res: Response) {
|
||||||
|
const user = await UserModel.loadById(parseInt(id + '', 10))
|
||||||
|
|
||||||
|
if (token !== user.feedToken) {
|
||||||
|
res.status(401)
|
||||||
|
.send({ error: 'User and token mismatch' })
|
||||||
|
.end()
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
res.locals.user = user
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export {
|
export {
|
||||||
doesAccountIdExist,
|
doesAccountIdExist,
|
||||||
doesLocalAccountNameExist,
|
doesLocalAccountNameExist,
|
||||||
doesAccountNameWithHostExist,
|
doesAccountNameWithHostExist,
|
||||||
doesAccountExist
|
doesAccountExist,
|
||||||
|
doesUserFeedTokenCorrespond
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,40 @@
|
||||||
|
import * as Sequelize from 'sequelize'
|
||||||
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
|
|
||||||
|
async function up (utils: {
|
||||||
|
transaction: Sequelize.Transaction
|
||||||
|
queryInterface: Sequelize.QueryInterface
|
||||||
|
sequelize: Sequelize.Sequelize
|
||||||
|
db: any
|
||||||
|
}): Promise<void> {
|
||||||
|
const q = utils.queryInterface
|
||||||
|
|
||||||
|
// Create uuid column for users
|
||||||
|
const userFeedTokenUUID = {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
defaultValue: Sequelize.UUIDV4,
|
||||||
|
allowNull: true
|
||||||
|
}
|
||||||
|
await q.addColumn('user', 'feedToken', userFeedTokenUUID)
|
||||||
|
|
||||||
|
// Set UUID to previous users
|
||||||
|
{
|
||||||
|
const query = 'SELECT * FROM "user" WHERE "feedToken" IS NULL'
|
||||||
|
const options = { type: Sequelize.QueryTypes.SELECT as Sequelize.QueryTypes.SELECT }
|
||||||
|
const users = await utils.sequelize.query<any>(query, options)
|
||||||
|
|
||||||
|
for (const user of users) {
|
||||||
|
const queryUpdate = `UPDATE "user" SET "feedToken" = '${uuidv4()}' WHERE id = ${user.id}`
|
||||||
|
await utils.sequelize.query(queryUpdate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function down (options) {
|
||||||
|
throw new Error('Not implemented.')
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
up,
|
||||||
|
down
|
||||||
|
}
|
|
@ -9,7 +9,8 @@ import {
|
||||||
doesAccountIdExist,
|
doesAccountIdExist,
|
||||||
doesAccountNameWithHostExist,
|
doesAccountNameWithHostExist,
|
||||||
doesVideoChannelIdExist,
|
doesVideoChannelIdExist,
|
||||||
doesVideoChannelNameWithHostExist
|
doesVideoChannelNameWithHostExist,
|
||||||
|
doesUserFeedTokenCorrespond
|
||||||
} from '../../helpers/middlewares'
|
} from '../../helpers/middlewares'
|
||||||
|
|
||||||
const feedsFormatValidator = [
|
const feedsFormatValidator = [
|
||||||
|
@ -62,6 +63,23 @@ const videoFeedsValidator = [
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const videoSubscriptonFeedsValidator = [
|
||||||
|
query('accountId').optional().custom(isIdValid),
|
||||||
|
query('token').optional(),
|
||||||
|
|
||||||
|
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
|
logger.debug('Checking feeds parameters', { parameters: req.query })
|
||||||
|
|
||||||
|
if (areValidationErrors(req, res)) return
|
||||||
|
|
||||||
|
// a token alone is erroneous
|
||||||
|
if (req.query.token && !req.query.accountId) return
|
||||||
|
if (req.query.token && !await doesUserFeedTokenCorrespond(res.locals.account.userId, req.query.token, res)) return
|
||||||
|
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
const videoCommentsFeedsValidator = [
|
const videoCommentsFeedsValidator = [
|
||||||
query('videoId').optional().custom(isIdOrUUIDValid),
|
query('videoId').optional().custom(isIdOrUUIDValid),
|
||||||
|
|
||||||
|
@ -88,5 +106,6 @@ export {
|
||||||
feedsFormatValidator,
|
feedsFormatValidator,
|
||||||
setFeedFormatContentType,
|
setFeedFormatContentType,
|
||||||
videoFeedsValidator,
|
videoFeedsValidator,
|
||||||
|
videoSubscriptonFeedsValidator,
|
||||||
videoCommentsFeedsValidator
|
videoCommentsFeedsValidator
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,7 +19,8 @@ import {
|
||||||
Model,
|
Model,
|
||||||
Scopes,
|
Scopes,
|
||||||
Table,
|
Table,
|
||||||
UpdatedAt
|
UpdatedAt,
|
||||||
|
IsUUID
|
||||||
} from 'sequelize-typescript'
|
} from 'sequelize-typescript'
|
||||||
import {
|
import {
|
||||||
MMyUserFormattable,
|
MMyUserFormattable,
|
||||||
|
@ -353,6 +354,12 @@ export class UserModel extends Model<UserModel> {
|
||||||
@Column
|
@Column
|
||||||
pluginAuth: string
|
pluginAuth: string
|
||||||
|
|
||||||
|
@AllowNull(false)
|
||||||
|
@Default(DataType.UUIDV4)
|
||||||
|
@IsUUID(4)
|
||||||
|
@Column(DataType.UUID)
|
||||||
|
feedToken: string
|
||||||
|
|
||||||
@AllowNull(true)
|
@AllowNull(true)
|
||||||
@Default(null)
|
@Default(null)
|
||||||
@Column
|
@Column
|
||||||
|
|
|
@ -22,11 +22,14 @@ import {
|
||||||
uploadVideo,
|
uploadVideo,
|
||||||
uploadVideoAndGetId,
|
uploadVideoAndGetId,
|
||||||
userLogin,
|
userLogin,
|
||||||
flushAndRunServer
|
flushAndRunServer,
|
||||||
|
getUserScopedTokens
|
||||||
} from '../../../shared/extra-utils'
|
} from '../../../shared/extra-utils'
|
||||||
import { waitJobs } from '../../../shared/extra-utils/server/jobs'
|
import { waitJobs } from '../../../shared/extra-utils/server/jobs'
|
||||||
import { addVideoCommentThread } from '../../../shared/extra-utils/videos/video-comments'
|
import { addVideoCommentThread } from '../../../shared/extra-utils/videos/video-comments'
|
||||||
import { User } from '../../../shared/models/users'
|
import { User } from '../../../shared/models/users'
|
||||||
|
import { ScopedToken } from '@shared/models/users/user-scoped-token'
|
||||||
|
import { listUserSubscriptionVideos, addUserSubscription } from '@shared/extra-utils/users/user-subscriptions'
|
||||||
|
|
||||||
chai.use(require('chai-xml'))
|
chai.use(require('chai-xml'))
|
||||||
chai.use(require('chai-json-schema'))
|
chai.use(require('chai-json-schema'))
|
||||||
|
@ -41,6 +44,7 @@ describe('Test syndication feeds', () => {
|
||||||
let rootChannelId: number
|
let rootChannelId: number
|
||||||
let userAccountId: number
|
let userAccountId: number
|
||||||
let userChannelId: number
|
let userChannelId: number
|
||||||
|
let userFeedToken: string
|
||||||
|
|
||||||
before(async function () {
|
before(async function () {
|
||||||
this.timeout(120000)
|
this.timeout(120000)
|
||||||
|
@ -74,6 +78,10 @@ describe('Test syndication feeds', () => {
|
||||||
const user: User = res.body
|
const user: User = res.body
|
||||||
userAccountId = user.account.id
|
userAccountId = user.account.id
|
||||||
userChannelId = user.videoChannels[0].id
|
userChannelId = user.videoChannels[0].id
|
||||||
|
|
||||||
|
const res2 = await getUserScopedTokens(servers[0].url, userAccessToken)
|
||||||
|
const token: ScopedToken = res2.body
|
||||||
|
userFeedToken = token.feedToken
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
|
@ -289,6 +297,87 @@ describe('Test syndication feeds', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('Video feed from my subscriptions', function () {
|
||||||
|
/**
|
||||||
|
* use the 'version' query parameter to bust cache between tests
|
||||||
|
*/
|
||||||
|
|
||||||
|
it('Should list no videos for a user with no videos and no subscriptions', async function () {
|
||||||
|
let feeduserAccountId: number
|
||||||
|
let feeduserFeedToken: string
|
||||||
|
|
||||||
|
const attr = { username: 'feeduser', password: 'password' }
|
||||||
|
await createUser({ url: servers[0].url, accessToken: servers[0].accessToken, username: attr.username, password: attr.password })
|
||||||
|
const feeduserAccessToken = await userLogin(servers[0], attr)
|
||||||
|
|
||||||
|
{
|
||||||
|
const res = await getMyUserInformation(servers[0].url, feeduserAccessToken)
|
||||||
|
const user: User = res.body
|
||||||
|
feeduserAccountId = user.account.id
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const res = await getUserScopedTokens(servers[0].url, feeduserAccessToken)
|
||||||
|
const token: ScopedToken = res.body
|
||||||
|
feeduserFeedToken = token.feedToken
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const res = await listUserSubscriptionVideos(servers[0].url, feeduserAccessToken)
|
||||||
|
expect(res.body.total).to.equal(0)
|
||||||
|
|
||||||
|
const json = await getJSONfeed(servers[0].url, 'videos', { accountId: feeduserAccountId, token: feeduserFeedToken })
|
||||||
|
const jsonObj = JSON.parse(json.text)
|
||||||
|
expect(jsonObj.items.length).to.be.equal(0) // no subscription, it should not list the instance's videos but list 0 videos
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should list no videos for a user with videos but no subscriptions', async function () {
|
||||||
|
{
|
||||||
|
const res = await listUserSubscriptionVideos(servers[0].url, userAccessToken)
|
||||||
|
expect(res.body.total).to.equal(0)
|
||||||
|
|
||||||
|
const json = await getJSONfeed(servers[0].url, 'videos', { accountId: userAccountId, token: userFeedToken })
|
||||||
|
const jsonObj = JSON.parse(json.text)
|
||||||
|
expect(jsonObj.items.length).to.be.equal(0) // no subscription, it should not list the instance's videos but list 0 videos
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should list self videos for a user with a subscription to themselves', async function () {
|
||||||
|
this.timeout(30000)
|
||||||
|
|
||||||
|
await addUserSubscription(servers[0].url, userAccessToken, 'john_channel@localhost:' + servers[0].port)
|
||||||
|
await waitJobs(servers)
|
||||||
|
|
||||||
|
{
|
||||||
|
const res = await listUserSubscriptionVideos(servers[0].url, userAccessToken)
|
||||||
|
expect(res.body.total).to.equal(1)
|
||||||
|
expect(res.body.data[0].name).to.equal('user video')
|
||||||
|
|
||||||
|
const json = await getJSONfeed(servers[0].url, 'videos', { accountId: userAccountId, token: userFeedToken, version: 1 })
|
||||||
|
const jsonObj = JSON.parse(json.text)
|
||||||
|
expect(jsonObj.items.length).to.be.equal(1) // subscribed to self, it should not list the instance's videos but list john's
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should list videos of a user\'s subscription', async function () {
|
||||||
|
this.timeout(30000)
|
||||||
|
|
||||||
|
await addUserSubscription(servers[0].url, userAccessToken, 'root_channel@localhost:' + servers[0].port)
|
||||||
|
await waitJobs(servers)
|
||||||
|
|
||||||
|
{
|
||||||
|
const res = await listUserSubscriptionVideos(servers[0].url, userAccessToken)
|
||||||
|
expect(res.body.total).to.equal(2, "there should be 2 videos part of the subscription")
|
||||||
|
|
||||||
|
const json = await getJSONfeed(servers[0].url, 'videos', { accountId: userAccountId, token: userFeedToken, version: 2 })
|
||||||
|
const jsonObj = JSON.parse(json.text)
|
||||||
|
expect(jsonObj.items.length).to.be.equal(2) // subscribed to root, it should not list the instance's videos but list root/john's
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
after(async function () {
|
after(async function () {
|
||||||
await cleanupTests([ ...servers, serverHLSOnly ])
|
await cleanupTests([ ...servers, serverHLSOnly ])
|
||||||
})
|
})
|
||||||
|
|
|
@ -109,6 +109,17 @@ function getMyUserInformation (url: string, accessToken: string, specialStatus =
|
||||||
.expect('Content-Type', /json/)
|
.expect('Content-Type', /json/)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getUserScopedTokens (url: string, accessToken: string, specialStatus = 200) {
|
||||||
|
const path = '/api/v1/users/scoped-tokens'
|
||||||
|
|
||||||
|
return request(url)
|
||||||
|
.get(path)
|
||||||
|
.set('Accept', 'application/json')
|
||||||
|
.set('Authorization', 'Bearer ' + accessToken)
|
||||||
|
.expect(specialStatus)
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
}
|
||||||
|
|
||||||
function deleteMe (url: string, accessToken: string, specialStatus = 204) {
|
function deleteMe (url: string, accessToken: string, specialStatus = 204) {
|
||||||
const path = '/api/v1/users/me'
|
const path = '/api/v1/users/me'
|
||||||
|
|
||||||
|
@ -351,5 +362,6 @@ export {
|
||||||
updateMyAvatar,
|
updateMyAvatar,
|
||||||
askSendVerifyEmail,
|
askSendVerifyEmail,
|
||||||
generateUserAccessToken,
|
generateUserAccessToken,
|
||||||
verifyEmail
|
verifyEmail,
|
||||||
|
getUserScopedTokens
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
export type ScopedTokenType = 'feedToken'
|
||||||
|
|
||||||
|
export type ScopedToken = {
|
||||||
|
feedToken: string
|
||||||
|
}
|
Loading…
Reference in New Issue