aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--client/src/app/videos/+video-watch/video-watch.component.ts2
-rw-r--r--config/default.yaml5
-rw-r--r--config/production.yaml.example5
-rw-r--r--server/controllers/api/videos/index.ts2
-rw-r--r--server/middlewares/oauth.ts16
-rw-r--r--server/middlewares/validators/videos/videos.ts52
-rw-r--r--server/models/video/video.ts17
-rw-r--r--server/tests/api/server/follow-constraints.ts215
-rw-r--r--server/tests/api/server/index.ts1
9 files changed, 301 insertions, 14 deletions
diff --git a/client/src/app/videos/+video-watch/video-watch.component.ts b/client/src/app/videos/+video-watch/video-watch.component.ts
index d0151ceb1..09ee96bdc 100644
--- a/client/src/app/videos/+video-watch/video-watch.component.ts
+++ b/client/src/app/videos/+video-watch/video-watch.component.ts
@@ -114,7 +114,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
114 ) 114 )
115 .pipe( 115 .pipe(
116 // If 401, the video is private or blacklisted so redirect to 404 116 // If 401, the video is private or blacklisted so redirect to 404
117 catchError(err => this.restExtractor.redirectTo404IfNotFound(err, [ 400, 401, 404 ])) 117 catchError(err => this.restExtractor.redirectTo404IfNotFound(err, [ 400, 401, 403, 404 ]))
118 ) 118 )
119 .subscribe(([ video, captionsResult ]) => { 119 .subscribe(([ video, captionsResult ]) => {
120 const startTime = this.route.snapshot.queryParams.start 120 const startTime = this.route.snapshot.queryParams.start
diff --git a/config/default.yaml b/config/default.yaml
index 0d7d948c2..257ec7ed1 100644
--- a/config/default.yaml
+++ b/config/default.yaml
@@ -58,7 +58,10 @@ log:
58 level: 'info' # debug/info/warning/error 58 level: 'info' # debug/info/warning/error
59 59
60search: 60search:
61 remote_uri: # Add ability to fetch remote videos/actors by their URI, that may not be federated with your instance 61 # Add ability to fetch remote videos/actors by their URI, that may not be federated with your instance
62 # If enabled, the associated group will be able to "escape" from the instance follows
63 # That means they will be able to follow channels, watch videos, list videos of non followed instances
64 remote_uri:
62 users: true 65 users: true
63 anonymous: false 66 anonymous: false
64 67
diff --git a/config/production.yaml.example b/config/production.yaml.example
index f9da8e0dd..ac15fc736 100644
--- a/config/production.yaml.example
+++ b/config/production.yaml.example
@@ -59,7 +59,10 @@ log:
59 level: 'info' # debug/info/warning/error 59 level: 'info' # debug/info/warning/error
60 60
61search: 61search:
62 remote_uri: # Add ability to search remote videos/actors by URI, that may not be federated with your instance 62 # Add ability to fetch remote videos/actors by their URI, that may not be federated with your instance
63 # If enabled, the associated group will be able to "escape" from the instance follows
64 # That means they will be able to follow channels, watch videos, list videos of non followed instances
65 remote_uri:
63 users: true 66 users: true
64 anonymous: false 67 anonymous: false
65 68
diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts
index e654bdd09..89fd0432f 100644
--- a/server/controllers/api/videos/index.ts
+++ b/server/controllers/api/videos/index.ts
@@ -31,6 +31,7 @@ import {
31 asyncMiddleware, 31 asyncMiddleware,
32 asyncRetryTransactionMiddleware, 32 asyncRetryTransactionMiddleware,
33 authenticate, 33 authenticate,
34 checkVideoFollowConstraints,
34 commonVideosFiltersValidator, 35 commonVideosFiltersValidator,
35 optionalAuthenticate, 36 optionalAuthenticate,
36 paginationValidator, 37 paginationValidator,
@@ -123,6 +124,7 @@ videosRouter.get('/:id/description',
123videosRouter.get('/:id', 124videosRouter.get('/:id',
124 optionalAuthenticate, 125 optionalAuthenticate,
125 asyncMiddleware(videosGetValidator), 126 asyncMiddleware(videosGetValidator),
127 asyncMiddleware(checkVideoFollowConstraints),
126 getVideo 128 getVideo
127) 129)
128videosRouter.post('/:id/views', 130videosRouter.post('/:id/views',
diff --git a/server/middlewares/oauth.ts b/server/middlewares/oauth.ts
index 5233b66bd..8c1df2c3e 100644
--- a/server/middlewares/oauth.ts
+++ b/server/middlewares/oauth.ts
@@ -28,9 +28,24 @@ function authenticate (req: express.Request, res: express.Response, next: expres
28 }) 28 })
29} 29}
30 30
31function authenticatePromiseIfNeeded (req: express.Request, res: express.Response) {
32 return new Promise(resolve => {
33 // Already authenticated? (or tried to)
34 if (res.locals.oauth && res.locals.oauth.token.User) return resolve()
35
36 if (res.locals.authenticated === false) return res.sendStatus(401)
37
38 authenticate(req, res, () => {
39 return resolve()
40 })
41 })
42}
43
31function optionalAuthenticate (req: express.Request, res: express.Response, next: express.NextFunction) { 44function optionalAuthenticate (req: express.Request, res: express.Response, next: express.NextFunction) {
32 if (req.header('authorization')) return authenticate(req, res, next) 45 if (req.header('authorization')) return authenticate(req, res, next)
33 46
47 res.locals.authenticated = false
48
34 return next() 49 return next()
35} 50}
36 51
@@ -53,6 +68,7 @@ function token (req: express.Request, res: express.Response, next: express.NextF
53 68
54export { 69export {
55 authenticate, 70 authenticate,
71 authenticatePromiseIfNeeded,
56 optionalAuthenticate, 72 optionalAuthenticate,
57 token 73 token
58} 74}
diff --git a/server/middlewares/validators/videos/videos.ts b/server/middlewares/validators/videos/videos.ts
index bf21bca8c..051a19e16 100644
--- a/server/middlewares/validators/videos/videos.ts
+++ b/server/middlewares/validators/videos/videos.ts
@@ -31,8 +31,8 @@ import {
31} from '../../../helpers/custom-validators/videos' 31} from '../../../helpers/custom-validators/videos'
32import { getDurationFromVideoFile } from '../../../helpers/ffmpeg-utils' 32import { getDurationFromVideoFile } from '../../../helpers/ffmpeg-utils'
33import { logger } from '../../../helpers/logger' 33import { logger } from '../../../helpers/logger'
34import { CONSTRAINTS_FIELDS } from '../../../initializers' 34import { CONFIG, CONSTRAINTS_FIELDS } from '../../../initializers'
35import { authenticate } from '../../oauth' 35import { authenticatePromiseIfNeeded } from '../../oauth'
36import { areValidationErrors } from '../utils' 36import { areValidationErrors } from '../utils'
37import { cleanUpReqFiles } from '../../../helpers/express-utils' 37import { cleanUpReqFiles } from '../../../helpers/express-utils'
38import { VideoModel } from '../../../models/video/video' 38import { VideoModel } from '../../../models/video/video'
@@ -43,6 +43,7 @@ import { VideoChangeOwnershipModel } from '../../../models/video/video-change-ow
43import { AccountModel } from '../../../models/account/account' 43import { AccountModel } from '../../../models/account/account'
44import { VideoFetchType } from '../../../helpers/video' 44import { VideoFetchType } from '../../../helpers/video'
45import { isNSFWQueryValid, isNumberArray, isStringArray } from '../../../helpers/custom-validators/search' 45import { isNSFWQueryValid, isNumberArray, isStringArray } from '../../../helpers/custom-validators/search'
46import { getServerActor } from '../../../helpers/utils'
46 47
47const videosAddValidator = getCommonVideoAttributes().concat([ 48const videosAddValidator = getCommonVideoAttributes().concat([
48 body('videofile') 49 body('videofile')
@@ -127,6 +128,31 @@ const videosUpdateValidator = getCommonVideoAttributes().concat([
127 } 128 }
128]) 129])
129 130
131async function checkVideoFollowConstraints (req: express.Request, res: express.Response, next: express.NextFunction) {
132 const video: VideoModel = res.locals.video
133
134 // Anybody can watch local videos
135 if (video.isOwned() === true) return next()
136
137 // Logged user
138 if (res.locals.oauth) {
139 // Users can search or watch remote videos
140 if (CONFIG.SEARCH.REMOTE_URI.USERS === true) return next()
141 }
142
143 // Anybody can search or watch remote videos
144 if (CONFIG.SEARCH.REMOTE_URI.ANONYMOUS === true) return next()
145
146 // Check our instance follows an actor that shared this video
147 const serverActor = await getServerActor()
148 if (await VideoModel.checkVideoHasInstanceFollow(video.id, serverActor.id) === true) return next()
149
150 return res.status(403)
151 .json({
152 error: 'Cannot get this video regarding follow constraints.'
153 })
154}
155
130const videosCustomGetValidator = (fetchType: VideoFetchType) => { 156const videosCustomGetValidator = (fetchType: VideoFetchType) => {
131 return [ 157 return [
132 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'), 158 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
@@ -141,17 +167,20 @@ const videosCustomGetValidator = (fetchType: VideoFetchType) => {
141 167
142 // Video private or blacklisted 168 // Video private or blacklisted
143 if (video.privacy === VideoPrivacy.PRIVATE || video.VideoBlacklist) { 169 if (video.privacy === VideoPrivacy.PRIVATE || video.VideoBlacklist) {
144 return authenticate(req, res, () => { 170 await authenticatePromiseIfNeeded(req, res)
145 const user: UserModel = res.locals.oauth.token.User 171
172 const user: UserModel = res.locals.oauth ? res.locals.oauth.token.User : null
146 173
147 // Only the owner or a user that have blacklist rights can see the video 174 // Only the owner or a user that have blacklist rights can see the video
148 if (video.VideoChannel.Account.userId !== user.id && !user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST)) { 175 if (
149 return res.status(403) 176 !user ||
150 .json({ error: 'Cannot get this private or blacklisted video.' }) 177 (video.VideoChannel.Account.userId !== user.id && !user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST))
151 } 178 ) {
179 return res.status(403)
180 .json({ error: 'Cannot get this private or blacklisted video.' })
181 }
152 182
153 return next() 183 return next()
154 })
155 } 184 }
156 185
157 // Video is public, anyone can access it 186 // Video is public, anyone can access it
@@ -376,6 +405,7 @@ export {
376 videosAddValidator, 405 videosAddValidator,
377 videosUpdateValidator, 406 videosUpdateValidator,
378 videosGetValidator, 407 videosGetValidator,
408 checkVideoFollowConstraints,
379 videosCustomGetValidator, 409 videosCustomGetValidator,
380 videosRemoveValidator, 410 videosRemoveValidator,
381 411
diff --git a/server/models/video/video.ts b/server/models/video/video.ts
index 6c183933b..1e68b380c 100644
--- a/server/models/video/video.ts
+++ b/server/models/video/video.ts
@@ -1253,6 +1253,23 @@ export class VideoModel extends Model<VideoModel> {
1253 }) 1253 })
1254 } 1254 }
1255 1255
1256 static checkVideoHasInstanceFollow (videoId: number, followerActorId: number) {
1257 // Instances only share videos
1258 const query = 'SELECT 1 FROM "videoShare" ' +
1259 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' +
1260 'WHERE "actorFollow"."actorId" = $followerActorId AND "videoShare"."videoId" = $videoId ' +
1261 'LIMIT 1'
1262
1263 const options = {
1264 type: Sequelize.QueryTypes.SELECT,
1265 bind: { followerActorId, videoId },
1266 raw: true
1267 }
1268
1269 return VideoModel.sequelize.query(query, options)
1270 .then(results => results.length === 1)
1271 }
1272
1256 // threshold corresponds to how many video the field should have to be returned 1273 // threshold corresponds to how many video the field should have to be returned
1257 static async getRandomFieldSamples (field: 'category' | 'channelId', threshold: number, count: number) { 1274 static async getRandomFieldSamples (field: 'category' | 'channelId', threshold: number, count: number) {
1258 const serverActor = await getServerActor() 1275 const serverActor = await getServerActor()
diff --git a/server/tests/api/server/follow-constraints.ts b/server/tests/api/server/follow-constraints.ts
new file mode 100644
index 000000000..3135fc568
--- /dev/null
+++ b/server/tests/api/server/follow-constraints.ts
@@ -0,0 +1,215 @@
1/* tslint:disable:no-unused-expression */
2
3import * as chai from 'chai'
4import 'mocha'
5import { doubleFollow, getAccountVideos, getVideo, getVideoChannelVideos, getVideoWithToken } from '../../utils'
6import { flushAndRunMultipleServers, killallServers, ServerInfo, setAccessTokensToServers, uploadVideo } from '../../utils/index'
7import { unfollow } from '../../utils/server/follows'
8import { userLogin } from '../../utils/users/login'
9import { createUser } from '../../utils/users/users'
10
11const expect = chai.expect
12
13describe('Test follow constraints', function () {
14 let servers: ServerInfo[] = []
15 let video1UUID: string
16 let video2UUID: string
17 let userAccessToken: string
18
19 before(async function () {
20 this.timeout(30000)
21
22 servers = await flushAndRunMultipleServers(2)
23
24 // Get the access tokens
25 await setAccessTokensToServers(servers)
26
27 {
28 const res = await uploadVideo(servers[ 0 ].url, servers[ 0 ].accessToken, { name: 'video server 1' })
29 video1UUID = res.body.video.uuid
30 }
31 {
32 const res = await uploadVideo(servers[ 1 ].url, servers[ 1 ].accessToken, { name: 'video server 2' })
33 video2UUID = res.body.video.uuid
34 }
35
36 const user = {
37 username: 'user1',
38 password: 'super_password'
39 }
40 await createUser(servers[0].url, servers[0].accessToken, user.username, user.password)
41 userAccessToken = await userLogin(servers[0], user)
42
43 await doubleFollow(servers[0], servers[1])
44 })
45
46 describe('With a followed instance', function () {
47
48 describe('With an unlogged user', function () {
49
50 it('Should get the local video', async function () {
51 await getVideo(servers[0].url, video1UUID, 200)
52 })
53
54 it('Should get the remote video', async function () {
55 await getVideo(servers[0].url, video2UUID, 200)
56 })
57
58 it('Should list local account videos', async function () {
59 const res = await getAccountVideos(servers[0].url, undefined, 'root@localhost:9001', 0, 5)
60
61 expect(res.body.total).to.equal(1)
62 expect(res.body.data).to.have.lengthOf(1)
63 })
64
65 it('Should list remote account videos', async function () {
66 const res = await getAccountVideos(servers[0].url, undefined, 'root@localhost:9002', 0, 5)
67
68 expect(res.body.total).to.equal(1)
69 expect(res.body.data).to.have.lengthOf(1)
70 })
71
72 it('Should list local channel videos', async function () {
73 const res = await getVideoChannelVideos(servers[0].url, undefined, 'root_channel@localhost:9001', 0, 5)
74
75 expect(res.body.total).to.equal(1)
76 expect(res.body.data).to.have.lengthOf(1)
77 })
78
79 it('Should list remote channel videos', async function () {
80 const res = await getVideoChannelVideos(servers[0].url, undefined, 'root_channel@localhost:9002', 0, 5)
81
82 expect(res.body.total).to.equal(1)
83 expect(res.body.data).to.have.lengthOf(1)
84 })
85 })
86
87 describe('With a logged user', function () {
88 it('Should get the local video', async function () {
89 await getVideoWithToken(servers[0].url, userAccessToken, video1UUID, 200)
90 })
91
92 it('Should get the remote video', async function () {
93 await getVideoWithToken(servers[0].url, userAccessToken, video2UUID, 200)
94 })
95
96 it('Should list local account videos', async function () {
97 const res = await getAccountVideos(servers[0].url, userAccessToken, 'root@localhost:9001', 0, 5)
98
99 expect(res.body.total).to.equal(1)
100 expect(res.body.data).to.have.lengthOf(1)
101 })
102
103 it('Should list remote account videos', async function () {
104 const res = await getAccountVideos(servers[0].url, userAccessToken, 'root@localhost:9002', 0, 5)
105
106 expect(res.body.total).to.equal(1)
107 expect(res.body.data).to.have.lengthOf(1)
108 })
109
110 it('Should list local channel videos', async function () {
111 const res = await getVideoChannelVideos(servers[0].url, userAccessToken, 'root_channel@localhost:9001', 0, 5)
112
113 expect(res.body.total).to.equal(1)
114 expect(res.body.data).to.have.lengthOf(1)
115 })
116
117 it('Should list remote channel videos', async function () {
118 const res = await getVideoChannelVideos(servers[0].url, userAccessToken, 'root_channel@localhost:9002', 0, 5)
119
120 expect(res.body.total).to.equal(1)
121 expect(res.body.data).to.have.lengthOf(1)
122 })
123 })
124 })
125
126 describe('With a non followed instance', function () {
127
128 before(async function () {
129 this.timeout(30000)
130
131 await unfollow(servers[0].url, servers[0].accessToken, servers[1])
132 })
133
134 describe('With an unlogged user', function () {
135
136 it('Should get the local video', async function () {
137 await getVideo(servers[0].url, video1UUID, 200)
138 })
139
140 it('Should not get the remote video', async function () {
141 await getVideo(servers[0].url, video2UUID, 403)
142 })
143
144 it('Should list local account videos', async function () {
145 const res = await getAccountVideos(servers[0].url, undefined, 'root@localhost:9001', 0, 5)
146
147 expect(res.body.total).to.equal(1)
148 expect(res.body.data).to.have.lengthOf(1)
149 })
150
151 it('Should not list remote account videos', async function () {
152 const res = await getAccountVideos(servers[0].url, undefined, 'root@localhost:9002', 0, 5)
153
154 expect(res.body.total).to.equal(0)
155 expect(res.body.data).to.have.lengthOf(0)
156 })
157
158 it('Should list local channel videos', async function () {
159 const res = await getVideoChannelVideos(servers[0].url, undefined, 'root_channel@localhost:9001', 0, 5)
160
161 expect(res.body.total).to.equal(1)
162 expect(res.body.data).to.have.lengthOf(1)
163 })
164
165 it('Should not list remote channel videos', async function () {
166 const res = await getVideoChannelVideos(servers[0].url, undefined, 'root_channel@localhost:9002', 0, 5)
167
168 expect(res.body.total).to.equal(0)
169 expect(res.body.data).to.have.lengthOf(0)
170 })
171 })
172
173 describe('With a logged user', function () {
174 it('Should get the local video', async function () {
175 await getVideoWithToken(servers[0].url, userAccessToken, video1UUID, 200)
176 })
177
178 it('Should get the remote video', async function () {
179 await getVideoWithToken(servers[0].url, userAccessToken, video2UUID, 200)
180 })
181
182 it('Should list local account videos', async function () {
183 const res = await getAccountVideos(servers[0].url, userAccessToken, 'root@localhost:9001', 0, 5)
184
185 expect(res.body.total).to.equal(1)
186 expect(res.body.data).to.have.lengthOf(1)
187 })
188
189 it('Should list remote account videos', async function () {
190 const res = await getAccountVideos(servers[0].url, userAccessToken, 'root@localhost:9002', 0, 5)
191
192 expect(res.body.total).to.equal(1)
193 expect(res.body.data).to.have.lengthOf(1)
194 })
195
196 it('Should list local channel videos', async function () {
197 const res = await getVideoChannelVideos(servers[0].url, userAccessToken, 'root_channel@localhost:9001', 0, 5)
198
199 expect(res.body.total).to.equal(1)
200 expect(res.body.data).to.have.lengthOf(1)
201 })
202
203 it('Should list remote channel videos', async function () {
204 const res = await getVideoChannelVideos(servers[0].url, userAccessToken, 'root_channel@localhost:9002', 0, 5)
205
206 expect(res.body.total).to.equal(1)
207 expect(res.body.data).to.have.lengthOf(1)
208 })
209 })
210 })
211
212 after(async function () {
213 killallServers(servers)
214 })
215})
diff --git a/server/tests/api/server/index.ts b/server/tests/api/server/index.ts
index 78ab7e18b..6afcab1f9 100644
--- a/server/tests/api/server/index.ts
+++ b/server/tests/api/server/index.ts
@@ -1,5 +1,6 @@
1import './config' 1import './config'
2import './email' 2import './email'
3import './follow-constraints'
3import './follows' 4import './follows'
4import './handle-down' 5import './handle-down'
5import './jobs' 6import './jobs'