APPEARANCE
-
diff --git a/client/src/app/+my-account/my-account-abuses/my-account-abuses-list.component.ts b/client/src/app/+my-account/my-account-abuses/my-account-abuses-list.component.ts
index e5dd723ff..9316fc0dd 100644
--- a/client/src/app/+my-account/my-account-abuses/my-account-abuses-list.component.ts
+++ b/client/src/app/+my-account/my-account-abuses/my-account-abuses-list.component.ts
@@ -1,4 +1,3 @@
-
import { Component } from '@angular/core'
@Component({
diff --git a/client/src/app/+my-account/my-account-applications/my-account-applications.component.html b/client/src/app/+my-account/my-account-applications/my-account-applications.component.html
new file mode 100644
index 000000000..62e2cb59b
--- /dev/null
+++ b/client/src/app/+my-account/my-account-applications/my-account-applications.component.html
@@ -0,0 +1,35 @@
+
+
+ Applications
+
+
+
+
+
diff --git a/client/src/app/+my-account/my-account-applications/my-account-applications.component.scss b/client/src/app/+my-account/my-account-applications/my-account-applications.component.scss
new file mode 100644
index 000000000..704132c03
--- /dev/null
+++ b/client/src/app/+my-account/my-account-applications/my-account-applications.component.scss
@@ -0,0 +1,28 @@
+@import '_variables';
+@import '_mixins';
+
+label {
+ font-weight: $font-regular;
+ font-size: 100%;
+}
+
+.applications-title {
+ @include settings-big-title;
+}
+
+.form-group {
+ max-width: 500px;
+}
+
+input[type=submit] {
+ @include peertube-button;
+ @include orange-button;
+
+ display: flex;
+ margin-left: auto;
+
+ & + .form-error {
+ display: inline;
+ margin-left: 5px;
+ }
+}
diff --git a/client/src/app/+my-account/my-account-applications/my-account-applications.component.ts b/client/src/app/+my-account/my-account-applications/my-account-applications.component.ts
new file mode 100644
index 000000000..c3f09dfe3
--- /dev/null
+++ b/client/src/app/+my-account/my-account-applications/my-account-applications.component.ts
@@ -0,0 +1,57 @@
+
+import { Component, OnInit } from '@angular/core'
+import { AuthService, Notifier, ConfirmService } from '@app/core'
+import { VideoService } from '@app/shared/shared-main'
+import { FeedFormat } from '@shared/models'
+import { Subject, merge } from 'rxjs'
+import { debounceTime } from 'rxjs/operators'
+
+@Component({
+ selector: 'my-account-applications',
+ templateUrl: './my-account-applications.component.html',
+ styleUrls: [ './my-account-applications.component.scss' ]
+})
+export class MyAccountApplicationsComponent implements OnInit {
+ feedUrl: string
+ feedToken: string
+
+ private baseURL = window.location.protocol + '//' + window.location.host
+ private tokenStream = new Subject()
+
+ constructor (
+ private authService: AuthService,
+ private videoService: VideoService,
+ private notifier: Notifier,
+ private confirmService: ConfirmService
+ ) {}
+
+ ngOnInit () {
+ this.feedUrl = this.baseURL
+
+ merge(
+ this.tokenStream,
+ this.authService.userInformationLoaded
+ ).pipe(debounceTime(400))
+ .subscribe(
+ _ => {
+ const user = this.authService.getUser()
+ this.videoService.getVideoSubscriptionFeedUrls(user.account.id)
+ .then(feeds => this.feedUrl = this.baseURL + feeds.find(f => f.format === FeedFormat.RSS).url)
+ .then(_ => this.authService.getScopedTokens().then(tokens => this.feedToken = tokens.feedToken))
+ },
+
+ err => {
+ this.notifier.error(err.message)
+ }
+ )
+ }
+
+ async renewToken () {
+ const res = await this.confirmService.confirm('Renewing the token will disallow previously configured clients from retrieving the feed until they use the new token. Proceed?', 'Renew token')
+ if (res === false) return
+
+ await this.authService.renewScopedTokens()
+ this.notifier.success('Token renewed. Update your client configuration accordingly.')
+ this.tokenStream.next()
+ }
+}
diff --git a/client/src/app/+my-account/my-account-routing.module.ts b/client/src/app/+my-account/my-account-routing.module.ts
index 81380ec6e..226a4a7be 100644
--- a/client/src/app/+my-account/my-account-routing.module.ts
+++ b/client/src/app/+my-account/my-account-routing.module.ts
@@ -8,6 +8,7 @@ import { MyAccountServerBlocklistComponent } from './my-account-blocklist/my-acc
import { MyAccountNotificationsComponent } from './my-account-notifications/my-account-notifications.component'
import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component'
import { MyAccountComponent } from './my-account.component'
+import { MyAccountApplicationsComponent } from './my-account-applications/my-account-applications.component'
const myAccountRoutes: Routes = [
{
@@ -117,6 +118,15 @@ const myAccountRoutes: Routes = [
title: $localize`My abuse reports`
}
}
+ },
+ {
+ path: 'applications',
+ component: MyAccountApplicationsComponent,
+ data: {
+ meta: {
+ title: 'Applications'
+ }
+ }
}
]
}
diff --git a/client/src/app/+my-account/my-account.component.ts b/client/src/app/+my-account/my-account.component.ts
index d6e9d1c15..12966aebb 100644
--- a/client/src/app/+my-account/my-account.component.ts
+++ b/client/src/app/+my-account/my-account.component.ts
@@ -41,6 +41,11 @@ export class MyAccountComponent implements OnInit {
label: $localize`Abuse reports`,
routerLink: '/my-account/abuses',
iconName: 'flag'
+ },
+ {
+ label: $localize`Applications`,
+ routerLink: '/my-account/applications',
+ iconName: 'codesandbox'
}
]
}
diff --git a/client/src/app/+my-account/my-account.module.ts b/client/src/app/+my-account/my-account.module.ts
index 9e3fbcf65..70bf58aae 100644
--- a/client/src/app/+my-account/my-account.module.ts
+++ b/client/src/app/+my-account/my-account.module.ts
@@ -21,6 +21,7 @@ import { MyAccountNotificationPreferencesComponent } from './my-account-settings
import { MyAccountProfileComponent } from './my-account-settings/my-account-profile/my-account-profile.component'
import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component'
import { MyAccountComponent } from './my-account.component'
+import { VideoChangeOwnershipComponent } from './my-account-applications/my-account-applications.component'
@NgModule({
imports: [
@@ -51,6 +52,7 @@ import { MyAccountComponent } from './my-account.component'
MyAccountAbusesListComponent,
MyAccountServerBlocklistComponent,
MyAccountNotificationsComponent,
+ MyAccountNotificationPreferencesComponent,
MyAccountNotificationPreferencesComponent
],
diff --git a/client/src/app/+videos/video-list/video-user-subscriptions.component.ts b/client/src/app/+videos/video-list/video-user-subscriptions.component.ts
index 6988c574b..10031d6cc 100644
--- a/client/src/app/+videos/video-list/video-user-subscriptions.component.ts
+++ b/client/src/app/+videos/video-list/video-user-subscriptions.component.ts
@@ -3,9 +3,12 @@ import { ActivatedRoute, Router } from '@angular/router'
import { AuthService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core'
import { HooksService } from '@app/core/plugins/hooks.service'
import { immutableAssign } from '@app/helpers'
+import { VideoService } from '@app/shared/shared-main'
import { UserSubscriptionService } from '@app/shared/shared-user-subscription'
import { AbstractVideoList, OwnerDisplayType } from '@app/shared/shared-video-miniature'
-import { VideoSortField } from '@shared/models'
+import { VideoSortField, FeedFormat } from '@shared/models'
+import { copyToClipboard } from '../../../root-helpers/utils'
+import { environment } from '../../../environments/environment'
@Component({
selector: 'my-videos-user-subscriptions',
@@ -28,11 +31,13 @@ export class VideoUserSubscriptionsComponent extends AbstractVideoList implement
protected screenService: ScreenService,
protected storageService: LocalStorageService,
private userSubscription: UserSubscriptionService,
- private hooks: HooksService
+ private hooks: HooksService,
+ private videoService: VideoService
) {
super()
this.titlePage = $localize`Videos from your subscriptions`
+
this.actions.push({
routerLink: '/my-library/subscriptions',
label: $localize`Subscriptions`,
@@ -42,6 +47,20 @@ export class VideoUserSubscriptionsComponent extends AbstractVideoList implement
ngOnInit () {
super.ngOnInit()
+
+ const user = this.authService.getUser()
+ let feedUrl = environment.embedUrl
+ this.videoService.getVideoSubscriptionFeedUrls(user.account.id)
+ .then((feeds: any) => feedUrl = feedUrl + feeds.find((f: any) => f.format === FeedFormat.RSS).url)
+ this.actions.unshift({
+ label: $localize`Feed`,
+ iconName: 'syndication',
+ justIcon: true,
+ click: () => {
+ copyToClipboard(feedUrl)
+ this.activateCopiedMessage()
+ }
+ })
}
ngOnDestroy () {
@@ -68,4 +87,8 @@ export class VideoUserSubscriptionsComponent extends AbstractVideoList implement
generateSyndicationList () {
// not implemented yet
}
+
+ activateCopiedMessage () {
+ this.notifier.success($localize`Feed URL copied`)
+ }
}
diff --git a/client/src/app/core/auth/auth.service.ts b/client/src/app/core/auth/auth.service.ts
index fd6062d3f..224f35f82 100644
--- a/client/src/app/core/auth/auth.service.ts
+++ b/client/src/app/core/auth/auth.service.ts
@@ -11,6 +11,7 @@ import { environment } from '../../../environments/environment'
import { RestExtractor } from '../rest/rest-extractor.service'
import { AuthStatus } from './auth-status.model'
import { AuthUser } from './auth-user.model'
+import { ScopedTokenType, ScopedToken } from '@shared/models/users/user-scoped-token'
interface UserLoginWithUsername extends UserLogin {
access_token: string
@@ -26,6 +27,7 @@ export class AuthService {
private static BASE_CLIENT_URL = environment.apiUrl + '/api/v1/oauth-clients/local'
private static BASE_TOKEN_URL = environment.apiUrl + '/api/v1/users/token'
private static BASE_REVOKE_TOKEN_URL = environment.apiUrl + '/api/v1/users/revoke-token'
+ private static BASE_SCOPED_TOKENS_URL = environment.apiUrl + '/api/v1/users/scoped-tokens'
private static BASE_USER_INFORMATION_URL = environment.apiUrl + '/api/v1/users/me'
private static LOCAL_STORAGE_OAUTH_CLIENT_KEYS = {
CLIENT_ID: 'client_id',
@@ -41,6 +43,7 @@ export class AuthService {
private loginChanged: Subject
private user: AuthUser = null
private refreshingTokenObservable: Observable
+ private scopedTokens: ScopedToken
constructor (
private http: HttpClient,
@@ -244,6 +247,48 @@ Ensure you have correctly configured PeerTube (config/ directory), in particular
)
}
+ getScopedTokens (): Promise {
+ return new Promise((res, rej) => {
+ if (this.scopedTokens) return res(this.scopedTokens)
+
+ const authHeaderValue = this.getRequestHeaderValue()
+ const headers = new HttpHeaders().set('Authorization', authHeaderValue)
+
+ this.http.get(AuthService.BASE_SCOPED_TOKENS_URL, { headers })
+ .subscribe(
+ scopedTokens => {
+ this.scopedTokens = scopedTokens
+ res(this.scopedTokens)
+ },
+
+ err => {
+ console.error(err)
+ rej(err)
+ }
+ )
+ })
+ }
+
+ renewScopedTokens (): Promise {
+ return new Promise((res, rej) => {
+ const authHeaderValue = this.getRequestHeaderValue()
+ const headers = new HttpHeaders().set('Authorization', authHeaderValue)
+
+ this.http.post(AuthService.BASE_SCOPED_TOKENS_URL, {}, { headers })
+ .subscribe(
+ scopedTokens => {
+ this.scopedTokens = scopedTokens
+ res(this.scopedTokens)
+ },
+
+ err => {
+ console.error(err)
+ rej(err)
+ }
+ )
+ })
+ }
+
private mergeUserInformation (obj: UserLoginWithUsername): Observable {
// User is not loaded yet, set manually auth header
const headers = new HttpHeaders().set('Authorization', `${obj.token_type} ${obj.access_token}`)
diff --git a/client/src/app/shared/shared-icons/global-icon.component.ts b/client/src/app/shared/shared-icons/global-icon.component.ts
index f3c1fe59b..53a2aee9a 100644
--- a/client/src/app/shared/shared-icons/global-icon.component.ts
+++ b/client/src/app/shared/shared-icons/global-icon.component.ts
@@ -69,7 +69,8 @@ const icons = {
'columns': require('!!raw-loader?!../../../assets/images/feather/columns.svg').default,
'live': require('!!raw-loader?!../../../assets/images/feather/live.svg').default,
'repeat': require('!!raw-loader?!../../../assets/images/feather/repeat.svg').default,
- 'message-circle': require('!!raw-loader?!../../../assets/images/feather/message-circle.svg').default
+ 'message-circle': require('!!raw-loader?!../../../assets/images/feather/message-circle.svg').default,
+ 'codesandbox': require('!!raw-loader?!../../../assets/images/feather/codesandbox.svg').default
}
export type GlobalIconName = keyof typeof icons
diff --git a/client/src/app/shared/shared-main/video/video.service.ts b/client/src/app/shared/shared-main/video/video.service.ts
index c8a3ec043..b81540e8d 100644
--- a/client/src/app/shared/shared-main/video/video.service.ts
+++ b/client/src/app/shared/shared-main/video/video.service.ts
@@ -2,7 +2,7 @@ import { Observable } from 'rxjs'
import { catchError, map, switchMap } from 'rxjs/operators'
import { HttpClient, HttpParams, HttpRequest } from '@angular/common/http'
import { Injectable } from '@angular/core'
-import { ComponentPaginationLight, RestExtractor, RestService, ServerService, UserService } from '@app/core'
+import { ComponentPaginationLight, RestExtractor, RestService, ServerService, UserService, AuthService } from '@app/core'
import { objectToFormData } from '@app/helpers'
import {
FeedFormat,
@@ -49,7 +49,8 @@ export class VideoService implements VideosProvider {
private authHttp: HttpClient,
private restExtractor: RestExtractor,
private restService: RestService,
- private serverService: ServerService
+ private serverService: ServerService,
+ private authService: AuthService
) {}
getVideoViewUrl (uuid: string) {
@@ -293,6 +294,16 @@ export class VideoService implements VideosProvider {
return this.buildBaseFeedUrls(params)
}
+ async getVideoSubscriptionFeedUrls (accountId: number) {
+ let params = this.restService.addRestGetParams(new HttpParams())
+ params = params.set('accountId', accountId.toString())
+
+ const { feedToken } = await this.authService.getScopedTokens()
+ params = params.set('token', feedToken)
+
+ return this.buildBaseFeedUrls(params)
+ }
+
getVideoFileMetadata (metadataUrl: string) {
return this.authHttp
.get(metadataUrl)
diff --git a/client/src/app/shared/shared-video-miniature/abstract-video-list.html b/client/src/app/shared/shared-video-miniature/abstract-video-list.html
index b1ac757db..18294513f 100644
--- a/client/src/app/shared/shared-video-miniature/abstract-video-list.html
+++ b/client/src/app/shared/shared-video-miniature/abstract-video-list.html
@@ -8,9 +8,25 @@
diff --git a/client/src/app/shared/shared-video-miniature/abstract-video-list.ts b/client/src/app/shared/shared-video-miniature/abstract-video-list.ts
index 2219ced30..c55e85afe 100644
--- a/client/src/app/shared/shared-video-miniature/abstract-video-list.ts
+++ b/client/src/app/shared/shared-video-miniature/abstract-video-list.ts
@@ -70,9 +70,12 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy, DisableFor
}
actions: {
- routerLink: string
iconName: GlobalIconName
label: string
+ justIcon?: boolean
+ routerLink?: string
+ click?: Function
+ clipboard?: string
}[] = []
onDataSubject = new Subject
()
diff --git a/client/src/assets/images/feather/codesandbox.svg b/client/src/assets/images/feather/codesandbox.svg
new file mode 100644
index 000000000..49848f520
--- /dev/null
+++ b/client/src/assets/images/feather/codesandbox.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/src/assets/player/peertube-player-manager.ts b/client/src/assets/player/peertube-player-manager.ts
index da23c59a7..9407cf123 100644
--- a/client/src/assets/player/peertube-player-manager.ts
+++ b/client/src/assets/player/peertube-player-manager.ts
@@ -35,7 +35,8 @@ import {
VideoJSPluginOptions
} from './peertube-videojs-typings'
import { TranslationsManager } from './translations-manager'
-import { buildVideoOrPlaylistEmbed, buildVideoLink, copyToClipboard, getRtcConfig, isSafari, isIOS } from './utils'
+import { buildVideoOrPlaylistEmbed, buildVideoLink, getRtcConfig, isSafari, isIOS } from './utils'
+import { copyToClipboard } from '../../root-helpers/utils'
// Change 'Playback Rate' to 'Speed' (smaller for our settings menu)
(videojs.getComponent('PlaybackRateMenuButton') as any).prototype.controlText_ = 'Speed'
diff --git a/client/src/assets/player/utils.ts b/client/src/assets/player/utils.ts
index ce7a7fe6c..280f721bd 100644
--- a/client/src/assets/player/utils.ts
+++ b/client/src/assets/player/utils.ts
@@ -176,18 +176,6 @@ function buildVideoOrPlaylistEmbed (embedUrl: string) {
''
}
-function copyToClipboard (text: string) {
- const el = document.createElement('textarea')
- el.value = text
- el.setAttribute('readonly', '')
- el.style.position = 'absolute'
- el.style.left = '-9999px'
- document.body.appendChild(el)
- el.select()
- document.execCommand('copy')
- document.body.removeChild(el)
-}
-
function videoFileMaxByResolution (files: VideoFile[]) {
let max = files[0]
@@ -236,7 +224,6 @@ export {
buildVideoOrPlaylistEmbed,
videoFileMaxByResolution,
videoFileMinByResolution,
- copyToClipboard,
isMobile,
bytes,
isIOS,
diff --git a/client/src/root-helpers/utils.ts b/client/src/root-helpers/utils.ts
index de4e08bf5..e32187ddb 100644
--- a/client/src/root-helpers/utils.ts
+++ b/client/src/root-helpers/utils.ts
@@ -9,6 +9,18 @@ function objectToUrlEncoded (obj: any) {
return str.join('&')
}
+function copyToClipboard (text: string) {
+ const el = document.createElement('textarea')
+ el.value = text
+ el.setAttribute('readonly', '')
+ el.style.position = 'absolute'
+ el.style.left = '-9999px'
+ document.body.appendChild(el)
+ el.select()
+ document.execCommand('copy')
+ document.body.removeChild(el)
+}
+
// Thanks: https://github.com/uupaa/dynamic-import-polyfill
function importModule (path: string) {
return new Promise((resolve, reject) => {
@@ -51,6 +63,7 @@ function wait (ms: number) {
}
export {
+ copyToClipboard,
importModule,
objectToUrlEncoded,
wait
diff --git a/client/src/sass/include/_mixins.scss b/client/src/sass/include/_mixins.scss
index e6491b492..4d70110fe 100644
--- a/client/src/sass/include/_mixins.scss
+++ b/client/src/sass/include/_mixins.scss
@@ -225,7 +225,7 @@
line-height: $button-height;
border-radius: 3px;
text-align: center;
- padding: 0 17px 0 13px;
+ padding: 0 13px 0 13px;
cursor: pointer;
}
diff --git a/server/controllers/api/users/token.ts b/server/controllers/api/users/token.ts
index 41aa26769..821429358 100644
--- a/server/controllers/api/users/token.ts
+++ b/server/controllers/api/users/token.ts
@@ -4,6 +4,8 @@ import { CONFIG } from '@server/initializers/config'
import * as express from 'express'
import { Hooks } from '@server/lib/plugins/hooks'
import { asyncMiddleware, authenticate } from '@server/middlewares'
+import { ScopedToken } from '@shared/models/users/user-scoped-token'
+import { v4 as uuidv4 } from 'uuid'
const tokensRouter = express.Router()
@@ -23,6 +25,16 @@ tokensRouter.post('/revoke-token',
asyncMiddleware(handleTokenRevocation)
)
+tokensRouter.get('/scoped-tokens',
+ authenticate,
+ getScopedTokens
+)
+
+tokensRouter.post('/scoped-tokens',
+ authenticate,
+ asyncMiddleware(renewScopedTokens)
+)
+
// ---------------------------------------------------------------------------
export {
@@ -35,3 +47,22 @@ function tokenSuccess (req: express.Request) {
Hooks.runAction('action:api.user.oauth2-got-token', { username, ip: req.ip })
}
+
+function getScopedTokens (req: express.Request, res: express.Response) {
+ const user = res.locals.oauth.token.user
+
+ return res.json({
+ feedToken: user.feedToken
+ } as ScopedToken)
+}
+
+async function renewScopedTokens (req: express.Request, res: express.Response) {
+ const user = res.locals.oauth.token.user
+
+ user.feedToken = uuidv4()
+ await user.save()
+
+ return res.json({
+ feedToken: user.feedToken
+ } as ScopedToken)
+}
diff --git a/server/controllers/feeds.ts b/server/controllers/feeds.ts
index f14c0d316..6e9f7e60c 100644
--- a/server/controllers/feeds.ts
+++ b/server/controllers/feeds.ts
@@ -11,11 +11,14 @@ import {
setFeedFormatContentType,
videoCommentsFeedsValidator,
videoFeedsValidator,
- videosSortValidator
+ videosSortValidator,
+ videoSubscriptonFeedsValidator
} from '../middlewares'
import { cacheRoute } from '../middlewares/cache'
import { VideoModel } from '../models/video/video'
import { VideoCommentModel } from '../models/video/video-comment'
+import { VideoFilter } from '../../shared/models/videos/video-query.type'
+import { logger } from '../helpers/logger'
const feedsRouter = express.Router()
@@ -44,6 +47,7 @@ feedsRouter.get('/feeds/videos.:format',
})(ROUTE_CACHE_LIFETIME.FEEDS)),
commonVideosFiltersValidator,
asyncMiddleware(videoFeedsValidator),
+ asyncMiddleware(videoSubscriptonFeedsValidator),
asyncMiddleware(generateVideoFeed)
)
@@ -124,6 +128,7 @@ async function generateVideoFeed (req: express.Request, res: express.Response) {
const account = res.locals.account
const videoChannel = res.locals.videoChannel
+ const token = req.query.token
const nsfw = buildNSFWFilter(res, req.query.nsfw)
let name: string
@@ -147,19 +152,36 @@ async function generateVideoFeed (req: express.Request, res: express.Response) {
queryString: new URL(WEBSERVER.URL + req.url).search
})
+ /**
+ * We have two ways to query video results:
+ * - one with account and token -> get subscription videos
+ * - one with either account, channel, or nothing: just videos with these filters
+ */
+ const options = token && token !== '' && res.locals.user
+ ? {
+ followerActorId: res.locals.user.Account.Actor.id,
+ user: res.locals.user,
+ includeLocalVideos: false
+ }
+ : {
+ accountId: account ? account.id : null,
+ videoChannelId: videoChannel ? videoChannel.id : null
+ }
+
const resultList = await VideoModel.listForApi({
start,
count: FEEDS.COUNT,
sort: req.query.sort,
includeLocalVideos: true,
nsfw,
- filter: req.query.filter,
+ filter: req.query.filter as VideoFilter,
withFiles: true,
- accountId: account ? account.id : null,
- videoChannelId: videoChannel ? videoChannel.id : null
+ ...options
})
- // Adding video items to the feed, one at a time
+ /**
+ * Adding video items to the feed object, one at a time
+ */
resultList.data.forEach(video => {
const formattedVideoFiles = video.getFormattedVideoFilesJSON()
diff --git a/server/helpers/middlewares/accounts.ts b/server/helpers/middlewares/accounts.ts
index 29b4ed1a6..9be80167c 100644
--- a/server/helpers/middlewares/accounts.ts
+++ b/server/helpers/middlewares/accounts.ts
@@ -2,6 +2,7 @@ import { Response } from 'express'
import { AccountModel } from '../../models/account/account'
import * as Bluebird from 'bluebird'
import { MAccountDefault } from '../../types/models'
+import { UserModel } from '@server/models/account/user'
function doesAccountIdExist (id: number | string, res: Response, sendNotFound = true) {
const promise = AccountModel.load(parseInt(id + '', 10))
@@ -39,11 +40,28 @@ async function doesAccountExist (p: Bluebird, res: Response, se
return true
}
+async function doesUserFeedTokenCorrespond (id: number | string, token: string, res: Response) {
+ const user = await UserModel.loadById(parseInt(id + '', 10))
+
+ if (token !== user.feedToken) {
+ res.status(401)
+ .send({ error: 'User and token mismatch' })
+ .end()
+
+ return false
+ }
+
+ res.locals.user = user
+
+ return true
+}
+
// ---------------------------------------------------------------------------
export {
doesAccountIdExist,
doesLocalAccountNameExist,
doesAccountNameWithHostExist,
- doesAccountExist
+ doesAccountExist,
+ doesUserFeedTokenCorrespond
}
diff --git a/server/initializers/migrations/0530-user-feed-token.ts b/server/initializers/migrations/0530-user-feed-token.ts
new file mode 100644
index 000000000..421016b11
--- /dev/null
+++ b/server/initializers/migrations/0530-user-feed-token.ts
@@ -0,0 +1,40 @@
+import * as Sequelize from 'sequelize'
+import { v4 as uuidv4 } from 'uuid'
+
+async function up (utils: {
+ transaction: Sequelize.Transaction
+ queryInterface: Sequelize.QueryInterface
+ sequelize: Sequelize.Sequelize
+ db: any
+}): Promise {
+ const q = utils.queryInterface
+
+ // Create uuid column for users
+ const userFeedTokenUUID = {
+ type: Sequelize.UUID,
+ defaultValue: Sequelize.UUIDV4,
+ allowNull: true
+ }
+ await q.addColumn('user', 'feedToken', userFeedTokenUUID)
+
+ // Set UUID to previous users
+ {
+ const query = 'SELECT * FROM "user" WHERE "feedToken" IS NULL'
+ const options = { type: Sequelize.QueryTypes.SELECT as Sequelize.QueryTypes.SELECT }
+ const users = await utils.sequelize.query(query, options)
+
+ for (const user of users) {
+ const queryUpdate = `UPDATE "user" SET "feedToken" = '${uuidv4()}' WHERE id = ${user.id}`
+ await utils.sequelize.query(queryUpdate)
+ }
+ }
+}
+
+function down (options) {
+ throw new Error('Not implemented.')
+}
+
+export {
+ up,
+ down
+}
diff --git a/server/middlewares/validators/feeds.ts b/server/middlewares/validators/feeds.ts
index c3de0f5fe..5c76a679f 100644
--- a/server/middlewares/validators/feeds.ts
+++ b/server/middlewares/validators/feeds.ts
@@ -9,7 +9,8 @@ import {
doesAccountIdExist,
doesAccountNameWithHostExist,
doesVideoChannelIdExist,
- doesVideoChannelNameWithHostExist
+ doesVideoChannelNameWithHostExist,
+ doesUserFeedTokenCorrespond
} from '../../helpers/middlewares'
const feedsFormatValidator = [
@@ -62,6 +63,23 @@ const videoFeedsValidator = [
}
]
+const videoSubscriptonFeedsValidator = [
+ query('accountId').optional().custom(isIdValid),
+ query('token').optional(),
+
+ async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+ logger.debug('Checking feeds parameters', { parameters: req.query })
+
+ if (areValidationErrors(req, res)) return
+
+ // a token alone is erroneous
+ if (req.query.token && !req.query.accountId) return
+ if (req.query.token && !await doesUserFeedTokenCorrespond(res.locals.account.userId, req.query.token, res)) return
+
+ return next()
+ }
+]
+
const videoCommentsFeedsValidator = [
query('videoId').optional().custom(isIdOrUUIDValid),
@@ -88,5 +106,6 @@ export {
feedsFormatValidator,
setFeedFormatContentType,
videoFeedsValidator,
+ videoSubscriptonFeedsValidator,
videoCommentsFeedsValidator
}
diff --git a/server/models/account/user.ts b/server/models/account/user.ts
index 2aa6469fb..10117099b 100644
--- a/server/models/account/user.ts
+++ b/server/models/account/user.ts
@@ -19,7 +19,8 @@ import {
Model,
Scopes,
Table,
- UpdatedAt
+ UpdatedAt,
+ IsUUID
} from 'sequelize-typescript'
import {
MMyUserFormattable,
@@ -353,6 +354,12 @@ export class UserModel extends Model {
@Column
pluginAuth: string
+ @AllowNull(false)
+ @Default(DataType.UUIDV4)
+ @IsUUID(4)
+ @Column(DataType.UUID)
+ feedToken: string
+
@AllowNull(true)
@Default(null)
@Column
diff --git a/server/tests/feeds/feeds.ts b/server/tests/feeds/feeds.ts
index 0ff690f34..2cd9b2d0a 100644
--- a/server/tests/feeds/feeds.ts
+++ b/server/tests/feeds/feeds.ts
@@ -22,11 +22,14 @@ import {
uploadVideo,
uploadVideoAndGetId,
userLogin,
- flushAndRunServer
+ flushAndRunServer,
+ getUserScopedTokens
} from '../../../shared/extra-utils'
import { waitJobs } from '../../../shared/extra-utils/server/jobs'
import { addVideoCommentThread } from '../../../shared/extra-utils/videos/video-comments'
import { User } from '../../../shared/models/users'
+import { ScopedToken } from '@shared/models/users/user-scoped-token'
+import { listUserSubscriptionVideos, addUserSubscription } from '@shared/extra-utils/users/user-subscriptions'
chai.use(require('chai-xml'))
chai.use(require('chai-json-schema'))
@@ -41,6 +44,7 @@ describe('Test syndication feeds', () => {
let rootChannelId: number
let userAccountId: number
let userChannelId: number
+ let userFeedToken: string
before(async function () {
this.timeout(120000)
@@ -74,6 +78,10 @@ describe('Test syndication feeds', () => {
const user: User = res.body
userAccountId = user.account.id
userChannelId = user.videoChannels[0].id
+
+ const res2 = await getUserScopedTokens(servers[0].url, userAccessToken)
+ const token: ScopedToken = res2.body
+ userFeedToken = token.feedToken
}
{
@@ -289,6 +297,87 @@ describe('Test syndication feeds', () => {
})
})
+ describe('Video feed from my subscriptions', function () {
+ /**
+ * use the 'version' query parameter to bust cache between tests
+ */
+
+ it('Should list no videos for a user with no videos and no subscriptions', async function () {
+ let feeduserAccountId: number
+ let feeduserFeedToken: string
+
+ const attr = { username: 'feeduser', password: 'password' }
+ await createUser({ url: servers[0].url, accessToken: servers[0].accessToken, username: attr.username, password: attr.password })
+ const feeduserAccessToken = await userLogin(servers[0], attr)
+
+ {
+ const res = await getMyUserInformation(servers[0].url, feeduserAccessToken)
+ const user: User = res.body
+ feeduserAccountId = user.account.id
+ }
+
+ {
+ const res = await getUserScopedTokens(servers[0].url, feeduserAccessToken)
+ const token: ScopedToken = res.body
+ feeduserFeedToken = token.feedToken
+ }
+
+ {
+ const res = await listUserSubscriptionVideos(servers[0].url, feeduserAccessToken)
+ expect(res.body.total).to.equal(0)
+
+ const json = await getJSONfeed(servers[0].url, 'videos', { accountId: feeduserAccountId, token: feeduserFeedToken })
+ const jsonObj = JSON.parse(json.text)
+ expect(jsonObj.items.length).to.be.equal(0) // no subscription, it should not list the instance's videos but list 0 videos
+ }
+ })
+
+ it('Should list no videos for a user with videos but no subscriptions', async function () {
+ {
+ const res = await listUserSubscriptionVideos(servers[0].url, userAccessToken)
+ expect(res.body.total).to.equal(0)
+
+ const json = await getJSONfeed(servers[0].url, 'videos', { accountId: userAccountId, token: userFeedToken })
+ const jsonObj = JSON.parse(json.text)
+ expect(jsonObj.items.length).to.be.equal(0) // no subscription, it should not list the instance's videos but list 0 videos
+ }
+ })
+
+ it('Should list self videos for a user with a subscription to themselves', async function () {
+ this.timeout(30000)
+
+ await addUserSubscription(servers[0].url, userAccessToken, 'john_channel@localhost:' + servers[0].port)
+ await waitJobs(servers)
+
+ {
+ const res = await listUserSubscriptionVideos(servers[0].url, userAccessToken)
+ expect(res.body.total).to.equal(1)
+ expect(res.body.data[0].name).to.equal('user video')
+
+ const json = await getJSONfeed(servers[0].url, 'videos', { accountId: userAccountId, token: userFeedToken, version: 1 })
+ const jsonObj = JSON.parse(json.text)
+ expect(jsonObj.items.length).to.be.equal(1) // subscribed to self, it should not list the instance's videos but list john's
+ }
+ })
+
+ it('Should list videos of a user\'s subscription', async function () {
+ this.timeout(30000)
+
+ await addUserSubscription(servers[0].url, userAccessToken, 'root_channel@localhost:' + servers[0].port)
+ await waitJobs(servers)
+
+ {
+ const res = await listUserSubscriptionVideos(servers[0].url, userAccessToken)
+ expect(res.body.total).to.equal(2, "there should be 2 videos part of the subscription")
+
+ const json = await getJSONfeed(servers[0].url, 'videos', { accountId: userAccountId, token: userFeedToken, version: 2 })
+ const jsonObj = JSON.parse(json.text)
+ expect(jsonObj.items.length).to.be.equal(2) // subscribed to root, it should not list the instance's videos but list root/john's
+ }
+ })
+
+ })
+
after(async function () {
await cleanupTests([ ...servers, serverHLSOnly ])
})
diff --git a/shared/extra-utils/users/users.ts b/shared/extra-utils/users/users.ts
index 9f193680d..4d0986ce3 100644
--- a/shared/extra-utils/users/users.ts
+++ b/shared/extra-utils/users/users.ts
@@ -109,6 +109,17 @@ function getMyUserInformation (url: string, accessToken: string, specialStatus =
.expect('Content-Type', /json/)
}
+function getUserScopedTokens (url: string, accessToken: string, specialStatus = 200) {
+ const path = '/api/v1/users/scoped-tokens'
+
+ return request(url)
+ .get(path)
+ .set('Accept', 'application/json')
+ .set('Authorization', 'Bearer ' + accessToken)
+ .expect(specialStatus)
+ .expect('Content-Type', /json/)
+}
+
function deleteMe (url: string, accessToken: string, specialStatus = 204) {
const path = '/api/v1/users/me'
@@ -351,5 +362,6 @@ export {
updateMyAvatar,
askSendVerifyEmail,
generateUserAccessToken,
- verifyEmail
+ verifyEmail,
+ getUserScopedTokens
}
diff --git a/shared/models/users/user-scoped-token.ts b/shared/models/users/user-scoped-token.ts
new file mode 100644
index 000000000..f9d9b0a8b
--- /dev/null
+++ b/shared/models/users/user-scoped-token.ts
@@ -0,0 +1,5 @@
+export type ScopedTokenType = 'feedToken'
+
+export type ScopedToken = {
+ feedToken: string
+}