aboutsummaryrefslogtreecommitdiffhomepage
path: root/server/controllers
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2023-07-31 14:34:36 +0200
committerChocobozzz <me@florianbigard.com>2023-08-11 15:02:33 +0200
commit3a4992633ee62d5edfbb484d9c6bcb3cf158489d (patch)
treee4510b39bdac9c318fdb4b47018d08f15368b8f0 /server/controllers
parent04d1da5621d25d59bd5fa1543b725c497bf5d9a8 (diff)
downloadPeerTube-3a4992633ee62d5edfbb484d9c6bcb3cf158489d.tar.gz
PeerTube-3a4992633ee62d5edfbb484d9c6bcb3cf158489d.tar.zst
PeerTube-3a4992633ee62d5edfbb484d9c6bcb3cf158489d.zip
Migrate server to ESM
Sorry for the very big commit that may lead to git log issues and merge conflicts, but it's a major step forward: * Server can be faster at startup because imports() are async and we can easily lazy import big modules * Angular doesn't seem to support ES import (with .js extension), so we had to correctly organize peertube into a monorepo: * Use yarn workspace feature * Use typescript reference projects for dependencies * Shared projects have been moved into "packages", each one is now a node module (with a dedicated package.json/tsconfig.json) * server/tools have been moved into apps/ and is now a dedicated app bundled and published on NPM so users don't have to build peertube cli tools manually * server/tests have been moved into packages/ so we don't compile them every time we want to run the server * Use isolatedModule option: * Had to move from const enum to const (https://www.typescriptlang.org/docs/handbook/enums.html#objects-vs-enums) * Had to explictely specify "type" imports when used in decorators * Prefer tsx (that uses esbuild under the hood) instead of ts-node to load typescript files (tests with mocha or scripts): * To reduce test complexity as esbuild doesn't support decorator metadata, we only test server files that do not import server models * We still build tests files into js files for a faster CI * Remove unmaintained peertube CLI import script * Removed some barrels to speed up execution (less imports)
Diffstat (limited to 'server/controllers')
-rw-r--r--server/controllers/activitypub/client.ts482
-rw-r--r--server/controllers/activitypub/inbox.ts85
-rw-r--r--server/controllers/activitypub/index.ts17
-rw-r--r--server/controllers/activitypub/outbox.ts86
-rw-r--r--server/controllers/activitypub/utils.ts12
-rw-r--r--server/controllers/api/abuse.ts259
-rw-r--r--server/controllers/api/accounts.ts266
-rw-r--r--server/controllers/api/blocklist.ts110
-rw-r--r--server/controllers/api/bulk.ts44
-rw-r--r--server/controllers/api/config.ts377
-rw-r--r--server/controllers/api/custom-page.ts48
-rw-r--r--server/controllers/api/index.ts73
-rw-r--r--server/controllers/api/jobs.ts109
-rw-r--r--server/controllers/api/metrics.ts34
-rw-r--r--server/controllers/api/oauth-clients.ts54
-rw-r--r--server/controllers/api/overviews.ts139
-rw-r--r--server/controllers/api/plugins.ts230
-rw-r--r--server/controllers/api/runners/index.ts20
-rw-r--r--server/controllers/api/runners/jobs-files.ts112
-rw-r--r--server/controllers/api/runners/jobs.ts416
-rw-r--r--server/controllers/api/runners/manage-runners.ts112
-rw-r--r--server/controllers/api/runners/registration-tokens.ts91
-rw-r--r--server/controllers/api/search/index.ts19
-rw-r--r--server/controllers/api/search/search-video-channels.ts152
-rw-r--r--server/controllers/api/search/search-video-playlists.ts131
-rw-r--r--server/controllers/api/search/search-videos.ts167
-rw-r--r--server/controllers/api/search/shared/index.ts1
-rw-r--r--server/controllers/api/search/shared/utils.ts16
-rw-r--r--server/controllers/api/server/contact.ts34
-rw-r--r--server/controllers/api/server/debug.ts56
-rw-r--r--server/controllers/api/server/follows.ts214
-rw-r--r--server/controllers/api/server/index.ts27
-rw-r--r--server/controllers/api/server/logs.ts203
-rw-r--r--server/controllers/api/server/redundancy.ts116
-rw-r--r--server/controllers/api/server/server-blocklist.ts158
-rw-r--r--server/controllers/api/server/stats.ts26
-rw-r--r--server/controllers/api/users/email-verification.ts72
-rw-r--r--server/controllers/api/users/index.ts319
-rw-r--r--server/controllers/api/users/me.ts277
-rw-r--r--server/controllers/api/users/my-abuses.ts48
-rw-r--r--server/controllers/api/users/my-blocklist.ts149
-rw-r--r--server/controllers/api/users/my-history.ts75
-rw-r--r--server/controllers/api/users/my-notifications.ts116
-rw-r--r--server/controllers/api/users/my-subscriptions.ts193
-rw-r--r--server/controllers/api/users/my-video-playlists.ts51
-rw-r--r--server/controllers/api/users/registrations.ts249
-rw-r--r--server/controllers/api/users/token.ts131
-rw-r--r--server/controllers/api/users/two-factor.ts95
-rw-r--r--server/controllers/api/video-channel-sync.ts79
-rw-r--r--server/controllers/api/video-channel.ts431
-rw-r--r--server/controllers/api/video-playlist.ts514
-rw-r--r--server/controllers/api/videos/blacklist.ts112
-rw-r--r--server/controllers/api/videos/captions.ts93
-rw-r--r--server/controllers/api/videos/comment.ts234
-rw-r--r--server/controllers/api/videos/files.ts122
-rw-r--r--server/controllers/api/videos/import.ts262
-rw-r--r--server/controllers/api/videos/index.ts228
-rw-r--r--server/controllers/api/videos/live.ts224
-rw-r--r--server/controllers/api/videos/ownership.ts138
-rw-r--r--server/controllers/api/videos/passwords.ts105
-rw-r--r--server/controllers/api/videos/rate.ts87
-rw-r--r--server/controllers/api/videos/source.ts206
-rw-r--r--server/controllers/api/videos/stats.ts75
-rw-r--r--server/controllers/api/videos/storyboard.ts29
-rw-r--r--server/controllers/api/videos/studio.ts143
-rw-r--r--server/controllers/api/videos/token.ts33
-rw-r--r--server/controllers/api/videos/transcoding.ts60
-rw-r--r--server/controllers/api/videos/update.ts210
-rw-r--r--server/controllers/api/videos/upload.ts287
-rw-r--r--server/controllers/api/videos/view.ts60
-rw-r--r--server/controllers/client.ts236
-rw-r--r--server/controllers/download.ts213
-rw-r--r--server/controllers/feeds/comment-feeds.ts96
-rw-r--r--server/controllers/feeds/index.ts25
-rw-r--r--server/controllers/feeds/shared/common-feed-utils.ts149
-rw-r--r--server/controllers/feeds/shared/index.ts2
-rw-r--r--server/controllers/feeds/shared/video-feed-utils.ts66
-rw-r--r--server/controllers/feeds/video-feeds.ts189
-rw-r--r--server/controllers/feeds/video-podcast-feeds.ts313
-rw-r--r--server/controllers/index.ts14
-rw-r--r--server/controllers/lazy-static.ts128
-rw-r--r--server/controllers/misc.ts210
-rw-r--r--server/controllers/object-storage-proxy.ts60
-rw-r--r--server/controllers/plugins.ts175
-rw-r--r--server/controllers/services.ts165
-rw-r--r--server/controllers/shared/m3u8-playlist.ts18
-rw-r--r--server/controllers/sitemap.ts115
-rw-r--r--server/controllers/static.ts116
-rw-r--r--server/controllers/tracker.ts148
-rw-r--r--server/controllers/well-known.ts125
90 files changed, 0 insertions, 12566 deletions
diff --git a/server/controllers/activitypub/client.ts b/server/controllers/activitypub/client.ts
deleted file mode 100644
index be52f1662..000000000
--- a/server/controllers/activitypub/client.ts
+++ /dev/null
@@ -1,482 +0,0 @@
1import cors from 'cors'
2import express from 'express'
3import { activityPubCollectionPagination } from '@server/lib/activitypub/collection'
4import { activityPubContextify } from '@server/lib/activitypub/context'
5import { getServerActor } from '@server/models/application/application'
6import { MAccountId, MActorId, MChannelId, MVideoId } from '@server/types/models'
7import { VideoCommentObject } from '@shared/models'
8import { VideoPrivacy, VideoRateType } from '../../../shared/models/videos'
9import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model'
10import { ROUTE_CACHE_LIFETIME, WEBSERVER } from '../../initializers/constants'
11import { audiencify, getAudience } from '../../lib/activitypub/audience'
12import { buildAnnounceWithVideoAudience, buildLikeActivity } from '../../lib/activitypub/send'
13import { buildCreateActivity } from '../../lib/activitypub/send/send-create'
14import { buildDislikeActivity } from '../../lib/activitypub/send/send-dislike'
15import {
16 getLocalVideoCommentsActivityPubUrl,
17 getLocalVideoDislikesActivityPubUrl,
18 getLocalVideoLikesActivityPubUrl,
19 getLocalVideoSharesActivityPubUrl
20} from '../../lib/activitypub/url'
21import {
22 activityPubRateLimiter,
23 asyncMiddleware,
24 ensureIsLocalChannel,
25 executeIfActivityPub,
26 localAccountValidator,
27 videoChannelsNameWithHostValidator,
28 videosCustomGetValidator,
29 videosShareValidator
30} from '../../middlewares'
31import { cacheRoute } from '../../middlewares/cache/cache'
32import { getAccountVideoRateValidatorFactory, getVideoLocalViewerValidator, videoCommentGetValidator } from '../../middlewares/validators'
33import { videoFileRedundancyGetValidator, videoPlaylistRedundancyGetValidator } from '../../middlewares/validators/redundancy'
34import { videoPlaylistElementAPGetValidator, videoPlaylistsGetValidator } from '../../middlewares/validators/videos/video-playlists'
35import { AccountModel } from '../../models/account/account'
36import { AccountVideoRateModel } from '../../models/account/account-video-rate'
37import { ActorFollowModel } from '../../models/actor/actor-follow'
38import { VideoCommentModel } from '../../models/video/video-comment'
39import { VideoPlaylistModel } from '../../models/video/video-playlist'
40import { VideoShareModel } from '../../models/video/video-share'
41import { activityPubResponse } from './utils'
42
43const activityPubClientRouter = express.Router()
44activityPubClientRouter.use(cors())
45
46// Intercept ActivityPub client requests
47
48activityPubClientRouter.get(
49 [ '/accounts?/:name', '/accounts?/:name/video-channels', '/a/:name', '/a/:name/video-channels' ],
50 executeIfActivityPub,
51 activityPubRateLimiter,
52 asyncMiddleware(localAccountValidator),
53 asyncMiddleware(accountController)
54)
55activityPubClientRouter.get('/accounts?/:name/followers',
56 executeIfActivityPub,
57 activityPubRateLimiter,
58 asyncMiddleware(localAccountValidator),
59 asyncMiddleware(accountFollowersController)
60)
61activityPubClientRouter.get('/accounts?/:name/following',
62 executeIfActivityPub,
63 activityPubRateLimiter,
64 asyncMiddleware(localAccountValidator),
65 asyncMiddleware(accountFollowingController)
66)
67activityPubClientRouter.get('/accounts?/:name/playlists',
68 executeIfActivityPub,
69 activityPubRateLimiter,
70 asyncMiddleware(localAccountValidator),
71 asyncMiddleware(accountPlaylistsController)
72)
73activityPubClientRouter.get('/accounts?/:name/likes/:videoId',
74 executeIfActivityPub,
75 activityPubRateLimiter,
76 cacheRoute(ROUTE_CACHE_LIFETIME.ACTIVITY_PUB.VIDEOS),
77 asyncMiddleware(getAccountVideoRateValidatorFactory('like')),
78 asyncMiddleware(getAccountVideoRateFactory('like'))
79)
80activityPubClientRouter.get('/accounts?/:name/dislikes/:videoId',
81 executeIfActivityPub,
82 activityPubRateLimiter,
83 cacheRoute(ROUTE_CACHE_LIFETIME.ACTIVITY_PUB.VIDEOS),
84 asyncMiddleware(getAccountVideoRateValidatorFactory('dislike')),
85 asyncMiddleware(getAccountVideoRateFactory('dislike'))
86)
87
88activityPubClientRouter.get(
89 [ '/videos/watch/:id', '/w/:id' ],
90 executeIfActivityPub,
91 activityPubRateLimiter,
92 cacheRoute(ROUTE_CACHE_LIFETIME.ACTIVITY_PUB.VIDEOS),
93 asyncMiddleware(videosCustomGetValidator('all')),
94 asyncMiddleware(videoController)
95)
96activityPubClientRouter.get('/videos/watch/:id/activity',
97 executeIfActivityPub,
98 activityPubRateLimiter,
99 asyncMiddleware(videosCustomGetValidator('all')),
100 asyncMiddleware(videoController)
101)
102activityPubClientRouter.get('/videos/watch/:id/announces',
103 executeIfActivityPub,
104 activityPubRateLimiter,
105 asyncMiddleware(videosCustomGetValidator('only-immutable-attributes')),
106 asyncMiddleware(videoAnnouncesController)
107)
108activityPubClientRouter.get('/videos/watch/:id/announces/:actorId',
109 executeIfActivityPub,
110 activityPubRateLimiter,
111 asyncMiddleware(videosShareValidator),
112 asyncMiddleware(videoAnnounceController)
113)
114activityPubClientRouter.get('/videos/watch/:id/likes',
115 executeIfActivityPub,
116 activityPubRateLimiter,
117 asyncMiddleware(videosCustomGetValidator('only-immutable-attributes')),
118 asyncMiddleware(videoLikesController)
119)
120activityPubClientRouter.get('/videos/watch/:id/dislikes',
121 executeIfActivityPub,
122 activityPubRateLimiter,
123 asyncMiddleware(videosCustomGetValidator('only-immutable-attributes')),
124 asyncMiddleware(videoDislikesController)
125)
126activityPubClientRouter.get('/videos/watch/:id/comments',
127 executeIfActivityPub,
128 activityPubRateLimiter,
129 asyncMiddleware(videosCustomGetValidator('only-immutable-attributes')),
130 asyncMiddleware(videoCommentsController)
131)
132activityPubClientRouter.get('/videos/watch/:videoId/comments/:commentId',
133 executeIfActivityPub,
134 activityPubRateLimiter,
135 asyncMiddleware(videoCommentGetValidator),
136 asyncMiddleware(videoCommentController)
137)
138activityPubClientRouter.get('/videos/watch/:videoId/comments/:commentId/activity',
139 executeIfActivityPub,
140 activityPubRateLimiter,
141 asyncMiddleware(videoCommentGetValidator),
142 asyncMiddleware(videoCommentController)
143)
144
145activityPubClientRouter.get(
146 [ '/video-channels/:nameWithHost', '/video-channels/:nameWithHost/videos', '/c/:nameWithHost', '/c/:nameWithHost/videos' ],
147 executeIfActivityPub,
148 activityPubRateLimiter,
149 asyncMiddleware(videoChannelsNameWithHostValidator),
150 ensureIsLocalChannel,
151 asyncMiddleware(videoChannelController)
152)
153activityPubClientRouter.get('/video-channels/:nameWithHost/followers',
154 executeIfActivityPub,
155 activityPubRateLimiter,
156 asyncMiddleware(videoChannelsNameWithHostValidator),
157 ensureIsLocalChannel,
158 asyncMiddleware(videoChannelFollowersController)
159)
160activityPubClientRouter.get('/video-channels/:nameWithHost/following',
161 executeIfActivityPub,
162 activityPubRateLimiter,
163 asyncMiddleware(videoChannelsNameWithHostValidator),
164 ensureIsLocalChannel,
165 asyncMiddleware(videoChannelFollowingController)
166)
167activityPubClientRouter.get('/video-channels/:nameWithHost/playlists',
168 executeIfActivityPub,
169 activityPubRateLimiter,
170 asyncMiddleware(videoChannelsNameWithHostValidator),
171 ensureIsLocalChannel,
172 asyncMiddleware(videoChannelPlaylistsController)
173)
174
175activityPubClientRouter.get('/redundancy/videos/:videoId/:resolution([0-9]+)(-:fps([0-9]+))?',
176 executeIfActivityPub,
177 activityPubRateLimiter,
178 asyncMiddleware(videoFileRedundancyGetValidator),
179 asyncMiddleware(videoRedundancyController)
180)
181activityPubClientRouter.get('/redundancy/streaming-playlists/:streamingPlaylistType/:videoId',
182 executeIfActivityPub,
183 activityPubRateLimiter,
184 asyncMiddleware(videoPlaylistRedundancyGetValidator),
185 asyncMiddleware(videoRedundancyController)
186)
187
188activityPubClientRouter.get(
189 [ '/video-playlists/:playlistId', '/videos/watch/playlist/:playlistId', '/w/p/:playlistId' ],
190 executeIfActivityPub,
191 activityPubRateLimiter,
192 asyncMiddleware(videoPlaylistsGetValidator('all')),
193 asyncMiddleware(videoPlaylistController)
194)
195activityPubClientRouter.get('/video-playlists/:playlistId/videos/:playlistElementId',
196 executeIfActivityPub,
197 activityPubRateLimiter,
198 asyncMiddleware(videoPlaylistElementAPGetValidator),
199 asyncMiddleware(videoPlaylistElementController)
200)
201
202activityPubClientRouter.get('/videos/local-viewer/:localViewerId',
203 executeIfActivityPub,
204 activityPubRateLimiter,
205 asyncMiddleware(getVideoLocalViewerValidator),
206 asyncMiddleware(getVideoLocalViewerController)
207)
208
209// ---------------------------------------------------------------------------
210
211export {
212 activityPubClientRouter
213}
214
215// ---------------------------------------------------------------------------
216
217async function accountController (req: express.Request, res: express.Response) {
218 const account = res.locals.account
219
220 return activityPubResponse(activityPubContextify(await account.toActivityPubObject(), 'Actor'), res)
221}
222
223async function accountFollowersController (req: express.Request, res: express.Response) {
224 const account = res.locals.account
225 const activityPubResult = await actorFollowers(req, account.Actor)
226
227 return activityPubResponse(activityPubContextify(activityPubResult, 'Collection'), res)
228}
229
230async function accountFollowingController (req: express.Request, res: express.Response) {
231 const account = res.locals.account
232 const activityPubResult = await actorFollowing(req, account.Actor)
233
234 return activityPubResponse(activityPubContextify(activityPubResult, 'Collection'), res)
235}
236
237async function accountPlaylistsController (req: express.Request, res: express.Response) {
238 const account = res.locals.account
239 const activityPubResult = await actorPlaylists(req, { account })
240
241 return activityPubResponse(activityPubContextify(activityPubResult, 'Collection'), res)
242}
243
244async function videoChannelPlaylistsController (req: express.Request, res: express.Response) {
245 const channel = res.locals.videoChannel
246 const activityPubResult = await actorPlaylists(req, { channel })
247
248 return activityPubResponse(activityPubContextify(activityPubResult, 'Collection'), res)
249}
250
251function getAccountVideoRateFactory (rateType: VideoRateType) {
252 return (req: express.Request, res: express.Response) => {
253 const accountVideoRate = res.locals.accountVideoRate
254
255 const byActor = accountVideoRate.Account.Actor
256 const APObject = rateType === 'like'
257 ? buildLikeActivity(accountVideoRate.url, byActor, accountVideoRate.Video)
258 : buildDislikeActivity(accountVideoRate.url, byActor, accountVideoRate.Video)
259
260 return activityPubResponse(activityPubContextify(APObject, 'Rate'), res)
261 }
262}
263
264async function videoController (req: express.Request, res: express.Response) {
265 const video = res.locals.videoAll
266
267 if (redirectIfNotOwned(video.url, res)) return
268
269 // We need captions to render AP object
270 const videoAP = await video.lightAPToFullAP(undefined)
271
272 const audience = getAudience(videoAP.VideoChannel.Account.Actor, videoAP.privacy === VideoPrivacy.PUBLIC)
273 const videoObject = audiencify(await videoAP.toActivityPubObject(), audience)
274
275 if (req.path.endsWith('/activity')) {
276 const data = buildCreateActivity(videoAP.url, video.VideoChannel.Account.Actor, videoObject, audience)
277 return activityPubResponse(activityPubContextify(data, 'Video'), res)
278 }
279
280 return activityPubResponse(activityPubContextify(videoObject, 'Video'), res)
281}
282
283async function videoAnnounceController (req: express.Request, res: express.Response) {
284 const share = res.locals.videoShare
285
286 if (redirectIfNotOwned(share.url, res)) return
287
288 const { activity } = await buildAnnounceWithVideoAudience(share.Actor, share, res.locals.videoAll, undefined)
289
290 return activityPubResponse(activityPubContextify(activity, 'Announce'), res)
291}
292
293async function videoAnnouncesController (req: express.Request, res: express.Response) {
294 const video = res.locals.onlyImmutableVideo
295
296 if (redirectIfNotOwned(video.url, res)) return
297
298 const handler = async (start: number, count: number) => {
299 const result = await VideoShareModel.listAndCountByVideoId(video.id, start, count)
300 return {
301 total: result.total,
302 data: result.data.map(r => r.url)
303 }
304 }
305 const json = await activityPubCollectionPagination(getLocalVideoSharesActivityPubUrl(video), handler, req.query.page)
306
307 return activityPubResponse(activityPubContextify(json, 'Collection'), res)
308}
309
310async function videoLikesController (req: express.Request, res: express.Response) {
311 const video = res.locals.onlyImmutableVideo
312
313 if (redirectIfNotOwned(video.url, res)) return
314
315 const json = await videoRates(req, 'like', video, getLocalVideoLikesActivityPubUrl(video))
316
317 return activityPubResponse(activityPubContextify(json, 'Collection'), res)
318}
319
320async function videoDislikesController (req: express.Request, res: express.Response) {
321 const video = res.locals.onlyImmutableVideo
322
323 if (redirectIfNotOwned(video.url, res)) return
324
325 const json = await videoRates(req, 'dislike', video, getLocalVideoDislikesActivityPubUrl(video))
326
327 return activityPubResponse(activityPubContextify(json, 'Collection'), res)
328}
329
330async function videoCommentsController (req: express.Request, res: express.Response) {
331 const video = res.locals.onlyImmutableVideo
332
333 if (redirectIfNotOwned(video.url, res)) return
334
335 const handler = async (start: number, count: number) => {
336 const result = await VideoCommentModel.listAndCountByVideoForAP({ video, start, count })
337
338 return {
339 total: result.total,
340 data: result.data.map(r => r.url)
341 }
342 }
343 const json = await activityPubCollectionPagination(getLocalVideoCommentsActivityPubUrl(video), handler, req.query.page)
344
345 return activityPubResponse(activityPubContextify(json, 'Collection'), res)
346}
347
348async function videoChannelController (req: express.Request, res: express.Response) {
349 const videoChannel = res.locals.videoChannel
350
351 return activityPubResponse(activityPubContextify(await videoChannel.toActivityPubObject(), 'Actor'), res)
352}
353
354async function videoChannelFollowersController (req: express.Request, res: express.Response) {
355 const videoChannel = res.locals.videoChannel
356 const activityPubResult = await actorFollowers(req, videoChannel.Actor)
357
358 return activityPubResponse(activityPubContextify(activityPubResult, 'Collection'), res)
359}
360
361async function videoChannelFollowingController (req: express.Request, res: express.Response) {
362 const videoChannel = res.locals.videoChannel
363 const activityPubResult = await actorFollowing(req, videoChannel.Actor)
364
365 return activityPubResponse(activityPubContextify(activityPubResult, 'Collection'), res)
366}
367
368async function videoCommentController (req: express.Request, res: express.Response) {
369 const videoComment = res.locals.videoCommentFull
370
371 if (redirectIfNotOwned(videoComment.url, res)) return
372
373 const threadParentComments = await VideoCommentModel.listThreadParentComments(videoComment, undefined)
374 const isPublic = true // Comments are always public
375 let videoCommentObject = videoComment.toActivityPubObject(threadParentComments)
376
377 if (videoComment.Account) {
378 const audience = getAudience(videoComment.Account.Actor, isPublic)
379 videoCommentObject = audiencify(videoCommentObject, audience)
380
381 if (req.path.endsWith('/activity')) {
382 const data = buildCreateActivity(videoComment.url, videoComment.Account.Actor, videoCommentObject as VideoCommentObject, audience)
383 return activityPubResponse(activityPubContextify(data, 'Comment'), res)
384 }
385 }
386
387 return activityPubResponse(activityPubContextify(videoCommentObject, 'Comment'), res)
388}
389
390async function videoRedundancyController (req: express.Request, res: express.Response) {
391 const videoRedundancy = res.locals.videoRedundancy
392
393 if (redirectIfNotOwned(videoRedundancy.url, res)) return
394
395 const serverActor = await getServerActor()
396
397 const audience = getAudience(serverActor)
398 const object = audiencify(videoRedundancy.toActivityPubObject(), audience)
399
400 if (req.path.endsWith('/activity')) {
401 const data = buildCreateActivity(videoRedundancy.url, serverActor, object, audience)
402 return activityPubResponse(activityPubContextify(data, 'CacheFile'), res)
403 }
404
405 return activityPubResponse(activityPubContextify(object, 'CacheFile'), res)
406}
407
408async function videoPlaylistController (req: express.Request, res: express.Response) {
409 const playlist = res.locals.videoPlaylistFull
410
411 if (redirectIfNotOwned(playlist.url, res)) return
412
413 // We need more attributes
414 playlist.OwnerAccount = await AccountModel.load(playlist.ownerAccountId)
415
416 const json = await playlist.toActivityPubObject(req.query.page, null)
417 const audience = getAudience(playlist.OwnerAccount.Actor, playlist.privacy === VideoPlaylistPrivacy.PUBLIC)
418 const object = audiencify(json, audience)
419
420 return activityPubResponse(activityPubContextify(object, 'Playlist'), res)
421}
422
423function videoPlaylistElementController (req: express.Request, res: express.Response) {
424 const videoPlaylistElement = res.locals.videoPlaylistElementAP
425
426 if (redirectIfNotOwned(videoPlaylistElement.url, res)) return
427
428 const json = videoPlaylistElement.toActivityPubObject()
429 return activityPubResponse(activityPubContextify(json, 'Playlist'), res)
430}
431
432function getVideoLocalViewerController (req: express.Request, res: express.Response) {
433 const localViewer = res.locals.localViewerFull
434
435 return activityPubResponse(activityPubContextify(localViewer.toActivityPubObject(), 'WatchAction'), res)
436}
437
438// ---------------------------------------------------------------------------
439
440function actorFollowing (req: express.Request, actor: MActorId) {
441 const handler = (start: number, count: number) => {
442 return ActorFollowModel.listAcceptedFollowingUrlsForApi([ actor.id ], undefined, start, count)
443 }
444
445 return activityPubCollectionPagination(WEBSERVER.URL + req.path, handler, req.query.page)
446}
447
448function actorFollowers (req: express.Request, actor: MActorId) {
449 const handler = (start: number, count: number) => {
450 return ActorFollowModel.listAcceptedFollowerUrlsForAP([ actor.id ], undefined, start, count)
451 }
452
453 return activityPubCollectionPagination(WEBSERVER.URL + req.path, handler, req.query.page)
454}
455
456function actorPlaylists (req: express.Request, options: { account: MAccountId } | { channel: MChannelId }) {
457 const handler = (start: number, count: number) => {
458 return VideoPlaylistModel.listPublicUrlsOfForAP(options, start, count)
459 }
460
461 return activityPubCollectionPagination(WEBSERVER.URL + req.path, handler, req.query.page)
462}
463
464function videoRates (req: express.Request, rateType: VideoRateType, video: MVideoId, url: string) {
465 const handler = async (start: number, count: number) => {
466 const result = await AccountVideoRateModel.listAndCountAccountUrlsByVideoId(rateType, video.id, start, count)
467 return {
468 total: result.total,
469 data: result.data.map(r => r.url)
470 }
471 }
472 return activityPubCollectionPagination(url, handler, req.query.page)
473}
474
475function redirectIfNotOwned (url: string, res: express.Response) {
476 if (url.startsWith(WEBSERVER.URL) === false) {
477 res.redirect(url)
478 return true
479 }
480
481 return false
482}
diff --git a/server/controllers/activitypub/inbox.ts b/server/controllers/activitypub/inbox.ts
deleted file mode 100644
index 862c7baf1..000000000
--- a/server/controllers/activitypub/inbox.ts
+++ /dev/null
@@ -1,85 +0,0 @@
1import express from 'express'
2import { InboxManager } from '@server/lib/activitypub/inbox-manager'
3import { Activity, ActivityPubCollection, ActivityPubOrderedCollection, RootActivity } from '@shared/models'
4import { HttpStatusCode } from '../../../shared/models/http/http-error-codes'
5import { isActivityValid } from '../../helpers/custom-validators/activitypub/activity'
6import { logger } from '../../helpers/logger'
7import {
8 activityPubRateLimiter,
9 asyncMiddleware,
10 checkSignature,
11 ensureIsLocalChannel,
12 localAccountValidator,
13 signatureValidator,
14 videoChannelsNameWithHostValidator
15} from '../../middlewares'
16import { activityPubValidator } from '../../middlewares/validators/activitypub/activity'
17
18const inboxRouter = express.Router()
19
20inboxRouter.post('/inbox',
21 activityPubRateLimiter,
22 signatureValidator,
23 asyncMiddleware(checkSignature),
24 asyncMiddleware(activityPubValidator),
25 inboxController
26)
27
28inboxRouter.post('/accounts/:name/inbox',
29 activityPubRateLimiter,
30 signatureValidator,
31 asyncMiddleware(checkSignature),
32 asyncMiddleware(localAccountValidator),
33 asyncMiddleware(activityPubValidator),
34 inboxController
35)
36
37inboxRouter.post('/video-channels/:nameWithHost/inbox',
38 activityPubRateLimiter,
39 signatureValidator,
40 asyncMiddleware(checkSignature),
41 asyncMiddleware(videoChannelsNameWithHostValidator),
42 ensureIsLocalChannel,
43 asyncMiddleware(activityPubValidator),
44 inboxController
45)
46
47// ---------------------------------------------------------------------------
48
49export {
50 inboxRouter
51}
52
53// ---------------------------------------------------------------------------
54
55function inboxController (req: express.Request, res: express.Response) {
56 const rootActivity: RootActivity = req.body
57 let activities: Activity[]
58
59 if ([ 'Collection', 'CollectionPage' ].includes(rootActivity.type)) {
60 activities = (rootActivity as ActivityPubCollection).items
61 } else if ([ 'OrderedCollection', 'OrderedCollectionPage' ].includes(rootActivity.type)) {
62 activities = (rootActivity as ActivityPubOrderedCollection<Activity>).orderedItems
63 } else {
64 activities = [ rootActivity as Activity ]
65 }
66
67 // Only keep activities we are able to process
68 logger.debug('Filtering %d activities...', activities.length)
69 activities = activities.filter(a => isActivityValid(a))
70 logger.debug('We keep %d activities.', activities.length, { activities })
71
72 const accountOrChannel = res.locals.account || res.locals.videoChannel
73
74 logger.info('Receiving inbox requests for %d activities by %s.', activities.length, res.locals.signature.actor.url)
75
76 InboxManager.Instance.addInboxMessage({
77 activities,
78 signatureActor: res.locals.signature.actor,
79 inboxActor: accountOrChannel
80 ? accountOrChannel.Actor
81 : undefined
82 })
83
84 return res.status(HttpStatusCode.NO_CONTENT_204).end()
85}
diff --git a/server/controllers/activitypub/index.ts b/server/controllers/activitypub/index.ts
deleted file mode 100644
index c14d95108..000000000
--- a/server/controllers/activitypub/index.ts
+++ /dev/null
@@ -1,17 +0,0 @@
1import express from 'express'
2
3import { activityPubClientRouter } from './client'
4import { inboxRouter } from './inbox'
5import { outboxRouter } from './outbox'
6
7const activityPubRouter = express.Router()
8
9activityPubRouter.use('/', inboxRouter)
10activityPubRouter.use('/', outboxRouter)
11activityPubRouter.use('/', activityPubClientRouter)
12
13// ---------------------------------------------------------------------------
14
15export {
16 activityPubRouter
17}
diff --git a/server/controllers/activitypub/outbox.ts b/server/controllers/activitypub/outbox.ts
deleted file mode 100644
index 8c88b6971..000000000
--- a/server/controllers/activitypub/outbox.ts
+++ /dev/null
@@ -1,86 +0,0 @@
1import express from 'express'
2import { activityPubCollectionPagination } from '@server/lib/activitypub/collection'
3import { activityPubContextify } from '@server/lib/activitypub/context'
4import { MActorLight } from '@server/types/models'
5import { Activity } from '../../../shared/models/activitypub/activity'
6import { VideoPrivacy } from '../../../shared/models/videos'
7import { logger } from '../../helpers/logger'
8import { buildAudience } from '../../lib/activitypub/audience'
9import { buildAnnounceActivity, buildCreateActivity } from '../../lib/activitypub/send'
10import {
11 activityPubRateLimiter,
12 asyncMiddleware,
13 ensureIsLocalChannel,
14 localAccountValidator,
15 videoChannelsNameWithHostValidator
16} from '../../middlewares'
17import { apPaginationValidator } from '../../middlewares/validators/activitypub'
18import { VideoModel } from '../../models/video/video'
19import { activityPubResponse } from './utils'
20
21const outboxRouter = express.Router()
22
23outboxRouter.get('/accounts/:name/outbox',
24 activityPubRateLimiter,
25 apPaginationValidator,
26 localAccountValidator,
27 asyncMiddleware(outboxController)
28)
29
30outboxRouter.get('/video-channels/:nameWithHost/outbox',
31 activityPubRateLimiter,
32 apPaginationValidator,
33 asyncMiddleware(videoChannelsNameWithHostValidator),
34 ensureIsLocalChannel,
35 asyncMiddleware(outboxController)
36)
37
38// ---------------------------------------------------------------------------
39
40export {
41 outboxRouter
42}
43
44// ---------------------------------------------------------------------------
45
46async function outboxController (req: express.Request, res: express.Response) {
47 const accountOrVideoChannel = res.locals.account || res.locals.videoChannel
48 const actor = accountOrVideoChannel.Actor
49 const actorOutboxUrl = actor.url + '/outbox'
50
51 logger.info('Receiving outbox request for %s.', actorOutboxUrl)
52
53 const handler = (start: number, count: number) => buildActivities(actor, start, count)
54 const json = await activityPubCollectionPagination(actorOutboxUrl, handler, req.query.page, req.query.size)
55
56 return activityPubResponse(activityPubContextify(json, 'Collection'), res)
57}
58
59async function buildActivities (actor: MActorLight, start: number, count: number) {
60 const data = await VideoModel.listAllAndSharedByActorForOutbox(actor.id, start, count)
61 const activities: Activity[] = []
62
63 for (const video of data.data) {
64 const byActor = video.VideoChannel.Account.Actor
65 const createActivityAudience = buildAudience([ byActor.followersUrl ], video.privacy === VideoPrivacy.PUBLIC)
66
67 // This is a shared video
68 if (video.VideoShares !== undefined && video.VideoShares.length !== 0) {
69 const videoShare = video.VideoShares[0]
70 const announceActivity = buildAnnounceActivity(videoShare.url, actor, video.url, createActivityAudience)
71
72 activities.push(announceActivity)
73 } else {
74 // FIXME: only use the video URL to reduce load. Breaks compat with PeerTube < 6.0.0
75 const videoObject = await video.toActivityPubObject()
76 const createActivity = buildCreateActivity(video.url, byActor, videoObject, createActivityAudience)
77
78 activities.push(createActivity)
79 }
80 }
81
82 return {
83 data: activities,
84 total: data.total
85 }
86}
diff --git a/server/controllers/activitypub/utils.ts b/server/controllers/activitypub/utils.ts
deleted file mode 100644
index 5de38eb43..000000000
--- a/server/controllers/activitypub/utils.ts
+++ /dev/null
@@ -1,12 +0,0 @@
1import express from 'express'
2
3async function activityPubResponse (promise: Promise<any>, res: express.Response) {
4 const data = await promise
5
6 return res.type('application/activity+json; charset=utf-8')
7 .json(data)
8}
9
10export {
11 activityPubResponse
12}
diff --git a/server/controllers/api/abuse.ts b/server/controllers/api/abuse.ts
deleted file mode 100644
index d582f198d..000000000
--- a/server/controllers/api/abuse.ts
+++ /dev/null
@@ -1,259 +0,0 @@
1import express from 'express'
2import { logger } from '@server/helpers/logger'
3import { createAccountAbuse, createVideoAbuse, createVideoCommentAbuse } from '@server/lib/moderation'
4import { Notifier } from '@server/lib/notifier'
5import { AbuseModel } from '@server/models/abuse/abuse'
6import { AbuseMessageModel } from '@server/models/abuse/abuse-message'
7import { getServerActor } from '@server/models/application/application'
8import { abusePredefinedReasonsMap } from '@shared/core-utils/abuse'
9import { AbuseCreate, AbuseState, HttpStatusCode, UserRight } from '@shared/models'
10import { getFormattedObjects } from '../../helpers/utils'
11import { sequelizeTypescript } from '../../initializers/database'
12import {
13 abuseGetValidator,
14 abuseListForAdminsValidator,
15 abuseReportValidator,
16 abusesSortValidator,
17 abuseUpdateValidator,
18 addAbuseMessageValidator,
19 apiRateLimiter,
20 asyncMiddleware,
21 asyncRetryTransactionMiddleware,
22 authenticate,
23 checkAbuseValidForMessagesValidator,
24 deleteAbuseMessageValidator,
25 ensureUserHasRight,
26 getAbuseValidator,
27 openapiOperationDoc,
28 paginationValidator,
29 setDefaultPagination,
30 setDefaultSort
31} from '../../middlewares'
32import { AccountModel } from '../../models/account/account'
33
34const abuseRouter = express.Router()
35
36abuseRouter.use(apiRateLimiter)
37
38abuseRouter.get('/',
39 openapiOperationDoc({ operationId: 'getAbuses' }),
40 authenticate,
41 ensureUserHasRight(UserRight.MANAGE_ABUSES),
42 paginationValidator,
43 abusesSortValidator,
44 setDefaultSort,
45 setDefaultPagination,
46 abuseListForAdminsValidator,
47 asyncMiddleware(listAbusesForAdmins)
48)
49abuseRouter.put('/:id',
50 authenticate,
51 ensureUserHasRight(UserRight.MANAGE_ABUSES),
52 asyncMiddleware(abuseUpdateValidator),
53 asyncRetryTransactionMiddleware(updateAbuse)
54)
55abuseRouter.post('/',
56 authenticate,
57 asyncMiddleware(abuseReportValidator),
58 asyncRetryTransactionMiddleware(reportAbuse)
59)
60abuseRouter.delete('/:id',
61 authenticate,
62 ensureUserHasRight(UserRight.MANAGE_ABUSES),
63 asyncMiddleware(abuseGetValidator),
64 asyncRetryTransactionMiddleware(deleteAbuse)
65)
66
67abuseRouter.get('/:id/messages',
68 authenticate,
69 asyncMiddleware(getAbuseValidator),
70 checkAbuseValidForMessagesValidator,
71 asyncRetryTransactionMiddleware(listAbuseMessages)
72)
73
74abuseRouter.post('/:id/messages',
75 authenticate,
76 asyncMiddleware(getAbuseValidator),
77 checkAbuseValidForMessagesValidator,
78 addAbuseMessageValidator,
79 asyncRetryTransactionMiddleware(addAbuseMessage)
80)
81
82abuseRouter.delete('/:id/messages/:messageId',
83 authenticate,
84 asyncMiddleware(getAbuseValidator),
85 checkAbuseValidForMessagesValidator,
86 asyncMiddleware(deleteAbuseMessageValidator),
87 asyncRetryTransactionMiddleware(deleteAbuseMessage)
88)
89
90// ---------------------------------------------------------------------------
91
92export {
93 abuseRouter
94}
95
96// ---------------------------------------------------------------------------
97
98async function listAbusesForAdmins (req: express.Request, res: express.Response) {
99 const user = res.locals.oauth.token.user
100 const serverActor = await getServerActor()
101
102 const resultList = await AbuseModel.listForAdminApi({
103 start: req.query.start,
104 count: req.query.count,
105 sort: req.query.sort,
106 id: req.query.id,
107 filter: req.query.filter,
108 predefinedReason: req.query.predefinedReason,
109 search: req.query.search,
110 state: req.query.state,
111 videoIs: req.query.videoIs,
112 searchReporter: req.query.searchReporter,
113 searchReportee: req.query.searchReportee,
114 searchVideo: req.query.searchVideo,
115 searchVideoChannel: req.query.searchVideoChannel,
116 serverAccountId: serverActor.Account.id,
117 user
118 })
119
120 return res.json({
121 total: resultList.total,
122 data: resultList.data.map(d => d.toFormattedAdminJSON())
123 })
124}
125
126async function updateAbuse (req: express.Request, res: express.Response) {
127 const abuse = res.locals.abuse
128 let stateUpdated = false
129
130 if (req.body.moderationComment !== undefined) abuse.moderationComment = req.body.moderationComment
131
132 if (req.body.state !== undefined) {
133 abuse.state = req.body.state
134 stateUpdated = true
135 }
136
137 await sequelizeTypescript.transaction(t => {
138 return abuse.save({ transaction: t })
139 })
140
141 if (stateUpdated === true) {
142 AbuseModel.loadFull(abuse.id)
143 .then(abuseFull => Notifier.Instance.notifyOnAbuseStateChange(abuseFull))
144 .catch(err => logger.error('Cannot notify on abuse state change', { err }))
145 }
146
147 // Do not send the delete to other instances, we updated OUR copy of this abuse
148
149 return res.status(HttpStatusCode.NO_CONTENT_204).end()
150}
151
152async function deleteAbuse (req: express.Request, res: express.Response) {
153 const abuse = res.locals.abuse
154
155 await sequelizeTypescript.transaction(t => {
156 return abuse.destroy({ transaction: t })
157 })
158
159 // Do not send the delete to other instances, we delete OUR copy of this abuse
160
161 return res.status(HttpStatusCode.NO_CONTENT_204).end()
162}
163
164async function reportAbuse (req: express.Request, res: express.Response) {
165 const videoInstance = res.locals.videoAll
166 const commentInstance = res.locals.videoCommentFull
167 const accountInstance = res.locals.account
168
169 const body: AbuseCreate = req.body
170
171 const { id } = await sequelizeTypescript.transaction(async t => {
172 const user = res.locals.oauth.token.User
173 // Don't send abuse notification if reporter is an admin/moderator
174 const skipNotification = user.hasRight(UserRight.MANAGE_ABUSES)
175
176 const reporterAccount = await AccountModel.load(user.Account.id, t)
177 const predefinedReasons = body.predefinedReasons?.map(r => abusePredefinedReasonsMap[r])
178
179 const baseAbuse = {
180 reporterAccountId: reporterAccount.id,
181 reason: body.reason,
182 state: AbuseState.PENDING,
183 predefinedReasons
184 }
185
186 if (body.video) {
187 return createVideoAbuse({
188 baseAbuse,
189 videoInstance,
190 reporterAccount,
191 transaction: t,
192 startAt: body.video.startAt,
193 endAt: body.video.endAt,
194 skipNotification
195 })
196 }
197
198 if (body.comment) {
199 return createVideoCommentAbuse({
200 baseAbuse,
201 commentInstance,
202 reporterAccount,
203 transaction: t,
204 skipNotification
205 })
206 }
207
208 // Account report
209 return createAccountAbuse({
210 baseAbuse,
211 accountInstance,
212 reporterAccount,
213 transaction: t,
214 skipNotification
215 })
216 })
217
218 return res.json({ abuse: { id } })
219}
220
221async function listAbuseMessages (req: express.Request, res: express.Response) {
222 const abuse = res.locals.abuse
223
224 const resultList = await AbuseMessageModel.listForApi(abuse.id)
225
226 return res.json(getFormattedObjects(resultList.data, resultList.total))
227}
228
229async function addAbuseMessage (req: express.Request, res: express.Response) {
230 const abuse = res.locals.abuse
231 const user = res.locals.oauth.token.user
232
233 const abuseMessage = await AbuseMessageModel.create({
234 message: req.body.message,
235 byModerator: abuse.reporterAccountId !== user.Account.id,
236 accountId: user.Account.id,
237 abuseId: abuse.id
238 })
239
240 AbuseModel.loadFull(abuse.id)
241 .then(abuseFull => Notifier.Instance.notifyOnAbuseMessage(abuseFull, abuseMessage))
242 .catch(err => logger.error('Cannot notify on new abuse message', { err }))
243
244 return res.json({
245 abuseMessage: {
246 id: abuseMessage.id
247 }
248 })
249}
250
251async function deleteAbuseMessage (req: express.Request, res: express.Response) {
252 const abuseMessage = res.locals.abuseMessage
253
254 await sequelizeTypescript.transaction(t => {
255 return abuseMessage.destroy({ transaction: t })
256 })
257
258 return res.status(HttpStatusCode.NO_CONTENT_204).end()
259}
diff --git a/server/controllers/api/accounts.ts b/server/controllers/api/accounts.ts
deleted file mode 100644
index 49cd7559a..000000000
--- a/server/controllers/api/accounts.ts
+++ /dev/null
@@ -1,266 +0,0 @@
1import express from 'express'
2import { pickCommonVideoQuery } from '@server/helpers/query'
3import { ActorFollowModel } from '@server/models/actor/actor-follow'
4import { getServerActor } from '@server/models/application/application'
5import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync'
6import { buildNSFWFilter, getCountVideos, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils'
7import { getFormattedObjects } from '../../helpers/utils'
8import { JobQueue } from '../../lib/job-queue'
9import { Hooks } from '../../lib/plugins/hooks'
10import {
11 apiRateLimiter,
12 asyncMiddleware,
13 authenticate,
14 commonVideosFiltersValidator,
15 optionalAuthenticate,
16 paginationValidator,
17 setDefaultPagination,
18 setDefaultSort,
19 setDefaultVideosSort,
20 videoPlaylistsSortValidator,
21 videoRatesSortValidator,
22 videoRatingValidator
23} from '../../middlewares'
24import {
25 accountNameWithHostGetValidator,
26 accountsFollowersSortValidator,
27 accountsSortValidator,
28 ensureAuthUserOwnsAccountValidator,
29 ensureCanManageChannelOrAccount,
30 videoChannelsSortValidator,
31 videoChannelStatsValidator,
32 videoChannelSyncsSortValidator,
33 videosSortValidator
34} from '../../middlewares/validators'
35import { commonVideoPlaylistFiltersValidator, videoPlaylistsSearchValidator } from '../../middlewares/validators/videos/video-playlists'
36import { AccountModel } from '../../models/account/account'
37import { AccountVideoRateModel } from '../../models/account/account-video-rate'
38import { guessAdditionalAttributesFromQuery } from '../../models/video/formatter'
39import { VideoModel } from '../../models/video/video'
40import { VideoChannelModel } from '../../models/video/video-channel'
41import { VideoPlaylistModel } from '../../models/video/video-playlist'
42
43const accountsRouter = express.Router()
44
45accountsRouter.use(apiRateLimiter)
46
47accountsRouter.get('/',
48 paginationValidator,
49 accountsSortValidator,
50 setDefaultSort,
51 setDefaultPagination,
52 asyncMiddleware(listAccounts)
53)
54
55accountsRouter.get('/:accountName',
56 asyncMiddleware(accountNameWithHostGetValidator),
57 getAccount
58)
59
60accountsRouter.get('/:accountName/videos',
61 asyncMiddleware(accountNameWithHostGetValidator),
62 paginationValidator,
63 videosSortValidator,
64 setDefaultVideosSort,
65 setDefaultPagination,
66 optionalAuthenticate,
67 commonVideosFiltersValidator,
68 asyncMiddleware(listAccountVideos)
69)
70
71accountsRouter.get('/:accountName/video-channels',
72 asyncMiddleware(accountNameWithHostGetValidator),
73 videoChannelStatsValidator,
74 paginationValidator,
75 videoChannelsSortValidator,
76 setDefaultSort,
77 setDefaultPagination,
78 asyncMiddleware(listAccountChannels)
79)
80
81accountsRouter.get('/:accountName/video-channel-syncs',
82 authenticate,
83 asyncMiddleware(accountNameWithHostGetValidator),
84 ensureCanManageChannelOrAccount,
85 paginationValidator,
86 videoChannelSyncsSortValidator,
87 setDefaultSort,
88 setDefaultPagination,
89 asyncMiddleware(listAccountChannelsSync)
90)
91
92accountsRouter.get('/:accountName/video-playlists',
93 optionalAuthenticate,
94 asyncMiddleware(accountNameWithHostGetValidator),
95 paginationValidator,
96 videoPlaylistsSortValidator,
97 setDefaultSort,
98 setDefaultPagination,
99 commonVideoPlaylistFiltersValidator,
100 videoPlaylistsSearchValidator,
101 asyncMiddleware(listAccountPlaylists)
102)
103
104accountsRouter.get('/:accountName/ratings',
105 authenticate,
106 asyncMiddleware(accountNameWithHostGetValidator),
107 ensureAuthUserOwnsAccountValidator,
108 paginationValidator,
109 videoRatesSortValidator,
110 setDefaultSort,
111 setDefaultPagination,
112 videoRatingValidator,
113 asyncMiddleware(listAccountRatings)
114)
115
116accountsRouter.get('/:accountName/followers',
117 authenticate,
118 asyncMiddleware(accountNameWithHostGetValidator),
119 ensureAuthUserOwnsAccountValidator,
120 paginationValidator,
121 accountsFollowersSortValidator,
122 setDefaultSort,
123 setDefaultPagination,
124 asyncMiddleware(listAccountFollowers)
125)
126
127// ---------------------------------------------------------------------------
128
129export {
130 accountsRouter
131}
132
133// ---------------------------------------------------------------------------
134
135function getAccount (req: express.Request, res: express.Response) {
136 const account = res.locals.account
137
138 if (account.isOutdated()) {
139 JobQueue.Instance.createJobAsync({ type: 'activitypub-refresher', payload: { type: 'actor', url: account.Actor.url } })
140 }
141
142 return res.json(account.toFormattedJSON())
143}
144
145async function listAccounts (req: express.Request, res: express.Response) {
146 const resultList = await AccountModel.listForApi(req.query.start, req.query.count, req.query.sort)
147
148 return res.json(getFormattedObjects(resultList.data, resultList.total))
149}
150
151async function listAccountChannels (req: express.Request, res: express.Response) {
152 const options = {
153 accountId: res.locals.account.id,
154 start: req.query.start,
155 count: req.query.count,
156 sort: req.query.sort,
157 withStats: req.query.withStats,
158 search: req.query.search
159 }
160
161 const resultList = await VideoChannelModel.listByAccountForAPI(options)
162
163 return res.json(getFormattedObjects(resultList.data, resultList.total))
164}
165
166async function listAccountChannelsSync (req: express.Request, res: express.Response) {
167 const options = {
168 accountId: res.locals.account.id,
169 start: req.query.start,
170 count: req.query.count,
171 sort: req.query.sort,
172 search: req.query.search
173 }
174
175 const resultList = await VideoChannelSyncModel.listByAccountForAPI(options)
176
177 return res.json(getFormattedObjects(resultList.data, resultList.total))
178}
179
180async function listAccountPlaylists (req: express.Request, res: express.Response) {
181 const serverActor = await getServerActor()
182
183 // Allow users to see their private/unlisted video playlists
184 let listMyPlaylists = false
185 if (res.locals.oauth && res.locals.oauth.token.User.Account.id === res.locals.account.id) {
186 listMyPlaylists = true
187 }
188
189 const resultList = await VideoPlaylistModel.listForApi({
190 search: req.query.search,
191 followerActorId: serverActor.id,
192 start: req.query.start,
193 count: req.query.count,
194 sort: req.query.sort,
195 accountId: res.locals.account.id,
196 listMyPlaylists,
197 type: req.query.playlistType
198 })
199
200 return res.json(getFormattedObjects(resultList.data, resultList.total))
201}
202
203async function listAccountVideos (req: express.Request, res: express.Response) {
204 const serverActor = await getServerActor()
205
206 const account = res.locals.account
207
208 const displayOnlyForFollower = isUserAbleToSearchRemoteURI(res)
209 ? null
210 : {
211 actorId: serverActor.id,
212 orLocalVideos: true
213 }
214
215 const countVideos = getCountVideos(req)
216 const query = pickCommonVideoQuery(req.query)
217
218 const apiOptions = await Hooks.wrapObject({
219 ...query,
220
221 displayOnlyForFollower,
222 nsfw: buildNSFWFilter(res, query.nsfw),
223 accountId: account.id,
224 user: res.locals.oauth ? res.locals.oauth.token.User : undefined,
225 countVideos
226 }, 'filter:api.accounts.videos.list.params')
227
228 const resultList = await Hooks.wrapPromiseFun(
229 VideoModel.listForApi,
230 apiOptions,
231 'filter:api.accounts.videos.list.result'
232 )
233
234 return res.json(getFormattedObjects(resultList.data, resultList.total, guessAdditionalAttributesFromQuery(query)))
235}
236
237async function listAccountRatings (req: express.Request, res: express.Response) {
238 const account = res.locals.account
239
240 const resultList = await AccountVideoRateModel.listByAccountForApi({
241 accountId: account.id,
242 start: req.query.start,
243 count: req.query.count,
244 sort: req.query.sort,
245 type: req.query.rating
246 })
247 return res.json(getFormattedObjects(resultList.data, resultList.total))
248}
249
250async function listAccountFollowers (req: express.Request, res: express.Response) {
251 const account = res.locals.account
252
253 const channels = await VideoChannelModel.listAllByAccount(account.id)
254 const actorIds = [ account.actorId ].concat(channels.map(c => c.actorId))
255
256 const resultList = await ActorFollowModel.listFollowersForApi({
257 actorIds,
258 start: req.query.start,
259 count: req.query.count,
260 sort: req.query.sort,
261 search: req.query.search,
262 state: 'accepted'
263 })
264
265 return res.json(getFormattedObjects(resultList.data, resultList.total))
266}
diff --git a/server/controllers/api/blocklist.ts b/server/controllers/api/blocklist.ts
deleted file mode 100644
index dee12b108..000000000
--- a/server/controllers/api/blocklist.ts
+++ /dev/null
@@ -1,110 +0,0 @@
1import express from 'express'
2import { handleToNameAndHost } from '@server/helpers/actors'
3import { logger } from '@server/helpers/logger'
4import { AccountBlocklistModel } from '@server/models/account/account-blocklist'
5import { getServerActor } from '@server/models/application/application'
6import { ServerBlocklistModel } from '@server/models/server/server-blocklist'
7import { MActorAccountId, MUserAccountId } from '@server/types/models'
8import { BlockStatus } from '@shared/models'
9import { apiRateLimiter, asyncMiddleware, blocklistStatusValidator, optionalAuthenticate } from '../../middlewares'
10
11const blocklistRouter = express.Router()
12
13blocklistRouter.use(apiRateLimiter)
14
15blocklistRouter.get('/status',
16 optionalAuthenticate,
17 blocklistStatusValidator,
18 asyncMiddleware(getBlocklistStatus)
19)
20
21// ---------------------------------------------------------------------------
22
23export {
24 blocklistRouter
25}
26
27// ---------------------------------------------------------------------------
28
29async function getBlocklistStatus (req: express.Request, res: express.Response) {
30 const hosts = req.query.hosts as string[]
31 const accounts = req.query.accounts as string[]
32 const user = res.locals.oauth?.token.User
33
34 const serverActor = await getServerActor()
35
36 const byAccountIds = [ serverActor.Account.id ]
37 if (user) byAccountIds.push(user.Account.id)
38
39 const status: BlockStatus = {
40 accounts: {},
41 hosts: {}
42 }
43
44 const baseOptions = {
45 byAccountIds,
46 user,
47 serverActor,
48 status
49 }
50
51 await Promise.all([
52 populateServerBlocklistStatus({ ...baseOptions, hosts }),
53 populateAccountBlocklistStatus({ ...baseOptions, accounts })
54 ])
55
56 return res.json(status)
57}
58
59async function populateServerBlocklistStatus (options: {
60 byAccountIds: number[]
61 user?: MUserAccountId
62 serverActor: MActorAccountId
63 hosts: string[]
64 status: BlockStatus
65}) {
66 const { byAccountIds, user, serverActor, hosts, status } = options
67
68 if (!hosts || hosts.length === 0) return
69
70 const serverBlocklistStatus = await ServerBlocklistModel.getBlockStatus(byAccountIds, hosts)
71
72 logger.debug('Got server blocklist status.', { serverBlocklistStatus, byAccountIds, hosts })
73
74 for (const host of hosts) {
75 const block = serverBlocklistStatus.find(b => b.host === host)
76
77 status.hosts[host] = getStatus(block, serverActor, user)
78 }
79}
80
81async function populateAccountBlocklistStatus (options: {
82 byAccountIds: number[]
83 user?: MUserAccountId
84 serverActor: MActorAccountId
85 accounts: string[]
86 status: BlockStatus
87}) {
88 const { byAccountIds, user, serverActor, accounts, status } = options
89
90 if (!accounts || accounts.length === 0) return
91
92 const accountBlocklistStatus = await AccountBlocklistModel.getBlockStatus(byAccountIds, accounts)
93
94 logger.debug('Got account blocklist status.', { accountBlocklistStatus, byAccountIds, accounts })
95
96 for (const account of accounts) {
97 const sanitizedHandle = handleToNameAndHost(account)
98
99 const block = accountBlocklistStatus.find(b => b.name === sanitizedHandle.name && b.host === sanitizedHandle.host)
100
101 status.accounts[sanitizedHandle.handle] = getStatus(block, serverActor, user)
102 }
103}
104
105function getStatus (block: { accountId: number }, serverActor: MActorAccountId, user?: MUserAccountId) {
106 return {
107 blockedByServer: !!(block && block.accountId === serverActor.Account.id),
108 blockedByUser: !!(block && user && block.accountId === user.Account.id)
109 }
110}
diff --git a/server/controllers/api/bulk.ts b/server/controllers/api/bulk.ts
deleted file mode 100644
index c41c7d378..000000000
--- a/server/controllers/api/bulk.ts
+++ /dev/null
@@ -1,44 +0,0 @@
1import express from 'express'
2import { removeComment } from '@server/lib/video-comment'
3import { bulkRemoveCommentsOfValidator } from '@server/middlewares/validators/bulk'
4import { VideoCommentModel } from '@server/models/video/video-comment'
5import { HttpStatusCode } from '@shared/models'
6import { BulkRemoveCommentsOfBody } from '@shared/models/bulk/bulk-remove-comments-of-body.model'
7import { apiRateLimiter, asyncMiddleware, authenticate } from '../../middlewares'
8
9const bulkRouter = express.Router()
10
11bulkRouter.use(apiRateLimiter)
12
13bulkRouter.post('/remove-comments-of',
14 authenticate,
15 asyncMiddleware(bulkRemoveCommentsOfValidator),
16 asyncMiddleware(bulkRemoveCommentsOf)
17)
18
19// ---------------------------------------------------------------------------
20
21export {
22 bulkRouter
23}
24
25// ---------------------------------------------------------------------------
26
27async function bulkRemoveCommentsOf (req: express.Request, res: express.Response) {
28 const account = res.locals.account
29 const body = req.body as BulkRemoveCommentsOfBody
30 const user = res.locals.oauth.token.User
31
32 const filter = body.scope === 'my-videos'
33 ? { onVideosOfAccount: user.Account }
34 : {}
35
36 const comments = await VideoCommentModel.listForBulkDelete(account, filter)
37
38 // Don't wait result
39 res.status(HttpStatusCode.NO_CONTENT_204).end()
40
41 for (const comment of comments) {
42 await removeComment(comment, req, res)
43 }
44}
diff --git a/server/controllers/api/config.ts b/server/controllers/api/config.ts
deleted file mode 100644
index c5c4c8a74..000000000
--- a/server/controllers/api/config.ts
+++ /dev/null
@@ -1,377 +0,0 @@
1import express from 'express'
2import { remove, writeJSON } from 'fs-extra'
3import { snakeCase } from 'lodash'
4import validator from 'validator'
5import { ServerConfigManager } from '@server/lib/server-config-manager'
6import { About, CustomConfig, UserRight } from '@shared/models'
7import { auditLoggerFactory, CustomConfigAuditView, getAuditIdFromRes } from '../../helpers/audit-logger'
8import { objectConverter } from '../../helpers/core-utils'
9import { CONFIG, reloadConfig } from '../../initializers/config'
10import { ClientHtml } from '../../lib/client-html'
11import { apiRateLimiter, asyncMiddleware, authenticate, ensureUserHasRight, openapiOperationDoc } from '../../middlewares'
12import { customConfigUpdateValidator, ensureConfigIsEditable } from '../../middlewares/validators/config'
13
14const configRouter = express.Router()
15
16configRouter.use(apiRateLimiter)
17
18const auditLogger = auditLoggerFactory('config')
19
20configRouter.get('/',
21 openapiOperationDoc({ operationId: 'getConfig' }),
22 asyncMiddleware(getConfig)
23)
24
25configRouter.get('/about',
26 openapiOperationDoc({ operationId: 'getAbout' }),
27 getAbout
28)
29
30configRouter.get('/custom',
31 openapiOperationDoc({ operationId: 'getCustomConfig' }),
32 authenticate,
33 ensureUserHasRight(UserRight.MANAGE_CONFIGURATION),
34 getCustomConfig
35)
36
37configRouter.put('/custom',
38 openapiOperationDoc({ operationId: 'putCustomConfig' }),
39 authenticate,
40 ensureUserHasRight(UserRight.MANAGE_CONFIGURATION),
41 ensureConfigIsEditable,
42 customConfigUpdateValidator,
43 asyncMiddleware(updateCustomConfig)
44)
45
46configRouter.delete('/custom',
47 openapiOperationDoc({ operationId: 'delCustomConfig' }),
48 authenticate,
49 ensureUserHasRight(UserRight.MANAGE_CONFIGURATION),
50 ensureConfigIsEditable,
51 asyncMiddleware(deleteCustomConfig)
52)
53
54async function getConfig (req: express.Request, res: express.Response) {
55 const json = await ServerConfigManager.Instance.getServerConfig(req.ip)
56
57 return res.json(json)
58}
59
60function getAbout (req: express.Request, res: express.Response) {
61 const about: About = {
62 instance: {
63 name: CONFIG.INSTANCE.NAME,
64 shortDescription: CONFIG.INSTANCE.SHORT_DESCRIPTION,
65 description: CONFIG.INSTANCE.DESCRIPTION,
66 terms: CONFIG.INSTANCE.TERMS,
67 codeOfConduct: CONFIG.INSTANCE.CODE_OF_CONDUCT,
68
69 hardwareInformation: CONFIG.INSTANCE.HARDWARE_INFORMATION,
70
71 creationReason: CONFIG.INSTANCE.CREATION_REASON,
72 moderationInformation: CONFIG.INSTANCE.MODERATION_INFORMATION,
73 administrator: CONFIG.INSTANCE.ADMINISTRATOR,
74 maintenanceLifetime: CONFIG.INSTANCE.MAINTENANCE_LIFETIME,
75 businessModel: CONFIG.INSTANCE.BUSINESS_MODEL,
76
77 languages: CONFIG.INSTANCE.LANGUAGES,
78 categories: CONFIG.INSTANCE.CATEGORIES
79 }
80 }
81
82 return res.json(about)
83}
84
85function getCustomConfig (req: express.Request, res: express.Response) {
86 const data = customConfig()
87
88 return res.json(data)
89}
90
91async function deleteCustomConfig (req: express.Request, res: express.Response) {
92 await remove(CONFIG.CUSTOM_FILE)
93
94 auditLogger.delete(getAuditIdFromRes(res), new CustomConfigAuditView(customConfig()))
95
96 reloadConfig()
97 ClientHtml.invalidCache()
98
99 const data = customConfig()
100
101 return res.json(data)
102}
103
104async function updateCustomConfig (req: express.Request, res: express.Response) {
105 const oldCustomConfigAuditKeys = new CustomConfigAuditView(customConfig())
106
107 // camelCase to snake_case key + Force number conversion
108 const toUpdateJSON = convertCustomConfigBody(req.body)
109
110 await writeJSON(CONFIG.CUSTOM_FILE, toUpdateJSON, { spaces: 2 })
111
112 reloadConfig()
113 ClientHtml.invalidCache()
114
115 const data = customConfig()
116
117 auditLogger.update(
118 getAuditIdFromRes(res),
119 new CustomConfigAuditView(data),
120 oldCustomConfigAuditKeys
121 )
122
123 return res.json(data)
124}
125
126// ---------------------------------------------------------------------------
127
128export {
129 configRouter
130}
131
132// ---------------------------------------------------------------------------
133
134function customConfig (): CustomConfig {
135 return {
136 instance: {
137 name: CONFIG.INSTANCE.NAME,
138 shortDescription: CONFIG.INSTANCE.SHORT_DESCRIPTION,
139 description: CONFIG.INSTANCE.DESCRIPTION,
140 terms: CONFIG.INSTANCE.TERMS,
141 codeOfConduct: CONFIG.INSTANCE.CODE_OF_CONDUCT,
142
143 creationReason: CONFIG.INSTANCE.CREATION_REASON,
144 moderationInformation: CONFIG.INSTANCE.MODERATION_INFORMATION,
145 administrator: CONFIG.INSTANCE.ADMINISTRATOR,
146 maintenanceLifetime: CONFIG.INSTANCE.MAINTENANCE_LIFETIME,
147 businessModel: CONFIG.INSTANCE.BUSINESS_MODEL,
148 hardwareInformation: CONFIG.INSTANCE.HARDWARE_INFORMATION,
149
150 languages: CONFIG.INSTANCE.LANGUAGES,
151 categories: CONFIG.INSTANCE.CATEGORIES,
152
153 isNSFW: CONFIG.INSTANCE.IS_NSFW,
154 defaultNSFWPolicy: CONFIG.INSTANCE.DEFAULT_NSFW_POLICY,
155
156 defaultClientRoute: CONFIG.INSTANCE.DEFAULT_CLIENT_ROUTE,
157
158 customizations: {
159 css: CONFIG.INSTANCE.CUSTOMIZATIONS.CSS,
160 javascript: CONFIG.INSTANCE.CUSTOMIZATIONS.JAVASCRIPT
161 }
162 },
163 theme: {
164 default: CONFIG.THEME.DEFAULT
165 },
166 services: {
167 twitter: {
168 username: CONFIG.SERVICES.TWITTER.USERNAME,
169 whitelisted: CONFIG.SERVICES.TWITTER.WHITELISTED
170 }
171 },
172 client: {
173 videos: {
174 miniature: {
175 preferAuthorDisplayName: CONFIG.CLIENT.VIDEOS.MINIATURE.PREFER_AUTHOR_DISPLAY_NAME
176 }
177 },
178 menu: {
179 login: {
180 redirectOnSingleExternalAuth: CONFIG.CLIENT.MENU.LOGIN.REDIRECT_ON_SINGLE_EXTERNAL_AUTH
181 }
182 }
183 },
184 cache: {
185 previews: {
186 size: CONFIG.CACHE.PREVIEWS.SIZE
187 },
188 captions: {
189 size: CONFIG.CACHE.VIDEO_CAPTIONS.SIZE
190 },
191 torrents: {
192 size: CONFIG.CACHE.TORRENTS.SIZE
193 },
194 storyboards: {
195 size: CONFIG.CACHE.STORYBOARDS.SIZE
196 }
197 },
198 signup: {
199 enabled: CONFIG.SIGNUP.ENABLED,
200 limit: CONFIG.SIGNUP.LIMIT,
201 requiresApproval: CONFIG.SIGNUP.REQUIRES_APPROVAL,
202 requiresEmailVerification: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION,
203 minimumAge: CONFIG.SIGNUP.MINIMUM_AGE
204 },
205 admin: {
206 email: CONFIG.ADMIN.EMAIL
207 },
208 contactForm: {
209 enabled: CONFIG.CONTACT_FORM.ENABLED
210 },
211 user: {
212 history: {
213 videos: {
214 enabled: CONFIG.USER.HISTORY.VIDEOS.ENABLED
215 }
216 },
217 videoQuota: CONFIG.USER.VIDEO_QUOTA,
218 videoQuotaDaily: CONFIG.USER.VIDEO_QUOTA_DAILY
219 },
220 videoChannels: {
221 maxPerUser: CONFIG.VIDEO_CHANNELS.MAX_PER_USER
222 },
223 transcoding: {
224 enabled: CONFIG.TRANSCODING.ENABLED,
225 remoteRunners: {
226 enabled: CONFIG.TRANSCODING.REMOTE_RUNNERS.ENABLED
227 },
228 allowAdditionalExtensions: CONFIG.TRANSCODING.ALLOW_ADDITIONAL_EXTENSIONS,
229 allowAudioFiles: CONFIG.TRANSCODING.ALLOW_AUDIO_FILES,
230 threads: CONFIG.TRANSCODING.THREADS,
231 concurrency: CONFIG.TRANSCODING.CONCURRENCY,
232 profile: CONFIG.TRANSCODING.PROFILE,
233 resolutions: {
234 '0p': CONFIG.TRANSCODING.RESOLUTIONS['0p'],
235 '144p': CONFIG.TRANSCODING.RESOLUTIONS['144p'],
236 '240p': CONFIG.TRANSCODING.RESOLUTIONS['240p'],
237 '360p': CONFIG.TRANSCODING.RESOLUTIONS['360p'],
238 '480p': CONFIG.TRANSCODING.RESOLUTIONS['480p'],
239 '720p': CONFIG.TRANSCODING.RESOLUTIONS['720p'],
240 '1080p': CONFIG.TRANSCODING.RESOLUTIONS['1080p'],
241 '1440p': CONFIG.TRANSCODING.RESOLUTIONS['1440p'],
242 '2160p': CONFIG.TRANSCODING.RESOLUTIONS['2160p']
243 },
244 alwaysTranscodeOriginalResolution: CONFIG.TRANSCODING.ALWAYS_TRANSCODE_ORIGINAL_RESOLUTION,
245 webVideos: {
246 enabled: CONFIG.TRANSCODING.WEB_VIDEOS.ENABLED
247 },
248 hls: {
249 enabled: CONFIG.TRANSCODING.HLS.ENABLED
250 }
251 },
252 live: {
253 enabled: CONFIG.LIVE.ENABLED,
254 allowReplay: CONFIG.LIVE.ALLOW_REPLAY,
255 latencySetting: {
256 enabled: CONFIG.LIVE.LATENCY_SETTING.ENABLED
257 },
258 maxDuration: CONFIG.LIVE.MAX_DURATION,
259 maxInstanceLives: CONFIG.LIVE.MAX_INSTANCE_LIVES,
260 maxUserLives: CONFIG.LIVE.MAX_USER_LIVES,
261 transcoding: {
262 enabled: CONFIG.LIVE.TRANSCODING.ENABLED,
263 remoteRunners: {
264 enabled: CONFIG.LIVE.TRANSCODING.REMOTE_RUNNERS.ENABLED
265 },
266 threads: CONFIG.LIVE.TRANSCODING.THREADS,
267 profile: CONFIG.LIVE.TRANSCODING.PROFILE,
268 resolutions: {
269 '144p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['144p'],
270 '240p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['240p'],
271 '360p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['360p'],
272 '480p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['480p'],
273 '720p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['720p'],
274 '1080p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['1080p'],
275 '1440p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['1440p'],
276 '2160p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['2160p']
277 },
278 alwaysTranscodeOriginalResolution: CONFIG.LIVE.TRANSCODING.ALWAYS_TRANSCODE_ORIGINAL_RESOLUTION
279 }
280 },
281 videoStudio: {
282 enabled: CONFIG.VIDEO_STUDIO.ENABLED,
283 remoteRunners: {
284 enabled: CONFIG.VIDEO_STUDIO.REMOTE_RUNNERS.ENABLED
285 }
286 },
287 videoFile: {
288 update: {
289 enabled: CONFIG.VIDEO_FILE.UPDATE.ENABLED
290 }
291 },
292 import: {
293 videos: {
294 concurrency: CONFIG.IMPORT.VIDEOS.CONCURRENCY,
295 http: {
296 enabled: CONFIG.IMPORT.VIDEOS.HTTP.ENABLED
297 },
298 torrent: {
299 enabled: CONFIG.IMPORT.VIDEOS.TORRENT.ENABLED
300 }
301 },
302 videoChannelSynchronization: {
303 enabled: CONFIG.IMPORT.VIDEO_CHANNEL_SYNCHRONIZATION.ENABLED,
304 maxPerUser: CONFIG.IMPORT.VIDEO_CHANNEL_SYNCHRONIZATION.MAX_PER_USER
305 }
306 },
307 trending: {
308 videos: {
309 algorithms: {
310 enabled: CONFIG.TRENDING.VIDEOS.ALGORITHMS.ENABLED,
311 default: CONFIG.TRENDING.VIDEOS.ALGORITHMS.DEFAULT
312 }
313 }
314 },
315 autoBlacklist: {
316 videos: {
317 ofUsers: {
318 enabled: CONFIG.AUTO_BLACKLIST.VIDEOS.OF_USERS.ENABLED
319 }
320 }
321 },
322 followers: {
323 instance: {
324 enabled: CONFIG.FOLLOWERS.INSTANCE.ENABLED,
325 manualApproval: CONFIG.FOLLOWERS.INSTANCE.MANUAL_APPROVAL
326 }
327 },
328 followings: {
329 instance: {
330 autoFollowBack: {
331 enabled: CONFIG.FOLLOWINGS.INSTANCE.AUTO_FOLLOW_BACK.ENABLED
332 },
333
334 autoFollowIndex: {
335 enabled: CONFIG.FOLLOWINGS.INSTANCE.AUTO_FOLLOW_INDEX.ENABLED,
336 indexUrl: CONFIG.FOLLOWINGS.INSTANCE.AUTO_FOLLOW_INDEX.INDEX_URL
337 }
338 }
339 },
340 broadcastMessage: {
341 enabled: CONFIG.BROADCAST_MESSAGE.ENABLED,
342 message: CONFIG.BROADCAST_MESSAGE.MESSAGE,
343 level: CONFIG.BROADCAST_MESSAGE.LEVEL,
344 dismissable: CONFIG.BROADCAST_MESSAGE.DISMISSABLE
345 },
346 search: {
347 remoteUri: {
348 users: CONFIG.SEARCH.REMOTE_URI.USERS,
349 anonymous: CONFIG.SEARCH.REMOTE_URI.ANONYMOUS
350 },
351 searchIndex: {
352 enabled: CONFIG.SEARCH.SEARCH_INDEX.ENABLED,
353 url: CONFIG.SEARCH.SEARCH_INDEX.URL,
354 disableLocalSearch: CONFIG.SEARCH.SEARCH_INDEX.DISABLE_LOCAL_SEARCH,
355 isDefaultSearch: CONFIG.SEARCH.SEARCH_INDEX.IS_DEFAULT_SEARCH
356 }
357 }
358 }
359}
360
361function convertCustomConfigBody (body: CustomConfig) {
362 function keyConverter (k: string) {
363 // Transcoding resolutions exception
364 if (/^\d{3,4}p$/.exec(k)) return k
365 if (k === '0p') return k
366
367 return snakeCase(k)
368 }
369
370 function valueConverter (v: any) {
371 if (validator.isNumeric(v + '')) return parseInt('' + v, 10)
372
373 return v
374 }
375
376 return objectConverter(body, keyConverter, valueConverter)
377}
diff --git a/server/controllers/api/custom-page.ts b/server/controllers/api/custom-page.ts
deleted file mode 100644
index f4e1a0e79..000000000
--- a/server/controllers/api/custom-page.ts
+++ /dev/null
@@ -1,48 +0,0 @@
1import express from 'express'
2import { ServerConfigManager } from '@server/lib/server-config-manager'
3import { ActorCustomPageModel } from '@server/models/account/actor-custom-page'
4import { HttpStatusCode, UserRight } from '@shared/models'
5import { apiRateLimiter, asyncMiddleware, authenticate, ensureUserHasRight } from '../../middlewares'
6
7const customPageRouter = express.Router()
8
9customPageRouter.use(apiRateLimiter)
10
11customPageRouter.get('/homepage/instance',
12 asyncMiddleware(getInstanceHomepage)
13)
14
15customPageRouter.put('/homepage/instance',
16 authenticate,
17 ensureUserHasRight(UserRight.MANAGE_INSTANCE_CUSTOM_PAGE),
18 asyncMiddleware(updateInstanceHomepage)
19)
20
21// ---------------------------------------------------------------------------
22
23export {
24 customPageRouter
25}
26
27// ---------------------------------------------------------------------------
28
29async function getInstanceHomepage (req: express.Request, res: express.Response) {
30 const page = await ActorCustomPageModel.loadInstanceHomepage()
31 if (!page) {
32 return res.fail({
33 status: HttpStatusCode.NOT_FOUND_404,
34 message: 'Instance homepage could not be found'
35 })
36 }
37
38 return res.json(page.toFormattedJSON())
39}
40
41async function updateInstanceHomepage (req: express.Request, res: express.Response) {
42 const content = req.body.content
43
44 await ActorCustomPageModel.updateInstanceHomepage(content)
45 ServerConfigManager.Instance.updateHomepageState(content)
46
47 return res.status(HttpStatusCode.NO_CONTENT_204).end()
48}
diff --git a/server/controllers/api/index.ts b/server/controllers/api/index.ts
deleted file mode 100644
index 38bd135d0..000000000
--- a/server/controllers/api/index.ts
+++ /dev/null
@@ -1,73 +0,0 @@
1import cors from 'cors'
2import express from 'express'
3import { logger } from '@server/helpers/logger'
4import { HttpStatusCode } from '../../../shared/models'
5import { abuseRouter } from './abuse'
6import { accountsRouter } from './accounts'
7import { blocklistRouter } from './blocklist'
8import { bulkRouter } from './bulk'
9import { configRouter } from './config'
10import { customPageRouter } from './custom-page'
11import { jobsRouter } from './jobs'
12import { metricsRouter } from './metrics'
13import { oauthClientsRouter } from './oauth-clients'
14import { overviewsRouter } from './overviews'
15import { pluginRouter } from './plugins'
16import { runnersRouter } from './runners'
17import { searchRouter } from './search'
18import { serverRouter } from './server'
19import { usersRouter } from './users'
20import { videoChannelRouter } from './video-channel'
21import { videoChannelSyncRouter } from './video-channel-sync'
22import { videoPlaylistRouter } from './video-playlist'
23import { videosRouter } from './videos'
24
25const apiRouter = express.Router()
26
27apiRouter.use(cors({
28 origin: '*',
29 exposedHeaders: 'Retry-After',
30 credentials: true
31}))
32
33apiRouter.use('/server', serverRouter)
34apiRouter.use('/abuses', abuseRouter)
35apiRouter.use('/bulk', bulkRouter)
36apiRouter.use('/oauth-clients', oauthClientsRouter)
37apiRouter.use('/config', configRouter)
38apiRouter.use('/users', usersRouter)
39apiRouter.use('/accounts', accountsRouter)
40apiRouter.use('/video-channels', videoChannelRouter)
41apiRouter.use('/video-channel-syncs', videoChannelSyncRouter)
42apiRouter.use('/video-playlists', videoPlaylistRouter)
43apiRouter.use('/videos', videosRouter)
44apiRouter.use('/jobs', jobsRouter)
45apiRouter.use('/metrics', metricsRouter)
46apiRouter.use('/search', searchRouter)
47apiRouter.use('/overviews', overviewsRouter)
48apiRouter.use('/plugins', pluginRouter)
49apiRouter.use('/custom-pages', customPageRouter)
50apiRouter.use('/blocklist', blocklistRouter)
51apiRouter.use('/runners', runnersRouter)
52
53// apiRouter.use(apiRateLimiter)
54apiRouter.use('/ping', pong)
55apiRouter.use('/*', badRequest)
56
57// ---------------------------------------------------------------------------
58
59export { apiRouter }
60
61// ---------------------------------------------------------------------------
62
63function pong (req: express.Request, res: express.Response) {
64 return res.send('pong').status(HttpStatusCode.OK_200).end()
65}
66
67function badRequest (req: express.Request, res: express.Response) {
68 logger.debug(`API express handler not found: bad PeerTube request for ${req.method} - ${req.originalUrl}`)
69
70 return res.type('json')
71 .status(HttpStatusCode.BAD_REQUEST_400)
72 .end()
73}
diff --git a/server/controllers/api/jobs.ts b/server/controllers/api/jobs.ts
deleted file mode 100644
index c701bc970..000000000
--- a/server/controllers/api/jobs.ts
+++ /dev/null
@@ -1,109 +0,0 @@
1import { Job as BullJob } from 'bullmq'
2import express from 'express'
3import { HttpStatusCode, Job, JobState, JobType, ResultList, UserRight } from '@shared/models'
4import { isArray } from '../../helpers/custom-validators/misc'
5import { JobQueue } from '../../lib/job-queue'
6import {
7 apiRateLimiter,
8 asyncMiddleware,
9 authenticate,
10 ensureUserHasRight,
11 jobsSortValidator,
12 openapiOperationDoc,
13 paginationValidatorBuilder,
14 setDefaultPagination,
15 setDefaultSort
16} from '../../middlewares'
17import { listJobsValidator } from '../../middlewares/validators/jobs'
18
19const jobsRouter = express.Router()
20
21jobsRouter.use(apiRateLimiter)
22
23jobsRouter.post('/pause',
24 authenticate,
25 ensureUserHasRight(UserRight.MANAGE_JOBS),
26 asyncMiddleware(pauseJobQueue)
27)
28
29jobsRouter.post('/resume',
30 authenticate,
31 ensureUserHasRight(UserRight.MANAGE_JOBS),
32 resumeJobQueue
33)
34
35jobsRouter.get('/:state?',
36 openapiOperationDoc({ operationId: 'getJobs' }),
37 authenticate,
38 ensureUserHasRight(UserRight.MANAGE_JOBS),
39 paginationValidatorBuilder([ 'jobs' ]),
40 jobsSortValidator,
41 setDefaultSort,
42 setDefaultPagination,
43 listJobsValidator,
44 asyncMiddleware(listJobs)
45)
46
47// ---------------------------------------------------------------------------
48
49export {
50 jobsRouter
51}
52
53// ---------------------------------------------------------------------------
54
55async function pauseJobQueue (req: express.Request, res: express.Response) {
56 await JobQueue.Instance.pause()
57
58 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
59}
60
61function resumeJobQueue (req: express.Request, res: express.Response) {
62 JobQueue.Instance.resume()
63
64 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
65}
66
67async function listJobs (req: express.Request, res: express.Response) {
68 const state = req.params.state as JobState
69 const asc = req.query.sort === 'createdAt'
70 const jobType = req.query.jobType
71
72 const jobs = await JobQueue.Instance.listForApi({
73 state,
74 start: req.query.start,
75 count: req.query.count,
76 asc,
77 jobType
78 })
79 const total = await JobQueue.Instance.count(state, jobType)
80
81 const result: ResultList<Job> = {
82 total,
83 data: await Promise.all(jobs.map(j => formatJob(j, state)))
84 }
85
86 return res.json(result)
87}
88
89async function formatJob (job: BullJob, state?: JobState): Promise<Job> {
90 const error = isArray(job.stacktrace) && job.stacktrace.length !== 0
91 ? job.stacktrace[0]
92 : null
93
94 return {
95 id: job.id,
96 state: state || await job.getState(),
97 type: job.queueName as JobType,
98 data: job.data,
99 parent: job.parent
100 ? { id: job.parent.id }
101 : undefined,
102 progress: job.progress as number,
103 priority: job.opts.priority,
104 error,
105 createdAt: new Date(job.timestamp),
106 finishedOn: new Date(job.finishedOn),
107 processedOn: new Date(job.processedOn)
108 }
109}
diff --git a/server/controllers/api/metrics.ts b/server/controllers/api/metrics.ts
deleted file mode 100644
index 909963fa7..000000000
--- a/server/controllers/api/metrics.ts
+++ /dev/null
@@ -1,34 +0,0 @@
1import express from 'express'
2import { CONFIG } from '@server/initializers/config'
3import { OpenTelemetryMetrics } from '@server/lib/opentelemetry/metrics'
4import { HttpStatusCode, PlaybackMetricCreate } from '@shared/models'
5import { addPlaybackMetricValidator, apiRateLimiter, asyncMiddleware } from '../../middlewares'
6
7const metricsRouter = express.Router()
8
9metricsRouter.use(apiRateLimiter)
10
11metricsRouter.post('/playback',
12 asyncMiddleware(addPlaybackMetricValidator),
13 addPlaybackMetric
14)
15
16// ---------------------------------------------------------------------------
17
18export {
19 metricsRouter
20}
21
22// ---------------------------------------------------------------------------
23
24function addPlaybackMetric (req: express.Request, res: express.Response) {
25 if (!CONFIG.OPEN_TELEMETRY.METRICS.ENABLED) {
26 return res.sendStatus(HttpStatusCode.FORBIDDEN_403)
27 }
28
29 const body: PlaybackMetricCreate = req.body
30
31 OpenTelemetryMetrics.Instance.observePlaybackMetric(res.locals.onlyImmutableVideo, body)
32
33 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
34}
diff --git a/server/controllers/api/oauth-clients.ts b/server/controllers/api/oauth-clients.ts
deleted file mode 100644
index 1899dbb02..000000000
--- a/server/controllers/api/oauth-clients.ts
+++ /dev/null
@@ -1,54 +0,0 @@
1import express from 'express'
2import { isTestOrDevInstance } from '@server/helpers/core-utils'
3import { OAuthClientModel } from '@server/models/oauth/oauth-client'
4import { HttpStatusCode, OAuthClientLocal } from '@shared/models'
5import { logger } from '../../helpers/logger'
6import { CONFIG } from '../../initializers/config'
7import { apiRateLimiter, asyncMiddleware, openapiOperationDoc } from '../../middlewares'
8
9const oauthClientsRouter = express.Router()
10
11oauthClientsRouter.use(apiRateLimiter)
12
13oauthClientsRouter.get('/local',
14 openapiOperationDoc({ operationId: 'getOAuthClient' }),
15 asyncMiddleware(getLocalClient)
16)
17
18// Get the client credentials for the PeerTube front end
19async function getLocalClient (req: express.Request, res: express.Response, next: express.NextFunction) {
20 const serverHostname = CONFIG.WEBSERVER.HOSTNAME
21 const serverPort = CONFIG.WEBSERVER.PORT
22 let headerHostShouldBe = serverHostname
23 if (serverPort !== 80 && serverPort !== 443) {
24 headerHostShouldBe += ':' + serverPort
25 }
26
27 // Don't make this check if this is a test instance
28 if (!isTestOrDevInstance() && req.get('host') !== headerHostShouldBe) {
29 logger.info(
30 'Getting client tokens for host %s is forbidden (expected %s).', req.get('host'), headerHostShouldBe,
31 { webserverConfig: CONFIG.WEBSERVER }
32 )
33
34 return res.fail({
35 status: HttpStatusCode.FORBIDDEN_403,
36 message: `Getting client tokens for host ${req.get('host')} is forbidden`
37 })
38 }
39
40 const client = await OAuthClientModel.loadFirstClient()
41 if (!client) throw new Error('No client available.')
42
43 const json: OAuthClientLocal = {
44 client_id: client.clientId,
45 client_secret: client.clientSecret
46 }
47 return res.json(json)
48}
49
50// ---------------------------------------------------------------------------
51
52export {
53 oauthClientsRouter
54}
diff --git a/server/controllers/api/overviews.ts b/server/controllers/api/overviews.ts
deleted file mode 100644
index fc616281e..000000000
--- a/server/controllers/api/overviews.ts
+++ /dev/null
@@ -1,139 +0,0 @@
1import express from 'express'
2import memoizee from 'memoizee'
3import { logger } from '@server/helpers/logger'
4import { Hooks } from '@server/lib/plugins/hooks'
5import { getServerActor } from '@server/models/application/application'
6import { VideoModel } from '@server/models/video/video'
7import { CategoryOverview, ChannelOverview, TagOverview, VideosOverview } from '../../../shared/models/overviews'
8import { buildNSFWFilter } from '../../helpers/express-utils'
9import { MEMOIZE_TTL, OVERVIEWS } from '../../initializers/constants'
10import { apiRateLimiter, asyncMiddleware, optionalAuthenticate, videosOverviewValidator } from '../../middlewares'
11import { TagModel } from '../../models/video/tag'
12
13const overviewsRouter = express.Router()
14
15overviewsRouter.use(apiRateLimiter)
16
17overviewsRouter.get('/videos',
18 videosOverviewValidator,
19 optionalAuthenticate,
20 asyncMiddleware(getVideosOverview)
21)
22
23// ---------------------------------------------------------------------------
24
25export { overviewsRouter }
26
27// ---------------------------------------------------------------------------
28
29const buildSamples = memoizee(async function () {
30 const [ categories, channels, tags ] = await Promise.all([
31 VideoModel.getRandomFieldSamples('category', OVERVIEWS.VIDEOS.SAMPLE_THRESHOLD, OVERVIEWS.VIDEOS.SAMPLES_COUNT),
32 VideoModel.getRandomFieldSamples('channelId', OVERVIEWS.VIDEOS.SAMPLE_THRESHOLD, OVERVIEWS.VIDEOS.SAMPLES_COUNT),
33 TagModel.getRandomSamples(OVERVIEWS.VIDEOS.SAMPLE_THRESHOLD, OVERVIEWS.VIDEOS.SAMPLES_COUNT)
34 ])
35
36 const result = { categories, channels, tags }
37
38 logger.debug('Building samples for overview endpoint.', { result })
39
40 return result
41}, { maxAge: MEMOIZE_TTL.OVERVIEWS_SAMPLE })
42
43// This endpoint could be quite long, but we cache it
44async function getVideosOverview (req: express.Request, res: express.Response) {
45 const attributes = await buildSamples()
46
47 const page = req.query.page || 1
48 const index = page - 1
49
50 const categories: CategoryOverview[] = []
51 const channels: ChannelOverview[] = []
52 const tags: TagOverview[] = []
53
54 await Promise.all([
55 getVideosByCategory(attributes.categories, index, res, categories),
56 getVideosByChannel(attributes.channels, index, res, channels),
57 getVideosByTag(attributes.tags, index, res, tags)
58 ])
59
60 const result: VideosOverview = {
61 categories,
62 channels,
63 tags
64 }
65
66 return res.json(result)
67}
68
69async function getVideosByTag (tagsSample: string[], index: number, res: express.Response, acc: TagOverview[]) {
70 if (tagsSample.length <= index) return
71
72 const tag = tagsSample[index]
73 const videos = await getVideos(res, { tagsOneOf: [ tag ] })
74
75 if (videos.length === 0) return
76
77 acc.push({
78 tag,
79 videos
80 })
81}
82
83async function getVideosByCategory (categoriesSample: number[], index: number, res: express.Response, acc: CategoryOverview[]) {
84 if (categoriesSample.length <= index) return
85
86 const category = categoriesSample[index]
87 const videos = await getVideos(res, { categoryOneOf: [ category ] })
88
89 if (videos.length === 0) return
90
91 acc.push({
92 category: videos[0].category,
93 videos
94 })
95}
96
97async function getVideosByChannel (channelsSample: number[], index: number, res: express.Response, acc: ChannelOverview[]) {
98 if (channelsSample.length <= index) return
99
100 const channelId = channelsSample[index]
101 const videos = await getVideos(res, { videoChannelId: channelId })
102
103 if (videos.length === 0) return
104
105 acc.push({
106 channel: videos[0].channel,
107 videos
108 })
109}
110
111async function getVideos (
112 res: express.Response,
113 where: { videoChannelId?: number, tagsOneOf?: string[], categoryOneOf?: number[] }
114) {
115 const serverActor = await getServerActor()
116
117 const query = await Hooks.wrapObject({
118 start: 0,
119 count: 12,
120 sort: '-createdAt',
121 displayOnlyForFollower: {
122 actorId: serverActor.id,
123 orLocalVideos: true
124 },
125 nsfw: buildNSFWFilter(res),
126 user: res.locals.oauth ? res.locals.oauth.token.User : undefined,
127 countVideos: false,
128
129 ...where
130 }, 'filter:api.overviews.videos.list.params')
131
132 const { data } = await Hooks.wrapPromiseFun(
133 VideoModel.listForApi,
134 query,
135 'filter:api.overviews.videos.list.result'
136 )
137
138 return data.map(d => d.toFormattedJSON())
139}
diff --git a/server/controllers/api/plugins.ts b/server/controllers/api/plugins.ts
deleted file mode 100644
index 337b72b2f..000000000
--- a/server/controllers/api/plugins.ts
+++ /dev/null
@@ -1,230 +0,0 @@
1import express from 'express'
2import { logger } from '@server/helpers/logger'
3import { getFormattedObjects } from '@server/helpers/utils'
4import { listAvailablePluginsFromIndex } from '@server/lib/plugins/plugin-index'
5import { PluginManager } from '@server/lib/plugins/plugin-manager'
6import {
7 apiRateLimiter,
8 asyncMiddleware,
9 authenticate,
10 availablePluginsSortValidator,
11 ensureUserHasRight,
12 openapiOperationDoc,
13 paginationValidator,
14 pluginsSortValidator,
15 setDefaultPagination,
16 setDefaultSort
17} from '@server/middlewares'
18import {
19 existingPluginValidator,
20 installOrUpdatePluginValidator,
21 listAvailablePluginsValidator,
22 listPluginsValidator,
23 uninstallPluginValidator,
24 updatePluginSettingsValidator
25} from '@server/middlewares/validators/plugins'
26import { PluginModel } from '@server/models/server/plugin'
27import {
28 HttpStatusCode,
29 InstallOrUpdatePlugin,
30 ManagePlugin,
31 PeertubePluginIndexList,
32 PublicServerSetting,
33 RegisteredServerSettings,
34 UserRight
35} from '@shared/models'
36
37const pluginRouter = express.Router()
38
39pluginRouter.use(apiRateLimiter)
40
41pluginRouter.get('/available',
42 openapiOperationDoc({ operationId: 'getAvailablePlugins' }),
43 authenticate,
44 ensureUserHasRight(UserRight.MANAGE_PLUGINS),
45 listAvailablePluginsValidator,
46 paginationValidator,
47 availablePluginsSortValidator,
48 setDefaultSort,
49 setDefaultPagination,
50 asyncMiddleware(listAvailablePlugins)
51)
52
53pluginRouter.get('/',
54 openapiOperationDoc({ operationId: 'getPlugins' }),
55 authenticate,
56 ensureUserHasRight(UserRight.MANAGE_PLUGINS),
57 listPluginsValidator,
58 paginationValidator,
59 pluginsSortValidator,
60 setDefaultSort,
61 setDefaultPagination,
62 asyncMiddleware(listPlugins)
63)
64
65pluginRouter.get('/:npmName/registered-settings',
66 authenticate,
67 ensureUserHasRight(UserRight.MANAGE_PLUGINS),
68 asyncMiddleware(existingPluginValidator),
69 getPluginRegisteredSettings
70)
71
72pluginRouter.get('/:npmName/public-settings',
73 asyncMiddleware(existingPluginValidator),
74 getPublicPluginSettings
75)
76
77pluginRouter.put('/:npmName/settings',
78 authenticate,
79 ensureUserHasRight(UserRight.MANAGE_PLUGINS),
80 updatePluginSettingsValidator,
81 asyncMiddleware(existingPluginValidator),
82 asyncMiddleware(updatePluginSettings)
83)
84
85pluginRouter.get('/:npmName',
86 authenticate,
87 ensureUserHasRight(UserRight.MANAGE_PLUGINS),
88 asyncMiddleware(existingPluginValidator),
89 getPlugin
90)
91
92pluginRouter.post('/install',
93 openapiOperationDoc({ operationId: 'addPlugin' }),
94 authenticate,
95 ensureUserHasRight(UserRight.MANAGE_PLUGINS),
96 installOrUpdatePluginValidator,
97 asyncMiddleware(installPlugin)
98)
99
100pluginRouter.post('/update',
101 openapiOperationDoc({ operationId: 'updatePlugin' }),
102 authenticate,
103 ensureUserHasRight(UserRight.MANAGE_PLUGINS),
104 installOrUpdatePluginValidator,
105 asyncMiddleware(updatePlugin)
106)
107
108pluginRouter.post('/uninstall',
109 openapiOperationDoc({ operationId: 'uninstallPlugin' }),
110 authenticate,
111 ensureUserHasRight(UserRight.MANAGE_PLUGINS),
112 uninstallPluginValidator,
113 asyncMiddleware(uninstallPlugin)
114)
115
116// ---------------------------------------------------------------------------
117
118export {
119 pluginRouter
120}
121
122// ---------------------------------------------------------------------------
123
124async function listPlugins (req: express.Request, res: express.Response) {
125 const pluginType = req.query.pluginType
126 const uninstalled = req.query.uninstalled
127
128 const resultList = await PluginModel.listForApi({
129 pluginType,
130 uninstalled,
131 start: req.query.start,
132 count: req.query.count,
133 sort: req.query.sort
134 })
135
136 return res.json(getFormattedObjects(resultList.data, resultList.total))
137}
138
139function getPlugin (req: express.Request, res: express.Response) {
140 const plugin = res.locals.plugin
141
142 return res.json(plugin.toFormattedJSON())
143}
144
145async function installPlugin (req: express.Request, res: express.Response) {
146 const body: InstallOrUpdatePlugin = req.body
147
148 const fromDisk = !!body.path
149 const toInstall = body.npmName || body.path
150
151 const pluginVersion = body.pluginVersion && body.npmName
152 ? body.pluginVersion
153 : undefined
154
155 try {
156 const plugin = await PluginManager.Instance.install({ toInstall, version: pluginVersion, fromDisk })
157
158 return res.json(plugin.toFormattedJSON())
159 } catch (err) {
160 logger.warn('Cannot install plugin %s.', toInstall, { err })
161 return res.fail({ message: 'Cannot install plugin ' + toInstall })
162 }
163}
164
165async function updatePlugin (req: express.Request, res: express.Response) {
166 const body: InstallOrUpdatePlugin = req.body
167
168 const fromDisk = !!body.path
169 const toUpdate = body.npmName || body.path
170 try {
171 const plugin = await PluginManager.Instance.update(toUpdate, fromDisk)
172
173 return res.json(plugin.toFormattedJSON())
174 } catch (err) {
175 logger.warn('Cannot update plugin %s.', toUpdate, { err })
176 return res.fail({ message: 'Cannot update plugin ' + toUpdate })
177 }
178}
179
180async function uninstallPlugin (req: express.Request, res: express.Response) {
181 const body: ManagePlugin = req.body
182
183 await PluginManager.Instance.uninstall({ npmName: body.npmName })
184
185 return res.status(HttpStatusCode.NO_CONTENT_204).end()
186}
187
188function getPublicPluginSettings (req: express.Request, res: express.Response) {
189 const plugin = res.locals.plugin
190 const registeredSettings = PluginManager.Instance.getRegisteredSettings(req.params.npmName)
191 const publicSettings = plugin.getPublicSettings(registeredSettings)
192
193 const json: PublicServerSetting = { publicSettings }
194
195 return res.json(json)
196}
197
198function getPluginRegisteredSettings (req: express.Request, res: express.Response) {
199 const registeredSettings = PluginManager.Instance.getRegisteredSettings(req.params.npmName)
200
201 const json: RegisteredServerSettings = { registeredSettings }
202
203 return res.json(json)
204}
205
206async function updatePluginSettings (req: express.Request, res: express.Response) {
207 const plugin = res.locals.plugin
208
209 plugin.settings = req.body.settings
210 await plugin.save()
211
212 await PluginManager.Instance.onSettingsChanged(plugin.name, plugin.settings)
213
214 return res.status(HttpStatusCode.NO_CONTENT_204).end()
215}
216
217async function listAvailablePlugins (req: express.Request, res: express.Response) {
218 const query: PeertubePluginIndexList = req.query
219
220 const resultList = await listAvailablePluginsFromIndex(query)
221
222 if (!resultList) {
223 return res.fail({
224 status: HttpStatusCode.SERVICE_UNAVAILABLE_503,
225 message: 'Plugin index unavailable. Please retry later'
226 })
227 }
228
229 return res.json(resultList)
230}
diff --git a/server/controllers/api/runners/index.ts b/server/controllers/api/runners/index.ts
deleted file mode 100644
index 9998fe4cc..000000000
--- a/server/controllers/api/runners/index.ts
+++ /dev/null
@@ -1,20 +0,0 @@
1import express from 'express'
2import { runnerJobsRouter } from './jobs'
3import { runnerJobFilesRouter } from './jobs-files'
4import { manageRunnersRouter } from './manage-runners'
5import { runnerRegistrationTokensRouter } from './registration-tokens'
6
7const runnersRouter = express.Router()
8
9// No api route limiter here, they are defined in child routers
10
11runnersRouter.use('/', manageRunnersRouter)
12runnersRouter.use('/', runnerJobsRouter)
13runnersRouter.use('/', runnerJobFilesRouter)
14runnersRouter.use('/', runnerRegistrationTokensRouter)
15
16// ---------------------------------------------------------------------------
17
18export {
19 runnersRouter
20}
diff --git a/server/controllers/api/runners/jobs-files.ts b/server/controllers/api/runners/jobs-files.ts
deleted file mode 100644
index d28f43701..000000000
--- a/server/controllers/api/runners/jobs-files.ts
+++ /dev/null
@@ -1,112 +0,0 @@
1import express from 'express'
2import { logger, loggerTagsFactory } from '@server/helpers/logger'
3import { proxifyHLS, proxifyWebVideoFile } from '@server/lib/object-storage'
4import { VideoPathManager } from '@server/lib/video-path-manager'
5import { getStudioTaskFilePath } from '@server/lib/video-studio'
6import { apiRateLimiter, asyncMiddleware } from '@server/middlewares'
7import { jobOfRunnerGetValidatorFactory } from '@server/middlewares/validators/runners'
8import {
9 runnerJobGetVideoStudioTaskFileValidator,
10 runnerJobGetVideoTranscodingFileValidator
11} from '@server/middlewares/validators/runners/job-files'
12import { RunnerJobState, VideoStorage } from '@shared/models'
13
14const lTags = loggerTagsFactory('api', 'runner')
15
16const runnerJobFilesRouter = express.Router()
17
18runnerJobFilesRouter.post('/jobs/:jobUUID/files/videos/:videoId/max-quality',
19 apiRateLimiter,
20 asyncMiddleware(jobOfRunnerGetValidatorFactory([ RunnerJobState.PROCESSING ])),
21 asyncMiddleware(runnerJobGetVideoTranscodingFileValidator),
22 asyncMiddleware(getMaxQualityVideoFile)
23)
24
25runnerJobFilesRouter.post('/jobs/:jobUUID/files/videos/:videoId/previews/max-quality',
26 apiRateLimiter,
27 asyncMiddleware(jobOfRunnerGetValidatorFactory([ RunnerJobState.PROCESSING ])),
28 asyncMiddleware(runnerJobGetVideoTranscodingFileValidator),
29 getMaxQualityVideoPreview
30)
31
32runnerJobFilesRouter.post('/jobs/:jobUUID/files/videos/:videoId/studio/task-files/:filename',
33 apiRateLimiter,
34 asyncMiddleware(jobOfRunnerGetValidatorFactory([ RunnerJobState.PROCESSING ])),
35 asyncMiddleware(runnerJobGetVideoTranscodingFileValidator),
36 runnerJobGetVideoStudioTaskFileValidator,
37 getVideoStudioTaskFile
38)
39
40// ---------------------------------------------------------------------------
41
42export {
43 runnerJobFilesRouter
44}
45
46// ---------------------------------------------------------------------------
47
48async function getMaxQualityVideoFile (req: express.Request, res: express.Response) {
49 const runnerJob = res.locals.runnerJob
50 const runner = runnerJob.Runner
51 const video = res.locals.videoAll
52
53 logger.info(
54 'Get max quality file of video %s of job %s for runner %s', video.uuid, runnerJob.uuid, runner.name,
55 lTags(runner.name, runnerJob.id, runnerJob.type)
56 )
57
58 const file = video.getMaxQualityFile()
59
60 if (file.storage === VideoStorage.OBJECT_STORAGE) {
61 if (file.isHLS()) {
62 return proxifyHLS({
63 req,
64 res,
65 filename: file.filename,
66 playlist: video.getHLSPlaylist(),
67 reinjectVideoFileToken: false,
68 video
69 })
70 }
71
72 // Web video
73 return proxifyWebVideoFile({
74 req,
75 res,
76 filename: file.filename
77 })
78 }
79
80 return VideoPathManager.Instance.makeAvailableVideoFile(file, videoPath => {
81 return res.sendFile(videoPath)
82 })
83}
84
85function getMaxQualityVideoPreview (req: express.Request, res: express.Response) {
86 const runnerJob = res.locals.runnerJob
87 const runner = runnerJob.Runner
88 const video = res.locals.videoAll
89
90 logger.info(
91 'Get max quality preview file of video %s of job %s for runner %s', video.uuid, runnerJob.uuid, runner.name,
92 lTags(runner.name, runnerJob.id, runnerJob.type)
93 )
94
95 const file = video.getPreview()
96
97 return res.sendFile(file.getPath())
98}
99
100function getVideoStudioTaskFile (req: express.Request, res: express.Response) {
101 const runnerJob = res.locals.runnerJob
102 const runner = runnerJob.Runner
103 const video = res.locals.videoAll
104 const filename = req.params.filename
105
106 logger.info(
107 'Get video studio task file %s of video %s of job %s for runner %s', filename, video.uuid, runnerJob.uuid, runner.name,
108 lTags(runner.name, runnerJob.id, runnerJob.type)
109 )
110
111 return res.sendFile(getStudioTaskFilePath(filename))
112}
diff --git a/server/controllers/api/runners/jobs.ts b/server/controllers/api/runners/jobs.ts
deleted file mode 100644
index e9e2ddf49..000000000
--- a/server/controllers/api/runners/jobs.ts
+++ /dev/null
@@ -1,416 +0,0 @@
1import express, { UploadFiles } from 'express'
2import { retryTransactionWrapper } from '@server/helpers/database-utils'
3import { createReqFiles } from '@server/helpers/express-utils'
4import { logger, loggerTagsFactory } from '@server/helpers/logger'
5import { generateRunnerJobToken } from '@server/helpers/token-generator'
6import { MIMETYPES } from '@server/initializers/constants'
7import { sequelizeTypescript } from '@server/initializers/database'
8import { getRunnerJobHandlerClass, runnerJobCanBeCancelled, updateLastRunnerContact } from '@server/lib/runners'
9import {
10 apiRateLimiter,
11 asyncMiddleware,
12 authenticate,
13 ensureUserHasRight,
14 paginationValidator,
15 runnerJobsSortValidator,
16 setDefaultPagination,
17 setDefaultSort
18} from '@server/middlewares'
19import {
20 abortRunnerJobValidator,
21 acceptRunnerJobValidator,
22 cancelRunnerJobValidator,
23 errorRunnerJobValidator,
24 getRunnerFromTokenValidator,
25 jobOfRunnerGetValidatorFactory,
26 listRunnerJobsValidator,
27 runnerJobGetValidator,
28 successRunnerJobValidator,
29 updateRunnerJobValidator
30} from '@server/middlewares/validators/runners'
31import { RunnerModel } from '@server/models/runner/runner'
32import { RunnerJobModel } from '@server/models/runner/runner-job'
33import {
34 AbortRunnerJobBody,
35 AcceptRunnerJobResult,
36 ErrorRunnerJobBody,
37 HttpStatusCode,
38 ListRunnerJobsQuery,
39 LiveRTMPHLSTranscodingUpdatePayload,
40 RequestRunnerJobResult,
41 RunnerJobState,
42 RunnerJobSuccessBody,
43 RunnerJobSuccessPayload,
44 RunnerJobType,
45 RunnerJobUpdateBody,
46 RunnerJobUpdatePayload,
47 ServerErrorCode,
48 UserRight,
49 VideoStudioTranscodingSuccess,
50 VODAudioMergeTranscodingSuccess,
51 VODHLSTranscodingSuccess,
52 VODWebVideoTranscodingSuccess
53} from '@shared/models'
54
55const postRunnerJobSuccessVideoFiles = createReqFiles(
56 [ 'payload[videoFile]', 'payload[resolutionPlaylistFile]' ],
57 { ...MIMETYPES.VIDEO.MIMETYPE_EXT, ...MIMETYPES.M3U8.MIMETYPE_EXT }
58)
59
60const runnerJobUpdateVideoFiles = createReqFiles(
61 [ 'payload[videoChunkFile]', 'payload[resolutionPlaylistFile]', 'payload[masterPlaylistFile]' ],
62 { ...MIMETYPES.VIDEO.MIMETYPE_EXT, ...MIMETYPES.M3U8.MIMETYPE_EXT }
63)
64
65const lTags = loggerTagsFactory('api', 'runner')
66
67const runnerJobsRouter = express.Router()
68
69// ---------------------------------------------------------------------------
70// Controllers for runners
71// ---------------------------------------------------------------------------
72
73runnerJobsRouter.post('/jobs/request',
74 apiRateLimiter,
75 asyncMiddleware(getRunnerFromTokenValidator),
76 asyncMiddleware(requestRunnerJob)
77)
78
79runnerJobsRouter.post('/jobs/:jobUUID/accept',
80 apiRateLimiter,
81 asyncMiddleware(runnerJobGetValidator),
82 acceptRunnerJobValidator,
83 asyncMiddleware(getRunnerFromTokenValidator),
84 asyncMiddleware(acceptRunnerJob)
85)
86
87runnerJobsRouter.post('/jobs/:jobUUID/abort',
88 apiRateLimiter,
89 asyncMiddleware(jobOfRunnerGetValidatorFactory([ RunnerJobState.PROCESSING ])),
90 abortRunnerJobValidator,
91 asyncMiddleware(abortRunnerJob)
92)
93
94runnerJobsRouter.post('/jobs/:jobUUID/update',
95 runnerJobUpdateVideoFiles,
96 apiRateLimiter, // Has to be after multer middleware to parse runner token
97 asyncMiddleware(jobOfRunnerGetValidatorFactory([ RunnerJobState.PROCESSING, RunnerJobState.COMPLETING, RunnerJobState.COMPLETED ])),
98 updateRunnerJobValidator,
99 asyncMiddleware(updateRunnerJobController)
100)
101
102runnerJobsRouter.post('/jobs/:jobUUID/error',
103 asyncMiddleware(jobOfRunnerGetValidatorFactory([ RunnerJobState.PROCESSING ])),
104 errorRunnerJobValidator,
105 asyncMiddleware(errorRunnerJob)
106)
107
108runnerJobsRouter.post('/jobs/:jobUUID/success',
109 postRunnerJobSuccessVideoFiles,
110 apiRateLimiter, // Has to be after multer middleware to parse runner token
111 asyncMiddleware(jobOfRunnerGetValidatorFactory([ RunnerJobState.PROCESSING ])),
112 successRunnerJobValidator,
113 asyncMiddleware(postRunnerJobSuccess)
114)
115
116// ---------------------------------------------------------------------------
117// Controllers for admins
118// ---------------------------------------------------------------------------
119
120runnerJobsRouter.post('/jobs/:jobUUID/cancel',
121 authenticate,
122 ensureUserHasRight(UserRight.MANAGE_RUNNERS),
123 asyncMiddleware(runnerJobGetValidator),
124 cancelRunnerJobValidator,
125 asyncMiddleware(cancelRunnerJob)
126)
127
128runnerJobsRouter.get('/jobs',
129 authenticate,
130 ensureUserHasRight(UserRight.MANAGE_RUNNERS),
131 paginationValidator,
132 runnerJobsSortValidator,
133 setDefaultSort,
134 setDefaultPagination,
135 listRunnerJobsValidator,
136 asyncMiddleware(listRunnerJobs)
137)
138
139runnerJobsRouter.delete('/jobs/:jobUUID',
140 authenticate,
141 ensureUserHasRight(UserRight.MANAGE_RUNNERS),
142 asyncMiddleware(runnerJobGetValidator),
143 asyncMiddleware(deleteRunnerJob)
144)
145
146// ---------------------------------------------------------------------------
147
148export {
149 runnerJobsRouter
150}
151
152// ---------------------------------------------------------------------------
153
154// ---------------------------------------------------------------------------
155// Controllers for runners
156// ---------------------------------------------------------------------------
157
158async function requestRunnerJob (req: express.Request, res: express.Response) {
159 const runner = res.locals.runner
160 const availableJobs = await RunnerJobModel.listAvailableJobs()
161
162 logger.debug('Runner %s requests for a job.', runner.name, { availableJobs, ...lTags(runner.name) })
163
164 const result: RequestRunnerJobResult = {
165 availableJobs: availableJobs.map(j => ({
166 uuid: j.uuid,
167 type: j.type,
168 payload: j.payload
169 }))
170 }
171
172 updateLastRunnerContact(req, runner)
173
174 return res.json(result)
175}
176
177async function acceptRunnerJob (req: express.Request, res: express.Response) {
178 const runner = res.locals.runner
179 const runnerJob = res.locals.runnerJob
180
181 const newRunnerJob = await retryTransactionWrapper(() => {
182 return sequelizeTypescript.transaction(async transaction => {
183 await runnerJob.reload({ transaction })
184
185 if (runnerJob.state !== RunnerJobState.PENDING) {
186 res.fail({
187 type: ServerErrorCode.RUNNER_JOB_NOT_IN_PENDING_STATE,
188 message: 'This job is not in pending state anymore',
189 status: HttpStatusCode.CONFLICT_409
190 })
191
192 return undefined
193 }
194
195 runnerJob.state = RunnerJobState.PROCESSING
196 runnerJob.processingJobToken = generateRunnerJobToken()
197 runnerJob.startedAt = new Date()
198 runnerJob.runnerId = runner.id
199
200 return runnerJob.save({ transaction })
201 })
202 })
203 if (!newRunnerJob) return
204
205 newRunnerJob.Runner = runner as RunnerModel
206
207 const result: AcceptRunnerJobResult = {
208 job: {
209 ...newRunnerJob.toFormattedJSON(),
210
211 jobToken: newRunnerJob.processingJobToken
212 }
213 }
214
215 updateLastRunnerContact(req, runner)
216
217 logger.info(
218 'Remote runner %s has accepted job %s (%s)', runner.name, runnerJob.uuid, runnerJob.type,
219 lTags(runner.name, runnerJob.uuid, runnerJob.type)
220 )
221
222 return res.json(result)
223}
224
225async function abortRunnerJob (req: express.Request, res: express.Response) {
226 const runnerJob = res.locals.runnerJob
227 const runner = runnerJob.Runner
228 const body: AbortRunnerJobBody = req.body
229
230 logger.info(
231 'Remote runner %s is aborting job %s (%s)', runner.name, runnerJob.uuid, runnerJob.type,
232 { reason: body.reason, ...lTags(runner.name, runnerJob.uuid, runnerJob.type) }
233 )
234
235 const RunnerJobHandler = getRunnerJobHandlerClass(runnerJob)
236 await new RunnerJobHandler().abort({ runnerJob })
237
238 updateLastRunnerContact(req, runnerJob.Runner)
239
240 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
241}
242
243async function errorRunnerJob (req: express.Request, res: express.Response) {
244 const runnerJob = res.locals.runnerJob
245 const runner = runnerJob.Runner
246 const body: ErrorRunnerJobBody = req.body
247
248 runnerJob.failures += 1
249
250 logger.error(
251 'Remote runner %s had an error with job %s (%s)', runner.name, runnerJob.uuid, runnerJob.type,
252 { errorMessage: body.message, totalFailures: runnerJob.failures, ...lTags(runner.name, runnerJob.uuid, runnerJob.type) }
253 )
254
255 const RunnerJobHandler = getRunnerJobHandlerClass(runnerJob)
256 await new RunnerJobHandler().error({ runnerJob, message: body.message })
257
258 updateLastRunnerContact(req, runnerJob.Runner)
259
260 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
261}
262
263// ---------------------------------------------------------------------------
264
265const jobUpdateBuilders: {
266 [id in RunnerJobType]?: (payload: RunnerJobUpdatePayload, files?: UploadFiles) => RunnerJobUpdatePayload
267} = {
268 'live-rtmp-hls-transcoding': (payload: LiveRTMPHLSTranscodingUpdatePayload, files) => {
269 return {
270 ...payload,
271
272 masterPlaylistFile: files['payload[masterPlaylistFile]']?.[0].path,
273 resolutionPlaylistFile: files['payload[resolutionPlaylistFile]']?.[0].path,
274 videoChunkFile: files['payload[videoChunkFile]']?.[0].path
275 }
276 }
277}
278
279async function updateRunnerJobController (req: express.Request, res: express.Response) {
280 const runnerJob = res.locals.runnerJob
281 const runner = runnerJob.Runner
282 const body: RunnerJobUpdateBody = req.body
283
284 if (runnerJob.state === RunnerJobState.COMPLETING || runnerJob.state === RunnerJobState.COMPLETED) {
285 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
286 }
287
288 const payloadBuilder = jobUpdateBuilders[runnerJob.type]
289 const updatePayload = payloadBuilder
290 ? payloadBuilder(body.payload, req.files as UploadFiles)
291 : undefined
292
293 logger.debug(
294 'Remote runner %s is updating job %s (%s)', runnerJob.Runner.name, runnerJob.uuid, runnerJob.type,
295 { body, updatePayload, ...lTags(runner.name, runnerJob.uuid, runnerJob.type) }
296 )
297
298 const RunnerJobHandler = getRunnerJobHandlerClass(runnerJob)
299 await new RunnerJobHandler().update({
300 runnerJob,
301 progress: req.body.progress,
302 updatePayload
303 })
304
305 updateLastRunnerContact(req, runnerJob.Runner)
306
307 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
308}
309
310// ---------------------------------------------------------------------------
311
312const jobSuccessPayloadBuilders: {
313 [id in RunnerJobType]: (payload: RunnerJobSuccessPayload, files?: UploadFiles) => RunnerJobSuccessPayload
314} = {
315 'vod-web-video-transcoding': (payload: VODWebVideoTranscodingSuccess, files) => {
316 return {
317 ...payload,
318
319 videoFile: files['payload[videoFile]'][0].path
320 }
321 },
322
323 'vod-hls-transcoding': (payload: VODHLSTranscodingSuccess, files) => {
324 return {
325 ...payload,
326
327 videoFile: files['payload[videoFile]'][0].path,
328 resolutionPlaylistFile: files['payload[resolutionPlaylistFile]'][0].path
329 }
330 },
331
332 'vod-audio-merge-transcoding': (payload: VODAudioMergeTranscodingSuccess, files) => {
333 return {
334 ...payload,
335
336 videoFile: files['payload[videoFile]'][0].path
337 }
338 },
339
340 'video-studio-transcoding': (payload: VideoStudioTranscodingSuccess, files) => {
341 return {
342 ...payload,
343
344 videoFile: files['payload[videoFile]'][0].path
345 }
346 },
347
348 'live-rtmp-hls-transcoding': () => ({})
349}
350
351async function postRunnerJobSuccess (req: express.Request, res: express.Response) {
352 const runnerJob = res.locals.runnerJob
353 const runner = runnerJob.Runner
354 const body: RunnerJobSuccessBody = req.body
355
356 const resultPayload = jobSuccessPayloadBuilders[runnerJob.type](body.payload, req.files as UploadFiles)
357
358 logger.info(
359 'Remote runner %s is sending success result for job %s (%s)', runnerJob.Runner.name, runnerJob.uuid, runnerJob.type,
360 { resultPayload, ...lTags(runner.name, runnerJob.uuid, runnerJob.type) }
361 )
362
363 const RunnerJobHandler = getRunnerJobHandlerClass(runnerJob)
364 await new RunnerJobHandler().complete({ runnerJob, resultPayload })
365
366 updateLastRunnerContact(req, runnerJob.Runner)
367
368 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
369}
370
371// ---------------------------------------------------------------------------
372// Controllers for admins
373// ---------------------------------------------------------------------------
374
375async function cancelRunnerJob (req: express.Request, res: express.Response) {
376 const runnerJob = res.locals.runnerJob
377
378 logger.info('Cancelling job %s (%s)', runnerJob.uuid, runnerJob.type, lTags(runnerJob.uuid, runnerJob.type))
379
380 const RunnerJobHandler = getRunnerJobHandlerClass(runnerJob)
381 await new RunnerJobHandler().cancel({ runnerJob })
382
383 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
384}
385
386async function deleteRunnerJob (req: express.Request, res: express.Response) {
387 const runnerJob = res.locals.runnerJob
388
389 logger.info('Deleting job %s (%s)', runnerJob.uuid, runnerJob.type, lTags(runnerJob.uuid, runnerJob.type))
390
391 if (runnerJobCanBeCancelled(runnerJob)) {
392 const RunnerJobHandler = getRunnerJobHandlerClass(runnerJob)
393 await new RunnerJobHandler().cancel({ runnerJob })
394 }
395
396 await runnerJob.destroy()
397
398 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
399}
400
401async function listRunnerJobs (req: express.Request, res: express.Response) {
402 const query: ListRunnerJobsQuery = req.query
403
404 const resultList = await RunnerJobModel.listForApi({
405 start: query.start,
406 count: query.count,
407 sort: query.sort,
408 search: query.search,
409 stateOneOf: query.stateOneOf
410 })
411
412 return res.json({
413 total: resultList.total,
414 data: resultList.data.map(d => d.toFormattedAdminJSON())
415 })
416}
diff --git a/server/controllers/api/runners/manage-runners.ts b/server/controllers/api/runners/manage-runners.ts
deleted file mode 100644
index be7ebc0b3..000000000
--- a/server/controllers/api/runners/manage-runners.ts
+++ /dev/null
@@ -1,112 +0,0 @@
1import express from 'express'
2import { logger, loggerTagsFactory } from '@server/helpers/logger'
3import { generateRunnerToken } from '@server/helpers/token-generator'
4import {
5 apiRateLimiter,
6 asyncMiddleware,
7 authenticate,
8 ensureUserHasRight,
9 paginationValidator,
10 runnersSortValidator,
11 setDefaultPagination,
12 setDefaultSort
13} from '@server/middlewares'
14import { deleteRunnerValidator, getRunnerFromTokenValidator, registerRunnerValidator } from '@server/middlewares/validators/runners'
15import { RunnerModel } from '@server/models/runner/runner'
16import { HttpStatusCode, ListRunnersQuery, RegisterRunnerBody, UserRight } from '@shared/models'
17
18const lTags = loggerTagsFactory('api', 'runner')
19
20const manageRunnersRouter = express.Router()
21
22manageRunnersRouter.post('/register',
23 apiRateLimiter,
24 asyncMiddleware(registerRunnerValidator),
25 asyncMiddleware(registerRunner)
26)
27manageRunnersRouter.post('/unregister',
28 apiRateLimiter,
29 asyncMiddleware(getRunnerFromTokenValidator),
30 asyncMiddleware(unregisterRunner)
31)
32
33manageRunnersRouter.delete('/:runnerId',
34 apiRateLimiter,
35 authenticate,
36 ensureUserHasRight(UserRight.MANAGE_RUNNERS),
37 asyncMiddleware(deleteRunnerValidator),
38 asyncMiddleware(deleteRunner)
39)
40
41manageRunnersRouter.get('/',
42 apiRateLimiter,
43 authenticate,
44 ensureUserHasRight(UserRight.MANAGE_RUNNERS),
45 paginationValidator,
46 runnersSortValidator,
47 setDefaultSort,
48 setDefaultPagination,
49 asyncMiddleware(listRunners)
50)
51
52// ---------------------------------------------------------------------------
53
54export {
55 manageRunnersRouter
56}
57
58// ---------------------------------------------------------------------------
59
60async function registerRunner (req: express.Request, res: express.Response) {
61 const body: RegisterRunnerBody = req.body
62
63 const runnerToken = generateRunnerToken()
64
65 const runner = new RunnerModel({
66 runnerToken,
67 name: body.name,
68 description: body.description,
69 lastContact: new Date(),
70 ip: req.ip,
71 runnerRegistrationTokenId: res.locals.runnerRegistrationToken.id
72 })
73
74 await runner.save()
75
76 logger.info('Registered new runner %s', runner.name, { ...lTags(runner.name) })
77
78 return res.json({ id: runner.id, runnerToken })
79}
80async function unregisterRunner (req: express.Request, res: express.Response) {
81 const runner = res.locals.runner
82 await runner.destroy()
83
84 logger.info('Unregistered runner %s', runner.name, { ...lTags(runner.name) })
85
86 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
87}
88
89async function deleteRunner (req: express.Request, res: express.Response) {
90 const runner = res.locals.runner
91
92 await runner.destroy()
93
94 logger.info('Deleted runner %s', runner.name, { ...lTags(runner.name) })
95
96 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
97}
98
99async function listRunners (req: express.Request, res: express.Response) {
100 const query: ListRunnersQuery = req.query
101
102 const resultList = await RunnerModel.listForApi({
103 start: query.start,
104 count: query.count,
105 sort: query.sort
106 })
107
108 return res.json({
109 total: resultList.total,
110 data: resultList.data.map(d => d.toFormattedJSON())
111 })
112}
diff --git a/server/controllers/api/runners/registration-tokens.ts b/server/controllers/api/runners/registration-tokens.ts
deleted file mode 100644
index 117ff271b..000000000
--- a/server/controllers/api/runners/registration-tokens.ts
+++ /dev/null
@@ -1,91 +0,0 @@
1import express from 'express'
2import { logger, loggerTagsFactory } from '@server/helpers/logger'
3import { generateRunnerRegistrationToken } from '@server/helpers/token-generator'
4import {
5 apiRateLimiter,
6 asyncMiddleware,
7 authenticate,
8 ensureUserHasRight,
9 paginationValidator,
10 runnerRegistrationTokensSortValidator,
11 setDefaultPagination,
12 setDefaultSort
13} from '@server/middlewares'
14import { deleteRegistrationTokenValidator } from '@server/middlewares/validators/runners'
15import { RunnerRegistrationTokenModel } from '@server/models/runner/runner-registration-token'
16import { HttpStatusCode, ListRunnerRegistrationTokensQuery, UserRight } from '@shared/models'
17
18const lTags = loggerTagsFactory('api', 'runner')
19
20const runnerRegistrationTokensRouter = express.Router()
21
22runnerRegistrationTokensRouter.post('/registration-tokens/generate',
23 apiRateLimiter,
24 authenticate,
25 ensureUserHasRight(UserRight.MANAGE_RUNNERS),
26 asyncMiddleware(generateRegistrationToken)
27)
28
29runnerRegistrationTokensRouter.delete('/registration-tokens/:id',
30 apiRateLimiter,
31 authenticate,
32 ensureUserHasRight(UserRight.MANAGE_RUNNERS),
33 asyncMiddleware(deleteRegistrationTokenValidator),
34 asyncMiddleware(deleteRegistrationToken)
35)
36
37runnerRegistrationTokensRouter.get('/registration-tokens',
38 apiRateLimiter,
39 authenticate,
40 ensureUserHasRight(UserRight.MANAGE_RUNNERS),
41 paginationValidator,
42 runnerRegistrationTokensSortValidator,
43 setDefaultSort,
44 setDefaultPagination,
45 asyncMiddleware(listRegistrationTokens)
46)
47
48// ---------------------------------------------------------------------------
49
50export {
51 runnerRegistrationTokensRouter
52}
53
54// ---------------------------------------------------------------------------
55
56async function generateRegistrationToken (req: express.Request, res: express.Response) {
57 logger.info('Generating new runner registration token.', lTags())
58
59 const registrationToken = new RunnerRegistrationTokenModel({
60 registrationToken: generateRunnerRegistrationToken()
61 })
62
63 await registrationToken.save()
64
65 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
66}
67
68async function deleteRegistrationToken (req: express.Request, res: express.Response) {
69 logger.info('Removing runner registration token.', lTags())
70
71 const runnerRegistrationToken = res.locals.runnerRegistrationToken
72
73 await runnerRegistrationToken.destroy()
74
75 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
76}
77
78async function listRegistrationTokens (req: express.Request, res: express.Response) {
79 const query: ListRunnerRegistrationTokensQuery = req.query
80
81 const resultList = await RunnerRegistrationTokenModel.listForApi({
82 start: query.start,
83 count: query.count,
84 sort: query.sort
85 })
86
87 return res.json({
88 total: resultList.total,
89 data: resultList.data.map(d => d.toFormattedJSON())
90 })
91}
diff --git a/server/controllers/api/search/index.ts b/server/controllers/api/search/index.ts
deleted file mode 100644
index 4d395161c..000000000
--- a/server/controllers/api/search/index.ts
+++ /dev/null
@@ -1,19 +0,0 @@
1import express from 'express'
2import { apiRateLimiter } from '@server/middlewares'
3import { searchChannelsRouter } from './search-video-channels'
4import { searchPlaylistsRouter } from './search-video-playlists'
5import { searchVideosRouter } from './search-videos'
6
7const searchRouter = express.Router()
8
9searchRouter.use(apiRateLimiter)
10
11searchRouter.use('/', searchVideosRouter)
12searchRouter.use('/', searchChannelsRouter)
13searchRouter.use('/', searchPlaylistsRouter)
14
15// ---------------------------------------------------------------------------
16
17export {
18 searchRouter
19}
diff --git a/server/controllers/api/search/search-video-channels.ts b/server/controllers/api/search/search-video-channels.ts
deleted file mode 100644
index 1d2a9d235..000000000
--- a/server/controllers/api/search/search-video-channels.ts
+++ /dev/null
@@ -1,152 +0,0 @@
1import express from 'express'
2import { sanitizeUrl } from '@server/helpers/core-utils'
3import { pickSearchChannelQuery } from '@server/helpers/query'
4import { doJSONRequest } from '@server/helpers/requests'
5import { CONFIG } from '@server/initializers/config'
6import { WEBSERVER } from '@server/initializers/constants'
7import { findLatestAPRedirection } from '@server/lib/activitypub/activity'
8import { Hooks } from '@server/lib/plugins/hooks'
9import { buildMutedForSearchIndex, isSearchIndexSearch, isURISearch } from '@server/lib/search'
10import { getServerActor } from '@server/models/application/application'
11import { HttpStatusCode, ResultList, VideoChannel } from '@shared/models'
12import { VideoChannelsSearchQueryAfterSanitize } from '../../../../shared/models/search'
13import { isUserAbleToSearchRemoteURI } from '../../../helpers/express-utils'
14import { logger } from '../../../helpers/logger'
15import { getFormattedObjects } from '../../../helpers/utils'
16import { getOrCreateAPActor, loadActorUrlOrGetFromWebfinger } from '../../../lib/activitypub/actors'
17import {
18 asyncMiddleware,
19 openapiOperationDoc,
20 optionalAuthenticate,
21 paginationValidator,
22 setDefaultPagination,
23 setDefaultSearchSort,
24 videoChannelsListSearchValidator,
25 videoChannelsSearchSortValidator
26} from '../../../middlewares'
27import { VideoChannelModel } from '../../../models/video/video-channel'
28import { MChannelAccountDefault } from '../../../types/models'
29import { searchLocalUrl } from './shared'
30
31const searchChannelsRouter = express.Router()
32
33searchChannelsRouter.get('/video-channels',
34 openapiOperationDoc({ operationId: 'searchChannels' }),
35 paginationValidator,
36 setDefaultPagination,
37 videoChannelsSearchSortValidator,
38 setDefaultSearchSort,
39 optionalAuthenticate,
40 videoChannelsListSearchValidator,
41 asyncMiddleware(searchVideoChannels)
42)
43
44// ---------------------------------------------------------------------------
45
46export { searchChannelsRouter }
47
48// ---------------------------------------------------------------------------
49
50function searchVideoChannels (req: express.Request, res: express.Response) {
51 const query = pickSearchChannelQuery(req.query)
52 const search = query.search || ''
53
54 const parts = search.split('@')
55
56 // Handle strings like @toto@example.com
57 if (parts.length === 3 && parts[0].length === 0) parts.shift()
58 const isWebfingerSearch = parts.length === 2 && parts.every(p => p && !p.includes(' '))
59
60 if (isURISearch(search) || isWebfingerSearch) return searchVideoChannelURI(search, res)
61
62 // @username -> username to search in DB
63 if (search.startsWith('@')) query.search = search.replace(/^@/, '')
64
65 if (isSearchIndexSearch(query)) {
66 return searchVideoChannelsIndex(query, res)
67 }
68
69 return searchVideoChannelsDB(query, res)
70}
71
72async function searchVideoChannelsIndex (query: VideoChannelsSearchQueryAfterSanitize, res: express.Response) {
73 const result = await buildMutedForSearchIndex(res)
74
75 const body = await Hooks.wrapObject(Object.assign(query, result), 'filter:api.search.video-channels.index.list.params')
76
77 const url = sanitizeUrl(CONFIG.SEARCH.SEARCH_INDEX.URL) + '/api/v1/search/video-channels'
78
79 try {
80 logger.debug('Doing video channels search index request on %s.', url, { body })
81
82 const { body: searchIndexResult } = await doJSONRequest<ResultList<VideoChannel>>(url, { method: 'POST', json: body })
83 const jsonResult = await Hooks.wrapObject(searchIndexResult, 'filter:api.search.video-channels.index.list.result')
84
85 return res.json(jsonResult)
86 } catch (err) {
87 logger.warn('Cannot use search index to make video channels search.', { err })
88
89 return res.fail({
90 status: HttpStatusCode.INTERNAL_SERVER_ERROR_500,
91 message: 'Cannot use search index to make video channels search'
92 })
93 }
94}
95
96async function searchVideoChannelsDB (query: VideoChannelsSearchQueryAfterSanitize, res: express.Response) {
97 const serverActor = await getServerActor()
98
99 const apiOptions = await Hooks.wrapObject({
100 ...query,
101
102 actorId: serverActor.id
103 }, 'filter:api.search.video-channels.local.list.params')
104
105 const resultList = await Hooks.wrapPromiseFun(
106 VideoChannelModel.searchForApi,
107 apiOptions,
108 'filter:api.search.video-channels.local.list.result'
109 )
110
111 return res.json(getFormattedObjects(resultList.data, resultList.total))
112}
113
114async function searchVideoChannelURI (search: string, res: express.Response) {
115 let videoChannel: MChannelAccountDefault
116 let uri = search
117
118 if (!isURISearch(search)) {
119 try {
120 uri = await loadActorUrlOrGetFromWebfinger(search)
121 } catch (err) {
122 logger.warn('Cannot load actor URL or get from webfinger.', { search, err })
123
124 return res.json({ total: 0, data: [] })
125 }
126 }
127
128 if (isUserAbleToSearchRemoteURI(res)) {
129 try {
130 const latestUri = await findLatestAPRedirection(uri)
131
132 const actor = await getOrCreateAPActor(latestUri, 'all', true, true)
133 videoChannel = actor.VideoChannel
134 } catch (err) {
135 logger.info('Cannot search remote video channel %s.', uri, { err })
136 }
137 } else {
138 videoChannel = await searchLocalUrl(sanitizeLocalUrl(uri), url => VideoChannelModel.loadByUrlAndPopulateAccount(url))
139 }
140
141 return res.json({
142 total: videoChannel ? 1 : 0,
143 data: videoChannel ? [ videoChannel.toFormattedJSON() ] : []
144 })
145}
146
147function sanitizeLocalUrl (url: string) {
148 if (!url) return ''
149
150 // Handle alternative channel URLs
151 return url.replace(new RegExp('^' + WEBSERVER.URL + '/c/'), WEBSERVER.URL + '/video-channels/')
152}
diff --git a/server/controllers/api/search/search-video-playlists.ts b/server/controllers/api/search/search-video-playlists.ts
deleted file mode 100644
index 97aeeaba9..000000000
--- a/server/controllers/api/search/search-video-playlists.ts
+++ /dev/null
@@ -1,131 +0,0 @@
1import express from 'express'
2import { sanitizeUrl } from '@server/helpers/core-utils'
3import { isUserAbleToSearchRemoteURI } from '@server/helpers/express-utils'
4import { logger } from '@server/helpers/logger'
5import { pickSearchPlaylistQuery } from '@server/helpers/query'
6import { doJSONRequest } from '@server/helpers/requests'
7import { getFormattedObjects } from '@server/helpers/utils'
8import { CONFIG } from '@server/initializers/config'
9import { WEBSERVER } from '@server/initializers/constants'
10import { findLatestAPRedirection } from '@server/lib/activitypub/activity'
11import { getOrCreateAPVideoPlaylist } from '@server/lib/activitypub/playlists/get'
12import { Hooks } from '@server/lib/plugins/hooks'
13import { buildMutedForSearchIndex, isSearchIndexSearch, isURISearch } from '@server/lib/search'
14import { getServerActor } from '@server/models/application/application'
15import { VideoPlaylistModel } from '@server/models/video/video-playlist'
16import { MVideoPlaylistFullSummary } from '@server/types/models'
17import { HttpStatusCode, ResultList, VideoPlaylist, VideoPlaylistsSearchQueryAfterSanitize } from '@shared/models'
18import {
19 asyncMiddleware,
20 openapiOperationDoc,
21 optionalAuthenticate,
22 paginationValidator,
23 setDefaultPagination,
24 setDefaultSearchSort,
25 videoPlaylistsListSearchValidator,
26 videoPlaylistsSearchSortValidator
27} from '../../../middlewares'
28import { searchLocalUrl } from './shared'
29
30const searchPlaylistsRouter = express.Router()
31
32searchPlaylistsRouter.get('/video-playlists',
33 openapiOperationDoc({ operationId: 'searchPlaylists' }),
34 paginationValidator,
35 setDefaultPagination,
36 videoPlaylistsSearchSortValidator,
37 setDefaultSearchSort,
38 optionalAuthenticate,
39 videoPlaylistsListSearchValidator,
40 asyncMiddleware(searchVideoPlaylists)
41)
42
43// ---------------------------------------------------------------------------
44
45export { searchPlaylistsRouter }
46
47// ---------------------------------------------------------------------------
48
49function searchVideoPlaylists (req: express.Request, res: express.Response) {
50 const query = pickSearchPlaylistQuery(req.query)
51 const search = query.search
52
53 if (isURISearch(search)) return searchVideoPlaylistsURI(search, res)
54
55 if (isSearchIndexSearch(query)) {
56 return searchVideoPlaylistsIndex(query, res)
57 }
58
59 return searchVideoPlaylistsDB(query, res)
60}
61
62async function searchVideoPlaylistsIndex (query: VideoPlaylistsSearchQueryAfterSanitize, res: express.Response) {
63 const result = await buildMutedForSearchIndex(res)
64
65 const body = await Hooks.wrapObject(Object.assign(query, result), 'filter:api.search.video-playlists.index.list.params')
66
67 const url = sanitizeUrl(CONFIG.SEARCH.SEARCH_INDEX.URL) + '/api/v1/search/video-playlists'
68
69 try {
70 logger.debug('Doing video playlists search index request on %s.', url, { body })
71
72 const { body: searchIndexResult } = await doJSONRequest<ResultList<VideoPlaylist>>(url, { method: 'POST', json: body })
73 const jsonResult = await Hooks.wrapObject(searchIndexResult, 'filter:api.search.video-playlists.index.list.result')
74
75 return res.json(jsonResult)
76 } catch (err) {
77 logger.warn('Cannot use search index to make video playlists search.', { err })
78
79 return res.fail({
80 status: HttpStatusCode.INTERNAL_SERVER_ERROR_500,
81 message: 'Cannot use search index to make video playlists search'
82 })
83 }
84}
85
86async function searchVideoPlaylistsDB (query: VideoPlaylistsSearchQueryAfterSanitize, res: express.Response) {
87 const serverActor = await getServerActor()
88
89 const apiOptions = await Hooks.wrapObject({
90 ...query,
91
92 followerActorId: serverActor.id
93 }, 'filter:api.search.video-playlists.local.list.params')
94
95 const resultList = await Hooks.wrapPromiseFun(
96 VideoPlaylistModel.searchForApi,
97 apiOptions,
98 'filter:api.search.video-playlists.local.list.result'
99 )
100
101 return res.json(getFormattedObjects(resultList.data, resultList.total))
102}
103
104async function searchVideoPlaylistsURI (search: string, res: express.Response) {
105 let videoPlaylist: MVideoPlaylistFullSummary
106
107 if (isUserAbleToSearchRemoteURI(res)) {
108 try {
109 const url = await findLatestAPRedirection(search)
110
111 videoPlaylist = await getOrCreateAPVideoPlaylist(url)
112 } catch (err) {
113 logger.info('Cannot search remote video playlist %s.', search, { err })
114 }
115 } else {
116 videoPlaylist = await searchLocalUrl(sanitizeLocalUrl(search), url => VideoPlaylistModel.loadByUrlWithAccountAndChannelSummary(url))
117 }
118
119 return res.json({
120 total: videoPlaylist ? 1 : 0,
121 data: videoPlaylist ? [ videoPlaylist.toFormattedJSON() ] : []
122 })
123}
124
125function sanitizeLocalUrl (url: string) {
126 if (!url) return ''
127
128 // Handle alternative channel URLs
129 return url.replace(new RegExp('^' + WEBSERVER.URL + '/videos/watch/playlist/'), WEBSERVER.URL + '/video-playlists/')
130 .replace(new RegExp('^' + WEBSERVER.URL + '/w/p/'), WEBSERVER.URL + '/video-playlists/')
131}
diff --git a/server/controllers/api/search/search-videos.ts b/server/controllers/api/search/search-videos.ts
deleted file mode 100644
index b33064335..000000000
--- a/server/controllers/api/search/search-videos.ts
+++ /dev/null
@@ -1,167 +0,0 @@
1import express from 'express'
2import { sanitizeUrl } from '@server/helpers/core-utils'
3import { pickSearchVideoQuery } from '@server/helpers/query'
4import { doJSONRequest } from '@server/helpers/requests'
5import { CONFIG } from '@server/initializers/config'
6import { WEBSERVER } from '@server/initializers/constants'
7import { findLatestAPRedirection } from '@server/lib/activitypub/activity'
8import { getOrCreateAPVideo } from '@server/lib/activitypub/videos'
9import { Hooks } from '@server/lib/plugins/hooks'
10import { buildMutedForSearchIndex, isSearchIndexSearch, isURISearch } from '@server/lib/search'
11import { getServerActor } from '@server/models/application/application'
12import { HttpStatusCode, ResultList, Video } from '@shared/models'
13import { VideosSearchQueryAfterSanitize } from '../../../../shared/models/search'
14import { buildNSFWFilter, isUserAbleToSearchRemoteURI } from '../../../helpers/express-utils'
15import { logger } from '../../../helpers/logger'
16import { getFormattedObjects } from '../../../helpers/utils'
17import {
18 asyncMiddleware,
19 commonVideosFiltersValidator,
20 openapiOperationDoc,
21 optionalAuthenticate,
22 paginationValidator,
23 setDefaultPagination,
24 setDefaultSearchSort,
25 videosSearchSortValidator,
26 videosSearchValidator
27} from '../../../middlewares'
28import { guessAdditionalAttributesFromQuery } from '../../../models/video/formatter'
29import { VideoModel } from '../../../models/video/video'
30import { MVideoAccountLightBlacklistAllFiles } from '../../../types/models'
31import { searchLocalUrl } from './shared'
32
33const searchVideosRouter = express.Router()
34
35searchVideosRouter.get('/videos',
36 openapiOperationDoc({ operationId: 'searchVideos' }),
37 paginationValidator,
38 setDefaultPagination,
39 videosSearchSortValidator,
40 setDefaultSearchSort,
41 optionalAuthenticate,
42 commonVideosFiltersValidator,
43 videosSearchValidator,
44 asyncMiddleware(searchVideos)
45)
46
47// ---------------------------------------------------------------------------
48
49export { searchVideosRouter }
50
51// ---------------------------------------------------------------------------
52
53function searchVideos (req: express.Request, res: express.Response) {
54 const query = pickSearchVideoQuery(req.query)
55 const search = query.search
56
57 if (isURISearch(search)) {
58 return searchVideoURI(search, res)
59 }
60
61 if (isSearchIndexSearch(query)) {
62 return searchVideosIndex(query, res)
63 }
64
65 return searchVideosDB(query, res)
66}
67
68async function searchVideosIndex (query: VideosSearchQueryAfterSanitize, res: express.Response) {
69 const result = await buildMutedForSearchIndex(res)
70
71 let body = { ...query, ...result }
72
73 // Use the default instance NSFW policy if not specified
74 if (!body.nsfw) {
75 const nsfwPolicy = res.locals.oauth
76 ? res.locals.oauth.token.User.nsfwPolicy
77 : CONFIG.INSTANCE.DEFAULT_NSFW_POLICY
78
79 body.nsfw = nsfwPolicy === 'do_not_list'
80 ? 'false'
81 : 'both'
82 }
83
84 body = await Hooks.wrapObject(body, 'filter:api.search.videos.index.list.params')
85
86 const url = sanitizeUrl(CONFIG.SEARCH.SEARCH_INDEX.URL) + '/api/v1/search/videos'
87
88 try {
89 logger.debug('Doing videos search index request on %s.', url, { body })
90
91 const { body: searchIndexResult } = await doJSONRequest<ResultList<Video>>(url, { method: 'POST', json: body })
92 const jsonResult = await Hooks.wrapObject(searchIndexResult, 'filter:api.search.videos.index.list.result')
93
94 return res.json(jsonResult)
95 } catch (err) {
96 logger.warn('Cannot use search index to make video search.', { err })
97
98 return res.fail({
99 status: HttpStatusCode.INTERNAL_SERVER_ERROR_500,
100 message: 'Cannot use search index to make video search'
101 })
102 }
103}
104
105async function searchVideosDB (query: VideosSearchQueryAfterSanitize, res: express.Response) {
106 const serverActor = await getServerActor()
107
108 const apiOptions = await Hooks.wrapObject({
109 ...query,
110
111 displayOnlyForFollower: {
112 actorId: serverActor.id,
113 orLocalVideos: true
114 },
115
116 nsfw: buildNSFWFilter(res, query.nsfw),
117 user: res.locals.oauth
118 ? res.locals.oauth.token.User
119 : undefined
120 }, 'filter:api.search.videos.local.list.params')
121
122 const resultList = await Hooks.wrapPromiseFun(
123 VideoModel.searchAndPopulateAccountAndServer,
124 apiOptions,
125 'filter:api.search.videos.local.list.result'
126 )
127
128 return res.json(getFormattedObjects(resultList.data, resultList.total, guessAdditionalAttributesFromQuery(query)))
129}
130
131async function searchVideoURI (url: string, res: express.Response) {
132 let video: MVideoAccountLightBlacklistAllFiles
133
134 // Check if we can fetch a remote video with the URL
135 if (isUserAbleToSearchRemoteURI(res)) {
136 try {
137 const syncParam = {
138 rates: false,
139 shares: false,
140 comments: false,
141 refreshVideo: false
142 }
143
144 const result = await getOrCreateAPVideo({
145 videoObject: await findLatestAPRedirection(url),
146 syncParam
147 })
148 video = result ? result.video : undefined
149 } catch (err) {
150 logger.info('Cannot search remote video %s.', url, { err })
151 }
152 } else {
153 video = await searchLocalUrl(sanitizeLocalUrl(url), url => VideoModel.loadByUrlAndPopulateAccount(url))
154 }
155
156 return res.json({
157 total: video ? 1 : 0,
158 data: video ? [ video.toFormattedJSON() ] : []
159 })
160}
161
162function sanitizeLocalUrl (url: string) {
163 if (!url) return ''
164
165 // Handle alternative video URLs
166 return url.replace(new RegExp('^' + WEBSERVER.URL + '/w/'), WEBSERVER.URL + '/videos/watch/')
167}
diff --git a/server/controllers/api/search/shared/index.ts b/server/controllers/api/search/shared/index.ts
deleted file mode 100644
index 9c56149ef..000000000
--- a/server/controllers/api/search/shared/index.ts
+++ /dev/null
@@ -1 +0,0 @@
1export * from './utils'
diff --git a/server/controllers/api/search/shared/utils.ts b/server/controllers/api/search/shared/utils.ts
deleted file mode 100644
index e02e84f31..000000000
--- a/server/controllers/api/search/shared/utils.ts
+++ /dev/null
@@ -1,16 +0,0 @@
1async function searchLocalUrl <T> (url: string, finder: (url: string) => Promise<T>) {
2 const data = await finder(url)
3 if (data) return data
4
5 return finder(removeQueryParams(url))
6}
7
8export {
9 searchLocalUrl
10}
11
12// ---------------------------------------------------------------------------
13
14function removeQueryParams (url: string) {
15 return url.split('?').shift()
16}
diff --git a/server/controllers/api/server/contact.ts b/server/controllers/api/server/contact.ts
deleted file mode 100644
index 56596bea5..000000000
--- a/server/controllers/api/server/contact.ts
+++ /dev/null
@@ -1,34 +0,0 @@
1import { logger } from '@server/helpers/logger'
2import express from 'express'
3import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes'
4import { ContactForm } from '../../../../shared/models/server'
5import { Emailer } from '../../../lib/emailer'
6import { Redis } from '../../../lib/redis'
7import { asyncMiddleware, contactAdministratorValidator } from '../../../middlewares'
8
9const contactRouter = express.Router()
10
11contactRouter.post('/contact',
12 asyncMiddleware(contactAdministratorValidator),
13 asyncMiddleware(contactAdministrator)
14)
15
16async function contactAdministrator (req: express.Request, res: express.Response) {
17 const data = req.body as ContactForm
18
19 Emailer.Instance.addContactFormJob(data.fromEmail, data.fromName, data.subject, data.body)
20
21 try {
22 await Redis.Instance.setContactFormIp(req.ip)
23 } catch (err) {
24 logger.error(err)
25 }
26
27 return res.status(HttpStatusCode.NO_CONTENT_204).end()
28}
29
30// ---------------------------------------------------------------------------
31
32export {
33 contactRouter
34}
diff --git a/server/controllers/api/server/debug.ts b/server/controllers/api/server/debug.ts
deleted file mode 100644
index f3792bfc8..000000000
--- a/server/controllers/api/server/debug.ts
+++ /dev/null
@@ -1,56 +0,0 @@
1import express from 'express'
2import { InboxManager } from '@server/lib/activitypub/inbox-manager'
3import { RemoveDanglingResumableUploadsScheduler } from '@server/lib/schedulers/remove-dangling-resumable-uploads-scheduler'
4import { VideoViewsBufferScheduler } from '@server/lib/schedulers/video-views-buffer-scheduler'
5import { VideoViewsManager } from '@server/lib/views/video-views-manager'
6import { Debug, SendDebugCommand } from '@shared/models'
7import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes'
8import { UserRight } from '../../../../shared/models/users'
9import { authenticate, ensureUserHasRight } from '../../../middlewares'
10import { VideoChannelSyncLatestScheduler } from '@server/lib/schedulers/video-channel-sync-latest-scheduler'
11import { UpdateVideosScheduler } from '@server/lib/schedulers/update-videos-scheduler'
12
13const debugRouter = express.Router()
14
15debugRouter.get('/debug',
16 authenticate,
17 ensureUserHasRight(UserRight.MANAGE_DEBUG),
18 getDebug
19)
20
21debugRouter.post('/debug/run-command',
22 authenticate,
23 ensureUserHasRight(UserRight.MANAGE_DEBUG),
24 runCommand
25)
26
27// ---------------------------------------------------------------------------
28
29export {
30 debugRouter
31}
32
33// ---------------------------------------------------------------------------
34
35function getDebug (req: express.Request, res: express.Response) {
36 return res.json({
37 ip: req.ip,
38 activityPubMessagesWaiting: InboxManager.Instance.getActivityPubMessagesWaiting()
39 } as Debug)
40}
41
42async function runCommand (req: express.Request, res: express.Response) {
43 const body: SendDebugCommand = req.body
44
45 const processors: { [id in SendDebugCommand['command']]: () => Promise<any> } = {
46 'remove-dandling-resumable-uploads': () => RemoveDanglingResumableUploadsScheduler.Instance.execute(),
47 'process-video-views-buffer': () => VideoViewsBufferScheduler.Instance.execute(),
48 'process-video-viewers': () => VideoViewsManager.Instance.processViewerStats(),
49 'process-update-videos-scheduler': () => UpdateVideosScheduler.Instance.execute(),
50 'process-video-channel-sync-latest': () => VideoChannelSyncLatestScheduler.Instance.execute()
51 }
52
53 await processors[body.command]()
54
55 return res.status(HttpStatusCode.NO_CONTENT_204).end()
56}
diff --git a/server/controllers/api/server/follows.ts b/server/controllers/api/server/follows.ts
deleted file mode 100644
index 87828813a..000000000
--- a/server/controllers/api/server/follows.ts
+++ /dev/null
@@ -1,214 +0,0 @@
1import express from 'express'
2import { getServerActor } from '@server/models/application/application'
3import { ServerFollowCreate } from '@shared/models'
4import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes'
5import { UserRight } from '../../../../shared/models/users'
6import { logger } from '../../../helpers/logger'
7import { getFormattedObjects } from '../../../helpers/utils'
8import { SERVER_ACTOR_NAME } from '../../../initializers/constants'
9import { sequelizeTypescript } from '../../../initializers/database'
10import { autoFollowBackIfNeeded } from '../../../lib/activitypub/follow'
11import { sendAccept, sendReject, sendUndoFollow } from '../../../lib/activitypub/send'
12import { JobQueue } from '../../../lib/job-queue'
13import { removeRedundanciesOfServer } from '../../../lib/redundancy'
14import {
15 asyncMiddleware,
16 authenticate,
17 ensureUserHasRight,
18 paginationValidator,
19 setBodyHostsPort,
20 setDefaultPagination,
21 setDefaultSort
22} from '../../../middlewares'
23import {
24 acceptFollowerValidator,
25 followValidator,
26 getFollowerValidator,
27 instanceFollowersSortValidator,
28 instanceFollowingSortValidator,
29 listFollowsValidator,
30 rejectFollowerValidator,
31 removeFollowingValidator
32} from '../../../middlewares/validators'
33import { ActorFollowModel } from '../../../models/actor/actor-follow'
34
35const serverFollowsRouter = express.Router()
36serverFollowsRouter.get('/following',
37 listFollowsValidator,
38 paginationValidator,
39 instanceFollowingSortValidator,
40 setDefaultSort,
41 setDefaultPagination,
42 asyncMiddleware(listFollowing)
43)
44
45serverFollowsRouter.post('/following',
46 authenticate,
47 ensureUserHasRight(UserRight.MANAGE_SERVER_FOLLOW),
48 followValidator,
49 setBodyHostsPort,
50 asyncMiddleware(addFollow)
51)
52
53serverFollowsRouter.delete('/following/:hostOrHandle',
54 authenticate,
55 ensureUserHasRight(UserRight.MANAGE_SERVER_FOLLOW),
56 asyncMiddleware(removeFollowingValidator),
57 asyncMiddleware(removeFollowing)
58)
59
60serverFollowsRouter.get('/followers',
61 listFollowsValidator,
62 paginationValidator,
63 instanceFollowersSortValidator,
64 setDefaultSort,
65 setDefaultPagination,
66 asyncMiddleware(listFollowers)
67)
68
69serverFollowsRouter.delete('/followers/:nameWithHost',
70 authenticate,
71 ensureUserHasRight(UserRight.MANAGE_SERVER_FOLLOW),
72 asyncMiddleware(getFollowerValidator),
73 asyncMiddleware(removeFollower)
74)
75
76serverFollowsRouter.post('/followers/:nameWithHost/reject',
77 authenticate,
78 ensureUserHasRight(UserRight.MANAGE_SERVER_FOLLOW),
79 asyncMiddleware(getFollowerValidator),
80 rejectFollowerValidator,
81 asyncMiddleware(rejectFollower)
82)
83
84serverFollowsRouter.post('/followers/:nameWithHost/accept',
85 authenticate,
86 ensureUserHasRight(UserRight.MANAGE_SERVER_FOLLOW),
87 asyncMiddleware(getFollowerValidator),
88 acceptFollowerValidator,
89 asyncMiddleware(acceptFollower)
90)
91
92// ---------------------------------------------------------------------------
93
94export {
95 serverFollowsRouter
96}
97
98// ---------------------------------------------------------------------------
99
100async function listFollowing (req: express.Request, res: express.Response) {
101 const serverActor = await getServerActor()
102 const resultList = await ActorFollowModel.listInstanceFollowingForApi({
103 followerId: serverActor.id,
104 start: req.query.start,
105 count: req.query.count,
106 sort: req.query.sort,
107 search: req.query.search,
108 actorType: req.query.actorType,
109 state: req.query.state
110 })
111
112 return res.json(getFormattedObjects(resultList.data, resultList.total))
113}
114
115async function listFollowers (req: express.Request, res: express.Response) {
116 const serverActor = await getServerActor()
117 const resultList = await ActorFollowModel.listFollowersForApi({
118 actorIds: [ serverActor.id ],
119 start: req.query.start,
120 count: req.query.count,
121 sort: req.query.sort,
122 search: req.query.search,
123 actorType: req.query.actorType,
124 state: req.query.state
125 })
126
127 return res.json(getFormattedObjects(resultList.data, resultList.total))
128}
129
130async function addFollow (req: express.Request, res: express.Response) {
131 const { hosts, handles } = req.body as ServerFollowCreate
132 const follower = await getServerActor()
133
134 for (const host of hosts) {
135 const payload = {
136 host,
137 name: SERVER_ACTOR_NAME,
138 followerActorId: follower.id
139 }
140
141 JobQueue.Instance.createJobAsync({ type: 'activitypub-follow', payload })
142 }
143
144 for (const handle of handles) {
145 const [ name, host ] = handle.split('@')
146
147 const payload = {
148 host,
149 name,
150 followerActorId: follower.id
151 }
152
153 JobQueue.Instance.createJobAsync({ type: 'activitypub-follow', payload })
154 }
155
156 return res.status(HttpStatusCode.NO_CONTENT_204).end()
157}
158
159async function removeFollowing (req: express.Request, res: express.Response) {
160 const follow = res.locals.follow
161
162 await sequelizeTypescript.transaction(async t => {
163 if (follow.state === 'accepted') sendUndoFollow(follow, t)
164
165 // Disable redundancy on unfollowed instances
166 const server = follow.ActorFollowing.Server
167 server.redundancyAllowed = false
168 await server.save({ transaction: t })
169
170 // Async, could be long
171 removeRedundanciesOfServer(server.id)
172 .catch(err => logger.error('Cannot remove redundancy of %s.', server.host, { err }))
173
174 await follow.destroy({ transaction: t })
175 })
176
177 return res.status(HttpStatusCode.NO_CONTENT_204).end()
178}
179
180async function rejectFollower (req: express.Request, res: express.Response) {
181 const follow = res.locals.follow
182
183 follow.state = 'rejected'
184 await follow.save()
185
186 sendReject(follow.url, follow.ActorFollower, follow.ActorFollowing)
187
188 return res.status(HttpStatusCode.NO_CONTENT_204).end()
189}
190
191async function removeFollower (req: express.Request, res: express.Response) {
192 const follow = res.locals.follow
193
194 if (follow.state === 'accepted' || follow.state === 'pending') {
195 sendReject(follow.url, follow.ActorFollower, follow.ActorFollowing)
196 }
197
198 await follow.destroy()
199
200 return res.status(HttpStatusCode.NO_CONTENT_204).end()
201}
202
203async function acceptFollower (req: express.Request, res: express.Response) {
204 const follow = res.locals.follow
205
206 sendAccept(follow)
207
208 follow.state = 'accepted'
209 await follow.save()
210
211 await autoFollowBackIfNeeded(follow)
212
213 return res.status(HttpStatusCode.NO_CONTENT_204).end()
214}
diff --git a/server/controllers/api/server/index.ts b/server/controllers/api/server/index.ts
deleted file mode 100644
index 57f7d601c..000000000
--- a/server/controllers/api/server/index.ts
+++ /dev/null
@@ -1,27 +0,0 @@
1import express from 'express'
2import { apiRateLimiter } from '@server/middlewares'
3import { contactRouter } from './contact'
4import { debugRouter } from './debug'
5import { serverFollowsRouter } from './follows'
6import { logsRouter } from './logs'
7import { serverRedundancyRouter } from './redundancy'
8import { serverBlocklistRouter } from './server-blocklist'
9import { statsRouter } from './stats'
10
11const serverRouter = express.Router()
12
13serverRouter.use(apiRateLimiter)
14
15serverRouter.use('/', serverFollowsRouter)
16serverRouter.use('/', serverRedundancyRouter)
17serverRouter.use('/', statsRouter)
18serverRouter.use('/', serverBlocklistRouter)
19serverRouter.use('/', contactRouter)
20serverRouter.use('/', logsRouter)
21serverRouter.use('/', debugRouter)
22
23// ---------------------------------------------------------------------------
24
25export {
26 serverRouter
27}
diff --git a/server/controllers/api/server/logs.ts b/server/controllers/api/server/logs.ts
deleted file mode 100644
index ed0aa6e8e..000000000
--- a/server/controllers/api/server/logs.ts
+++ /dev/null
@@ -1,203 +0,0 @@
1import express from 'express'
2import { readdir, readFile } from 'fs-extra'
3import { join } from 'path'
4import { isArray } from '@server/helpers/custom-validators/misc'
5import { logger, mtimeSortFilesDesc } from '@server/helpers/logger'
6import { pick } from '@shared/core-utils'
7import { ClientLogCreate, HttpStatusCode } from '@shared/models'
8import { ServerLogLevel } from '../../../../shared/models/server/server-log-level.type'
9import { UserRight } from '../../../../shared/models/users'
10import { CONFIG } from '../../../initializers/config'
11import { AUDIT_LOG_FILENAME, LOG_FILENAME, MAX_LOGS_OUTPUT_CHARACTERS } from '../../../initializers/constants'
12import { asyncMiddleware, authenticate, buildRateLimiter, ensureUserHasRight, optionalAuthenticate } from '../../../middlewares'
13import { createClientLogValidator, getAuditLogsValidator, getLogsValidator } from '../../../middlewares/validators/logs'
14
15const createClientLogRateLimiter = buildRateLimiter({
16 windowMs: CONFIG.RATES_LIMIT.RECEIVE_CLIENT_LOG.WINDOW_MS,
17 max: CONFIG.RATES_LIMIT.RECEIVE_CLIENT_LOG.MAX
18})
19
20const logsRouter = express.Router()
21
22logsRouter.post('/logs/client',
23 createClientLogRateLimiter,
24 optionalAuthenticate,
25 createClientLogValidator,
26 createClientLog
27)
28
29logsRouter.get('/logs',
30 authenticate,
31 ensureUserHasRight(UserRight.MANAGE_LOGS),
32 getLogsValidator,
33 asyncMiddleware(getLogs)
34)
35
36logsRouter.get('/audit-logs',
37 authenticate,
38 ensureUserHasRight(UserRight.MANAGE_LOGS),
39 getAuditLogsValidator,
40 asyncMiddleware(getAuditLogs)
41)
42
43// ---------------------------------------------------------------------------
44
45export {
46 logsRouter
47}
48
49// ---------------------------------------------------------------------------
50
51function createClientLog (req: express.Request, res: express.Response) {
52 const logInfo = req.body as ClientLogCreate
53
54 const meta = {
55 tags: [ 'client' ],
56 username: res.locals.oauth?.token?.User?.username,
57
58 ...pick(logInfo, [ 'userAgent', 'stackTrace', 'meta', 'url' ])
59 }
60
61 logger.log(logInfo.level, `Client log: ${logInfo.message}`, meta)
62
63 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
64}
65
66const auditLogNameFilter = generateLogNameFilter(AUDIT_LOG_FILENAME)
67async function getAuditLogs (req: express.Request, res: express.Response) {
68 const output = await generateOutput({
69 startDateQuery: req.query.startDate,
70 endDateQuery: req.query.endDate,
71 level: 'audit',
72 nameFilter: auditLogNameFilter
73 })
74
75 return res.json(output).end()
76}
77
78const logNameFilter = generateLogNameFilter(LOG_FILENAME)
79async function getLogs (req: express.Request, res: express.Response) {
80 const output = await generateOutput({
81 startDateQuery: req.query.startDate,
82 endDateQuery: req.query.endDate,
83 level: req.query.level || 'info',
84 tagsOneOf: req.query.tagsOneOf,
85 nameFilter: logNameFilter
86 })
87
88 return res.json(output)
89}
90
91async function generateOutput (options: {
92 startDateQuery: string
93 endDateQuery?: string
94
95 level: ServerLogLevel
96 nameFilter: RegExp
97 tagsOneOf?: string[]
98}) {
99 const { startDateQuery, level, nameFilter } = options
100
101 const tagsOneOf = Array.isArray(options.tagsOneOf) && options.tagsOneOf.length !== 0
102 ? new Set(options.tagsOneOf)
103 : undefined
104
105 const logFiles = await readdir(CONFIG.STORAGE.LOG_DIR)
106 const sortedLogFiles = await mtimeSortFilesDesc(logFiles, CONFIG.STORAGE.LOG_DIR)
107 let currentSize = 0
108
109 const startDate = new Date(startDateQuery)
110 const endDate = options.endDateQuery ? new Date(options.endDateQuery) : new Date()
111
112 let output: string[] = []
113
114 for (const meta of sortedLogFiles) {
115 if (nameFilter.exec(meta.file) === null) continue
116
117 const path = join(CONFIG.STORAGE.LOG_DIR, meta.file)
118 logger.debug('Opening %s to fetch logs.', path)
119
120 const result = await getOutputFromFile({ path, startDate, endDate, level, currentSize, tagsOneOf })
121 if (!result.output) break
122
123 output = result.output.concat(output)
124 currentSize = result.currentSize
125
126 if (currentSize > MAX_LOGS_OUTPUT_CHARACTERS || (result.logTime && result.logTime < startDate.getTime())) break
127 }
128
129 return output
130}
131
132async function getOutputFromFile (options: {
133 path: string
134 startDate: Date
135 endDate: Date
136 level: ServerLogLevel
137 currentSize: number
138 tagsOneOf: Set<string>
139}) {
140 const { path, startDate, endDate, level, tagsOneOf } = options
141
142 const startTime = startDate.getTime()
143 const endTime = endDate.getTime()
144 let currentSize = options.currentSize
145
146 let logTime: number
147
148 const logsLevel: { [ id in ServerLogLevel ]: number } = {
149 audit: -1,
150 debug: 0,
151 info: 1,
152 warn: 2,
153 error: 3
154 }
155
156 const content = await readFile(path)
157 const lines = content.toString().split('\n')
158 const output: any[] = []
159
160 for (let i = lines.length - 1; i >= 0; i--) {
161 const line = lines[i]
162 let log: any
163
164 try {
165 log = JSON.parse(line)
166 } catch {
167 // Maybe there a multiple \n at the end of the file
168 continue
169 }
170
171 logTime = new Date(log.timestamp).getTime()
172 if (
173 logTime >= startTime &&
174 logTime <= endTime &&
175 logsLevel[log.level] >= logsLevel[level] &&
176 (!tagsOneOf || lineHasTag(log, tagsOneOf))
177 ) {
178 output.push(log)
179
180 currentSize += line.length
181
182 if (currentSize > MAX_LOGS_OUTPUT_CHARACTERS) break
183 } else if (logTime < startTime) {
184 break
185 }
186 }
187
188 return { currentSize, output: output.reverse(), logTime }
189}
190
191function lineHasTag (line: { tags?: string }, tagsOneOf: Set<string>) {
192 if (!isArray(line.tags)) return false
193
194 for (const lineTag of line.tags) {
195 if (tagsOneOf.has(lineTag)) return true
196 }
197
198 return false
199}
200
201function generateLogNameFilter (baseName: string) {
202 return new RegExp('^' + baseName.replace(/\.log$/, '') + '\\d*.log$')
203}
diff --git a/server/controllers/api/server/redundancy.ts b/server/controllers/api/server/redundancy.ts
deleted file mode 100644
index 94e187cd4..000000000
--- a/server/controllers/api/server/redundancy.ts
+++ /dev/null
@@ -1,116 +0,0 @@
1import express from 'express'
2import { JobQueue } from '@server/lib/job-queue'
3import { VideoRedundancyModel } from '@server/models/redundancy/video-redundancy'
4import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes'
5import { UserRight } from '../../../../shared/models/users'
6import { logger } from '../../../helpers/logger'
7import { removeRedundanciesOfServer, removeVideoRedundancy } from '../../../lib/redundancy'
8import {
9 asyncMiddleware,
10 authenticate,
11 ensureUserHasRight,
12 paginationValidator,
13 setDefaultPagination,
14 setDefaultVideoRedundanciesSort,
15 videoRedundanciesSortValidator
16} from '../../../middlewares'
17import {
18 addVideoRedundancyValidator,
19 listVideoRedundanciesValidator,
20 removeVideoRedundancyValidator,
21 updateServerRedundancyValidator
22} from '../../../middlewares/validators/redundancy'
23
24const serverRedundancyRouter = express.Router()
25
26serverRedundancyRouter.put('/redundancy/:host',
27 authenticate,
28 ensureUserHasRight(UserRight.MANAGE_SERVER_FOLLOW),
29 asyncMiddleware(updateServerRedundancyValidator),
30 asyncMiddleware(updateRedundancy)
31)
32
33serverRedundancyRouter.get('/redundancy/videos',
34 authenticate,
35 ensureUserHasRight(UserRight.MANAGE_VIDEOS_REDUNDANCIES),
36 listVideoRedundanciesValidator,
37 paginationValidator,
38 videoRedundanciesSortValidator,
39 setDefaultVideoRedundanciesSort,
40 setDefaultPagination,
41 asyncMiddleware(listVideoRedundancies)
42)
43
44serverRedundancyRouter.post('/redundancy/videos',
45 authenticate,
46 ensureUserHasRight(UserRight.MANAGE_VIDEOS_REDUNDANCIES),
47 addVideoRedundancyValidator,
48 asyncMiddleware(addVideoRedundancy)
49)
50
51serverRedundancyRouter.delete('/redundancy/videos/:redundancyId',
52 authenticate,
53 ensureUserHasRight(UserRight.MANAGE_VIDEOS_REDUNDANCIES),
54 removeVideoRedundancyValidator,
55 asyncMiddleware(removeVideoRedundancyController)
56)
57
58// ---------------------------------------------------------------------------
59
60export {
61 serverRedundancyRouter
62}
63
64// ---------------------------------------------------------------------------
65
66async function listVideoRedundancies (req: express.Request, res: express.Response) {
67 const resultList = await VideoRedundancyModel.listForApi({
68 start: req.query.start,
69 count: req.query.count,
70 sort: req.query.sort,
71 target: req.query.target,
72 strategy: req.query.strategy
73 })
74
75 const result = {
76 total: resultList.total,
77 data: resultList.data.map(r => VideoRedundancyModel.toFormattedJSONStatic(r))
78 }
79
80 return res.json(result)
81}
82
83async function addVideoRedundancy (req: express.Request, res: express.Response) {
84 const payload = {
85 videoId: res.locals.onlyVideo.id
86 }
87
88 await JobQueue.Instance.createJob({
89 type: 'video-redundancy',
90 payload
91 })
92
93 return res.status(HttpStatusCode.NO_CONTENT_204).end()
94}
95
96async function removeVideoRedundancyController (req: express.Request, res: express.Response) {
97 await removeVideoRedundancy(res.locals.videoRedundancy)
98
99 return res.status(HttpStatusCode.NO_CONTENT_204).end()
100}
101
102async function updateRedundancy (req: express.Request, res: express.Response) {
103 const server = res.locals.server
104
105 server.redundancyAllowed = req.body.redundancyAllowed
106
107 await server.save()
108
109 if (server.redundancyAllowed !== true) {
110 // Async, could be long
111 removeRedundanciesOfServer(server.id)
112 .catch(err => logger.error('Cannot remove redundancy of %s.', server.host, { err }))
113 }
114
115 return res.status(HttpStatusCode.NO_CONTENT_204).end()
116}
diff --git a/server/controllers/api/server/server-blocklist.ts b/server/controllers/api/server/server-blocklist.ts
deleted file mode 100644
index 740f95da3..000000000
--- a/server/controllers/api/server/server-blocklist.ts
+++ /dev/null
@@ -1,158 +0,0 @@
1import 'multer'
2import express from 'express'
3import { logger } from '@server/helpers/logger'
4import { getServerActor } from '@server/models/application/application'
5import { UserNotificationModel } from '@server/models/user/user-notification'
6import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes'
7import { UserRight } from '../../../../shared/models/users'
8import { getFormattedObjects } from '../../../helpers/utils'
9import { addAccountInBlocklist, addServerInBlocklist, removeAccountFromBlocklist, removeServerFromBlocklist } from '../../../lib/blocklist'
10import {
11 asyncMiddleware,
12 asyncRetryTransactionMiddleware,
13 authenticate,
14 ensureUserHasRight,
15 paginationValidator,
16 setDefaultPagination,
17 setDefaultSort
18} from '../../../middlewares'
19import {
20 accountsBlocklistSortValidator,
21 blockAccountValidator,
22 blockServerValidator,
23 serversBlocklistSortValidator,
24 unblockAccountByServerValidator,
25 unblockServerByServerValidator
26} from '../../../middlewares/validators'
27import { AccountBlocklistModel } from '../../../models/account/account-blocklist'
28import { ServerBlocklistModel } from '../../../models/server/server-blocklist'
29
30const serverBlocklistRouter = express.Router()
31
32serverBlocklistRouter.get('/blocklist/accounts',
33 authenticate,
34 ensureUserHasRight(UserRight.MANAGE_ACCOUNTS_BLOCKLIST),
35 paginationValidator,
36 accountsBlocklistSortValidator,
37 setDefaultSort,
38 setDefaultPagination,
39 asyncMiddleware(listBlockedAccounts)
40)
41
42serverBlocklistRouter.post('/blocklist/accounts',
43 authenticate,
44 ensureUserHasRight(UserRight.MANAGE_ACCOUNTS_BLOCKLIST),
45 asyncMiddleware(blockAccountValidator),
46 asyncRetryTransactionMiddleware(blockAccount)
47)
48
49serverBlocklistRouter.delete('/blocklist/accounts/:accountName',
50 authenticate,
51 ensureUserHasRight(UserRight.MANAGE_ACCOUNTS_BLOCKLIST),
52 asyncMiddleware(unblockAccountByServerValidator),
53 asyncRetryTransactionMiddleware(unblockAccount)
54)
55
56serverBlocklistRouter.get('/blocklist/servers',
57 authenticate,
58 ensureUserHasRight(UserRight.MANAGE_SERVERS_BLOCKLIST),
59 paginationValidator,
60 serversBlocklistSortValidator,
61 setDefaultSort,
62 setDefaultPagination,
63 asyncMiddleware(listBlockedServers)
64)
65
66serverBlocklistRouter.post('/blocklist/servers',
67 authenticate,
68 ensureUserHasRight(UserRight.MANAGE_SERVERS_BLOCKLIST),
69 asyncMiddleware(blockServerValidator),
70 asyncRetryTransactionMiddleware(blockServer)
71)
72
73serverBlocklistRouter.delete('/blocklist/servers/:host',
74 authenticate,
75 ensureUserHasRight(UserRight.MANAGE_SERVERS_BLOCKLIST),
76 asyncMiddleware(unblockServerByServerValidator),
77 asyncRetryTransactionMiddleware(unblockServer)
78)
79
80export {
81 serverBlocklistRouter
82}
83
84// ---------------------------------------------------------------------------
85
86async function listBlockedAccounts (req: express.Request, res: express.Response) {
87 const serverActor = await getServerActor()
88
89 const resultList = await AccountBlocklistModel.listForApi({
90 start: req.query.start,
91 count: req.query.count,
92 sort: req.query.sort,
93 search: req.query.search,
94 accountId: serverActor.Account.id
95 })
96
97 return res.json(getFormattedObjects(resultList.data, resultList.total))
98}
99
100async function blockAccount (req: express.Request, res: express.Response) {
101 const serverActor = await getServerActor()
102 const accountToBlock = res.locals.account
103
104 await addAccountInBlocklist(serverActor.Account.id, accountToBlock.id)
105
106 UserNotificationModel.removeNotificationsOf({
107 id: accountToBlock.id,
108 type: 'account',
109 forUserId: null // For all users
110 }).catch(err => logger.error('Cannot remove notifications after an account mute.', { err }))
111
112 return res.status(HttpStatusCode.NO_CONTENT_204).end()
113}
114
115async function unblockAccount (req: express.Request, res: express.Response) {
116 const accountBlock = res.locals.accountBlock
117
118 await removeAccountFromBlocklist(accountBlock)
119
120 return res.status(HttpStatusCode.NO_CONTENT_204).end()
121}
122
123async function listBlockedServers (req: express.Request, res: express.Response) {
124 const serverActor = await getServerActor()
125
126 const resultList = await ServerBlocklistModel.listForApi({
127 start: req.query.start,
128 count: req.query.count,
129 sort: req.query.sort,
130 search: req.query.search,
131 accountId: serverActor.Account.id
132 })
133
134 return res.json(getFormattedObjects(resultList.data, resultList.total))
135}
136
137async function blockServer (req: express.Request, res: express.Response) {
138 const serverActor = await getServerActor()
139 const serverToBlock = res.locals.server
140
141 await addServerInBlocklist(serverActor.Account.id, serverToBlock.id)
142
143 UserNotificationModel.removeNotificationsOf({
144 id: serverToBlock.id,
145 type: 'server',
146 forUserId: null // For all users
147 }).catch(err => logger.error('Cannot remove notifications after a server mute.', { err }))
148
149 return res.status(HttpStatusCode.NO_CONTENT_204).end()
150}
151
152async function unblockServer (req: express.Request, res: express.Response) {
153 const serverBlock = res.locals.serverBlock
154
155 await removeServerFromBlocklist(serverBlock)
156
157 return res.status(HttpStatusCode.NO_CONTENT_204).end()
158}
diff --git a/server/controllers/api/server/stats.ts b/server/controllers/api/server/stats.ts
deleted file mode 100644
index 2ab398f4d..000000000
--- a/server/controllers/api/server/stats.ts
+++ /dev/null
@@ -1,26 +0,0 @@
1import express from 'express'
2import { StatsManager } from '@server/lib/stat-manager'
3import { ROUTE_CACHE_LIFETIME } from '../../../initializers/constants'
4import { asyncMiddleware } from '../../../middlewares'
5import { cacheRoute } from '../../../middlewares/cache/cache'
6import { Hooks } from '@server/lib/plugins/hooks'
7
8const statsRouter = express.Router()
9
10statsRouter.get('/stats',
11 cacheRoute(ROUTE_CACHE_LIFETIME.STATS),
12 asyncMiddleware(getStats)
13)
14
15async function getStats (_req: express.Request, res: express.Response) {
16 let data = await StatsManager.Instance.getStats()
17 data = await Hooks.wrapObject(data, 'filter:api.server.stats.get.result')
18
19 return res.json(data)
20}
21
22// ---------------------------------------------------------------------------
23
24export {
25 statsRouter
26}
diff --git a/server/controllers/api/users/email-verification.ts b/server/controllers/api/users/email-verification.ts
deleted file mode 100644
index 230aaa9af..000000000
--- a/server/controllers/api/users/email-verification.ts
+++ /dev/null
@@ -1,72 +0,0 @@
1import express from 'express'
2import { HttpStatusCode } from '@shared/models'
3import { CONFIG } from '../../../initializers/config'
4import { sendVerifyRegistrationEmail, sendVerifyUserEmail } from '../../../lib/user'
5import { asyncMiddleware, buildRateLimiter } from '../../../middlewares'
6import {
7 registrationVerifyEmailValidator,
8 usersAskSendVerifyEmailValidator,
9 usersVerifyEmailValidator
10} from '../../../middlewares/validators'
11
12const askSendEmailLimiter = buildRateLimiter({
13 windowMs: CONFIG.RATES_LIMIT.ASK_SEND_EMAIL.WINDOW_MS,
14 max: CONFIG.RATES_LIMIT.ASK_SEND_EMAIL.MAX
15})
16
17const emailVerificationRouter = express.Router()
18
19emailVerificationRouter.post([ '/ask-send-verify-email', '/registrations/ask-send-verify-email' ],
20 askSendEmailLimiter,
21 asyncMiddleware(usersAskSendVerifyEmailValidator),
22 asyncMiddleware(reSendVerifyUserEmail)
23)
24
25emailVerificationRouter.post('/:id/verify-email',
26 asyncMiddleware(usersVerifyEmailValidator),
27 asyncMiddleware(verifyUserEmail)
28)
29
30emailVerificationRouter.post('/registrations/:registrationId/verify-email',
31 asyncMiddleware(registrationVerifyEmailValidator),
32 asyncMiddleware(verifyRegistrationEmail)
33)
34
35// ---------------------------------------------------------------------------
36
37export {
38 emailVerificationRouter
39}
40
41async function reSendVerifyUserEmail (req: express.Request, res: express.Response) {
42 const user = res.locals.user
43 const registration = res.locals.userRegistration
44
45 if (user) await sendVerifyUserEmail(user)
46 else if (registration) await sendVerifyRegistrationEmail(registration)
47
48 return res.status(HttpStatusCode.NO_CONTENT_204).end()
49}
50
51async function verifyUserEmail (req: express.Request, res: express.Response) {
52 const user = res.locals.user
53 user.emailVerified = true
54
55 if (req.body.isPendingEmail === true) {
56 user.email = user.pendingEmail
57 user.pendingEmail = null
58 }
59
60 await user.save()
61
62 return res.status(HttpStatusCode.NO_CONTENT_204).end()
63}
64
65async function verifyRegistrationEmail (req: express.Request, res: express.Response) {
66 const registration = res.locals.userRegistration
67 registration.emailVerified = true
68
69 await registration.save()
70
71 return res.status(HttpStatusCode.NO_CONTENT_204).end()
72}
diff --git a/server/controllers/api/users/index.ts b/server/controllers/api/users/index.ts
deleted file mode 100644
index 5eac6fd0f..000000000
--- a/server/controllers/api/users/index.ts
+++ /dev/null
@@ -1,319 +0,0 @@
1import express from 'express'
2import { tokensRouter } from '@server/controllers/api/users/token'
3import { Hooks } from '@server/lib/plugins/hooks'
4import { OAuthTokenModel } from '@server/models/oauth/oauth-token'
5import { MUserAccountDefault } from '@server/types/models'
6import { pick } from '@shared/core-utils'
7import { HttpStatusCode, UserCreate, UserCreateResult, UserRight, UserUpdate } from '@shared/models'
8import { auditLoggerFactory, getAuditIdFromRes, UserAuditView } from '../../../helpers/audit-logger'
9import { logger } from '../../../helpers/logger'
10import { generateRandomString, getFormattedObjects } from '../../../helpers/utils'
11import { WEBSERVER } from '../../../initializers/constants'
12import { sequelizeTypescript } from '../../../initializers/database'
13import { Emailer } from '../../../lib/emailer'
14import { Redis } from '../../../lib/redis'
15import { buildUser, createUserAccountAndChannelAndPlaylist } from '../../../lib/user'
16import {
17 adminUsersSortValidator,
18 apiRateLimiter,
19 asyncMiddleware,
20 asyncRetryTransactionMiddleware,
21 authenticate,
22 ensureUserHasRight,
23 paginationValidator,
24 setDefaultPagination,
25 setDefaultSort,
26 userAutocompleteValidator,
27 usersAddValidator,
28 usersGetValidator,
29 usersListValidator,
30 usersRemoveValidator,
31 usersUpdateValidator
32} from '../../../middlewares'
33import {
34 ensureCanModerateUser,
35 usersAskResetPasswordValidator,
36 usersBlockingValidator,
37 usersResetPasswordValidator
38} from '../../../middlewares/validators'
39import { UserModel } from '../../../models/user/user'
40import { emailVerificationRouter } from './email-verification'
41import { meRouter } from './me'
42import { myAbusesRouter } from './my-abuses'
43import { myBlocklistRouter } from './my-blocklist'
44import { myVideosHistoryRouter } from './my-history'
45import { myNotificationsRouter } from './my-notifications'
46import { mySubscriptionsRouter } from './my-subscriptions'
47import { myVideoPlaylistsRouter } from './my-video-playlists'
48import { registrationsRouter } from './registrations'
49import { twoFactorRouter } from './two-factor'
50
51const auditLogger = auditLoggerFactory('users')
52
53const usersRouter = express.Router()
54
55usersRouter.use(apiRateLimiter)
56
57usersRouter.use('/', emailVerificationRouter)
58usersRouter.use('/', registrationsRouter)
59usersRouter.use('/', twoFactorRouter)
60usersRouter.use('/', tokensRouter)
61usersRouter.use('/', myNotificationsRouter)
62usersRouter.use('/', mySubscriptionsRouter)
63usersRouter.use('/', myBlocklistRouter)
64usersRouter.use('/', myVideosHistoryRouter)
65usersRouter.use('/', myVideoPlaylistsRouter)
66usersRouter.use('/', myAbusesRouter)
67usersRouter.use('/', meRouter)
68
69usersRouter.get('/autocomplete',
70 userAutocompleteValidator,
71 asyncMiddleware(autocompleteUsers)
72)
73
74usersRouter.get('/',
75 authenticate,
76 ensureUserHasRight(UserRight.MANAGE_USERS),
77 paginationValidator,
78 adminUsersSortValidator,
79 setDefaultSort,
80 setDefaultPagination,
81 usersListValidator,
82 asyncMiddleware(listUsers)
83)
84
85usersRouter.post('/:id/block',
86 authenticate,
87 ensureUserHasRight(UserRight.MANAGE_USERS),
88 asyncMiddleware(usersBlockingValidator),
89 ensureCanModerateUser,
90 asyncMiddleware(blockUser)
91)
92usersRouter.post('/:id/unblock',
93 authenticate,
94 ensureUserHasRight(UserRight.MANAGE_USERS),
95 asyncMiddleware(usersBlockingValidator),
96 ensureCanModerateUser,
97 asyncMiddleware(unblockUser)
98)
99
100usersRouter.get('/:id',
101 authenticate,
102 ensureUserHasRight(UserRight.MANAGE_USERS),
103 asyncMiddleware(usersGetValidator),
104 getUser
105)
106
107usersRouter.post('/',
108 authenticate,
109 ensureUserHasRight(UserRight.MANAGE_USERS),
110 asyncMiddleware(usersAddValidator),
111 asyncRetryTransactionMiddleware(createUser)
112)
113
114usersRouter.put('/:id',
115 authenticate,
116 ensureUserHasRight(UserRight.MANAGE_USERS),
117 asyncMiddleware(usersUpdateValidator),
118 ensureCanModerateUser,
119 asyncMiddleware(updateUser)
120)
121
122usersRouter.delete('/:id',
123 authenticate,
124 ensureUserHasRight(UserRight.MANAGE_USERS),
125 asyncMiddleware(usersRemoveValidator),
126 ensureCanModerateUser,
127 asyncMiddleware(removeUser)
128)
129
130usersRouter.post('/ask-reset-password',
131 asyncMiddleware(usersAskResetPasswordValidator),
132 asyncMiddleware(askResetUserPassword)
133)
134
135usersRouter.post('/:id/reset-password',
136 asyncMiddleware(usersResetPasswordValidator),
137 asyncMiddleware(resetUserPassword)
138)
139
140// ---------------------------------------------------------------------------
141
142export {
143 usersRouter
144}
145
146// ---------------------------------------------------------------------------
147
148async function createUser (req: express.Request, res: express.Response) {
149 const body: UserCreate = req.body
150
151 const userToCreate = buildUser({
152 ...pick(body, [ 'username', 'password', 'email', 'role', 'videoQuota', 'videoQuotaDaily', 'adminFlags' ]),
153
154 emailVerified: null
155 })
156
157 // NB: due to the validator usersAddValidator, password==='' can only be true if we can send the mail.
158 const createPassword = userToCreate.password === ''
159 if (createPassword) {
160 userToCreate.password = await generateRandomString(20)
161 }
162
163 const { user, account, videoChannel } = await createUserAccountAndChannelAndPlaylist({
164 userToCreate,
165 channelNames: body.channelName && { name: body.channelName, displayName: body.channelName }
166 })
167
168 auditLogger.create(getAuditIdFromRes(res), new UserAuditView(user.toFormattedJSON()))
169 logger.info('User %s with its channel and account created.', body.username)
170
171 if (createPassword) {
172 // this will send an email for newly created users, so then can set their first password.
173 logger.info('Sending to user %s a create password email', body.username)
174 const verificationString = await Redis.Instance.setCreatePasswordVerificationString(user.id)
175 const url = WEBSERVER.URL + '/reset-password?userId=' + user.id + '&verificationString=' + verificationString
176 Emailer.Instance.addPasswordCreateEmailJob(userToCreate.username, user.email, url)
177 }
178
179 Hooks.runAction('action:api.user.created', { body, user, account, videoChannel, req, res })
180
181 return res.json({
182 user: {
183 id: user.id,
184 account: {
185 id: account.id
186 }
187 } as UserCreateResult
188 })
189}
190
191async function unblockUser (req: express.Request, res: express.Response) {
192 const user = res.locals.user
193
194 await changeUserBlock(res, user, false)
195
196 Hooks.runAction('action:api.user.unblocked', { user, req, res })
197
198 return res.status(HttpStatusCode.NO_CONTENT_204).end()
199}
200
201async function blockUser (req: express.Request, res: express.Response) {
202 const user = res.locals.user
203 const reason = req.body.reason
204
205 await changeUserBlock(res, user, true, reason)
206
207 Hooks.runAction('action:api.user.blocked', { user, req, res })
208
209 return res.status(HttpStatusCode.NO_CONTENT_204).end()
210}
211
212function getUser (req: express.Request, res: express.Response) {
213 return res.json(res.locals.user.toFormattedJSON({ withAdminFlags: true }))
214}
215
216async function autocompleteUsers (req: express.Request, res: express.Response) {
217 const resultList = await UserModel.autoComplete(req.query.search as string)
218
219 return res.json(resultList)
220}
221
222async function listUsers (req: express.Request, res: express.Response) {
223 const resultList = await UserModel.listForAdminApi({
224 start: req.query.start,
225 count: req.query.count,
226 sort: req.query.sort,
227 search: req.query.search,
228 blocked: req.query.blocked
229 })
230
231 return res.json(getFormattedObjects(resultList.data, resultList.total, { withAdminFlags: true }))
232}
233
234async function removeUser (req: express.Request, res: express.Response) {
235 const user = res.locals.user
236
237 auditLogger.delete(getAuditIdFromRes(res), new UserAuditView(user.toFormattedJSON()))
238
239 await sequelizeTypescript.transaction(async t => {
240 // Use a transaction to avoid inconsistencies with hooks (account/channel deletion & federation)
241 await user.destroy({ transaction: t })
242 })
243
244 Hooks.runAction('action:api.user.deleted', { user, req, res })
245
246 return res.status(HttpStatusCode.NO_CONTENT_204).end()
247}
248
249async function updateUser (req: express.Request, res: express.Response) {
250 const body: UserUpdate = req.body
251 const userToUpdate = res.locals.user
252 const oldUserAuditView = new UserAuditView(userToUpdate.toFormattedJSON())
253 const roleChanged = body.role !== undefined && body.role !== userToUpdate.role
254
255 const keysToUpdate: (keyof UserUpdate)[] = [
256 'password',
257 'email',
258 'emailVerified',
259 'videoQuota',
260 'videoQuotaDaily',
261 'role',
262 'adminFlags',
263 'pluginAuth'
264 ]
265
266 for (const key of keysToUpdate) {
267 if (body[key] !== undefined) userToUpdate.set(key, body[key])
268 }
269
270 const user = await userToUpdate.save()
271
272 // Destroy user token to refresh rights
273 if (roleChanged || body.password !== undefined) await OAuthTokenModel.deleteUserToken(userToUpdate.id)
274
275 auditLogger.update(getAuditIdFromRes(res), new UserAuditView(user.toFormattedJSON()), oldUserAuditView)
276
277 Hooks.runAction('action:api.user.updated', { user, req, res })
278
279 // Don't need to send this update to followers, these attributes are not federated
280
281 return res.status(HttpStatusCode.NO_CONTENT_204).end()
282}
283
284async function askResetUserPassword (req: express.Request, res: express.Response) {
285 const user = res.locals.user
286
287 const verificationString = await Redis.Instance.setResetPasswordVerificationString(user.id)
288 const url = WEBSERVER.URL + '/reset-password?userId=' + user.id + '&verificationString=' + verificationString
289 Emailer.Instance.addPasswordResetEmailJob(user.username, user.email, url)
290
291 return res.status(HttpStatusCode.NO_CONTENT_204).end()
292}
293
294async function resetUserPassword (req: express.Request, res: express.Response) {
295 const user = res.locals.user
296 user.password = req.body.password
297
298 await user.save()
299 await Redis.Instance.removePasswordVerificationString(user.id)
300
301 return res.status(HttpStatusCode.NO_CONTENT_204).end()
302}
303
304async function changeUserBlock (res: express.Response, user: MUserAccountDefault, block: boolean, reason?: string) {
305 const oldUserAuditView = new UserAuditView(user.toFormattedJSON())
306
307 user.blocked = block
308 user.blockedReason = reason || null
309
310 await sequelizeTypescript.transaction(async t => {
311 await OAuthTokenModel.deleteUserToken(user.id, t)
312
313 await user.save({ transaction: t })
314 })
315
316 Emailer.Instance.addUserBlockJob(user, block, reason)
317
318 auditLogger.update(getAuditIdFromRes(res), new UserAuditView(user.toFormattedJSON()), oldUserAuditView)
319}
diff --git a/server/controllers/api/users/me.ts b/server/controllers/api/users/me.ts
deleted file mode 100644
index 26811136e..000000000
--- a/server/controllers/api/users/me.ts
+++ /dev/null
@@ -1,277 +0,0 @@
1import 'multer'
2import express from 'express'
3import { auditLoggerFactory, getAuditIdFromRes, UserAuditView } from '@server/helpers/audit-logger'
4import { Hooks } from '@server/lib/plugins/hooks'
5import { pick } from '@shared/core-utils'
6import { ActorImageType, HttpStatusCode, UserUpdateMe, UserVideoQuota, UserVideoRate as FormattedUserVideoRate } from '@shared/models'
7import { AttributesOnly } from '@shared/typescript-utils'
8import { createReqFiles } from '../../../helpers/express-utils'
9import { getFormattedObjects } from '../../../helpers/utils'
10import { CONFIG } from '../../../initializers/config'
11import { MIMETYPES } from '../../../initializers/constants'
12import { sequelizeTypescript } from '../../../initializers/database'
13import { sendUpdateActor } from '../../../lib/activitypub/send'
14import { deleteLocalActorImageFile, updateLocalActorImageFiles } from '../../../lib/local-actor'
15import { getOriginalVideoFileTotalDailyFromUser, getOriginalVideoFileTotalFromUser, sendVerifyUserEmail } from '../../../lib/user'
16import {
17 asyncMiddleware,
18 asyncRetryTransactionMiddleware,
19 authenticate,
20 paginationValidator,
21 setDefaultPagination,
22 setDefaultSort,
23 setDefaultVideosSort,
24 usersUpdateMeValidator,
25 usersVideoRatingValidator
26} from '../../../middlewares'
27import {
28 deleteMeValidator,
29 getMyVideoImportsValidator,
30 usersVideosValidator,
31 videoImportsSortValidator,
32 videosSortValidator
33} from '../../../middlewares/validators'
34import { updateAvatarValidator } from '../../../middlewares/validators/actor-image'
35import { AccountModel } from '../../../models/account/account'
36import { AccountVideoRateModel } from '../../../models/account/account-video-rate'
37import { UserModel } from '../../../models/user/user'
38import { VideoModel } from '../../../models/video/video'
39import { VideoImportModel } from '../../../models/video/video-import'
40
41const auditLogger = auditLoggerFactory('users')
42
43const reqAvatarFile = createReqFiles([ 'avatarfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT)
44
45const meRouter = express.Router()
46
47meRouter.get('/me',
48 authenticate,
49 asyncMiddleware(getUserInformation)
50)
51meRouter.delete('/me',
52 authenticate,
53 deleteMeValidator,
54 asyncMiddleware(deleteMe)
55)
56
57meRouter.get('/me/video-quota-used',
58 authenticate,
59 asyncMiddleware(getUserVideoQuotaUsed)
60)
61
62meRouter.get('/me/videos/imports',
63 authenticate,
64 paginationValidator,
65 videoImportsSortValidator,
66 setDefaultSort,
67 setDefaultPagination,
68 getMyVideoImportsValidator,
69 asyncMiddleware(getUserVideoImports)
70)
71
72meRouter.get('/me/videos',
73 authenticate,
74 paginationValidator,
75 videosSortValidator,
76 setDefaultVideosSort,
77 setDefaultPagination,
78 asyncMiddleware(usersVideosValidator),
79 asyncMiddleware(getUserVideos)
80)
81
82meRouter.get('/me/videos/:videoId/rating',
83 authenticate,
84 asyncMiddleware(usersVideoRatingValidator),
85 asyncMiddleware(getUserVideoRating)
86)
87
88meRouter.put('/me',
89 authenticate,
90 asyncMiddleware(usersUpdateMeValidator),
91 asyncRetryTransactionMiddleware(updateMe)
92)
93
94meRouter.post('/me/avatar/pick',
95 authenticate,
96 reqAvatarFile,
97 updateAvatarValidator,
98 asyncRetryTransactionMiddleware(updateMyAvatar)
99)
100
101meRouter.delete('/me/avatar',
102 authenticate,
103 asyncRetryTransactionMiddleware(deleteMyAvatar)
104)
105
106// ---------------------------------------------------------------------------
107
108export {
109 meRouter
110}
111
112// ---------------------------------------------------------------------------
113
114async function getUserVideos (req: express.Request, res: express.Response) {
115 const user = res.locals.oauth.token.User
116
117 const apiOptions = await Hooks.wrapObject({
118 accountId: user.Account.id,
119 start: req.query.start,
120 count: req.query.count,
121 sort: req.query.sort,
122 search: req.query.search,
123 channelId: res.locals.videoChannel?.id,
124 isLive: req.query.isLive
125 }, 'filter:api.user.me.videos.list.params')
126
127 const resultList = await Hooks.wrapPromiseFun(
128 VideoModel.listUserVideosForApi,
129 apiOptions,
130 'filter:api.user.me.videos.list.result'
131 )
132
133 const additionalAttributes = {
134 waitTranscoding: true,
135 state: true,
136 scheduledUpdate: true,
137 blacklistInfo: true
138 }
139 return res.json(getFormattedObjects(resultList.data, resultList.total, { additionalAttributes }))
140}
141
142async function getUserVideoImports (req: express.Request, res: express.Response) {
143 const user = res.locals.oauth.token.User
144 const resultList = await VideoImportModel.listUserVideoImportsForApi({
145 userId: user.id,
146
147 ...pick(req.query, [ 'targetUrl', 'start', 'count', 'sort', 'search', 'videoChannelSyncId' ])
148 })
149
150 return res.json(getFormattedObjects(resultList.data, resultList.total))
151}
152
153async function getUserInformation (req: express.Request, res: express.Response) {
154 // We did not load channels in res.locals.user
155 const user = await UserModel.loadForMeAPI(res.locals.oauth.token.user.id)
156
157 return res.json(user.toMeFormattedJSON())
158}
159
160async function getUserVideoQuotaUsed (req: express.Request, res: express.Response) {
161 const user = res.locals.oauth.token.user
162 const videoQuotaUsed = await getOriginalVideoFileTotalFromUser(user)
163 const videoQuotaUsedDaily = await getOriginalVideoFileTotalDailyFromUser(user)
164
165 const data: UserVideoQuota = {
166 videoQuotaUsed,
167 videoQuotaUsedDaily
168 }
169 return res.json(data)
170}
171
172async function getUserVideoRating (req: express.Request, res: express.Response) {
173 const videoId = res.locals.videoId.id
174 const accountId = +res.locals.oauth.token.User.Account.id
175
176 const ratingObj = await AccountVideoRateModel.load(accountId, videoId, null)
177 const rating = ratingObj ? ratingObj.type : 'none'
178
179 const json: FormattedUserVideoRate = {
180 videoId,
181 rating
182 }
183 return res.json(json)
184}
185
186async function deleteMe (req: express.Request, res: express.Response) {
187 const user = await UserModel.loadByIdWithChannels(res.locals.oauth.token.User.id)
188
189 auditLogger.delete(getAuditIdFromRes(res), new UserAuditView(user.toFormattedJSON()))
190
191 await user.destroy()
192
193 return res.status(HttpStatusCode.NO_CONTENT_204).end()
194}
195
196async function updateMe (req: express.Request, res: express.Response) {
197 const body: UserUpdateMe = req.body
198 let sendVerificationEmail = false
199
200 const user = res.locals.oauth.token.user
201
202 const keysToUpdate: (keyof UserUpdateMe & keyof AttributesOnly<UserModel>)[] = [
203 'password',
204 'nsfwPolicy',
205 'p2pEnabled',
206 'autoPlayVideo',
207 'autoPlayNextVideo',
208 'autoPlayNextVideoPlaylist',
209 'videosHistoryEnabled',
210 'videoLanguages',
211 'theme',
212 'noInstanceConfigWarningModal',
213 'noAccountSetupWarningModal',
214 'noWelcomeModal',
215 'emailPublic',
216 'p2pEnabled'
217 ]
218
219 for (const key of keysToUpdate) {
220 if (body[key] !== undefined) user.set(key, body[key])
221 }
222
223 if (body.email !== undefined) {
224 if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) {
225 user.pendingEmail = body.email
226 sendVerificationEmail = true
227 } else {
228 user.email = body.email
229 }
230 }
231
232 await sequelizeTypescript.transaction(async t => {
233 await user.save({ transaction: t })
234
235 if (body.displayName === undefined && body.description === undefined) return
236
237 const userAccount = await AccountModel.load(user.Account.id, t)
238
239 if (body.displayName !== undefined) userAccount.name = body.displayName
240 if (body.description !== undefined) userAccount.description = body.description
241 await userAccount.save({ transaction: t })
242
243 await sendUpdateActor(userAccount, t)
244 })
245
246 if (sendVerificationEmail === true) {
247 await sendVerifyUserEmail(user, true)
248 }
249
250 return res.status(HttpStatusCode.NO_CONTENT_204).end()
251}
252
253async function updateMyAvatar (req: express.Request, res: express.Response) {
254 const avatarPhysicalFile = req.files['avatarfile'][0]
255 const user = res.locals.oauth.token.user
256
257 const userAccount = await AccountModel.load(user.Account.id)
258
259 const avatars = await updateLocalActorImageFiles(
260 userAccount,
261 avatarPhysicalFile,
262 ActorImageType.AVATAR
263 )
264
265 return res.json({
266 avatars: avatars.map(avatar => avatar.toFormattedJSON())
267 })
268}
269
270async function deleteMyAvatar (req: express.Request, res: express.Response) {
271 const user = res.locals.oauth.token.user
272
273 const userAccount = await AccountModel.load(user.Account.id)
274 await deleteLocalActorImageFile(userAccount, ActorImageType.AVATAR)
275
276 return res.json({ avatars: [] })
277}
diff --git a/server/controllers/api/users/my-abuses.ts b/server/controllers/api/users/my-abuses.ts
deleted file mode 100644
index 103c3d332..000000000
--- a/server/controllers/api/users/my-abuses.ts
+++ /dev/null
@@ -1,48 +0,0 @@
1import express from 'express'
2import { AbuseModel } from '@server/models/abuse/abuse'
3import {
4 abuseListForUserValidator,
5 abusesSortValidator,
6 asyncMiddleware,
7 authenticate,
8 paginationValidator,
9 setDefaultPagination,
10 setDefaultSort
11} from '../../../middlewares'
12
13const myAbusesRouter = express.Router()
14
15myAbusesRouter.get('/me/abuses',
16 authenticate,
17 paginationValidator,
18 abusesSortValidator,
19 setDefaultSort,
20 setDefaultPagination,
21 abuseListForUserValidator,
22 asyncMiddleware(listMyAbuses)
23)
24
25// ---------------------------------------------------------------------------
26
27export {
28 myAbusesRouter
29}
30
31// ---------------------------------------------------------------------------
32
33async function listMyAbuses (req: express.Request, res: express.Response) {
34 const resultList = await AbuseModel.listForUserApi({
35 start: req.query.start,
36 count: req.query.count,
37 sort: req.query.sort,
38 id: req.query.id,
39 search: req.query.search,
40 state: req.query.state,
41 user: res.locals.oauth.token.User
42 })
43
44 return res.json({
45 total: resultList.total,
46 data: resultList.data.map(d => d.toFormattedUserJSON())
47 })
48}
diff --git a/server/controllers/api/users/my-blocklist.ts b/server/controllers/api/users/my-blocklist.ts
deleted file mode 100644
index 0b56645cf..000000000
--- a/server/controllers/api/users/my-blocklist.ts
+++ /dev/null
@@ -1,149 +0,0 @@
1import 'multer'
2import express from 'express'
3import { logger } from '@server/helpers/logger'
4import { UserNotificationModel } from '@server/models/user/user-notification'
5import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes'
6import { getFormattedObjects } from '../../../helpers/utils'
7import { addAccountInBlocklist, addServerInBlocklist, removeAccountFromBlocklist, removeServerFromBlocklist } from '../../../lib/blocklist'
8import {
9 asyncMiddleware,
10 asyncRetryTransactionMiddleware,
11 authenticate,
12 paginationValidator,
13 setDefaultPagination,
14 setDefaultSort,
15 unblockAccountByAccountValidator
16} from '../../../middlewares'
17import {
18 accountsBlocklistSortValidator,
19 blockAccountValidator,
20 blockServerValidator,
21 serversBlocklistSortValidator,
22 unblockServerByAccountValidator
23} from '../../../middlewares/validators'
24import { AccountBlocklistModel } from '../../../models/account/account-blocklist'
25import { ServerBlocklistModel } from '../../../models/server/server-blocklist'
26
27const myBlocklistRouter = express.Router()
28
29myBlocklistRouter.get('/me/blocklist/accounts',
30 authenticate,
31 paginationValidator,
32 accountsBlocklistSortValidator,
33 setDefaultSort,
34 setDefaultPagination,
35 asyncMiddleware(listBlockedAccounts)
36)
37
38myBlocklistRouter.post('/me/blocklist/accounts',
39 authenticate,
40 asyncMiddleware(blockAccountValidator),
41 asyncRetryTransactionMiddleware(blockAccount)
42)
43
44myBlocklistRouter.delete('/me/blocklist/accounts/:accountName',
45 authenticate,
46 asyncMiddleware(unblockAccountByAccountValidator),
47 asyncRetryTransactionMiddleware(unblockAccount)
48)
49
50myBlocklistRouter.get('/me/blocklist/servers',
51 authenticate,
52 paginationValidator,
53 serversBlocklistSortValidator,
54 setDefaultSort,
55 setDefaultPagination,
56 asyncMiddleware(listBlockedServers)
57)
58
59myBlocklistRouter.post('/me/blocklist/servers',
60 authenticate,
61 asyncMiddleware(blockServerValidator),
62 asyncRetryTransactionMiddleware(blockServer)
63)
64
65myBlocklistRouter.delete('/me/blocklist/servers/:host',
66 authenticate,
67 asyncMiddleware(unblockServerByAccountValidator),
68 asyncRetryTransactionMiddleware(unblockServer)
69)
70
71export {
72 myBlocklistRouter
73}
74
75// ---------------------------------------------------------------------------
76
77async function listBlockedAccounts (req: express.Request, res: express.Response) {
78 const user = res.locals.oauth.token.User
79
80 const resultList = await AccountBlocklistModel.listForApi({
81 start: req.query.start,
82 count: req.query.count,
83 sort: req.query.sort,
84 search: req.query.search,
85 accountId: user.Account.id
86 })
87
88 return res.json(getFormattedObjects(resultList.data, resultList.total))
89}
90
91async function blockAccount (req: express.Request, res: express.Response) {
92 const user = res.locals.oauth.token.User
93 const accountToBlock = res.locals.account
94
95 await addAccountInBlocklist(user.Account.id, accountToBlock.id)
96
97 UserNotificationModel.removeNotificationsOf({
98 id: accountToBlock.id,
99 type: 'account',
100 forUserId: user.id
101 }).catch(err => logger.error('Cannot remove notifications after an account mute.', { err }))
102
103 return res.status(HttpStatusCode.NO_CONTENT_204).end()
104}
105
106async function unblockAccount (req: express.Request, res: express.Response) {
107 const accountBlock = res.locals.accountBlock
108
109 await removeAccountFromBlocklist(accountBlock)
110
111 return res.status(HttpStatusCode.NO_CONTENT_204).end()
112}
113
114async function listBlockedServers (req: express.Request, res: express.Response) {
115 const user = res.locals.oauth.token.User
116
117 const resultList = await ServerBlocklistModel.listForApi({
118 start: req.query.start,
119 count: req.query.count,
120 sort: req.query.sort,
121 search: req.query.search,
122 accountId: user.Account.id
123 })
124
125 return res.json(getFormattedObjects(resultList.data, resultList.total))
126}
127
128async function blockServer (req: express.Request, res: express.Response) {
129 const user = res.locals.oauth.token.User
130 const serverToBlock = res.locals.server
131
132 await addServerInBlocklist(user.Account.id, serverToBlock.id)
133
134 UserNotificationModel.removeNotificationsOf({
135 id: serverToBlock.id,
136 type: 'server',
137 forUserId: user.id
138 }).catch(err => logger.error('Cannot remove notifications after a server mute.', { err }))
139
140 return res.status(HttpStatusCode.NO_CONTENT_204).end()
141}
142
143async function unblockServer (req: express.Request, res: express.Response) {
144 const serverBlock = res.locals.serverBlock
145
146 await removeServerFromBlocklist(serverBlock)
147
148 return res.status(HttpStatusCode.NO_CONTENT_204).end()
149}
diff --git a/server/controllers/api/users/my-history.ts b/server/controllers/api/users/my-history.ts
deleted file mode 100644
index e6d3e86ac..000000000
--- a/server/controllers/api/users/my-history.ts
+++ /dev/null
@@ -1,75 +0,0 @@
1import { forceNumber } from '@shared/core-utils'
2import express from 'express'
3import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes'
4import { getFormattedObjects } from '../../../helpers/utils'
5import { sequelizeTypescript } from '../../../initializers/database'
6import {
7 asyncMiddleware,
8 asyncRetryTransactionMiddleware,
9 authenticate,
10 paginationValidator,
11 setDefaultPagination,
12 userHistoryListValidator,
13 userHistoryRemoveAllValidator,
14 userHistoryRemoveElementValidator
15} from '../../../middlewares'
16import { UserVideoHistoryModel } from '../../../models/user/user-video-history'
17
18const myVideosHistoryRouter = express.Router()
19
20myVideosHistoryRouter.get('/me/history/videos',
21 authenticate,
22 paginationValidator,
23 setDefaultPagination,
24 userHistoryListValidator,
25 asyncMiddleware(listMyVideosHistory)
26)
27
28myVideosHistoryRouter.delete('/me/history/videos/:videoId',
29 authenticate,
30 userHistoryRemoveElementValidator,
31 asyncMiddleware(removeUserHistoryElement)
32)
33
34myVideosHistoryRouter.post('/me/history/videos/remove',
35 authenticate,
36 userHistoryRemoveAllValidator,
37 asyncRetryTransactionMiddleware(removeAllUserHistory)
38)
39
40// ---------------------------------------------------------------------------
41
42export {
43 myVideosHistoryRouter
44}
45
46// ---------------------------------------------------------------------------
47
48async function listMyVideosHistory (req: express.Request, res: express.Response) {
49 const user = res.locals.oauth.token.User
50
51 const resultList = await UserVideoHistoryModel.listForApi(user, req.query.start, req.query.count, req.query.search)
52
53 return res.json(getFormattedObjects(resultList.data, resultList.total))
54}
55
56async function removeUserHistoryElement (req: express.Request, res: express.Response) {
57 const user = res.locals.oauth.token.User
58
59 await UserVideoHistoryModel.removeUserHistoryElement(user, forceNumber(req.params.videoId))
60
61 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
62}
63
64async function removeAllUserHistory (req: express.Request, res: express.Response) {
65 const user = res.locals.oauth.token.User
66 const beforeDate = req.body.beforeDate || null
67
68 await sequelizeTypescript.transaction(t => {
69 return UserVideoHistoryModel.removeUserHistoryBefore(user, beforeDate, t)
70 })
71
72 return res.type('json')
73 .status(HttpStatusCode.NO_CONTENT_204)
74 .end()
75}
diff --git a/server/controllers/api/users/my-notifications.ts b/server/controllers/api/users/my-notifications.ts
deleted file mode 100644
index 6014cdbbf..000000000
--- a/server/controllers/api/users/my-notifications.ts
+++ /dev/null
@@ -1,116 +0,0 @@
1import 'multer'
2import express from 'express'
3import { UserNotificationModel } from '@server/models/user/user-notification'
4import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes'
5import { UserNotificationSetting } from '../../../../shared/models/users'
6import {
7 asyncMiddleware,
8 asyncRetryTransactionMiddleware,
9 authenticate,
10 paginationValidator,
11 setDefaultPagination,
12 setDefaultSort,
13 userNotificationsSortValidator
14} from '../../../middlewares'
15import {
16 listUserNotificationsValidator,
17 markAsReadUserNotificationsValidator,
18 updateNotificationSettingsValidator
19} from '../../../middlewares/validators/user-notifications'
20import { UserNotificationSettingModel } from '../../../models/user/user-notification-setting'
21import { meRouter } from './me'
22import { getFormattedObjects } from '@server/helpers/utils'
23
24const myNotificationsRouter = express.Router()
25
26meRouter.put('/me/notification-settings',
27 authenticate,
28 updateNotificationSettingsValidator,
29 asyncRetryTransactionMiddleware(updateNotificationSettings)
30)
31
32myNotificationsRouter.get('/me/notifications',
33 authenticate,
34 paginationValidator,
35 userNotificationsSortValidator,
36 setDefaultSort,
37 setDefaultPagination,
38 listUserNotificationsValidator,
39 asyncMiddleware(listUserNotifications)
40)
41
42myNotificationsRouter.post('/me/notifications/read',
43 authenticate,
44 markAsReadUserNotificationsValidator,
45 asyncMiddleware(markAsReadUserNotifications)
46)
47
48myNotificationsRouter.post('/me/notifications/read-all',
49 authenticate,
50 asyncMiddleware(markAsReadAllUserNotifications)
51)
52
53export {
54 myNotificationsRouter
55}
56
57// ---------------------------------------------------------------------------
58
59async function updateNotificationSettings (req: express.Request, res: express.Response) {
60 const user = res.locals.oauth.token.User
61 const body = req.body as UserNotificationSetting
62
63 const query = {
64 where: {
65 userId: user.id
66 }
67 }
68
69 const values: UserNotificationSetting = {
70 newVideoFromSubscription: body.newVideoFromSubscription,
71 newCommentOnMyVideo: body.newCommentOnMyVideo,
72 abuseAsModerator: body.abuseAsModerator,
73 videoAutoBlacklistAsModerator: body.videoAutoBlacklistAsModerator,
74 blacklistOnMyVideo: body.blacklistOnMyVideo,
75 myVideoPublished: body.myVideoPublished,
76 myVideoImportFinished: body.myVideoImportFinished,
77 newFollow: body.newFollow,
78 newUserRegistration: body.newUserRegistration,
79 commentMention: body.commentMention,
80 newInstanceFollower: body.newInstanceFollower,
81 autoInstanceFollowing: body.autoInstanceFollowing,
82 abuseNewMessage: body.abuseNewMessage,
83 abuseStateChange: body.abuseStateChange,
84 newPeerTubeVersion: body.newPeerTubeVersion,
85 newPluginVersion: body.newPluginVersion,
86 myVideoStudioEditionFinished: body.myVideoStudioEditionFinished
87 }
88
89 await UserNotificationSettingModel.update(values, query)
90
91 return res.status(HttpStatusCode.NO_CONTENT_204).end()
92}
93
94async function listUserNotifications (req: express.Request, res: express.Response) {
95 const user = res.locals.oauth.token.User
96
97 const resultList = await UserNotificationModel.listForApi(user.id, req.query.start, req.query.count, req.query.sort, req.query.unread)
98
99 return res.json(getFormattedObjects(resultList.data, resultList.total))
100}
101
102async function markAsReadUserNotifications (req: express.Request, res: express.Response) {
103 const user = res.locals.oauth.token.User
104
105 await UserNotificationModel.markAsRead(user.id, req.body.ids)
106
107 return res.status(HttpStatusCode.NO_CONTENT_204).end()
108}
109
110async function markAsReadAllUserNotifications (req: express.Request, res: express.Response) {
111 const user = res.locals.oauth.token.User
112
113 await UserNotificationModel.markAllAsRead(user.id)
114
115 return res.status(HttpStatusCode.NO_CONTENT_204).end()
116}
diff --git a/server/controllers/api/users/my-subscriptions.ts b/server/controllers/api/users/my-subscriptions.ts
deleted file mode 100644
index c4360f59d..000000000
--- a/server/controllers/api/users/my-subscriptions.ts
+++ /dev/null
@@ -1,193 +0,0 @@
1import 'multer'
2import express from 'express'
3import { handlesToNameAndHost } from '@server/helpers/actors'
4import { pickCommonVideoQuery } from '@server/helpers/query'
5import { sendUndoFollow } from '@server/lib/activitypub/send'
6import { Hooks } from '@server/lib/plugins/hooks'
7import { VideoChannelModel } from '@server/models/video/video-channel'
8import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes'
9import { buildNSFWFilter, getCountVideos } from '../../../helpers/express-utils'
10import { getFormattedObjects } from '../../../helpers/utils'
11import { sequelizeTypescript } from '../../../initializers/database'
12import { JobQueue } from '../../../lib/job-queue'
13import {
14 asyncMiddleware,
15 asyncRetryTransactionMiddleware,
16 authenticate,
17 commonVideosFiltersValidator,
18 paginationValidator,
19 setDefaultPagination,
20 setDefaultSort,
21 setDefaultVideosSort,
22 userSubscriptionAddValidator,
23 userSubscriptionGetValidator
24} from '../../../middlewares'
25import {
26 areSubscriptionsExistValidator,
27 userSubscriptionListValidator,
28 userSubscriptionsSortValidator,
29 videosSortValidator
30} from '../../../middlewares/validators'
31import { ActorFollowModel } from '../../../models/actor/actor-follow'
32import { guessAdditionalAttributesFromQuery } from '../../../models/video/formatter'
33import { VideoModel } from '../../../models/video/video'
34
35const mySubscriptionsRouter = express.Router()
36
37mySubscriptionsRouter.get('/me/subscriptions/videos',
38 authenticate,
39 paginationValidator,
40 videosSortValidator,
41 setDefaultVideosSort,
42 setDefaultPagination,
43 commonVideosFiltersValidator,
44 asyncMiddleware(getUserSubscriptionVideos)
45)
46
47mySubscriptionsRouter.get('/me/subscriptions/exist',
48 authenticate,
49 areSubscriptionsExistValidator,
50 asyncMiddleware(areSubscriptionsExist)
51)
52
53mySubscriptionsRouter.get('/me/subscriptions',
54 authenticate,
55 paginationValidator,
56 userSubscriptionsSortValidator,
57 setDefaultSort,
58 setDefaultPagination,
59 userSubscriptionListValidator,
60 asyncMiddleware(getUserSubscriptions)
61)
62
63mySubscriptionsRouter.post('/me/subscriptions',
64 authenticate,
65 userSubscriptionAddValidator,
66 addUserSubscription
67)
68
69mySubscriptionsRouter.get('/me/subscriptions/:uri',
70 authenticate,
71 userSubscriptionGetValidator,
72 asyncMiddleware(getUserSubscription)
73)
74
75mySubscriptionsRouter.delete('/me/subscriptions/:uri',
76 authenticate,
77 userSubscriptionGetValidator,
78 asyncRetryTransactionMiddleware(deleteUserSubscription)
79)
80
81// ---------------------------------------------------------------------------
82
83export {
84 mySubscriptionsRouter
85}
86
87// ---------------------------------------------------------------------------
88
89async function areSubscriptionsExist (req: express.Request, res: express.Response) {
90 const uris = req.query.uris as string[]
91 const user = res.locals.oauth.token.User
92
93 const sanitizedHandles = handlesToNameAndHost(uris)
94
95 const results = await ActorFollowModel.listSubscriptionsOf(user.Account.Actor.id, sanitizedHandles)
96
97 const existObject: { [id: string ]: boolean } = {}
98 for (const sanitizedHandle of sanitizedHandles) {
99 const obj = results.find(r => {
100 const server = r.ActorFollowing.Server
101
102 return r.ActorFollowing.preferredUsername.toLowerCase() === sanitizedHandle.name.toLowerCase() &&
103 (
104 (!server && !sanitizedHandle.host) ||
105 (server.host === sanitizedHandle.host)
106 )
107 })
108
109 existObject[sanitizedHandle.handle] = obj !== undefined
110 }
111
112 return res.json(existObject)
113}
114
115function addUserSubscription (req: express.Request, res: express.Response) {
116 const user = res.locals.oauth.token.User
117 const [ name, host ] = req.body.uri.split('@')
118
119 const payload = {
120 name,
121 host,
122 assertIsChannel: true,
123 followerActorId: user.Account.Actor.id
124 }
125
126 JobQueue.Instance.createJobAsync({ type: 'activitypub-follow', payload })
127
128 return res.status(HttpStatusCode.NO_CONTENT_204).end()
129}
130
131async function getUserSubscription (req: express.Request, res: express.Response) {
132 const subscription = res.locals.subscription
133 const videoChannel = await VideoChannelModel.loadAndPopulateAccount(subscription.ActorFollowing.VideoChannel.id)
134
135 return res.json(videoChannel.toFormattedJSON())
136}
137
138async function deleteUserSubscription (req: express.Request, res: express.Response) {
139 const subscription = res.locals.subscription
140
141 await sequelizeTypescript.transaction(async t => {
142 if (subscription.state === 'accepted') {
143 sendUndoFollow(subscription, t)
144 }
145
146 return subscription.destroy({ transaction: t })
147 })
148
149 return res.type('json')
150 .status(HttpStatusCode.NO_CONTENT_204)
151 .end()
152}
153
154async function getUserSubscriptions (req: express.Request, res: express.Response) {
155 const user = res.locals.oauth.token.User
156 const actorId = user.Account.Actor.id
157
158 const resultList = await ActorFollowModel.listSubscriptionsForApi({
159 actorId,
160 start: req.query.start,
161 count: req.query.count,
162 sort: req.query.sort,
163 search: req.query.search
164 })
165
166 return res.json(getFormattedObjects(resultList.data, resultList.total))
167}
168
169async function getUserSubscriptionVideos (req: express.Request, res: express.Response) {
170 const user = res.locals.oauth.token.User
171 const countVideos = getCountVideos(req)
172 const query = pickCommonVideoQuery(req.query)
173
174 const apiOptions = await Hooks.wrapObject({
175 ...query,
176
177 displayOnlyForFollower: {
178 actorId: user.Account.Actor.id,
179 orLocalVideos: false
180 },
181 nsfw: buildNSFWFilter(res, query.nsfw),
182 user,
183 countVideos
184 }, 'filter:api.user.me.subscription-videos.list.params')
185
186 const resultList = await Hooks.wrapPromiseFun(
187 VideoModel.listForApi,
188 apiOptions,
189 'filter:api.user.me.subscription-videos.list.result'
190 )
191
192 return res.json(getFormattedObjects(resultList.data, resultList.total, guessAdditionalAttributesFromQuery(query)))
193}
diff --git a/server/controllers/api/users/my-video-playlists.ts b/server/controllers/api/users/my-video-playlists.ts
deleted file mode 100644
index fbdbb7e50..000000000
--- a/server/controllers/api/users/my-video-playlists.ts
+++ /dev/null
@@ -1,51 +0,0 @@
1import express from 'express'
2import { forceNumber } from '@shared/core-utils'
3import { uuidToShort } from '@shared/extra-utils'
4import { VideosExistInPlaylists } from '../../../../shared/models/videos/playlist/video-exist-in-playlist.model'
5import { asyncMiddleware, authenticate } from '../../../middlewares'
6import { doVideosInPlaylistExistValidator } from '../../../middlewares/validators/videos/video-playlists'
7import { VideoPlaylistModel } from '../../../models/video/video-playlist'
8
9const myVideoPlaylistsRouter = express.Router()
10
11myVideoPlaylistsRouter.get('/me/video-playlists/videos-exist',
12 authenticate,
13 doVideosInPlaylistExistValidator,
14 asyncMiddleware(doVideosInPlaylistExist)
15)
16
17// ---------------------------------------------------------------------------
18
19export {
20 myVideoPlaylistsRouter
21}
22
23// ---------------------------------------------------------------------------
24
25async function doVideosInPlaylistExist (req: express.Request, res: express.Response) {
26 const videoIds = req.query.videoIds.map(i => forceNumber(i))
27 const user = res.locals.oauth.token.User
28
29 const results = await VideoPlaylistModel.listPlaylistSummariesOf(user.Account.id, videoIds)
30
31 const existObject: VideosExistInPlaylists = {}
32
33 for (const videoId of videoIds) {
34 existObject[videoId] = []
35 }
36
37 for (const result of results) {
38 for (const element of result.VideoPlaylistElements) {
39 existObject[element.videoId].push({
40 playlistElementId: element.id,
41 playlistId: result.id,
42 playlistDisplayName: result.name,
43 playlistShortUUID: uuidToShort(result.uuid),
44 startTimestamp: element.startTimestamp,
45 stopTimestamp: element.stopTimestamp
46 })
47 }
48 }
49
50 return res.json(existObject)
51}
diff --git a/server/controllers/api/users/registrations.ts b/server/controllers/api/users/registrations.ts
deleted file mode 100644
index 5e213d6cc..000000000
--- a/server/controllers/api/users/registrations.ts
+++ /dev/null
@@ -1,249 +0,0 @@
1import express from 'express'
2import { Emailer } from '@server/lib/emailer'
3import { Hooks } from '@server/lib/plugins/hooks'
4import { UserRegistrationModel } from '@server/models/user/user-registration'
5import { pick } from '@shared/core-utils'
6import {
7 HttpStatusCode,
8 UserRegister,
9 UserRegistrationRequest,
10 UserRegistrationState,
11 UserRegistrationUpdateState,
12 UserRight
13} from '@shared/models'
14import { auditLoggerFactory, UserAuditView } from '../../../helpers/audit-logger'
15import { logger } from '../../../helpers/logger'
16import { CONFIG } from '../../../initializers/config'
17import { Notifier } from '../../../lib/notifier'
18import { buildUser, createUserAccountAndChannelAndPlaylist, sendVerifyRegistrationEmail, sendVerifyUserEmail } from '../../../lib/user'
19import {
20 acceptOrRejectRegistrationValidator,
21 asyncMiddleware,
22 asyncRetryTransactionMiddleware,
23 authenticate,
24 buildRateLimiter,
25 ensureUserHasRight,
26 ensureUserRegistrationAllowedFactory,
27 ensureUserRegistrationAllowedForIP,
28 getRegistrationValidator,
29 listRegistrationsValidator,
30 paginationValidator,
31 setDefaultPagination,
32 setDefaultSort,
33 userRegistrationsSortValidator,
34 usersDirectRegistrationValidator,
35 usersRequestRegistrationValidator
36} from '../../../middlewares'
37
38const auditLogger = auditLoggerFactory('users')
39
40const registrationRateLimiter = buildRateLimiter({
41 windowMs: CONFIG.RATES_LIMIT.SIGNUP.WINDOW_MS,
42 max: CONFIG.RATES_LIMIT.SIGNUP.MAX,
43 skipFailedRequests: true
44})
45
46const registrationsRouter = express.Router()
47
48registrationsRouter.post('/registrations/request',
49 registrationRateLimiter,
50 asyncMiddleware(ensureUserRegistrationAllowedFactory('request-registration')),
51 ensureUserRegistrationAllowedForIP,
52 asyncMiddleware(usersRequestRegistrationValidator),
53 asyncRetryTransactionMiddleware(requestRegistration)
54)
55
56registrationsRouter.post('/registrations/:registrationId/accept',
57 authenticate,
58 ensureUserHasRight(UserRight.MANAGE_REGISTRATIONS),
59 asyncMiddleware(acceptOrRejectRegistrationValidator),
60 asyncRetryTransactionMiddleware(acceptRegistration)
61)
62registrationsRouter.post('/registrations/:registrationId/reject',
63 authenticate,
64 ensureUserHasRight(UserRight.MANAGE_REGISTRATIONS),
65 asyncMiddleware(acceptOrRejectRegistrationValidator),
66 asyncRetryTransactionMiddleware(rejectRegistration)
67)
68
69registrationsRouter.delete('/registrations/:registrationId',
70 authenticate,
71 ensureUserHasRight(UserRight.MANAGE_REGISTRATIONS),
72 asyncMiddleware(getRegistrationValidator),
73 asyncRetryTransactionMiddleware(deleteRegistration)
74)
75
76registrationsRouter.get('/registrations',
77 authenticate,
78 ensureUserHasRight(UserRight.MANAGE_REGISTRATIONS),
79 paginationValidator,
80 userRegistrationsSortValidator,
81 setDefaultSort,
82 setDefaultPagination,
83 listRegistrationsValidator,
84 asyncMiddleware(listRegistrations)
85)
86
87registrationsRouter.post('/register',
88 registrationRateLimiter,
89 asyncMiddleware(ensureUserRegistrationAllowedFactory('direct-registration')),
90 ensureUserRegistrationAllowedForIP,
91 asyncMiddleware(usersDirectRegistrationValidator),
92 asyncRetryTransactionMiddleware(registerUser)
93)
94
95// ---------------------------------------------------------------------------
96
97export {
98 registrationsRouter
99}
100
101// ---------------------------------------------------------------------------
102
103async function requestRegistration (req: express.Request, res: express.Response) {
104 const body: UserRegistrationRequest = req.body
105
106 const registration = new UserRegistrationModel({
107 ...pick(body, [ 'username', 'password', 'email', 'registrationReason' ]),
108
109 accountDisplayName: body.displayName,
110 channelDisplayName: body.channel?.displayName,
111 channelHandle: body.channel?.name,
112
113 state: UserRegistrationState.PENDING,
114
115 emailVerified: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION ? false : null
116 })
117
118 await registration.save()
119
120 if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) {
121 await sendVerifyRegistrationEmail(registration)
122 }
123
124 Notifier.Instance.notifyOnNewRegistrationRequest(registration)
125
126 Hooks.runAction('action:api.user.requested-registration', { body, registration, req, res })
127
128 return res.json(registration.toFormattedJSON())
129}
130
131// ---------------------------------------------------------------------------
132
133async function acceptRegistration (req: express.Request, res: express.Response) {
134 const registration = res.locals.userRegistration
135 const body: UserRegistrationUpdateState = req.body
136
137 const userToCreate = buildUser({
138 username: registration.username,
139 password: registration.password,
140 email: registration.email,
141 emailVerified: registration.emailVerified
142 })
143 // We already encrypted password in registration model
144 userToCreate.skipPasswordEncryption = true
145
146 // TODO: handle conflicts if someone else created a channel handle/user handle/user email between registration and approval
147
148 const { user } = await createUserAccountAndChannelAndPlaylist({
149 userToCreate,
150 userDisplayName: registration.accountDisplayName,
151 channelNames: registration.channelHandle && registration.channelDisplayName
152 ? {
153 name: registration.channelHandle,
154 displayName: registration.channelDisplayName
155 }
156 : undefined
157 })
158
159 registration.userId = user.id
160 registration.state = UserRegistrationState.ACCEPTED
161 registration.moderationResponse = body.moderationResponse
162
163 await registration.save()
164
165 logger.info('Registration of %s accepted', registration.username)
166
167 if (body.preventEmailDelivery !== true) {
168 Emailer.Instance.addUserRegistrationRequestProcessedJob(registration)
169 }
170
171 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
172}
173
174async function rejectRegistration (req: express.Request, res: express.Response) {
175 const registration = res.locals.userRegistration
176 const body: UserRegistrationUpdateState = req.body
177
178 registration.state = UserRegistrationState.REJECTED
179 registration.moderationResponse = body.moderationResponse
180
181 await registration.save()
182
183 if (body.preventEmailDelivery !== true) {
184 Emailer.Instance.addUserRegistrationRequestProcessedJob(registration)
185 }
186
187 logger.info('Registration of %s rejected', registration.username)
188
189 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
190}
191
192// ---------------------------------------------------------------------------
193
194async function deleteRegistration (req: express.Request, res: express.Response) {
195 const registration = res.locals.userRegistration
196
197 await registration.destroy()
198
199 logger.info('Registration of %s deleted', registration.username)
200
201 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
202}
203
204// ---------------------------------------------------------------------------
205
206async function listRegistrations (req: express.Request, res: express.Response) {
207 const resultList = await UserRegistrationModel.listForApi({
208 start: req.query.start,
209 count: req.query.count,
210 sort: req.query.sort,
211 search: req.query.search
212 })
213
214 return res.json({
215 total: resultList.total,
216 data: resultList.data.map(d => d.toFormattedJSON())
217 })
218}
219
220// ---------------------------------------------------------------------------
221
222async function registerUser (req: express.Request, res: express.Response) {
223 const body: UserRegister = req.body
224
225 const userToCreate = buildUser({
226 ...pick(body, [ 'username', 'password', 'email' ]),
227
228 emailVerified: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION ? false : null
229 })
230
231 const { user, account, videoChannel } = await createUserAccountAndChannelAndPlaylist({
232 userToCreate,
233 userDisplayName: body.displayName || undefined,
234 channelNames: body.channel
235 })
236
237 auditLogger.create(body.username, new UserAuditView(user.toFormattedJSON()))
238 logger.info('User %s with its channel and account registered.', body.username)
239
240 if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) {
241 await sendVerifyUserEmail(user)
242 }
243
244 Notifier.Instance.notifyOnNewDirectRegistration(user)
245
246 Hooks.runAction('action:api.user.registered', { body, user, account, videoChannel, req, res })
247
248 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
249}
diff --git a/server/controllers/api/users/token.ts b/server/controllers/api/users/token.ts
deleted file mode 100644
index c6afea67c..000000000
--- a/server/controllers/api/users/token.ts
+++ /dev/null
@@ -1,131 +0,0 @@
1import express from 'express'
2import { logger } from '@server/helpers/logger'
3import { CONFIG } from '@server/initializers/config'
4import { OTP } from '@server/initializers/constants'
5import { getAuthNameFromRefreshGrant, getBypassFromExternalAuth, getBypassFromPasswordGrant } from '@server/lib/auth/external-auth'
6import { handleOAuthToken, MissingTwoFactorError } from '@server/lib/auth/oauth'
7import { BypassLogin, revokeToken } from '@server/lib/auth/oauth-model'
8import { Hooks } from '@server/lib/plugins/hooks'
9import { asyncMiddleware, authenticate, buildRateLimiter, openapiOperationDoc } from '@server/middlewares'
10import { buildUUID } from '@shared/extra-utils'
11import { ScopedToken } from '@shared/models/users/user-scoped-token'
12
13const tokensRouter = express.Router()
14
15const loginRateLimiter = buildRateLimiter({
16 windowMs: CONFIG.RATES_LIMIT.LOGIN.WINDOW_MS,
17 max: CONFIG.RATES_LIMIT.LOGIN.MAX
18})
19
20tokensRouter.post('/token',
21 loginRateLimiter,
22 openapiOperationDoc({ operationId: 'getOAuthToken' }),
23 asyncMiddleware(handleToken)
24)
25
26tokensRouter.post('/revoke-token',
27 openapiOperationDoc({ operationId: 'revokeOAuthToken' }),
28 authenticate,
29 asyncMiddleware(handleTokenRevocation)
30)
31
32tokensRouter.get('/scoped-tokens',
33 authenticate,
34 getScopedTokens
35)
36
37tokensRouter.post('/scoped-tokens',
38 authenticate,
39 asyncMiddleware(renewScopedTokens)
40)
41
42// ---------------------------------------------------------------------------
43
44export {
45 tokensRouter
46}
47// ---------------------------------------------------------------------------
48
49async function handleToken (req: express.Request, res: express.Response, next: express.NextFunction) {
50 const grantType = req.body.grant_type
51
52 try {
53 const bypassLogin = await buildByPassLogin(req, grantType)
54
55 const refreshTokenAuthName = grantType === 'refresh_token'
56 ? await getAuthNameFromRefreshGrant(req.body.refresh_token)
57 : undefined
58
59 const options = {
60 refreshTokenAuthName,
61 bypassLogin
62 }
63
64 const token = await handleOAuthToken(req, options)
65
66 res.set('Cache-Control', 'no-store')
67 res.set('Pragma', 'no-cache')
68
69 Hooks.runAction('action:api.user.oauth2-got-token', { username: token.user.username, ip: req.ip, req, res })
70
71 return res.json({
72 token_type: 'Bearer',
73
74 access_token: token.accessToken,
75 refresh_token: token.refreshToken,
76
77 expires_in: token.accessTokenExpiresIn,
78 refresh_token_expires_in: token.refreshTokenExpiresIn
79 })
80 } catch (err) {
81 logger.warn('Login error', { err })
82
83 if (err instanceof MissingTwoFactorError) {
84 res.set(OTP.HEADER_NAME, OTP.HEADER_REQUIRED_VALUE)
85 }
86
87 return res.fail({
88 status: err.code,
89 message: err.message,
90 type: err.name
91 })
92 }
93}
94
95async function handleTokenRevocation (req: express.Request, res: express.Response) {
96 const token = res.locals.oauth.token
97
98 const result = await revokeToken(token, { req, explicitLogout: true })
99
100 return res.json(result)
101}
102
103function getScopedTokens (req: express.Request, res: express.Response) {
104 const user = res.locals.oauth.token.user
105
106 return res.json({
107 feedToken: user.feedToken
108 } as ScopedToken)
109}
110
111async function renewScopedTokens (req: express.Request, res: express.Response) {
112 const user = res.locals.oauth.token.user
113
114 user.feedToken = buildUUID()
115 await user.save()
116
117 return res.json({
118 feedToken: user.feedToken
119 } as ScopedToken)
120}
121
122async function buildByPassLogin (req: express.Request, grantType: string): Promise<BypassLogin> {
123 if (grantType !== 'password') return undefined
124
125 if (req.body.externalAuthToken) {
126 // Consistency with the getBypassFromPasswordGrant promise
127 return getBypassFromExternalAuth(req.body.username, req.body.externalAuthToken)
128 }
129
130 return getBypassFromPasswordGrant(req.body.username, req.body.password)
131}
diff --git a/server/controllers/api/users/two-factor.ts b/server/controllers/api/users/two-factor.ts
deleted file mode 100644
index e6ae9e4dd..000000000
--- a/server/controllers/api/users/two-factor.ts
+++ /dev/null
@@ -1,95 +0,0 @@
1import express from 'express'
2import { generateOTPSecret, isOTPValid } from '@server/helpers/otp'
3import { encrypt } from '@server/helpers/peertube-crypto'
4import { CONFIG } from '@server/initializers/config'
5import { Redis } from '@server/lib/redis'
6import { asyncMiddleware, authenticate, usersCheckCurrentPasswordFactory } from '@server/middlewares'
7import {
8 confirmTwoFactorValidator,
9 disableTwoFactorValidator,
10 requestOrConfirmTwoFactorValidator
11} from '@server/middlewares/validators/two-factor'
12import { HttpStatusCode, TwoFactorEnableResult } from '@shared/models'
13
14const twoFactorRouter = express.Router()
15
16twoFactorRouter.post('/:id/two-factor/request',
17 authenticate,
18 asyncMiddleware(usersCheckCurrentPasswordFactory(req => req.params.id)),
19 asyncMiddleware(requestOrConfirmTwoFactorValidator),
20 asyncMiddleware(requestTwoFactor)
21)
22
23twoFactorRouter.post('/:id/two-factor/confirm-request',
24 authenticate,
25 asyncMiddleware(requestOrConfirmTwoFactorValidator),
26 confirmTwoFactorValidator,
27 asyncMiddleware(confirmRequestTwoFactor)
28)
29
30twoFactorRouter.post('/:id/two-factor/disable',
31 authenticate,
32 asyncMiddleware(usersCheckCurrentPasswordFactory(req => req.params.id)),
33 asyncMiddleware(disableTwoFactorValidator),
34 asyncMiddleware(disableTwoFactor)
35)
36
37// ---------------------------------------------------------------------------
38
39export {
40 twoFactorRouter
41}
42
43// ---------------------------------------------------------------------------
44
45async function requestTwoFactor (req: express.Request, res: express.Response) {
46 const user = res.locals.user
47
48 const { secret, uri } = generateOTPSecret(user.email)
49
50 const encryptedSecret = await encrypt(secret, CONFIG.SECRETS.PEERTUBE)
51 const requestToken = await Redis.Instance.setTwoFactorRequest(user.id, encryptedSecret)
52
53 return res.json({
54 otpRequest: {
55 requestToken,
56 secret,
57 uri
58 }
59 } as TwoFactorEnableResult)
60}
61
62async function confirmRequestTwoFactor (req: express.Request, res: express.Response) {
63 const requestToken = req.body.requestToken
64 const otpToken = req.body.otpToken
65 const user = res.locals.user
66
67 const encryptedSecret = await Redis.Instance.getTwoFactorRequestToken(user.id, requestToken)
68 if (!encryptedSecret) {
69 return res.fail({
70 message: 'Invalid request token',
71 status: HttpStatusCode.FORBIDDEN_403
72 })
73 }
74
75 if (await isOTPValid({ encryptedSecret, token: otpToken }) !== true) {
76 return res.fail({
77 message: 'Invalid OTP token',
78 status: HttpStatusCode.FORBIDDEN_403
79 })
80 }
81
82 user.otpSecret = encryptedSecret
83 await user.save()
84
85 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
86}
87
88async function disableTwoFactor (req: express.Request, res: express.Response) {
89 const user = res.locals.user
90
91 user.otpSecret = null
92 await user.save()
93
94 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
95}
diff --git a/server/controllers/api/video-channel-sync.ts b/server/controllers/api/video-channel-sync.ts
deleted file mode 100644
index 6b52ac7dd..000000000
--- a/server/controllers/api/video-channel-sync.ts
+++ /dev/null
@@ -1,79 +0,0 @@
1import express from 'express'
2import { auditLoggerFactory, getAuditIdFromRes, VideoChannelSyncAuditView } from '@server/helpers/audit-logger'
3import { logger } from '@server/helpers/logger'
4import {
5 apiRateLimiter,
6 asyncMiddleware,
7 asyncRetryTransactionMiddleware,
8 authenticate,
9 ensureCanManageChannelOrAccount,
10 ensureSyncExists,
11 ensureSyncIsEnabled,
12 videoChannelSyncValidator
13} from '@server/middlewares'
14import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync'
15import { MChannelSyncFormattable } from '@server/types/models'
16import { HttpStatusCode, VideoChannelSyncState } from '@shared/models'
17
18const videoChannelSyncRouter = express.Router()
19const auditLogger = auditLoggerFactory('channel-syncs')
20
21videoChannelSyncRouter.use(apiRateLimiter)
22
23videoChannelSyncRouter.post('/',
24 authenticate,
25 ensureSyncIsEnabled,
26 asyncMiddleware(videoChannelSyncValidator),
27 ensureCanManageChannelOrAccount,
28 asyncRetryTransactionMiddleware(createVideoChannelSync)
29)
30
31videoChannelSyncRouter.delete('/:id',
32 authenticate,
33 asyncMiddleware(ensureSyncExists),
34 ensureCanManageChannelOrAccount,
35 asyncRetryTransactionMiddleware(removeVideoChannelSync)
36)
37
38export { videoChannelSyncRouter }
39
40// ---------------------------------------------------------------------------
41
42async function createVideoChannelSync (req: express.Request, res: express.Response) {
43 const syncCreated: MChannelSyncFormattable = new VideoChannelSyncModel({
44 externalChannelUrl: req.body.externalChannelUrl,
45 videoChannelId: req.body.videoChannelId,
46 state: VideoChannelSyncState.WAITING_FIRST_RUN
47 })
48
49 await syncCreated.save()
50 syncCreated.VideoChannel = res.locals.videoChannel
51
52 auditLogger.create(getAuditIdFromRes(res), new VideoChannelSyncAuditView(syncCreated.toFormattedJSON()))
53
54 logger.info(
55 'Video synchronization for channel "%s" with external channel "%s" created.',
56 syncCreated.VideoChannel.name,
57 syncCreated.externalChannelUrl
58 )
59
60 return res.json({
61 videoChannelSync: syncCreated.toFormattedJSON()
62 })
63}
64
65async function removeVideoChannelSync (req: express.Request, res: express.Response) {
66 const syncInstance = res.locals.videoChannelSync
67
68 await syncInstance.destroy()
69
70 auditLogger.delete(getAuditIdFromRes(res), new VideoChannelSyncAuditView(syncInstance.toFormattedJSON()))
71
72 logger.info(
73 'Video synchronization for channel "%s" with external channel "%s" deleted.',
74 syncInstance.VideoChannel.name,
75 syncInstance.externalChannelUrl
76 )
77
78 return res.type('json').status(HttpStatusCode.NO_CONTENT_204).end()
79}
diff --git a/server/controllers/api/video-channel.ts b/server/controllers/api/video-channel.ts
deleted file mode 100644
index 18de5bf6a..000000000
--- a/server/controllers/api/video-channel.ts
+++ /dev/null
@@ -1,431 +0,0 @@
1import express from 'express'
2import { pickCommonVideoQuery } from '@server/helpers/query'
3import { Hooks } from '@server/lib/plugins/hooks'
4import { ActorFollowModel } from '@server/models/actor/actor-follow'
5import { getServerActor } from '@server/models/application/application'
6import { MChannelBannerAccountDefault } from '@server/types/models'
7import { ActorImageType, HttpStatusCode, VideoChannelCreate, VideoChannelUpdate, VideosImportInChannelCreate } from '@shared/models'
8import { auditLoggerFactory, getAuditIdFromRes, VideoChannelAuditView } from '../../helpers/audit-logger'
9import { resetSequelizeInstance } from '../../helpers/database-utils'
10import { buildNSFWFilter, createReqFiles, getCountVideos, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils'
11import { logger } from '../../helpers/logger'
12import { getFormattedObjects } from '../../helpers/utils'
13import { MIMETYPES } from '../../initializers/constants'
14import { sequelizeTypescript } from '../../initializers/database'
15import { sendUpdateActor } from '../../lib/activitypub/send'
16import { JobQueue } from '../../lib/job-queue'
17import { deleteLocalActorImageFile, updateLocalActorImageFiles } from '../../lib/local-actor'
18import { createLocalVideoChannel, federateAllVideosOfChannel } from '../../lib/video-channel'
19import {
20 apiRateLimiter,
21 asyncMiddleware,
22 asyncRetryTransactionMiddleware,
23 authenticate,
24 commonVideosFiltersValidator,
25 ensureCanManageChannelOrAccount,
26 optionalAuthenticate,
27 paginationValidator,
28 setDefaultPagination,
29 setDefaultSort,
30 setDefaultVideosSort,
31 videoChannelsAddValidator,
32 videoChannelsRemoveValidator,
33 videoChannelsSortValidator,
34 videoChannelsUpdateValidator,
35 videoPlaylistsSortValidator
36} from '../../middlewares'
37import {
38 ensureChannelOwnerCanUpload,
39 ensureIsLocalChannel,
40 videoChannelImportVideosValidator,
41 videoChannelsFollowersSortValidator,
42 videoChannelsListValidator,
43 videoChannelsNameWithHostValidator,
44 videosSortValidator
45} from '../../middlewares/validators'
46import { updateAvatarValidator, updateBannerValidator } from '../../middlewares/validators/actor-image'
47import { commonVideoPlaylistFiltersValidator } from '../../middlewares/validators/videos/video-playlists'
48import { AccountModel } from '../../models/account/account'
49import { guessAdditionalAttributesFromQuery } from '../../models/video/formatter'
50import { VideoModel } from '../../models/video/video'
51import { VideoChannelModel } from '../../models/video/video-channel'
52import { VideoPlaylistModel } from '../../models/video/video-playlist'
53
54const auditLogger = auditLoggerFactory('channels')
55const reqAvatarFile = createReqFiles([ 'avatarfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT)
56const reqBannerFile = createReqFiles([ 'bannerfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT)
57
58const videoChannelRouter = express.Router()
59
60videoChannelRouter.use(apiRateLimiter)
61
62videoChannelRouter.get('/',
63 paginationValidator,
64 videoChannelsSortValidator,
65 setDefaultSort,
66 setDefaultPagination,
67 videoChannelsListValidator,
68 asyncMiddleware(listVideoChannels)
69)
70
71videoChannelRouter.post('/',
72 authenticate,
73 asyncMiddleware(videoChannelsAddValidator),
74 asyncRetryTransactionMiddleware(addVideoChannel)
75)
76
77videoChannelRouter.post('/:nameWithHost/avatar/pick',
78 authenticate,
79 reqAvatarFile,
80 asyncMiddleware(videoChannelsNameWithHostValidator),
81 ensureIsLocalChannel,
82 ensureCanManageChannelOrAccount,
83 updateAvatarValidator,
84 asyncMiddleware(updateVideoChannelAvatar)
85)
86
87videoChannelRouter.post('/:nameWithHost/banner/pick',
88 authenticate,
89 reqBannerFile,
90 asyncMiddleware(videoChannelsNameWithHostValidator),
91 ensureIsLocalChannel,
92 ensureCanManageChannelOrAccount,
93 updateBannerValidator,
94 asyncMiddleware(updateVideoChannelBanner)
95)
96
97videoChannelRouter.delete('/:nameWithHost/avatar',
98 authenticate,
99 asyncMiddleware(videoChannelsNameWithHostValidator),
100 ensureIsLocalChannel,
101 ensureCanManageChannelOrAccount,
102 asyncMiddleware(deleteVideoChannelAvatar)
103)
104
105videoChannelRouter.delete('/:nameWithHost/banner',
106 authenticate,
107 asyncMiddleware(videoChannelsNameWithHostValidator),
108 ensureIsLocalChannel,
109 ensureCanManageChannelOrAccount,
110 asyncMiddleware(deleteVideoChannelBanner)
111)
112
113videoChannelRouter.put('/:nameWithHost',
114 authenticate,
115 asyncMiddleware(videoChannelsNameWithHostValidator),
116 ensureIsLocalChannel,
117 ensureCanManageChannelOrAccount,
118 videoChannelsUpdateValidator,
119 asyncRetryTransactionMiddleware(updateVideoChannel)
120)
121
122videoChannelRouter.delete('/:nameWithHost',
123 authenticate,
124 asyncMiddleware(videoChannelsNameWithHostValidator),
125 ensureIsLocalChannel,
126 ensureCanManageChannelOrAccount,
127 asyncMiddleware(videoChannelsRemoveValidator),
128 asyncRetryTransactionMiddleware(removeVideoChannel)
129)
130
131videoChannelRouter.get('/:nameWithHost',
132 asyncMiddleware(videoChannelsNameWithHostValidator),
133 asyncMiddleware(getVideoChannel)
134)
135
136videoChannelRouter.get('/:nameWithHost/video-playlists',
137 asyncMiddleware(videoChannelsNameWithHostValidator),
138 paginationValidator,
139 videoPlaylistsSortValidator,
140 setDefaultSort,
141 setDefaultPagination,
142 commonVideoPlaylistFiltersValidator,
143 asyncMiddleware(listVideoChannelPlaylists)
144)
145
146videoChannelRouter.get('/:nameWithHost/videos',
147 asyncMiddleware(videoChannelsNameWithHostValidator),
148 paginationValidator,
149 videosSortValidator,
150 setDefaultVideosSort,
151 setDefaultPagination,
152 optionalAuthenticate,
153 commonVideosFiltersValidator,
154 asyncMiddleware(listVideoChannelVideos)
155)
156
157videoChannelRouter.get('/:nameWithHost/followers',
158 authenticate,
159 asyncMiddleware(videoChannelsNameWithHostValidator),
160 ensureCanManageChannelOrAccount,
161 paginationValidator,
162 videoChannelsFollowersSortValidator,
163 setDefaultSort,
164 setDefaultPagination,
165 asyncMiddleware(listVideoChannelFollowers)
166)
167
168videoChannelRouter.post('/:nameWithHost/import-videos',
169 authenticate,
170 asyncMiddleware(videoChannelsNameWithHostValidator),
171 asyncMiddleware(videoChannelImportVideosValidator),
172 ensureIsLocalChannel,
173 ensureCanManageChannelOrAccount,
174 asyncMiddleware(ensureChannelOwnerCanUpload),
175 asyncMiddleware(importVideosInChannel)
176)
177
178// ---------------------------------------------------------------------------
179
180export {
181 videoChannelRouter
182}
183
184// ---------------------------------------------------------------------------
185
186async function listVideoChannels (req: express.Request, res: express.Response) {
187 const serverActor = await getServerActor()
188
189 const apiOptions = await Hooks.wrapObject({
190 actorId: serverActor.id,
191 start: req.query.start,
192 count: req.query.count,
193 sort: req.query.sort
194 }, 'filter:api.video-channels.list.params')
195
196 const resultList = await Hooks.wrapPromiseFun(
197 VideoChannelModel.listForApi,
198 apiOptions,
199 'filter:api.video-channels.list.result'
200 )
201
202 return res.json(getFormattedObjects(resultList.data, resultList.total))
203}
204
205async function updateVideoChannelBanner (req: express.Request, res: express.Response) {
206 const bannerPhysicalFile = req.files['bannerfile'][0]
207 const videoChannel = res.locals.videoChannel
208 const oldVideoChannelAuditKeys = new VideoChannelAuditView(videoChannel.toFormattedJSON())
209
210 const banners = await updateLocalActorImageFiles(videoChannel, bannerPhysicalFile, ActorImageType.BANNER)
211
212 auditLogger.update(getAuditIdFromRes(res), new VideoChannelAuditView(videoChannel.toFormattedJSON()), oldVideoChannelAuditKeys)
213
214 return res.json({
215 banners: banners.map(b => b.toFormattedJSON())
216 })
217}
218
219async function updateVideoChannelAvatar (req: express.Request, res: express.Response) {
220 const avatarPhysicalFile = req.files['avatarfile'][0]
221 const videoChannel = res.locals.videoChannel
222 const oldVideoChannelAuditKeys = new VideoChannelAuditView(videoChannel.toFormattedJSON())
223
224 const avatars = await updateLocalActorImageFiles(videoChannel, avatarPhysicalFile, ActorImageType.AVATAR)
225 auditLogger.update(getAuditIdFromRes(res), new VideoChannelAuditView(videoChannel.toFormattedJSON()), oldVideoChannelAuditKeys)
226
227 return res.json({
228 avatars: avatars.map(a => a.toFormattedJSON())
229 })
230}
231
232async function deleteVideoChannelAvatar (req: express.Request, res: express.Response) {
233 const videoChannel = res.locals.videoChannel
234
235 await deleteLocalActorImageFile(videoChannel, ActorImageType.AVATAR)
236
237 return res.status(HttpStatusCode.NO_CONTENT_204).end()
238}
239
240async function deleteVideoChannelBanner (req: express.Request, res: express.Response) {
241 const videoChannel = res.locals.videoChannel
242
243 await deleteLocalActorImageFile(videoChannel, ActorImageType.BANNER)
244
245 return res.status(HttpStatusCode.NO_CONTENT_204).end()
246}
247
248async function addVideoChannel (req: express.Request, res: express.Response) {
249 const videoChannelInfo: VideoChannelCreate = req.body
250
251 const videoChannelCreated = await sequelizeTypescript.transaction(async t => {
252 const account = await AccountModel.load(res.locals.oauth.token.User.Account.id, t)
253
254 return createLocalVideoChannel(videoChannelInfo, account, t)
255 })
256
257 const payload = { actorId: videoChannelCreated.actorId }
258 await JobQueue.Instance.createJob({ type: 'actor-keys', payload })
259
260 auditLogger.create(getAuditIdFromRes(res), new VideoChannelAuditView(videoChannelCreated.toFormattedJSON()))
261 logger.info('Video channel %s created.', videoChannelCreated.Actor.url)
262
263 Hooks.runAction('action:api.video-channel.created', { videoChannel: videoChannelCreated, req, res })
264
265 return res.json({
266 videoChannel: {
267 id: videoChannelCreated.id
268 }
269 })
270}
271
272async function updateVideoChannel (req: express.Request, res: express.Response) {
273 const videoChannelInstance = res.locals.videoChannel
274 const oldVideoChannelAuditKeys = new VideoChannelAuditView(videoChannelInstance.toFormattedJSON())
275 const videoChannelInfoToUpdate = req.body as VideoChannelUpdate
276 let doBulkVideoUpdate = false
277
278 try {
279 await sequelizeTypescript.transaction(async t => {
280 if (videoChannelInfoToUpdate.displayName !== undefined) videoChannelInstance.name = videoChannelInfoToUpdate.displayName
281 if (videoChannelInfoToUpdate.description !== undefined) videoChannelInstance.description = videoChannelInfoToUpdate.description
282
283 if (videoChannelInfoToUpdate.support !== undefined) {
284 const oldSupportField = videoChannelInstance.support
285 videoChannelInstance.support = videoChannelInfoToUpdate.support
286
287 if (videoChannelInfoToUpdate.bulkVideosSupportUpdate === true && oldSupportField !== videoChannelInfoToUpdate.support) {
288 doBulkVideoUpdate = true
289 await VideoModel.bulkUpdateSupportField(videoChannelInstance, t)
290 }
291 }
292
293 const videoChannelInstanceUpdated = await videoChannelInstance.save({ transaction: t }) as MChannelBannerAccountDefault
294 await sendUpdateActor(videoChannelInstanceUpdated, t)
295
296 auditLogger.update(
297 getAuditIdFromRes(res),
298 new VideoChannelAuditView(videoChannelInstanceUpdated.toFormattedJSON()),
299 oldVideoChannelAuditKeys
300 )
301
302 Hooks.runAction('action:api.video-channel.updated', { videoChannel: videoChannelInstanceUpdated, req, res })
303
304 logger.info('Video channel %s updated.', videoChannelInstance.Actor.url)
305 })
306 } catch (err) {
307 logger.debug('Cannot update the video channel.', { err })
308
309 // If the transaction is retried, sequelize will think the object has not changed
310 // So we need to restore the previous fields
311 await resetSequelizeInstance(videoChannelInstance)
312
313 throw err
314 }
315
316 res.type('json').status(HttpStatusCode.NO_CONTENT_204).end()
317
318 // Don't process in a transaction, and after the response because it could be long
319 if (doBulkVideoUpdate) {
320 await federateAllVideosOfChannel(videoChannelInstance)
321 }
322}
323
324async function removeVideoChannel (req: express.Request, res: express.Response) {
325 const videoChannelInstance = res.locals.videoChannel
326
327 await sequelizeTypescript.transaction(async t => {
328 await VideoPlaylistModel.resetPlaylistsOfChannel(videoChannelInstance.id, t)
329
330 await videoChannelInstance.destroy({ transaction: t })
331
332 Hooks.runAction('action:api.video-channel.deleted', { videoChannel: videoChannelInstance, req, res })
333
334 auditLogger.delete(getAuditIdFromRes(res), new VideoChannelAuditView(videoChannelInstance.toFormattedJSON()))
335 logger.info('Video channel %s deleted.', videoChannelInstance.Actor.url)
336 })
337
338 return res.type('json').status(HttpStatusCode.NO_CONTENT_204).end()
339}
340
341async function getVideoChannel (req: express.Request, res: express.Response) {
342 const id = res.locals.videoChannel.id
343 const videoChannel = await Hooks.wrapObject(res.locals.videoChannel, 'filter:api.video-channel.get.result', { id })
344
345 if (videoChannel.isOutdated()) {
346 JobQueue.Instance.createJobAsync({ type: 'activitypub-refresher', payload: { type: 'actor', url: videoChannel.Actor.url } })
347 }
348
349 return res.json(videoChannel.toFormattedJSON())
350}
351
352async function listVideoChannelPlaylists (req: express.Request, res: express.Response) {
353 const serverActor = await getServerActor()
354
355 const resultList = await VideoPlaylistModel.listForApi({
356 followerActorId: serverActor.id,
357 start: req.query.start,
358 count: req.query.count,
359 sort: req.query.sort,
360 videoChannelId: res.locals.videoChannel.id,
361 type: req.query.playlistType
362 })
363
364 return res.json(getFormattedObjects(resultList.data, resultList.total))
365}
366
367async function listVideoChannelVideos (req: express.Request, res: express.Response) {
368 const serverActor = await getServerActor()
369
370 const videoChannelInstance = res.locals.videoChannel
371
372 const displayOnlyForFollower = isUserAbleToSearchRemoteURI(res)
373 ? null
374 : {
375 actorId: serverActor.id,
376 orLocalVideos: true
377 }
378
379 const countVideos = getCountVideos(req)
380 const query = pickCommonVideoQuery(req.query)
381
382 const apiOptions = await Hooks.wrapObject({
383 ...query,
384
385 displayOnlyForFollower,
386 nsfw: buildNSFWFilter(res, query.nsfw),
387 videoChannelId: videoChannelInstance.id,
388 user: res.locals.oauth ? res.locals.oauth.token.User : undefined,
389 countVideos
390 }, 'filter:api.video-channels.videos.list.params')
391
392 const resultList = await Hooks.wrapPromiseFun(
393 VideoModel.listForApi,
394 apiOptions,
395 'filter:api.video-channels.videos.list.result'
396 )
397
398 return res.json(getFormattedObjects(resultList.data, resultList.total, guessAdditionalAttributesFromQuery(query)))
399}
400
401async function listVideoChannelFollowers (req: express.Request, res: express.Response) {
402 const channel = res.locals.videoChannel
403
404 const resultList = await ActorFollowModel.listFollowersForApi({
405 actorIds: [ channel.actorId ],
406 start: req.query.start,
407 count: req.query.count,
408 sort: req.query.sort,
409 search: req.query.search,
410 state: 'accepted'
411 })
412
413 return res.json(getFormattedObjects(resultList.data, resultList.total))
414}
415
416async function importVideosInChannel (req: express.Request, res: express.Response) {
417 const { externalChannelUrl } = req.body as VideosImportInChannelCreate
418
419 await JobQueue.Instance.createJob({
420 type: 'video-channel-import',
421 payload: {
422 externalChannelUrl,
423 videoChannelId: res.locals.videoChannel.id,
424 partOfChannelSyncId: res.locals.videoChannelSync?.id
425 }
426 })
427
428 logger.info('Video import job for channel "%s" with url "%s" created.', res.locals.videoChannel.name, externalChannelUrl)
429
430 return res.type('json').status(HttpStatusCode.NO_CONTENT_204).end()
431}
diff --git a/server/controllers/api/video-playlist.ts b/server/controllers/api/video-playlist.ts
deleted file mode 100644
index 73362e1e3..000000000
--- a/server/controllers/api/video-playlist.ts
+++ /dev/null
@@ -1,514 +0,0 @@
1import express from 'express'
2import { scheduleRefreshIfNeeded } from '@server/lib/activitypub/playlists'
3import { VideoMiniaturePermanentFileCache } from '@server/lib/files-cache'
4import { Hooks } from '@server/lib/plugins/hooks'
5import { getServerActor } from '@server/models/application/application'
6import { MVideoPlaylistFull, MVideoPlaylistThumbnail, MVideoThumbnail } from '@server/types/models'
7import { forceNumber } from '@shared/core-utils'
8import { uuidToShort } from '@shared/extra-utils'
9import { VideoPlaylistCreateResult, VideoPlaylistElementCreateResult } from '@shared/models'
10import { HttpStatusCode } from '../../../shared/models/http/http-error-codes'
11import { VideoPlaylistCreate } from '../../../shared/models/videos/playlist/video-playlist-create.model'
12import { VideoPlaylistElementCreate } from '../../../shared/models/videos/playlist/video-playlist-element-create.model'
13import { VideoPlaylistElementUpdate } from '../../../shared/models/videos/playlist/video-playlist-element-update.model'
14import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model'
15import { VideoPlaylistReorder } from '../../../shared/models/videos/playlist/video-playlist-reorder.model'
16import { VideoPlaylistUpdate } from '../../../shared/models/videos/playlist/video-playlist-update.model'
17import { resetSequelizeInstance } from '../../helpers/database-utils'
18import { createReqFiles } from '../../helpers/express-utils'
19import { logger } from '../../helpers/logger'
20import { getFormattedObjects } from '../../helpers/utils'
21import { MIMETYPES, VIDEO_PLAYLIST_PRIVACIES } from '../../initializers/constants'
22import { sequelizeTypescript } from '../../initializers/database'
23import { sendCreateVideoPlaylist, sendDeleteVideoPlaylist, sendUpdateVideoPlaylist } from '../../lib/activitypub/send'
24import { getLocalVideoPlaylistActivityPubUrl, getLocalVideoPlaylistElementActivityPubUrl } from '../../lib/activitypub/url'
25import { updateLocalPlaylistMiniatureFromExisting } from '../../lib/thumbnail'
26import {
27 apiRateLimiter,
28 asyncMiddleware,
29 asyncRetryTransactionMiddleware,
30 authenticate,
31 optionalAuthenticate,
32 paginationValidator,
33 setDefaultPagination,
34 setDefaultSort
35} from '../../middlewares'
36import { videoPlaylistsSortValidator } from '../../middlewares/validators'
37import {
38 commonVideoPlaylistFiltersValidator,
39 videoPlaylistsAddValidator,
40 videoPlaylistsAddVideoValidator,
41 videoPlaylistsDeleteValidator,
42 videoPlaylistsGetValidator,
43 videoPlaylistsReorderVideosValidator,
44 videoPlaylistsUpdateOrRemoveVideoValidator,
45 videoPlaylistsUpdateValidator
46} from '../../middlewares/validators/videos/video-playlists'
47import { AccountModel } from '../../models/account/account'
48import { VideoPlaylistModel } from '../../models/video/video-playlist'
49import { VideoPlaylistElementModel } from '../../models/video/video-playlist-element'
50
51const reqThumbnailFile = createReqFiles([ 'thumbnailfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT)
52
53const videoPlaylistRouter = express.Router()
54
55videoPlaylistRouter.use(apiRateLimiter)
56
57videoPlaylistRouter.get('/privacies', listVideoPlaylistPrivacies)
58
59videoPlaylistRouter.get('/',
60 paginationValidator,
61 videoPlaylistsSortValidator,
62 setDefaultSort,
63 setDefaultPagination,
64 commonVideoPlaylistFiltersValidator,
65 asyncMiddleware(listVideoPlaylists)
66)
67
68videoPlaylistRouter.get('/:playlistId',
69 asyncMiddleware(videoPlaylistsGetValidator('summary')),
70 getVideoPlaylist
71)
72
73videoPlaylistRouter.post('/',
74 authenticate,
75 reqThumbnailFile,
76 asyncMiddleware(videoPlaylistsAddValidator),
77 asyncRetryTransactionMiddleware(addVideoPlaylist)
78)
79
80videoPlaylistRouter.put('/:playlistId',
81 authenticate,
82 reqThumbnailFile,
83 asyncMiddleware(videoPlaylistsUpdateValidator),
84 asyncRetryTransactionMiddleware(updateVideoPlaylist)
85)
86
87videoPlaylistRouter.delete('/:playlistId',
88 authenticate,
89 asyncMiddleware(videoPlaylistsDeleteValidator),
90 asyncRetryTransactionMiddleware(removeVideoPlaylist)
91)
92
93videoPlaylistRouter.get('/:playlistId/videos',
94 asyncMiddleware(videoPlaylistsGetValidator('summary')),
95 paginationValidator,
96 setDefaultPagination,
97 optionalAuthenticate,
98 asyncMiddleware(getVideoPlaylistVideos)
99)
100
101videoPlaylistRouter.post('/:playlistId/videos',
102 authenticate,
103 asyncMiddleware(videoPlaylistsAddVideoValidator),
104 asyncRetryTransactionMiddleware(addVideoInPlaylist)
105)
106
107videoPlaylistRouter.post('/:playlistId/videos/reorder',
108 authenticate,
109 asyncMiddleware(videoPlaylistsReorderVideosValidator),
110 asyncRetryTransactionMiddleware(reorderVideosPlaylist)
111)
112
113videoPlaylistRouter.put('/:playlistId/videos/:playlistElementId',
114 authenticate,
115 asyncMiddleware(videoPlaylistsUpdateOrRemoveVideoValidator),
116 asyncRetryTransactionMiddleware(updateVideoPlaylistElement)
117)
118
119videoPlaylistRouter.delete('/:playlistId/videos/:playlistElementId',
120 authenticate,
121 asyncMiddleware(videoPlaylistsUpdateOrRemoveVideoValidator),
122 asyncRetryTransactionMiddleware(removeVideoFromPlaylist)
123)
124
125// ---------------------------------------------------------------------------
126
127export {
128 videoPlaylistRouter
129}
130
131// ---------------------------------------------------------------------------
132
133function listVideoPlaylistPrivacies (req: express.Request, res: express.Response) {
134 res.json(VIDEO_PLAYLIST_PRIVACIES)
135}
136
137async function listVideoPlaylists (req: express.Request, res: express.Response) {
138 const serverActor = await getServerActor()
139 const resultList = await VideoPlaylistModel.listForApi({
140 followerActorId: serverActor.id,
141 start: req.query.start,
142 count: req.query.count,
143 sort: req.query.sort,
144 type: req.query.playlistType
145 })
146
147 return res.json(getFormattedObjects(resultList.data, resultList.total))
148}
149
150function getVideoPlaylist (req: express.Request, res: express.Response) {
151 const videoPlaylist = res.locals.videoPlaylistSummary
152
153 scheduleRefreshIfNeeded(videoPlaylist)
154
155 return res.json(videoPlaylist.toFormattedJSON())
156}
157
158async function addVideoPlaylist (req: express.Request, res: express.Response) {
159 const videoPlaylistInfo: VideoPlaylistCreate = req.body
160 const user = res.locals.oauth.token.User
161
162 const videoPlaylist = new VideoPlaylistModel({
163 name: videoPlaylistInfo.displayName,
164 description: videoPlaylistInfo.description,
165 privacy: videoPlaylistInfo.privacy || VideoPlaylistPrivacy.PRIVATE,
166 ownerAccountId: user.Account.id
167 }) as MVideoPlaylistFull
168
169 videoPlaylist.url = getLocalVideoPlaylistActivityPubUrl(videoPlaylist) // We use the UUID, so set the URL after building the object
170
171 if (videoPlaylistInfo.videoChannelId) {
172 const videoChannel = res.locals.videoChannel
173
174 videoPlaylist.videoChannelId = videoChannel.id
175 videoPlaylist.VideoChannel = videoChannel
176 }
177
178 const thumbnailField = req.files['thumbnailfile']
179 const thumbnailModel = thumbnailField
180 ? await updateLocalPlaylistMiniatureFromExisting({
181 inputPath: thumbnailField[0].path,
182 playlist: videoPlaylist,
183 automaticallyGenerated: false
184 })
185 : undefined
186
187 const videoPlaylistCreated = await sequelizeTypescript.transaction(async t => {
188 const videoPlaylistCreated = await videoPlaylist.save({ transaction: t }) as MVideoPlaylistFull
189
190 if (thumbnailModel) {
191 thumbnailModel.automaticallyGenerated = false
192 await videoPlaylistCreated.setAndSaveThumbnail(thumbnailModel, t)
193 }
194
195 // We need more attributes for the federation
196 videoPlaylistCreated.OwnerAccount = await AccountModel.load(user.Account.id, t)
197 await sendCreateVideoPlaylist(videoPlaylistCreated, t)
198
199 return videoPlaylistCreated
200 })
201
202 logger.info('Video playlist with uuid %s created.', videoPlaylist.uuid)
203
204 return res.json({
205 videoPlaylist: {
206 id: videoPlaylistCreated.id,
207 shortUUID: uuidToShort(videoPlaylistCreated.uuid),
208 uuid: videoPlaylistCreated.uuid
209 } as VideoPlaylistCreateResult
210 })
211}
212
213async function updateVideoPlaylist (req: express.Request, res: express.Response) {
214 const videoPlaylistInstance = res.locals.videoPlaylistFull
215 const videoPlaylistInfoToUpdate = req.body as VideoPlaylistUpdate
216
217 const wasPrivatePlaylist = videoPlaylistInstance.privacy === VideoPlaylistPrivacy.PRIVATE
218 const wasNotPrivatePlaylist = videoPlaylistInstance.privacy !== VideoPlaylistPrivacy.PRIVATE
219
220 const thumbnailField = req.files['thumbnailfile']
221 const thumbnailModel = thumbnailField
222 ? await updateLocalPlaylistMiniatureFromExisting({
223 inputPath: thumbnailField[0].path,
224 playlist: videoPlaylistInstance,
225 automaticallyGenerated: false
226 })
227 : undefined
228
229 try {
230 await sequelizeTypescript.transaction(async t => {
231 const sequelizeOptions = {
232 transaction: t
233 }
234
235 if (videoPlaylistInfoToUpdate.videoChannelId !== undefined) {
236 if (videoPlaylistInfoToUpdate.videoChannelId === null) {
237 videoPlaylistInstance.videoChannelId = null
238 } else {
239 const videoChannel = res.locals.videoChannel
240
241 videoPlaylistInstance.videoChannelId = videoChannel.id
242 videoPlaylistInstance.VideoChannel = videoChannel
243 }
244 }
245
246 if (videoPlaylistInfoToUpdate.displayName !== undefined) videoPlaylistInstance.name = videoPlaylistInfoToUpdate.displayName
247 if (videoPlaylistInfoToUpdate.description !== undefined) videoPlaylistInstance.description = videoPlaylistInfoToUpdate.description
248
249 if (videoPlaylistInfoToUpdate.privacy !== undefined) {
250 videoPlaylistInstance.privacy = forceNumber(videoPlaylistInfoToUpdate.privacy)
251
252 if (wasNotPrivatePlaylist === true && videoPlaylistInstance.privacy === VideoPlaylistPrivacy.PRIVATE) {
253 await sendDeleteVideoPlaylist(videoPlaylistInstance, t)
254 }
255 }
256
257 const playlistUpdated = await videoPlaylistInstance.save(sequelizeOptions)
258
259 if (thumbnailModel) {
260 thumbnailModel.automaticallyGenerated = false
261 await playlistUpdated.setAndSaveThumbnail(thumbnailModel, t)
262 }
263
264 const isNewPlaylist = wasPrivatePlaylist && playlistUpdated.privacy !== VideoPlaylistPrivacy.PRIVATE
265
266 if (isNewPlaylist) {
267 await sendCreateVideoPlaylist(playlistUpdated, t)
268 } else {
269 await sendUpdateVideoPlaylist(playlistUpdated, t)
270 }
271
272 logger.info('Video playlist %s updated.', videoPlaylistInstance.uuid)
273
274 return playlistUpdated
275 })
276 } catch (err) {
277 logger.debug('Cannot update the video playlist.', { err })
278
279 // If the transaction is retried, sequelize will think the object has not changed
280 // So we need to restore the previous fields
281 await resetSequelizeInstance(videoPlaylistInstance)
282
283 throw err
284 }
285
286 return res.type('json').status(HttpStatusCode.NO_CONTENT_204).end()
287}
288
289async function removeVideoPlaylist (req: express.Request, res: express.Response) {
290 const videoPlaylistInstance = res.locals.videoPlaylistSummary
291
292 await sequelizeTypescript.transaction(async t => {
293 await videoPlaylistInstance.destroy({ transaction: t })
294
295 await sendDeleteVideoPlaylist(videoPlaylistInstance, t)
296
297 logger.info('Video playlist %s deleted.', videoPlaylistInstance.uuid)
298 })
299
300 return res.type('json').status(HttpStatusCode.NO_CONTENT_204).end()
301}
302
303async function addVideoInPlaylist (req: express.Request, res: express.Response) {
304 const body: VideoPlaylistElementCreate = req.body
305 const videoPlaylist = res.locals.videoPlaylistFull
306 const video = res.locals.onlyVideo
307
308 const playlistElement = await sequelizeTypescript.transaction(async t => {
309 const position = await VideoPlaylistElementModel.getNextPositionOf(videoPlaylist.id, t)
310
311 const playlistElement = await VideoPlaylistElementModel.create({
312 position,
313 startTimestamp: body.startTimestamp || null,
314 stopTimestamp: body.stopTimestamp || null,
315 videoPlaylistId: videoPlaylist.id,
316 videoId: video.id
317 }, { transaction: t })
318
319 playlistElement.url = getLocalVideoPlaylistElementActivityPubUrl(videoPlaylist, playlistElement)
320 await playlistElement.save({ transaction: t })
321
322 videoPlaylist.changed('updatedAt', true)
323 await videoPlaylist.save({ transaction: t })
324
325 return playlistElement
326 })
327
328 // If the user did not set a thumbnail, automatically take the video thumbnail
329 if (videoPlaylist.hasThumbnail() === false || (videoPlaylist.hasGeneratedThumbnail() && playlistElement.position === 1)) {
330 await generateThumbnailForPlaylist(videoPlaylist, video)
331 }
332
333 sendUpdateVideoPlaylist(videoPlaylist, undefined)
334 .catch(err => logger.error('Cannot send video playlist update.', { err }))
335
336 logger.info('Video added in playlist %s at position %d.', videoPlaylist.uuid, playlistElement.position)
337
338 Hooks.runAction('action:api.video-playlist-element.created', { playlistElement, req, res })
339
340 return res.json({
341 videoPlaylistElement: {
342 id: playlistElement.id
343 } as VideoPlaylistElementCreateResult
344 })
345}
346
347async function updateVideoPlaylistElement (req: express.Request, res: express.Response) {
348 const body: VideoPlaylistElementUpdate = req.body
349 const videoPlaylist = res.locals.videoPlaylistFull
350 const videoPlaylistElement = res.locals.videoPlaylistElement
351
352 const playlistElement: VideoPlaylistElementModel = await sequelizeTypescript.transaction(async t => {
353 if (body.startTimestamp !== undefined) videoPlaylistElement.startTimestamp = body.startTimestamp
354 if (body.stopTimestamp !== undefined) videoPlaylistElement.stopTimestamp = body.stopTimestamp
355
356 const element = await videoPlaylistElement.save({ transaction: t })
357
358 videoPlaylist.changed('updatedAt', true)
359 await videoPlaylist.save({ transaction: t })
360
361 await sendUpdateVideoPlaylist(videoPlaylist, t)
362
363 return element
364 })
365
366 logger.info('Element of position %d of playlist %s updated.', playlistElement.position, videoPlaylist.uuid)
367
368 return res.type('json').status(HttpStatusCode.NO_CONTENT_204).end()
369}
370
371async function removeVideoFromPlaylist (req: express.Request, res: express.Response) {
372 const videoPlaylistElement = res.locals.videoPlaylistElement
373 const videoPlaylist = res.locals.videoPlaylistFull
374 const positionToDelete = videoPlaylistElement.position
375
376 await sequelizeTypescript.transaction(async t => {
377 await videoPlaylistElement.destroy({ transaction: t })
378
379 // Decrease position of the next elements
380 await VideoPlaylistElementModel.increasePositionOf(videoPlaylist.id, positionToDelete, -1, t)
381
382 videoPlaylist.changed('updatedAt', true)
383 await videoPlaylist.save({ transaction: t })
384
385 logger.info('Video playlist element %d of playlist %s deleted.', videoPlaylistElement.position, videoPlaylist.uuid)
386 })
387
388 // Do we need to regenerate the default thumbnail?
389 if (positionToDelete === 1 && videoPlaylist.hasGeneratedThumbnail()) {
390 await regeneratePlaylistThumbnail(videoPlaylist)
391 }
392
393 sendUpdateVideoPlaylist(videoPlaylist, undefined)
394 .catch(err => logger.error('Cannot send video playlist update.', { err }))
395
396 return res.type('json').status(HttpStatusCode.NO_CONTENT_204).end()
397}
398
399async function reorderVideosPlaylist (req: express.Request, res: express.Response) {
400 const videoPlaylist = res.locals.videoPlaylistFull
401 const body: VideoPlaylistReorder = req.body
402
403 const start: number = body.startPosition
404 const insertAfter: number = body.insertAfterPosition
405 const reorderLength: number = body.reorderLength || 1
406
407 if (start === insertAfter) {
408 return res.status(HttpStatusCode.NO_CONTENT_204).end()
409 }
410
411 // Example: if we reorder position 2 and insert after position 5 (so at position 6): # 1 2 3 4 5 6 7 8 9
412 // * increase position when position > 5 # 1 2 3 4 5 7 8 9 10
413 // * update position 2 -> position 6 # 1 3 4 5 6 7 8 9 10
414 // * decrease position when position position > 2 # 1 2 3 4 5 6 7 8 9
415 await sequelizeTypescript.transaction(async t => {
416 const newPosition = insertAfter + 1
417
418 // Add space after the position when we want to insert our reordered elements (increase)
419 await VideoPlaylistElementModel.increasePositionOf(videoPlaylist.id, newPosition, reorderLength, t)
420
421 let oldPosition = start
422
423 // We incremented the position of the elements we want to reorder
424 if (start >= newPosition) oldPosition += reorderLength
425
426 const endOldPosition = oldPosition + reorderLength - 1
427 // Insert our reordered elements in their place (update)
428 await VideoPlaylistElementModel.reassignPositionOf({
429 videoPlaylistId: videoPlaylist.id,
430 firstPosition: oldPosition,
431 endPosition: endOldPosition,
432 newPosition,
433 transaction: t
434 })
435
436 // Decrease positions of elements after the old position of our ordered elements (decrease)
437 await VideoPlaylistElementModel.increasePositionOf(videoPlaylist.id, oldPosition, -reorderLength, t)
438
439 videoPlaylist.changed('updatedAt', true)
440 await videoPlaylist.save({ transaction: t })
441
442 await sendUpdateVideoPlaylist(videoPlaylist, t)
443 })
444
445 // The first element changed
446 if ((start === 1 || insertAfter === 0) && videoPlaylist.hasGeneratedThumbnail()) {
447 await regeneratePlaylistThumbnail(videoPlaylist)
448 }
449
450 logger.info(
451 'Reordered playlist %s (inserted after position %d elements %d - %d).',
452 videoPlaylist.uuid, insertAfter, start, start + reorderLength - 1
453 )
454
455 return res.type('json').status(HttpStatusCode.NO_CONTENT_204).end()
456}
457
458async function getVideoPlaylistVideos (req: express.Request, res: express.Response) {
459 const videoPlaylistInstance = res.locals.videoPlaylistSummary
460 const user = res.locals.oauth ? res.locals.oauth.token.User : undefined
461 const server = await getServerActor()
462
463 const apiOptions = await Hooks.wrapObject({
464 start: req.query.start,
465 count: req.query.count,
466 videoPlaylistId: videoPlaylistInstance.id,
467 serverAccount: server.Account,
468 user
469 }, 'filter:api.video-playlist.videos.list.params')
470
471 const resultList = await Hooks.wrapPromiseFun(
472 VideoPlaylistElementModel.listForApi,
473 apiOptions,
474 'filter:api.video-playlist.videos.list.result'
475 )
476
477 const options = { accountId: user?.Account?.id }
478 return res.json(getFormattedObjects(resultList.data, resultList.total, options))
479}
480
481async function regeneratePlaylistThumbnail (videoPlaylist: MVideoPlaylistThumbnail) {
482 await videoPlaylist.Thumbnail.destroy()
483 videoPlaylist.Thumbnail = null
484
485 const firstElement = await VideoPlaylistElementModel.loadFirstElementWithVideoThumbnail(videoPlaylist.id)
486 if (firstElement) await generateThumbnailForPlaylist(videoPlaylist, firstElement.Video)
487}
488
489async function generateThumbnailForPlaylist (videoPlaylist: MVideoPlaylistThumbnail, video: MVideoThumbnail) {
490 logger.info('Generating default thumbnail to playlist %s.', videoPlaylist.url)
491
492 const videoMiniature = video.getMiniature()
493 if (!videoMiniature) {
494 logger.info('Cannot generate thumbnail for playlist %s because video %s does not have any.', videoPlaylist.url, video.url)
495 return
496 }
497
498 // Ensure the file is on disk
499 const videoMiniaturePermanentFileCache = new VideoMiniaturePermanentFileCache()
500 const inputPath = videoMiniature.isOwned()
501 ? videoMiniature.getPath()
502 : await videoMiniaturePermanentFileCache.downloadRemoteFile(videoMiniature)
503
504 const thumbnailModel = await updateLocalPlaylistMiniatureFromExisting({
505 inputPath,
506 playlist: videoPlaylist,
507 automaticallyGenerated: true,
508 keepOriginal: true
509 })
510
511 thumbnailModel.videoPlaylistId = videoPlaylist.id
512
513 videoPlaylist.Thumbnail = await thumbnailModel.save()
514}
diff --git a/server/controllers/api/videos/blacklist.ts b/server/controllers/api/videos/blacklist.ts
deleted file mode 100644
index 4103bb063..000000000
--- a/server/controllers/api/videos/blacklist.ts
+++ /dev/null
@@ -1,112 +0,0 @@
1import express from 'express'
2import { blacklistVideo, unblacklistVideo } from '@server/lib/video-blacklist'
3import { HttpStatusCode, UserRight, VideoBlacklistCreate } from '@shared/models'
4import { logger } from '../../../helpers/logger'
5import { getFormattedObjects } from '../../../helpers/utils'
6import { sequelizeTypescript } from '../../../initializers/database'
7import {
8 asyncMiddleware,
9 authenticate,
10 blacklistSortValidator,
11 ensureUserHasRight,
12 openapiOperationDoc,
13 paginationValidator,
14 setBlacklistSort,
15 setDefaultPagination,
16 videosBlacklistAddValidator,
17 videosBlacklistFiltersValidator,
18 videosBlacklistRemoveValidator,
19 videosBlacklistUpdateValidator
20} from '../../../middlewares'
21import { VideoBlacklistModel } from '../../../models/video/video-blacklist'
22
23const blacklistRouter = express.Router()
24
25blacklistRouter.post('/:videoId/blacklist',
26 openapiOperationDoc({ operationId: 'addVideoBlock' }),
27 authenticate,
28 ensureUserHasRight(UserRight.MANAGE_VIDEO_BLACKLIST),
29 asyncMiddleware(videosBlacklistAddValidator),
30 asyncMiddleware(addVideoToBlacklistController)
31)
32
33blacklistRouter.get('/blacklist',
34 openapiOperationDoc({ operationId: 'getVideoBlocks' }),
35 authenticate,
36 ensureUserHasRight(UserRight.MANAGE_VIDEO_BLACKLIST),
37 paginationValidator,
38 blacklistSortValidator,
39 setBlacklistSort,
40 setDefaultPagination,
41 videosBlacklistFiltersValidator,
42 asyncMiddleware(listBlacklist)
43)
44
45blacklistRouter.put('/:videoId/blacklist',
46 authenticate,
47 ensureUserHasRight(UserRight.MANAGE_VIDEO_BLACKLIST),
48 asyncMiddleware(videosBlacklistUpdateValidator),
49 asyncMiddleware(updateVideoBlacklistController)
50)
51
52blacklistRouter.delete('/:videoId/blacklist',
53 openapiOperationDoc({ operationId: 'delVideoBlock' }),
54 authenticate,
55 ensureUserHasRight(UserRight.MANAGE_VIDEO_BLACKLIST),
56 asyncMiddleware(videosBlacklistRemoveValidator),
57 asyncMiddleware(removeVideoFromBlacklistController)
58)
59
60// ---------------------------------------------------------------------------
61
62export {
63 blacklistRouter
64}
65
66// ---------------------------------------------------------------------------
67
68async function addVideoToBlacklistController (req: express.Request, res: express.Response) {
69 const videoInstance = res.locals.videoAll
70 const body: VideoBlacklistCreate = req.body
71
72 await blacklistVideo(videoInstance, body)
73
74 logger.info('Video %s blacklisted.', videoInstance.uuid)
75
76 return res.type('json').status(HttpStatusCode.NO_CONTENT_204).end()
77}
78
79async function updateVideoBlacklistController (req: express.Request, res: express.Response) {
80 const videoBlacklist = res.locals.videoBlacklist
81
82 if (req.body.reason !== undefined) videoBlacklist.reason = req.body.reason
83
84 await sequelizeTypescript.transaction(t => {
85 return videoBlacklist.save({ transaction: t })
86 })
87
88 return res.type('json').status(HttpStatusCode.NO_CONTENT_204).end()
89}
90
91async function listBlacklist (req: express.Request, res: express.Response) {
92 const resultList = await VideoBlacklistModel.listForApi({
93 start: req.query.start,
94 count: req.query.count,
95 sort: req.query.sort,
96 search: req.query.search,
97 type: req.query.type
98 })
99
100 return res.json(getFormattedObjects(resultList.data, resultList.total))
101}
102
103async function removeVideoFromBlacklistController (req: express.Request, res: express.Response) {
104 const videoBlacklist = res.locals.videoBlacklist
105 const video = res.locals.videoAll
106
107 await unblacklistVideo(videoBlacklist, video)
108
109 logger.info('Video %s removed from blacklist.', video.uuid)
110
111 return res.type('json').status(HttpStatusCode.NO_CONTENT_204).end()
112}
diff --git a/server/controllers/api/videos/captions.ts b/server/controllers/api/videos/captions.ts
deleted file mode 100644
index 2b511a398..000000000
--- a/server/controllers/api/videos/captions.ts
+++ /dev/null
@@ -1,93 +0,0 @@
1import express from 'express'
2import { Hooks } from '@server/lib/plugins/hooks'
3import { MVideoCaption } from '@server/types/models'
4import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes'
5import { moveAndProcessCaptionFile } from '../../../helpers/captions-utils'
6import { createReqFiles } from '../../../helpers/express-utils'
7import { logger } from '../../../helpers/logger'
8import { getFormattedObjects } from '../../../helpers/utils'
9import { MIMETYPES } from '../../../initializers/constants'
10import { sequelizeTypescript } from '../../../initializers/database'
11import { federateVideoIfNeeded } from '../../../lib/activitypub/videos'
12import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate } from '../../../middlewares'
13import { addVideoCaptionValidator, deleteVideoCaptionValidator, listVideoCaptionsValidator } from '../../../middlewares/validators'
14import { VideoCaptionModel } from '../../../models/video/video-caption'
15
16const reqVideoCaptionAdd = createReqFiles([ 'captionfile' ], MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT)
17
18const videoCaptionsRouter = express.Router()
19
20videoCaptionsRouter.get('/:videoId/captions',
21 asyncMiddleware(listVideoCaptionsValidator),
22 asyncMiddleware(listVideoCaptions)
23)
24videoCaptionsRouter.put('/:videoId/captions/:captionLanguage',
25 authenticate,
26 reqVideoCaptionAdd,
27 asyncMiddleware(addVideoCaptionValidator),
28 asyncRetryTransactionMiddleware(addVideoCaption)
29)
30videoCaptionsRouter.delete('/:videoId/captions/:captionLanguage',
31 authenticate,
32 asyncMiddleware(deleteVideoCaptionValidator),
33 asyncRetryTransactionMiddleware(deleteVideoCaption)
34)
35
36// ---------------------------------------------------------------------------
37
38export {
39 videoCaptionsRouter
40}
41
42// ---------------------------------------------------------------------------
43
44async function listVideoCaptions (req: express.Request, res: express.Response) {
45 const data = await VideoCaptionModel.listVideoCaptions(res.locals.onlyVideo.id)
46
47 return res.json(getFormattedObjects(data, data.length))
48}
49
50async function addVideoCaption (req: express.Request, res: express.Response) {
51 const videoCaptionPhysicalFile = req.files['captionfile'][0]
52 const video = res.locals.videoAll
53
54 const captionLanguage = req.params.captionLanguage
55
56 const videoCaption = new VideoCaptionModel({
57 videoId: video.id,
58 filename: VideoCaptionModel.generateCaptionName(captionLanguage),
59 language: captionLanguage
60 }) as MVideoCaption
61
62 // Move physical file
63 await moveAndProcessCaptionFile(videoCaptionPhysicalFile, videoCaption)
64
65 await sequelizeTypescript.transaction(async t => {
66 await VideoCaptionModel.insertOrReplaceLanguage(videoCaption, t)
67
68 // Update video update
69 await federateVideoIfNeeded(video, false, t)
70 })
71
72 Hooks.runAction('action:api.video-caption.created', { caption: videoCaption, req, res })
73
74 return res.status(HttpStatusCode.NO_CONTENT_204).end()
75}
76
77async function deleteVideoCaption (req: express.Request, res: express.Response) {
78 const video = res.locals.videoAll
79 const videoCaption = res.locals.videoCaption
80
81 await sequelizeTypescript.transaction(async t => {
82 await videoCaption.destroy({ transaction: t })
83
84 // Send video update
85 await federateVideoIfNeeded(video, false, t)
86 })
87
88 logger.info('Video caption %s of video %s deleted.', videoCaption.language, video.uuid)
89
90 Hooks.runAction('action:api.video-caption.deleted', { caption: videoCaption, req, res })
91
92 return res.type('json').status(HttpStatusCode.NO_CONTENT_204).end()
93}
diff --git a/server/controllers/api/videos/comment.ts b/server/controllers/api/videos/comment.ts
deleted file mode 100644
index 70ca21500..000000000
--- a/server/controllers/api/videos/comment.ts
+++ /dev/null
@@ -1,234 +0,0 @@
1import { MCommentFormattable } from '@server/types/models'
2import express from 'express'
3
4import { ResultList, ThreadsResultList, UserRight, VideoCommentCreate } from '../../../../shared/models'
5import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes'
6import { VideoCommentThreads } from '../../../../shared/models/videos/comment/video-comment.model'
7import { auditLoggerFactory, CommentAuditView, getAuditIdFromRes } from '../../../helpers/audit-logger'
8import { getFormattedObjects } from '../../../helpers/utils'
9import { sequelizeTypescript } from '../../../initializers/database'
10import { Notifier } from '../../../lib/notifier'
11import { Hooks } from '../../../lib/plugins/hooks'
12import { buildFormattedCommentTree, createVideoComment, removeComment } from '../../../lib/video-comment'
13import {
14 asyncMiddleware,
15 asyncRetryTransactionMiddleware,
16 authenticate,
17 ensureUserHasRight,
18 optionalAuthenticate,
19 paginationValidator,
20 setDefaultPagination,
21 setDefaultSort
22} from '../../../middlewares'
23import {
24 addVideoCommentReplyValidator,
25 addVideoCommentThreadValidator,
26 listVideoCommentsValidator,
27 listVideoCommentThreadsValidator,
28 listVideoThreadCommentsValidator,
29 removeVideoCommentValidator,
30 videoCommentsValidator,
31 videoCommentThreadsSortValidator
32} from '../../../middlewares/validators'
33import { AccountModel } from '../../../models/account/account'
34import { VideoCommentModel } from '../../../models/video/video-comment'
35
36const auditLogger = auditLoggerFactory('comments')
37const videoCommentRouter = express.Router()
38
39videoCommentRouter.get('/:videoId/comment-threads',
40 paginationValidator,
41 videoCommentThreadsSortValidator,
42 setDefaultSort,
43 setDefaultPagination,
44 asyncMiddleware(listVideoCommentThreadsValidator),
45 optionalAuthenticate,
46 asyncMiddleware(listVideoThreads)
47)
48videoCommentRouter.get('/:videoId/comment-threads/:threadId',
49 asyncMiddleware(listVideoThreadCommentsValidator),
50 optionalAuthenticate,
51 asyncMiddleware(listVideoThreadComments)
52)
53
54videoCommentRouter.post('/:videoId/comment-threads',
55 authenticate,
56 asyncMiddleware(addVideoCommentThreadValidator),
57 asyncRetryTransactionMiddleware(addVideoCommentThread)
58)
59videoCommentRouter.post('/:videoId/comments/:commentId',
60 authenticate,
61 asyncMiddleware(addVideoCommentReplyValidator),
62 asyncRetryTransactionMiddleware(addVideoCommentReply)
63)
64videoCommentRouter.delete('/:videoId/comments/:commentId',
65 authenticate,
66 asyncMiddleware(removeVideoCommentValidator),
67 asyncRetryTransactionMiddleware(removeVideoComment)
68)
69
70videoCommentRouter.get('/comments',
71 authenticate,
72 ensureUserHasRight(UserRight.SEE_ALL_COMMENTS),
73 paginationValidator,
74 videoCommentsValidator,
75 setDefaultSort,
76 setDefaultPagination,
77 listVideoCommentsValidator,
78 asyncMiddleware(listComments)
79)
80
81// ---------------------------------------------------------------------------
82
83export {
84 videoCommentRouter
85}
86
87// ---------------------------------------------------------------------------
88
89async function listComments (req: express.Request, res: express.Response) {
90 const options = {
91 start: req.query.start,
92 count: req.query.count,
93 sort: req.query.sort,
94
95 isLocal: req.query.isLocal,
96 onLocalVideo: req.query.onLocalVideo,
97 search: req.query.search,
98 searchAccount: req.query.searchAccount,
99 searchVideo: req.query.searchVideo
100 }
101
102 const resultList = await VideoCommentModel.listCommentsForApi(options)
103
104 return res.json({
105 total: resultList.total,
106 data: resultList.data.map(c => c.toFormattedAdminJSON())
107 })
108}
109
110async function listVideoThreads (req: express.Request, res: express.Response) {
111 const video = res.locals.onlyVideo
112 const user = res.locals.oauth ? res.locals.oauth.token.User : undefined
113
114 let resultList: ThreadsResultList<MCommentFormattable>
115
116 if (video.commentsEnabled === true) {
117 const apiOptions = await Hooks.wrapObject({
118 videoId: video.id,
119 isVideoOwned: video.isOwned(),
120 start: req.query.start,
121 count: req.query.count,
122 sort: req.query.sort,
123 user
124 }, 'filter:api.video-threads.list.params')
125
126 resultList = await Hooks.wrapPromiseFun(
127 VideoCommentModel.listThreadsForApi,
128 apiOptions,
129 'filter:api.video-threads.list.result'
130 )
131 } else {
132 resultList = {
133 total: 0,
134 totalNotDeletedComments: 0,
135 data: []
136 }
137 }
138
139 return res.json({
140 ...getFormattedObjects(resultList.data, resultList.total),
141 totalNotDeletedComments: resultList.totalNotDeletedComments
142 } as VideoCommentThreads)
143}
144
145async function listVideoThreadComments (req: express.Request, res: express.Response) {
146 const video = res.locals.onlyVideo
147 const user = res.locals.oauth ? res.locals.oauth.token.User : undefined
148
149 let resultList: ResultList<MCommentFormattable>
150
151 if (video.commentsEnabled === true) {
152 const apiOptions = await Hooks.wrapObject({
153 videoId: video.id,
154 threadId: res.locals.videoCommentThread.id,
155 user
156 }, 'filter:api.video-thread-comments.list.params')
157
158 resultList = await Hooks.wrapPromiseFun(
159 VideoCommentModel.listThreadCommentsForApi,
160 apiOptions,
161 'filter:api.video-thread-comments.list.result'
162 )
163 } else {
164 resultList = {
165 total: 0,
166 data: []
167 }
168 }
169
170 if (resultList.data.length === 0) {
171 return res.fail({
172 status: HttpStatusCode.NOT_FOUND_404,
173 message: 'No comments were found'
174 })
175 }
176
177 return res.json(buildFormattedCommentTree(resultList))
178}
179
180async function addVideoCommentThread (req: express.Request, res: express.Response) {
181 const videoCommentInfo: VideoCommentCreate = req.body
182
183 const comment = await sequelizeTypescript.transaction(async t => {
184 const account = await AccountModel.load(res.locals.oauth.token.User.Account.id, t)
185
186 return createVideoComment({
187 text: videoCommentInfo.text,
188 inReplyToComment: null,
189 video: res.locals.videoAll,
190 account
191 }, t)
192 })
193
194 Notifier.Instance.notifyOnNewComment(comment)
195 auditLogger.create(getAuditIdFromRes(res), new CommentAuditView(comment.toFormattedJSON()))
196
197 Hooks.runAction('action:api.video-thread.created', { comment, req, res })
198
199 return res.json({ comment: comment.toFormattedJSON() })
200}
201
202async function addVideoCommentReply (req: express.Request, res: express.Response) {
203 const videoCommentInfo: VideoCommentCreate = req.body
204
205 const comment = await sequelizeTypescript.transaction(async t => {
206 const account = await AccountModel.load(res.locals.oauth.token.User.Account.id, t)
207
208 return createVideoComment({
209 text: videoCommentInfo.text,
210 inReplyToComment: res.locals.videoCommentFull,
211 video: res.locals.videoAll,
212 account
213 }, t)
214 })
215
216 Notifier.Instance.notifyOnNewComment(comment)
217 auditLogger.create(getAuditIdFromRes(res), new CommentAuditView(comment.toFormattedJSON()))
218
219 Hooks.runAction('action:api.video-comment-reply.created', { comment, req, res })
220
221 return res.json({ comment: comment.toFormattedJSON() })
222}
223
224async function removeVideoComment (req: express.Request, res: express.Response) {
225 const videoCommentInstance = res.locals.videoCommentFull
226
227 await removeComment(videoCommentInstance, req, res)
228
229 auditLogger.delete(getAuditIdFromRes(res), new CommentAuditView(videoCommentInstance.toFormattedJSON()))
230
231 return res.type('json')
232 .status(HttpStatusCode.NO_CONTENT_204)
233 .end()
234}
diff --git a/server/controllers/api/videos/files.ts b/server/controllers/api/videos/files.ts
deleted file mode 100644
index 67b60ff63..000000000
--- a/server/controllers/api/videos/files.ts
+++ /dev/null
@@ -1,122 +0,0 @@
1import express from 'express'
2import toInt from 'validator/lib/toInt'
3import { logger, loggerTagsFactory } from '@server/helpers/logger'
4import { federateVideoIfNeeded } from '@server/lib/activitypub/videos'
5import { updatePlaylistAfterFileChange } from '@server/lib/hls'
6import { removeAllWebVideoFiles, removeHLSFile, removeHLSPlaylist, removeWebVideoFile } from '@server/lib/video-file'
7import { VideoFileModel } from '@server/models/video/video-file'
8import { HttpStatusCode, UserRight } from '@shared/models'
9import {
10 asyncMiddleware,
11 authenticate,
12 ensureUserHasRight,
13 videoFileMetadataGetValidator,
14 videoFilesDeleteHLSFileValidator,
15 videoFilesDeleteHLSValidator,
16 videoFilesDeleteWebVideoFileValidator,
17 videoFilesDeleteWebVideoValidator,
18 videosGetValidator
19} from '../../../middlewares'
20
21const lTags = loggerTagsFactory('api', 'video')
22const filesRouter = express.Router()
23
24filesRouter.get('/:id/metadata/:videoFileId',
25 asyncMiddleware(videosGetValidator),
26 asyncMiddleware(videoFileMetadataGetValidator),
27 asyncMiddleware(getVideoFileMetadata)
28)
29
30filesRouter.delete('/:id/hls',
31 authenticate,
32 ensureUserHasRight(UserRight.MANAGE_VIDEO_FILES),
33 asyncMiddleware(videoFilesDeleteHLSValidator),
34 asyncMiddleware(removeHLSPlaylistController)
35)
36filesRouter.delete('/:id/hls/:videoFileId',
37 authenticate,
38 ensureUserHasRight(UserRight.MANAGE_VIDEO_FILES),
39 asyncMiddleware(videoFilesDeleteHLSFileValidator),
40 asyncMiddleware(removeHLSFileController)
41)
42
43filesRouter.delete(
44 [ '/:id/webtorrent', '/:id/web-videos' ], // TODO: remove webtorrent in V7
45 authenticate,
46 ensureUserHasRight(UserRight.MANAGE_VIDEO_FILES),
47 asyncMiddleware(videoFilesDeleteWebVideoValidator),
48 asyncMiddleware(removeAllWebVideoFilesController)
49)
50filesRouter.delete(
51 [ '/:id/webtorrent/:videoFileId', '/:id/web-videos/:videoFileId' ], // TODO: remove webtorrent in V7
52 authenticate,
53 ensureUserHasRight(UserRight.MANAGE_VIDEO_FILES),
54 asyncMiddleware(videoFilesDeleteWebVideoFileValidator),
55 asyncMiddleware(removeWebVideoFileController)
56)
57
58// ---------------------------------------------------------------------------
59
60export {
61 filesRouter
62}
63
64// ---------------------------------------------------------------------------
65
66async function getVideoFileMetadata (req: express.Request, res: express.Response) {
67 const videoFile = await VideoFileModel.loadWithMetadata(toInt(req.params.videoFileId))
68
69 return res.json(videoFile.metadata)
70}
71
72// ---------------------------------------------------------------------------
73
74async function removeHLSPlaylistController (req: express.Request, res: express.Response) {
75 const video = res.locals.videoAll
76
77 logger.info('Deleting HLS playlist of %s.', video.url, lTags(video.uuid))
78 await removeHLSPlaylist(video)
79
80 await federateVideoIfNeeded(video, false, undefined)
81
82 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
83}
84
85async function removeHLSFileController (req: express.Request, res: express.Response) {
86 const video = res.locals.videoAll
87 const videoFileId = +req.params.videoFileId
88
89 logger.info('Deleting HLS file %d of %s.', videoFileId, video.url, lTags(video.uuid))
90
91 const playlist = await removeHLSFile(video, videoFileId)
92 if (playlist) await updatePlaylistAfterFileChange(video, playlist)
93
94 await federateVideoIfNeeded(video, false, undefined)
95
96 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
97}
98
99// ---------------------------------------------------------------------------
100
101async function removeAllWebVideoFilesController (req: express.Request, res: express.Response) {
102 const video = res.locals.videoAll
103
104 logger.info('Deleting Web Video files of %s.', video.url, lTags(video.uuid))
105
106 await removeAllWebVideoFiles(video)
107 await federateVideoIfNeeded(video, false, undefined)
108
109 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
110}
111
112async function removeWebVideoFileController (req: express.Request, res: express.Response) {
113 const video = res.locals.videoAll
114
115 const videoFileId = +req.params.videoFileId
116 logger.info('Deleting Web Video file %d of %s.', videoFileId, video.url, lTags(video.uuid))
117
118 await removeWebVideoFile(video, videoFileId)
119 await federateVideoIfNeeded(video, false, undefined)
120
121 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
122}
diff --git a/server/controllers/api/videos/import.ts b/server/controllers/api/videos/import.ts
deleted file mode 100644
index defe9efd4..000000000
--- a/server/controllers/api/videos/import.ts
+++ /dev/null
@@ -1,262 +0,0 @@
1import express from 'express'
2import { move, readFile } from 'fs-extra'
3import { decode } from 'magnet-uri'
4import parseTorrent, { Instance } from 'parse-torrent'
5import { join } from 'path'
6import { buildYoutubeDLImport, buildVideoFromImport, insertFromImportIntoDB, YoutubeDlImportError } from '@server/lib/video-pre-import'
7import { MThumbnail, MVideoThumbnail } from '@server/types/models'
8import { HttpStatusCode, ServerErrorCode, ThumbnailType, VideoImportCreate, VideoImportPayload, VideoImportState } from '@shared/models'
9import { auditLoggerFactory, getAuditIdFromRes, VideoImportAuditView } from '../../../helpers/audit-logger'
10import { isArray } from '../../../helpers/custom-validators/misc'
11import { cleanUpReqFiles, createReqFiles } from '../../../helpers/express-utils'
12import { logger } from '../../../helpers/logger'
13import { getSecureTorrentName } from '../../../helpers/utils'
14import { CONFIG } from '../../../initializers/config'
15import { MIMETYPES } from '../../../initializers/constants'
16import { JobQueue } from '../../../lib/job-queue/job-queue'
17import { updateLocalVideoMiniatureFromExisting } from '../../../lib/thumbnail'
18import {
19 asyncMiddleware,
20 asyncRetryTransactionMiddleware,
21 authenticate,
22 videoImportAddValidator,
23 videoImportCancelValidator,
24 videoImportDeleteValidator
25} from '../../../middlewares'
26
27const auditLogger = auditLoggerFactory('video-imports')
28const videoImportsRouter = express.Router()
29
30const reqVideoFileImport = createReqFiles(
31 [ 'thumbnailfile', 'previewfile', 'torrentfile' ],
32 { ...MIMETYPES.TORRENT.MIMETYPE_EXT, ...MIMETYPES.IMAGE.MIMETYPE_EXT }
33)
34
35videoImportsRouter.post('/imports',
36 authenticate,
37 reqVideoFileImport,
38 asyncMiddleware(videoImportAddValidator),
39 asyncRetryTransactionMiddleware(handleVideoImport)
40)
41
42videoImportsRouter.post('/imports/:id/cancel',
43 authenticate,
44 asyncMiddleware(videoImportCancelValidator),
45 asyncRetryTransactionMiddleware(cancelVideoImport)
46)
47
48videoImportsRouter.delete('/imports/:id',
49 authenticate,
50 asyncMiddleware(videoImportDeleteValidator),
51 asyncRetryTransactionMiddleware(deleteVideoImport)
52)
53
54// ---------------------------------------------------------------------------
55
56export {
57 videoImportsRouter
58}
59
60// ---------------------------------------------------------------------------
61
62async function deleteVideoImport (req: express.Request, res: express.Response) {
63 const videoImport = res.locals.videoImport
64
65 await videoImport.destroy()
66
67 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
68}
69
70async function cancelVideoImport (req: express.Request, res: express.Response) {
71 const videoImport = res.locals.videoImport
72
73 videoImport.state = VideoImportState.CANCELLED
74 await videoImport.save()
75
76 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
77}
78
79function handleVideoImport (req: express.Request, res: express.Response) {
80 if (req.body.targetUrl) return handleYoutubeDlImport(req, res)
81
82 const file = req.files?.['torrentfile']?.[0]
83 if (req.body.magnetUri || file) return handleTorrentImport(req, res, file)
84}
85
86async function handleTorrentImport (req: express.Request, res: express.Response, torrentfile: Express.Multer.File) {
87 const body: VideoImportCreate = req.body
88 const user = res.locals.oauth.token.User
89
90 let videoName: string
91 let torrentName: string
92 let magnetUri: string
93
94 if (torrentfile) {
95 const result = await processTorrentOrAbortRequest(req, res, torrentfile)
96 if (!result) return
97
98 videoName = result.name
99 torrentName = result.torrentName
100 } else {
101 const result = processMagnetURI(body)
102 magnetUri = result.magnetUri
103 videoName = result.name
104 }
105
106 const video = await buildVideoFromImport({
107 channelId: res.locals.videoChannel.id,
108 importData: { name: videoName },
109 importDataOverride: body,
110 importType: 'torrent'
111 })
112
113 const thumbnailModel = await processThumbnail(req, video)
114 const previewModel = await processPreview(req, video)
115
116 const videoImport = await insertFromImportIntoDB({
117 video,
118 thumbnailModel,
119 previewModel,
120 videoChannel: res.locals.videoChannel,
121 tags: body.tags || undefined,
122 user,
123 videoPasswords: body.videoPasswords,
124 videoImportAttributes: {
125 magnetUri,
126 torrentName,
127 state: VideoImportState.PENDING,
128 userId: user.id
129 }
130 })
131
132 const payload: VideoImportPayload = {
133 type: torrentfile
134 ? 'torrent-file'
135 : 'magnet-uri',
136 videoImportId: videoImport.id,
137 preventException: false
138 }
139 await JobQueue.Instance.createJob({ type: 'video-import', payload })
140
141 auditLogger.create(getAuditIdFromRes(res), new VideoImportAuditView(videoImport.toFormattedJSON()))
142
143 return res.json(videoImport.toFormattedJSON()).end()
144}
145
146function statusFromYtDlImportError (err: YoutubeDlImportError): number {
147 switch (err.code) {
148 case YoutubeDlImportError.CODE.NOT_ONLY_UNICAST_URL:
149 return HttpStatusCode.FORBIDDEN_403
150
151 case YoutubeDlImportError.CODE.FETCH_ERROR:
152 return HttpStatusCode.BAD_REQUEST_400
153
154 default:
155 return HttpStatusCode.INTERNAL_SERVER_ERROR_500
156 }
157}
158
159async function handleYoutubeDlImport (req: express.Request, res: express.Response) {
160 const body: VideoImportCreate = req.body
161 const targetUrl = body.targetUrl
162 const user = res.locals.oauth.token.User
163
164 try {
165 const { job, videoImport } = await buildYoutubeDLImport({
166 targetUrl,
167 channel: res.locals.videoChannel,
168 importDataOverride: body,
169 thumbnailFilePath: req.files?.['thumbnailfile']?.[0].path,
170 previewFilePath: req.files?.['previewfile']?.[0].path,
171 user
172 })
173 await JobQueue.Instance.createJob(job)
174
175 auditLogger.create(getAuditIdFromRes(res), new VideoImportAuditView(videoImport.toFormattedJSON()))
176
177 return res.json(videoImport.toFormattedJSON()).end()
178 } catch (err) {
179 logger.error('An error occurred while importing the video %s. ', targetUrl, { err })
180
181 return res.fail({
182 message: err.message,
183 status: statusFromYtDlImportError(err),
184 data: {
185 targetUrl
186 }
187 })
188 }
189}
190
191async function processThumbnail (req: express.Request, video: MVideoThumbnail) {
192 const thumbnailField = req.files ? req.files['thumbnailfile'] : undefined
193 if (thumbnailField) {
194 const thumbnailPhysicalFile = thumbnailField[0]
195
196 return updateLocalVideoMiniatureFromExisting({
197 inputPath: thumbnailPhysicalFile.path,
198 video,
199 type: ThumbnailType.MINIATURE,
200 automaticallyGenerated: false
201 })
202 }
203
204 return undefined
205}
206
207async function processPreview (req: express.Request, video: MVideoThumbnail): Promise<MThumbnail> {
208 const previewField = req.files ? req.files['previewfile'] : undefined
209 if (previewField) {
210 const previewPhysicalFile = previewField[0]
211
212 return updateLocalVideoMiniatureFromExisting({
213 inputPath: previewPhysicalFile.path,
214 video,
215 type: ThumbnailType.PREVIEW,
216 automaticallyGenerated: false
217 })
218 }
219
220 return undefined
221}
222
223async function processTorrentOrAbortRequest (req: express.Request, res: express.Response, torrentfile: Express.Multer.File) {
224 const torrentName = torrentfile.originalname
225
226 // Rename the torrent to a secured name
227 const newTorrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, getSecureTorrentName(torrentName))
228 await move(torrentfile.path, newTorrentPath, { overwrite: true })
229 torrentfile.path = newTorrentPath
230
231 const buf = await readFile(torrentfile.path)
232 const parsedTorrent = parseTorrent(buf) as Instance
233
234 if (parsedTorrent.files.length !== 1) {
235 cleanUpReqFiles(req)
236
237 res.fail({
238 type: ServerErrorCode.INCORRECT_FILES_IN_TORRENT,
239 message: 'Torrents with only 1 file are supported.'
240 })
241 return undefined
242 }
243
244 return {
245 name: extractNameFromArray(parsedTorrent.name),
246 torrentName
247 }
248}
249
250function processMagnetURI (body: VideoImportCreate) {
251 const magnetUri = body.magnetUri
252 const parsed = decode(magnetUri)
253
254 return {
255 name: extractNameFromArray(parsed.name),
256 magnetUri
257 }
258}
259
260function extractNameFromArray (name: string | string[]) {
261 return isArray(name) ? name[0] : name
262}
diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts
deleted file mode 100644
index 3cdd42289..000000000
--- a/server/controllers/api/videos/index.ts
+++ /dev/null
@@ -1,228 +0,0 @@
1import express from 'express'
2import { pickCommonVideoQuery } from '@server/helpers/query'
3import { doJSONRequest } from '@server/helpers/requests'
4import { openapiOperationDoc } from '@server/middlewares/doc'
5import { getServerActor } from '@server/models/application/application'
6import { MVideoAccountLight } from '@server/types/models'
7import { HttpStatusCode } from '../../../../shared/models'
8import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
9import { buildNSFWFilter, getCountVideos } from '../../../helpers/express-utils'
10import { logger } from '../../../helpers/logger'
11import { getFormattedObjects } from '../../../helpers/utils'
12import { REMOTE_SCHEME, VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES, VIDEO_PRIVACIES } from '../../../initializers/constants'
13import { sequelizeTypescript } from '../../../initializers/database'
14import { JobQueue } from '../../../lib/job-queue'
15import { Hooks } from '../../../lib/plugins/hooks'
16import {
17 apiRateLimiter,
18 asyncMiddleware,
19 asyncRetryTransactionMiddleware,
20 authenticate,
21 checkVideoFollowConstraints,
22 commonVideosFiltersValidator,
23 optionalAuthenticate,
24 paginationValidator,
25 setDefaultPagination,
26 setDefaultVideosSort,
27 videosCustomGetValidator,
28 videosGetValidator,
29 videosRemoveValidator,
30 videosSortValidator
31} from '../../../middlewares'
32import { guessAdditionalAttributesFromQuery } from '../../../models/video/formatter'
33import { VideoModel } from '../../../models/video/video'
34import { blacklistRouter } from './blacklist'
35import { videoCaptionsRouter } from './captions'
36import { videoCommentRouter } from './comment'
37import { filesRouter } from './files'
38import { videoImportsRouter } from './import'
39import { liveRouter } from './live'
40import { ownershipVideoRouter } from './ownership'
41import { videoPasswordRouter } from './passwords'
42import { rateVideoRouter } from './rate'
43import { videoSourceRouter } from './source'
44import { statsRouter } from './stats'
45import { storyboardRouter } from './storyboard'
46import { studioRouter } from './studio'
47import { tokenRouter } from './token'
48import { transcodingRouter } from './transcoding'
49import { updateRouter } from './update'
50import { uploadRouter } from './upload'
51import { viewRouter } from './view'
52
53const auditLogger = auditLoggerFactory('videos')
54const videosRouter = express.Router()
55
56videosRouter.use(apiRateLimiter)
57
58videosRouter.use('/', blacklistRouter)
59videosRouter.use('/', statsRouter)
60videosRouter.use('/', rateVideoRouter)
61videosRouter.use('/', videoCommentRouter)
62videosRouter.use('/', studioRouter)
63videosRouter.use('/', videoCaptionsRouter)
64videosRouter.use('/', videoImportsRouter)
65videosRouter.use('/', ownershipVideoRouter)
66videosRouter.use('/', viewRouter)
67videosRouter.use('/', liveRouter)
68videosRouter.use('/', uploadRouter)
69videosRouter.use('/', updateRouter)
70videosRouter.use('/', filesRouter)
71videosRouter.use('/', transcodingRouter)
72videosRouter.use('/', tokenRouter)
73videosRouter.use('/', videoPasswordRouter)
74videosRouter.use('/', storyboardRouter)
75videosRouter.use('/', videoSourceRouter)
76
77videosRouter.get('/categories',
78 openapiOperationDoc({ operationId: 'getCategories' }),
79 listVideoCategories
80)
81videosRouter.get('/licences',
82 openapiOperationDoc({ operationId: 'getLicences' }),
83 listVideoLicences
84)
85videosRouter.get('/languages',
86 openapiOperationDoc({ operationId: 'getLanguages' }),
87 listVideoLanguages
88)
89videosRouter.get('/privacies',
90 openapiOperationDoc({ operationId: 'getPrivacies' }),
91 listVideoPrivacies
92)
93
94videosRouter.get('/',
95 openapiOperationDoc({ operationId: 'getVideos' }),
96 paginationValidator,
97 videosSortValidator,
98 setDefaultVideosSort,
99 setDefaultPagination,
100 optionalAuthenticate,
101 commonVideosFiltersValidator,
102 asyncMiddleware(listVideos)
103)
104
105// TODO: remove, deprecated in 5.0 now we send the complete description in VideoDetails
106videosRouter.get('/:id/description',
107 openapiOperationDoc({ operationId: 'getVideoDesc' }),
108 asyncMiddleware(videosGetValidator),
109 asyncMiddleware(getVideoDescription)
110)
111
112videosRouter.get('/:id',
113 openapiOperationDoc({ operationId: 'getVideo' }),
114 optionalAuthenticate,
115 asyncMiddleware(videosCustomGetValidator('for-api')),
116 asyncMiddleware(checkVideoFollowConstraints),
117 asyncMiddleware(getVideo)
118)
119
120videosRouter.delete('/:id',
121 openapiOperationDoc({ operationId: 'delVideo' }),
122 authenticate,
123 asyncMiddleware(videosRemoveValidator),
124 asyncRetryTransactionMiddleware(removeVideo)
125)
126
127// ---------------------------------------------------------------------------
128
129export {
130 videosRouter
131}
132
133// ---------------------------------------------------------------------------
134
135function listVideoCategories (_req: express.Request, res: express.Response) {
136 res.json(VIDEO_CATEGORIES)
137}
138
139function listVideoLicences (_req: express.Request, res: express.Response) {
140 res.json(VIDEO_LICENCES)
141}
142
143function listVideoLanguages (_req: express.Request, res: express.Response) {
144 res.json(VIDEO_LANGUAGES)
145}
146
147function listVideoPrivacies (_req: express.Request, res: express.Response) {
148 res.json(VIDEO_PRIVACIES)
149}
150
151async function getVideo (_req: express.Request, res: express.Response) {
152 const videoId = res.locals.videoAPI.id
153 const userId = res.locals.oauth?.token.User.id
154
155 const video = await Hooks.wrapObject(res.locals.videoAPI, 'filter:api.video.get.result', { id: videoId, userId })
156
157 if (video.isOutdated()) {
158 JobQueue.Instance.createJobAsync({ type: 'activitypub-refresher', payload: { type: 'video', url: video.url } })
159 }
160
161 return res.json(video.toFormattedDetailsJSON())
162}
163
164async function getVideoDescription (req: express.Request, res: express.Response) {
165 const videoInstance = res.locals.videoAll
166
167 const description = videoInstance.isOwned()
168 ? videoInstance.description
169 : await fetchRemoteVideoDescription(videoInstance)
170
171 return res.json({ description })
172}
173
174async function listVideos (req: express.Request, res: express.Response) {
175 const serverActor = await getServerActor()
176
177 const query = pickCommonVideoQuery(req.query)
178 const countVideos = getCountVideos(req)
179
180 const apiOptions = await Hooks.wrapObject({
181 ...query,
182
183 displayOnlyForFollower: {
184 actorId: serverActor.id,
185 orLocalVideos: true
186 },
187 nsfw: buildNSFWFilter(res, query.nsfw),
188 user: res.locals.oauth ? res.locals.oauth.token.User : undefined,
189 countVideos
190 }, 'filter:api.videos.list.params')
191
192 const resultList = await Hooks.wrapPromiseFun(
193 VideoModel.listForApi,
194 apiOptions,
195 'filter:api.videos.list.result'
196 )
197
198 return res.json(getFormattedObjects(resultList.data, resultList.total, guessAdditionalAttributesFromQuery(query)))
199}
200
201async function removeVideo (req: express.Request, res: express.Response) {
202 const videoInstance = res.locals.videoAll
203
204 await sequelizeTypescript.transaction(async t => {
205 await videoInstance.destroy({ transaction: t })
206 })
207
208 auditLogger.delete(getAuditIdFromRes(res), new VideoAuditView(videoInstance.toFormattedDetailsJSON()))
209 logger.info('Video with name %s and uuid %s deleted.', videoInstance.name, videoInstance.uuid)
210
211 Hooks.runAction('action:api.video.deleted', { video: videoInstance, req, res })
212
213 return res.type('json')
214 .status(HttpStatusCode.NO_CONTENT_204)
215 .end()
216}
217
218// ---------------------------------------------------------------------------
219
220// FIXME: Should not exist, we rely on specific API
221async function fetchRemoteVideoDescription (video: MVideoAccountLight) {
222 const host = video.VideoChannel.Account.Actor.Server.host
223 const path = video.getDescriptionAPIPath()
224 const url = REMOTE_SCHEME.HTTP + '://' + host + path
225
226 const { body } = await doJSONRequest<any>(url)
227 return body.description || ''
228}
diff --git a/server/controllers/api/videos/live.ts b/server/controllers/api/videos/live.ts
deleted file mode 100644
index e19e8c652..000000000
--- a/server/controllers/api/videos/live.ts
+++ /dev/null
@@ -1,224 +0,0 @@
1import express from 'express'
2import { exists } from '@server/helpers/custom-validators/misc'
3import { createReqFiles } from '@server/helpers/express-utils'
4import { getFormattedObjects } from '@server/helpers/utils'
5import { ASSETS_PATH, MIMETYPES } from '@server/initializers/constants'
6import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url'
7import { federateVideoIfNeeded } from '@server/lib/activitypub/videos'
8import { Hooks } from '@server/lib/plugins/hooks'
9import { buildLocalVideoFromReq, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video'
10import {
11 videoLiveAddValidator,
12 videoLiveFindReplaySessionValidator,
13 videoLiveGetValidator,
14 videoLiveListSessionsValidator,
15 videoLiveUpdateValidator
16} from '@server/middlewares/validators/videos/video-live'
17import { VideoLiveModel } from '@server/models/video/video-live'
18import { VideoLiveSessionModel } from '@server/models/video/video-live-session'
19import { MVideoDetails, MVideoFullLight, MVideoLive } from '@server/types/models'
20import { buildUUID, uuidToShort } from '@shared/extra-utils'
21import { HttpStatusCode, LiveVideoCreate, LiveVideoLatencyMode, LiveVideoUpdate, UserRight, VideoPrivacy, VideoState } from '@shared/models'
22import { logger } from '../../../helpers/logger'
23import { sequelizeTypescript } from '../../../initializers/database'
24import { updateLocalVideoMiniatureFromExisting } from '../../../lib/thumbnail'
25import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, optionalAuthenticate } from '../../../middlewares'
26import { VideoModel } from '../../../models/video/video'
27import { VideoLiveReplaySettingModel } from '@server/models/video/video-live-replay-setting'
28import { VideoPasswordModel } from '@server/models/video/video-password'
29
30const liveRouter = express.Router()
31
32const reqVideoFileLive = createReqFiles([ 'thumbnailfile', 'previewfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT)
33
34liveRouter.post('/live',
35 authenticate,
36 reqVideoFileLive,
37 asyncMiddleware(videoLiveAddValidator),
38 asyncRetryTransactionMiddleware(addLiveVideo)
39)
40
41liveRouter.get('/live/:videoId/sessions',
42 authenticate,
43 asyncMiddleware(videoLiveGetValidator),
44 videoLiveListSessionsValidator,
45 asyncMiddleware(getLiveVideoSessions)
46)
47
48liveRouter.get('/live/:videoId',
49 optionalAuthenticate,
50 asyncMiddleware(videoLiveGetValidator),
51 getLiveVideo
52)
53
54liveRouter.put('/live/:videoId',
55 authenticate,
56 asyncMiddleware(videoLiveGetValidator),
57 videoLiveUpdateValidator,
58 asyncRetryTransactionMiddleware(updateLiveVideo)
59)
60
61liveRouter.get('/:videoId/live-session',
62 asyncMiddleware(videoLiveFindReplaySessionValidator),
63 getLiveReplaySession
64)
65
66// ---------------------------------------------------------------------------
67
68export {
69 liveRouter
70}
71
72// ---------------------------------------------------------------------------
73
74function getLiveVideo (req: express.Request, res: express.Response) {
75 const videoLive = res.locals.videoLive
76
77 return res.json(videoLive.toFormattedJSON(canSeePrivateLiveInformation(res)))
78}
79
80function getLiveReplaySession (req: express.Request, res: express.Response) {
81 const session = res.locals.videoLiveSession
82
83 return res.json(session.toFormattedJSON())
84}
85
86async function getLiveVideoSessions (req: express.Request, res: express.Response) {
87 const videoLive = res.locals.videoLive
88
89 const data = await VideoLiveSessionModel.listSessionsOfLiveForAPI({ videoId: videoLive.videoId })
90
91 return res.json(getFormattedObjects(data, data.length))
92}
93
94function canSeePrivateLiveInformation (res: express.Response) {
95 const user = res.locals.oauth?.token.User
96 if (!user) return false
97
98 if (user.hasRight(UserRight.GET_ANY_LIVE)) return true
99
100 const video = res.locals.videoAll
101 return video.VideoChannel.Account.userId === user.id
102}
103
104async function updateLiveVideo (req: express.Request, res: express.Response) {
105 const body: LiveVideoUpdate = req.body
106
107 const video = res.locals.videoAll
108 const videoLive = res.locals.videoLive
109
110 const newReplaySettingModel = await updateReplaySettings(videoLive, body)
111 if (newReplaySettingModel) videoLive.replaySettingId = newReplaySettingModel.id
112 else videoLive.replaySettingId = null
113
114 if (exists(body.permanentLive)) videoLive.permanentLive = body.permanentLive
115 if (exists(body.latencyMode)) videoLive.latencyMode = body.latencyMode
116
117 video.VideoLive = await videoLive.save()
118
119 await federateVideoIfNeeded(video, false)
120
121 return res.status(HttpStatusCode.NO_CONTENT_204).end()
122}
123
124async function updateReplaySettings (videoLive: MVideoLive, body: LiveVideoUpdate) {
125 if (exists(body.saveReplay)) videoLive.saveReplay = body.saveReplay
126
127 // The live replay is not saved anymore, destroy the old model if it existed
128 if (!videoLive.saveReplay) {
129 if (videoLive.replaySettingId) {
130 await VideoLiveReplaySettingModel.removeSettings(videoLive.replaySettingId)
131 }
132
133 return undefined
134 }
135
136 const settingModel = videoLive.replaySettingId
137 ? await VideoLiveReplaySettingModel.load(videoLive.replaySettingId)
138 : new VideoLiveReplaySettingModel()
139
140 if (exists(body.replaySettings.privacy)) settingModel.privacy = body.replaySettings.privacy
141
142 return settingModel.save()
143}
144
145async function addLiveVideo (req: express.Request, res: express.Response) {
146 const videoInfo: LiveVideoCreate = req.body
147
148 // Prepare data so we don't block the transaction
149 let videoData = buildLocalVideoFromReq(videoInfo, res.locals.videoChannel.id)
150 videoData = await Hooks.wrapObject(videoData, 'filter:api.video.live.video-attribute.result')
151
152 videoData.isLive = true
153 videoData.state = VideoState.WAITING_FOR_LIVE
154 videoData.duration = 0
155
156 const video = new VideoModel(videoData) as MVideoDetails
157 video.url = getLocalVideoActivityPubUrl(video) // We use the UUID, so set the URL after building the object
158
159 const videoLive = new VideoLiveModel()
160 videoLive.saveReplay = videoInfo.saveReplay || false
161 videoLive.permanentLive = videoInfo.permanentLive || false
162 videoLive.latencyMode = videoInfo.latencyMode || LiveVideoLatencyMode.DEFAULT
163 videoLive.streamKey = buildUUID()
164
165 const [ thumbnailModel, previewModel ] = await buildVideoThumbnailsFromReq({
166 video,
167 files: req.files,
168 fallback: type => {
169 return updateLocalVideoMiniatureFromExisting({
170 inputPath: ASSETS_PATH.DEFAULT_LIVE_BACKGROUND,
171 video,
172 type,
173 automaticallyGenerated: true,
174 keepOriginal: true
175 })
176 }
177 })
178
179 const { videoCreated } = await sequelizeTypescript.transaction(async t => {
180 const sequelizeOptions = { transaction: t }
181
182 const videoCreated = await video.save(sequelizeOptions) as MVideoFullLight
183
184 if (thumbnailModel) await videoCreated.addAndSaveThumbnail(thumbnailModel, t)
185 if (previewModel) await videoCreated.addAndSaveThumbnail(previewModel, t)
186
187 // Do not forget to add video channel information to the created video
188 videoCreated.VideoChannel = res.locals.videoChannel
189
190 if (videoLive.saveReplay) {
191 const replaySettings = new VideoLiveReplaySettingModel({
192 privacy: videoInfo.replaySettings.privacy
193 })
194 await replaySettings.save(sequelizeOptions)
195
196 videoLive.replaySettingId = replaySettings.id
197 }
198
199 videoLive.videoId = videoCreated.id
200 videoCreated.VideoLive = await videoLive.save(sequelizeOptions)
201
202 await setVideoTags({ video, tags: videoInfo.tags, transaction: t })
203
204 await federateVideoIfNeeded(videoCreated, true, t)
205
206 if (videoInfo.privacy === VideoPrivacy.PASSWORD_PROTECTED) {
207 await VideoPasswordModel.addPasswords(videoInfo.videoPasswords, video.id, t)
208 }
209
210 logger.info('Video live %s with uuid %s created.', videoInfo.name, videoCreated.uuid)
211
212 return { videoCreated }
213 })
214
215 Hooks.runAction('action:api.live-video.created', { video: videoCreated, req, res })
216
217 return res.json({
218 video: {
219 id: videoCreated.id,
220 shortUUID: uuidToShort(videoCreated.uuid),
221 uuid: videoCreated.uuid
222 }
223 })
224}
diff --git a/server/controllers/api/videos/ownership.ts b/server/controllers/api/videos/ownership.ts
deleted file mode 100644
index 88355b289..000000000
--- a/server/controllers/api/videos/ownership.ts
+++ /dev/null
@@ -1,138 +0,0 @@
1import express from 'express'
2import { MVideoFullLight } from '@server/types/models'
3import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes'
4import { VideoChangeOwnershipStatus, VideoState } from '../../../../shared/models/videos'
5import { logger } from '../../../helpers/logger'
6import { getFormattedObjects } from '../../../helpers/utils'
7import { sequelizeTypescript } from '../../../initializers/database'
8import { sendUpdateVideo } from '../../../lib/activitypub/send'
9import { changeVideoChannelShare } from '../../../lib/activitypub/share'
10import {
11 asyncMiddleware,
12 asyncRetryTransactionMiddleware,
13 authenticate,
14 paginationValidator,
15 setDefaultPagination,
16 videosAcceptChangeOwnershipValidator,
17 videosChangeOwnershipValidator,
18 videosTerminateChangeOwnershipValidator
19} from '../../../middlewares'
20import { VideoModel } from '../../../models/video/video'
21import { VideoChangeOwnershipModel } from '../../../models/video/video-change-ownership'
22import { VideoChannelModel } from '../../../models/video/video-channel'
23
24const ownershipVideoRouter = express.Router()
25
26ownershipVideoRouter.post('/:videoId/give-ownership',
27 authenticate,
28 asyncMiddleware(videosChangeOwnershipValidator),
29 asyncRetryTransactionMiddleware(giveVideoOwnership)
30)
31
32ownershipVideoRouter.get('/ownership',
33 authenticate,
34 paginationValidator,
35 setDefaultPagination,
36 asyncRetryTransactionMiddleware(listVideoOwnership)
37)
38
39ownershipVideoRouter.post('/ownership/:id/accept',
40 authenticate,
41 asyncMiddleware(videosTerminateChangeOwnershipValidator),
42 asyncMiddleware(videosAcceptChangeOwnershipValidator),
43 asyncRetryTransactionMiddleware(acceptOwnership)
44)
45
46ownershipVideoRouter.post('/ownership/:id/refuse',
47 authenticate,
48 asyncMiddleware(videosTerminateChangeOwnershipValidator),
49 asyncRetryTransactionMiddleware(refuseOwnership)
50)
51
52// ---------------------------------------------------------------------------
53
54export {
55 ownershipVideoRouter
56}
57
58// ---------------------------------------------------------------------------
59
60async function giveVideoOwnership (req: express.Request, res: express.Response) {
61 const videoInstance = res.locals.videoAll
62 const initiatorAccountId = res.locals.oauth.token.User.Account.id
63 const nextOwner = res.locals.nextOwner
64
65 await sequelizeTypescript.transaction(t => {
66 return VideoChangeOwnershipModel.findOrCreate({
67 where: {
68 initiatorAccountId,
69 nextOwnerAccountId: nextOwner.id,
70 videoId: videoInstance.id,
71 status: VideoChangeOwnershipStatus.WAITING
72 },
73 defaults: {
74 initiatorAccountId,
75 nextOwnerAccountId: nextOwner.id,
76 videoId: videoInstance.id,
77 status: VideoChangeOwnershipStatus.WAITING
78 },
79 transaction: t
80 })
81 })
82
83 logger.info('Ownership change for video %s created.', videoInstance.name)
84 return res.type('json')
85 .status(HttpStatusCode.NO_CONTENT_204)
86 .end()
87}
88
89async function listVideoOwnership (req: express.Request, res: express.Response) {
90 const currentAccountId = res.locals.oauth.token.User.Account.id
91
92 const resultList = await VideoChangeOwnershipModel.listForApi(
93 currentAccountId,
94 req.query.start || 0,
95 req.query.count || 10,
96 req.query.sort || 'createdAt'
97 )
98
99 return res.json(getFormattedObjects(resultList.data, resultList.total))
100}
101
102function acceptOwnership (req: express.Request, res: express.Response) {
103 return sequelizeTypescript.transaction(async t => {
104 const videoChangeOwnership = res.locals.videoChangeOwnership
105 const channel = res.locals.videoChannel
106
107 // We need more attributes for federation
108 const targetVideo = await VideoModel.loadFull(videoChangeOwnership.Video.id, t)
109
110 const oldVideoChannel = await VideoChannelModel.loadAndPopulateAccount(targetVideo.channelId, t)
111
112 targetVideo.channelId = channel.id
113
114 const targetVideoUpdated = await targetVideo.save({ transaction: t }) as MVideoFullLight
115 targetVideoUpdated.VideoChannel = channel
116
117 if (targetVideoUpdated.hasPrivacyForFederation() && targetVideoUpdated.state === VideoState.PUBLISHED) {
118 await changeVideoChannelShare(targetVideoUpdated, oldVideoChannel, t)
119 await sendUpdateVideo(targetVideoUpdated, t, oldVideoChannel.Account.Actor)
120 }
121
122 videoChangeOwnership.status = VideoChangeOwnershipStatus.ACCEPTED
123 await videoChangeOwnership.save({ transaction: t })
124
125 return res.status(HttpStatusCode.NO_CONTENT_204).end()
126 })
127}
128
129function refuseOwnership (req: express.Request, res: express.Response) {
130 return sequelizeTypescript.transaction(async t => {
131 const videoChangeOwnership = res.locals.videoChangeOwnership
132
133 videoChangeOwnership.status = VideoChangeOwnershipStatus.REFUSED
134 await videoChangeOwnership.save({ transaction: t })
135
136 return res.status(HttpStatusCode.NO_CONTENT_204).end()
137 })
138}
diff --git a/server/controllers/api/videos/passwords.ts b/server/controllers/api/videos/passwords.ts
deleted file mode 100644
index d11cf5bcc..000000000
--- a/server/controllers/api/videos/passwords.ts
+++ /dev/null
@@ -1,105 +0,0 @@
1import express from 'express'
2
3import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes'
4import { getFormattedObjects } from '../../../helpers/utils'
5import {
6 asyncMiddleware,
7 asyncRetryTransactionMiddleware,
8 authenticate,
9 setDefaultPagination,
10 setDefaultSort
11} from '../../../middlewares'
12import {
13 listVideoPasswordValidator,
14 paginationValidator,
15 removeVideoPasswordValidator,
16 updateVideoPasswordListValidator,
17 videoPasswordsSortValidator
18} from '../../../middlewares/validators'
19import { VideoPasswordModel } from '@server/models/video/video-password'
20import { logger, loggerTagsFactory } from '@server/helpers/logger'
21import { Transaction } from 'sequelize'
22import { getVideoWithAttributes } from '@server/helpers/video'
23
24const lTags = loggerTagsFactory('api', 'video')
25const videoPasswordRouter = express.Router()
26
27videoPasswordRouter.get('/:videoId/passwords',
28 authenticate,
29 paginationValidator,
30 videoPasswordsSortValidator,
31 setDefaultSort,
32 setDefaultPagination,
33 asyncMiddleware(listVideoPasswordValidator),
34 asyncMiddleware(listVideoPasswords)
35)
36
37videoPasswordRouter.put('/:videoId/passwords',
38 authenticate,
39 asyncMiddleware(updateVideoPasswordListValidator),
40 asyncMiddleware(updateVideoPasswordList)
41)
42
43videoPasswordRouter.delete('/:videoId/passwords/:passwordId',
44 authenticate,
45 asyncMiddleware(removeVideoPasswordValidator),
46 asyncRetryTransactionMiddleware(removeVideoPassword)
47)
48
49// ---------------------------------------------------------------------------
50
51export {
52 videoPasswordRouter
53}
54
55// ---------------------------------------------------------------------------
56
57async function listVideoPasswords (req: express.Request, res: express.Response) {
58 const options = {
59 videoId: res.locals.videoAll.id,
60 start: req.query.start,
61 count: req.query.count,
62 sort: req.query.sort
63 }
64
65 const resultList = await VideoPasswordModel.listPasswords(options)
66
67 return res.json(getFormattedObjects(resultList.data, resultList.total))
68}
69
70async function updateVideoPasswordList (req: express.Request, res: express.Response) {
71 const videoInstance = getVideoWithAttributes(res)
72 const videoId = videoInstance.id
73
74 const passwordArray = req.body.passwords as string[]
75
76 await VideoPasswordModel.sequelize.transaction(async (t: Transaction) => {
77 await VideoPasswordModel.deleteAllPasswords(videoId, t)
78 await VideoPasswordModel.addPasswords(passwordArray, videoId, t)
79 })
80
81 logger.info(
82 `Video passwords for video with name %s and uuid %s have been updated`,
83 videoInstance.name,
84 videoInstance.uuid,
85 lTags(videoInstance.uuid)
86 )
87
88 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
89}
90
91async function removeVideoPassword (req: express.Request, res: express.Response) {
92 const videoInstance = getVideoWithAttributes(res)
93 const password = res.locals.videoPassword
94
95 await VideoPasswordModel.deletePassword(password.id)
96 logger.info(
97 'Password with id %d of video named %s and uuid %s has been deleted.',
98 password.id,
99 videoInstance.name,
100 videoInstance.uuid,
101 lTags(videoInstance.uuid)
102 )
103
104 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
105}
diff --git a/server/controllers/api/videos/rate.ts b/server/controllers/api/videos/rate.ts
deleted file mode 100644
index 6b26a8eee..000000000
--- a/server/controllers/api/videos/rate.ts
+++ /dev/null
@@ -1,87 +0,0 @@
1import express from 'express'
2import { HttpStatusCode, UserVideoRateUpdate } from '@shared/models'
3import { logger } from '../../../helpers/logger'
4import { VIDEO_RATE_TYPES } from '../../../initializers/constants'
5import { sequelizeTypescript } from '../../../initializers/database'
6import { getLocalRateUrl, sendVideoRateChange } from '../../../lib/activitypub/video-rates'
7import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videoUpdateRateValidator } from '../../../middlewares'
8import { AccountModel } from '../../../models/account/account'
9import { AccountVideoRateModel } from '../../../models/account/account-video-rate'
10
11const rateVideoRouter = express.Router()
12
13rateVideoRouter.put('/:id/rate',
14 authenticate,
15 asyncMiddleware(videoUpdateRateValidator),
16 asyncRetryTransactionMiddleware(rateVideo)
17)
18
19// ---------------------------------------------------------------------------
20
21export {
22 rateVideoRouter
23}
24
25// ---------------------------------------------------------------------------
26
27async function rateVideo (req: express.Request, res: express.Response) {
28 const body: UserVideoRateUpdate = req.body
29 const rateType = body.rating
30 const videoInstance = res.locals.videoAll
31 const userAccount = res.locals.oauth.token.User.Account
32
33 await sequelizeTypescript.transaction(async t => {
34 const sequelizeOptions = { transaction: t }
35
36 const accountInstance = await AccountModel.load(userAccount.id, t)
37 const previousRate = await AccountVideoRateModel.load(accountInstance.id, videoInstance.id, t)
38
39 // Same rate, nothing do to
40 if (rateType === 'none' && !previousRate || previousRate?.type === rateType) return
41
42 let likesToIncrement = 0
43 let dislikesToIncrement = 0
44
45 if (rateType === VIDEO_RATE_TYPES.LIKE) likesToIncrement++
46 else if (rateType === VIDEO_RATE_TYPES.DISLIKE) dislikesToIncrement++
47
48 // There was a previous rate, update it
49 if (previousRate) {
50 // We will remove the previous rate, so we will need to update the video count attribute
51 if (previousRate.type === 'like') likesToIncrement--
52 else if (previousRate.type === 'dislike') dislikesToIncrement--
53
54 if (rateType === 'none') { // Destroy previous rate
55 await previousRate.destroy(sequelizeOptions)
56 } else { // Update previous rate
57 previousRate.type = rateType
58 previousRate.url = getLocalRateUrl(rateType, userAccount.Actor, videoInstance)
59 await previousRate.save(sequelizeOptions)
60 }
61 } else if (rateType !== 'none') { // There was not a previous rate, insert a new one if there is a rate
62 const query = {
63 accountId: accountInstance.id,
64 videoId: videoInstance.id,
65 type: rateType,
66 url: getLocalRateUrl(rateType, userAccount.Actor, videoInstance)
67 }
68
69 await AccountVideoRateModel.create(query, sequelizeOptions)
70 }
71
72 const incrementQuery = {
73 likes: likesToIncrement,
74 dislikes: dislikesToIncrement
75 }
76
77 await videoInstance.increment(incrementQuery, sequelizeOptions)
78
79 await sendVideoRateChange(accountInstance, videoInstance, likesToIncrement, dislikesToIncrement, t)
80
81 logger.info('Account video rate for video %s of account %s updated.', videoInstance.name, accountInstance.name)
82 })
83
84 return res.type('json')
85 .status(HttpStatusCode.NO_CONTENT_204)
86 .end()
87}
diff --git a/server/controllers/api/videos/source.ts b/server/controllers/api/videos/source.ts
deleted file mode 100644
index 75fe68b6c..000000000
--- a/server/controllers/api/videos/source.ts
+++ /dev/null
@@ -1,206 +0,0 @@
1import express from 'express'
2import { move } from 'fs-extra'
3import { sequelizeTypescript } from '@server/initializers/database'
4import { CreateJobArgument, CreateJobOptions, JobQueue } from '@server/lib/job-queue'
5import { Hooks } from '@server/lib/plugins/hooks'
6import { regenerateMiniaturesIfNeeded } from '@server/lib/thumbnail'
7import { uploadx } from '@server/lib/uploadx'
8import { buildMoveToObjectStorageJob } from '@server/lib/video'
9import { autoBlacklistVideoIfNeeded } from '@server/lib/video-blacklist'
10import { buildNewFile } from '@server/lib/video-file'
11import { VideoPathManager } from '@server/lib/video-path-manager'
12import { buildNextVideoState } from '@server/lib/video-state'
13import { openapiOperationDoc } from '@server/middlewares/doc'
14import { VideoModel } from '@server/models/video/video'
15import { VideoSourceModel } from '@server/models/video/video-source'
16import { MStreamingPlaylistFiles, MVideo, MVideoFile, MVideoFullLight } from '@server/types/models'
17import { VideoState } from '@shared/models'
18import { logger, loggerTagsFactory } from '../../../helpers/logger'
19import {
20 asyncMiddleware,
21 authenticate,
22 replaceVideoSourceResumableInitValidator,
23 replaceVideoSourceResumableValidator,
24 videoSourceGetLatestValidator
25} from '../../../middlewares'
26
27const lTags = loggerTagsFactory('api', 'video')
28
29const videoSourceRouter = express.Router()
30
31videoSourceRouter.get('/:id/source',
32 openapiOperationDoc({ operationId: 'getVideoSource' }),
33 authenticate,
34 asyncMiddleware(videoSourceGetLatestValidator),
35 getVideoLatestSource
36)
37
38videoSourceRouter.post('/:id/source/replace-resumable',
39 authenticate,
40 asyncMiddleware(replaceVideoSourceResumableInitValidator),
41 (req, res) => uploadx.upload(req, res) // Prevent next() call, explicitely tell to uploadx it's the end
42)
43
44videoSourceRouter.delete('/:id/source/replace-resumable',
45 authenticate,
46 (req, res) => uploadx.upload(req, res) // Prevent next() call, explicitely tell to uploadx it's the end
47)
48
49videoSourceRouter.put('/:id/source/replace-resumable',
50 authenticate,
51 uploadx.upload, // uploadx doesn't next() before the file upload completes
52 asyncMiddleware(replaceVideoSourceResumableValidator),
53 asyncMiddleware(replaceVideoSourceResumable)
54)
55
56// ---------------------------------------------------------------------------
57
58export {
59 videoSourceRouter
60}
61
62// ---------------------------------------------------------------------------
63
64function getVideoLatestSource (req: express.Request, res: express.Response) {
65 return res.json(res.locals.videoSource.toFormattedJSON())
66}
67
68async function replaceVideoSourceResumable (req: express.Request, res: express.Response) {
69 const videoPhysicalFile = res.locals.updateVideoFileResumable
70 const user = res.locals.oauth.token.User
71
72 const videoFile = await buildNewFile({ path: videoPhysicalFile.path, mode: 'web-video' })
73 const originalFilename = videoPhysicalFile.originalname
74
75 const videoFileMutexReleaser = await VideoPathManager.Instance.lockFiles(res.locals.videoAll.uuid)
76
77 try {
78 const destination = VideoPathManager.Instance.getFSVideoFileOutputPath(res.locals.videoAll, videoFile)
79 await move(videoPhysicalFile.path, destination)
80
81 let oldWebVideoFiles: MVideoFile[] = []
82 let oldStreamingPlaylists: MStreamingPlaylistFiles[] = []
83
84 const inputFileUpdatedAt = new Date()
85
86 const video = await sequelizeTypescript.transaction(async transaction => {
87 const video = await VideoModel.loadFull(res.locals.videoAll.id, transaction)
88
89 oldWebVideoFiles = video.VideoFiles
90 oldStreamingPlaylists = video.VideoStreamingPlaylists
91
92 for (const file of video.VideoFiles) {
93 await file.destroy({ transaction })
94 }
95 for (const playlist of oldStreamingPlaylists) {
96 await playlist.destroy({ transaction })
97 }
98
99 videoFile.videoId = video.id
100 await videoFile.save({ transaction })
101
102 video.VideoFiles = [ videoFile ]
103 video.VideoStreamingPlaylists = []
104
105 video.state = buildNextVideoState()
106 video.duration = videoPhysicalFile.duration
107 video.inputFileUpdatedAt = inputFileUpdatedAt
108 await video.save({ transaction })
109
110 await autoBlacklistVideoIfNeeded({
111 video,
112 user,
113 isRemote: false,
114 isNew: false,
115 isNewFile: true,
116 transaction
117 })
118
119 return video
120 })
121
122 await removeOldFiles({ video, files: oldWebVideoFiles, playlists: oldStreamingPlaylists })
123
124 const source = await VideoSourceModel.create({
125 filename: originalFilename,
126 videoId: video.id,
127 createdAt: inputFileUpdatedAt
128 })
129
130 await regenerateMiniaturesIfNeeded(video)
131 await video.VideoChannel.setAsUpdated()
132 await addVideoJobsAfterUpload(video, video.getMaxQualityFile())
133
134 logger.info('Replaced video file of video %s with uuid %s.', video.name, video.uuid, lTags(video.uuid))
135
136 Hooks.runAction('action:api.video.file-updated', { video, req, res })
137
138 return res.json(source.toFormattedJSON())
139 } finally {
140 videoFileMutexReleaser()
141 }
142}
143
144async function addVideoJobsAfterUpload (video: MVideoFullLight, videoFile: MVideoFile) {
145 const jobs: (CreateJobArgument & CreateJobOptions)[] = [
146 {
147 type: 'manage-video-torrent' as 'manage-video-torrent',
148 payload: {
149 videoId: video.id,
150 videoFileId: videoFile.id,
151 action: 'create'
152 }
153 },
154
155 {
156 type: 'generate-video-storyboard' as 'generate-video-storyboard',
157 payload: {
158 videoUUID: video.uuid,
159 // No need to federate, we process these jobs sequentially
160 federate: false
161 }
162 },
163
164 {
165 type: 'federate-video' as 'federate-video',
166 payload: {
167 videoUUID: video.uuid,
168 isNewVideo: false
169 }
170 }
171 ]
172
173 if (video.state === VideoState.TO_MOVE_TO_EXTERNAL_STORAGE) {
174 jobs.push(await buildMoveToObjectStorageJob({ video, isNewVideo: false, previousVideoState: undefined }))
175 }
176
177 if (video.state === VideoState.TO_TRANSCODE) {
178 jobs.push({
179 type: 'transcoding-job-builder' as 'transcoding-job-builder',
180 payload: {
181 videoUUID: video.uuid,
182 optimizeJob: {
183 isNewVideo: false
184 }
185 }
186 })
187 }
188
189 return JobQueue.Instance.createSequentialJobFlow(...jobs)
190}
191
192async function removeOldFiles (options: {
193 video: MVideo
194 files: MVideoFile[]
195 playlists: MStreamingPlaylistFiles[]
196}) {
197 const { video, files, playlists } = options
198
199 for (const file of files) {
200 await video.removeWebVideoFile(file)
201 }
202
203 for (const playlist of playlists) {
204 await video.removeStreamingPlaylistFiles(playlist)
205 }
206}
diff --git a/server/controllers/api/videos/stats.ts b/server/controllers/api/videos/stats.ts
deleted file mode 100644
index e79f01888..000000000
--- a/server/controllers/api/videos/stats.ts
+++ /dev/null
@@ -1,75 +0,0 @@
1import express from 'express'
2import { LocalVideoViewerModel } from '@server/models/view/local-video-viewer'
3import { VideoStatsOverallQuery, VideoStatsTimeserieMetric, VideoStatsTimeserieQuery } from '@shared/models'
4import {
5 asyncMiddleware,
6 authenticate,
7 videoOverallStatsValidator,
8 videoRetentionStatsValidator,
9 videoTimeserieStatsValidator
10} from '../../../middlewares'
11
12const statsRouter = express.Router()
13
14statsRouter.get('/:videoId/stats/overall',
15 authenticate,
16 asyncMiddleware(videoOverallStatsValidator),
17 asyncMiddleware(getOverallStats)
18)
19
20statsRouter.get('/:videoId/stats/timeseries/:metric',
21 authenticate,
22 asyncMiddleware(videoTimeserieStatsValidator),
23 asyncMiddleware(getTimeserieStats)
24)
25
26statsRouter.get('/:videoId/stats/retention',
27 authenticate,
28 asyncMiddleware(videoRetentionStatsValidator),
29 asyncMiddleware(getRetentionStats)
30)
31
32// ---------------------------------------------------------------------------
33
34export {
35 statsRouter
36}
37
38// ---------------------------------------------------------------------------
39
40async function getOverallStats (req: express.Request, res: express.Response) {
41 const video = res.locals.videoAll
42 const query = req.query as VideoStatsOverallQuery
43
44 const stats = await LocalVideoViewerModel.getOverallStats({
45 video,
46 startDate: query.startDate,
47 endDate: query.endDate
48 })
49
50 return res.json(stats)
51}
52
53async function getRetentionStats (req: express.Request, res: express.Response) {
54 const video = res.locals.videoAll
55
56 const stats = await LocalVideoViewerModel.getRetentionStats(video)
57
58 return res.json(stats)
59}
60
61async function getTimeserieStats (req: express.Request, res: express.Response) {
62 const video = res.locals.videoAll
63 const metric = req.params.metric as VideoStatsTimeserieMetric
64
65 const query = req.query as VideoStatsTimeserieQuery
66
67 const stats = await LocalVideoViewerModel.getTimeserieStats({
68 video,
69 metric,
70 startDate: query.startDate ?? video.createdAt.toISOString(),
71 endDate: query.endDate ?? new Date().toISOString()
72 })
73
74 return res.json(stats)
75}
diff --git a/server/controllers/api/videos/storyboard.ts b/server/controllers/api/videos/storyboard.ts
deleted file mode 100644
index 47a22011d..000000000
--- a/server/controllers/api/videos/storyboard.ts
+++ /dev/null
@@ -1,29 +0,0 @@
1import express from 'express'
2import { getVideoWithAttributes } from '@server/helpers/video'
3import { StoryboardModel } from '@server/models/video/storyboard'
4import { asyncMiddleware, videosGetValidator } from '../../../middlewares'
5
6const storyboardRouter = express.Router()
7
8storyboardRouter.get('/:id/storyboards',
9 asyncMiddleware(videosGetValidator),
10 asyncMiddleware(listStoryboards)
11)
12
13// ---------------------------------------------------------------------------
14
15export {
16 storyboardRouter
17}
18
19// ---------------------------------------------------------------------------
20
21async function listStoryboards (req: express.Request, res: express.Response) {
22 const video = getVideoWithAttributes(res)
23
24 const storyboards = await StoryboardModel.listStoryboardsOf(video)
25
26 return res.json({
27 storyboards: storyboards.map(s => s.toFormattedJSON())
28 })
29}
diff --git a/server/controllers/api/videos/studio.ts b/server/controllers/api/videos/studio.ts
deleted file mode 100644
index 7c31dfd2b..000000000
--- a/server/controllers/api/videos/studio.ts
+++ /dev/null
@@ -1,143 +0,0 @@
1import Bluebird from 'bluebird'
2import express from 'express'
3import { move } from 'fs-extra'
4import { basename } from 'path'
5import { createAnyReqFiles } from '@server/helpers/express-utils'
6import { MIMETYPES, VIDEO_FILTERS } from '@server/initializers/constants'
7import { buildTaskFileFieldname, createVideoStudioJob, getStudioTaskFilePath, getTaskFileFromReq } from '@server/lib/video-studio'
8import {
9 HttpStatusCode,
10 VideoState,
11 VideoStudioCreateEdition,
12 VideoStudioTask,
13 VideoStudioTaskCut,
14 VideoStudioTaskIntro,
15 VideoStudioTaskOutro,
16 VideoStudioTaskPayload,
17 VideoStudioTaskWatermark
18} from '@shared/models'
19import { asyncMiddleware, authenticate, videoStudioAddEditionValidator } from '../../../middlewares'
20
21const studioRouter = express.Router()
22
23const tasksFiles = createAnyReqFiles(
24 MIMETYPES.VIDEO.MIMETYPE_EXT,
25 (req: express.Request, file: Express.Multer.File, cb: (err: Error, result?: boolean) => void) => {
26 const body = req.body as VideoStudioCreateEdition
27
28 // Fetch array element
29 const matches = file.fieldname.match(/tasks\[(\d+)\]/)
30 if (!matches) return cb(new Error('Cannot find array element indice for ' + file.fieldname))
31
32 const indice = parseInt(matches[1])
33 const task = body.tasks[indice]
34
35 if (!task) return cb(new Error('Cannot find array element of indice ' + indice + ' for ' + file.fieldname))
36
37 if (
38 [ 'add-intro', 'add-outro', 'add-watermark' ].includes(task.name) &&
39 file.fieldname === buildTaskFileFieldname(indice)
40 ) {
41 return cb(null, true)
42 }
43
44 return cb(null, false)
45 }
46)
47
48studioRouter.post('/:videoId/studio/edit',
49 authenticate,
50 tasksFiles,
51 asyncMiddleware(videoStudioAddEditionValidator),
52 asyncMiddleware(createEditionTasks)
53)
54
55// ---------------------------------------------------------------------------
56
57export {
58 studioRouter
59}
60
61// ---------------------------------------------------------------------------
62
63async function createEditionTasks (req: express.Request, res: express.Response) {
64 const files = req.files as Express.Multer.File[]
65 const body = req.body as VideoStudioCreateEdition
66 const video = res.locals.videoAll
67
68 video.state = VideoState.TO_EDIT
69 await video.save()
70
71 const payload = {
72 videoUUID: video.uuid,
73 tasks: await Bluebird.mapSeries(body.tasks, (t, i) => buildTaskPayload(t, i, files))
74 }
75
76 await createVideoStudioJob({
77 user: res.locals.oauth.token.User,
78 payload,
79 video
80 })
81
82 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
83}
84
85const taskPayloadBuilders: {
86 [id in VideoStudioTask['name']]: (
87 task: VideoStudioTask,
88 indice?: number,
89 files?: Express.Multer.File[]
90 ) => Promise<VideoStudioTaskPayload>
91} = {
92 'add-intro': buildIntroOutroTask,
93 'add-outro': buildIntroOutroTask,
94 'cut': buildCutTask,
95 'add-watermark': buildWatermarkTask
96}
97
98function buildTaskPayload (task: VideoStudioTask, indice: number, files: Express.Multer.File[]): Promise<VideoStudioTaskPayload> {
99 return taskPayloadBuilders[task.name](task, indice, files)
100}
101
102async function buildIntroOutroTask (task: VideoStudioTaskIntro | VideoStudioTaskOutro, indice: number, files: Express.Multer.File[]) {
103 const destination = await moveStudioFileToPersistentTMP(getTaskFileFromReq(files, indice).path)
104
105 return {
106 name: task.name,
107 options: {
108 file: destination
109 }
110 }
111}
112
113function buildCutTask (task: VideoStudioTaskCut) {
114 return Promise.resolve({
115 name: task.name,
116 options: {
117 start: task.options.start,
118 end: task.options.end
119 }
120 })
121}
122
123async function buildWatermarkTask (task: VideoStudioTaskWatermark, indice: number, files: Express.Multer.File[]) {
124 const destination = await moveStudioFileToPersistentTMP(getTaskFileFromReq(files, indice).path)
125
126 return {
127 name: task.name,
128 options: {
129 file: destination,
130 watermarkSizeRatio: VIDEO_FILTERS.WATERMARK.SIZE_RATIO,
131 horitonzalMarginRatio: VIDEO_FILTERS.WATERMARK.HORIZONTAL_MARGIN_RATIO,
132 verticalMarginRatio: VIDEO_FILTERS.WATERMARK.VERTICAL_MARGIN_RATIO
133 }
134 }
135}
136
137async function moveStudioFileToPersistentTMP (file: string) {
138 const destination = getStudioTaskFilePath(basename(file))
139
140 await move(file, destination)
141
142 return destination
143}
diff --git a/server/controllers/api/videos/token.ts b/server/controllers/api/videos/token.ts
deleted file mode 100644
index e961ffd9e..000000000
--- a/server/controllers/api/videos/token.ts
+++ /dev/null
@@ -1,33 +0,0 @@
1import express from 'express'
2import { VideoTokensManager } from '@server/lib/video-tokens-manager'
3import { VideoPrivacy, VideoToken } from '@shared/models'
4import { asyncMiddleware, optionalAuthenticate, videoFileTokenValidator, videosCustomGetValidator } from '../../../middlewares'
5
6const tokenRouter = express.Router()
7
8tokenRouter.post('/:id/token',
9 optionalAuthenticate,
10 asyncMiddleware(videosCustomGetValidator('only-video')),
11 videoFileTokenValidator,
12 generateToken
13)
14
15// ---------------------------------------------------------------------------
16
17export {
18 tokenRouter
19}
20
21// ---------------------------------------------------------------------------
22
23function generateToken (req: express.Request, res: express.Response) {
24 const video = res.locals.onlyVideo
25
26 const files = video.privacy === VideoPrivacy.PASSWORD_PROTECTED
27 ? VideoTokensManager.Instance.createForPasswordProtectedVideo({ videoUUID: video.uuid })
28 : VideoTokensManager.Instance.createForAuthUser({ videoUUID: video.uuid, user: res.locals.oauth.token.User })
29
30 return res.json({
31 files
32 } as VideoToken)
33}
diff --git a/server/controllers/api/videos/transcoding.ts b/server/controllers/api/videos/transcoding.ts
deleted file mode 100644
index c0b93742f..000000000
--- a/server/controllers/api/videos/transcoding.ts
+++ /dev/null
@@ -1,60 +0,0 @@
1import express from 'express'
2import { logger, loggerTagsFactory } from '@server/helpers/logger'
3import { Hooks } from '@server/lib/plugins/hooks'
4import { createTranscodingJobs } from '@server/lib/transcoding/create-transcoding-job'
5import { computeResolutionsToTranscode } from '@server/lib/transcoding/transcoding-resolutions'
6import { VideoJobInfoModel } from '@server/models/video/video-job-info'
7import { HttpStatusCode, UserRight, VideoState, VideoTranscodingCreate } from '@shared/models'
8import { asyncMiddleware, authenticate, createTranscodingValidator, ensureUserHasRight } from '../../../middlewares'
9
10const lTags = loggerTagsFactory('api', 'video')
11const transcodingRouter = express.Router()
12
13transcodingRouter.post('/:videoId/transcoding',
14 authenticate,
15 ensureUserHasRight(UserRight.RUN_VIDEO_TRANSCODING),
16 asyncMiddleware(createTranscodingValidator),
17 asyncMiddleware(createTranscoding)
18)
19
20// ---------------------------------------------------------------------------
21
22export {
23 transcodingRouter
24}
25
26// ---------------------------------------------------------------------------
27
28async function createTranscoding (req: express.Request, res: express.Response) {
29 const video = res.locals.videoAll
30 logger.info('Creating %s transcoding job for %s.', req.body.transcodingType, video.url, lTags())
31
32 const body: VideoTranscodingCreate = req.body
33
34 await VideoJobInfoModel.abortAllTasks(video.uuid, 'pendingTranscode')
35
36 const { resolution: maxResolution, hasAudio } = await video.probeMaxQualityFile()
37
38 const resolutions = await Hooks.wrapObject(
39 computeResolutionsToTranscode({ input: maxResolution, type: 'vod', includeInput: true, strictLower: false, hasAudio }),
40 'filter:transcoding.manual.resolutions-to-transcode.result',
41 body
42 )
43
44 if (resolutions.length === 0) {
45 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
46 }
47
48 video.state = VideoState.TO_TRANSCODE
49 await video.save()
50
51 await createTranscodingJobs({
52 video,
53 resolutions,
54 transcodingType: body.transcodingType,
55 isNewVideo: false,
56 user: null // Don't specify priority since these transcoding jobs are fired by the admin
57 })
58
59 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
60}
diff --git a/server/controllers/api/videos/update.ts b/server/controllers/api/videos/update.ts
deleted file mode 100644
index 1edc509dc..000000000
--- a/server/controllers/api/videos/update.ts
+++ /dev/null
@@ -1,210 +0,0 @@
1import express from 'express'
2import { Transaction } from 'sequelize/types'
3import { changeVideoChannelShare } from '@server/lib/activitypub/share'
4import { addVideoJobsAfterUpdate, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video'
5import { setVideoPrivacy } from '@server/lib/video-privacy'
6import { openapiOperationDoc } from '@server/middlewares/doc'
7import { FilteredModelAttributes } from '@server/types'
8import { MVideoFullLight } from '@server/types/models'
9import { forceNumber } from '@shared/core-utils'
10import { HttpStatusCode, VideoPrivacy, VideoUpdate } from '@shared/models'
11import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
12import { resetSequelizeInstance } from '../../../helpers/database-utils'
13import { createReqFiles } from '../../../helpers/express-utils'
14import { logger, loggerTagsFactory } from '../../../helpers/logger'
15import { MIMETYPES } from '../../../initializers/constants'
16import { sequelizeTypescript } from '../../../initializers/database'
17import { Hooks } from '../../../lib/plugins/hooks'
18import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist'
19import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videosUpdateValidator } from '../../../middlewares'
20import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
21import { VideoModel } from '../../../models/video/video'
22import { VideoPathManager } from '@server/lib/video-path-manager'
23import { VideoPasswordModel } from '@server/models/video/video-password'
24import { exists } from '@server/helpers/custom-validators/misc'
25
26const lTags = loggerTagsFactory('api', 'video')
27const auditLogger = auditLoggerFactory('videos')
28const updateRouter = express.Router()
29
30const reqVideoFileUpdate = createReqFiles([ 'thumbnailfile', 'previewfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT)
31
32updateRouter.put('/:id',
33 openapiOperationDoc({ operationId: 'putVideo' }),
34 authenticate,
35 reqVideoFileUpdate,
36 asyncMiddleware(videosUpdateValidator),
37 asyncRetryTransactionMiddleware(updateVideo)
38)
39
40// ---------------------------------------------------------------------------
41
42export {
43 updateRouter
44}
45
46// ---------------------------------------------------------------------------
47
48async function updateVideo (req: express.Request, res: express.Response) {
49 const videoFromReq = res.locals.videoAll
50 const oldVideoAuditView = new VideoAuditView(videoFromReq.toFormattedDetailsJSON())
51 const videoInfoToUpdate: VideoUpdate = req.body
52
53 const hadPrivacyForFederation = videoFromReq.hasPrivacyForFederation()
54 const oldPrivacy = videoFromReq.privacy
55
56 const [ thumbnailModel, previewModel ] = await buildVideoThumbnailsFromReq({
57 video: videoFromReq,
58 files: req.files,
59 fallback: () => Promise.resolve(undefined),
60 automaticallyGenerated: false
61 })
62
63 const videoFileLockReleaser = await VideoPathManager.Instance.lockFiles(videoFromReq.uuid)
64
65 try {
66 const { videoInstanceUpdated, isNewVideo } = await sequelizeTypescript.transaction(async t => {
67 // Refresh video since thumbnails to prevent concurrent updates
68 const video = await VideoModel.loadFull(videoFromReq.id, t)
69
70 const oldVideoChannel = video.VideoChannel
71
72 const keysToUpdate: (keyof VideoUpdate & FilteredModelAttributes<VideoModel>)[] = [
73 'name',
74 'category',
75 'licence',
76 'language',
77 'nsfw',
78 'waitTranscoding',
79 'support',
80 'description',
81 'commentsEnabled',
82 'downloadEnabled'
83 ]
84
85 for (const key of keysToUpdate) {
86 if (videoInfoToUpdate[key] !== undefined) video.set(key, videoInfoToUpdate[key])
87 }
88
89 if (videoInfoToUpdate.originallyPublishedAt !== undefined && videoInfoToUpdate.originallyPublishedAt !== null) {
90 video.originallyPublishedAt = new Date(videoInfoToUpdate.originallyPublishedAt)
91 }
92
93 // Privacy update?
94 let isNewVideo = false
95 if (videoInfoToUpdate.privacy !== undefined) {
96 isNewVideo = await updateVideoPrivacy({ videoInstance: video, videoInfoToUpdate, hadPrivacyForFederation, transaction: t })
97 }
98
99 // Force updatedAt attribute change
100 if (!video.changed()) {
101 await video.setAsRefreshed(t)
102 }
103
104 const videoInstanceUpdated = await video.save({ transaction: t }) as MVideoFullLight
105
106 // Thumbnail & preview updates?
107 if (thumbnailModel) await videoInstanceUpdated.addAndSaveThumbnail(thumbnailModel, t)
108 if (previewModel) await videoInstanceUpdated.addAndSaveThumbnail(previewModel, t)
109
110 // Video tags update?
111 if (videoInfoToUpdate.tags !== undefined) {
112 await setVideoTags({ video: videoInstanceUpdated, tags: videoInfoToUpdate.tags, transaction: t })
113 }
114
115 // Video channel update?
116 if (res.locals.videoChannel && videoInstanceUpdated.channelId !== res.locals.videoChannel.id) {
117 await videoInstanceUpdated.$set('VideoChannel', res.locals.videoChannel, { transaction: t })
118 videoInstanceUpdated.VideoChannel = res.locals.videoChannel
119
120 if (hadPrivacyForFederation === true) {
121 await changeVideoChannelShare(videoInstanceUpdated, oldVideoChannel, t)
122 }
123 }
124
125 // Schedule an update in the future?
126 await updateSchedule(videoInstanceUpdated, videoInfoToUpdate, t)
127
128 await autoBlacklistVideoIfNeeded({
129 video: videoInstanceUpdated,
130 user: res.locals.oauth.token.User,
131 isRemote: false,
132 isNew: false,
133 isNewFile: false,
134 transaction: t
135 })
136
137 auditLogger.update(
138 getAuditIdFromRes(res),
139 new VideoAuditView(videoInstanceUpdated.toFormattedDetailsJSON()),
140 oldVideoAuditView
141 )
142 logger.info('Video with name %s and uuid %s updated.', video.name, video.uuid, lTags(video.uuid))
143
144 return { videoInstanceUpdated, isNewVideo }
145 })
146
147 Hooks.runAction('action:api.video.updated', { video: videoInstanceUpdated, body: req.body, req, res })
148
149 await addVideoJobsAfterUpdate({
150 video: videoInstanceUpdated,
151 nameChanged: !!videoInfoToUpdate.name,
152 oldPrivacy,
153 isNewVideo
154 })
155 } catch (err) {
156 // If the transaction is retried, sequelize will think the object has not changed
157 // So we need to restore the previous fields
158 await resetSequelizeInstance(videoFromReq)
159
160 throw err
161 } finally {
162 videoFileLockReleaser()
163 }
164
165 return res.type('json')
166 .status(HttpStatusCode.NO_CONTENT_204)
167 .end()
168}
169
170async function updateVideoPrivacy (options: {
171 videoInstance: MVideoFullLight
172 videoInfoToUpdate: VideoUpdate
173 hadPrivacyForFederation: boolean
174 transaction: Transaction
175}) {
176 const { videoInstance, videoInfoToUpdate, hadPrivacyForFederation, transaction } = options
177 const isNewVideo = videoInstance.isNewVideo(videoInfoToUpdate.privacy)
178
179 const newPrivacy = forceNumber(videoInfoToUpdate.privacy)
180 setVideoPrivacy(videoInstance, newPrivacy)
181
182 // Delete passwords if video is not anymore password protected
183 if (videoInstance.privacy === VideoPrivacy.PASSWORD_PROTECTED && newPrivacy !== VideoPrivacy.PASSWORD_PROTECTED) {
184 await VideoPasswordModel.deleteAllPasswords(videoInstance.id, transaction)
185 }
186
187 if (newPrivacy === VideoPrivacy.PASSWORD_PROTECTED && exists(videoInfoToUpdate.videoPasswords)) {
188 await VideoPasswordModel.deleteAllPasswords(videoInstance.id, transaction)
189 await VideoPasswordModel.addPasswords(videoInfoToUpdate.videoPasswords, videoInstance.id, transaction)
190 }
191
192 // Unfederate the video if the new privacy is not compatible with federation
193 if (hadPrivacyForFederation && !videoInstance.hasPrivacyForFederation()) {
194 await VideoModel.sendDelete(videoInstance, { transaction })
195 }
196
197 return isNewVideo
198}
199
200function updateSchedule (videoInstance: MVideoFullLight, videoInfoToUpdate: VideoUpdate, transaction: Transaction) {
201 if (videoInfoToUpdate.scheduleUpdate) {
202 return ScheduleVideoUpdateModel.upsert({
203 videoId: videoInstance.id,
204 updateAt: new Date(videoInfoToUpdate.scheduleUpdate.updateAt),
205 privacy: videoInfoToUpdate.scheduleUpdate.privacy || null
206 }, { transaction })
207 } else if (videoInfoToUpdate.scheduleUpdate === null) {
208 return ScheduleVideoUpdateModel.deleteByVideoId(videoInstance.id, transaction)
209 }
210}
diff --git a/server/controllers/api/videos/upload.ts b/server/controllers/api/videos/upload.ts
deleted file mode 100644
index e520bf4b5..000000000
--- a/server/controllers/api/videos/upload.ts
+++ /dev/null
@@ -1,287 +0,0 @@
1import express from 'express'
2import { move } from 'fs-extra'
3import { basename } from 'path'
4import { getResumableUploadPath } from '@server/helpers/upload'
5import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url'
6import { CreateJobArgument, CreateJobOptions, JobQueue } from '@server/lib/job-queue'
7import { Redis } from '@server/lib/redis'
8import { uploadx } from '@server/lib/uploadx'
9import { buildLocalVideoFromReq, buildMoveToObjectStorageJob, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video'
10import { buildNewFile } from '@server/lib/video-file'
11import { VideoPathManager } from '@server/lib/video-path-manager'
12import { buildNextVideoState } from '@server/lib/video-state'
13import { openapiOperationDoc } from '@server/middlewares/doc'
14import { VideoPasswordModel } from '@server/models/video/video-password'
15import { VideoSourceModel } from '@server/models/video/video-source'
16import { MVideoFile, MVideoFullLight } from '@server/types/models'
17import { uuidToShort } from '@shared/extra-utils'
18import { HttpStatusCode, VideoCreate, VideoPrivacy, VideoState } from '@shared/models'
19import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
20import { createReqFiles } from '../../../helpers/express-utils'
21import { logger, loggerTagsFactory } from '../../../helpers/logger'
22import { MIMETYPES } from '../../../initializers/constants'
23import { sequelizeTypescript } from '../../../initializers/database'
24import { Hooks } from '../../../lib/plugins/hooks'
25import { generateLocalVideoMiniature } from '../../../lib/thumbnail'
26import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist'
27import {
28 asyncMiddleware,
29 asyncRetryTransactionMiddleware,
30 authenticate,
31 videosAddLegacyValidator,
32 videosAddResumableInitValidator,
33 videosAddResumableValidator
34} from '../../../middlewares'
35import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
36import { VideoModel } from '../../../models/video/video'
37
38const lTags = loggerTagsFactory('api', 'video')
39const auditLogger = auditLoggerFactory('videos')
40const uploadRouter = express.Router()
41
42const reqVideoFileAdd = createReqFiles(
43 [ 'videofile', 'thumbnailfile', 'previewfile' ],
44 { ...MIMETYPES.VIDEO.MIMETYPE_EXT, ...MIMETYPES.IMAGE.MIMETYPE_EXT }
45)
46
47const reqVideoFileAddResumable = createReqFiles(
48 [ 'thumbnailfile', 'previewfile' ],
49 MIMETYPES.IMAGE.MIMETYPE_EXT,
50 getResumableUploadPath()
51)
52
53uploadRouter.post('/upload',
54 openapiOperationDoc({ operationId: 'uploadLegacy' }),
55 authenticate,
56 reqVideoFileAdd,
57 asyncMiddleware(videosAddLegacyValidator),
58 asyncRetryTransactionMiddleware(addVideoLegacy)
59)
60
61uploadRouter.post('/upload-resumable',
62 openapiOperationDoc({ operationId: 'uploadResumableInit' }),
63 authenticate,
64 reqVideoFileAddResumable,
65 asyncMiddleware(videosAddResumableInitValidator),
66 (req, res) => uploadx.upload(req, res) // Prevent next() call, explicitely tell to uploadx it's the end
67)
68
69uploadRouter.delete('/upload-resumable',
70 authenticate,
71 asyncMiddleware(deleteUploadResumableCache),
72 (req, res) => uploadx.upload(req, res) // Prevent next() call, explicitely tell to uploadx it's the end
73)
74
75uploadRouter.put('/upload-resumable',
76 openapiOperationDoc({ operationId: 'uploadResumable' }),
77 authenticate,
78 uploadx.upload, // uploadx doesn't next() before the file upload completes
79 asyncMiddleware(videosAddResumableValidator),
80 asyncMiddleware(addVideoResumable)
81)
82
83// ---------------------------------------------------------------------------
84
85export {
86 uploadRouter
87}
88
89// ---------------------------------------------------------------------------
90
91async function addVideoLegacy (req: express.Request, res: express.Response) {
92 // Uploading the video could be long
93 // Set timeout to 10 minutes, as Express's default is 2 minutes
94 req.setTimeout(1000 * 60 * 10, () => {
95 logger.error('Video upload has timed out.')
96 return res.fail({
97 status: HttpStatusCode.REQUEST_TIMEOUT_408,
98 message: 'Video upload has timed out.'
99 })
100 })
101
102 const videoPhysicalFile = req.files['videofile'][0]
103 const videoInfo: VideoCreate = req.body
104 const files = req.files
105
106 const response = await addVideo({ req, res, videoPhysicalFile, videoInfo, files })
107
108 return res.json(response)
109}
110
111async function addVideoResumable (req: express.Request, res: express.Response) {
112 const videoPhysicalFile = res.locals.uploadVideoFileResumable
113 const videoInfo = videoPhysicalFile.metadata
114 const files = { previewfile: videoInfo.previewfile, thumbnailfile: videoInfo.thumbnailfile }
115
116 const response = await addVideo({ req, res, videoPhysicalFile, videoInfo, files })
117 await Redis.Instance.setUploadSession(req.query.upload_id, response)
118
119 return res.json(response)
120}
121
122async function addVideo (options: {
123 req: express.Request
124 res: express.Response
125 videoPhysicalFile: express.VideoUploadFile
126 videoInfo: VideoCreate
127 files: express.UploadFiles
128}) {
129 const { req, res, videoPhysicalFile, videoInfo, files } = options
130 const videoChannel = res.locals.videoChannel
131 const user = res.locals.oauth.token.User
132
133 let videoData = buildLocalVideoFromReq(videoInfo, videoChannel.id)
134 videoData = await Hooks.wrapObject(videoData, 'filter:api.video.upload.video-attribute.result')
135
136 videoData.state = buildNextVideoState()
137 videoData.duration = videoPhysicalFile.duration // duration was added by a previous middleware
138
139 const video = new VideoModel(videoData) as MVideoFullLight
140 video.VideoChannel = videoChannel
141 video.url = getLocalVideoActivityPubUrl(video) // We use the UUID, so set the URL after building the object
142
143 const videoFile = await buildNewFile({ path: videoPhysicalFile.path, mode: 'web-video' })
144 const originalFilename = videoPhysicalFile.originalname
145
146 // Move physical file
147 const destination = VideoPathManager.Instance.getFSVideoFileOutputPath(video, videoFile)
148 await move(videoPhysicalFile.path, destination)
149 // This is important in case if there is another attempt in the retry process
150 videoPhysicalFile.filename = basename(destination)
151 videoPhysicalFile.path = destination
152
153 const [ thumbnailModel, previewModel ] = await buildVideoThumbnailsFromReq({
154 video,
155 files,
156 fallback: type => generateLocalVideoMiniature({ video, videoFile, type })
157 })
158
159 const { videoCreated } = await sequelizeTypescript.transaction(async t => {
160 const sequelizeOptions = { transaction: t }
161
162 const videoCreated = await video.save(sequelizeOptions) as MVideoFullLight
163
164 await videoCreated.addAndSaveThumbnail(thumbnailModel, t)
165 await videoCreated.addAndSaveThumbnail(previewModel, t)
166
167 // Do not forget to add video channel information to the created video
168 videoCreated.VideoChannel = res.locals.videoChannel
169
170 videoFile.videoId = video.id
171 await videoFile.save(sequelizeOptions)
172
173 video.VideoFiles = [ videoFile ]
174
175 await VideoSourceModel.create({
176 filename: originalFilename,
177 videoId: video.id
178 }, { transaction: t })
179
180 await setVideoTags({ video, tags: videoInfo.tags, transaction: t })
181
182 // Schedule an update in the future?
183 if (videoInfo.scheduleUpdate) {
184 await ScheduleVideoUpdateModel.create({
185 videoId: video.id,
186 updateAt: new Date(videoInfo.scheduleUpdate.updateAt),
187 privacy: videoInfo.scheduleUpdate.privacy || null
188 }, sequelizeOptions)
189 }
190
191 await autoBlacklistVideoIfNeeded({
192 video,
193 user,
194 isRemote: false,
195 isNew: true,
196 isNewFile: true,
197 transaction: t
198 })
199
200 if (videoInfo.privacy === VideoPrivacy.PASSWORD_PROTECTED) {
201 await VideoPasswordModel.addPasswords(videoInfo.videoPasswords, video.id, t)
202 }
203
204 auditLogger.create(getAuditIdFromRes(res), new VideoAuditView(videoCreated.toFormattedDetailsJSON()))
205 logger.info('Video with name %s and uuid %s created.', videoInfo.name, videoCreated.uuid, lTags(videoCreated.uuid))
206
207 return { videoCreated }
208 })
209
210 // Channel has a new content, set as updated
211 await videoCreated.VideoChannel.setAsUpdated()
212
213 addVideoJobsAfterUpload(videoCreated, videoFile)
214 .catch(err => logger.error('Cannot build new video jobs of %s.', videoCreated.uuid, { err, ...lTags(videoCreated.uuid) }))
215
216 Hooks.runAction('action:api.video.uploaded', { video: videoCreated, req, res })
217
218 return {
219 video: {
220 id: videoCreated.id,
221 shortUUID: uuidToShort(videoCreated.uuid),
222 uuid: videoCreated.uuid
223 }
224 }
225}
226
227async function addVideoJobsAfterUpload (video: MVideoFullLight, videoFile: MVideoFile) {
228 const jobs: (CreateJobArgument & CreateJobOptions)[] = [
229 {
230 type: 'manage-video-torrent' as 'manage-video-torrent',
231 payload: {
232 videoId: video.id,
233 videoFileId: videoFile.id,
234 action: 'create'
235 }
236 },
237
238 {
239 type: 'generate-video-storyboard' as 'generate-video-storyboard',
240 payload: {
241 videoUUID: video.uuid,
242 // No need to federate, we process these jobs sequentially
243 federate: false
244 }
245 },
246
247 {
248 type: 'notify',
249 payload: {
250 action: 'new-video',
251 videoUUID: video.uuid
252 }
253 },
254
255 {
256 type: 'federate-video' as 'federate-video',
257 payload: {
258 videoUUID: video.uuid,
259 isNewVideo: true
260 }
261 }
262 ]
263
264 if (video.state === VideoState.TO_MOVE_TO_EXTERNAL_STORAGE) {
265 jobs.push(await buildMoveToObjectStorageJob({ video, previousVideoState: undefined }))
266 }
267
268 if (video.state === VideoState.TO_TRANSCODE) {
269 jobs.push({
270 type: 'transcoding-job-builder' as 'transcoding-job-builder',
271 payload: {
272 videoUUID: video.uuid,
273 optimizeJob: {
274 isNewVideo: true
275 }
276 }
277 })
278 }
279
280 return JobQueue.Instance.createSequentialJobFlow(...jobs)
281}
282
283async function deleteUploadResumableCache (req: express.Request, res: express.Response, next: express.NextFunction) {
284 await Redis.Instance.deleteUploadSession(req.query.upload_id)
285
286 return next()
287}
diff --git a/server/controllers/api/videos/view.ts b/server/controllers/api/videos/view.ts
deleted file mode 100644
index a747fa334..000000000
--- a/server/controllers/api/videos/view.ts
+++ /dev/null
@@ -1,60 +0,0 @@
1import express from 'express'
2import { Hooks } from '@server/lib/plugins/hooks'
3import { VideoViewsManager } from '@server/lib/views/video-views-manager'
4import { MVideoId } from '@server/types/models'
5import { HttpStatusCode, VideoView } from '@shared/models'
6import { asyncMiddleware, methodsValidator, openapiOperationDoc, optionalAuthenticate, videoViewValidator } from '../../../middlewares'
7import { UserVideoHistoryModel } from '../../../models/user/user-video-history'
8
9const viewRouter = express.Router()
10
11viewRouter.all(
12 [ '/:videoId/views', '/:videoId/watching' ],
13 openapiOperationDoc({ operationId: 'addView' }),
14 methodsValidator([ 'PUT', 'POST' ]),
15 optionalAuthenticate,
16 asyncMiddleware(videoViewValidator),
17 asyncMiddleware(viewVideo)
18)
19
20// ---------------------------------------------------------------------------
21
22export {
23 viewRouter
24}
25
26// ---------------------------------------------------------------------------
27
28async function viewVideo (req: express.Request, res: express.Response) {
29 const video = res.locals.onlyImmutableVideo
30
31 const body = req.body as VideoView
32
33 const ip = req.ip
34 const { successView } = await VideoViewsManager.Instance.processLocalView({
35 video,
36 ip,
37 currentTime: body.currentTime,
38 viewEvent: body.viewEvent
39 })
40
41 if (successView) {
42 Hooks.runAction('action:api.video.viewed', { video, ip, req, res })
43 }
44
45 await updateUserHistoryIfNeeded(body, video, res)
46
47 return res.status(HttpStatusCode.NO_CONTENT_204).end()
48}
49
50async function updateUserHistoryIfNeeded (body: VideoView, video: MVideoId, res: express.Response) {
51 const user = res.locals.oauth?.token.User
52 if (!user) return
53 if (user.videosHistoryEnabled !== true) return
54
55 await UserVideoHistoryModel.upsert({
56 videoId: video.id,
57 userId: user.id,
58 currentTime: body.currentTime
59 })
60}
diff --git a/server/controllers/client.ts b/server/controllers/client.ts
deleted file mode 100644
index 2d0c49904..000000000
--- a/server/controllers/client.ts
+++ /dev/null
@@ -1,236 +0,0 @@
1import express from 'express'
2import { constants, promises as fs } from 'fs'
3import { readFile } from 'fs-extra'
4import { join } from 'path'
5import { logger } from '@server/helpers/logger'
6import { CONFIG } from '@server/initializers/config'
7import { Hooks } from '@server/lib/plugins/hooks'
8import { root } from '@shared/core-utils'
9import { buildFileLocale, getCompleteLocale, is18nLocale, LOCALE_FILES } from '@shared/core-utils/i18n'
10import { HttpStatusCode } from '@shared/models'
11import { STATIC_MAX_AGE } from '../initializers/constants'
12import { ClientHtml, sendHTML, serveIndexHTML } from '../lib/client-html'
13import { asyncMiddleware, buildRateLimiter, embedCSP } from '../middlewares'
14
15const clientsRouter = express.Router()
16
17const clientsRateLimiter = buildRateLimiter({
18 windowMs: CONFIG.RATES_LIMIT.CLIENT.WINDOW_MS,
19 max: CONFIG.RATES_LIMIT.CLIENT.MAX
20})
21
22const distPath = join(root(), 'client', 'dist')
23const testEmbedPath = join(distPath, 'standalone', 'videos', 'test-embed.html')
24
25// Special route that add OpenGraph and oEmbed tags
26// Do not use a template engine for a so little thing
27clientsRouter.use([ '/w/p/:id', '/videos/watch/playlist/:id' ],
28 clientsRateLimiter,
29 asyncMiddleware(generateWatchPlaylistHtmlPage)
30)
31
32clientsRouter.use([ '/w/:id', '/videos/watch/:id' ],
33 clientsRateLimiter,
34 asyncMiddleware(generateWatchHtmlPage)
35)
36
37clientsRouter.use([ '/accounts/:nameWithHost', '/a/:nameWithHost' ],
38 clientsRateLimiter,
39 asyncMiddleware(generateAccountHtmlPage)
40)
41
42clientsRouter.use([ '/video-channels/:nameWithHost', '/c/:nameWithHost' ],
43 clientsRateLimiter,
44 asyncMiddleware(generateVideoChannelHtmlPage)
45)
46
47clientsRouter.use('/@:nameWithHost',
48 clientsRateLimiter,
49 asyncMiddleware(generateActorHtmlPage)
50)
51
52const embedMiddlewares = [
53 clientsRateLimiter,
54
55 CONFIG.CSP.ENABLED
56 ? embedCSP
57 : (req: express.Request, res: express.Response, next: express.NextFunction) => next(),
58
59 // Set headers
60 (req: express.Request, res: express.Response, next: express.NextFunction) => {
61 res.removeHeader('X-Frame-Options')
62
63 // Don't cache HTML file since it's an index to the immutable JS/CSS files
64 res.setHeader('Cache-Control', 'public, max-age=0')
65
66 next()
67 },
68
69 asyncMiddleware(generateEmbedHtmlPage)
70]
71
72clientsRouter.use('/videos/embed', ...embedMiddlewares)
73clientsRouter.use('/video-playlists/embed', ...embedMiddlewares)
74
75const testEmbedController = (req: express.Request, res: express.Response) => res.sendFile(testEmbedPath)
76
77clientsRouter.use('/videos/test-embed', clientsRateLimiter, testEmbedController)
78clientsRouter.use('/video-playlists/test-embed', clientsRateLimiter, testEmbedController)
79
80// Dynamic PWA manifest
81clientsRouter.get('/manifest.webmanifest', clientsRateLimiter, asyncMiddleware(generateManifest))
82
83// Static client overrides
84// Must be consistent with static client overrides redirections in /support/nginx/peertube
85const staticClientOverrides = [
86 'assets/images/logo.svg',
87 'assets/images/favicon.png',
88 'assets/images/icons/icon-36x36.png',
89 'assets/images/icons/icon-48x48.png',
90 'assets/images/icons/icon-72x72.png',
91 'assets/images/icons/icon-96x96.png',
92 'assets/images/icons/icon-144x144.png',
93 'assets/images/icons/icon-192x192.png',
94 'assets/images/icons/icon-512x512.png',
95 'assets/images/default-playlist.jpg',
96 'assets/images/default-avatar-account.png',
97 'assets/images/default-avatar-account-48x48.png',
98 'assets/images/default-avatar-video-channel.png',
99 'assets/images/default-avatar-video-channel-48x48.png'
100]
101
102for (const staticClientOverride of staticClientOverrides) {
103 const overridePhysicalPath = join(CONFIG.STORAGE.CLIENT_OVERRIDES_DIR, staticClientOverride)
104 clientsRouter.use(`/client/${staticClientOverride}`, asyncMiddleware(serveClientOverride(overridePhysicalPath)))
105}
106
107clientsRouter.use('/client/locales/:locale/:file.json', serveServerTranslations)
108clientsRouter.use('/client', express.static(distPath, { maxAge: STATIC_MAX_AGE.CLIENT }))
109
110// 404 for static files not found
111clientsRouter.use('/client/*', (req: express.Request, res: express.Response) => {
112 res.status(HttpStatusCode.NOT_FOUND_404).end()
113})
114
115// Always serve index client page (the client is a single page application, let it handle routing)
116// Try to provide the right language index.html
117clientsRouter.use('/(:language)?',
118 clientsRateLimiter,
119 asyncMiddleware(serveIndexHTML)
120)
121
122// ---------------------------------------------------------------------------
123
124export {
125 clientsRouter
126}
127
128// ---------------------------------------------------------------------------
129
130function serveServerTranslations (req: express.Request, res: express.Response) {
131 const locale = req.params.locale
132 const file = req.params.file
133
134 if (is18nLocale(locale) && LOCALE_FILES.includes(file)) {
135 const completeLocale = getCompleteLocale(locale)
136 const completeFileLocale = buildFileLocale(completeLocale)
137
138 const path = join(__dirname, `../../../client/dist/locale/${file}.${completeFileLocale}.json`)
139 return res.sendFile(path, { maxAge: STATIC_MAX_AGE.SERVER })
140 }
141
142 return res.status(HttpStatusCode.NOT_FOUND_404).end()
143}
144
145async function generateEmbedHtmlPage (req: express.Request, res: express.Response) {
146 const hookName = req.originalUrl.startsWith('/video-playlists/')
147 ? 'filter:html.embed.video-playlist.allowed.result'
148 : 'filter:html.embed.video.allowed.result'
149
150 const allowParameters = { req }
151
152 const allowedResult = await Hooks.wrapFun(
153 isEmbedAllowed,
154 allowParameters,
155 hookName
156 )
157
158 if (!allowedResult || allowedResult.allowed !== true) {
159 logger.info('Embed is not allowed.', { allowedResult })
160
161 return sendHTML(allowedResult?.html || '', res)
162 }
163
164 const html = await ClientHtml.getEmbedHTML()
165
166 return sendHTML(html, res)
167}
168
169async function generateWatchHtmlPage (req: express.Request, res: express.Response) {
170 // Thread link is '/w/:videoId;threadId=:threadId'
171 // So to get the videoId we need to remove the last part
172 let videoId = req.params.id + ''
173
174 const threadIdIndex = videoId.indexOf(';threadId')
175 if (threadIdIndex !== -1) videoId = videoId.substring(0, threadIdIndex)
176
177 const html = await ClientHtml.getWatchHTMLPage(videoId, req, res)
178
179 return sendHTML(html, res, true)
180}
181
182async function generateWatchPlaylistHtmlPage (req: express.Request, res: express.Response) {
183 const html = await ClientHtml.getWatchPlaylistHTMLPage(req.params.id + '', req, res)
184
185 return sendHTML(html, res, true)
186}
187
188async function generateAccountHtmlPage (req: express.Request, res: express.Response) {
189 const html = await ClientHtml.getAccountHTMLPage(req.params.nameWithHost, req, res)
190
191 return sendHTML(html, res, true)
192}
193
194async function generateVideoChannelHtmlPage (req: express.Request, res: express.Response) {
195 const html = await ClientHtml.getVideoChannelHTMLPage(req.params.nameWithHost, req, res)
196
197 return sendHTML(html, res, true)
198}
199
200async function generateActorHtmlPage (req: express.Request, res: express.Response) {
201 const html = await ClientHtml.getActorHTMLPage(req.params.nameWithHost, req, res)
202
203 return sendHTML(html, res, true)
204}
205
206async function generateManifest (req: express.Request, res: express.Response) {
207 const manifestPhysicalPath = join(root(), 'client', 'dist', 'manifest.webmanifest')
208 const manifestJson = await readFile(manifestPhysicalPath, 'utf8')
209 const manifest = JSON.parse(manifestJson)
210
211 manifest.name = CONFIG.INSTANCE.NAME
212 manifest.short_name = CONFIG.INSTANCE.NAME
213 manifest.description = CONFIG.INSTANCE.SHORT_DESCRIPTION
214
215 res.json(manifest)
216}
217
218function serveClientOverride (path: string) {
219 return async (req: express.Request, res: express.Response, next: express.NextFunction) => {
220 try {
221 await fs.access(path, constants.F_OK)
222 // Serve override client
223 res.sendFile(path, { maxAge: STATIC_MAX_AGE.SERVER })
224 } catch {
225 // Serve dist client
226 next()
227 }
228 }
229}
230
231type AllowedResult = { allowed: boolean, html?: string }
232function isEmbedAllowed (_object: {
233 req: express.Request
234}): AllowedResult {
235 return { allowed: true }
236}
diff --git a/server/controllers/download.ts b/server/controllers/download.ts
deleted file mode 100644
index 4b94e34bd..000000000
--- a/server/controllers/download.ts
+++ /dev/null
@@ -1,213 +0,0 @@
1import cors from 'cors'
2import express from 'express'
3import { logger } from '@server/helpers/logger'
4import { VideoTorrentsSimpleFileCache } from '@server/lib/files-cache'
5import { generateHLSFilePresignedUrl, generateWebVideoPresignedUrl } from '@server/lib/object-storage'
6import { Hooks } from '@server/lib/plugins/hooks'
7import { VideoPathManager } from '@server/lib/video-path-manager'
8import { MStreamingPlaylist, MStreamingPlaylistVideo, MVideo, MVideoFile, MVideoFullLight } from '@server/types/models'
9import { forceNumber } from '@shared/core-utils'
10import { HttpStatusCode, VideoStorage, VideoStreamingPlaylistType } from '@shared/models'
11import { STATIC_DOWNLOAD_PATHS } from '../initializers/constants'
12import { asyncMiddleware, optionalAuthenticate, videosDownloadValidator } from '../middlewares'
13
14const downloadRouter = express.Router()
15
16downloadRouter.use(cors())
17
18downloadRouter.use(
19 STATIC_DOWNLOAD_PATHS.TORRENTS + ':filename',
20 asyncMiddleware(downloadTorrent)
21)
22
23downloadRouter.use(
24 STATIC_DOWNLOAD_PATHS.VIDEOS + ':id-:resolution([0-9]+).:extension',
25 optionalAuthenticate,
26 asyncMiddleware(videosDownloadValidator),
27 asyncMiddleware(downloadVideoFile)
28)
29
30downloadRouter.use(
31 STATIC_DOWNLOAD_PATHS.HLS_VIDEOS + ':id-:resolution([0-9]+)-fragmented.:extension',
32 optionalAuthenticate,
33 asyncMiddleware(videosDownloadValidator),
34 asyncMiddleware(downloadHLSVideoFile)
35)
36
37// ---------------------------------------------------------------------------
38
39export {
40 downloadRouter
41}
42
43// ---------------------------------------------------------------------------
44
45async function downloadTorrent (req: express.Request, res: express.Response) {
46 const result = await VideoTorrentsSimpleFileCache.Instance.getFilePath(req.params.filename)
47 if (!result) {
48 return res.fail({
49 status: HttpStatusCode.NOT_FOUND_404,
50 message: 'Torrent file not found'
51 })
52 }
53
54 const allowParameters = {
55 req,
56 res,
57 torrentPath: result.path,
58 downloadName: result.downloadName
59 }
60
61 const allowedResult = await Hooks.wrapFun(
62 isTorrentDownloadAllowed,
63 allowParameters,
64 'filter:api.download.torrent.allowed.result'
65 )
66
67 if (!checkAllowResult(res, allowParameters, allowedResult)) return
68
69 return res.download(result.path, result.downloadName)
70}
71
72async function downloadVideoFile (req: express.Request, res: express.Response) {
73 const video = res.locals.videoAll
74
75 const videoFile = getVideoFile(req, video.VideoFiles)
76 if (!videoFile) {
77 return res.fail({
78 status: HttpStatusCode.NOT_FOUND_404,
79 message: 'Video file not found'
80 })
81 }
82
83 const allowParameters = {
84 req,
85 res,
86 video,
87 videoFile
88 }
89
90 const allowedResult = await Hooks.wrapFun(
91 isVideoDownloadAllowed,
92 allowParameters,
93 'filter:api.download.video.allowed.result'
94 )
95
96 if (!checkAllowResult(res, allowParameters, allowedResult)) return
97
98 // Express uses basename on filename parameter
99 const videoName = video.name.replace(/[/\\]/g, '_')
100 const downloadFilename = `${videoName}-${videoFile.resolution}p${videoFile.extname}`
101
102 if (videoFile.storage === VideoStorage.OBJECT_STORAGE) {
103 return redirectToObjectStorage({ req, res, video, file: videoFile, downloadFilename })
104 }
105
106 await VideoPathManager.Instance.makeAvailableVideoFile(videoFile.withVideoOrPlaylist(video), path => {
107 return res.download(path, downloadFilename)
108 })
109}
110
111async function downloadHLSVideoFile (req: express.Request, res: express.Response) {
112 const video = res.locals.videoAll
113 const streamingPlaylist = getHLSPlaylist(video)
114 if (!streamingPlaylist) return res.status(HttpStatusCode.NOT_FOUND_404).end
115
116 const videoFile = getVideoFile(req, streamingPlaylist.VideoFiles)
117 if (!videoFile) {
118 return res.fail({
119 status: HttpStatusCode.NOT_FOUND_404,
120 message: 'Video file not found'
121 })
122 }
123
124 const allowParameters = {
125 req,
126 res,
127 video,
128 streamingPlaylist,
129 videoFile
130 }
131
132 const allowedResult = await Hooks.wrapFun(
133 isVideoDownloadAllowed,
134 allowParameters,
135 'filter:api.download.video.allowed.result'
136 )
137
138 if (!checkAllowResult(res, allowParameters, allowedResult)) return
139
140 const downloadFilename = `${video.name}-${videoFile.resolution}p-${streamingPlaylist.getStringType()}${videoFile.extname}`
141
142 if (videoFile.storage === VideoStorage.OBJECT_STORAGE) {
143 return redirectToObjectStorage({ req, res, video, streamingPlaylist, file: videoFile, downloadFilename })
144 }
145
146 await VideoPathManager.Instance.makeAvailableVideoFile(videoFile.withVideoOrPlaylist(streamingPlaylist), path => {
147 return res.download(path, downloadFilename)
148 })
149}
150
151function getVideoFile (req: express.Request, files: MVideoFile[]) {
152 const resolution = forceNumber(req.params.resolution)
153 return files.find(f => f.resolution === resolution)
154}
155
156function getHLSPlaylist (video: MVideoFullLight) {
157 const playlist = video.VideoStreamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS)
158 if (!playlist) return undefined
159
160 return Object.assign(playlist, { Video: video })
161}
162
163type AllowedResult = {
164 allowed: boolean
165 errorMessage?: string
166}
167
168function isTorrentDownloadAllowed (_object: {
169 torrentPath: string
170}): AllowedResult {
171 return { allowed: true }
172}
173
174function isVideoDownloadAllowed (_object: {
175 video: MVideo
176 videoFile: MVideoFile
177 streamingPlaylist?: MStreamingPlaylist
178}): AllowedResult {
179 return { allowed: true }
180}
181
182function checkAllowResult (res: express.Response, allowParameters: any, result?: AllowedResult) {
183 if (!result || result.allowed !== true) {
184 logger.info('Download is not allowed.', { result, allowParameters })
185
186 res.fail({
187 status: HttpStatusCode.FORBIDDEN_403,
188 message: result?.errorMessage || 'Refused download'
189 })
190 return false
191 }
192
193 return true
194}
195
196async function redirectToObjectStorage (options: {
197 req: express.Request
198 res: express.Response
199 video: MVideo
200 file: MVideoFile
201 streamingPlaylist?: MStreamingPlaylistVideo
202 downloadFilename: string
203}) {
204 const { res, video, streamingPlaylist, file, downloadFilename } = options
205
206 const url = streamingPlaylist
207 ? await generateHLSFilePresignedUrl({ streamingPlaylist, file, downloadFilename })
208 : await generateWebVideoPresignedUrl({ file, downloadFilename })
209
210 logger.debug('Generating pre-signed URL %s for video %s', url, video.uuid)
211
212 return res.redirect(url)
213}
diff --git a/server/controllers/feeds/comment-feeds.ts b/server/controllers/feeds/comment-feeds.ts
deleted file mode 100644
index c013662ea..000000000
--- a/server/controllers/feeds/comment-feeds.ts
+++ /dev/null
@@ -1,96 +0,0 @@
1import express from 'express'
2import { toSafeHtml } from '@server/helpers/markdown'
3import { cacheRouteFactory } from '@server/middlewares'
4import { CONFIG } from '../../initializers/config'
5import { ROUTE_CACHE_LIFETIME, WEBSERVER } from '../../initializers/constants'
6import {
7 asyncMiddleware,
8 feedsFormatValidator,
9 setFeedFormatContentType,
10 videoCommentsFeedsValidator,
11 feedsAccountOrChannelFiltersValidator
12} from '../../middlewares'
13import { VideoCommentModel } from '../../models/video/video-comment'
14import { buildFeedMetadata, initFeed, sendFeed } from './shared'
15
16const commentFeedsRouter = express.Router()
17
18// ---------------------------------------------------------------------------
19
20const { middleware: cacheRouteMiddleware } = cacheRouteFactory({
21 headerBlacklist: [ 'Content-Type' ]
22})
23
24// ---------------------------------------------------------------------------
25
26commentFeedsRouter.get('/video-comments.:format',
27 feedsFormatValidator,
28 setFeedFormatContentType,
29 cacheRouteMiddleware(ROUTE_CACHE_LIFETIME.FEEDS),
30 asyncMiddleware(feedsAccountOrChannelFiltersValidator),
31 asyncMiddleware(videoCommentsFeedsValidator),
32 asyncMiddleware(generateVideoCommentsFeed)
33)
34
35// ---------------------------------------------------------------------------
36
37export {
38 commentFeedsRouter
39}
40
41// ---------------------------------------------------------------------------
42
43async function generateVideoCommentsFeed (req: express.Request, res: express.Response) {
44 const start = 0
45 const video = res.locals.videoAll
46 const account = res.locals.account
47 const videoChannel = res.locals.videoChannel
48
49 const comments = await VideoCommentModel.listForFeed({
50 start,
51 count: CONFIG.FEEDS.COMMENTS.COUNT,
52 videoId: video ? video.id : undefined,
53 accountId: account ? account.id : undefined,
54 videoChannelId: videoChannel ? videoChannel.id : undefined
55 })
56
57 const { name, description, imageUrl, link } = await buildFeedMetadata({ video, account, videoChannel })
58
59 const feed = initFeed({
60 name,
61 description,
62 imageUrl,
63 isPodcast: false,
64 link,
65 resourceType: 'video-comments',
66 queryString: new URL(WEBSERVER.URL + req.originalUrl).search
67 })
68
69 // Adding video items to the feed, one at a time
70 for (const comment of comments) {
71 const localLink = WEBSERVER.URL + comment.getCommentStaticPath()
72
73 let title = comment.Video.name
74 const author: { name: string, link: string }[] = []
75
76 if (comment.Account) {
77 title += ` - ${comment.Account.getDisplayName()}`
78 author.push({
79 name: comment.Account.getDisplayName(),
80 link: comment.Account.Actor.url
81 })
82 }
83
84 feed.addItem({
85 title,
86 id: localLink,
87 link: localLink,
88 content: toSafeHtml(comment.text),
89 author,
90 date: comment.createdAt
91 })
92 }
93
94 // Now the feed generation is done, let's send it!
95 return sendFeed(feed, req, res)
96}
diff --git a/server/controllers/feeds/index.ts b/server/controllers/feeds/index.ts
deleted file mode 100644
index 19352318d..000000000
--- a/server/controllers/feeds/index.ts
+++ /dev/null
@@ -1,25 +0,0 @@
1import express from 'express'
2import { CONFIG } from '@server/initializers/config'
3import { buildRateLimiter } from '@server/middlewares'
4import { commentFeedsRouter } from './comment-feeds'
5import { videoFeedsRouter } from './video-feeds'
6import { videoPodcastFeedsRouter } from './video-podcast-feeds'
7
8const feedsRouter = express.Router()
9
10const feedsRateLimiter = buildRateLimiter({
11 windowMs: CONFIG.RATES_LIMIT.FEEDS.WINDOW_MS,
12 max: CONFIG.RATES_LIMIT.FEEDS.MAX
13})
14
15feedsRouter.use('/feeds', feedsRateLimiter)
16
17feedsRouter.use('/feeds', commentFeedsRouter)
18feedsRouter.use('/feeds', videoFeedsRouter)
19feedsRouter.use('/feeds', videoPodcastFeedsRouter)
20
21// ---------------------------------------------------------------------------
22
23export {
24 feedsRouter
25}
diff --git a/server/controllers/feeds/shared/common-feed-utils.ts b/server/controllers/feeds/shared/common-feed-utils.ts
deleted file mode 100644
index 9e2f8adbb..000000000
--- a/server/controllers/feeds/shared/common-feed-utils.ts
+++ /dev/null
@@ -1,149 +0,0 @@
1import express from 'express'
2import { Feed } from '@peertube/feed'
3import { CustomTag, CustomXMLNS, Person } from '@peertube/feed/lib/typings'
4import { mdToOneLinePlainText } from '@server/helpers/markdown'
5import { CONFIG } from '@server/initializers/config'
6import { WEBSERVER } from '@server/initializers/constants'
7import { getBiggestActorImage } from '@server/lib/actor-image'
8import { UserModel } from '@server/models/user/user'
9import { MAccountDefault, MChannelBannerAccountDefault, MUser, MVideoFullLight } from '@server/types/models'
10import { pick } from '@shared/core-utils'
11import { ActorImageType } from '@shared/models'
12
13export function initFeed (parameters: {
14 name: string
15 description: string
16 imageUrl: string
17 isPodcast: boolean
18 link?: string
19 locked?: { isLocked: boolean, email: string }
20 author?: {
21 name: string
22 link: string
23 imageUrl: string
24 }
25 person?: Person[]
26 resourceType?: 'videos' | 'video-comments'
27 queryString?: string
28 medium?: string
29 stunServers?: string[]
30 trackers?: string[]
31 customXMLNS?: CustomXMLNS[]
32 customTags?: CustomTag[]
33}) {
34 const webserverUrl = WEBSERVER.URL
35 const { name, description, link, imageUrl, isPodcast, resourceType, queryString, medium } = parameters
36
37 return new Feed({
38 title: name,
39 description: mdToOneLinePlainText(description),
40 // updated: TODO: somehowGetLatestUpdate, // optional, default = today
41 id: link || webserverUrl,
42 link: link || webserverUrl,
43 image: imageUrl,
44 favicon: webserverUrl + '/client/assets/images/favicon.png',
45 copyright: `All rights reserved, unless otherwise specified in the terms specified at ${webserverUrl}/about` +
46 ` and potential licenses granted by each content's rightholder.`,
47 generator: `Toraifōsu`, // ^.~
48 medium: medium || 'video',
49 feedLinks: {
50 json: `${webserverUrl}/feeds/${resourceType}.json${queryString}`,
51 atom: `${webserverUrl}/feeds/${resourceType}.atom${queryString}`,
52 rss: isPodcast
53 ? `${webserverUrl}/feeds/podcast/videos.xml${queryString}`
54 : `${webserverUrl}/feeds/${resourceType}.xml${queryString}`
55 },
56
57 ...pick(parameters, [ 'stunServers', 'trackers', 'customXMLNS', 'customTags', 'author', 'person', 'locked' ])
58 })
59}
60
61export function sendFeed (feed: Feed, req: express.Request, res: express.Response) {
62 const format = req.params.format
63
64 if (format === 'atom' || format === 'atom1') {
65 return res.send(feed.atom1()).end()
66 }
67
68 if (format === 'json' || format === 'json1') {
69 return res.send(feed.json1()).end()
70 }
71
72 if (format === 'rss' || format === 'rss2') {
73 return res.send(feed.rss2()).end()
74 }
75
76 // We're in the ambiguous '.xml' case and we look at the format query parameter
77 if (req.query.format === 'atom' || req.query.format === 'atom1') {
78 return res.send(feed.atom1()).end()
79 }
80
81 return res.send(feed.rss2()).end()
82}
83
84export async function buildFeedMetadata (options: {
85 videoChannel?: MChannelBannerAccountDefault
86 account?: MAccountDefault
87 video?: MVideoFullLight
88}) {
89 const { video, videoChannel, account } = options
90
91 let imageUrl = WEBSERVER.URL + '/client/assets/images/icons/icon-96x96.png'
92 let accountImageUrl: string
93 let name: string
94 let userName: string
95 let description: string
96 let email: string
97 let link: string
98 let accountLink: string
99 let user: MUser
100
101 if (videoChannel) {
102 name = videoChannel.getDisplayName()
103 description = videoChannel.description
104 link = videoChannel.getClientUrl()
105 accountLink = videoChannel.Account.getClientUrl()
106
107 if (videoChannel.Actor.hasImage(ActorImageType.AVATAR)) {
108 const videoChannelAvatar = getBiggestActorImage(videoChannel.Actor.Avatars)
109 imageUrl = WEBSERVER.URL + videoChannelAvatar.getStaticPath()
110 }
111
112 if (videoChannel.Account.Actor.hasImage(ActorImageType.AVATAR)) {
113 const accountAvatar = getBiggestActorImage(videoChannel.Account.Actor.Avatars)
114 accountImageUrl = WEBSERVER.URL + accountAvatar.getStaticPath()
115 }
116
117 user = await UserModel.loadById(videoChannel.Account.userId)
118 userName = videoChannel.Account.getDisplayName()
119 } else if (account) {
120 name = account.getDisplayName()
121 description = account.description
122 link = account.getClientUrl()
123 accountLink = link
124
125 if (account.Actor.hasImage(ActorImageType.AVATAR)) {
126 const accountAvatar = getBiggestActorImage(account.Actor.Avatars)
127 imageUrl = WEBSERVER.URL + accountAvatar?.getStaticPath()
128 accountImageUrl = imageUrl
129 }
130
131 user = await UserModel.loadById(account.userId)
132 } else if (video) {
133 name = video.name
134 description = video.description
135 link = video.url
136 } else {
137 name = CONFIG.INSTANCE.NAME
138 description = CONFIG.INSTANCE.DESCRIPTION
139 link = WEBSERVER.URL
140 }
141
142 // If the user is local, has a verified email address, and allows it to be publicly displayed
143 // Return it so the owner can prove ownership of their feed
144 if (user && !user.pluginAuth && user.emailVerified && user.emailPublic) {
145 email = user.email
146 }
147
148 return { name, userName, description, imageUrl, accountImageUrl, email, link, accountLink }
149}
diff --git a/server/controllers/feeds/shared/index.ts b/server/controllers/feeds/shared/index.ts
deleted file mode 100644
index 0136c8477..000000000
--- a/server/controllers/feeds/shared/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
1export * from './video-feed-utils'
2export * from './common-feed-utils'
diff --git a/server/controllers/feeds/shared/video-feed-utils.ts b/server/controllers/feeds/shared/video-feed-utils.ts
deleted file mode 100644
index b154e04fa..000000000
--- a/server/controllers/feeds/shared/video-feed-utils.ts
+++ /dev/null
@@ -1,66 +0,0 @@
1import { mdToOneLinePlainText, toSafeHtml } from '@server/helpers/markdown'
2import { CONFIG } from '@server/initializers/config'
3import { WEBSERVER } from '@server/initializers/constants'
4import { getServerActor } from '@server/models/application/application'
5import { getCategoryLabel } from '@server/models/video/formatter'
6import { DisplayOnlyForFollowerOptions } from '@server/models/video/sql/video'
7import { VideoModel } from '@server/models/video/video'
8import { MThumbnail, MUserDefault } from '@server/types/models'
9import { VideoInclude } from '@shared/models'
10
11export async function getVideosForFeeds (options: {
12 sort: string
13 nsfw: boolean
14 isLocal: boolean
15 include: VideoInclude
16
17 accountId?: number
18 videoChannelId?: number
19 displayOnlyForFollower?: DisplayOnlyForFollowerOptions
20 user?: MUserDefault
21}) {
22 const server = await getServerActor()
23
24 const { data } = await VideoModel.listForApi({
25 start: 0,
26 count: CONFIG.FEEDS.VIDEOS.COUNT,
27 displayOnlyForFollower: {
28 actorId: server.id,
29 orLocalVideos: true
30 },
31 hasFiles: true,
32 countVideos: false,
33
34 ...options
35 })
36
37 return data
38}
39
40export function getCommonVideoFeedAttributes (video: VideoModel) {
41 const localLink = WEBSERVER.URL + video.getWatchStaticPath()
42
43 const thumbnailModels: MThumbnail[] = []
44 if (video.hasPreview()) thumbnailModels.push(video.getPreview())
45 thumbnailModels.push(video.getMiniature())
46
47 return {
48 title: video.name,
49 link: localLink,
50 description: mdToOneLinePlainText(video.getTruncatedDescription()),
51 content: toSafeHtml(video.description),
52
53 date: video.publishedAt,
54 nsfw: video.nsfw,
55
56 category: video.category
57 ? [ { name: getCategoryLabel(video.category) } ]
58 : undefined,
59
60 thumbnails: thumbnailModels.map(t => ({
61 url: WEBSERVER.URL + t.getLocalStaticPath(),
62 width: t.width,
63 height: t.height
64 }))
65 }
66}
diff --git a/server/controllers/feeds/video-feeds.ts b/server/controllers/feeds/video-feeds.ts
deleted file mode 100644
index e5941be40..000000000
--- a/server/controllers/feeds/video-feeds.ts
+++ /dev/null
@@ -1,189 +0,0 @@
1import express from 'express'
2import { extname } from 'path'
3import { Feed } from '@peertube/feed'
4import { cacheRouteFactory } from '@server/middlewares'
5import { VideoModel } from '@server/models/video/video'
6import { VideoInclude } from '@shared/models'
7import { buildNSFWFilter } from '../../helpers/express-utils'
8import { MIMETYPES, ROUTE_CACHE_LIFETIME, WEBSERVER } from '../../initializers/constants'
9import {
10 asyncMiddleware,
11 commonVideosFiltersValidator,
12 feedsFormatValidator,
13 setDefaultVideosSort,
14 setFeedFormatContentType,
15 feedsAccountOrChannelFiltersValidator,
16 videosSortValidator,
17 videoSubscriptionFeedsValidator
18} from '../../middlewares'
19import { buildFeedMetadata, getCommonVideoFeedAttributes, getVideosForFeeds, initFeed, sendFeed } from './shared'
20
21const videoFeedsRouter = express.Router()
22
23const { middleware: cacheRouteMiddleware } = cacheRouteFactory({
24 headerBlacklist: [ 'Content-Type' ]
25})
26
27// ---------------------------------------------------------------------------
28
29videoFeedsRouter.get('/videos.:format',
30 videosSortValidator,
31 setDefaultVideosSort,
32 feedsFormatValidator,
33 setFeedFormatContentType,
34 cacheRouteMiddleware(ROUTE_CACHE_LIFETIME.FEEDS),
35 commonVideosFiltersValidator,
36 asyncMiddleware(feedsAccountOrChannelFiltersValidator),
37 asyncMiddleware(generateVideoFeed)
38)
39
40videoFeedsRouter.get('/subscriptions.:format',
41 videosSortValidator,
42 setDefaultVideosSort,
43 feedsFormatValidator,
44 setFeedFormatContentType,
45 cacheRouteMiddleware(ROUTE_CACHE_LIFETIME.FEEDS),
46 commonVideosFiltersValidator,
47 asyncMiddleware(videoSubscriptionFeedsValidator),
48 asyncMiddleware(generateVideoFeedForSubscriptions)
49)
50
51// ---------------------------------------------------------------------------
52
53export {
54 videoFeedsRouter
55}
56
57// ---------------------------------------------------------------------------
58
59async function generateVideoFeed (req: express.Request, res: express.Response) {
60 const account = res.locals.account
61 const videoChannel = res.locals.videoChannel
62
63 const { name, description, imageUrl, accountImageUrl, link, accountLink } = await buildFeedMetadata({ videoChannel, account })
64
65 const feed = initFeed({
66 name,
67 description,
68 link,
69 isPodcast: false,
70 imageUrl,
71 author: { name, link: accountLink, imageUrl: accountImageUrl },
72 resourceType: 'videos',
73 queryString: new URL(WEBSERVER.URL + req.url).search
74 })
75
76 const data = await getVideosForFeeds({
77 sort: req.query.sort,
78 nsfw: buildNSFWFilter(res, req.query.nsfw),
79 isLocal: req.query.isLocal,
80 include: req.query.include | VideoInclude.FILES,
81 accountId: account?.id,
82 videoChannelId: videoChannel?.id
83 })
84
85 addVideosToFeed(feed, data)
86
87 // Now the feed generation is done, let's send it!
88 return sendFeed(feed, req, res)
89}
90
91async function generateVideoFeedForSubscriptions (req: express.Request, res: express.Response) {
92 const account = res.locals.account
93 const { name, description, imageUrl, link } = await buildFeedMetadata({ account })
94
95 const feed = initFeed({
96 name,
97 description,
98 link,
99 isPodcast: false,
100 imageUrl,
101 resourceType: 'videos',
102 queryString: new URL(WEBSERVER.URL + req.url).search
103 })
104
105 const data = await getVideosForFeeds({
106 sort: req.query.sort,
107 nsfw: buildNSFWFilter(res, req.query.nsfw),
108 isLocal: req.query.isLocal,
109 include: req.query.include | VideoInclude.FILES,
110 displayOnlyForFollower: {
111 actorId: res.locals.user.Account.Actor.id,
112 orLocalVideos: false
113 },
114 user: res.locals.user
115 })
116
117 addVideosToFeed(feed, data)
118
119 // Now the feed generation is done, let's send it!
120 return sendFeed(feed, req, res)
121}
122
123// ---------------------------------------------------------------------------
124
125function addVideosToFeed (feed: Feed, videos: VideoModel[]) {
126 /**
127 * Adding video items to the feed object, one at a time
128 */
129 for (const video of videos) {
130 const formattedVideoFiles = video.getFormattedAllVideoFilesJSON(false)
131
132 const torrents = formattedVideoFiles.map(videoFile => ({
133 title: video.name,
134 url: videoFile.torrentUrl,
135 size_in_bytes: videoFile.size
136 }))
137
138 const videoFiles = formattedVideoFiles.map(videoFile => {
139 return {
140 type: MIMETYPES.VIDEO.EXT_MIMETYPE[extname(videoFile.fileUrl)],
141 medium: 'video',
142 height: videoFile.resolution.id,
143 fileSize: videoFile.size,
144 url: videoFile.fileUrl,
145 framerate: videoFile.fps,
146 duration: video.duration,
147 lang: video.language
148 }
149 })
150
151 feed.addItem({
152 ...getCommonVideoFeedAttributes(video),
153
154 id: WEBSERVER.URL + video.getWatchStaticPath(),
155 author: [
156 {
157 name: video.VideoChannel.getDisplayName(),
158 link: video.VideoChannel.getClientUrl()
159 }
160 ],
161 torrents,
162
163 // Enclosure
164 video: videoFiles.length !== 0
165 ? {
166 url: videoFiles[0].url,
167 length: videoFiles[0].fileSize,
168 type: videoFiles[0].type
169 }
170 : undefined,
171
172 // Media RSS
173 videos: videoFiles,
174
175 embed: {
176 url: WEBSERVER.URL + video.getEmbedStaticPath(),
177 allowFullscreen: true
178 },
179 player: {
180 url: WEBSERVER.URL + video.getWatchStaticPath()
181 },
182 community: {
183 statistics: {
184 views: video.views
185 }
186 }
187 })
188 }
189}
diff --git a/server/controllers/feeds/video-podcast-feeds.ts b/server/controllers/feeds/video-podcast-feeds.ts
deleted file mode 100644
index fca82ba68..000000000
--- a/server/controllers/feeds/video-podcast-feeds.ts
+++ /dev/null
@@ -1,313 +0,0 @@
1import express from 'express'
2import { extname } from 'path'
3import { Feed } from '@peertube/feed'
4import { CustomTag, CustomXMLNS, LiveItemStatus } from '@peertube/feed/lib/typings'
5import { getBiggestActorImage } from '@server/lib/actor-image'
6import { InternalEventEmitter } from '@server/lib/internal-event-emitter'
7import { Hooks } from '@server/lib/plugins/hooks'
8import { buildPodcastGroupsCache, cacheRouteFactory, videoFeedsPodcastSetCacheKey } from '@server/middlewares'
9import { MVideo, MVideoCaptionVideo, MVideoFullLight } from '@server/types/models'
10import { sortObjectComparator } from '@shared/core-utils'
11import { ActorImageType, VideoFile, VideoInclude, VideoResolution, VideoState } from '@shared/models'
12import { buildNSFWFilter } from '../../helpers/express-utils'
13import { MIMETYPES, ROUTE_CACHE_LIFETIME, WEBSERVER } from '../../initializers/constants'
14import { asyncMiddleware, setFeedPodcastContentType, videoFeedsPodcastValidator } from '../../middlewares'
15import { VideoModel } from '../../models/video/video'
16import { VideoCaptionModel } from '../../models/video/video-caption'
17import { buildFeedMetadata, getCommonVideoFeedAttributes, getVideosForFeeds, initFeed } from './shared'
18
19const videoPodcastFeedsRouter = express.Router()
20
21// ---------------------------------------------------------------------------
22
23const { middleware: podcastCacheRouteMiddleware, instance: podcastApiCache } = cacheRouteFactory({
24 headerBlacklist: [ 'Content-Type' ]
25})
26
27for (const event of ([ 'video-created', 'video-updated', 'video-deleted' ] as const)) {
28 InternalEventEmitter.Instance.on(event, ({ video }) => {
29 if (video.remote) return
30
31 podcastApiCache.clearGroupSafe(buildPodcastGroupsCache({ channelId: video.channelId }))
32 })
33}
34
35for (const event of ([ 'channel-updated', 'channel-deleted' ] as const)) {
36 InternalEventEmitter.Instance.on(event, ({ channel }) => {
37 podcastApiCache.clearGroupSafe(buildPodcastGroupsCache({ channelId: channel.id }))
38 })
39}
40
41// ---------------------------------------------------------------------------
42
43videoPodcastFeedsRouter.get('/podcast/videos.xml',
44 setFeedPodcastContentType,
45 videoFeedsPodcastSetCacheKey,
46 podcastCacheRouteMiddleware(ROUTE_CACHE_LIFETIME.FEEDS),
47 asyncMiddleware(videoFeedsPodcastValidator),
48 asyncMiddleware(generateVideoPodcastFeed)
49)
50
51// ---------------------------------------------------------------------------
52
53export {
54 videoPodcastFeedsRouter
55}
56
57// ---------------------------------------------------------------------------
58
59async function generateVideoPodcastFeed (req: express.Request, res: express.Response) {
60 const videoChannel = res.locals.videoChannel
61
62 const { name, userName, description, imageUrl, accountImageUrl, email, link, accountLink } = await buildFeedMetadata({ videoChannel })
63
64 const data = await getVideosForFeeds({
65 sort: '-publishedAt',
66 nsfw: buildNSFWFilter(),
67 // Prevent podcast feeds from listing videos in other instances
68 // helps prevent duplicates when they are indexed -- only the author should control them
69 isLocal: true,
70 include: VideoInclude.FILES,
71 videoChannelId: videoChannel?.id
72 })
73
74 const customTags: CustomTag[] = await Hooks.wrapObject(
75 [],
76 'filter:feed.podcast.channel.create-custom-tags.result',
77 { videoChannel }
78 )
79
80 const customXMLNS: CustomXMLNS[] = await Hooks.wrapObject(
81 [],
82 'filter:feed.podcast.rss.create-custom-xmlns.result'
83 )
84
85 const feed = initFeed({
86 name,
87 description,
88 link,
89 isPodcast: true,
90 imageUrl,
91
92 locked: email
93 ? { isLocked: true, email } // Default to true because we have no way of offering a redirect yet
94 : undefined,
95
96 person: [ { name: userName, href: accountLink, img: accountImageUrl } ],
97 resourceType: 'videos',
98 queryString: new URL(WEBSERVER.URL + req.url).search,
99 medium: 'video',
100 customXMLNS,
101 customTags
102 })
103
104 await addVideosToPodcastFeed(feed, data)
105
106 // Now the feed generation is done, let's send it!
107 return res.send(feed.podcast()).end()
108}
109
110type PodcastMedia =
111 {
112 type: string
113 length: number
114 bitrate: number
115 sources: { uri: string, contentType?: string }[]
116 title: string
117 language?: string
118 } |
119 {
120 sources: { uri: string }[]
121 type: string
122 title: string
123 }
124
125async function generatePodcastItem (options: {
126 video: VideoModel
127 liveItem: boolean
128 media: PodcastMedia[]
129}) {
130 const { video, liveItem, media } = options
131
132 const customTags: CustomTag[] = await Hooks.wrapObject(
133 [],
134 'filter:feed.podcast.video.create-custom-tags.result',
135 { video, liveItem }
136 )
137
138 const account = video.VideoChannel.Account
139
140 const author = {
141 name: account.getDisplayName(),
142 href: account.getClientUrl()
143 }
144
145 const commonAttributes = getCommonVideoFeedAttributes(video)
146 const guid = liveItem
147 ? `${video.uuid}_${video.publishedAt.toISOString()}`
148 : commonAttributes.link
149
150 let personImage: string
151
152 if (account.Actor.hasImage(ActorImageType.AVATAR)) {
153 const avatar = getBiggestActorImage(account.Actor.Avatars)
154 personImage = WEBSERVER.URL + avatar.getStaticPath()
155 }
156
157 return {
158 guid,
159 ...commonAttributes,
160
161 trackers: video.getTrackerUrls(),
162
163 author: [ author ],
164 person: [
165 {
166 ...author,
167
168 img: personImage
169 }
170 ],
171
172 media,
173
174 socialInteract: [
175 {
176 uri: video.url,
177 protocol: 'activitypub',
178 accountUrl: account.getClientUrl()
179 }
180 ],
181
182 customTags
183 }
184}
185
186async function addVideosToPodcastFeed (feed: Feed, videos: VideoModel[]) {
187 const captionsGroup = await VideoCaptionModel.listCaptionsOfMultipleVideos(videos.map(v => v.id))
188
189 for (const video of videos) {
190 if (!video.isLive) {
191 await addVODPodcastItem({ feed, video, captionsGroup })
192 } else if (video.isLive && video.state !== VideoState.LIVE_ENDED) {
193 await addLivePodcastItem({ feed, video })
194 }
195 }
196}
197
198async function addVODPodcastItem (options: {
199 feed: Feed
200 video: VideoModel
201 captionsGroup: { [ id: number ]: MVideoCaptionVideo[] }
202}) {
203 const { feed, video, captionsGroup } = options
204
205 const webVideos = video.getFormattedWebVideoFilesJSON(true)
206 .map(f => buildVODWebVideoFile(video, f))
207 .sort(sortObjectComparator('bitrate', 'desc'))
208
209 const streamingPlaylistFiles = buildVODStreamingPlaylists(video)
210
211 // Order matters here, the first media URI will be the "default"
212 // So web videos are default if enabled
213 const media = [ ...webVideos, ...streamingPlaylistFiles ]
214
215 const videoCaptions = buildVODCaptions(video, captionsGroup[video.id])
216 const item = await generatePodcastItem({ video, liveItem: false, media })
217
218 feed.addPodcastItem({ ...item, subTitle: videoCaptions })
219}
220
221async function addLivePodcastItem (options: {
222 feed: Feed
223 video: VideoModel
224}) {
225 const { feed, video } = options
226
227 let status: LiveItemStatus
228
229 switch (video.state) {
230 case VideoState.WAITING_FOR_LIVE:
231 status = LiveItemStatus.pending
232 break
233 case VideoState.PUBLISHED:
234 status = LiveItemStatus.live
235 break
236 }
237
238 const item = await generatePodcastItem({ video, liveItem: true, media: buildLiveStreamingPlaylists(video) })
239
240 feed.addPodcastLiveItem({ ...item, status, start: video.updatedAt.toISOString() })
241}
242
243// ---------------------------------------------------------------------------
244
245function buildVODWebVideoFile (video: MVideo, videoFile: VideoFile) {
246 const isAudio = videoFile.resolution.id === VideoResolution.H_NOVIDEO
247 const type = isAudio
248 ? MIMETYPES.AUDIO.EXT_MIMETYPE[extname(videoFile.fileUrl)]
249 : MIMETYPES.VIDEO.EXT_MIMETYPE[extname(videoFile.fileUrl)]
250
251 const sources = [
252 { uri: videoFile.fileUrl },
253 { uri: videoFile.torrentUrl, contentType: 'application/x-bittorrent' }
254 ]
255
256 if (videoFile.magnetUri) {
257 sources.push({ uri: videoFile.magnetUri })
258 }
259
260 return {
261 type,
262 title: videoFile.resolution.label,
263 length: videoFile.size,
264 bitrate: videoFile.size / video.duration * 8,
265 language: video.language,
266 sources
267 }
268}
269
270function buildVODStreamingPlaylists (video: MVideoFullLight) {
271 const hls = video.getHLSPlaylist()
272 if (!hls) return []
273
274 return [
275 {
276 type: 'application/x-mpegURL',
277 title: 'HLS',
278 sources: [
279 { uri: hls.getMasterPlaylistUrl(video) }
280 ],
281 language: video.language
282 }
283 ]
284}
285
286function buildLiveStreamingPlaylists (video: MVideoFullLight) {
287 const hls = video.getHLSPlaylist()
288
289 return [
290 {
291 type: 'application/x-mpegURL',
292 title: `HLS live stream`,
293 sources: [
294 { uri: hls.getMasterPlaylistUrl(video) }
295 ],
296 language: video.language
297 }
298 ]
299}
300
301function buildVODCaptions (video: MVideo, videoCaptions: MVideoCaptionVideo[]) {
302 return videoCaptions.map(caption => {
303 const type = MIMETYPES.VIDEO_CAPTIONS.EXT_MIMETYPE[extname(caption.filename)]
304 if (!type) return null
305
306 return {
307 url: caption.getFileUrl(video),
308 language: caption.language,
309 type,
310 rel: 'captions'
311 }
312 }).filter(c => c)
313}
diff --git a/server/controllers/index.ts b/server/controllers/index.ts
deleted file mode 100644
index 8a647aff1..000000000
--- a/server/controllers/index.ts
+++ /dev/null
@@ -1,14 +0,0 @@
1export * from './activitypub'
2export * from './api'
3export * from './sitemap'
4export * from './client'
5export * from './download'
6export * from './feeds'
7export * from './lazy-static'
8export * from './misc'
9export * from './object-storage-proxy'
10export * from './plugins'
11export * from './services'
12export * from './static'
13export * from './tracker'
14export * from './well-known'
diff --git a/server/controllers/lazy-static.ts b/server/controllers/lazy-static.ts
deleted file mode 100644
index dad30365c..000000000
--- a/server/controllers/lazy-static.ts
+++ /dev/null
@@ -1,128 +0,0 @@
1import cors from 'cors'
2import express from 'express'
3import { CONFIG } from '@server/initializers/config'
4import { HttpStatusCode } from '../../shared/models/http/http-error-codes'
5import { FILES_CACHE, LAZY_STATIC_PATHS, STATIC_MAX_AGE } from '../initializers/constants'
6import {
7 AvatarPermanentFileCache,
8 VideoCaptionsSimpleFileCache,
9 VideoMiniaturePermanentFileCache,
10 VideoPreviewsSimpleFileCache,
11 VideoStoryboardsSimpleFileCache,
12 VideoTorrentsSimpleFileCache
13} from '../lib/files-cache'
14import { asyncMiddleware, handleStaticError } from '../middlewares'
15
16// ---------------------------------------------------------------------------
17// Cache initializations
18// ---------------------------------------------------------------------------
19
20VideoPreviewsSimpleFileCache.Instance.init(CONFIG.CACHE.PREVIEWS.SIZE, FILES_CACHE.PREVIEWS.MAX_AGE)
21VideoCaptionsSimpleFileCache.Instance.init(CONFIG.CACHE.VIDEO_CAPTIONS.SIZE, FILES_CACHE.VIDEO_CAPTIONS.MAX_AGE)
22VideoTorrentsSimpleFileCache.Instance.init(CONFIG.CACHE.TORRENTS.SIZE, FILES_CACHE.TORRENTS.MAX_AGE)
23VideoStoryboardsSimpleFileCache.Instance.init(CONFIG.CACHE.STORYBOARDS.SIZE, FILES_CACHE.STORYBOARDS.MAX_AGE)
24
25// ---------------------------------------------------------------------------
26
27const lazyStaticRouter = express.Router()
28
29lazyStaticRouter.use(cors())
30
31lazyStaticRouter.use(
32 LAZY_STATIC_PATHS.AVATARS + ':filename',
33 asyncMiddleware(getActorImage),
34 handleStaticError
35)
36
37lazyStaticRouter.use(
38 LAZY_STATIC_PATHS.BANNERS + ':filename',
39 asyncMiddleware(getActorImage),
40 handleStaticError
41)
42
43lazyStaticRouter.use(
44 LAZY_STATIC_PATHS.THUMBNAILS + ':filename',
45 asyncMiddleware(getThumbnail),
46 handleStaticError
47)
48
49lazyStaticRouter.use(
50 LAZY_STATIC_PATHS.PREVIEWS + ':filename',
51 asyncMiddleware(getPreview),
52 handleStaticError
53)
54
55lazyStaticRouter.use(
56 LAZY_STATIC_PATHS.STORYBOARDS + ':filename',
57 asyncMiddleware(getStoryboard),
58 handleStaticError
59)
60
61lazyStaticRouter.use(
62 LAZY_STATIC_PATHS.VIDEO_CAPTIONS + ':filename',
63 asyncMiddleware(getVideoCaption),
64 handleStaticError
65)
66
67lazyStaticRouter.use(
68 LAZY_STATIC_PATHS.TORRENTS + ':filename',
69 asyncMiddleware(getTorrent),
70 handleStaticError
71)
72
73// ---------------------------------------------------------------------------
74
75export {
76 lazyStaticRouter,
77 getPreview,
78 getVideoCaption
79}
80
81// ---------------------------------------------------------------------------
82const avatarPermanentFileCache = new AvatarPermanentFileCache()
83
84function getActorImage (req: express.Request, res: express.Response, next: express.NextFunction) {
85 const filename = req.params.filename
86
87 return avatarPermanentFileCache.lazyServe({ filename, res, next })
88}
89
90// ---------------------------------------------------------------------------
91const videoMiniaturePermanentFileCache = new VideoMiniaturePermanentFileCache()
92
93function getThumbnail (req: express.Request, res: express.Response, next: express.NextFunction) {
94 const filename = req.params.filename
95
96 return videoMiniaturePermanentFileCache.lazyServe({ filename, res, next })
97}
98
99// ---------------------------------------------------------------------------
100
101async function getPreview (req: express.Request, res: express.Response) {
102 const result = await VideoPreviewsSimpleFileCache.Instance.getFilePath(req.params.filename)
103 if (!result) return res.status(HttpStatusCode.NOT_FOUND_404).end()
104
105 return res.sendFile(result.path, { maxAge: STATIC_MAX_AGE.LAZY_SERVER })
106}
107
108async function getStoryboard (req: express.Request, res: express.Response) {
109 const result = await VideoStoryboardsSimpleFileCache.Instance.getFilePath(req.params.filename)
110 if (!result) return res.status(HttpStatusCode.NOT_FOUND_404).end()
111
112 return res.sendFile(result.path, { maxAge: STATIC_MAX_AGE.LAZY_SERVER })
113}
114
115async function getVideoCaption (req: express.Request, res: express.Response) {
116 const result = await VideoCaptionsSimpleFileCache.Instance.getFilePath(req.params.filename)
117 if (!result) return res.status(HttpStatusCode.NOT_FOUND_404).end()
118
119 return res.sendFile(result.path, { maxAge: STATIC_MAX_AGE.LAZY_SERVER })
120}
121
122async function getTorrent (req: express.Request, res: express.Response) {
123 const result = await VideoTorrentsSimpleFileCache.Instance.getFilePath(req.params.filename)
124 if (!result) return res.status(HttpStatusCode.NOT_FOUND_404).end()
125
126 // Torrents still use the old naming convention (video uuid + .torrent)
127 return res.sendFile(result.path, { maxAge: STATIC_MAX_AGE.SERVER })
128}
diff --git a/server/controllers/misc.ts b/server/controllers/misc.ts
deleted file mode 100644
index a7dfc7867..000000000
--- a/server/controllers/misc.ts
+++ /dev/null
@@ -1,210 +0,0 @@
1import cors from 'cors'
2import express from 'express'
3import { CONFIG, isEmailEnabled } from '@server/initializers/config'
4import { serveIndexHTML } from '@server/lib/client-html'
5import { ServerConfigManager } from '@server/lib/server-config-manager'
6import { HttpStatusCode } from '@shared/models'
7import { HttpNodeinfoDiasporaSoftwareNsSchema20 } from '../../shared/models/nodeinfo/nodeinfo.model'
8import { CONSTRAINTS_FIELDS, DEFAULT_THEME_NAME, PEERTUBE_VERSION, ROUTE_CACHE_LIFETIME } from '../initializers/constants'
9import { getThemeOrDefault } from '../lib/plugins/theme-utils'
10import { apiRateLimiter, asyncMiddleware } from '../middlewares'
11import { cacheRoute } from '../middlewares/cache/cache'
12import { UserModel } from '../models/user/user'
13import { VideoModel } from '../models/video/video'
14import { VideoCommentModel } from '../models/video/video-comment'
15
16const miscRouter = express.Router()
17
18miscRouter.use(cors())
19
20miscRouter.use('/nodeinfo/:version.json',
21 apiRateLimiter,
22 cacheRoute(ROUTE_CACHE_LIFETIME.NODEINFO),
23 asyncMiddleware(generateNodeinfo)
24)
25
26// robots.txt service
27miscRouter.get('/robots.txt',
28 apiRateLimiter,
29 cacheRoute(ROUTE_CACHE_LIFETIME.ROBOTS),
30 (_, res: express.Response) => {
31 res.type('text/plain')
32
33 return res.send(CONFIG.INSTANCE.ROBOTS)
34 }
35)
36
37miscRouter.all('/teapot',
38 apiRateLimiter,
39 getCup,
40 asyncMiddleware(serveIndexHTML)
41)
42
43// security.txt service
44miscRouter.get('/security.txt',
45 apiRateLimiter,
46 (_, res: express.Response) => {
47 return res.redirect(HttpStatusCode.MOVED_PERMANENTLY_301, '/.well-known/security.txt')
48 }
49)
50
51// ---------------------------------------------------------------------------
52
53export {
54 miscRouter
55}
56
57// ---------------------------------------------------------------------------
58
59async function generateNodeinfo (req: express.Request, res: express.Response) {
60 const { totalVideos } = await VideoModel.getStats()
61 const { totalLocalVideoComments } = await VideoCommentModel.getStats()
62 const { totalUsers, totalMonthlyActiveUsers, totalHalfYearActiveUsers } = await UserModel.getStats()
63
64 if (!req.params.version || req.params.version !== '2.0') {
65 return res.fail({
66 status: HttpStatusCode.NOT_FOUND_404,
67 message: 'Nodeinfo schema version not handled'
68 })
69 }
70
71 const json = {
72 version: '2.0',
73 software: {
74 name: 'peertube',
75 version: PEERTUBE_VERSION
76 },
77 protocols: [
78 'activitypub'
79 ],
80 services: {
81 inbound: [],
82 outbound: [
83 'atom1.0',
84 'rss2.0'
85 ]
86 },
87 openRegistrations: CONFIG.SIGNUP.ENABLED,
88 usage: {
89 users: {
90 total: totalUsers,
91 activeMonth: totalMonthlyActiveUsers,
92 activeHalfyear: totalHalfYearActiveUsers
93 },
94 localPosts: totalVideos,
95 localComments: totalLocalVideoComments
96 },
97 metadata: {
98 taxonomy: {
99 postsName: 'Videos'
100 },
101 nodeName: CONFIG.INSTANCE.NAME,
102 nodeDescription: CONFIG.INSTANCE.SHORT_DESCRIPTION,
103 nodeConfig: {
104 search: {
105 remoteUri: {
106 users: CONFIG.SEARCH.REMOTE_URI.USERS,
107 anonymous: CONFIG.SEARCH.REMOTE_URI.ANONYMOUS
108 }
109 },
110 plugin: {
111 registered: ServerConfigManager.Instance.getRegisteredPlugins()
112 },
113 theme: {
114 registered: ServerConfigManager.Instance.getRegisteredThemes(),
115 default: getThemeOrDefault(CONFIG.THEME.DEFAULT, DEFAULT_THEME_NAME)
116 },
117 email: {
118 enabled: isEmailEnabled()
119 },
120 contactForm: {
121 enabled: CONFIG.CONTACT_FORM.ENABLED
122 },
123 transcoding: {
124 hls: {
125 enabled: CONFIG.TRANSCODING.HLS.ENABLED
126 },
127 web_videos: {
128 enabled: CONFIG.TRANSCODING.WEB_VIDEOS.ENABLED
129 },
130 enabledResolutions: ServerConfigManager.Instance.getEnabledResolutions('vod')
131 },
132 live: {
133 enabled: CONFIG.LIVE.ENABLED,
134 transcoding: {
135 enabled: CONFIG.LIVE.TRANSCODING.ENABLED,
136 enabledResolutions: ServerConfigManager.Instance.getEnabledResolutions('live')
137 }
138 },
139 import: {
140 videos: {
141 http: {
142 enabled: CONFIG.IMPORT.VIDEOS.HTTP.ENABLED
143 },
144 torrent: {
145 enabled: CONFIG.IMPORT.VIDEOS.TORRENT.ENABLED
146 }
147 }
148 },
149 autoBlacklist: {
150 videos: {
151 ofUsers: {
152 enabled: CONFIG.AUTO_BLACKLIST.VIDEOS.OF_USERS.ENABLED
153 }
154 }
155 },
156 avatar: {
157 file: {
158 size: {
159 max: CONSTRAINTS_FIELDS.ACTORS.IMAGE.FILE_SIZE.max
160 },
161 extensions: CONSTRAINTS_FIELDS.ACTORS.IMAGE.EXTNAME
162 }
163 },
164 video: {
165 image: {
166 extensions: CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME,
167 size: {
168 max: CONSTRAINTS_FIELDS.VIDEOS.IMAGE.FILE_SIZE.max
169 }
170 },
171 file: {
172 extensions: CONSTRAINTS_FIELDS.VIDEOS.EXTNAME
173 }
174 },
175 videoCaption: {
176 file: {
177 size: {
178 max: CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.FILE_SIZE.max
179 },
180 extensions: CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.EXTNAME
181 }
182 },
183 user: {
184 videoQuota: CONFIG.USER.VIDEO_QUOTA,
185 videoQuotaDaily: CONFIG.USER.VIDEO_QUOTA_DAILY
186 },
187 trending: {
188 videos: {
189 intervalDays: CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS
190 }
191 },
192 tracker: {
193 enabled: CONFIG.TRACKER.ENABLED
194 }
195 }
196 }
197 } as HttpNodeinfoDiasporaSoftwareNsSchema20
198
199 res.contentType('application/json; profile="http://nodeinfo.diaspora.software/ns/schema/2.0#"')
200 .send(json)
201 .end()
202}
203
204function getCup (req: express.Request, res: express.Response, next: express.NextFunction) {
205 res.status(HttpStatusCode.I_AM_A_TEAPOT_418)
206 res.setHeader('Accept-Additions', 'Non-Dairy;1,Sugar;1')
207 res.setHeader('Safe', 'if-sepia-awake')
208
209 return next()
210}
diff --git a/server/controllers/object-storage-proxy.ts b/server/controllers/object-storage-proxy.ts
deleted file mode 100644
index d0c59bf93..000000000
--- a/server/controllers/object-storage-proxy.ts
+++ /dev/null
@@ -1,60 +0,0 @@
1import cors from 'cors'
2import express from 'express'
3import { OBJECT_STORAGE_PROXY_PATHS } from '@server/initializers/constants'
4import { proxifyHLS, proxifyWebVideoFile } from '@server/lib/object-storage'
5import {
6 asyncMiddleware,
7 ensureCanAccessPrivateVideoHLSFiles,
8 ensureCanAccessVideoPrivateWebVideoFiles,
9 ensurePrivateObjectStorageProxyIsEnabled,
10 optionalAuthenticate
11} from '@server/middlewares'
12import { doReinjectVideoFileToken } from './shared/m3u8-playlist'
13
14const objectStorageProxyRouter = express.Router()
15
16objectStorageProxyRouter.use(cors())
17
18objectStorageProxyRouter.get(
19 [ OBJECT_STORAGE_PROXY_PATHS.PRIVATE_WEB_VIDEOS + ':filename', OBJECT_STORAGE_PROXY_PATHS.LEGACY_PRIVATE_WEB_VIDEOS + ':filename' ],
20 ensurePrivateObjectStorageProxyIsEnabled,
21 optionalAuthenticate,
22 asyncMiddleware(ensureCanAccessVideoPrivateWebVideoFiles),
23 asyncMiddleware(proxifyWebVideoController)
24)
25
26objectStorageProxyRouter.get(OBJECT_STORAGE_PROXY_PATHS.STREAMING_PLAYLISTS.PRIVATE_HLS + ':videoUUID/:filename',
27 ensurePrivateObjectStorageProxyIsEnabled,
28 optionalAuthenticate,
29 asyncMiddleware(ensureCanAccessPrivateVideoHLSFiles),
30 asyncMiddleware(proxifyHLSController)
31)
32
33// ---------------------------------------------------------------------------
34
35export {
36 objectStorageProxyRouter
37}
38
39function proxifyWebVideoController (req: express.Request, res: express.Response) {
40 const filename = req.params.filename
41
42 return proxifyWebVideoFile({ req, res, filename })
43}
44
45function proxifyHLSController (req: express.Request, res: express.Response) {
46 const playlist = res.locals.videoStreamingPlaylist
47 const video = res.locals.onlyVideo
48 const filename = req.params.filename
49
50 const reinjectVideoFileToken = filename.endsWith('.m3u8') && doReinjectVideoFileToken(req)
51
52 return proxifyHLS({
53 req,
54 res,
55 playlist,
56 video,
57 filename,
58 reinjectVideoFileToken
59 })
60}
diff --git a/server/controllers/plugins.ts b/server/controllers/plugins.ts
deleted file mode 100644
index f0491b16a..000000000
--- a/server/controllers/plugins.ts
+++ /dev/null
@@ -1,175 +0,0 @@
1import express from 'express'
2import { join } from 'path'
3import { logger } from '@server/helpers/logger'
4import { CONFIG } from '@server/initializers/config'
5import { buildRateLimiter } from '@server/middlewares'
6import { optionalAuthenticate } from '@server/middlewares/auth'
7import { getCompleteLocale, is18nLocale } from '../../shared/core-utils/i18n'
8import { HttpStatusCode } from '../../shared/models/http/http-error-codes'
9import { PluginType } from '../../shared/models/plugins/plugin.type'
10import { isProdInstance } from '../helpers/core-utils'
11import { PLUGIN_GLOBAL_CSS_PATH } from '../initializers/constants'
12import { PluginManager, RegisteredPlugin } from '../lib/plugins/plugin-manager'
13import { getExternalAuthValidator, getPluginValidator, pluginStaticDirectoryValidator } from '../middlewares/validators/plugins'
14import { serveThemeCSSValidator } from '../middlewares/validators/themes'
15
16const sendFileOptions = {
17 maxAge: '30 days',
18 immutable: isProdInstance()
19}
20
21const pluginsRouter = express.Router()
22
23const pluginsRateLimiter = buildRateLimiter({
24 windowMs: CONFIG.RATES_LIMIT.PLUGINS.WINDOW_MS,
25 max: CONFIG.RATES_LIMIT.PLUGINS.MAX
26})
27
28pluginsRouter.get('/plugins/global.css',
29 pluginsRateLimiter,
30 servePluginGlobalCSS
31)
32
33pluginsRouter.get('/plugins/translations/:locale.json',
34 pluginsRateLimiter,
35 getPluginTranslations
36)
37
38pluginsRouter.get('/plugins/:pluginName/:pluginVersion/auth/:authName',
39 pluginsRateLimiter,
40 getPluginValidator(PluginType.PLUGIN),
41 getExternalAuthValidator,
42 handleAuthInPlugin
43)
44
45pluginsRouter.get('/plugins/:pluginName/:pluginVersion/static/:staticEndpoint(*)',
46 pluginsRateLimiter,
47 getPluginValidator(PluginType.PLUGIN),
48 pluginStaticDirectoryValidator,
49 servePluginStaticDirectory
50)
51
52pluginsRouter.get('/plugins/:pluginName/:pluginVersion/client-scripts/:staticEndpoint(*)',
53 pluginsRateLimiter,
54 getPluginValidator(PluginType.PLUGIN),
55 pluginStaticDirectoryValidator,
56 servePluginClientScripts
57)
58
59pluginsRouter.use('/plugins/:pluginName/router',
60 pluginsRateLimiter,
61 getPluginValidator(PluginType.PLUGIN, false),
62 optionalAuthenticate,
63 servePluginCustomRoutes
64)
65
66pluginsRouter.use('/plugins/:pluginName/:pluginVersion/router',
67 pluginsRateLimiter,
68 getPluginValidator(PluginType.PLUGIN),
69 optionalAuthenticate,
70 servePluginCustomRoutes
71)
72
73pluginsRouter.get('/themes/:pluginName/:pluginVersion/static/:staticEndpoint(*)',
74 pluginsRateLimiter,
75 getPluginValidator(PluginType.THEME),
76 pluginStaticDirectoryValidator,
77 servePluginStaticDirectory
78)
79
80pluginsRouter.get('/themes/:pluginName/:pluginVersion/client-scripts/:staticEndpoint(*)',
81 pluginsRateLimiter,
82 getPluginValidator(PluginType.THEME),
83 pluginStaticDirectoryValidator,
84 servePluginClientScripts
85)
86
87pluginsRouter.get('/themes/:themeName/:themeVersion/css/:staticEndpoint(*)',
88 pluginsRateLimiter,
89 serveThemeCSSValidator,
90 serveThemeCSSDirectory
91)
92
93// ---------------------------------------------------------------------------
94
95export {
96 pluginsRouter
97}
98
99// ---------------------------------------------------------------------------
100
101function servePluginGlobalCSS (req: express.Request, res: express.Response) {
102 // Only cache requests that have a ?hash=... query param
103 const globalCSSOptions = req.query.hash
104 ? sendFileOptions
105 : {}
106
107 return res.sendFile(PLUGIN_GLOBAL_CSS_PATH, globalCSSOptions)
108}
109
110function getPluginTranslations (req: express.Request, res: express.Response) {
111 const locale = req.params.locale
112
113 if (is18nLocale(locale)) {
114 const completeLocale = getCompleteLocale(locale)
115 const json = PluginManager.Instance.getTranslations(completeLocale)
116
117 return res.json(json)
118 }
119
120 return res.status(HttpStatusCode.NOT_FOUND_404).end()
121}
122
123function servePluginStaticDirectory (req: express.Request, res: express.Response) {
124 const plugin: RegisteredPlugin = res.locals.registeredPlugin
125 const staticEndpoint = req.params.staticEndpoint
126
127 const [ directory, ...file ] = staticEndpoint.split('/')
128
129 const staticPath = plugin.staticDirs[directory]
130 if (!staticPath) return res.status(HttpStatusCode.NOT_FOUND_404).end()
131
132 const filepath = file.join('/')
133 return res.sendFile(join(plugin.path, staticPath, filepath), sendFileOptions)
134}
135
136function servePluginCustomRoutes (req: express.Request, res: express.Response, next: express.NextFunction) {
137 const plugin: RegisteredPlugin = res.locals.registeredPlugin
138 const router = PluginManager.Instance.getRouter(plugin.npmName)
139
140 if (!router) return res.status(HttpStatusCode.NOT_FOUND_404).end()
141
142 return router(req, res, next)
143}
144
145function servePluginClientScripts (req: express.Request, res: express.Response) {
146 const plugin: RegisteredPlugin = res.locals.registeredPlugin
147 const staticEndpoint = req.params.staticEndpoint
148
149 const file = plugin.clientScripts[staticEndpoint]
150 if (!file) return res.status(HttpStatusCode.NOT_FOUND_404).end()
151
152 return res.sendFile(join(plugin.path, staticEndpoint), sendFileOptions)
153}
154
155function serveThemeCSSDirectory (req: express.Request, res: express.Response) {
156 const plugin: RegisteredPlugin = res.locals.registeredPlugin
157 const staticEndpoint = req.params.staticEndpoint
158
159 if (plugin.css.includes(staticEndpoint) === false) {
160 return res.status(HttpStatusCode.NOT_FOUND_404).end()
161 }
162
163 return res.sendFile(join(plugin.path, staticEndpoint), sendFileOptions)
164}
165
166function handleAuthInPlugin (req: express.Request, res: express.Response) {
167 const authOptions = res.locals.externalAuth
168
169 try {
170 logger.debug('Forwarding auth plugin request in %s of plugin %s.', authOptions.authName, res.locals.registeredPlugin.npmName)
171 authOptions.onAuthRequest(req, res)
172 } catch (err) {
173 logger.error('Forward request error in auth %s of plugin %s.', authOptions.authName, res.locals.registeredPlugin.npmName, { err })
174 }
175}
diff --git a/server/controllers/services.ts b/server/controllers/services.ts
deleted file mode 100644
index 0fd63a30f..000000000
--- a/server/controllers/services.ts
+++ /dev/null
@@ -1,165 +0,0 @@
1import express from 'express'
2import { MChannelSummary } from '@server/types/models'
3import { escapeHTML } from '@shared/core-utils/renderer'
4import { EMBED_SIZE, PREVIEWS_SIZE, THUMBNAILS_SIZE, WEBSERVER } from '../initializers/constants'
5import { apiRateLimiter, asyncMiddleware, oembedValidator } from '../middlewares'
6import { accountNameWithHostGetValidator } from '../middlewares/validators'
7import { forceNumber } from '@shared/core-utils'
8
9const servicesRouter = express.Router()
10
11servicesRouter.use('/oembed',
12 apiRateLimiter,
13 asyncMiddleware(oembedValidator),
14 generateOEmbed
15)
16servicesRouter.use('/redirect/accounts/:accountName',
17 apiRateLimiter,
18 asyncMiddleware(accountNameWithHostGetValidator),
19 redirectToAccountUrl
20)
21
22// ---------------------------------------------------------------------------
23
24export {
25 servicesRouter
26}
27
28// ---------------------------------------------------------------------------
29
30function generateOEmbed (req: express.Request, res: express.Response) {
31 if (res.locals.videoAll) return generateVideoOEmbed(req, res)
32
33 return generatePlaylistOEmbed(req, res)
34}
35
36function generatePlaylistOEmbed (req: express.Request, res: express.Response) {
37 const playlist = res.locals.videoPlaylistSummary
38
39 const json = buildOEmbed({
40 channel: playlist.VideoChannel,
41 title: playlist.name,
42 embedPath: playlist.getEmbedStaticPath() + buildPlayerURLQuery(req.query.url),
43 previewPath: playlist.getThumbnailStaticPath(),
44 previewSize: THUMBNAILS_SIZE,
45 req
46 })
47
48 return res.json(json)
49}
50
51function generateVideoOEmbed (req: express.Request, res: express.Response) {
52 const video = res.locals.videoAll
53
54 const json = buildOEmbed({
55 channel: video.VideoChannel,
56 title: video.name,
57 embedPath: video.getEmbedStaticPath() + buildPlayerURLQuery(req.query.url),
58 previewPath: video.getPreviewStaticPath(),
59 previewSize: PREVIEWS_SIZE,
60 req
61 })
62
63 return res.json(json)
64}
65
66function buildPlayerURLQuery (inputQueryUrl: string) {
67 const allowedParameters = new Set([
68 'start',
69 'stop',
70 'loop',
71 'autoplay',
72 'muted',
73 'controls',
74 'controlBar',
75 'title',
76 'api',
77 'warningTitle',
78 'peertubeLink',
79 'p2p',
80 'subtitle',
81 'bigPlayBackgroundColor',
82 'mode',
83 'foregroundColor'
84 ])
85
86 const params = new URLSearchParams()
87
88 new URL(inputQueryUrl).searchParams.forEach((v, k) => {
89 if (allowedParameters.has(k)) {
90 params.append(k, v)
91 }
92 })
93
94 const stringQuery = params.toString()
95 if (!stringQuery) return ''
96
97 return '?' + stringQuery
98}
99
100function buildOEmbed (options: {
101 req: express.Request
102 title: string
103 channel: MChannelSummary
104 previewPath: string | null
105 embedPath: string
106 previewSize: {
107 height: number
108 width: number
109 }
110}) {
111 const { req, previewSize, previewPath, title, channel, embedPath } = options
112
113 const webserverUrl = WEBSERVER.URL
114 const maxHeight = forceNumber(req.query.maxheight)
115 const maxWidth = forceNumber(req.query.maxwidth)
116
117 const embedUrl = webserverUrl + embedPath
118 const embedTitle = escapeHTML(title)
119
120 let thumbnailUrl = previewPath
121 ? webserverUrl + previewPath
122 : undefined
123
124 let embedWidth = EMBED_SIZE.width
125 if (maxWidth < embedWidth) embedWidth = maxWidth
126
127 let embedHeight = EMBED_SIZE.height
128 if (maxHeight < embedHeight) embedHeight = maxHeight
129
130 // Our thumbnail is too big for the consumer
131 if (
132 (maxHeight !== undefined && maxHeight < previewSize.height) ||
133 (maxWidth !== undefined && maxWidth < previewSize.width)
134 ) {
135 thumbnailUrl = undefined
136 }
137
138 const html = `<iframe width="${embedWidth}" height="${embedHeight}" sandbox="allow-same-origin allow-scripts allow-popups" ` +
139 `title="${embedTitle}" src="${embedUrl}" frameborder="0" allowfullscreen></iframe>`
140
141 const json: any = {
142 type: 'video',
143 version: '1.0',
144 html,
145 width: embedWidth,
146 height: embedHeight,
147 title,
148 author_name: channel.name,
149 author_url: channel.Actor.url,
150 provider_name: 'PeerTube',
151 provider_url: webserverUrl
152 }
153
154 if (thumbnailUrl !== undefined) {
155 json.thumbnail_url = thumbnailUrl
156 json.thumbnail_width = previewSize.width
157 json.thumbnail_height = previewSize.height
158 }
159
160 return json
161}
162
163function redirectToAccountUrl (req: express.Request, res: express.Response, next: express.NextFunction) {
164 return res.redirect(res.locals.account.Actor.url)
165}
diff --git a/server/controllers/shared/m3u8-playlist.ts b/server/controllers/shared/m3u8-playlist.ts
deleted file mode 100644
index cea5eb5d2..000000000
--- a/server/controllers/shared/m3u8-playlist.ts
+++ /dev/null
@@ -1,18 +0,0 @@
1import express from 'express'
2
3function doReinjectVideoFileToken (req: express.Request) {
4 return req.query.videoFileToken && req.query.reinjectVideoFileToken
5}
6
7function buildReinjectVideoFileTokenQuery (req: express.Request, isMaster: boolean) {
8 const query = 'videoFileToken=' + req.query.videoFileToken
9 if (isMaster) {
10 return query + '&reinjectVideoFileToken=true'
11 }
12 return query
13}
14
15export {
16 doReinjectVideoFileToken,
17 buildReinjectVideoFileTokenQuery
18}
diff --git a/server/controllers/sitemap.ts b/server/controllers/sitemap.ts
deleted file mode 100644
index 07f4c554e..000000000
--- a/server/controllers/sitemap.ts
+++ /dev/null
@@ -1,115 +0,0 @@
1import express from 'express'
2import { truncate } from 'lodash'
3import { ErrorLevel, SitemapStream, streamToPromise } from 'sitemap'
4import { logger } from '@server/helpers/logger'
5import { getServerActor } from '@server/models/application/application'
6import { buildNSFWFilter } from '../helpers/express-utils'
7import { ROUTE_CACHE_LIFETIME, WEBSERVER } from '../initializers/constants'
8import { apiRateLimiter, asyncMiddleware } from '../middlewares'
9import { cacheRoute } from '../middlewares/cache/cache'
10import { AccountModel } from '../models/account/account'
11import { VideoModel } from '../models/video/video'
12import { VideoChannelModel } from '../models/video/video-channel'
13
14const sitemapRouter = express.Router()
15
16sitemapRouter.use('/sitemap.xml',
17 apiRateLimiter,
18 cacheRoute(ROUTE_CACHE_LIFETIME.SITEMAP),
19 asyncMiddleware(getSitemap)
20)
21
22// ---------------------------------------------------------------------------
23
24export {
25 sitemapRouter
26}
27
28// ---------------------------------------------------------------------------
29
30async function getSitemap (req: express.Request, res: express.Response) {
31 let urls = getSitemapBasicUrls()
32
33 urls = urls.concat(await getSitemapLocalVideoUrls())
34 urls = urls.concat(await getSitemapVideoChannelUrls())
35 urls = urls.concat(await getSitemapAccountUrls())
36
37 const sitemapStream = new SitemapStream({
38 hostname: WEBSERVER.URL,
39 errorHandler: (err: Error, level: ErrorLevel) => {
40 if (level === 'warn') {
41 logger.warn('Warning in sitemap generation.', { err })
42 } else if (level === 'throw') {
43 logger.error('Error in sitemap generation.', { err })
44
45 throw err
46 }
47 }
48 })
49
50 for (const urlObj of urls) {
51 sitemapStream.write(urlObj)
52 }
53 sitemapStream.end()
54
55 const xml = await streamToPromise(sitemapStream)
56
57 res.header('Content-Type', 'application/xml')
58 res.send(xml)
59}
60
61async function getSitemapVideoChannelUrls () {
62 const rows = await VideoChannelModel.listLocalsForSitemap('createdAt')
63
64 return rows.map(channel => ({
65 url: WEBSERVER.URL + '/video-channels/' + channel.Actor.preferredUsername
66 }))
67}
68
69async function getSitemapAccountUrls () {
70 const rows = await AccountModel.listLocalsForSitemap('createdAt')
71
72 return rows.map(channel => ({
73 url: WEBSERVER.URL + '/accounts/' + channel.Actor.preferredUsername
74 }))
75}
76
77async function getSitemapLocalVideoUrls () {
78 const serverActor = await getServerActor()
79
80 const { data } = await VideoModel.listForApi({
81 start: 0,
82 count: undefined,
83 sort: 'createdAt',
84 displayOnlyForFollower: {
85 actorId: serverActor.id,
86 orLocalVideos: true
87 },
88 isLocal: true,
89 nsfw: buildNSFWFilter(),
90 countVideos: false
91 })
92
93 return data.map(v => ({
94 url: WEBSERVER.URL + v.getWatchStaticPath(),
95 video: [
96 {
97 // Sitemap title should be < 100 characters
98 title: truncate(v.name, { length: 100, omission: '...' }),
99 // Sitemap description should be < 2000 characters
100 description: truncate(v.description || v.name, { length: 2000, omission: '...' }),
101 player_loc: WEBSERVER.URL + v.getEmbedStaticPath(),
102 thumbnail_loc: WEBSERVER.URL + v.getMiniatureStaticPath()
103 }
104 ]
105 }))
106}
107
108function getSitemapBasicUrls () {
109 const paths = [
110 '/about/instance',
111 '/videos/local'
112 ]
113
114 return paths.map(p => ({ url: WEBSERVER.URL + p }))
115}
diff --git a/server/controllers/static.ts b/server/controllers/static.ts
deleted file mode 100644
index 97caa8292..000000000
--- a/server/controllers/static.ts
+++ /dev/null
@@ -1,116 +0,0 @@
1import cors from 'cors'
2import express from 'express'
3import { readFile } from 'fs-extra'
4import { join } from 'path'
5import { injectQueryToPlaylistUrls } from '@server/lib/hls'
6import {
7 asyncMiddleware,
8 ensureCanAccessPrivateVideoHLSFiles,
9 ensureCanAccessVideoPrivateWebVideoFiles,
10 handleStaticError,
11 optionalAuthenticate
12} from '@server/middlewares'
13import { HttpStatusCode } from '@shared/models'
14import { CONFIG } from '../initializers/config'
15import { DIRECTORIES, STATIC_MAX_AGE, STATIC_PATHS } from '../initializers/constants'
16import { buildReinjectVideoFileTokenQuery, doReinjectVideoFileToken } from './shared/m3u8-playlist'
17
18const staticRouter = express.Router()
19
20// Cors is very important to let other servers access torrent and video files
21staticRouter.use(cors())
22
23// ---------------------------------------------------------------------------
24// Web videos/Classic videos
25// ---------------------------------------------------------------------------
26
27const privateWebVideoStaticMiddlewares = CONFIG.STATIC_FILES.PRIVATE_FILES_REQUIRE_AUTH === true
28 ? [ optionalAuthenticate, asyncMiddleware(ensureCanAccessVideoPrivateWebVideoFiles) ]
29 : []
30
31staticRouter.use(
32 [ STATIC_PATHS.PRIVATE_WEB_VIDEOS, STATIC_PATHS.LEGACY_PRIVATE_WEB_VIDEOS ],
33 ...privateWebVideoStaticMiddlewares,
34 express.static(DIRECTORIES.VIDEOS.PRIVATE, { fallthrough: false }),
35 handleStaticError
36)
37staticRouter.use(
38 [ STATIC_PATHS.WEB_VIDEOS, STATIC_PATHS.LEGACY_WEB_VIDEOS ],
39 express.static(DIRECTORIES.VIDEOS.PUBLIC, { fallthrough: false }),
40 handleStaticError
41)
42
43staticRouter.use(
44 STATIC_PATHS.REDUNDANCY,
45 express.static(CONFIG.STORAGE.REDUNDANCY_DIR, { fallthrough: false }),
46 handleStaticError
47)
48
49// ---------------------------------------------------------------------------
50// HLS
51// ---------------------------------------------------------------------------
52
53const privateHLSStaticMiddlewares = CONFIG.STATIC_FILES.PRIVATE_FILES_REQUIRE_AUTH === true
54 ? [ optionalAuthenticate, asyncMiddleware(ensureCanAccessPrivateVideoHLSFiles) ]
55 : []
56
57staticRouter.use(
58 STATIC_PATHS.STREAMING_PLAYLISTS.PRIVATE_HLS + ':videoUUID/:playlistName.m3u8',
59 ...privateHLSStaticMiddlewares,
60 asyncMiddleware(servePrivateM3U8)
61)
62
63staticRouter.use(
64 STATIC_PATHS.STREAMING_PLAYLISTS.PRIVATE_HLS,
65 ...privateHLSStaticMiddlewares,
66 express.static(DIRECTORIES.HLS_STREAMING_PLAYLIST.PRIVATE, { fallthrough: false }),
67 handleStaticError
68)
69staticRouter.use(
70 STATIC_PATHS.STREAMING_PLAYLISTS.HLS,
71 express.static(DIRECTORIES.HLS_STREAMING_PLAYLIST.PUBLIC, { fallthrough: false }),
72 handleStaticError
73)
74
75// FIXME: deprecated in v6, to remove
76const thumbnailsPhysicalPath = CONFIG.STORAGE.THUMBNAILS_DIR
77staticRouter.use(
78 STATIC_PATHS.THUMBNAILS,
79 express.static(thumbnailsPhysicalPath, { maxAge: STATIC_MAX_AGE.SERVER, fallthrough: false }),
80 handleStaticError
81)
82
83// ---------------------------------------------------------------------------
84
85export {
86 staticRouter
87}
88
89// ---------------------------------------------------------------------------
90
91async function servePrivateM3U8 (req: express.Request, res: express.Response) {
92 const path = join(DIRECTORIES.HLS_STREAMING_PLAYLIST.PRIVATE, req.params.videoUUID, req.params.playlistName + '.m3u8')
93 const filename = req.params.playlistName + '.m3u8'
94
95 let playlistContent: string
96
97 try {
98 playlistContent = await readFile(path, 'utf-8')
99 } catch (err) {
100 if (err.message.includes('ENOENT')) {
101 return res.fail({
102 status: HttpStatusCode.NOT_FOUND_404,
103 message: 'File not found'
104 })
105 }
106
107 throw err
108 }
109
110 // Inject token in playlist so players that cannot alter the HTTP request can still watch the video
111 const transformedContent = doReinjectVideoFileToken(req)
112 ? injectQueryToPlaylistUrls(playlistContent, buildReinjectVideoFileTokenQuery(req, filename.endsWith('master.m3u8')))
113 : playlistContent
114
115 return res.set('content-type', 'application/vnd.apple.mpegurl').send(transformedContent).end()
116}
diff --git a/server/controllers/tracker.ts b/server/controllers/tracker.ts
deleted file mode 100644
index 9a8aa88bc..000000000
--- a/server/controllers/tracker.ts
+++ /dev/null
@@ -1,148 +0,0 @@
1import { Server as TrackerServer } from 'bittorrent-tracker'
2import express from 'express'
3import { createServer } from 'http'
4import { LRUCache } from 'lru-cache'
5import proxyAddr from 'proxy-addr'
6import { WebSocketServer } from 'ws'
7import { logger } from '../helpers/logger'
8import { CONFIG } from '../initializers/config'
9import { LRU_CACHE, TRACKER_RATE_LIMITS } from '../initializers/constants'
10import { VideoFileModel } from '../models/video/video-file'
11import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist'
12
13const trackerRouter = express.Router()
14
15const blockedIPs = new LRUCache<string, boolean>({
16 max: LRU_CACHE.TRACKER_IPS.MAX_SIZE,
17 ttl: TRACKER_RATE_LIMITS.BLOCK_IP_LIFETIME
18})
19
20let peersIps = {}
21let peersIpInfoHash = {}
22runPeersChecker()
23
24const trackerServer = new TrackerServer({
25 http: false,
26 udp: false,
27 ws: false,
28 filter: async function (infoHash, params, cb) {
29 if (CONFIG.TRACKER.ENABLED === false) {
30 return cb(new Error('Tracker is disabled on this instance.'))
31 }
32
33 let ip: string
34
35 if (params.type === 'ws') {
36 ip = params.ip
37 } else {
38 ip = params.httpReq.ip
39 }
40
41 const key = ip + '-' + infoHash
42
43 peersIps[ip] = peersIps[ip] ? peersIps[ip] + 1 : 1
44 peersIpInfoHash[key] = peersIpInfoHash[key] ? peersIpInfoHash[key] + 1 : 1
45
46 if (CONFIG.TRACKER.REJECT_TOO_MANY_ANNOUNCES && peersIpInfoHash[key] > TRACKER_RATE_LIMITS.ANNOUNCES_PER_IP_PER_INFOHASH) {
47 return cb(new Error(`Too many requests (${peersIpInfoHash[key]} of ip ${ip} for torrent ${infoHash}`))
48 }
49
50 try {
51 if (CONFIG.TRACKER.PRIVATE === false) return cb()
52
53 const videoFileExists = await VideoFileModel.doesInfohashExistCached(infoHash)
54 if (videoFileExists === true) return cb()
55
56 const playlistExists = await VideoStreamingPlaylistModel.doesInfohashExistCached(infoHash)
57 if (playlistExists === true) return cb()
58
59 cb(new Error(`Unknown infoHash ${infoHash} requested by ip ${ip}`))
60
61 // Close socket connection and block IP for a few time
62 if (params.type === 'ws') {
63 blockedIPs.set(ip, true)
64
65 // setTimeout to wait filter response
66 setTimeout(() => params.socket.close(), 0)
67 }
68 } catch (err) {
69 logger.error('Error in tracker filter.', { err })
70 return cb(err)
71 }
72 }
73})
74
75if (CONFIG.TRACKER.ENABLED !== false) {
76 trackerServer.on('error', function (err) {
77 logger.error('Error in tracker.', { err })
78 })
79
80 trackerServer.on('warning', function (err) {
81 const message = err.message || ''
82
83 if (CONFIG.LOG.LOG_TRACKER_UNKNOWN_INFOHASH === false && message.includes('Unknown infoHash')) {
84 return
85 }
86
87 logger.warn('Warning in tracker.', { err })
88 })
89}
90
91const onHttpRequest = trackerServer.onHttpRequest.bind(trackerServer)
92trackerRouter.get('/tracker/announce', (req, res) => onHttpRequest(req, res, { action: 'announce' }))
93trackerRouter.get('/tracker/scrape', (req, res) => onHttpRequest(req, res, { action: 'scrape' }))
94
95function createWebsocketTrackerServer (app: express.Application) {
96 const server = createServer(app)
97 const wss = new WebSocketServer({ noServer: true })
98
99 wss.on('connection', function (ws, req) {
100 ws['ip'] = proxyAddr(req, CONFIG.TRUST_PROXY)
101
102 trackerServer.onWebSocketConnection(ws)
103 })
104
105 server.on('upgrade', (request: express.Request, socket, head) => {
106 if (request.url === '/tracker/socket') {
107 const ip = proxyAddr(request, CONFIG.TRUST_PROXY)
108
109 if (blockedIPs.has(ip)) {
110 logger.debug('Blocking IP %s from tracker.', ip)
111
112 socket.write('HTTP/1.1 403 Forbidden\r\n\r\n')
113 socket.destroy()
114 return
115 }
116
117 return wss.handleUpgrade(request, socket, head, ws => wss.emit('connection', ws, request))
118 }
119
120 // Don't destroy socket, we have Socket.IO too
121 })
122
123 return { server, trackerServer }
124}
125
126// ---------------------------------------------------------------------------
127
128export {
129 trackerRouter,
130 createWebsocketTrackerServer
131}
132
133// ---------------------------------------------------------------------------
134
135function runPeersChecker () {
136 setInterval(() => {
137 logger.debug('Checking peers.')
138
139 for (const ip of Object.keys(peersIpInfoHash)) {
140 if (peersIps[ip] > TRACKER_RATE_LIMITS.ANNOUNCES_PER_IP) {
141 logger.warn('Peer %s made abnormal requests (%d).', ip, peersIps[ip])
142 }
143 }
144
145 peersIpInfoHash = {}
146 peersIps = {}
147 }, TRACKER_RATE_LIMITS.INTERVAL)
148}
diff --git a/server/controllers/well-known.ts b/server/controllers/well-known.ts
deleted file mode 100644
index 322cf6ea2..000000000
--- a/server/controllers/well-known.ts
+++ /dev/null
@@ -1,125 +0,0 @@
1import cors from 'cors'
2import express from 'express'
3import { join } from 'path'
4import { asyncMiddleware, buildRateLimiter, handleStaticError, webfingerValidator } from '@server/middlewares'
5import { root } from '@shared/core-utils'
6import { CONFIG } from '../initializers/config'
7import { ROUTE_CACHE_LIFETIME, WEBSERVER } from '../initializers/constants'
8import { cacheRoute } from '../middlewares/cache/cache'
9
10const wellKnownRouter = express.Router()
11
12const wellKnownRateLimiter = buildRateLimiter({
13 windowMs: CONFIG.RATES_LIMIT.WELL_KNOWN.WINDOW_MS,
14 max: CONFIG.RATES_LIMIT.WELL_KNOWN.MAX
15})
16
17wellKnownRouter.use(cors())
18
19wellKnownRouter.get('/.well-known/webfinger',
20 wellKnownRateLimiter,
21 asyncMiddleware(webfingerValidator),
22 webfingerController
23)
24
25wellKnownRouter.get('/.well-known/security.txt',
26 wellKnownRateLimiter,
27 cacheRoute(ROUTE_CACHE_LIFETIME.SECURITYTXT),
28 (_, res: express.Response) => {
29 res.type('text/plain')
30 return res.send(CONFIG.INSTANCE.SECURITYTXT + CONFIG.INSTANCE.SECURITYTXT_CONTACT)
31 }
32)
33
34// nodeinfo service
35wellKnownRouter.use('/.well-known/nodeinfo',
36 wellKnownRateLimiter,
37 cacheRoute(ROUTE_CACHE_LIFETIME.NODEINFO),
38 (_, res: express.Response) => {
39 return res.json({
40 links: [
41 {
42 rel: 'http://nodeinfo.diaspora.software/ns/schema/2.0',
43 href: WEBSERVER.URL + '/nodeinfo/2.0.json'
44 }
45 ]
46 })
47 }
48)
49
50// dnt-policy.txt service (see https://www.eff.org/dnt-policy)
51wellKnownRouter.use('/.well-known/dnt-policy.txt',
52 wellKnownRateLimiter,
53 cacheRoute(ROUTE_CACHE_LIFETIME.DNT_POLICY),
54 (_, res: express.Response) => {
55 res.type('text/plain')
56
57 return res.sendFile(join(root(), 'dist/server/static/dnt-policy/dnt-policy-1.0.txt'))
58 }
59)
60
61// dnt service (see https://www.w3.org/TR/tracking-dnt/#status-resource)
62wellKnownRouter.use('/.well-known/dnt/',
63 wellKnownRateLimiter,
64 (_, res: express.Response) => {
65 res.json({ tracking: 'N' })
66 }
67)
68
69wellKnownRouter.use('/.well-known/change-password',
70 wellKnownRateLimiter,
71 (_, res: express.Response) => {
72 res.redirect('/my-account/settings')
73 }
74)
75
76wellKnownRouter.use('/.well-known/host-meta',
77 wellKnownRateLimiter,
78 (_, res: express.Response) => {
79 res.type('application/xml')
80
81 const xml = '<?xml version="1.0" encoding="UTF-8"?>\n' +
82 '<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">\n' +
83 ` <Link rel="lrdd" type="application/xrd+xml" template="${WEBSERVER.URL}/.well-known/webfinger?resource={uri}"/>\n` +
84 '</XRD>'
85
86 res.send(xml).end()
87 }
88)
89
90wellKnownRouter.use('/.well-known/',
91 wellKnownRateLimiter,
92 cacheRoute(ROUTE_CACHE_LIFETIME.WELL_KNOWN),
93 express.static(CONFIG.STORAGE.WELL_KNOWN_DIR, { fallthrough: false }),
94 handleStaticError
95)
96
97// ---------------------------------------------------------------------------
98
99export {
100 wellKnownRouter
101}
102
103// ---------------------------------------------------------------------------
104
105function webfingerController (req: express.Request, res: express.Response) {
106 const actor = res.locals.actorUrl
107
108 const json = {
109 subject: req.query.resource,
110 aliases: [ actor.url ],
111 links: [
112 {
113 rel: 'self',
114 type: 'application/activity+json',
115 href: actor.url
116 },
117 {
118 rel: 'http://ostatus.org/schema/1.0/subscribe',
119 template: WEBSERVER.URL + '/remote-interaction?uri={uri}'
120 }
121 ]
122 }
123
124 return res.json(json)
125}