diff options
author | Chocobozzz <me@florianbigard.com> | 2023-07-31 14:34:36 +0200 |
---|---|---|
committer | Chocobozzz <me@florianbigard.com> | 2023-08-11 15:02:33 +0200 |
commit | 3a4992633ee62d5edfbb484d9c6bcb3cf158489d (patch) | |
tree | e4510b39bdac9c318fdb4b47018d08f15368b8f0 /server/controllers | |
parent | 04d1da5621d25d59bd5fa1543b725c497bf5d9a8 (diff) | |
download | PeerTube-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')
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 @@ | |||
1 | import cors from 'cors' | ||
2 | import express from 'express' | ||
3 | import { activityPubCollectionPagination } from '@server/lib/activitypub/collection' | ||
4 | import { activityPubContextify } from '@server/lib/activitypub/context' | ||
5 | import { getServerActor } from '@server/models/application/application' | ||
6 | import { MAccountId, MActorId, MChannelId, MVideoId } from '@server/types/models' | ||
7 | import { VideoCommentObject } from '@shared/models' | ||
8 | import { VideoPrivacy, VideoRateType } from '../../../shared/models/videos' | ||
9 | import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model' | ||
10 | import { ROUTE_CACHE_LIFETIME, WEBSERVER } from '../../initializers/constants' | ||
11 | import { audiencify, getAudience } from '../../lib/activitypub/audience' | ||
12 | import { buildAnnounceWithVideoAudience, buildLikeActivity } from '../../lib/activitypub/send' | ||
13 | import { buildCreateActivity } from '../../lib/activitypub/send/send-create' | ||
14 | import { buildDislikeActivity } from '../../lib/activitypub/send/send-dislike' | ||
15 | import { | ||
16 | getLocalVideoCommentsActivityPubUrl, | ||
17 | getLocalVideoDislikesActivityPubUrl, | ||
18 | getLocalVideoLikesActivityPubUrl, | ||
19 | getLocalVideoSharesActivityPubUrl | ||
20 | } from '../../lib/activitypub/url' | ||
21 | import { | ||
22 | activityPubRateLimiter, | ||
23 | asyncMiddleware, | ||
24 | ensureIsLocalChannel, | ||
25 | executeIfActivityPub, | ||
26 | localAccountValidator, | ||
27 | videoChannelsNameWithHostValidator, | ||
28 | videosCustomGetValidator, | ||
29 | videosShareValidator | ||
30 | } from '../../middlewares' | ||
31 | import { cacheRoute } from '../../middlewares/cache/cache' | ||
32 | import { getAccountVideoRateValidatorFactory, getVideoLocalViewerValidator, videoCommentGetValidator } from '../../middlewares/validators' | ||
33 | import { videoFileRedundancyGetValidator, videoPlaylistRedundancyGetValidator } from '../../middlewares/validators/redundancy' | ||
34 | import { videoPlaylistElementAPGetValidator, videoPlaylistsGetValidator } from '../../middlewares/validators/videos/video-playlists' | ||
35 | import { AccountModel } from '../../models/account/account' | ||
36 | import { AccountVideoRateModel } from '../../models/account/account-video-rate' | ||
37 | import { ActorFollowModel } from '../../models/actor/actor-follow' | ||
38 | import { VideoCommentModel } from '../../models/video/video-comment' | ||
39 | import { VideoPlaylistModel } from '../../models/video/video-playlist' | ||
40 | import { VideoShareModel } from '../../models/video/video-share' | ||
41 | import { activityPubResponse } from './utils' | ||
42 | |||
43 | const activityPubClientRouter = express.Router() | ||
44 | activityPubClientRouter.use(cors()) | ||
45 | |||
46 | // Intercept ActivityPub client requests | ||
47 | |||
48 | activityPubClientRouter.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 | ) | ||
55 | activityPubClientRouter.get('/accounts?/:name/followers', | ||
56 | executeIfActivityPub, | ||
57 | activityPubRateLimiter, | ||
58 | asyncMiddleware(localAccountValidator), | ||
59 | asyncMiddleware(accountFollowersController) | ||
60 | ) | ||
61 | activityPubClientRouter.get('/accounts?/:name/following', | ||
62 | executeIfActivityPub, | ||
63 | activityPubRateLimiter, | ||
64 | asyncMiddleware(localAccountValidator), | ||
65 | asyncMiddleware(accountFollowingController) | ||
66 | ) | ||
67 | activityPubClientRouter.get('/accounts?/:name/playlists', | ||
68 | executeIfActivityPub, | ||
69 | activityPubRateLimiter, | ||
70 | asyncMiddleware(localAccountValidator), | ||
71 | asyncMiddleware(accountPlaylistsController) | ||
72 | ) | ||
73 | activityPubClientRouter.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 | ) | ||
80 | activityPubClientRouter.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 | |||
88 | activityPubClientRouter.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 | ) | ||
96 | activityPubClientRouter.get('/videos/watch/:id/activity', | ||
97 | executeIfActivityPub, | ||
98 | activityPubRateLimiter, | ||
99 | asyncMiddleware(videosCustomGetValidator('all')), | ||
100 | asyncMiddleware(videoController) | ||
101 | ) | ||
102 | activityPubClientRouter.get('/videos/watch/:id/announces', | ||
103 | executeIfActivityPub, | ||
104 | activityPubRateLimiter, | ||
105 | asyncMiddleware(videosCustomGetValidator('only-immutable-attributes')), | ||
106 | asyncMiddleware(videoAnnouncesController) | ||
107 | ) | ||
108 | activityPubClientRouter.get('/videos/watch/:id/announces/:actorId', | ||
109 | executeIfActivityPub, | ||
110 | activityPubRateLimiter, | ||
111 | asyncMiddleware(videosShareValidator), | ||
112 | asyncMiddleware(videoAnnounceController) | ||
113 | ) | ||
114 | activityPubClientRouter.get('/videos/watch/:id/likes', | ||
115 | executeIfActivityPub, | ||
116 | activityPubRateLimiter, | ||
117 | asyncMiddleware(videosCustomGetValidator('only-immutable-attributes')), | ||
118 | asyncMiddleware(videoLikesController) | ||
119 | ) | ||
120 | activityPubClientRouter.get('/videos/watch/:id/dislikes', | ||
121 | executeIfActivityPub, | ||
122 | activityPubRateLimiter, | ||
123 | asyncMiddleware(videosCustomGetValidator('only-immutable-attributes')), | ||
124 | asyncMiddleware(videoDislikesController) | ||
125 | ) | ||
126 | activityPubClientRouter.get('/videos/watch/:id/comments', | ||
127 | executeIfActivityPub, | ||
128 | activityPubRateLimiter, | ||
129 | asyncMiddleware(videosCustomGetValidator('only-immutable-attributes')), | ||
130 | asyncMiddleware(videoCommentsController) | ||
131 | ) | ||
132 | activityPubClientRouter.get('/videos/watch/:videoId/comments/:commentId', | ||
133 | executeIfActivityPub, | ||
134 | activityPubRateLimiter, | ||
135 | asyncMiddleware(videoCommentGetValidator), | ||
136 | asyncMiddleware(videoCommentController) | ||
137 | ) | ||
138 | activityPubClientRouter.get('/videos/watch/:videoId/comments/:commentId/activity', | ||
139 | executeIfActivityPub, | ||
140 | activityPubRateLimiter, | ||
141 | asyncMiddleware(videoCommentGetValidator), | ||
142 | asyncMiddleware(videoCommentController) | ||
143 | ) | ||
144 | |||
145 | activityPubClientRouter.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 | ) | ||
153 | activityPubClientRouter.get('/video-channels/:nameWithHost/followers', | ||
154 | executeIfActivityPub, | ||
155 | activityPubRateLimiter, | ||
156 | asyncMiddleware(videoChannelsNameWithHostValidator), | ||
157 | ensureIsLocalChannel, | ||
158 | asyncMiddleware(videoChannelFollowersController) | ||
159 | ) | ||
160 | activityPubClientRouter.get('/video-channels/:nameWithHost/following', | ||
161 | executeIfActivityPub, | ||
162 | activityPubRateLimiter, | ||
163 | asyncMiddleware(videoChannelsNameWithHostValidator), | ||
164 | ensureIsLocalChannel, | ||
165 | asyncMiddleware(videoChannelFollowingController) | ||
166 | ) | ||
167 | activityPubClientRouter.get('/video-channels/:nameWithHost/playlists', | ||
168 | executeIfActivityPub, | ||
169 | activityPubRateLimiter, | ||
170 | asyncMiddleware(videoChannelsNameWithHostValidator), | ||
171 | ensureIsLocalChannel, | ||
172 | asyncMiddleware(videoChannelPlaylistsController) | ||
173 | ) | ||
174 | |||
175 | activityPubClientRouter.get('/redundancy/videos/:videoId/:resolution([0-9]+)(-:fps([0-9]+))?', | ||
176 | executeIfActivityPub, | ||
177 | activityPubRateLimiter, | ||
178 | asyncMiddleware(videoFileRedundancyGetValidator), | ||
179 | asyncMiddleware(videoRedundancyController) | ||
180 | ) | ||
181 | activityPubClientRouter.get('/redundancy/streaming-playlists/:streamingPlaylistType/:videoId', | ||
182 | executeIfActivityPub, | ||
183 | activityPubRateLimiter, | ||
184 | asyncMiddleware(videoPlaylistRedundancyGetValidator), | ||
185 | asyncMiddleware(videoRedundancyController) | ||
186 | ) | ||
187 | |||
188 | activityPubClientRouter.get( | ||
189 | [ '/video-playlists/:playlistId', '/videos/watch/playlist/:playlistId', '/w/p/:playlistId' ], | ||
190 | executeIfActivityPub, | ||
191 | activityPubRateLimiter, | ||
192 | asyncMiddleware(videoPlaylistsGetValidator('all')), | ||
193 | asyncMiddleware(videoPlaylistController) | ||
194 | ) | ||
195 | activityPubClientRouter.get('/video-playlists/:playlistId/videos/:playlistElementId', | ||
196 | executeIfActivityPub, | ||
197 | activityPubRateLimiter, | ||
198 | asyncMiddleware(videoPlaylistElementAPGetValidator), | ||
199 | asyncMiddleware(videoPlaylistElementController) | ||
200 | ) | ||
201 | |||
202 | activityPubClientRouter.get('/videos/local-viewer/:localViewerId', | ||
203 | executeIfActivityPub, | ||
204 | activityPubRateLimiter, | ||
205 | asyncMiddleware(getVideoLocalViewerValidator), | ||
206 | asyncMiddleware(getVideoLocalViewerController) | ||
207 | ) | ||
208 | |||
209 | // --------------------------------------------------------------------------- | ||
210 | |||
211 | export { | ||
212 | activityPubClientRouter | ||
213 | } | ||
214 | |||
215 | // --------------------------------------------------------------------------- | ||
216 | |||
217 | async 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 | |||
223 | async 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 | |||
230 | async 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 | |||
237 | async 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 | |||
244 | async 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 | |||
251 | function 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 | |||
264 | async 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 | |||
283 | async 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 | |||
293 | async 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 | |||
310 | async 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 | |||
320 | async 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 | |||
330 | async 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 | |||
348 | async 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 | |||
354 | async 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 | |||
361 | async 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 | |||
368 | async 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 | |||
390 | async 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 | |||
408 | async 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 | |||
423 | function 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 | |||
432 | function 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 | |||
440 | function 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 | |||
448 | function 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 | |||
456 | function 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 | |||
464 | function 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 | |||
475 | function 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 @@ | |||
1 | import express from 'express' | ||
2 | import { InboxManager } from '@server/lib/activitypub/inbox-manager' | ||
3 | import { Activity, ActivityPubCollection, ActivityPubOrderedCollection, RootActivity } from '@shared/models' | ||
4 | import { HttpStatusCode } from '../../../shared/models/http/http-error-codes' | ||
5 | import { isActivityValid } from '../../helpers/custom-validators/activitypub/activity' | ||
6 | import { logger } from '../../helpers/logger' | ||
7 | import { | ||
8 | activityPubRateLimiter, | ||
9 | asyncMiddleware, | ||
10 | checkSignature, | ||
11 | ensureIsLocalChannel, | ||
12 | localAccountValidator, | ||
13 | signatureValidator, | ||
14 | videoChannelsNameWithHostValidator | ||
15 | } from '../../middlewares' | ||
16 | import { activityPubValidator } from '../../middlewares/validators/activitypub/activity' | ||
17 | |||
18 | const inboxRouter = express.Router() | ||
19 | |||
20 | inboxRouter.post('/inbox', | ||
21 | activityPubRateLimiter, | ||
22 | signatureValidator, | ||
23 | asyncMiddleware(checkSignature), | ||
24 | asyncMiddleware(activityPubValidator), | ||
25 | inboxController | ||
26 | ) | ||
27 | |||
28 | inboxRouter.post('/accounts/:name/inbox', | ||
29 | activityPubRateLimiter, | ||
30 | signatureValidator, | ||
31 | asyncMiddleware(checkSignature), | ||
32 | asyncMiddleware(localAccountValidator), | ||
33 | asyncMiddleware(activityPubValidator), | ||
34 | inboxController | ||
35 | ) | ||
36 | |||
37 | inboxRouter.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 | |||
49 | export { | ||
50 | inboxRouter | ||
51 | } | ||
52 | |||
53 | // --------------------------------------------------------------------------- | ||
54 | |||
55 | function 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 @@ | |||
1 | import express from 'express' | ||
2 | |||
3 | import { activityPubClientRouter } from './client' | ||
4 | import { inboxRouter } from './inbox' | ||
5 | import { outboxRouter } from './outbox' | ||
6 | |||
7 | const activityPubRouter = express.Router() | ||
8 | |||
9 | activityPubRouter.use('/', inboxRouter) | ||
10 | activityPubRouter.use('/', outboxRouter) | ||
11 | activityPubRouter.use('/', activityPubClientRouter) | ||
12 | |||
13 | // --------------------------------------------------------------------------- | ||
14 | |||
15 | export { | ||
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 @@ | |||
1 | import express from 'express' | ||
2 | import { activityPubCollectionPagination } from '@server/lib/activitypub/collection' | ||
3 | import { activityPubContextify } from '@server/lib/activitypub/context' | ||
4 | import { MActorLight } from '@server/types/models' | ||
5 | import { Activity } from '../../../shared/models/activitypub/activity' | ||
6 | import { VideoPrivacy } from '../../../shared/models/videos' | ||
7 | import { logger } from '../../helpers/logger' | ||
8 | import { buildAudience } from '../../lib/activitypub/audience' | ||
9 | import { buildAnnounceActivity, buildCreateActivity } from '../../lib/activitypub/send' | ||
10 | import { | ||
11 | activityPubRateLimiter, | ||
12 | asyncMiddleware, | ||
13 | ensureIsLocalChannel, | ||
14 | localAccountValidator, | ||
15 | videoChannelsNameWithHostValidator | ||
16 | } from '../../middlewares' | ||
17 | import { apPaginationValidator } from '../../middlewares/validators/activitypub' | ||
18 | import { VideoModel } from '../../models/video/video' | ||
19 | import { activityPubResponse } from './utils' | ||
20 | |||
21 | const outboxRouter = express.Router() | ||
22 | |||
23 | outboxRouter.get('/accounts/:name/outbox', | ||
24 | activityPubRateLimiter, | ||
25 | apPaginationValidator, | ||
26 | localAccountValidator, | ||
27 | asyncMiddleware(outboxController) | ||
28 | ) | ||
29 | |||
30 | outboxRouter.get('/video-channels/:nameWithHost/outbox', | ||
31 | activityPubRateLimiter, | ||
32 | apPaginationValidator, | ||
33 | asyncMiddleware(videoChannelsNameWithHostValidator), | ||
34 | ensureIsLocalChannel, | ||
35 | asyncMiddleware(outboxController) | ||
36 | ) | ||
37 | |||
38 | // --------------------------------------------------------------------------- | ||
39 | |||
40 | export { | ||
41 | outboxRouter | ||
42 | } | ||
43 | |||
44 | // --------------------------------------------------------------------------- | ||
45 | |||
46 | async 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 | |||
59 | async 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 @@ | |||
1 | import express from 'express' | ||
2 | |||
3 | async 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 | |||
10 | export { | ||
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 @@ | |||
1 | import express from 'express' | ||
2 | import { logger } from '@server/helpers/logger' | ||
3 | import { createAccountAbuse, createVideoAbuse, createVideoCommentAbuse } from '@server/lib/moderation' | ||
4 | import { Notifier } from '@server/lib/notifier' | ||
5 | import { AbuseModel } from '@server/models/abuse/abuse' | ||
6 | import { AbuseMessageModel } from '@server/models/abuse/abuse-message' | ||
7 | import { getServerActor } from '@server/models/application/application' | ||
8 | import { abusePredefinedReasonsMap } from '@shared/core-utils/abuse' | ||
9 | import { AbuseCreate, AbuseState, HttpStatusCode, UserRight } from '@shared/models' | ||
10 | import { getFormattedObjects } from '../../helpers/utils' | ||
11 | import { sequelizeTypescript } from '../../initializers/database' | ||
12 | import { | ||
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' | ||
32 | import { AccountModel } from '../../models/account/account' | ||
33 | |||
34 | const abuseRouter = express.Router() | ||
35 | |||
36 | abuseRouter.use(apiRateLimiter) | ||
37 | |||
38 | abuseRouter.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 | ) | ||
49 | abuseRouter.put('/:id', | ||
50 | authenticate, | ||
51 | ensureUserHasRight(UserRight.MANAGE_ABUSES), | ||
52 | asyncMiddleware(abuseUpdateValidator), | ||
53 | asyncRetryTransactionMiddleware(updateAbuse) | ||
54 | ) | ||
55 | abuseRouter.post('/', | ||
56 | authenticate, | ||
57 | asyncMiddleware(abuseReportValidator), | ||
58 | asyncRetryTransactionMiddleware(reportAbuse) | ||
59 | ) | ||
60 | abuseRouter.delete('/:id', | ||
61 | authenticate, | ||
62 | ensureUserHasRight(UserRight.MANAGE_ABUSES), | ||
63 | asyncMiddleware(abuseGetValidator), | ||
64 | asyncRetryTransactionMiddleware(deleteAbuse) | ||
65 | ) | ||
66 | |||
67 | abuseRouter.get('/:id/messages', | ||
68 | authenticate, | ||
69 | asyncMiddleware(getAbuseValidator), | ||
70 | checkAbuseValidForMessagesValidator, | ||
71 | asyncRetryTransactionMiddleware(listAbuseMessages) | ||
72 | ) | ||
73 | |||
74 | abuseRouter.post('/:id/messages', | ||
75 | authenticate, | ||
76 | asyncMiddleware(getAbuseValidator), | ||
77 | checkAbuseValidForMessagesValidator, | ||
78 | addAbuseMessageValidator, | ||
79 | asyncRetryTransactionMiddleware(addAbuseMessage) | ||
80 | ) | ||
81 | |||
82 | abuseRouter.delete('/:id/messages/:messageId', | ||
83 | authenticate, | ||
84 | asyncMiddleware(getAbuseValidator), | ||
85 | checkAbuseValidForMessagesValidator, | ||
86 | asyncMiddleware(deleteAbuseMessageValidator), | ||
87 | asyncRetryTransactionMiddleware(deleteAbuseMessage) | ||
88 | ) | ||
89 | |||
90 | // --------------------------------------------------------------------------- | ||
91 | |||
92 | export { | ||
93 | abuseRouter | ||
94 | } | ||
95 | |||
96 | // --------------------------------------------------------------------------- | ||
97 | |||
98 | async 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 | |||
126 | async 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 | |||
152 | async 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 | |||
164 | async 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 | |||
221 | async 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 | |||
229 | async 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 | |||
251 | async 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 @@ | |||
1 | import express from 'express' | ||
2 | import { pickCommonVideoQuery } from '@server/helpers/query' | ||
3 | import { ActorFollowModel } from '@server/models/actor/actor-follow' | ||
4 | import { getServerActor } from '@server/models/application/application' | ||
5 | import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync' | ||
6 | import { buildNSFWFilter, getCountVideos, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils' | ||
7 | import { getFormattedObjects } from '../../helpers/utils' | ||
8 | import { JobQueue } from '../../lib/job-queue' | ||
9 | import { Hooks } from '../../lib/plugins/hooks' | ||
10 | import { | ||
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' | ||
24 | import { | ||
25 | accountNameWithHostGetValidator, | ||
26 | accountsFollowersSortValidator, | ||
27 | accountsSortValidator, | ||
28 | ensureAuthUserOwnsAccountValidator, | ||
29 | ensureCanManageChannelOrAccount, | ||
30 | videoChannelsSortValidator, | ||
31 | videoChannelStatsValidator, | ||
32 | videoChannelSyncsSortValidator, | ||
33 | videosSortValidator | ||
34 | } from '../../middlewares/validators' | ||
35 | import { commonVideoPlaylistFiltersValidator, videoPlaylistsSearchValidator } from '../../middlewares/validators/videos/video-playlists' | ||
36 | import { AccountModel } from '../../models/account/account' | ||
37 | import { AccountVideoRateModel } from '../../models/account/account-video-rate' | ||
38 | import { guessAdditionalAttributesFromQuery } from '../../models/video/formatter' | ||
39 | import { VideoModel } from '../../models/video/video' | ||
40 | import { VideoChannelModel } from '../../models/video/video-channel' | ||
41 | import { VideoPlaylistModel } from '../../models/video/video-playlist' | ||
42 | |||
43 | const accountsRouter = express.Router() | ||
44 | |||
45 | accountsRouter.use(apiRateLimiter) | ||
46 | |||
47 | accountsRouter.get('/', | ||
48 | paginationValidator, | ||
49 | accountsSortValidator, | ||
50 | setDefaultSort, | ||
51 | setDefaultPagination, | ||
52 | asyncMiddleware(listAccounts) | ||
53 | ) | ||
54 | |||
55 | accountsRouter.get('/:accountName', | ||
56 | asyncMiddleware(accountNameWithHostGetValidator), | ||
57 | getAccount | ||
58 | ) | ||
59 | |||
60 | accountsRouter.get('/:accountName/videos', | ||
61 | asyncMiddleware(accountNameWithHostGetValidator), | ||
62 | paginationValidator, | ||
63 | videosSortValidator, | ||
64 | setDefaultVideosSort, | ||
65 | setDefaultPagination, | ||
66 | optionalAuthenticate, | ||
67 | commonVideosFiltersValidator, | ||
68 | asyncMiddleware(listAccountVideos) | ||
69 | ) | ||
70 | |||
71 | accountsRouter.get('/:accountName/video-channels', | ||
72 | asyncMiddleware(accountNameWithHostGetValidator), | ||
73 | videoChannelStatsValidator, | ||
74 | paginationValidator, | ||
75 | videoChannelsSortValidator, | ||
76 | setDefaultSort, | ||
77 | setDefaultPagination, | ||
78 | asyncMiddleware(listAccountChannels) | ||
79 | ) | ||
80 | |||
81 | accountsRouter.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 | |||
92 | accountsRouter.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 | |||
104 | accountsRouter.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 | |||
116 | accountsRouter.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 | |||
129 | export { | ||
130 | accountsRouter | ||
131 | } | ||
132 | |||
133 | // --------------------------------------------------------------------------- | ||
134 | |||
135 | function 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 | |||
145 | async 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 | |||
151 | async 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 | |||
166 | async 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 | |||
180 | async 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 | |||
203 | async 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 | |||
237 | async 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 | |||
250 | async 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 @@ | |||
1 | import express from 'express' | ||
2 | import { handleToNameAndHost } from '@server/helpers/actors' | ||
3 | import { logger } from '@server/helpers/logger' | ||
4 | import { AccountBlocklistModel } from '@server/models/account/account-blocklist' | ||
5 | import { getServerActor } from '@server/models/application/application' | ||
6 | import { ServerBlocklistModel } from '@server/models/server/server-blocklist' | ||
7 | import { MActorAccountId, MUserAccountId } from '@server/types/models' | ||
8 | import { BlockStatus } from '@shared/models' | ||
9 | import { apiRateLimiter, asyncMiddleware, blocklistStatusValidator, optionalAuthenticate } from '../../middlewares' | ||
10 | |||
11 | const blocklistRouter = express.Router() | ||
12 | |||
13 | blocklistRouter.use(apiRateLimiter) | ||
14 | |||
15 | blocklistRouter.get('/status', | ||
16 | optionalAuthenticate, | ||
17 | blocklistStatusValidator, | ||
18 | asyncMiddleware(getBlocklistStatus) | ||
19 | ) | ||
20 | |||
21 | // --------------------------------------------------------------------------- | ||
22 | |||
23 | export { | ||
24 | blocklistRouter | ||
25 | } | ||
26 | |||
27 | // --------------------------------------------------------------------------- | ||
28 | |||
29 | async 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 | |||
59 | async 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 | |||
81 | async 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 | |||
105 | function 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 @@ | |||
1 | import express from 'express' | ||
2 | import { removeComment } from '@server/lib/video-comment' | ||
3 | import { bulkRemoveCommentsOfValidator } from '@server/middlewares/validators/bulk' | ||
4 | import { VideoCommentModel } from '@server/models/video/video-comment' | ||
5 | import { HttpStatusCode } from '@shared/models' | ||
6 | import { BulkRemoveCommentsOfBody } from '@shared/models/bulk/bulk-remove-comments-of-body.model' | ||
7 | import { apiRateLimiter, asyncMiddleware, authenticate } from '../../middlewares' | ||
8 | |||
9 | const bulkRouter = express.Router() | ||
10 | |||
11 | bulkRouter.use(apiRateLimiter) | ||
12 | |||
13 | bulkRouter.post('/remove-comments-of', | ||
14 | authenticate, | ||
15 | asyncMiddleware(bulkRemoveCommentsOfValidator), | ||
16 | asyncMiddleware(bulkRemoveCommentsOf) | ||
17 | ) | ||
18 | |||
19 | // --------------------------------------------------------------------------- | ||
20 | |||
21 | export { | ||
22 | bulkRouter | ||
23 | } | ||
24 | |||
25 | // --------------------------------------------------------------------------- | ||
26 | |||
27 | async 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 @@ | |||
1 | import express from 'express' | ||
2 | import { remove, writeJSON } from 'fs-extra' | ||
3 | import { snakeCase } from 'lodash' | ||
4 | import validator from 'validator' | ||
5 | import { ServerConfigManager } from '@server/lib/server-config-manager' | ||
6 | import { About, CustomConfig, UserRight } from '@shared/models' | ||
7 | import { auditLoggerFactory, CustomConfigAuditView, getAuditIdFromRes } from '../../helpers/audit-logger' | ||
8 | import { objectConverter } from '../../helpers/core-utils' | ||
9 | import { CONFIG, reloadConfig } from '../../initializers/config' | ||
10 | import { ClientHtml } from '../../lib/client-html' | ||
11 | import { apiRateLimiter, asyncMiddleware, authenticate, ensureUserHasRight, openapiOperationDoc } from '../../middlewares' | ||
12 | import { customConfigUpdateValidator, ensureConfigIsEditable } from '../../middlewares/validators/config' | ||
13 | |||
14 | const configRouter = express.Router() | ||
15 | |||
16 | configRouter.use(apiRateLimiter) | ||
17 | |||
18 | const auditLogger = auditLoggerFactory('config') | ||
19 | |||
20 | configRouter.get('/', | ||
21 | openapiOperationDoc({ operationId: 'getConfig' }), | ||
22 | asyncMiddleware(getConfig) | ||
23 | ) | ||
24 | |||
25 | configRouter.get('/about', | ||
26 | openapiOperationDoc({ operationId: 'getAbout' }), | ||
27 | getAbout | ||
28 | ) | ||
29 | |||
30 | configRouter.get('/custom', | ||
31 | openapiOperationDoc({ operationId: 'getCustomConfig' }), | ||
32 | authenticate, | ||
33 | ensureUserHasRight(UserRight.MANAGE_CONFIGURATION), | ||
34 | getCustomConfig | ||
35 | ) | ||
36 | |||
37 | configRouter.put('/custom', | ||
38 | openapiOperationDoc({ operationId: 'putCustomConfig' }), | ||
39 | authenticate, | ||
40 | ensureUserHasRight(UserRight.MANAGE_CONFIGURATION), | ||
41 | ensureConfigIsEditable, | ||
42 | customConfigUpdateValidator, | ||
43 | asyncMiddleware(updateCustomConfig) | ||
44 | ) | ||
45 | |||
46 | configRouter.delete('/custom', | ||
47 | openapiOperationDoc({ operationId: 'delCustomConfig' }), | ||
48 | authenticate, | ||
49 | ensureUserHasRight(UserRight.MANAGE_CONFIGURATION), | ||
50 | ensureConfigIsEditable, | ||
51 | asyncMiddleware(deleteCustomConfig) | ||
52 | ) | ||
53 | |||
54 | async 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 | |||
60 | function 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 | |||
85 | function getCustomConfig (req: express.Request, res: express.Response) { | ||
86 | const data = customConfig() | ||
87 | |||
88 | return res.json(data) | ||
89 | } | ||
90 | |||
91 | async 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 | |||
104 | async 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 | |||
128 | export { | ||
129 | configRouter | ||
130 | } | ||
131 | |||
132 | // --------------------------------------------------------------------------- | ||
133 | |||
134 | function 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 | |||
361 | function 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 @@ | |||
1 | import express from 'express' | ||
2 | import { ServerConfigManager } from '@server/lib/server-config-manager' | ||
3 | import { ActorCustomPageModel } from '@server/models/account/actor-custom-page' | ||
4 | import { HttpStatusCode, UserRight } from '@shared/models' | ||
5 | import { apiRateLimiter, asyncMiddleware, authenticate, ensureUserHasRight } from '../../middlewares' | ||
6 | |||
7 | const customPageRouter = express.Router() | ||
8 | |||
9 | customPageRouter.use(apiRateLimiter) | ||
10 | |||
11 | customPageRouter.get('/homepage/instance', | ||
12 | asyncMiddleware(getInstanceHomepage) | ||
13 | ) | ||
14 | |||
15 | customPageRouter.put('/homepage/instance', | ||
16 | authenticate, | ||
17 | ensureUserHasRight(UserRight.MANAGE_INSTANCE_CUSTOM_PAGE), | ||
18 | asyncMiddleware(updateInstanceHomepage) | ||
19 | ) | ||
20 | |||
21 | // --------------------------------------------------------------------------- | ||
22 | |||
23 | export { | ||
24 | customPageRouter | ||
25 | } | ||
26 | |||
27 | // --------------------------------------------------------------------------- | ||
28 | |||
29 | async 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 | |||
41 | async 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 @@ | |||
1 | import cors from 'cors' | ||
2 | import express from 'express' | ||
3 | import { logger } from '@server/helpers/logger' | ||
4 | import { HttpStatusCode } from '../../../shared/models' | ||
5 | import { abuseRouter } from './abuse' | ||
6 | import { accountsRouter } from './accounts' | ||
7 | import { blocklistRouter } from './blocklist' | ||
8 | import { bulkRouter } from './bulk' | ||
9 | import { configRouter } from './config' | ||
10 | import { customPageRouter } from './custom-page' | ||
11 | import { jobsRouter } from './jobs' | ||
12 | import { metricsRouter } from './metrics' | ||
13 | import { oauthClientsRouter } from './oauth-clients' | ||
14 | import { overviewsRouter } from './overviews' | ||
15 | import { pluginRouter } from './plugins' | ||
16 | import { runnersRouter } from './runners' | ||
17 | import { searchRouter } from './search' | ||
18 | import { serverRouter } from './server' | ||
19 | import { usersRouter } from './users' | ||
20 | import { videoChannelRouter } from './video-channel' | ||
21 | import { videoChannelSyncRouter } from './video-channel-sync' | ||
22 | import { videoPlaylistRouter } from './video-playlist' | ||
23 | import { videosRouter } from './videos' | ||
24 | |||
25 | const apiRouter = express.Router() | ||
26 | |||
27 | apiRouter.use(cors({ | ||
28 | origin: '*', | ||
29 | exposedHeaders: 'Retry-After', | ||
30 | credentials: true | ||
31 | })) | ||
32 | |||
33 | apiRouter.use('/server', serverRouter) | ||
34 | apiRouter.use('/abuses', abuseRouter) | ||
35 | apiRouter.use('/bulk', bulkRouter) | ||
36 | apiRouter.use('/oauth-clients', oauthClientsRouter) | ||
37 | apiRouter.use('/config', configRouter) | ||
38 | apiRouter.use('/users', usersRouter) | ||
39 | apiRouter.use('/accounts', accountsRouter) | ||
40 | apiRouter.use('/video-channels', videoChannelRouter) | ||
41 | apiRouter.use('/video-channel-syncs', videoChannelSyncRouter) | ||
42 | apiRouter.use('/video-playlists', videoPlaylistRouter) | ||
43 | apiRouter.use('/videos', videosRouter) | ||
44 | apiRouter.use('/jobs', jobsRouter) | ||
45 | apiRouter.use('/metrics', metricsRouter) | ||
46 | apiRouter.use('/search', searchRouter) | ||
47 | apiRouter.use('/overviews', overviewsRouter) | ||
48 | apiRouter.use('/plugins', pluginRouter) | ||
49 | apiRouter.use('/custom-pages', customPageRouter) | ||
50 | apiRouter.use('/blocklist', blocklistRouter) | ||
51 | apiRouter.use('/runners', runnersRouter) | ||
52 | |||
53 | // apiRouter.use(apiRateLimiter) | ||
54 | apiRouter.use('/ping', pong) | ||
55 | apiRouter.use('/*', badRequest) | ||
56 | |||
57 | // --------------------------------------------------------------------------- | ||
58 | |||
59 | export { apiRouter } | ||
60 | |||
61 | // --------------------------------------------------------------------------- | ||
62 | |||
63 | function pong (req: express.Request, res: express.Response) { | ||
64 | return res.send('pong').status(HttpStatusCode.OK_200).end() | ||
65 | } | ||
66 | |||
67 | function 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 @@ | |||
1 | import { Job as BullJob } from 'bullmq' | ||
2 | import express from 'express' | ||
3 | import { HttpStatusCode, Job, JobState, JobType, ResultList, UserRight } from '@shared/models' | ||
4 | import { isArray } from '../../helpers/custom-validators/misc' | ||
5 | import { JobQueue } from '../../lib/job-queue' | ||
6 | import { | ||
7 | apiRateLimiter, | ||
8 | asyncMiddleware, | ||
9 | authenticate, | ||
10 | ensureUserHasRight, | ||
11 | jobsSortValidator, | ||
12 | openapiOperationDoc, | ||
13 | paginationValidatorBuilder, | ||
14 | setDefaultPagination, | ||
15 | setDefaultSort | ||
16 | } from '../../middlewares' | ||
17 | import { listJobsValidator } from '../../middlewares/validators/jobs' | ||
18 | |||
19 | const jobsRouter = express.Router() | ||
20 | |||
21 | jobsRouter.use(apiRateLimiter) | ||
22 | |||
23 | jobsRouter.post('/pause', | ||
24 | authenticate, | ||
25 | ensureUserHasRight(UserRight.MANAGE_JOBS), | ||
26 | asyncMiddleware(pauseJobQueue) | ||
27 | ) | ||
28 | |||
29 | jobsRouter.post('/resume', | ||
30 | authenticate, | ||
31 | ensureUserHasRight(UserRight.MANAGE_JOBS), | ||
32 | resumeJobQueue | ||
33 | ) | ||
34 | |||
35 | jobsRouter.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 | |||
49 | export { | ||
50 | jobsRouter | ||
51 | } | ||
52 | |||
53 | // --------------------------------------------------------------------------- | ||
54 | |||
55 | async function pauseJobQueue (req: express.Request, res: express.Response) { | ||
56 | await JobQueue.Instance.pause() | ||
57 | |||
58 | return res.sendStatus(HttpStatusCode.NO_CONTENT_204) | ||
59 | } | ||
60 | |||
61 | function resumeJobQueue (req: express.Request, res: express.Response) { | ||
62 | JobQueue.Instance.resume() | ||
63 | |||
64 | return res.sendStatus(HttpStatusCode.NO_CONTENT_204) | ||
65 | } | ||
66 | |||
67 | async 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 | |||
89 | async 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 @@ | |||
1 | import express from 'express' | ||
2 | import { CONFIG } from '@server/initializers/config' | ||
3 | import { OpenTelemetryMetrics } from '@server/lib/opentelemetry/metrics' | ||
4 | import { HttpStatusCode, PlaybackMetricCreate } from '@shared/models' | ||
5 | import { addPlaybackMetricValidator, apiRateLimiter, asyncMiddleware } from '../../middlewares' | ||
6 | |||
7 | const metricsRouter = express.Router() | ||
8 | |||
9 | metricsRouter.use(apiRateLimiter) | ||
10 | |||
11 | metricsRouter.post('/playback', | ||
12 | asyncMiddleware(addPlaybackMetricValidator), | ||
13 | addPlaybackMetric | ||
14 | ) | ||
15 | |||
16 | // --------------------------------------------------------------------------- | ||
17 | |||
18 | export { | ||
19 | metricsRouter | ||
20 | } | ||
21 | |||
22 | // --------------------------------------------------------------------------- | ||
23 | |||
24 | function 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 @@ | |||
1 | import express from 'express' | ||
2 | import { isTestOrDevInstance } from '@server/helpers/core-utils' | ||
3 | import { OAuthClientModel } from '@server/models/oauth/oauth-client' | ||
4 | import { HttpStatusCode, OAuthClientLocal } from '@shared/models' | ||
5 | import { logger } from '../../helpers/logger' | ||
6 | import { CONFIG } from '../../initializers/config' | ||
7 | import { apiRateLimiter, asyncMiddleware, openapiOperationDoc } from '../../middlewares' | ||
8 | |||
9 | const oauthClientsRouter = express.Router() | ||
10 | |||
11 | oauthClientsRouter.use(apiRateLimiter) | ||
12 | |||
13 | oauthClientsRouter.get('/local', | ||
14 | openapiOperationDoc({ operationId: 'getOAuthClient' }), | ||
15 | asyncMiddleware(getLocalClient) | ||
16 | ) | ||
17 | |||
18 | // Get the client credentials for the PeerTube front end | ||
19 | async 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 | |||
52 | export { | ||
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 @@ | |||
1 | import express from 'express' | ||
2 | import memoizee from 'memoizee' | ||
3 | import { logger } from '@server/helpers/logger' | ||
4 | import { Hooks } from '@server/lib/plugins/hooks' | ||
5 | import { getServerActor } from '@server/models/application/application' | ||
6 | import { VideoModel } from '@server/models/video/video' | ||
7 | import { CategoryOverview, ChannelOverview, TagOverview, VideosOverview } from '../../../shared/models/overviews' | ||
8 | import { buildNSFWFilter } from '../../helpers/express-utils' | ||
9 | import { MEMOIZE_TTL, OVERVIEWS } from '../../initializers/constants' | ||
10 | import { apiRateLimiter, asyncMiddleware, optionalAuthenticate, videosOverviewValidator } from '../../middlewares' | ||
11 | import { TagModel } from '../../models/video/tag' | ||
12 | |||
13 | const overviewsRouter = express.Router() | ||
14 | |||
15 | overviewsRouter.use(apiRateLimiter) | ||
16 | |||
17 | overviewsRouter.get('/videos', | ||
18 | videosOverviewValidator, | ||
19 | optionalAuthenticate, | ||
20 | asyncMiddleware(getVideosOverview) | ||
21 | ) | ||
22 | |||
23 | // --------------------------------------------------------------------------- | ||
24 | |||
25 | export { overviewsRouter } | ||
26 | |||
27 | // --------------------------------------------------------------------------- | ||
28 | |||
29 | const 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 | ||
44 | async 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 | |||
69 | async 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 | |||
83 | async 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 | |||
97 | async 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 | |||
111 | async 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 @@ | |||
1 | import express from 'express' | ||
2 | import { logger } from '@server/helpers/logger' | ||
3 | import { getFormattedObjects } from '@server/helpers/utils' | ||
4 | import { listAvailablePluginsFromIndex } from '@server/lib/plugins/plugin-index' | ||
5 | import { PluginManager } from '@server/lib/plugins/plugin-manager' | ||
6 | import { | ||
7 | apiRateLimiter, | ||
8 | asyncMiddleware, | ||
9 | authenticate, | ||
10 | availablePluginsSortValidator, | ||
11 | ensureUserHasRight, | ||
12 | openapiOperationDoc, | ||
13 | paginationValidator, | ||
14 | pluginsSortValidator, | ||
15 | setDefaultPagination, | ||
16 | setDefaultSort | ||
17 | } from '@server/middlewares' | ||
18 | import { | ||
19 | existingPluginValidator, | ||
20 | installOrUpdatePluginValidator, | ||
21 | listAvailablePluginsValidator, | ||
22 | listPluginsValidator, | ||
23 | uninstallPluginValidator, | ||
24 | updatePluginSettingsValidator | ||
25 | } from '@server/middlewares/validators/plugins' | ||
26 | import { PluginModel } from '@server/models/server/plugin' | ||
27 | import { | ||
28 | HttpStatusCode, | ||
29 | InstallOrUpdatePlugin, | ||
30 | ManagePlugin, | ||
31 | PeertubePluginIndexList, | ||
32 | PublicServerSetting, | ||
33 | RegisteredServerSettings, | ||
34 | UserRight | ||
35 | } from '@shared/models' | ||
36 | |||
37 | const pluginRouter = express.Router() | ||
38 | |||
39 | pluginRouter.use(apiRateLimiter) | ||
40 | |||
41 | pluginRouter.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 | |||
53 | pluginRouter.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 | |||
65 | pluginRouter.get('/:npmName/registered-settings', | ||
66 | authenticate, | ||
67 | ensureUserHasRight(UserRight.MANAGE_PLUGINS), | ||
68 | asyncMiddleware(existingPluginValidator), | ||
69 | getPluginRegisteredSettings | ||
70 | ) | ||
71 | |||
72 | pluginRouter.get('/:npmName/public-settings', | ||
73 | asyncMiddleware(existingPluginValidator), | ||
74 | getPublicPluginSettings | ||
75 | ) | ||
76 | |||
77 | pluginRouter.put('/:npmName/settings', | ||
78 | authenticate, | ||
79 | ensureUserHasRight(UserRight.MANAGE_PLUGINS), | ||
80 | updatePluginSettingsValidator, | ||
81 | asyncMiddleware(existingPluginValidator), | ||
82 | asyncMiddleware(updatePluginSettings) | ||
83 | ) | ||
84 | |||
85 | pluginRouter.get('/:npmName', | ||
86 | authenticate, | ||
87 | ensureUserHasRight(UserRight.MANAGE_PLUGINS), | ||
88 | asyncMiddleware(existingPluginValidator), | ||
89 | getPlugin | ||
90 | ) | ||
91 | |||
92 | pluginRouter.post('/install', | ||
93 | openapiOperationDoc({ operationId: 'addPlugin' }), | ||
94 | authenticate, | ||
95 | ensureUserHasRight(UserRight.MANAGE_PLUGINS), | ||
96 | installOrUpdatePluginValidator, | ||
97 | asyncMiddleware(installPlugin) | ||
98 | ) | ||
99 | |||
100 | pluginRouter.post('/update', | ||
101 | openapiOperationDoc({ operationId: 'updatePlugin' }), | ||
102 | authenticate, | ||
103 | ensureUserHasRight(UserRight.MANAGE_PLUGINS), | ||
104 | installOrUpdatePluginValidator, | ||
105 | asyncMiddleware(updatePlugin) | ||
106 | ) | ||
107 | |||
108 | pluginRouter.post('/uninstall', | ||
109 | openapiOperationDoc({ operationId: 'uninstallPlugin' }), | ||
110 | authenticate, | ||
111 | ensureUserHasRight(UserRight.MANAGE_PLUGINS), | ||
112 | uninstallPluginValidator, | ||
113 | asyncMiddleware(uninstallPlugin) | ||
114 | ) | ||
115 | |||
116 | // --------------------------------------------------------------------------- | ||
117 | |||
118 | export { | ||
119 | pluginRouter | ||
120 | } | ||
121 | |||
122 | // --------------------------------------------------------------------------- | ||
123 | |||
124 | async 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 | |||
139 | function getPlugin (req: express.Request, res: express.Response) { | ||
140 | const plugin = res.locals.plugin | ||
141 | |||
142 | return res.json(plugin.toFormattedJSON()) | ||
143 | } | ||
144 | |||
145 | async 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 | |||
165 | async 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 | |||
180 | async 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 | |||
188 | function 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 | |||
198 | function 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 | |||
206 | async 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 | |||
217 | async 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 @@ | |||
1 | import express from 'express' | ||
2 | import { runnerJobsRouter } from './jobs' | ||
3 | import { runnerJobFilesRouter } from './jobs-files' | ||
4 | import { manageRunnersRouter } from './manage-runners' | ||
5 | import { runnerRegistrationTokensRouter } from './registration-tokens' | ||
6 | |||
7 | const runnersRouter = express.Router() | ||
8 | |||
9 | // No api route limiter here, they are defined in child routers | ||
10 | |||
11 | runnersRouter.use('/', manageRunnersRouter) | ||
12 | runnersRouter.use('/', runnerJobsRouter) | ||
13 | runnersRouter.use('/', runnerJobFilesRouter) | ||
14 | runnersRouter.use('/', runnerRegistrationTokensRouter) | ||
15 | |||
16 | // --------------------------------------------------------------------------- | ||
17 | |||
18 | export { | ||
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 @@ | |||
1 | import express from 'express' | ||
2 | import { logger, loggerTagsFactory } from '@server/helpers/logger' | ||
3 | import { proxifyHLS, proxifyWebVideoFile } from '@server/lib/object-storage' | ||
4 | import { VideoPathManager } from '@server/lib/video-path-manager' | ||
5 | import { getStudioTaskFilePath } from '@server/lib/video-studio' | ||
6 | import { apiRateLimiter, asyncMiddleware } from '@server/middlewares' | ||
7 | import { jobOfRunnerGetValidatorFactory } from '@server/middlewares/validators/runners' | ||
8 | import { | ||
9 | runnerJobGetVideoStudioTaskFileValidator, | ||
10 | runnerJobGetVideoTranscodingFileValidator | ||
11 | } from '@server/middlewares/validators/runners/job-files' | ||
12 | import { RunnerJobState, VideoStorage } from '@shared/models' | ||
13 | |||
14 | const lTags = loggerTagsFactory('api', 'runner') | ||
15 | |||
16 | const runnerJobFilesRouter = express.Router() | ||
17 | |||
18 | runnerJobFilesRouter.post('/jobs/:jobUUID/files/videos/:videoId/max-quality', | ||
19 | apiRateLimiter, | ||
20 | asyncMiddleware(jobOfRunnerGetValidatorFactory([ RunnerJobState.PROCESSING ])), | ||
21 | asyncMiddleware(runnerJobGetVideoTranscodingFileValidator), | ||
22 | asyncMiddleware(getMaxQualityVideoFile) | ||
23 | ) | ||
24 | |||
25 | runnerJobFilesRouter.post('/jobs/:jobUUID/files/videos/:videoId/previews/max-quality', | ||
26 | apiRateLimiter, | ||
27 | asyncMiddleware(jobOfRunnerGetValidatorFactory([ RunnerJobState.PROCESSING ])), | ||
28 | asyncMiddleware(runnerJobGetVideoTranscodingFileValidator), | ||
29 | getMaxQualityVideoPreview | ||
30 | ) | ||
31 | |||
32 | runnerJobFilesRouter.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 | |||
42 | export { | ||
43 | runnerJobFilesRouter | ||
44 | } | ||
45 | |||
46 | // --------------------------------------------------------------------------- | ||
47 | |||
48 | async 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 | |||
85 | function 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 | |||
100 | function 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 @@ | |||
1 | import express, { UploadFiles } from 'express' | ||
2 | import { retryTransactionWrapper } from '@server/helpers/database-utils' | ||
3 | import { createReqFiles } from '@server/helpers/express-utils' | ||
4 | import { logger, loggerTagsFactory } from '@server/helpers/logger' | ||
5 | import { generateRunnerJobToken } from '@server/helpers/token-generator' | ||
6 | import { MIMETYPES } from '@server/initializers/constants' | ||
7 | import { sequelizeTypescript } from '@server/initializers/database' | ||
8 | import { getRunnerJobHandlerClass, runnerJobCanBeCancelled, updateLastRunnerContact } from '@server/lib/runners' | ||
9 | import { | ||
10 | apiRateLimiter, | ||
11 | asyncMiddleware, | ||
12 | authenticate, | ||
13 | ensureUserHasRight, | ||
14 | paginationValidator, | ||
15 | runnerJobsSortValidator, | ||
16 | setDefaultPagination, | ||
17 | setDefaultSort | ||
18 | } from '@server/middlewares' | ||
19 | import { | ||
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' | ||
31 | import { RunnerModel } from '@server/models/runner/runner' | ||
32 | import { RunnerJobModel } from '@server/models/runner/runner-job' | ||
33 | import { | ||
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 | |||
55 | const postRunnerJobSuccessVideoFiles = createReqFiles( | ||
56 | [ 'payload[videoFile]', 'payload[resolutionPlaylistFile]' ], | ||
57 | { ...MIMETYPES.VIDEO.MIMETYPE_EXT, ...MIMETYPES.M3U8.MIMETYPE_EXT } | ||
58 | ) | ||
59 | |||
60 | const runnerJobUpdateVideoFiles = createReqFiles( | ||
61 | [ 'payload[videoChunkFile]', 'payload[resolutionPlaylistFile]', 'payload[masterPlaylistFile]' ], | ||
62 | { ...MIMETYPES.VIDEO.MIMETYPE_EXT, ...MIMETYPES.M3U8.MIMETYPE_EXT } | ||
63 | ) | ||
64 | |||
65 | const lTags = loggerTagsFactory('api', 'runner') | ||
66 | |||
67 | const runnerJobsRouter = express.Router() | ||
68 | |||
69 | // --------------------------------------------------------------------------- | ||
70 | // Controllers for runners | ||
71 | // --------------------------------------------------------------------------- | ||
72 | |||
73 | runnerJobsRouter.post('/jobs/request', | ||
74 | apiRateLimiter, | ||
75 | asyncMiddleware(getRunnerFromTokenValidator), | ||
76 | asyncMiddleware(requestRunnerJob) | ||
77 | ) | ||
78 | |||
79 | runnerJobsRouter.post('/jobs/:jobUUID/accept', | ||
80 | apiRateLimiter, | ||
81 | asyncMiddleware(runnerJobGetValidator), | ||
82 | acceptRunnerJobValidator, | ||
83 | asyncMiddleware(getRunnerFromTokenValidator), | ||
84 | asyncMiddleware(acceptRunnerJob) | ||
85 | ) | ||
86 | |||
87 | runnerJobsRouter.post('/jobs/:jobUUID/abort', | ||
88 | apiRateLimiter, | ||
89 | asyncMiddleware(jobOfRunnerGetValidatorFactory([ RunnerJobState.PROCESSING ])), | ||
90 | abortRunnerJobValidator, | ||
91 | asyncMiddleware(abortRunnerJob) | ||
92 | ) | ||
93 | |||
94 | runnerJobsRouter.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 | |||
102 | runnerJobsRouter.post('/jobs/:jobUUID/error', | ||
103 | asyncMiddleware(jobOfRunnerGetValidatorFactory([ RunnerJobState.PROCESSING ])), | ||
104 | errorRunnerJobValidator, | ||
105 | asyncMiddleware(errorRunnerJob) | ||
106 | ) | ||
107 | |||
108 | runnerJobsRouter.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 | |||
120 | runnerJobsRouter.post('/jobs/:jobUUID/cancel', | ||
121 | authenticate, | ||
122 | ensureUserHasRight(UserRight.MANAGE_RUNNERS), | ||
123 | asyncMiddleware(runnerJobGetValidator), | ||
124 | cancelRunnerJobValidator, | ||
125 | asyncMiddleware(cancelRunnerJob) | ||
126 | ) | ||
127 | |||
128 | runnerJobsRouter.get('/jobs', | ||
129 | authenticate, | ||
130 | ensureUserHasRight(UserRight.MANAGE_RUNNERS), | ||
131 | paginationValidator, | ||
132 | runnerJobsSortValidator, | ||
133 | setDefaultSort, | ||
134 | setDefaultPagination, | ||
135 | listRunnerJobsValidator, | ||
136 | asyncMiddleware(listRunnerJobs) | ||
137 | ) | ||
138 | |||
139 | runnerJobsRouter.delete('/jobs/:jobUUID', | ||
140 | authenticate, | ||
141 | ensureUserHasRight(UserRight.MANAGE_RUNNERS), | ||
142 | asyncMiddleware(runnerJobGetValidator), | ||
143 | asyncMiddleware(deleteRunnerJob) | ||
144 | ) | ||
145 | |||
146 | // --------------------------------------------------------------------------- | ||
147 | |||
148 | export { | ||
149 | runnerJobsRouter | ||
150 | } | ||
151 | |||
152 | // --------------------------------------------------------------------------- | ||
153 | |||
154 | // --------------------------------------------------------------------------- | ||
155 | // Controllers for runners | ||
156 | // --------------------------------------------------------------------------- | ||
157 | |||
158 | async 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 | |||
177 | async 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 | |||
225 | async 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 | |||
243 | async 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 | |||
265 | const 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 | |||
279 | async 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 | |||
312 | const 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 | |||
351 | async 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 | |||
375 | async 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 | |||
386 | async 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 | |||
401 | async 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 @@ | |||
1 | import express from 'express' | ||
2 | import { logger, loggerTagsFactory } from '@server/helpers/logger' | ||
3 | import { generateRunnerToken } from '@server/helpers/token-generator' | ||
4 | import { | ||
5 | apiRateLimiter, | ||
6 | asyncMiddleware, | ||
7 | authenticate, | ||
8 | ensureUserHasRight, | ||
9 | paginationValidator, | ||
10 | runnersSortValidator, | ||
11 | setDefaultPagination, | ||
12 | setDefaultSort | ||
13 | } from '@server/middlewares' | ||
14 | import { deleteRunnerValidator, getRunnerFromTokenValidator, registerRunnerValidator } from '@server/middlewares/validators/runners' | ||
15 | import { RunnerModel } from '@server/models/runner/runner' | ||
16 | import { HttpStatusCode, ListRunnersQuery, RegisterRunnerBody, UserRight } from '@shared/models' | ||
17 | |||
18 | const lTags = loggerTagsFactory('api', 'runner') | ||
19 | |||
20 | const manageRunnersRouter = express.Router() | ||
21 | |||
22 | manageRunnersRouter.post('/register', | ||
23 | apiRateLimiter, | ||
24 | asyncMiddleware(registerRunnerValidator), | ||
25 | asyncMiddleware(registerRunner) | ||
26 | ) | ||
27 | manageRunnersRouter.post('/unregister', | ||
28 | apiRateLimiter, | ||
29 | asyncMiddleware(getRunnerFromTokenValidator), | ||
30 | asyncMiddleware(unregisterRunner) | ||
31 | ) | ||
32 | |||
33 | manageRunnersRouter.delete('/:runnerId', | ||
34 | apiRateLimiter, | ||
35 | authenticate, | ||
36 | ensureUserHasRight(UserRight.MANAGE_RUNNERS), | ||
37 | asyncMiddleware(deleteRunnerValidator), | ||
38 | asyncMiddleware(deleteRunner) | ||
39 | ) | ||
40 | |||
41 | manageRunnersRouter.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 | |||
54 | export { | ||
55 | manageRunnersRouter | ||
56 | } | ||
57 | |||
58 | // --------------------------------------------------------------------------- | ||
59 | |||
60 | async 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 | } | ||
80 | async 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 | |||
89 | async 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 | |||
99 | async 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 @@ | |||
1 | import express from 'express' | ||
2 | import { logger, loggerTagsFactory } from '@server/helpers/logger' | ||
3 | import { generateRunnerRegistrationToken } from '@server/helpers/token-generator' | ||
4 | import { | ||
5 | apiRateLimiter, | ||
6 | asyncMiddleware, | ||
7 | authenticate, | ||
8 | ensureUserHasRight, | ||
9 | paginationValidator, | ||
10 | runnerRegistrationTokensSortValidator, | ||
11 | setDefaultPagination, | ||
12 | setDefaultSort | ||
13 | } from '@server/middlewares' | ||
14 | import { deleteRegistrationTokenValidator } from '@server/middlewares/validators/runners' | ||
15 | import { RunnerRegistrationTokenModel } from '@server/models/runner/runner-registration-token' | ||
16 | import { HttpStatusCode, ListRunnerRegistrationTokensQuery, UserRight } from '@shared/models' | ||
17 | |||
18 | const lTags = loggerTagsFactory('api', 'runner') | ||
19 | |||
20 | const runnerRegistrationTokensRouter = express.Router() | ||
21 | |||
22 | runnerRegistrationTokensRouter.post('/registration-tokens/generate', | ||
23 | apiRateLimiter, | ||
24 | authenticate, | ||
25 | ensureUserHasRight(UserRight.MANAGE_RUNNERS), | ||
26 | asyncMiddleware(generateRegistrationToken) | ||
27 | ) | ||
28 | |||
29 | runnerRegistrationTokensRouter.delete('/registration-tokens/:id', | ||
30 | apiRateLimiter, | ||
31 | authenticate, | ||
32 | ensureUserHasRight(UserRight.MANAGE_RUNNERS), | ||
33 | asyncMiddleware(deleteRegistrationTokenValidator), | ||
34 | asyncMiddleware(deleteRegistrationToken) | ||
35 | ) | ||
36 | |||
37 | runnerRegistrationTokensRouter.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 | |||
50 | export { | ||
51 | runnerRegistrationTokensRouter | ||
52 | } | ||
53 | |||
54 | // --------------------------------------------------------------------------- | ||
55 | |||
56 | async 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 | |||
68 | async 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 | |||
78 | async 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 @@ | |||
1 | import express from 'express' | ||
2 | import { apiRateLimiter } from '@server/middlewares' | ||
3 | import { searchChannelsRouter } from './search-video-channels' | ||
4 | import { searchPlaylistsRouter } from './search-video-playlists' | ||
5 | import { searchVideosRouter } from './search-videos' | ||
6 | |||
7 | const searchRouter = express.Router() | ||
8 | |||
9 | searchRouter.use(apiRateLimiter) | ||
10 | |||
11 | searchRouter.use('/', searchVideosRouter) | ||
12 | searchRouter.use('/', searchChannelsRouter) | ||
13 | searchRouter.use('/', searchPlaylistsRouter) | ||
14 | |||
15 | // --------------------------------------------------------------------------- | ||
16 | |||
17 | export { | ||
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 @@ | |||
1 | import express from 'express' | ||
2 | import { sanitizeUrl } from '@server/helpers/core-utils' | ||
3 | import { pickSearchChannelQuery } from '@server/helpers/query' | ||
4 | import { doJSONRequest } from '@server/helpers/requests' | ||
5 | import { CONFIG } from '@server/initializers/config' | ||
6 | import { WEBSERVER } from '@server/initializers/constants' | ||
7 | import { findLatestAPRedirection } from '@server/lib/activitypub/activity' | ||
8 | import { Hooks } from '@server/lib/plugins/hooks' | ||
9 | import { buildMutedForSearchIndex, isSearchIndexSearch, isURISearch } from '@server/lib/search' | ||
10 | import { getServerActor } from '@server/models/application/application' | ||
11 | import { HttpStatusCode, ResultList, VideoChannel } from '@shared/models' | ||
12 | import { VideoChannelsSearchQueryAfterSanitize } from '../../../../shared/models/search' | ||
13 | import { isUserAbleToSearchRemoteURI } from '../../../helpers/express-utils' | ||
14 | import { logger } from '../../../helpers/logger' | ||
15 | import { getFormattedObjects } from '../../../helpers/utils' | ||
16 | import { getOrCreateAPActor, loadActorUrlOrGetFromWebfinger } from '../../../lib/activitypub/actors' | ||
17 | import { | ||
18 | asyncMiddleware, | ||
19 | openapiOperationDoc, | ||
20 | optionalAuthenticate, | ||
21 | paginationValidator, | ||
22 | setDefaultPagination, | ||
23 | setDefaultSearchSort, | ||
24 | videoChannelsListSearchValidator, | ||
25 | videoChannelsSearchSortValidator | ||
26 | } from '../../../middlewares' | ||
27 | import { VideoChannelModel } from '../../../models/video/video-channel' | ||
28 | import { MChannelAccountDefault } from '../../../types/models' | ||
29 | import { searchLocalUrl } from './shared' | ||
30 | |||
31 | const searchChannelsRouter = express.Router() | ||
32 | |||
33 | searchChannelsRouter.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 | |||
46 | export { searchChannelsRouter } | ||
47 | |||
48 | // --------------------------------------------------------------------------- | ||
49 | |||
50 | function 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 | |||
72 | async 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 | |||
96 | async 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 | |||
114 | async 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 | |||
147 | function 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 @@ | |||
1 | import express from 'express' | ||
2 | import { sanitizeUrl } from '@server/helpers/core-utils' | ||
3 | import { isUserAbleToSearchRemoteURI } from '@server/helpers/express-utils' | ||
4 | import { logger } from '@server/helpers/logger' | ||
5 | import { pickSearchPlaylistQuery } from '@server/helpers/query' | ||
6 | import { doJSONRequest } from '@server/helpers/requests' | ||
7 | import { getFormattedObjects } from '@server/helpers/utils' | ||
8 | import { CONFIG } from '@server/initializers/config' | ||
9 | import { WEBSERVER } from '@server/initializers/constants' | ||
10 | import { findLatestAPRedirection } from '@server/lib/activitypub/activity' | ||
11 | import { getOrCreateAPVideoPlaylist } from '@server/lib/activitypub/playlists/get' | ||
12 | import { Hooks } from '@server/lib/plugins/hooks' | ||
13 | import { buildMutedForSearchIndex, isSearchIndexSearch, isURISearch } from '@server/lib/search' | ||
14 | import { getServerActor } from '@server/models/application/application' | ||
15 | import { VideoPlaylistModel } from '@server/models/video/video-playlist' | ||
16 | import { MVideoPlaylistFullSummary } from '@server/types/models' | ||
17 | import { HttpStatusCode, ResultList, VideoPlaylist, VideoPlaylistsSearchQueryAfterSanitize } from '@shared/models' | ||
18 | import { | ||
19 | asyncMiddleware, | ||
20 | openapiOperationDoc, | ||
21 | optionalAuthenticate, | ||
22 | paginationValidator, | ||
23 | setDefaultPagination, | ||
24 | setDefaultSearchSort, | ||
25 | videoPlaylistsListSearchValidator, | ||
26 | videoPlaylistsSearchSortValidator | ||
27 | } from '../../../middlewares' | ||
28 | import { searchLocalUrl } from './shared' | ||
29 | |||
30 | const searchPlaylistsRouter = express.Router() | ||
31 | |||
32 | searchPlaylistsRouter.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 | |||
45 | export { searchPlaylistsRouter } | ||
46 | |||
47 | // --------------------------------------------------------------------------- | ||
48 | |||
49 | function 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 | |||
62 | async 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 | |||
86 | async 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 | |||
104 | async 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 | |||
125 | function 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 @@ | |||
1 | import express from 'express' | ||
2 | import { sanitizeUrl } from '@server/helpers/core-utils' | ||
3 | import { pickSearchVideoQuery } from '@server/helpers/query' | ||
4 | import { doJSONRequest } from '@server/helpers/requests' | ||
5 | import { CONFIG } from '@server/initializers/config' | ||
6 | import { WEBSERVER } from '@server/initializers/constants' | ||
7 | import { findLatestAPRedirection } from '@server/lib/activitypub/activity' | ||
8 | import { getOrCreateAPVideo } from '@server/lib/activitypub/videos' | ||
9 | import { Hooks } from '@server/lib/plugins/hooks' | ||
10 | import { buildMutedForSearchIndex, isSearchIndexSearch, isURISearch } from '@server/lib/search' | ||
11 | import { getServerActor } from '@server/models/application/application' | ||
12 | import { HttpStatusCode, ResultList, Video } from '@shared/models' | ||
13 | import { VideosSearchQueryAfterSanitize } from '../../../../shared/models/search' | ||
14 | import { buildNSFWFilter, isUserAbleToSearchRemoteURI } from '../../../helpers/express-utils' | ||
15 | import { logger } from '../../../helpers/logger' | ||
16 | import { getFormattedObjects } from '../../../helpers/utils' | ||
17 | import { | ||
18 | asyncMiddleware, | ||
19 | commonVideosFiltersValidator, | ||
20 | openapiOperationDoc, | ||
21 | optionalAuthenticate, | ||
22 | paginationValidator, | ||
23 | setDefaultPagination, | ||
24 | setDefaultSearchSort, | ||
25 | videosSearchSortValidator, | ||
26 | videosSearchValidator | ||
27 | } from '../../../middlewares' | ||
28 | import { guessAdditionalAttributesFromQuery } from '../../../models/video/formatter' | ||
29 | import { VideoModel } from '../../../models/video/video' | ||
30 | import { MVideoAccountLightBlacklistAllFiles } from '../../../types/models' | ||
31 | import { searchLocalUrl } from './shared' | ||
32 | |||
33 | const searchVideosRouter = express.Router() | ||
34 | |||
35 | searchVideosRouter.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 | |||
49 | export { searchVideosRouter } | ||
50 | |||
51 | // --------------------------------------------------------------------------- | ||
52 | |||
53 | function 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 | |||
68 | async 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 | |||
105 | async 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 | |||
131 | async 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 | |||
162 | function 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 @@ | |||
1 | export * 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 @@ | |||
1 | async 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 | |||
8 | export { | ||
9 | searchLocalUrl | ||
10 | } | ||
11 | |||
12 | // --------------------------------------------------------------------------- | ||
13 | |||
14 | function 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 @@ | |||
1 | import { logger } from '@server/helpers/logger' | ||
2 | import express from 'express' | ||
3 | import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes' | ||
4 | import { ContactForm } from '../../../../shared/models/server' | ||
5 | import { Emailer } from '../../../lib/emailer' | ||
6 | import { Redis } from '../../../lib/redis' | ||
7 | import { asyncMiddleware, contactAdministratorValidator } from '../../../middlewares' | ||
8 | |||
9 | const contactRouter = express.Router() | ||
10 | |||
11 | contactRouter.post('/contact', | ||
12 | asyncMiddleware(contactAdministratorValidator), | ||
13 | asyncMiddleware(contactAdministrator) | ||
14 | ) | ||
15 | |||
16 | async 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 | |||
32 | export { | ||
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 @@ | |||
1 | import express from 'express' | ||
2 | import { InboxManager } from '@server/lib/activitypub/inbox-manager' | ||
3 | import { RemoveDanglingResumableUploadsScheduler } from '@server/lib/schedulers/remove-dangling-resumable-uploads-scheduler' | ||
4 | import { VideoViewsBufferScheduler } from '@server/lib/schedulers/video-views-buffer-scheduler' | ||
5 | import { VideoViewsManager } from '@server/lib/views/video-views-manager' | ||
6 | import { Debug, SendDebugCommand } from '@shared/models' | ||
7 | import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes' | ||
8 | import { UserRight } from '../../../../shared/models/users' | ||
9 | import { authenticate, ensureUserHasRight } from '../../../middlewares' | ||
10 | import { VideoChannelSyncLatestScheduler } from '@server/lib/schedulers/video-channel-sync-latest-scheduler' | ||
11 | import { UpdateVideosScheduler } from '@server/lib/schedulers/update-videos-scheduler' | ||
12 | |||
13 | const debugRouter = express.Router() | ||
14 | |||
15 | debugRouter.get('/debug', | ||
16 | authenticate, | ||
17 | ensureUserHasRight(UserRight.MANAGE_DEBUG), | ||
18 | getDebug | ||
19 | ) | ||
20 | |||
21 | debugRouter.post('/debug/run-command', | ||
22 | authenticate, | ||
23 | ensureUserHasRight(UserRight.MANAGE_DEBUG), | ||
24 | runCommand | ||
25 | ) | ||
26 | |||
27 | // --------------------------------------------------------------------------- | ||
28 | |||
29 | export { | ||
30 | debugRouter | ||
31 | } | ||
32 | |||
33 | // --------------------------------------------------------------------------- | ||
34 | |||
35 | function 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 | |||
42 | async 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 @@ | |||
1 | import express from 'express' | ||
2 | import { getServerActor } from '@server/models/application/application' | ||
3 | import { ServerFollowCreate } from '@shared/models' | ||
4 | import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes' | ||
5 | import { UserRight } from '../../../../shared/models/users' | ||
6 | import { logger } from '../../../helpers/logger' | ||
7 | import { getFormattedObjects } from '../../../helpers/utils' | ||
8 | import { SERVER_ACTOR_NAME } from '../../../initializers/constants' | ||
9 | import { sequelizeTypescript } from '../../../initializers/database' | ||
10 | import { autoFollowBackIfNeeded } from '../../../lib/activitypub/follow' | ||
11 | import { sendAccept, sendReject, sendUndoFollow } from '../../../lib/activitypub/send' | ||
12 | import { JobQueue } from '../../../lib/job-queue' | ||
13 | import { removeRedundanciesOfServer } from '../../../lib/redundancy' | ||
14 | import { | ||
15 | asyncMiddleware, | ||
16 | authenticate, | ||
17 | ensureUserHasRight, | ||
18 | paginationValidator, | ||
19 | setBodyHostsPort, | ||
20 | setDefaultPagination, | ||
21 | setDefaultSort | ||
22 | } from '../../../middlewares' | ||
23 | import { | ||
24 | acceptFollowerValidator, | ||
25 | followValidator, | ||
26 | getFollowerValidator, | ||
27 | instanceFollowersSortValidator, | ||
28 | instanceFollowingSortValidator, | ||
29 | listFollowsValidator, | ||
30 | rejectFollowerValidator, | ||
31 | removeFollowingValidator | ||
32 | } from '../../../middlewares/validators' | ||
33 | import { ActorFollowModel } from '../../../models/actor/actor-follow' | ||
34 | |||
35 | const serverFollowsRouter = express.Router() | ||
36 | serverFollowsRouter.get('/following', | ||
37 | listFollowsValidator, | ||
38 | paginationValidator, | ||
39 | instanceFollowingSortValidator, | ||
40 | setDefaultSort, | ||
41 | setDefaultPagination, | ||
42 | asyncMiddleware(listFollowing) | ||
43 | ) | ||
44 | |||
45 | serverFollowsRouter.post('/following', | ||
46 | authenticate, | ||
47 | ensureUserHasRight(UserRight.MANAGE_SERVER_FOLLOW), | ||
48 | followValidator, | ||
49 | setBodyHostsPort, | ||
50 | asyncMiddleware(addFollow) | ||
51 | ) | ||
52 | |||
53 | serverFollowsRouter.delete('/following/:hostOrHandle', | ||
54 | authenticate, | ||
55 | ensureUserHasRight(UserRight.MANAGE_SERVER_FOLLOW), | ||
56 | asyncMiddleware(removeFollowingValidator), | ||
57 | asyncMiddleware(removeFollowing) | ||
58 | ) | ||
59 | |||
60 | serverFollowsRouter.get('/followers', | ||
61 | listFollowsValidator, | ||
62 | paginationValidator, | ||
63 | instanceFollowersSortValidator, | ||
64 | setDefaultSort, | ||
65 | setDefaultPagination, | ||
66 | asyncMiddleware(listFollowers) | ||
67 | ) | ||
68 | |||
69 | serverFollowsRouter.delete('/followers/:nameWithHost', | ||
70 | authenticate, | ||
71 | ensureUserHasRight(UserRight.MANAGE_SERVER_FOLLOW), | ||
72 | asyncMiddleware(getFollowerValidator), | ||
73 | asyncMiddleware(removeFollower) | ||
74 | ) | ||
75 | |||
76 | serverFollowsRouter.post('/followers/:nameWithHost/reject', | ||
77 | authenticate, | ||
78 | ensureUserHasRight(UserRight.MANAGE_SERVER_FOLLOW), | ||
79 | asyncMiddleware(getFollowerValidator), | ||
80 | rejectFollowerValidator, | ||
81 | asyncMiddleware(rejectFollower) | ||
82 | ) | ||
83 | |||
84 | serverFollowsRouter.post('/followers/:nameWithHost/accept', | ||
85 | authenticate, | ||
86 | ensureUserHasRight(UserRight.MANAGE_SERVER_FOLLOW), | ||
87 | asyncMiddleware(getFollowerValidator), | ||
88 | acceptFollowerValidator, | ||
89 | asyncMiddleware(acceptFollower) | ||
90 | ) | ||
91 | |||
92 | // --------------------------------------------------------------------------- | ||
93 | |||
94 | export { | ||
95 | serverFollowsRouter | ||
96 | } | ||
97 | |||
98 | // --------------------------------------------------------------------------- | ||
99 | |||
100 | async 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 | |||
115 | async 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 | |||
130 | async 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 | |||
159 | async 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 | |||
180 | async 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 | |||
191 | async 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 | |||
203 | async 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 @@ | |||
1 | import express from 'express' | ||
2 | import { apiRateLimiter } from '@server/middlewares' | ||
3 | import { contactRouter } from './contact' | ||
4 | import { debugRouter } from './debug' | ||
5 | import { serverFollowsRouter } from './follows' | ||
6 | import { logsRouter } from './logs' | ||
7 | import { serverRedundancyRouter } from './redundancy' | ||
8 | import { serverBlocklistRouter } from './server-blocklist' | ||
9 | import { statsRouter } from './stats' | ||
10 | |||
11 | const serverRouter = express.Router() | ||
12 | |||
13 | serverRouter.use(apiRateLimiter) | ||
14 | |||
15 | serverRouter.use('/', serverFollowsRouter) | ||
16 | serverRouter.use('/', serverRedundancyRouter) | ||
17 | serverRouter.use('/', statsRouter) | ||
18 | serverRouter.use('/', serverBlocklistRouter) | ||
19 | serverRouter.use('/', contactRouter) | ||
20 | serverRouter.use('/', logsRouter) | ||
21 | serverRouter.use('/', debugRouter) | ||
22 | |||
23 | // --------------------------------------------------------------------------- | ||
24 | |||
25 | export { | ||
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 @@ | |||
1 | import express from 'express' | ||
2 | import { readdir, readFile } from 'fs-extra' | ||
3 | import { join } from 'path' | ||
4 | import { isArray } from '@server/helpers/custom-validators/misc' | ||
5 | import { logger, mtimeSortFilesDesc } from '@server/helpers/logger' | ||
6 | import { pick } from '@shared/core-utils' | ||
7 | import { ClientLogCreate, HttpStatusCode } from '@shared/models' | ||
8 | import { ServerLogLevel } from '../../../../shared/models/server/server-log-level.type' | ||
9 | import { UserRight } from '../../../../shared/models/users' | ||
10 | import { CONFIG } from '../../../initializers/config' | ||
11 | import { AUDIT_LOG_FILENAME, LOG_FILENAME, MAX_LOGS_OUTPUT_CHARACTERS } from '../../../initializers/constants' | ||
12 | import { asyncMiddleware, authenticate, buildRateLimiter, ensureUserHasRight, optionalAuthenticate } from '../../../middlewares' | ||
13 | import { createClientLogValidator, getAuditLogsValidator, getLogsValidator } from '../../../middlewares/validators/logs' | ||
14 | |||
15 | const createClientLogRateLimiter = buildRateLimiter({ | ||
16 | windowMs: CONFIG.RATES_LIMIT.RECEIVE_CLIENT_LOG.WINDOW_MS, | ||
17 | max: CONFIG.RATES_LIMIT.RECEIVE_CLIENT_LOG.MAX | ||
18 | }) | ||
19 | |||
20 | const logsRouter = express.Router() | ||
21 | |||
22 | logsRouter.post('/logs/client', | ||
23 | createClientLogRateLimiter, | ||
24 | optionalAuthenticate, | ||
25 | createClientLogValidator, | ||
26 | createClientLog | ||
27 | ) | ||
28 | |||
29 | logsRouter.get('/logs', | ||
30 | authenticate, | ||
31 | ensureUserHasRight(UserRight.MANAGE_LOGS), | ||
32 | getLogsValidator, | ||
33 | asyncMiddleware(getLogs) | ||
34 | ) | ||
35 | |||
36 | logsRouter.get('/audit-logs', | ||
37 | authenticate, | ||
38 | ensureUserHasRight(UserRight.MANAGE_LOGS), | ||
39 | getAuditLogsValidator, | ||
40 | asyncMiddleware(getAuditLogs) | ||
41 | ) | ||
42 | |||
43 | // --------------------------------------------------------------------------- | ||
44 | |||
45 | export { | ||
46 | logsRouter | ||
47 | } | ||
48 | |||
49 | // --------------------------------------------------------------------------- | ||
50 | |||
51 | function 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 | |||
66 | const auditLogNameFilter = generateLogNameFilter(AUDIT_LOG_FILENAME) | ||
67 | async 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 | |||
78 | const logNameFilter = generateLogNameFilter(LOG_FILENAME) | ||
79 | async 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 | |||
91 | async 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 | |||
132 | async 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 | |||
191 | function 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 | |||
201 | function 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 @@ | |||
1 | import express from 'express' | ||
2 | import { JobQueue } from '@server/lib/job-queue' | ||
3 | import { VideoRedundancyModel } from '@server/models/redundancy/video-redundancy' | ||
4 | import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes' | ||
5 | import { UserRight } from '../../../../shared/models/users' | ||
6 | import { logger } from '../../../helpers/logger' | ||
7 | import { removeRedundanciesOfServer, removeVideoRedundancy } from '../../../lib/redundancy' | ||
8 | import { | ||
9 | asyncMiddleware, | ||
10 | authenticate, | ||
11 | ensureUserHasRight, | ||
12 | paginationValidator, | ||
13 | setDefaultPagination, | ||
14 | setDefaultVideoRedundanciesSort, | ||
15 | videoRedundanciesSortValidator | ||
16 | } from '../../../middlewares' | ||
17 | import { | ||
18 | addVideoRedundancyValidator, | ||
19 | listVideoRedundanciesValidator, | ||
20 | removeVideoRedundancyValidator, | ||
21 | updateServerRedundancyValidator | ||
22 | } from '../../../middlewares/validators/redundancy' | ||
23 | |||
24 | const serverRedundancyRouter = express.Router() | ||
25 | |||
26 | serverRedundancyRouter.put('/redundancy/:host', | ||
27 | authenticate, | ||
28 | ensureUserHasRight(UserRight.MANAGE_SERVER_FOLLOW), | ||
29 | asyncMiddleware(updateServerRedundancyValidator), | ||
30 | asyncMiddleware(updateRedundancy) | ||
31 | ) | ||
32 | |||
33 | serverRedundancyRouter.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 | |||
44 | serverRedundancyRouter.post('/redundancy/videos', | ||
45 | authenticate, | ||
46 | ensureUserHasRight(UserRight.MANAGE_VIDEOS_REDUNDANCIES), | ||
47 | addVideoRedundancyValidator, | ||
48 | asyncMiddleware(addVideoRedundancy) | ||
49 | ) | ||
50 | |||
51 | serverRedundancyRouter.delete('/redundancy/videos/:redundancyId', | ||
52 | authenticate, | ||
53 | ensureUserHasRight(UserRight.MANAGE_VIDEOS_REDUNDANCIES), | ||
54 | removeVideoRedundancyValidator, | ||
55 | asyncMiddleware(removeVideoRedundancyController) | ||
56 | ) | ||
57 | |||
58 | // --------------------------------------------------------------------------- | ||
59 | |||
60 | export { | ||
61 | serverRedundancyRouter | ||
62 | } | ||
63 | |||
64 | // --------------------------------------------------------------------------- | ||
65 | |||
66 | async 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 | |||
83 | async 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 | |||
96 | async 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 | |||
102 | async 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 @@ | |||
1 | import 'multer' | ||
2 | import express from 'express' | ||
3 | import { logger } from '@server/helpers/logger' | ||
4 | import { getServerActor } from '@server/models/application/application' | ||
5 | import { UserNotificationModel } from '@server/models/user/user-notification' | ||
6 | import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes' | ||
7 | import { UserRight } from '../../../../shared/models/users' | ||
8 | import { getFormattedObjects } from '../../../helpers/utils' | ||
9 | import { addAccountInBlocklist, addServerInBlocklist, removeAccountFromBlocklist, removeServerFromBlocklist } from '../../../lib/blocklist' | ||
10 | import { | ||
11 | asyncMiddleware, | ||
12 | asyncRetryTransactionMiddleware, | ||
13 | authenticate, | ||
14 | ensureUserHasRight, | ||
15 | paginationValidator, | ||
16 | setDefaultPagination, | ||
17 | setDefaultSort | ||
18 | } from '../../../middlewares' | ||
19 | import { | ||
20 | accountsBlocklistSortValidator, | ||
21 | blockAccountValidator, | ||
22 | blockServerValidator, | ||
23 | serversBlocklistSortValidator, | ||
24 | unblockAccountByServerValidator, | ||
25 | unblockServerByServerValidator | ||
26 | } from '../../../middlewares/validators' | ||
27 | import { AccountBlocklistModel } from '../../../models/account/account-blocklist' | ||
28 | import { ServerBlocklistModel } from '../../../models/server/server-blocklist' | ||
29 | |||
30 | const serverBlocklistRouter = express.Router() | ||
31 | |||
32 | serverBlocklistRouter.get('/blocklist/accounts', | ||
33 | authenticate, | ||
34 | ensureUserHasRight(UserRight.MANAGE_ACCOUNTS_BLOCKLIST), | ||
35 | paginationValidator, | ||
36 | accountsBlocklistSortValidator, | ||
37 | setDefaultSort, | ||
38 | setDefaultPagination, | ||
39 | asyncMiddleware(listBlockedAccounts) | ||
40 | ) | ||
41 | |||
42 | serverBlocklistRouter.post('/blocklist/accounts', | ||
43 | authenticate, | ||
44 | ensureUserHasRight(UserRight.MANAGE_ACCOUNTS_BLOCKLIST), | ||
45 | asyncMiddleware(blockAccountValidator), | ||
46 | asyncRetryTransactionMiddleware(blockAccount) | ||
47 | ) | ||
48 | |||
49 | serverBlocklistRouter.delete('/blocklist/accounts/:accountName', | ||
50 | authenticate, | ||
51 | ensureUserHasRight(UserRight.MANAGE_ACCOUNTS_BLOCKLIST), | ||
52 | asyncMiddleware(unblockAccountByServerValidator), | ||
53 | asyncRetryTransactionMiddleware(unblockAccount) | ||
54 | ) | ||
55 | |||
56 | serverBlocklistRouter.get('/blocklist/servers', | ||
57 | authenticate, | ||
58 | ensureUserHasRight(UserRight.MANAGE_SERVERS_BLOCKLIST), | ||
59 | paginationValidator, | ||
60 | serversBlocklistSortValidator, | ||
61 | setDefaultSort, | ||
62 | setDefaultPagination, | ||
63 | asyncMiddleware(listBlockedServers) | ||
64 | ) | ||
65 | |||
66 | serverBlocklistRouter.post('/blocklist/servers', | ||
67 | authenticate, | ||
68 | ensureUserHasRight(UserRight.MANAGE_SERVERS_BLOCKLIST), | ||
69 | asyncMiddleware(blockServerValidator), | ||
70 | asyncRetryTransactionMiddleware(blockServer) | ||
71 | ) | ||
72 | |||
73 | serverBlocklistRouter.delete('/blocklist/servers/:host', | ||
74 | authenticate, | ||
75 | ensureUserHasRight(UserRight.MANAGE_SERVERS_BLOCKLIST), | ||
76 | asyncMiddleware(unblockServerByServerValidator), | ||
77 | asyncRetryTransactionMiddleware(unblockServer) | ||
78 | ) | ||
79 | |||
80 | export { | ||
81 | serverBlocklistRouter | ||
82 | } | ||
83 | |||
84 | // --------------------------------------------------------------------------- | ||
85 | |||
86 | async 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 | |||
100 | async 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 | |||
115 | async 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 | |||
123 | async 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 | |||
137 | async 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 | |||
152 | async 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 @@ | |||
1 | import express from 'express' | ||
2 | import { StatsManager } from '@server/lib/stat-manager' | ||
3 | import { ROUTE_CACHE_LIFETIME } from '../../../initializers/constants' | ||
4 | import { asyncMiddleware } from '../../../middlewares' | ||
5 | import { cacheRoute } from '../../../middlewares/cache/cache' | ||
6 | import { Hooks } from '@server/lib/plugins/hooks' | ||
7 | |||
8 | const statsRouter = express.Router() | ||
9 | |||
10 | statsRouter.get('/stats', | ||
11 | cacheRoute(ROUTE_CACHE_LIFETIME.STATS), | ||
12 | asyncMiddleware(getStats) | ||
13 | ) | ||
14 | |||
15 | async 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 | |||
24 | export { | ||
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 @@ | |||
1 | import express from 'express' | ||
2 | import { HttpStatusCode } from '@shared/models' | ||
3 | import { CONFIG } from '../../../initializers/config' | ||
4 | import { sendVerifyRegistrationEmail, sendVerifyUserEmail } from '../../../lib/user' | ||
5 | import { asyncMiddleware, buildRateLimiter } from '../../../middlewares' | ||
6 | import { | ||
7 | registrationVerifyEmailValidator, | ||
8 | usersAskSendVerifyEmailValidator, | ||
9 | usersVerifyEmailValidator | ||
10 | } from '../../../middlewares/validators' | ||
11 | |||
12 | const askSendEmailLimiter = buildRateLimiter({ | ||
13 | windowMs: CONFIG.RATES_LIMIT.ASK_SEND_EMAIL.WINDOW_MS, | ||
14 | max: CONFIG.RATES_LIMIT.ASK_SEND_EMAIL.MAX | ||
15 | }) | ||
16 | |||
17 | const emailVerificationRouter = express.Router() | ||
18 | |||
19 | emailVerificationRouter.post([ '/ask-send-verify-email', '/registrations/ask-send-verify-email' ], | ||
20 | askSendEmailLimiter, | ||
21 | asyncMiddleware(usersAskSendVerifyEmailValidator), | ||
22 | asyncMiddleware(reSendVerifyUserEmail) | ||
23 | ) | ||
24 | |||
25 | emailVerificationRouter.post('/:id/verify-email', | ||
26 | asyncMiddleware(usersVerifyEmailValidator), | ||
27 | asyncMiddleware(verifyUserEmail) | ||
28 | ) | ||
29 | |||
30 | emailVerificationRouter.post('/registrations/:registrationId/verify-email', | ||
31 | asyncMiddleware(registrationVerifyEmailValidator), | ||
32 | asyncMiddleware(verifyRegistrationEmail) | ||
33 | ) | ||
34 | |||
35 | // --------------------------------------------------------------------------- | ||
36 | |||
37 | export { | ||
38 | emailVerificationRouter | ||
39 | } | ||
40 | |||
41 | async 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 | |||
51 | async 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 | |||
65 | async 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 @@ | |||
1 | import express from 'express' | ||
2 | import { tokensRouter } from '@server/controllers/api/users/token' | ||
3 | import { Hooks } from '@server/lib/plugins/hooks' | ||
4 | import { OAuthTokenModel } from '@server/models/oauth/oauth-token' | ||
5 | import { MUserAccountDefault } from '@server/types/models' | ||
6 | import { pick } from '@shared/core-utils' | ||
7 | import { HttpStatusCode, UserCreate, UserCreateResult, UserRight, UserUpdate } from '@shared/models' | ||
8 | import { auditLoggerFactory, getAuditIdFromRes, UserAuditView } from '../../../helpers/audit-logger' | ||
9 | import { logger } from '../../../helpers/logger' | ||
10 | import { generateRandomString, getFormattedObjects } from '../../../helpers/utils' | ||
11 | import { WEBSERVER } from '../../../initializers/constants' | ||
12 | import { sequelizeTypescript } from '../../../initializers/database' | ||
13 | import { Emailer } from '../../../lib/emailer' | ||
14 | import { Redis } from '../../../lib/redis' | ||
15 | import { buildUser, createUserAccountAndChannelAndPlaylist } from '../../../lib/user' | ||
16 | import { | ||
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' | ||
33 | import { | ||
34 | ensureCanModerateUser, | ||
35 | usersAskResetPasswordValidator, | ||
36 | usersBlockingValidator, | ||
37 | usersResetPasswordValidator | ||
38 | } from '../../../middlewares/validators' | ||
39 | import { UserModel } from '../../../models/user/user' | ||
40 | import { emailVerificationRouter } from './email-verification' | ||
41 | import { meRouter } from './me' | ||
42 | import { myAbusesRouter } from './my-abuses' | ||
43 | import { myBlocklistRouter } from './my-blocklist' | ||
44 | import { myVideosHistoryRouter } from './my-history' | ||
45 | import { myNotificationsRouter } from './my-notifications' | ||
46 | import { mySubscriptionsRouter } from './my-subscriptions' | ||
47 | import { myVideoPlaylistsRouter } from './my-video-playlists' | ||
48 | import { registrationsRouter } from './registrations' | ||
49 | import { twoFactorRouter } from './two-factor' | ||
50 | |||
51 | const auditLogger = auditLoggerFactory('users') | ||
52 | |||
53 | const usersRouter = express.Router() | ||
54 | |||
55 | usersRouter.use(apiRateLimiter) | ||
56 | |||
57 | usersRouter.use('/', emailVerificationRouter) | ||
58 | usersRouter.use('/', registrationsRouter) | ||
59 | usersRouter.use('/', twoFactorRouter) | ||
60 | usersRouter.use('/', tokensRouter) | ||
61 | usersRouter.use('/', myNotificationsRouter) | ||
62 | usersRouter.use('/', mySubscriptionsRouter) | ||
63 | usersRouter.use('/', myBlocklistRouter) | ||
64 | usersRouter.use('/', myVideosHistoryRouter) | ||
65 | usersRouter.use('/', myVideoPlaylistsRouter) | ||
66 | usersRouter.use('/', myAbusesRouter) | ||
67 | usersRouter.use('/', meRouter) | ||
68 | |||
69 | usersRouter.get('/autocomplete', | ||
70 | userAutocompleteValidator, | ||
71 | asyncMiddleware(autocompleteUsers) | ||
72 | ) | ||
73 | |||
74 | usersRouter.get('/', | ||
75 | authenticate, | ||
76 | ensureUserHasRight(UserRight.MANAGE_USERS), | ||
77 | paginationValidator, | ||
78 | adminUsersSortValidator, | ||
79 | setDefaultSort, | ||
80 | setDefaultPagination, | ||
81 | usersListValidator, | ||
82 | asyncMiddleware(listUsers) | ||
83 | ) | ||
84 | |||
85 | usersRouter.post('/:id/block', | ||
86 | authenticate, | ||
87 | ensureUserHasRight(UserRight.MANAGE_USERS), | ||
88 | asyncMiddleware(usersBlockingValidator), | ||
89 | ensureCanModerateUser, | ||
90 | asyncMiddleware(blockUser) | ||
91 | ) | ||
92 | usersRouter.post('/:id/unblock', | ||
93 | authenticate, | ||
94 | ensureUserHasRight(UserRight.MANAGE_USERS), | ||
95 | asyncMiddleware(usersBlockingValidator), | ||
96 | ensureCanModerateUser, | ||
97 | asyncMiddleware(unblockUser) | ||
98 | ) | ||
99 | |||
100 | usersRouter.get('/:id', | ||
101 | authenticate, | ||
102 | ensureUserHasRight(UserRight.MANAGE_USERS), | ||
103 | asyncMiddleware(usersGetValidator), | ||
104 | getUser | ||
105 | ) | ||
106 | |||
107 | usersRouter.post('/', | ||
108 | authenticate, | ||
109 | ensureUserHasRight(UserRight.MANAGE_USERS), | ||
110 | asyncMiddleware(usersAddValidator), | ||
111 | asyncRetryTransactionMiddleware(createUser) | ||
112 | ) | ||
113 | |||
114 | usersRouter.put('/:id', | ||
115 | authenticate, | ||
116 | ensureUserHasRight(UserRight.MANAGE_USERS), | ||
117 | asyncMiddleware(usersUpdateValidator), | ||
118 | ensureCanModerateUser, | ||
119 | asyncMiddleware(updateUser) | ||
120 | ) | ||
121 | |||
122 | usersRouter.delete('/:id', | ||
123 | authenticate, | ||
124 | ensureUserHasRight(UserRight.MANAGE_USERS), | ||
125 | asyncMiddleware(usersRemoveValidator), | ||
126 | ensureCanModerateUser, | ||
127 | asyncMiddleware(removeUser) | ||
128 | ) | ||
129 | |||
130 | usersRouter.post('/ask-reset-password', | ||
131 | asyncMiddleware(usersAskResetPasswordValidator), | ||
132 | asyncMiddleware(askResetUserPassword) | ||
133 | ) | ||
134 | |||
135 | usersRouter.post('/:id/reset-password', | ||
136 | asyncMiddleware(usersResetPasswordValidator), | ||
137 | asyncMiddleware(resetUserPassword) | ||
138 | ) | ||
139 | |||
140 | // --------------------------------------------------------------------------- | ||
141 | |||
142 | export { | ||
143 | usersRouter | ||
144 | } | ||
145 | |||
146 | // --------------------------------------------------------------------------- | ||
147 | |||
148 | async 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 | |||
191 | async 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 | |||
201 | async 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 | |||
212 | function getUser (req: express.Request, res: express.Response) { | ||
213 | return res.json(res.locals.user.toFormattedJSON({ withAdminFlags: true })) | ||
214 | } | ||
215 | |||
216 | async 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 | |||
222 | async 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 | |||
234 | async 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 | |||
249 | async 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 | |||
284 | async 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 | |||
294 | async 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 | |||
304 | async 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 @@ | |||
1 | import 'multer' | ||
2 | import express from 'express' | ||
3 | import { auditLoggerFactory, getAuditIdFromRes, UserAuditView } from '@server/helpers/audit-logger' | ||
4 | import { Hooks } from '@server/lib/plugins/hooks' | ||
5 | import { pick } from '@shared/core-utils' | ||
6 | import { ActorImageType, HttpStatusCode, UserUpdateMe, UserVideoQuota, UserVideoRate as FormattedUserVideoRate } from '@shared/models' | ||
7 | import { AttributesOnly } from '@shared/typescript-utils' | ||
8 | import { createReqFiles } from '../../../helpers/express-utils' | ||
9 | import { getFormattedObjects } from '../../../helpers/utils' | ||
10 | import { CONFIG } from '../../../initializers/config' | ||
11 | import { MIMETYPES } from '../../../initializers/constants' | ||
12 | import { sequelizeTypescript } from '../../../initializers/database' | ||
13 | import { sendUpdateActor } from '../../../lib/activitypub/send' | ||
14 | import { deleteLocalActorImageFile, updateLocalActorImageFiles } from '../../../lib/local-actor' | ||
15 | import { getOriginalVideoFileTotalDailyFromUser, getOriginalVideoFileTotalFromUser, sendVerifyUserEmail } from '../../../lib/user' | ||
16 | import { | ||
17 | asyncMiddleware, | ||
18 | asyncRetryTransactionMiddleware, | ||
19 | authenticate, | ||
20 | paginationValidator, | ||
21 | setDefaultPagination, | ||
22 | setDefaultSort, | ||
23 | setDefaultVideosSort, | ||
24 | usersUpdateMeValidator, | ||
25 | usersVideoRatingValidator | ||
26 | } from '../../../middlewares' | ||
27 | import { | ||
28 | deleteMeValidator, | ||
29 | getMyVideoImportsValidator, | ||
30 | usersVideosValidator, | ||
31 | videoImportsSortValidator, | ||
32 | videosSortValidator | ||
33 | } from '../../../middlewares/validators' | ||
34 | import { updateAvatarValidator } from '../../../middlewares/validators/actor-image' | ||
35 | import { AccountModel } from '../../../models/account/account' | ||
36 | import { AccountVideoRateModel } from '../../../models/account/account-video-rate' | ||
37 | import { UserModel } from '../../../models/user/user' | ||
38 | import { VideoModel } from '../../../models/video/video' | ||
39 | import { VideoImportModel } from '../../../models/video/video-import' | ||
40 | |||
41 | const auditLogger = auditLoggerFactory('users') | ||
42 | |||
43 | const reqAvatarFile = createReqFiles([ 'avatarfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT) | ||
44 | |||
45 | const meRouter = express.Router() | ||
46 | |||
47 | meRouter.get('/me', | ||
48 | authenticate, | ||
49 | asyncMiddleware(getUserInformation) | ||
50 | ) | ||
51 | meRouter.delete('/me', | ||
52 | authenticate, | ||
53 | deleteMeValidator, | ||
54 | asyncMiddleware(deleteMe) | ||
55 | ) | ||
56 | |||
57 | meRouter.get('/me/video-quota-used', | ||
58 | authenticate, | ||
59 | asyncMiddleware(getUserVideoQuotaUsed) | ||
60 | ) | ||
61 | |||
62 | meRouter.get('/me/videos/imports', | ||
63 | authenticate, | ||
64 | paginationValidator, | ||
65 | videoImportsSortValidator, | ||
66 | setDefaultSort, | ||
67 | setDefaultPagination, | ||
68 | getMyVideoImportsValidator, | ||
69 | asyncMiddleware(getUserVideoImports) | ||
70 | ) | ||
71 | |||
72 | meRouter.get('/me/videos', | ||
73 | authenticate, | ||
74 | paginationValidator, | ||
75 | videosSortValidator, | ||
76 | setDefaultVideosSort, | ||
77 | setDefaultPagination, | ||
78 | asyncMiddleware(usersVideosValidator), | ||
79 | asyncMiddleware(getUserVideos) | ||
80 | ) | ||
81 | |||
82 | meRouter.get('/me/videos/:videoId/rating', | ||
83 | authenticate, | ||
84 | asyncMiddleware(usersVideoRatingValidator), | ||
85 | asyncMiddleware(getUserVideoRating) | ||
86 | ) | ||
87 | |||
88 | meRouter.put('/me', | ||
89 | authenticate, | ||
90 | asyncMiddleware(usersUpdateMeValidator), | ||
91 | asyncRetryTransactionMiddleware(updateMe) | ||
92 | ) | ||
93 | |||
94 | meRouter.post('/me/avatar/pick', | ||
95 | authenticate, | ||
96 | reqAvatarFile, | ||
97 | updateAvatarValidator, | ||
98 | asyncRetryTransactionMiddleware(updateMyAvatar) | ||
99 | ) | ||
100 | |||
101 | meRouter.delete('/me/avatar', | ||
102 | authenticate, | ||
103 | asyncRetryTransactionMiddleware(deleteMyAvatar) | ||
104 | ) | ||
105 | |||
106 | // --------------------------------------------------------------------------- | ||
107 | |||
108 | export { | ||
109 | meRouter | ||
110 | } | ||
111 | |||
112 | // --------------------------------------------------------------------------- | ||
113 | |||
114 | async 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 | |||
142 | async 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 | |||
153 | async 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 | |||
160 | async 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 | |||
172 | async 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 | |||
186 | async 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 | |||
196 | async 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 | |||
253 | async 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 | |||
270 | async 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 @@ | |||
1 | import express from 'express' | ||
2 | import { AbuseModel } from '@server/models/abuse/abuse' | ||
3 | import { | ||
4 | abuseListForUserValidator, | ||
5 | abusesSortValidator, | ||
6 | asyncMiddleware, | ||
7 | authenticate, | ||
8 | paginationValidator, | ||
9 | setDefaultPagination, | ||
10 | setDefaultSort | ||
11 | } from '../../../middlewares' | ||
12 | |||
13 | const myAbusesRouter = express.Router() | ||
14 | |||
15 | myAbusesRouter.get('/me/abuses', | ||
16 | authenticate, | ||
17 | paginationValidator, | ||
18 | abusesSortValidator, | ||
19 | setDefaultSort, | ||
20 | setDefaultPagination, | ||
21 | abuseListForUserValidator, | ||
22 | asyncMiddleware(listMyAbuses) | ||
23 | ) | ||
24 | |||
25 | // --------------------------------------------------------------------------- | ||
26 | |||
27 | export { | ||
28 | myAbusesRouter | ||
29 | } | ||
30 | |||
31 | // --------------------------------------------------------------------------- | ||
32 | |||
33 | async 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 @@ | |||
1 | import 'multer' | ||
2 | import express from 'express' | ||
3 | import { logger } from '@server/helpers/logger' | ||
4 | import { UserNotificationModel } from '@server/models/user/user-notification' | ||
5 | import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes' | ||
6 | import { getFormattedObjects } from '../../../helpers/utils' | ||
7 | import { addAccountInBlocklist, addServerInBlocklist, removeAccountFromBlocklist, removeServerFromBlocklist } from '../../../lib/blocklist' | ||
8 | import { | ||
9 | asyncMiddleware, | ||
10 | asyncRetryTransactionMiddleware, | ||
11 | authenticate, | ||
12 | paginationValidator, | ||
13 | setDefaultPagination, | ||
14 | setDefaultSort, | ||
15 | unblockAccountByAccountValidator | ||
16 | } from '../../../middlewares' | ||
17 | import { | ||
18 | accountsBlocklistSortValidator, | ||
19 | blockAccountValidator, | ||
20 | blockServerValidator, | ||
21 | serversBlocklistSortValidator, | ||
22 | unblockServerByAccountValidator | ||
23 | } from '../../../middlewares/validators' | ||
24 | import { AccountBlocklistModel } from '../../../models/account/account-blocklist' | ||
25 | import { ServerBlocklistModel } from '../../../models/server/server-blocklist' | ||
26 | |||
27 | const myBlocklistRouter = express.Router() | ||
28 | |||
29 | myBlocklistRouter.get('/me/blocklist/accounts', | ||
30 | authenticate, | ||
31 | paginationValidator, | ||
32 | accountsBlocklistSortValidator, | ||
33 | setDefaultSort, | ||
34 | setDefaultPagination, | ||
35 | asyncMiddleware(listBlockedAccounts) | ||
36 | ) | ||
37 | |||
38 | myBlocklistRouter.post('/me/blocklist/accounts', | ||
39 | authenticate, | ||
40 | asyncMiddleware(blockAccountValidator), | ||
41 | asyncRetryTransactionMiddleware(blockAccount) | ||
42 | ) | ||
43 | |||
44 | myBlocklistRouter.delete('/me/blocklist/accounts/:accountName', | ||
45 | authenticate, | ||
46 | asyncMiddleware(unblockAccountByAccountValidator), | ||
47 | asyncRetryTransactionMiddleware(unblockAccount) | ||
48 | ) | ||
49 | |||
50 | myBlocklistRouter.get('/me/blocklist/servers', | ||
51 | authenticate, | ||
52 | paginationValidator, | ||
53 | serversBlocklistSortValidator, | ||
54 | setDefaultSort, | ||
55 | setDefaultPagination, | ||
56 | asyncMiddleware(listBlockedServers) | ||
57 | ) | ||
58 | |||
59 | myBlocklistRouter.post('/me/blocklist/servers', | ||
60 | authenticate, | ||
61 | asyncMiddleware(blockServerValidator), | ||
62 | asyncRetryTransactionMiddleware(blockServer) | ||
63 | ) | ||
64 | |||
65 | myBlocklistRouter.delete('/me/blocklist/servers/:host', | ||
66 | authenticate, | ||
67 | asyncMiddleware(unblockServerByAccountValidator), | ||
68 | asyncRetryTransactionMiddleware(unblockServer) | ||
69 | ) | ||
70 | |||
71 | export { | ||
72 | myBlocklistRouter | ||
73 | } | ||
74 | |||
75 | // --------------------------------------------------------------------------- | ||
76 | |||
77 | async 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 | |||
91 | async 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 | |||
106 | async 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 | |||
114 | async 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 | |||
128 | async 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 | |||
143 | async 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 @@ | |||
1 | import { forceNumber } from '@shared/core-utils' | ||
2 | import express from 'express' | ||
3 | import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes' | ||
4 | import { getFormattedObjects } from '../../../helpers/utils' | ||
5 | import { sequelizeTypescript } from '../../../initializers/database' | ||
6 | import { | ||
7 | asyncMiddleware, | ||
8 | asyncRetryTransactionMiddleware, | ||
9 | authenticate, | ||
10 | paginationValidator, | ||
11 | setDefaultPagination, | ||
12 | userHistoryListValidator, | ||
13 | userHistoryRemoveAllValidator, | ||
14 | userHistoryRemoveElementValidator | ||
15 | } from '../../../middlewares' | ||
16 | import { UserVideoHistoryModel } from '../../../models/user/user-video-history' | ||
17 | |||
18 | const myVideosHistoryRouter = express.Router() | ||
19 | |||
20 | myVideosHistoryRouter.get('/me/history/videos', | ||
21 | authenticate, | ||
22 | paginationValidator, | ||
23 | setDefaultPagination, | ||
24 | userHistoryListValidator, | ||
25 | asyncMiddleware(listMyVideosHistory) | ||
26 | ) | ||
27 | |||
28 | myVideosHistoryRouter.delete('/me/history/videos/:videoId', | ||
29 | authenticate, | ||
30 | userHistoryRemoveElementValidator, | ||
31 | asyncMiddleware(removeUserHistoryElement) | ||
32 | ) | ||
33 | |||
34 | myVideosHistoryRouter.post('/me/history/videos/remove', | ||
35 | authenticate, | ||
36 | userHistoryRemoveAllValidator, | ||
37 | asyncRetryTransactionMiddleware(removeAllUserHistory) | ||
38 | ) | ||
39 | |||
40 | // --------------------------------------------------------------------------- | ||
41 | |||
42 | export { | ||
43 | myVideosHistoryRouter | ||
44 | } | ||
45 | |||
46 | // --------------------------------------------------------------------------- | ||
47 | |||
48 | async 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 | |||
56 | async 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 | |||
64 | async 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 @@ | |||
1 | import 'multer' | ||
2 | import express from 'express' | ||
3 | import { UserNotificationModel } from '@server/models/user/user-notification' | ||
4 | import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes' | ||
5 | import { UserNotificationSetting } from '../../../../shared/models/users' | ||
6 | import { | ||
7 | asyncMiddleware, | ||
8 | asyncRetryTransactionMiddleware, | ||
9 | authenticate, | ||
10 | paginationValidator, | ||
11 | setDefaultPagination, | ||
12 | setDefaultSort, | ||
13 | userNotificationsSortValidator | ||
14 | } from '../../../middlewares' | ||
15 | import { | ||
16 | listUserNotificationsValidator, | ||
17 | markAsReadUserNotificationsValidator, | ||
18 | updateNotificationSettingsValidator | ||
19 | } from '../../../middlewares/validators/user-notifications' | ||
20 | import { UserNotificationSettingModel } from '../../../models/user/user-notification-setting' | ||
21 | import { meRouter } from './me' | ||
22 | import { getFormattedObjects } from '@server/helpers/utils' | ||
23 | |||
24 | const myNotificationsRouter = express.Router() | ||
25 | |||
26 | meRouter.put('/me/notification-settings', | ||
27 | authenticate, | ||
28 | updateNotificationSettingsValidator, | ||
29 | asyncRetryTransactionMiddleware(updateNotificationSettings) | ||
30 | ) | ||
31 | |||
32 | myNotificationsRouter.get('/me/notifications', | ||
33 | authenticate, | ||
34 | paginationValidator, | ||
35 | userNotificationsSortValidator, | ||
36 | setDefaultSort, | ||
37 | setDefaultPagination, | ||
38 | listUserNotificationsValidator, | ||
39 | asyncMiddleware(listUserNotifications) | ||
40 | ) | ||
41 | |||
42 | myNotificationsRouter.post('/me/notifications/read', | ||
43 | authenticate, | ||
44 | markAsReadUserNotificationsValidator, | ||
45 | asyncMiddleware(markAsReadUserNotifications) | ||
46 | ) | ||
47 | |||
48 | myNotificationsRouter.post('/me/notifications/read-all', | ||
49 | authenticate, | ||
50 | asyncMiddleware(markAsReadAllUserNotifications) | ||
51 | ) | ||
52 | |||
53 | export { | ||
54 | myNotificationsRouter | ||
55 | } | ||
56 | |||
57 | // --------------------------------------------------------------------------- | ||
58 | |||
59 | async 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 | |||
94 | async 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 | |||
102 | async 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 | |||
110 | async 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 @@ | |||
1 | import 'multer' | ||
2 | import express from 'express' | ||
3 | import { handlesToNameAndHost } from '@server/helpers/actors' | ||
4 | import { pickCommonVideoQuery } from '@server/helpers/query' | ||
5 | import { sendUndoFollow } from '@server/lib/activitypub/send' | ||
6 | import { Hooks } from '@server/lib/plugins/hooks' | ||
7 | import { VideoChannelModel } from '@server/models/video/video-channel' | ||
8 | import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes' | ||
9 | import { buildNSFWFilter, getCountVideos } from '../../../helpers/express-utils' | ||
10 | import { getFormattedObjects } from '../../../helpers/utils' | ||
11 | import { sequelizeTypescript } from '../../../initializers/database' | ||
12 | import { JobQueue } from '../../../lib/job-queue' | ||
13 | import { | ||
14 | asyncMiddleware, | ||
15 | asyncRetryTransactionMiddleware, | ||
16 | authenticate, | ||
17 | commonVideosFiltersValidator, | ||
18 | paginationValidator, | ||
19 | setDefaultPagination, | ||
20 | setDefaultSort, | ||
21 | setDefaultVideosSort, | ||
22 | userSubscriptionAddValidator, | ||
23 | userSubscriptionGetValidator | ||
24 | } from '../../../middlewares' | ||
25 | import { | ||
26 | areSubscriptionsExistValidator, | ||
27 | userSubscriptionListValidator, | ||
28 | userSubscriptionsSortValidator, | ||
29 | videosSortValidator | ||
30 | } from '../../../middlewares/validators' | ||
31 | import { ActorFollowModel } from '../../../models/actor/actor-follow' | ||
32 | import { guessAdditionalAttributesFromQuery } from '../../../models/video/formatter' | ||
33 | import { VideoModel } from '../../../models/video/video' | ||
34 | |||
35 | const mySubscriptionsRouter = express.Router() | ||
36 | |||
37 | mySubscriptionsRouter.get('/me/subscriptions/videos', | ||
38 | authenticate, | ||
39 | paginationValidator, | ||
40 | videosSortValidator, | ||
41 | setDefaultVideosSort, | ||
42 | setDefaultPagination, | ||
43 | commonVideosFiltersValidator, | ||
44 | asyncMiddleware(getUserSubscriptionVideos) | ||
45 | ) | ||
46 | |||
47 | mySubscriptionsRouter.get('/me/subscriptions/exist', | ||
48 | authenticate, | ||
49 | areSubscriptionsExistValidator, | ||
50 | asyncMiddleware(areSubscriptionsExist) | ||
51 | ) | ||
52 | |||
53 | mySubscriptionsRouter.get('/me/subscriptions', | ||
54 | authenticate, | ||
55 | paginationValidator, | ||
56 | userSubscriptionsSortValidator, | ||
57 | setDefaultSort, | ||
58 | setDefaultPagination, | ||
59 | userSubscriptionListValidator, | ||
60 | asyncMiddleware(getUserSubscriptions) | ||
61 | ) | ||
62 | |||
63 | mySubscriptionsRouter.post('/me/subscriptions', | ||
64 | authenticate, | ||
65 | userSubscriptionAddValidator, | ||
66 | addUserSubscription | ||
67 | ) | ||
68 | |||
69 | mySubscriptionsRouter.get('/me/subscriptions/:uri', | ||
70 | authenticate, | ||
71 | userSubscriptionGetValidator, | ||
72 | asyncMiddleware(getUserSubscription) | ||
73 | ) | ||
74 | |||
75 | mySubscriptionsRouter.delete('/me/subscriptions/:uri', | ||
76 | authenticate, | ||
77 | userSubscriptionGetValidator, | ||
78 | asyncRetryTransactionMiddleware(deleteUserSubscription) | ||
79 | ) | ||
80 | |||
81 | // --------------------------------------------------------------------------- | ||
82 | |||
83 | export { | ||
84 | mySubscriptionsRouter | ||
85 | } | ||
86 | |||
87 | // --------------------------------------------------------------------------- | ||
88 | |||
89 | async 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 | |||
115 | function 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 | |||
131 | async 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 | |||
138 | async 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 | |||
154 | async 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 | |||
169 | async 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 @@ | |||
1 | import express from 'express' | ||
2 | import { forceNumber } from '@shared/core-utils' | ||
3 | import { uuidToShort } from '@shared/extra-utils' | ||
4 | import { VideosExistInPlaylists } from '../../../../shared/models/videos/playlist/video-exist-in-playlist.model' | ||
5 | import { asyncMiddleware, authenticate } from '../../../middlewares' | ||
6 | import { doVideosInPlaylistExistValidator } from '../../../middlewares/validators/videos/video-playlists' | ||
7 | import { VideoPlaylistModel } from '../../../models/video/video-playlist' | ||
8 | |||
9 | const myVideoPlaylistsRouter = express.Router() | ||
10 | |||
11 | myVideoPlaylistsRouter.get('/me/video-playlists/videos-exist', | ||
12 | authenticate, | ||
13 | doVideosInPlaylistExistValidator, | ||
14 | asyncMiddleware(doVideosInPlaylistExist) | ||
15 | ) | ||
16 | |||
17 | // --------------------------------------------------------------------------- | ||
18 | |||
19 | export { | ||
20 | myVideoPlaylistsRouter | ||
21 | } | ||
22 | |||
23 | // --------------------------------------------------------------------------- | ||
24 | |||
25 | async 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 @@ | |||
1 | import express from 'express' | ||
2 | import { Emailer } from '@server/lib/emailer' | ||
3 | import { Hooks } from '@server/lib/plugins/hooks' | ||
4 | import { UserRegistrationModel } from '@server/models/user/user-registration' | ||
5 | import { pick } from '@shared/core-utils' | ||
6 | import { | ||
7 | HttpStatusCode, | ||
8 | UserRegister, | ||
9 | UserRegistrationRequest, | ||
10 | UserRegistrationState, | ||
11 | UserRegistrationUpdateState, | ||
12 | UserRight | ||
13 | } from '@shared/models' | ||
14 | import { auditLoggerFactory, UserAuditView } from '../../../helpers/audit-logger' | ||
15 | import { logger } from '../../../helpers/logger' | ||
16 | import { CONFIG } from '../../../initializers/config' | ||
17 | import { Notifier } from '../../../lib/notifier' | ||
18 | import { buildUser, createUserAccountAndChannelAndPlaylist, sendVerifyRegistrationEmail, sendVerifyUserEmail } from '../../../lib/user' | ||
19 | import { | ||
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 | |||
38 | const auditLogger = auditLoggerFactory('users') | ||
39 | |||
40 | const registrationRateLimiter = buildRateLimiter({ | ||
41 | windowMs: CONFIG.RATES_LIMIT.SIGNUP.WINDOW_MS, | ||
42 | max: CONFIG.RATES_LIMIT.SIGNUP.MAX, | ||
43 | skipFailedRequests: true | ||
44 | }) | ||
45 | |||
46 | const registrationsRouter = express.Router() | ||
47 | |||
48 | registrationsRouter.post('/registrations/request', | ||
49 | registrationRateLimiter, | ||
50 | asyncMiddleware(ensureUserRegistrationAllowedFactory('request-registration')), | ||
51 | ensureUserRegistrationAllowedForIP, | ||
52 | asyncMiddleware(usersRequestRegistrationValidator), | ||
53 | asyncRetryTransactionMiddleware(requestRegistration) | ||
54 | ) | ||
55 | |||
56 | registrationsRouter.post('/registrations/:registrationId/accept', | ||
57 | authenticate, | ||
58 | ensureUserHasRight(UserRight.MANAGE_REGISTRATIONS), | ||
59 | asyncMiddleware(acceptOrRejectRegistrationValidator), | ||
60 | asyncRetryTransactionMiddleware(acceptRegistration) | ||
61 | ) | ||
62 | registrationsRouter.post('/registrations/:registrationId/reject', | ||
63 | authenticate, | ||
64 | ensureUserHasRight(UserRight.MANAGE_REGISTRATIONS), | ||
65 | asyncMiddleware(acceptOrRejectRegistrationValidator), | ||
66 | asyncRetryTransactionMiddleware(rejectRegistration) | ||
67 | ) | ||
68 | |||
69 | registrationsRouter.delete('/registrations/:registrationId', | ||
70 | authenticate, | ||
71 | ensureUserHasRight(UserRight.MANAGE_REGISTRATIONS), | ||
72 | asyncMiddleware(getRegistrationValidator), | ||
73 | asyncRetryTransactionMiddleware(deleteRegistration) | ||
74 | ) | ||
75 | |||
76 | registrationsRouter.get('/registrations', | ||
77 | authenticate, | ||
78 | ensureUserHasRight(UserRight.MANAGE_REGISTRATIONS), | ||
79 | paginationValidator, | ||
80 | userRegistrationsSortValidator, | ||
81 | setDefaultSort, | ||
82 | setDefaultPagination, | ||
83 | listRegistrationsValidator, | ||
84 | asyncMiddleware(listRegistrations) | ||
85 | ) | ||
86 | |||
87 | registrationsRouter.post('/register', | ||
88 | registrationRateLimiter, | ||
89 | asyncMiddleware(ensureUserRegistrationAllowedFactory('direct-registration')), | ||
90 | ensureUserRegistrationAllowedForIP, | ||
91 | asyncMiddleware(usersDirectRegistrationValidator), | ||
92 | asyncRetryTransactionMiddleware(registerUser) | ||
93 | ) | ||
94 | |||
95 | // --------------------------------------------------------------------------- | ||
96 | |||
97 | export { | ||
98 | registrationsRouter | ||
99 | } | ||
100 | |||
101 | // --------------------------------------------------------------------------- | ||
102 | |||
103 | async 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 | |||
133 | async 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 | |||
174 | async 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 | |||
194 | async 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 | |||
206 | async 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 | |||
222 | async 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 @@ | |||
1 | import express from 'express' | ||
2 | import { logger } from '@server/helpers/logger' | ||
3 | import { CONFIG } from '@server/initializers/config' | ||
4 | import { OTP } from '@server/initializers/constants' | ||
5 | import { getAuthNameFromRefreshGrant, getBypassFromExternalAuth, getBypassFromPasswordGrant } from '@server/lib/auth/external-auth' | ||
6 | import { handleOAuthToken, MissingTwoFactorError } from '@server/lib/auth/oauth' | ||
7 | import { BypassLogin, revokeToken } from '@server/lib/auth/oauth-model' | ||
8 | import { Hooks } from '@server/lib/plugins/hooks' | ||
9 | import { asyncMiddleware, authenticate, buildRateLimiter, openapiOperationDoc } from '@server/middlewares' | ||
10 | import { buildUUID } from '@shared/extra-utils' | ||
11 | import { ScopedToken } from '@shared/models/users/user-scoped-token' | ||
12 | |||
13 | const tokensRouter = express.Router() | ||
14 | |||
15 | const loginRateLimiter = buildRateLimiter({ | ||
16 | windowMs: CONFIG.RATES_LIMIT.LOGIN.WINDOW_MS, | ||
17 | max: CONFIG.RATES_LIMIT.LOGIN.MAX | ||
18 | }) | ||
19 | |||
20 | tokensRouter.post('/token', | ||
21 | loginRateLimiter, | ||
22 | openapiOperationDoc({ operationId: 'getOAuthToken' }), | ||
23 | asyncMiddleware(handleToken) | ||
24 | ) | ||
25 | |||
26 | tokensRouter.post('/revoke-token', | ||
27 | openapiOperationDoc({ operationId: 'revokeOAuthToken' }), | ||
28 | authenticate, | ||
29 | asyncMiddleware(handleTokenRevocation) | ||
30 | ) | ||
31 | |||
32 | tokensRouter.get('/scoped-tokens', | ||
33 | authenticate, | ||
34 | getScopedTokens | ||
35 | ) | ||
36 | |||
37 | tokensRouter.post('/scoped-tokens', | ||
38 | authenticate, | ||
39 | asyncMiddleware(renewScopedTokens) | ||
40 | ) | ||
41 | |||
42 | // --------------------------------------------------------------------------- | ||
43 | |||
44 | export { | ||
45 | tokensRouter | ||
46 | } | ||
47 | // --------------------------------------------------------------------------- | ||
48 | |||
49 | async 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 | |||
95 | async 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 | |||
103 | function 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 | |||
111 | async 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 | |||
122 | async 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 @@ | |||
1 | import express from 'express' | ||
2 | import { generateOTPSecret, isOTPValid } from '@server/helpers/otp' | ||
3 | import { encrypt } from '@server/helpers/peertube-crypto' | ||
4 | import { CONFIG } from '@server/initializers/config' | ||
5 | import { Redis } from '@server/lib/redis' | ||
6 | import { asyncMiddleware, authenticate, usersCheckCurrentPasswordFactory } from '@server/middlewares' | ||
7 | import { | ||
8 | confirmTwoFactorValidator, | ||
9 | disableTwoFactorValidator, | ||
10 | requestOrConfirmTwoFactorValidator | ||
11 | } from '@server/middlewares/validators/two-factor' | ||
12 | import { HttpStatusCode, TwoFactorEnableResult } from '@shared/models' | ||
13 | |||
14 | const twoFactorRouter = express.Router() | ||
15 | |||
16 | twoFactorRouter.post('/:id/two-factor/request', | ||
17 | authenticate, | ||
18 | asyncMiddleware(usersCheckCurrentPasswordFactory(req => req.params.id)), | ||
19 | asyncMiddleware(requestOrConfirmTwoFactorValidator), | ||
20 | asyncMiddleware(requestTwoFactor) | ||
21 | ) | ||
22 | |||
23 | twoFactorRouter.post('/:id/two-factor/confirm-request', | ||
24 | authenticate, | ||
25 | asyncMiddleware(requestOrConfirmTwoFactorValidator), | ||
26 | confirmTwoFactorValidator, | ||
27 | asyncMiddleware(confirmRequestTwoFactor) | ||
28 | ) | ||
29 | |||
30 | twoFactorRouter.post('/:id/two-factor/disable', | ||
31 | authenticate, | ||
32 | asyncMiddleware(usersCheckCurrentPasswordFactory(req => req.params.id)), | ||
33 | asyncMiddleware(disableTwoFactorValidator), | ||
34 | asyncMiddleware(disableTwoFactor) | ||
35 | ) | ||
36 | |||
37 | // --------------------------------------------------------------------------- | ||
38 | |||
39 | export { | ||
40 | twoFactorRouter | ||
41 | } | ||
42 | |||
43 | // --------------------------------------------------------------------------- | ||
44 | |||
45 | async 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 | |||
62 | async 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 | |||
88 | async 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 @@ | |||
1 | import express from 'express' | ||
2 | import { auditLoggerFactory, getAuditIdFromRes, VideoChannelSyncAuditView } from '@server/helpers/audit-logger' | ||
3 | import { logger } from '@server/helpers/logger' | ||
4 | import { | ||
5 | apiRateLimiter, | ||
6 | asyncMiddleware, | ||
7 | asyncRetryTransactionMiddleware, | ||
8 | authenticate, | ||
9 | ensureCanManageChannelOrAccount, | ||
10 | ensureSyncExists, | ||
11 | ensureSyncIsEnabled, | ||
12 | videoChannelSyncValidator | ||
13 | } from '@server/middlewares' | ||
14 | import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync' | ||
15 | import { MChannelSyncFormattable } from '@server/types/models' | ||
16 | import { HttpStatusCode, VideoChannelSyncState } from '@shared/models' | ||
17 | |||
18 | const videoChannelSyncRouter = express.Router() | ||
19 | const auditLogger = auditLoggerFactory('channel-syncs') | ||
20 | |||
21 | videoChannelSyncRouter.use(apiRateLimiter) | ||
22 | |||
23 | videoChannelSyncRouter.post('/', | ||
24 | authenticate, | ||
25 | ensureSyncIsEnabled, | ||
26 | asyncMiddleware(videoChannelSyncValidator), | ||
27 | ensureCanManageChannelOrAccount, | ||
28 | asyncRetryTransactionMiddleware(createVideoChannelSync) | ||
29 | ) | ||
30 | |||
31 | videoChannelSyncRouter.delete('/:id', | ||
32 | authenticate, | ||
33 | asyncMiddleware(ensureSyncExists), | ||
34 | ensureCanManageChannelOrAccount, | ||
35 | asyncRetryTransactionMiddleware(removeVideoChannelSync) | ||
36 | ) | ||
37 | |||
38 | export { videoChannelSyncRouter } | ||
39 | |||
40 | // --------------------------------------------------------------------------- | ||
41 | |||
42 | async 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 | |||
65 | async 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 @@ | |||
1 | import express from 'express' | ||
2 | import { pickCommonVideoQuery } from '@server/helpers/query' | ||
3 | import { Hooks } from '@server/lib/plugins/hooks' | ||
4 | import { ActorFollowModel } from '@server/models/actor/actor-follow' | ||
5 | import { getServerActor } from '@server/models/application/application' | ||
6 | import { MChannelBannerAccountDefault } from '@server/types/models' | ||
7 | import { ActorImageType, HttpStatusCode, VideoChannelCreate, VideoChannelUpdate, VideosImportInChannelCreate } from '@shared/models' | ||
8 | import { auditLoggerFactory, getAuditIdFromRes, VideoChannelAuditView } from '../../helpers/audit-logger' | ||
9 | import { resetSequelizeInstance } from '../../helpers/database-utils' | ||
10 | import { buildNSFWFilter, createReqFiles, getCountVideos, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils' | ||
11 | import { logger } from '../../helpers/logger' | ||
12 | import { getFormattedObjects } from '../../helpers/utils' | ||
13 | import { MIMETYPES } from '../../initializers/constants' | ||
14 | import { sequelizeTypescript } from '../../initializers/database' | ||
15 | import { sendUpdateActor } from '../../lib/activitypub/send' | ||
16 | import { JobQueue } from '../../lib/job-queue' | ||
17 | import { deleteLocalActorImageFile, updateLocalActorImageFiles } from '../../lib/local-actor' | ||
18 | import { createLocalVideoChannel, federateAllVideosOfChannel } from '../../lib/video-channel' | ||
19 | import { | ||
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' | ||
37 | import { | ||
38 | ensureChannelOwnerCanUpload, | ||
39 | ensureIsLocalChannel, | ||
40 | videoChannelImportVideosValidator, | ||
41 | videoChannelsFollowersSortValidator, | ||
42 | videoChannelsListValidator, | ||
43 | videoChannelsNameWithHostValidator, | ||
44 | videosSortValidator | ||
45 | } from '../../middlewares/validators' | ||
46 | import { updateAvatarValidator, updateBannerValidator } from '../../middlewares/validators/actor-image' | ||
47 | import { commonVideoPlaylistFiltersValidator } from '../../middlewares/validators/videos/video-playlists' | ||
48 | import { AccountModel } from '../../models/account/account' | ||
49 | import { guessAdditionalAttributesFromQuery } from '../../models/video/formatter' | ||
50 | import { VideoModel } from '../../models/video/video' | ||
51 | import { VideoChannelModel } from '../../models/video/video-channel' | ||
52 | import { VideoPlaylistModel } from '../../models/video/video-playlist' | ||
53 | |||
54 | const auditLogger = auditLoggerFactory('channels') | ||
55 | const reqAvatarFile = createReqFiles([ 'avatarfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT) | ||
56 | const reqBannerFile = createReqFiles([ 'bannerfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT) | ||
57 | |||
58 | const videoChannelRouter = express.Router() | ||
59 | |||
60 | videoChannelRouter.use(apiRateLimiter) | ||
61 | |||
62 | videoChannelRouter.get('/', | ||
63 | paginationValidator, | ||
64 | videoChannelsSortValidator, | ||
65 | setDefaultSort, | ||
66 | setDefaultPagination, | ||
67 | videoChannelsListValidator, | ||
68 | asyncMiddleware(listVideoChannels) | ||
69 | ) | ||
70 | |||
71 | videoChannelRouter.post('/', | ||
72 | authenticate, | ||
73 | asyncMiddleware(videoChannelsAddValidator), | ||
74 | asyncRetryTransactionMiddleware(addVideoChannel) | ||
75 | ) | ||
76 | |||
77 | videoChannelRouter.post('/:nameWithHost/avatar/pick', | ||
78 | authenticate, | ||
79 | reqAvatarFile, | ||
80 | asyncMiddleware(videoChannelsNameWithHostValidator), | ||
81 | ensureIsLocalChannel, | ||
82 | ensureCanManageChannelOrAccount, | ||
83 | updateAvatarValidator, | ||
84 | asyncMiddleware(updateVideoChannelAvatar) | ||
85 | ) | ||
86 | |||
87 | videoChannelRouter.post('/:nameWithHost/banner/pick', | ||
88 | authenticate, | ||
89 | reqBannerFile, | ||
90 | asyncMiddleware(videoChannelsNameWithHostValidator), | ||
91 | ensureIsLocalChannel, | ||
92 | ensureCanManageChannelOrAccount, | ||
93 | updateBannerValidator, | ||
94 | asyncMiddleware(updateVideoChannelBanner) | ||
95 | ) | ||
96 | |||
97 | videoChannelRouter.delete('/:nameWithHost/avatar', | ||
98 | authenticate, | ||
99 | asyncMiddleware(videoChannelsNameWithHostValidator), | ||
100 | ensureIsLocalChannel, | ||
101 | ensureCanManageChannelOrAccount, | ||
102 | asyncMiddleware(deleteVideoChannelAvatar) | ||
103 | ) | ||
104 | |||
105 | videoChannelRouter.delete('/:nameWithHost/banner', | ||
106 | authenticate, | ||
107 | asyncMiddleware(videoChannelsNameWithHostValidator), | ||
108 | ensureIsLocalChannel, | ||
109 | ensureCanManageChannelOrAccount, | ||
110 | asyncMiddleware(deleteVideoChannelBanner) | ||
111 | ) | ||
112 | |||
113 | videoChannelRouter.put('/:nameWithHost', | ||
114 | authenticate, | ||
115 | asyncMiddleware(videoChannelsNameWithHostValidator), | ||
116 | ensureIsLocalChannel, | ||
117 | ensureCanManageChannelOrAccount, | ||
118 | videoChannelsUpdateValidator, | ||
119 | asyncRetryTransactionMiddleware(updateVideoChannel) | ||
120 | ) | ||
121 | |||
122 | videoChannelRouter.delete('/:nameWithHost', | ||
123 | authenticate, | ||
124 | asyncMiddleware(videoChannelsNameWithHostValidator), | ||
125 | ensureIsLocalChannel, | ||
126 | ensureCanManageChannelOrAccount, | ||
127 | asyncMiddleware(videoChannelsRemoveValidator), | ||
128 | asyncRetryTransactionMiddleware(removeVideoChannel) | ||
129 | ) | ||
130 | |||
131 | videoChannelRouter.get('/:nameWithHost', | ||
132 | asyncMiddleware(videoChannelsNameWithHostValidator), | ||
133 | asyncMiddleware(getVideoChannel) | ||
134 | ) | ||
135 | |||
136 | videoChannelRouter.get('/:nameWithHost/video-playlists', | ||
137 | asyncMiddleware(videoChannelsNameWithHostValidator), | ||
138 | paginationValidator, | ||
139 | videoPlaylistsSortValidator, | ||
140 | setDefaultSort, | ||
141 | setDefaultPagination, | ||
142 | commonVideoPlaylistFiltersValidator, | ||
143 | asyncMiddleware(listVideoChannelPlaylists) | ||
144 | ) | ||
145 | |||
146 | videoChannelRouter.get('/:nameWithHost/videos', | ||
147 | asyncMiddleware(videoChannelsNameWithHostValidator), | ||
148 | paginationValidator, | ||
149 | videosSortValidator, | ||
150 | setDefaultVideosSort, | ||
151 | setDefaultPagination, | ||
152 | optionalAuthenticate, | ||
153 | commonVideosFiltersValidator, | ||
154 | asyncMiddleware(listVideoChannelVideos) | ||
155 | ) | ||
156 | |||
157 | videoChannelRouter.get('/:nameWithHost/followers', | ||
158 | authenticate, | ||
159 | asyncMiddleware(videoChannelsNameWithHostValidator), | ||
160 | ensureCanManageChannelOrAccount, | ||
161 | paginationValidator, | ||
162 | videoChannelsFollowersSortValidator, | ||
163 | setDefaultSort, | ||
164 | setDefaultPagination, | ||
165 | asyncMiddleware(listVideoChannelFollowers) | ||
166 | ) | ||
167 | |||
168 | videoChannelRouter.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 | |||
180 | export { | ||
181 | videoChannelRouter | ||
182 | } | ||
183 | |||
184 | // --------------------------------------------------------------------------- | ||
185 | |||
186 | async 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 | |||
205 | async 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 | |||
219 | async 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 | |||
232 | async 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 | |||
240 | async 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 | |||
248 | async 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 | |||
272 | async 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 | |||
324 | async 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 | |||
341 | async 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 | |||
352 | async 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 | |||
367 | async 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 | |||
401 | async 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 | |||
416 | async 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 @@ | |||
1 | import express from 'express' | ||
2 | import { scheduleRefreshIfNeeded } from '@server/lib/activitypub/playlists' | ||
3 | import { VideoMiniaturePermanentFileCache } from '@server/lib/files-cache' | ||
4 | import { Hooks } from '@server/lib/plugins/hooks' | ||
5 | import { getServerActor } from '@server/models/application/application' | ||
6 | import { MVideoPlaylistFull, MVideoPlaylistThumbnail, MVideoThumbnail } from '@server/types/models' | ||
7 | import { forceNumber } from '@shared/core-utils' | ||
8 | import { uuidToShort } from '@shared/extra-utils' | ||
9 | import { VideoPlaylistCreateResult, VideoPlaylistElementCreateResult } from '@shared/models' | ||
10 | import { HttpStatusCode } from '../../../shared/models/http/http-error-codes' | ||
11 | import { VideoPlaylistCreate } from '../../../shared/models/videos/playlist/video-playlist-create.model' | ||
12 | import { VideoPlaylistElementCreate } from '../../../shared/models/videos/playlist/video-playlist-element-create.model' | ||
13 | import { VideoPlaylistElementUpdate } from '../../../shared/models/videos/playlist/video-playlist-element-update.model' | ||
14 | import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model' | ||
15 | import { VideoPlaylistReorder } from '../../../shared/models/videos/playlist/video-playlist-reorder.model' | ||
16 | import { VideoPlaylistUpdate } from '../../../shared/models/videos/playlist/video-playlist-update.model' | ||
17 | import { resetSequelizeInstance } from '../../helpers/database-utils' | ||
18 | import { createReqFiles } from '../../helpers/express-utils' | ||
19 | import { logger } from '../../helpers/logger' | ||
20 | import { getFormattedObjects } from '../../helpers/utils' | ||
21 | import { MIMETYPES, VIDEO_PLAYLIST_PRIVACIES } from '../../initializers/constants' | ||
22 | import { sequelizeTypescript } from '../../initializers/database' | ||
23 | import { sendCreateVideoPlaylist, sendDeleteVideoPlaylist, sendUpdateVideoPlaylist } from '../../lib/activitypub/send' | ||
24 | import { getLocalVideoPlaylistActivityPubUrl, getLocalVideoPlaylistElementActivityPubUrl } from '../../lib/activitypub/url' | ||
25 | import { updateLocalPlaylistMiniatureFromExisting } from '../../lib/thumbnail' | ||
26 | import { | ||
27 | apiRateLimiter, | ||
28 | asyncMiddleware, | ||
29 | asyncRetryTransactionMiddleware, | ||
30 | authenticate, | ||
31 | optionalAuthenticate, | ||
32 | paginationValidator, | ||
33 | setDefaultPagination, | ||
34 | setDefaultSort | ||
35 | } from '../../middlewares' | ||
36 | import { videoPlaylistsSortValidator } from '../../middlewares/validators' | ||
37 | import { | ||
38 | commonVideoPlaylistFiltersValidator, | ||
39 | videoPlaylistsAddValidator, | ||
40 | videoPlaylistsAddVideoValidator, | ||
41 | videoPlaylistsDeleteValidator, | ||
42 | videoPlaylistsGetValidator, | ||
43 | videoPlaylistsReorderVideosValidator, | ||
44 | videoPlaylistsUpdateOrRemoveVideoValidator, | ||
45 | videoPlaylistsUpdateValidator | ||
46 | } from '../../middlewares/validators/videos/video-playlists' | ||
47 | import { AccountModel } from '../../models/account/account' | ||
48 | import { VideoPlaylistModel } from '../../models/video/video-playlist' | ||
49 | import { VideoPlaylistElementModel } from '../../models/video/video-playlist-element' | ||
50 | |||
51 | const reqThumbnailFile = createReqFiles([ 'thumbnailfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT) | ||
52 | |||
53 | const videoPlaylistRouter = express.Router() | ||
54 | |||
55 | videoPlaylistRouter.use(apiRateLimiter) | ||
56 | |||
57 | videoPlaylistRouter.get('/privacies', listVideoPlaylistPrivacies) | ||
58 | |||
59 | videoPlaylistRouter.get('/', | ||
60 | paginationValidator, | ||
61 | videoPlaylistsSortValidator, | ||
62 | setDefaultSort, | ||
63 | setDefaultPagination, | ||
64 | commonVideoPlaylistFiltersValidator, | ||
65 | asyncMiddleware(listVideoPlaylists) | ||
66 | ) | ||
67 | |||
68 | videoPlaylistRouter.get('/:playlistId', | ||
69 | asyncMiddleware(videoPlaylistsGetValidator('summary')), | ||
70 | getVideoPlaylist | ||
71 | ) | ||
72 | |||
73 | videoPlaylistRouter.post('/', | ||
74 | authenticate, | ||
75 | reqThumbnailFile, | ||
76 | asyncMiddleware(videoPlaylistsAddValidator), | ||
77 | asyncRetryTransactionMiddleware(addVideoPlaylist) | ||
78 | ) | ||
79 | |||
80 | videoPlaylistRouter.put('/:playlistId', | ||
81 | authenticate, | ||
82 | reqThumbnailFile, | ||
83 | asyncMiddleware(videoPlaylistsUpdateValidator), | ||
84 | asyncRetryTransactionMiddleware(updateVideoPlaylist) | ||
85 | ) | ||
86 | |||
87 | videoPlaylistRouter.delete('/:playlistId', | ||
88 | authenticate, | ||
89 | asyncMiddleware(videoPlaylistsDeleteValidator), | ||
90 | asyncRetryTransactionMiddleware(removeVideoPlaylist) | ||
91 | ) | ||
92 | |||
93 | videoPlaylistRouter.get('/:playlistId/videos', | ||
94 | asyncMiddleware(videoPlaylistsGetValidator('summary')), | ||
95 | paginationValidator, | ||
96 | setDefaultPagination, | ||
97 | optionalAuthenticate, | ||
98 | asyncMiddleware(getVideoPlaylistVideos) | ||
99 | ) | ||
100 | |||
101 | videoPlaylistRouter.post('/:playlistId/videos', | ||
102 | authenticate, | ||
103 | asyncMiddleware(videoPlaylistsAddVideoValidator), | ||
104 | asyncRetryTransactionMiddleware(addVideoInPlaylist) | ||
105 | ) | ||
106 | |||
107 | videoPlaylistRouter.post('/:playlistId/videos/reorder', | ||
108 | authenticate, | ||
109 | asyncMiddleware(videoPlaylistsReorderVideosValidator), | ||
110 | asyncRetryTransactionMiddleware(reorderVideosPlaylist) | ||
111 | ) | ||
112 | |||
113 | videoPlaylistRouter.put('/:playlistId/videos/:playlistElementId', | ||
114 | authenticate, | ||
115 | asyncMiddleware(videoPlaylistsUpdateOrRemoveVideoValidator), | ||
116 | asyncRetryTransactionMiddleware(updateVideoPlaylistElement) | ||
117 | ) | ||
118 | |||
119 | videoPlaylistRouter.delete('/:playlistId/videos/:playlistElementId', | ||
120 | authenticate, | ||
121 | asyncMiddleware(videoPlaylistsUpdateOrRemoveVideoValidator), | ||
122 | asyncRetryTransactionMiddleware(removeVideoFromPlaylist) | ||
123 | ) | ||
124 | |||
125 | // --------------------------------------------------------------------------- | ||
126 | |||
127 | export { | ||
128 | videoPlaylistRouter | ||
129 | } | ||
130 | |||
131 | // --------------------------------------------------------------------------- | ||
132 | |||
133 | function listVideoPlaylistPrivacies (req: express.Request, res: express.Response) { | ||
134 | res.json(VIDEO_PLAYLIST_PRIVACIES) | ||
135 | } | ||
136 | |||
137 | async 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 | |||
150 | function 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 | |||
158 | async 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 | |||
213 | async 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 | |||
289 | async 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 | |||
303 | async 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 | |||
347 | async 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 | |||
371 | async 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 | |||
399 | async 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 | |||
458 | async 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 | |||
481 | async 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 | |||
489 | async 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 @@ | |||
1 | import express from 'express' | ||
2 | import { blacklistVideo, unblacklistVideo } from '@server/lib/video-blacklist' | ||
3 | import { HttpStatusCode, UserRight, VideoBlacklistCreate } from '@shared/models' | ||
4 | import { logger } from '../../../helpers/logger' | ||
5 | import { getFormattedObjects } from '../../../helpers/utils' | ||
6 | import { sequelizeTypescript } from '../../../initializers/database' | ||
7 | import { | ||
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' | ||
21 | import { VideoBlacklistModel } from '../../../models/video/video-blacklist' | ||
22 | |||
23 | const blacklistRouter = express.Router() | ||
24 | |||
25 | blacklistRouter.post('/:videoId/blacklist', | ||
26 | openapiOperationDoc({ operationId: 'addVideoBlock' }), | ||
27 | authenticate, | ||
28 | ensureUserHasRight(UserRight.MANAGE_VIDEO_BLACKLIST), | ||
29 | asyncMiddleware(videosBlacklistAddValidator), | ||
30 | asyncMiddleware(addVideoToBlacklistController) | ||
31 | ) | ||
32 | |||
33 | blacklistRouter.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 | |||
45 | blacklistRouter.put('/:videoId/blacklist', | ||
46 | authenticate, | ||
47 | ensureUserHasRight(UserRight.MANAGE_VIDEO_BLACKLIST), | ||
48 | asyncMiddleware(videosBlacklistUpdateValidator), | ||
49 | asyncMiddleware(updateVideoBlacklistController) | ||
50 | ) | ||
51 | |||
52 | blacklistRouter.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 | |||
62 | export { | ||
63 | blacklistRouter | ||
64 | } | ||
65 | |||
66 | // --------------------------------------------------------------------------- | ||
67 | |||
68 | async 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 | |||
79 | async 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 | |||
91 | async 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 | |||
103 | async 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 @@ | |||
1 | import express from 'express' | ||
2 | import { Hooks } from '@server/lib/plugins/hooks' | ||
3 | import { MVideoCaption } from '@server/types/models' | ||
4 | import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes' | ||
5 | import { moveAndProcessCaptionFile } from '../../../helpers/captions-utils' | ||
6 | import { createReqFiles } from '../../../helpers/express-utils' | ||
7 | import { logger } from '../../../helpers/logger' | ||
8 | import { getFormattedObjects } from '../../../helpers/utils' | ||
9 | import { MIMETYPES } from '../../../initializers/constants' | ||
10 | import { sequelizeTypescript } from '../../../initializers/database' | ||
11 | import { federateVideoIfNeeded } from '../../../lib/activitypub/videos' | ||
12 | import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate } from '../../../middlewares' | ||
13 | import { addVideoCaptionValidator, deleteVideoCaptionValidator, listVideoCaptionsValidator } from '../../../middlewares/validators' | ||
14 | import { VideoCaptionModel } from '../../../models/video/video-caption' | ||
15 | |||
16 | const reqVideoCaptionAdd = createReqFiles([ 'captionfile' ], MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT) | ||
17 | |||
18 | const videoCaptionsRouter = express.Router() | ||
19 | |||
20 | videoCaptionsRouter.get('/:videoId/captions', | ||
21 | asyncMiddleware(listVideoCaptionsValidator), | ||
22 | asyncMiddleware(listVideoCaptions) | ||
23 | ) | ||
24 | videoCaptionsRouter.put('/:videoId/captions/:captionLanguage', | ||
25 | authenticate, | ||
26 | reqVideoCaptionAdd, | ||
27 | asyncMiddleware(addVideoCaptionValidator), | ||
28 | asyncRetryTransactionMiddleware(addVideoCaption) | ||
29 | ) | ||
30 | videoCaptionsRouter.delete('/:videoId/captions/:captionLanguage', | ||
31 | authenticate, | ||
32 | asyncMiddleware(deleteVideoCaptionValidator), | ||
33 | asyncRetryTransactionMiddleware(deleteVideoCaption) | ||
34 | ) | ||
35 | |||
36 | // --------------------------------------------------------------------------- | ||
37 | |||
38 | export { | ||
39 | videoCaptionsRouter | ||
40 | } | ||
41 | |||
42 | // --------------------------------------------------------------------------- | ||
43 | |||
44 | async 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 | |||
50 | async 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 | |||
77 | async 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 @@ | |||
1 | import { MCommentFormattable } from '@server/types/models' | ||
2 | import express from 'express' | ||
3 | |||
4 | import { ResultList, ThreadsResultList, UserRight, VideoCommentCreate } from '../../../../shared/models' | ||
5 | import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes' | ||
6 | import { VideoCommentThreads } from '../../../../shared/models/videos/comment/video-comment.model' | ||
7 | import { auditLoggerFactory, CommentAuditView, getAuditIdFromRes } from '../../../helpers/audit-logger' | ||
8 | import { getFormattedObjects } from '../../../helpers/utils' | ||
9 | import { sequelizeTypescript } from '../../../initializers/database' | ||
10 | import { Notifier } from '../../../lib/notifier' | ||
11 | import { Hooks } from '../../../lib/plugins/hooks' | ||
12 | import { buildFormattedCommentTree, createVideoComment, removeComment } from '../../../lib/video-comment' | ||
13 | import { | ||
14 | asyncMiddleware, | ||
15 | asyncRetryTransactionMiddleware, | ||
16 | authenticate, | ||
17 | ensureUserHasRight, | ||
18 | optionalAuthenticate, | ||
19 | paginationValidator, | ||
20 | setDefaultPagination, | ||
21 | setDefaultSort | ||
22 | } from '../../../middlewares' | ||
23 | import { | ||
24 | addVideoCommentReplyValidator, | ||
25 | addVideoCommentThreadValidator, | ||
26 | listVideoCommentsValidator, | ||
27 | listVideoCommentThreadsValidator, | ||
28 | listVideoThreadCommentsValidator, | ||
29 | removeVideoCommentValidator, | ||
30 | videoCommentsValidator, | ||
31 | videoCommentThreadsSortValidator | ||
32 | } from '../../../middlewares/validators' | ||
33 | import { AccountModel } from '../../../models/account/account' | ||
34 | import { VideoCommentModel } from '../../../models/video/video-comment' | ||
35 | |||
36 | const auditLogger = auditLoggerFactory('comments') | ||
37 | const videoCommentRouter = express.Router() | ||
38 | |||
39 | videoCommentRouter.get('/:videoId/comment-threads', | ||
40 | paginationValidator, | ||
41 | videoCommentThreadsSortValidator, | ||
42 | setDefaultSort, | ||
43 | setDefaultPagination, | ||
44 | asyncMiddleware(listVideoCommentThreadsValidator), | ||
45 | optionalAuthenticate, | ||
46 | asyncMiddleware(listVideoThreads) | ||
47 | ) | ||
48 | videoCommentRouter.get('/:videoId/comment-threads/:threadId', | ||
49 | asyncMiddleware(listVideoThreadCommentsValidator), | ||
50 | optionalAuthenticate, | ||
51 | asyncMiddleware(listVideoThreadComments) | ||
52 | ) | ||
53 | |||
54 | videoCommentRouter.post('/:videoId/comment-threads', | ||
55 | authenticate, | ||
56 | asyncMiddleware(addVideoCommentThreadValidator), | ||
57 | asyncRetryTransactionMiddleware(addVideoCommentThread) | ||
58 | ) | ||
59 | videoCommentRouter.post('/:videoId/comments/:commentId', | ||
60 | authenticate, | ||
61 | asyncMiddleware(addVideoCommentReplyValidator), | ||
62 | asyncRetryTransactionMiddleware(addVideoCommentReply) | ||
63 | ) | ||
64 | videoCommentRouter.delete('/:videoId/comments/:commentId', | ||
65 | authenticate, | ||
66 | asyncMiddleware(removeVideoCommentValidator), | ||
67 | asyncRetryTransactionMiddleware(removeVideoComment) | ||
68 | ) | ||
69 | |||
70 | videoCommentRouter.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 | |||
83 | export { | ||
84 | videoCommentRouter | ||
85 | } | ||
86 | |||
87 | // --------------------------------------------------------------------------- | ||
88 | |||
89 | async 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 | |||
110 | async 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 | |||
145 | async 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 | |||
180 | async 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 | |||
202 | async 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 | |||
224 | async 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 @@ | |||
1 | import express from 'express' | ||
2 | import toInt from 'validator/lib/toInt' | ||
3 | import { logger, loggerTagsFactory } from '@server/helpers/logger' | ||
4 | import { federateVideoIfNeeded } from '@server/lib/activitypub/videos' | ||
5 | import { updatePlaylistAfterFileChange } from '@server/lib/hls' | ||
6 | import { removeAllWebVideoFiles, removeHLSFile, removeHLSPlaylist, removeWebVideoFile } from '@server/lib/video-file' | ||
7 | import { VideoFileModel } from '@server/models/video/video-file' | ||
8 | import { HttpStatusCode, UserRight } from '@shared/models' | ||
9 | import { | ||
10 | asyncMiddleware, | ||
11 | authenticate, | ||
12 | ensureUserHasRight, | ||
13 | videoFileMetadataGetValidator, | ||
14 | videoFilesDeleteHLSFileValidator, | ||
15 | videoFilesDeleteHLSValidator, | ||
16 | videoFilesDeleteWebVideoFileValidator, | ||
17 | videoFilesDeleteWebVideoValidator, | ||
18 | videosGetValidator | ||
19 | } from '../../../middlewares' | ||
20 | |||
21 | const lTags = loggerTagsFactory('api', 'video') | ||
22 | const filesRouter = express.Router() | ||
23 | |||
24 | filesRouter.get('/:id/metadata/:videoFileId', | ||
25 | asyncMiddleware(videosGetValidator), | ||
26 | asyncMiddleware(videoFileMetadataGetValidator), | ||
27 | asyncMiddleware(getVideoFileMetadata) | ||
28 | ) | ||
29 | |||
30 | filesRouter.delete('/:id/hls', | ||
31 | authenticate, | ||
32 | ensureUserHasRight(UserRight.MANAGE_VIDEO_FILES), | ||
33 | asyncMiddleware(videoFilesDeleteHLSValidator), | ||
34 | asyncMiddleware(removeHLSPlaylistController) | ||
35 | ) | ||
36 | filesRouter.delete('/:id/hls/:videoFileId', | ||
37 | authenticate, | ||
38 | ensureUserHasRight(UserRight.MANAGE_VIDEO_FILES), | ||
39 | asyncMiddleware(videoFilesDeleteHLSFileValidator), | ||
40 | asyncMiddleware(removeHLSFileController) | ||
41 | ) | ||
42 | |||
43 | filesRouter.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 | ) | ||
50 | filesRouter.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 | |||
60 | export { | ||
61 | filesRouter | ||
62 | } | ||
63 | |||
64 | // --------------------------------------------------------------------------- | ||
65 | |||
66 | async 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 | |||
74 | async 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 | |||
85 | async 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 | |||
101 | async 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 | |||
112 | async 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 @@ | |||
1 | import express from 'express' | ||
2 | import { move, readFile } from 'fs-extra' | ||
3 | import { decode } from 'magnet-uri' | ||
4 | import parseTorrent, { Instance } from 'parse-torrent' | ||
5 | import { join } from 'path' | ||
6 | import { buildYoutubeDLImport, buildVideoFromImport, insertFromImportIntoDB, YoutubeDlImportError } from '@server/lib/video-pre-import' | ||
7 | import { MThumbnail, MVideoThumbnail } from '@server/types/models' | ||
8 | import { HttpStatusCode, ServerErrorCode, ThumbnailType, VideoImportCreate, VideoImportPayload, VideoImportState } from '@shared/models' | ||
9 | import { auditLoggerFactory, getAuditIdFromRes, VideoImportAuditView } from '../../../helpers/audit-logger' | ||
10 | import { isArray } from '../../../helpers/custom-validators/misc' | ||
11 | import { cleanUpReqFiles, createReqFiles } from '../../../helpers/express-utils' | ||
12 | import { logger } from '../../../helpers/logger' | ||
13 | import { getSecureTorrentName } from '../../../helpers/utils' | ||
14 | import { CONFIG } from '../../../initializers/config' | ||
15 | import { MIMETYPES } from '../../../initializers/constants' | ||
16 | import { JobQueue } from '../../../lib/job-queue/job-queue' | ||
17 | import { updateLocalVideoMiniatureFromExisting } from '../../../lib/thumbnail' | ||
18 | import { | ||
19 | asyncMiddleware, | ||
20 | asyncRetryTransactionMiddleware, | ||
21 | authenticate, | ||
22 | videoImportAddValidator, | ||
23 | videoImportCancelValidator, | ||
24 | videoImportDeleteValidator | ||
25 | } from '../../../middlewares' | ||
26 | |||
27 | const auditLogger = auditLoggerFactory('video-imports') | ||
28 | const videoImportsRouter = express.Router() | ||
29 | |||
30 | const reqVideoFileImport = createReqFiles( | ||
31 | [ 'thumbnailfile', 'previewfile', 'torrentfile' ], | ||
32 | { ...MIMETYPES.TORRENT.MIMETYPE_EXT, ...MIMETYPES.IMAGE.MIMETYPE_EXT } | ||
33 | ) | ||
34 | |||
35 | videoImportsRouter.post('/imports', | ||
36 | authenticate, | ||
37 | reqVideoFileImport, | ||
38 | asyncMiddleware(videoImportAddValidator), | ||
39 | asyncRetryTransactionMiddleware(handleVideoImport) | ||
40 | ) | ||
41 | |||
42 | videoImportsRouter.post('/imports/:id/cancel', | ||
43 | authenticate, | ||
44 | asyncMiddleware(videoImportCancelValidator), | ||
45 | asyncRetryTransactionMiddleware(cancelVideoImport) | ||
46 | ) | ||
47 | |||
48 | videoImportsRouter.delete('/imports/:id', | ||
49 | authenticate, | ||
50 | asyncMiddleware(videoImportDeleteValidator), | ||
51 | asyncRetryTransactionMiddleware(deleteVideoImport) | ||
52 | ) | ||
53 | |||
54 | // --------------------------------------------------------------------------- | ||
55 | |||
56 | export { | ||
57 | videoImportsRouter | ||
58 | } | ||
59 | |||
60 | // --------------------------------------------------------------------------- | ||
61 | |||
62 | async 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 | |||
70 | async 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 | |||
79 | function 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 | |||
86 | async 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 | |||
146 | function 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 | |||
159 | async 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 | |||
191 | async 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 | |||
207 | async 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 | |||
223 | async 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 | |||
250 | function 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 | |||
260 | function 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 @@ | |||
1 | import express from 'express' | ||
2 | import { pickCommonVideoQuery } from '@server/helpers/query' | ||
3 | import { doJSONRequest } from '@server/helpers/requests' | ||
4 | import { openapiOperationDoc } from '@server/middlewares/doc' | ||
5 | import { getServerActor } from '@server/models/application/application' | ||
6 | import { MVideoAccountLight } from '@server/types/models' | ||
7 | import { HttpStatusCode } from '../../../../shared/models' | ||
8 | import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' | ||
9 | import { buildNSFWFilter, getCountVideos } from '../../../helpers/express-utils' | ||
10 | import { logger } from '../../../helpers/logger' | ||
11 | import { getFormattedObjects } from '../../../helpers/utils' | ||
12 | import { REMOTE_SCHEME, VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES, VIDEO_PRIVACIES } from '../../../initializers/constants' | ||
13 | import { sequelizeTypescript } from '../../../initializers/database' | ||
14 | import { JobQueue } from '../../../lib/job-queue' | ||
15 | import { Hooks } from '../../../lib/plugins/hooks' | ||
16 | import { | ||
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' | ||
32 | import { guessAdditionalAttributesFromQuery } from '../../../models/video/formatter' | ||
33 | import { VideoModel } from '../../../models/video/video' | ||
34 | import { blacklistRouter } from './blacklist' | ||
35 | import { videoCaptionsRouter } from './captions' | ||
36 | import { videoCommentRouter } from './comment' | ||
37 | import { filesRouter } from './files' | ||
38 | import { videoImportsRouter } from './import' | ||
39 | import { liveRouter } from './live' | ||
40 | import { ownershipVideoRouter } from './ownership' | ||
41 | import { videoPasswordRouter } from './passwords' | ||
42 | import { rateVideoRouter } from './rate' | ||
43 | import { videoSourceRouter } from './source' | ||
44 | import { statsRouter } from './stats' | ||
45 | import { storyboardRouter } from './storyboard' | ||
46 | import { studioRouter } from './studio' | ||
47 | import { tokenRouter } from './token' | ||
48 | import { transcodingRouter } from './transcoding' | ||
49 | import { updateRouter } from './update' | ||
50 | import { uploadRouter } from './upload' | ||
51 | import { viewRouter } from './view' | ||
52 | |||
53 | const auditLogger = auditLoggerFactory('videos') | ||
54 | const videosRouter = express.Router() | ||
55 | |||
56 | videosRouter.use(apiRateLimiter) | ||
57 | |||
58 | videosRouter.use('/', blacklistRouter) | ||
59 | videosRouter.use('/', statsRouter) | ||
60 | videosRouter.use('/', rateVideoRouter) | ||
61 | videosRouter.use('/', videoCommentRouter) | ||
62 | videosRouter.use('/', studioRouter) | ||
63 | videosRouter.use('/', videoCaptionsRouter) | ||
64 | videosRouter.use('/', videoImportsRouter) | ||
65 | videosRouter.use('/', ownershipVideoRouter) | ||
66 | videosRouter.use('/', viewRouter) | ||
67 | videosRouter.use('/', liveRouter) | ||
68 | videosRouter.use('/', uploadRouter) | ||
69 | videosRouter.use('/', updateRouter) | ||
70 | videosRouter.use('/', filesRouter) | ||
71 | videosRouter.use('/', transcodingRouter) | ||
72 | videosRouter.use('/', tokenRouter) | ||
73 | videosRouter.use('/', videoPasswordRouter) | ||
74 | videosRouter.use('/', storyboardRouter) | ||
75 | videosRouter.use('/', videoSourceRouter) | ||
76 | |||
77 | videosRouter.get('/categories', | ||
78 | openapiOperationDoc({ operationId: 'getCategories' }), | ||
79 | listVideoCategories | ||
80 | ) | ||
81 | videosRouter.get('/licences', | ||
82 | openapiOperationDoc({ operationId: 'getLicences' }), | ||
83 | listVideoLicences | ||
84 | ) | ||
85 | videosRouter.get('/languages', | ||
86 | openapiOperationDoc({ operationId: 'getLanguages' }), | ||
87 | listVideoLanguages | ||
88 | ) | ||
89 | videosRouter.get('/privacies', | ||
90 | openapiOperationDoc({ operationId: 'getPrivacies' }), | ||
91 | listVideoPrivacies | ||
92 | ) | ||
93 | |||
94 | videosRouter.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 | ||
106 | videosRouter.get('/:id/description', | ||
107 | openapiOperationDoc({ operationId: 'getVideoDesc' }), | ||
108 | asyncMiddleware(videosGetValidator), | ||
109 | asyncMiddleware(getVideoDescription) | ||
110 | ) | ||
111 | |||
112 | videosRouter.get('/:id', | ||
113 | openapiOperationDoc({ operationId: 'getVideo' }), | ||
114 | optionalAuthenticate, | ||
115 | asyncMiddleware(videosCustomGetValidator('for-api')), | ||
116 | asyncMiddleware(checkVideoFollowConstraints), | ||
117 | asyncMiddleware(getVideo) | ||
118 | ) | ||
119 | |||
120 | videosRouter.delete('/:id', | ||
121 | openapiOperationDoc({ operationId: 'delVideo' }), | ||
122 | authenticate, | ||
123 | asyncMiddleware(videosRemoveValidator), | ||
124 | asyncRetryTransactionMiddleware(removeVideo) | ||
125 | ) | ||
126 | |||
127 | // --------------------------------------------------------------------------- | ||
128 | |||
129 | export { | ||
130 | videosRouter | ||
131 | } | ||
132 | |||
133 | // --------------------------------------------------------------------------- | ||
134 | |||
135 | function listVideoCategories (_req: express.Request, res: express.Response) { | ||
136 | res.json(VIDEO_CATEGORIES) | ||
137 | } | ||
138 | |||
139 | function listVideoLicences (_req: express.Request, res: express.Response) { | ||
140 | res.json(VIDEO_LICENCES) | ||
141 | } | ||
142 | |||
143 | function listVideoLanguages (_req: express.Request, res: express.Response) { | ||
144 | res.json(VIDEO_LANGUAGES) | ||
145 | } | ||
146 | |||
147 | function listVideoPrivacies (_req: express.Request, res: express.Response) { | ||
148 | res.json(VIDEO_PRIVACIES) | ||
149 | } | ||
150 | |||
151 | async 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 | |||
164 | async 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 | |||
174 | async 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 | |||
201 | async 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 | ||
221 | async 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 @@ | |||
1 | import express from 'express' | ||
2 | import { exists } from '@server/helpers/custom-validators/misc' | ||
3 | import { createReqFiles } from '@server/helpers/express-utils' | ||
4 | import { getFormattedObjects } from '@server/helpers/utils' | ||
5 | import { ASSETS_PATH, MIMETYPES } from '@server/initializers/constants' | ||
6 | import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url' | ||
7 | import { federateVideoIfNeeded } from '@server/lib/activitypub/videos' | ||
8 | import { Hooks } from '@server/lib/plugins/hooks' | ||
9 | import { buildLocalVideoFromReq, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video' | ||
10 | import { | ||
11 | videoLiveAddValidator, | ||
12 | videoLiveFindReplaySessionValidator, | ||
13 | videoLiveGetValidator, | ||
14 | videoLiveListSessionsValidator, | ||
15 | videoLiveUpdateValidator | ||
16 | } from '@server/middlewares/validators/videos/video-live' | ||
17 | import { VideoLiveModel } from '@server/models/video/video-live' | ||
18 | import { VideoLiveSessionModel } from '@server/models/video/video-live-session' | ||
19 | import { MVideoDetails, MVideoFullLight, MVideoLive } from '@server/types/models' | ||
20 | import { buildUUID, uuidToShort } from '@shared/extra-utils' | ||
21 | import { HttpStatusCode, LiveVideoCreate, LiveVideoLatencyMode, LiveVideoUpdate, UserRight, VideoPrivacy, VideoState } from '@shared/models' | ||
22 | import { logger } from '../../../helpers/logger' | ||
23 | import { sequelizeTypescript } from '../../../initializers/database' | ||
24 | import { updateLocalVideoMiniatureFromExisting } from '../../../lib/thumbnail' | ||
25 | import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, optionalAuthenticate } from '../../../middlewares' | ||
26 | import { VideoModel } from '../../../models/video/video' | ||
27 | import { VideoLiveReplaySettingModel } from '@server/models/video/video-live-replay-setting' | ||
28 | import { VideoPasswordModel } from '@server/models/video/video-password' | ||
29 | |||
30 | const liveRouter = express.Router() | ||
31 | |||
32 | const reqVideoFileLive = createReqFiles([ 'thumbnailfile', 'previewfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT) | ||
33 | |||
34 | liveRouter.post('/live', | ||
35 | authenticate, | ||
36 | reqVideoFileLive, | ||
37 | asyncMiddleware(videoLiveAddValidator), | ||
38 | asyncRetryTransactionMiddleware(addLiveVideo) | ||
39 | ) | ||
40 | |||
41 | liveRouter.get('/live/:videoId/sessions', | ||
42 | authenticate, | ||
43 | asyncMiddleware(videoLiveGetValidator), | ||
44 | videoLiveListSessionsValidator, | ||
45 | asyncMiddleware(getLiveVideoSessions) | ||
46 | ) | ||
47 | |||
48 | liveRouter.get('/live/:videoId', | ||
49 | optionalAuthenticate, | ||
50 | asyncMiddleware(videoLiveGetValidator), | ||
51 | getLiveVideo | ||
52 | ) | ||
53 | |||
54 | liveRouter.put('/live/:videoId', | ||
55 | authenticate, | ||
56 | asyncMiddleware(videoLiveGetValidator), | ||
57 | videoLiveUpdateValidator, | ||
58 | asyncRetryTransactionMiddleware(updateLiveVideo) | ||
59 | ) | ||
60 | |||
61 | liveRouter.get('/:videoId/live-session', | ||
62 | asyncMiddleware(videoLiveFindReplaySessionValidator), | ||
63 | getLiveReplaySession | ||
64 | ) | ||
65 | |||
66 | // --------------------------------------------------------------------------- | ||
67 | |||
68 | export { | ||
69 | liveRouter | ||
70 | } | ||
71 | |||
72 | // --------------------------------------------------------------------------- | ||
73 | |||
74 | function getLiveVideo (req: express.Request, res: express.Response) { | ||
75 | const videoLive = res.locals.videoLive | ||
76 | |||
77 | return res.json(videoLive.toFormattedJSON(canSeePrivateLiveInformation(res))) | ||
78 | } | ||
79 | |||
80 | function getLiveReplaySession (req: express.Request, res: express.Response) { | ||
81 | const session = res.locals.videoLiveSession | ||
82 | |||
83 | return res.json(session.toFormattedJSON()) | ||
84 | } | ||
85 | |||
86 | async 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 | |||
94 | function 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 | |||
104 | async 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 | |||
124 | async 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 | |||
145 | async 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 @@ | |||
1 | import express from 'express' | ||
2 | import { MVideoFullLight } from '@server/types/models' | ||
3 | import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes' | ||
4 | import { VideoChangeOwnershipStatus, VideoState } from '../../../../shared/models/videos' | ||
5 | import { logger } from '../../../helpers/logger' | ||
6 | import { getFormattedObjects } from '../../../helpers/utils' | ||
7 | import { sequelizeTypescript } from '../../../initializers/database' | ||
8 | import { sendUpdateVideo } from '../../../lib/activitypub/send' | ||
9 | import { changeVideoChannelShare } from '../../../lib/activitypub/share' | ||
10 | import { | ||
11 | asyncMiddleware, | ||
12 | asyncRetryTransactionMiddleware, | ||
13 | authenticate, | ||
14 | paginationValidator, | ||
15 | setDefaultPagination, | ||
16 | videosAcceptChangeOwnershipValidator, | ||
17 | videosChangeOwnershipValidator, | ||
18 | videosTerminateChangeOwnershipValidator | ||
19 | } from '../../../middlewares' | ||
20 | import { VideoModel } from '../../../models/video/video' | ||
21 | import { VideoChangeOwnershipModel } from '../../../models/video/video-change-ownership' | ||
22 | import { VideoChannelModel } from '../../../models/video/video-channel' | ||
23 | |||
24 | const ownershipVideoRouter = express.Router() | ||
25 | |||
26 | ownershipVideoRouter.post('/:videoId/give-ownership', | ||
27 | authenticate, | ||
28 | asyncMiddleware(videosChangeOwnershipValidator), | ||
29 | asyncRetryTransactionMiddleware(giveVideoOwnership) | ||
30 | ) | ||
31 | |||
32 | ownershipVideoRouter.get('/ownership', | ||
33 | authenticate, | ||
34 | paginationValidator, | ||
35 | setDefaultPagination, | ||
36 | asyncRetryTransactionMiddleware(listVideoOwnership) | ||
37 | ) | ||
38 | |||
39 | ownershipVideoRouter.post('/ownership/:id/accept', | ||
40 | authenticate, | ||
41 | asyncMiddleware(videosTerminateChangeOwnershipValidator), | ||
42 | asyncMiddleware(videosAcceptChangeOwnershipValidator), | ||
43 | asyncRetryTransactionMiddleware(acceptOwnership) | ||
44 | ) | ||
45 | |||
46 | ownershipVideoRouter.post('/ownership/:id/refuse', | ||
47 | authenticate, | ||
48 | asyncMiddleware(videosTerminateChangeOwnershipValidator), | ||
49 | asyncRetryTransactionMiddleware(refuseOwnership) | ||
50 | ) | ||
51 | |||
52 | // --------------------------------------------------------------------------- | ||
53 | |||
54 | export { | ||
55 | ownershipVideoRouter | ||
56 | } | ||
57 | |||
58 | // --------------------------------------------------------------------------- | ||
59 | |||
60 | async 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 | |||
89 | async 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 | |||
102 | function 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 | |||
129 | function 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 @@ | |||
1 | import express from 'express' | ||
2 | |||
3 | import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes' | ||
4 | import { getFormattedObjects } from '../../../helpers/utils' | ||
5 | import { | ||
6 | asyncMiddleware, | ||
7 | asyncRetryTransactionMiddleware, | ||
8 | authenticate, | ||
9 | setDefaultPagination, | ||
10 | setDefaultSort | ||
11 | } from '../../../middlewares' | ||
12 | import { | ||
13 | listVideoPasswordValidator, | ||
14 | paginationValidator, | ||
15 | removeVideoPasswordValidator, | ||
16 | updateVideoPasswordListValidator, | ||
17 | videoPasswordsSortValidator | ||
18 | } from '../../../middlewares/validators' | ||
19 | import { VideoPasswordModel } from '@server/models/video/video-password' | ||
20 | import { logger, loggerTagsFactory } from '@server/helpers/logger' | ||
21 | import { Transaction } from 'sequelize' | ||
22 | import { getVideoWithAttributes } from '@server/helpers/video' | ||
23 | |||
24 | const lTags = loggerTagsFactory('api', 'video') | ||
25 | const videoPasswordRouter = express.Router() | ||
26 | |||
27 | videoPasswordRouter.get('/:videoId/passwords', | ||
28 | authenticate, | ||
29 | paginationValidator, | ||
30 | videoPasswordsSortValidator, | ||
31 | setDefaultSort, | ||
32 | setDefaultPagination, | ||
33 | asyncMiddleware(listVideoPasswordValidator), | ||
34 | asyncMiddleware(listVideoPasswords) | ||
35 | ) | ||
36 | |||
37 | videoPasswordRouter.put('/:videoId/passwords', | ||
38 | authenticate, | ||
39 | asyncMiddleware(updateVideoPasswordListValidator), | ||
40 | asyncMiddleware(updateVideoPasswordList) | ||
41 | ) | ||
42 | |||
43 | videoPasswordRouter.delete('/:videoId/passwords/:passwordId', | ||
44 | authenticate, | ||
45 | asyncMiddleware(removeVideoPasswordValidator), | ||
46 | asyncRetryTransactionMiddleware(removeVideoPassword) | ||
47 | ) | ||
48 | |||
49 | // --------------------------------------------------------------------------- | ||
50 | |||
51 | export { | ||
52 | videoPasswordRouter | ||
53 | } | ||
54 | |||
55 | // --------------------------------------------------------------------------- | ||
56 | |||
57 | async 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 | |||
70 | async 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 | |||
91 | async 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 @@ | |||
1 | import express from 'express' | ||
2 | import { HttpStatusCode, UserVideoRateUpdate } from '@shared/models' | ||
3 | import { logger } from '../../../helpers/logger' | ||
4 | import { VIDEO_RATE_TYPES } from '../../../initializers/constants' | ||
5 | import { sequelizeTypescript } from '../../../initializers/database' | ||
6 | import { getLocalRateUrl, sendVideoRateChange } from '../../../lib/activitypub/video-rates' | ||
7 | import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videoUpdateRateValidator } from '../../../middlewares' | ||
8 | import { AccountModel } from '../../../models/account/account' | ||
9 | import { AccountVideoRateModel } from '../../../models/account/account-video-rate' | ||
10 | |||
11 | const rateVideoRouter = express.Router() | ||
12 | |||
13 | rateVideoRouter.put('/:id/rate', | ||
14 | authenticate, | ||
15 | asyncMiddleware(videoUpdateRateValidator), | ||
16 | asyncRetryTransactionMiddleware(rateVideo) | ||
17 | ) | ||
18 | |||
19 | // --------------------------------------------------------------------------- | ||
20 | |||
21 | export { | ||
22 | rateVideoRouter | ||
23 | } | ||
24 | |||
25 | // --------------------------------------------------------------------------- | ||
26 | |||
27 | async 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 @@ | |||
1 | import express from 'express' | ||
2 | import { move } from 'fs-extra' | ||
3 | import { sequelizeTypescript } from '@server/initializers/database' | ||
4 | import { CreateJobArgument, CreateJobOptions, JobQueue } from '@server/lib/job-queue' | ||
5 | import { Hooks } from '@server/lib/plugins/hooks' | ||
6 | import { regenerateMiniaturesIfNeeded } from '@server/lib/thumbnail' | ||
7 | import { uploadx } from '@server/lib/uploadx' | ||
8 | import { buildMoveToObjectStorageJob } from '@server/lib/video' | ||
9 | import { autoBlacklistVideoIfNeeded } from '@server/lib/video-blacklist' | ||
10 | import { buildNewFile } from '@server/lib/video-file' | ||
11 | import { VideoPathManager } from '@server/lib/video-path-manager' | ||
12 | import { buildNextVideoState } from '@server/lib/video-state' | ||
13 | import { openapiOperationDoc } from '@server/middlewares/doc' | ||
14 | import { VideoModel } from '@server/models/video/video' | ||
15 | import { VideoSourceModel } from '@server/models/video/video-source' | ||
16 | import { MStreamingPlaylistFiles, MVideo, MVideoFile, MVideoFullLight } from '@server/types/models' | ||
17 | import { VideoState } from '@shared/models' | ||
18 | import { logger, loggerTagsFactory } from '../../../helpers/logger' | ||
19 | import { | ||
20 | asyncMiddleware, | ||
21 | authenticate, | ||
22 | replaceVideoSourceResumableInitValidator, | ||
23 | replaceVideoSourceResumableValidator, | ||
24 | videoSourceGetLatestValidator | ||
25 | } from '../../../middlewares' | ||
26 | |||
27 | const lTags = loggerTagsFactory('api', 'video') | ||
28 | |||
29 | const videoSourceRouter = express.Router() | ||
30 | |||
31 | videoSourceRouter.get('/:id/source', | ||
32 | openapiOperationDoc({ operationId: 'getVideoSource' }), | ||
33 | authenticate, | ||
34 | asyncMiddleware(videoSourceGetLatestValidator), | ||
35 | getVideoLatestSource | ||
36 | ) | ||
37 | |||
38 | videoSourceRouter.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 | |||
44 | videoSourceRouter.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 | |||
49 | videoSourceRouter.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 | |||
58 | export { | ||
59 | videoSourceRouter | ||
60 | } | ||
61 | |||
62 | // --------------------------------------------------------------------------- | ||
63 | |||
64 | function getVideoLatestSource (req: express.Request, res: express.Response) { | ||
65 | return res.json(res.locals.videoSource.toFormattedJSON()) | ||
66 | } | ||
67 | |||
68 | async 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 | |||
144 | async 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 | |||
192 | async 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 @@ | |||
1 | import express from 'express' | ||
2 | import { LocalVideoViewerModel } from '@server/models/view/local-video-viewer' | ||
3 | import { VideoStatsOverallQuery, VideoStatsTimeserieMetric, VideoStatsTimeserieQuery } from '@shared/models' | ||
4 | import { | ||
5 | asyncMiddleware, | ||
6 | authenticate, | ||
7 | videoOverallStatsValidator, | ||
8 | videoRetentionStatsValidator, | ||
9 | videoTimeserieStatsValidator | ||
10 | } from '../../../middlewares' | ||
11 | |||
12 | const statsRouter = express.Router() | ||
13 | |||
14 | statsRouter.get('/:videoId/stats/overall', | ||
15 | authenticate, | ||
16 | asyncMiddleware(videoOverallStatsValidator), | ||
17 | asyncMiddleware(getOverallStats) | ||
18 | ) | ||
19 | |||
20 | statsRouter.get('/:videoId/stats/timeseries/:metric', | ||
21 | authenticate, | ||
22 | asyncMiddleware(videoTimeserieStatsValidator), | ||
23 | asyncMiddleware(getTimeserieStats) | ||
24 | ) | ||
25 | |||
26 | statsRouter.get('/:videoId/stats/retention', | ||
27 | authenticate, | ||
28 | asyncMiddleware(videoRetentionStatsValidator), | ||
29 | asyncMiddleware(getRetentionStats) | ||
30 | ) | ||
31 | |||
32 | // --------------------------------------------------------------------------- | ||
33 | |||
34 | export { | ||
35 | statsRouter | ||
36 | } | ||
37 | |||
38 | // --------------------------------------------------------------------------- | ||
39 | |||
40 | async 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 | |||
53 | async 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 | |||
61 | async 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 @@ | |||
1 | import express from 'express' | ||
2 | import { getVideoWithAttributes } from '@server/helpers/video' | ||
3 | import { StoryboardModel } from '@server/models/video/storyboard' | ||
4 | import { asyncMiddleware, videosGetValidator } from '../../../middlewares' | ||
5 | |||
6 | const storyboardRouter = express.Router() | ||
7 | |||
8 | storyboardRouter.get('/:id/storyboards', | ||
9 | asyncMiddleware(videosGetValidator), | ||
10 | asyncMiddleware(listStoryboards) | ||
11 | ) | ||
12 | |||
13 | // --------------------------------------------------------------------------- | ||
14 | |||
15 | export { | ||
16 | storyboardRouter | ||
17 | } | ||
18 | |||
19 | // --------------------------------------------------------------------------- | ||
20 | |||
21 | async 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 @@ | |||
1 | import Bluebird from 'bluebird' | ||
2 | import express from 'express' | ||
3 | import { move } from 'fs-extra' | ||
4 | import { basename } from 'path' | ||
5 | import { createAnyReqFiles } from '@server/helpers/express-utils' | ||
6 | import { MIMETYPES, VIDEO_FILTERS } from '@server/initializers/constants' | ||
7 | import { buildTaskFileFieldname, createVideoStudioJob, getStudioTaskFilePath, getTaskFileFromReq } from '@server/lib/video-studio' | ||
8 | import { | ||
9 | HttpStatusCode, | ||
10 | VideoState, | ||
11 | VideoStudioCreateEdition, | ||
12 | VideoStudioTask, | ||
13 | VideoStudioTaskCut, | ||
14 | VideoStudioTaskIntro, | ||
15 | VideoStudioTaskOutro, | ||
16 | VideoStudioTaskPayload, | ||
17 | VideoStudioTaskWatermark | ||
18 | } from '@shared/models' | ||
19 | import { asyncMiddleware, authenticate, videoStudioAddEditionValidator } from '../../../middlewares' | ||
20 | |||
21 | const studioRouter = express.Router() | ||
22 | |||
23 | const 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 | |||
48 | studioRouter.post('/:videoId/studio/edit', | ||
49 | authenticate, | ||
50 | tasksFiles, | ||
51 | asyncMiddleware(videoStudioAddEditionValidator), | ||
52 | asyncMiddleware(createEditionTasks) | ||
53 | ) | ||
54 | |||
55 | // --------------------------------------------------------------------------- | ||
56 | |||
57 | export { | ||
58 | studioRouter | ||
59 | } | ||
60 | |||
61 | // --------------------------------------------------------------------------- | ||
62 | |||
63 | async 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 | |||
85 | const 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 | |||
98 | function buildTaskPayload (task: VideoStudioTask, indice: number, files: Express.Multer.File[]): Promise<VideoStudioTaskPayload> { | ||
99 | return taskPayloadBuilders[task.name](task, indice, files) | ||
100 | } | ||
101 | |||
102 | async 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 | |||
113 | function 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 | |||
123 | async 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 | |||
137 | async 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 @@ | |||
1 | import express from 'express' | ||
2 | import { VideoTokensManager } from '@server/lib/video-tokens-manager' | ||
3 | import { VideoPrivacy, VideoToken } from '@shared/models' | ||
4 | import { asyncMiddleware, optionalAuthenticate, videoFileTokenValidator, videosCustomGetValidator } from '../../../middlewares' | ||
5 | |||
6 | const tokenRouter = express.Router() | ||
7 | |||
8 | tokenRouter.post('/:id/token', | ||
9 | optionalAuthenticate, | ||
10 | asyncMiddleware(videosCustomGetValidator('only-video')), | ||
11 | videoFileTokenValidator, | ||
12 | generateToken | ||
13 | ) | ||
14 | |||
15 | // --------------------------------------------------------------------------- | ||
16 | |||
17 | export { | ||
18 | tokenRouter | ||
19 | } | ||
20 | |||
21 | // --------------------------------------------------------------------------- | ||
22 | |||
23 | function 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 @@ | |||
1 | import express from 'express' | ||
2 | import { logger, loggerTagsFactory } from '@server/helpers/logger' | ||
3 | import { Hooks } from '@server/lib/plugins/hooks' | ||
4 | import { createTranscodingJobs } from '@server/lib/transcoding/create-transcoding-job' | ||
5 | import { computeResolutionsToTranscode } from '@server/lib/transcoding/transcoding-resolutions' | ||
6 | import { VideoJobInfoModel } from '@server/models/video/video-job-info' | ||
7 | import { HttpStatusCode, UserRight, VideoState, VideoTranscodingCreate } from '@shared/models' | ||
8 | import { asyncMiddleware, authenticate, createTranscodingValidator, ensureUserHasRight } from '../../../middlewares' | ||
9 | |||
10 | const lTags = loggerTagsFactory('api', 'video') | ||
11 | const transcodingRouter = express.Router() | ||
12 | |||
13 | transcodingRouter.post('/:videoId/transcoding', | ||
14 | authenticate, | ||
15 | ensureUserHasRight(UserRight.RUN_VIDEO_TRANSCODING), | ||
16 | asyncMiddleware(createTranscodingValidator), | ||
17 | asyncMiddleware(createTranscoding) | ||
18 | ) | ||
19 | |||
20 | // --------------------------------------------------------------------------- | ||
21 | |||
22 | export { | ||
23 | transcodingRouter | ||
24 | } | ||
25 | |||
26 | // --------------------------------------------------------------------------- | ||
27 | |||
28 | async 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 @@ | |||
1 | import express from 'express' | ||
2 | import { Transaction } from 'sequelize/types' | ||
3 | import { changeVideoChannelShare } from '@server/lib/activitypub/share' | ||
4 | import { addVideoJobsAfterUpdate, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video' | ||
5 | import { setVideoPrivacy } from '@server/lib/video-privacy' | ||
6 | import { openapiOperationDoc } from '@server/middlewares/doc' | ||
7 | import { FilteredModelAttributes } from '@server/types' | ||
8 | import { MVideoFullLight } from '@server/types/models' | ||
9 | import { forceNumber } from '@shared/core-utils' | ||
10 | import { HttpStatusCode, VideoPrivacy, VideoUpdate } from '@shared/models' | ||
11 | import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' | ||
12 | import { resetSequelizeInstance } from '../../../helpers/database-utils' | ||
13 | import { createReqFiles } from '../../../helpers/express-utils' | ||
14 | import { logger, loggerTagsFactory } from '../../../helpers/logger' | ||
15 | import { MIMETYPES } from '../../../initializers/constants' | ||
16 | import { sequelizeTypescript } from '../../../initializers/database' | ||
17 | import { Hooks } from '../../../lib/plugins/hooks' | ||
18 | import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist' | ||
19 | import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videosUpdateValidator } from '../../../middlewares' | ||
20 | import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update' | ||
21 | import { VideoModel } from '../../../models/video/video' | ||
22 | import { VideoPathManager } from '@server/lib/video-path-manager' | ||
23 | import { VideoPasswordModel } from '@server/models/video/video-password' | ||
24 | import { exists } from '@server/helpers/custom-validators/misc' | ||
25 | |||
26 | const lTags = loggerTagsFactory('api', 'video') | ||
27 | const auditLogger = auditLoggerFactory('videos') | ||
28 | const updateRouter = express.Router() | ||
29 | |||
30 | const reqVideoFileUpdate = createReqFiles([ 'thumbnailfile', 'previewfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT) | ||
31 | |||
32 | updateRouter.put('/:id', | ||
33 | openapiOperationDoc({ operationId: 'putVideo' }), | ||
34 | authenticate, | ||
35 | reqVideoFileUpdate, | ||
36 | asyncMiddleware(videosUpdateValidator), | ||
37 | asyncRetryTransactionMiddleware(updateVideo) | ||
38 | ) | ||
39 | |||
40 | // --------------------------------------------------------------------------- | ||
41 | |||
42 | export { | ||
43 | updateRouter | ||
44 | } | ||
45 | |||
46 | // --------------------------------------------------------------------------- | ||
47 | |||
48 | async 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 | |||
170 | async 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 | |||
200 | function 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 @@ | |||
1 | import express from 'express' | ||
2 | import { move } from 'fs-extra' | ||
3 | import { basename } from 'path' | ||
4 | import { getResumableUploadPath } from '@server/helpers/upload' | ||
5 | import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url' | ||
6 | import { CreateJobArgument, CreateJobOptions, JobQueue } from '@server/lib/job-queue' | ||
7 | import { Redis } from '@server/lib/redis' | ||
8 | import { uploadx } from '@server/lib/uploadx' | ||
9 | import { buildLocalVideoFromReq, buildMoveToObjectStorageJob, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video' | ||
10 | import { buildNewFile } from '@server/lib/video-file' | ||
11 | import { VideoPathManager } from '@server/lib/video-path-manager' | ||
12 | import { buildNextVideoState } from '@server/lib/video-state' | ||
13 | import { openapiOperationDoc } from '@server/middlewares/doc' | ||
14 | import { VideoPasswordModel } from '@server/models/video/video-password' | ||
15 | import { VideoSourceModel } from '@server/models/video/video-source' | ||
16 | import { MVideoFile, MVideoFullLight } from '@server/types/models' | ||
17 | import { uuidToShort } from '@shared/extra-utils' | ||
18 | import { HttpStatusCode, VideoCreate, VideoPrivacy, VideoState } from '@shared/models' | ||
19 | import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' | ||
20 | import { createReqFiles } from '../../../helpers/express-utils' | ||
21 | import { logger, loggerTagsFactory } from '../../../helpers/logger' | ||
22 | import { MIMETYPES } from '../../../initializers/constants' | ||
23 | import { sequelizeTypescript } from '../../../initializers/database' | ||
24 | import { Hooks } from '../../../lib/plugins/hooks' | ||
25 | import { generateLocalVideoMiniature } from '../../../lib/thumbnail' | ||
26 | import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist' | ||
27 | import { | ||
28 | asyncMiddleware, | ||
29 | asyncRetryTransactionMiddleware, | ||
30 | authenticate, | ||
31 | videosAddLegacyValidator, | ||
32 | videosAddResumableInitValidator, | ||
33 | videosAddResumableValidator | ||
34 | } from '../../../middlewares' | ||
35 | import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update' | ||
36 | import { VideoModel } from '../../../models/video/video' | ||
37 | |||
38 | const lTags = loggerTagsFactory('api', 'video') | ||
39 | const auditLogger = auditLoggerFactory('videos') | ||
40 | const uploadRouter = express.Router() | ||
41 | |||
42 | const reqVideoFileAdd = createReqFiles( | ||
43 | [ 'videofile', 'thumbnailfile', 'previewfile' ], | ||
44 | { ...MIMETYPES.VIDEO.MIMETYPE_EXT, ...MIMETYPES.IMAGE.MIMETYPE_EXT } | ||
45 | ) | ||
46 | |||
47 | const reqVideoFileAddResumable = createReqFiles( | ||
48 | [ 'thumbnailfile', 'previewfile' ], | ||
49 | MIMETYPES.IMAGE.MIMETYPE_EXT, | ||
50 | getResumableUploadPath() | ||
51 | ) | ||
52 | |||
53 | uploadRouter.post('/upload', | ||
54 | openapiOperationDoc({ operationId: 'uploadLegacy' }), | ||
55 | authenticate, | ||
56 | reqVideoFileAdd, | ||
57 | asyncMiddleware(videosAddLegacyValidator), | ||
58 | asyncRetryTransactionMiddleware(addVideoLegacy) | ||
59 | ) | ||
60 | |||
61 | uploadRouter.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 | |||
69 | uploadRouter.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 | |||
75 | uploadRouter.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 | |||
85 | export { | ||
86 | uploadRouter | ||
87 | } | ||
88 | |||
89 | // --------------------------------------------------------------------------- | ||
90 | |||
91 | async 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 | |||
111 | async 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 | |||
122 | async 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 | |||
227 | async 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 | |||
283 | async 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 @@ | |||
1 | import express from 'express' | ||
2 | import { Hooks } from '@server/lib/plugins/hooks' | ||
3 | import { VideoViewsManager } from '@server/lib/views/video-views-manager' | ||
4 | import { MVideoId } from '@server/types/models' | ||
5 | import { HttpStatusCode, VideoView } from '@shared/models' | ||
6 | import { asyncMiddleware, methodsValidator, openapiOperationDoc, optionalAuthenticate, videoViewValidator } from '../../../middlewares' | ||
7 | import { UserVideoHistoryModel } from '../../../models/user/user-video-history' | ||
8 | |||
9 | const viewRouter = express.Router() | ||
10 | |||
11 | viewRouter.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 | |||
22 | export { | ||
23 | viewRouter | ||
24 | } | ||
25 | |||
26 | // --------------------------------------------------------------------------- | ||
27 | |||
28 | async 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 | |||
50 | async 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 @@ | |||
1 | import express from 'express' | ||
2 | import { constants, promises as fs } from 'fs' | ||
3 | import { readFile } from 'fs-extra' | ||
4 | import { join } from 'path' | ||
5 | import { logger } from '@server/helpers/logger' | ||
6 | import { CONFIG } from '@server/initializers/config' | ||
7 | import { Hooks } from '@server/lib/plugins/hooks' | ||
8 | import { root } from '@shared/core-utils' | ||
9 | import { buildFileLocale, getCompleteLocale, is18nLocale, LOCALE_FILES } from '@shared/core-utils/i18n' | ||
10 | import { HttpStatusCode } from '@shared/models' | ||
11 | import { STATIC_MAX_AGE } from '../initializers/constants' | ||
12 | import { ClientHtml, sendHTML, serveIndexHTML } from '../lib/client-html' | ||
13 | import { asyncMiddleware, buildRateLimiter, embedCSP } from '../middlewares' | ||
14 | |||
15 | const clientsRouter = express.Router() | ||
16 | |||
17 | const clientsRateLimiter = buildRateLimiter({ | ||
18 | windowMs: CONFIG.RATES_LIMIT.CLIENT.WINDOW_MS, | ||
19 | max: CONFIG.RATES_LIMIT.CLIENT.MAX | ||
20 | }) | ||
21 | |||
22 | const distPath = join(root(), 'client', 'dist') | ||
23 | const 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 | ||
27 | clientsRouter.use([ '/w/p/:id', '/videos/watch/playlist/:id' ], | ||
28 | clientsRateLimiter, | ||
29 | asyncMiddleware(generateWatchPlaylistHtmlPage) | ||
30 | ) | ||
31 | |||
32 | clientsRouter.use([ '/w/:id', '/videos/watch/:id' ], | ||
33 | clientsRateLimiter, | ||
34 | asyncMiddleware(generateWatchHtmlPage) | ||
35 | ) | ||
36 | |||
37 | clientsRouter.use([ '/accounts/:nameWithHost', '/a/:nameWithHost' ], | ||
38 | clientsRateLimiter, | ||
39 | asyncMiddleware(generateAccountHtmlPage) | ||
40 | ) | ||
41 | |||
42 | clientsRouter.use([ '/video-channels/:nameWithHost', '/c/:nameWithHost' ], | ||
43 | clientsRateLimiter, | ||
44 | asyncMiddleware(generateVideoChannelHtmlPage) | ||
45 | ) | ||
46 | |||
47 | clientsRouter.use('/@:nameWithHost', | ||
48 | clientsRateLimiter, | ||
49 | asyncMiddleware(generateActorHtmlPage) | ||
50 | ) | ||
51 | |||
52 | const 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 | |||
72 | clientsRouter.use('/videos/embed', ...embedMiddlewares) | ||
73 | clientsRouter.use('/video-playlists/embed', ...embedMiddlewares) | ||
74 | |||
75 | const testEmbedController = (req: express.Request, res: express.Response) => res.sendFile(testEmbedPath) | ||
76 | |||
77 | clientsRouter.use('/videos/test-embed', clientsRateLimiter, testEmbedController) | ||
78 | clientsRouter.use('/video-playlists/test-embed', clientsRateLimiter, testEmbedController) | ||
79 | |||
80 | // Dynamic PWA manifest | ||
81 | clientsRouter.get('/manifest.webmanifest', clientsRateLimiter, asyncMiddleware(generateManifest)) | ||
82 | |||
83 | // Static client overrides | ||
84 | // Must be consistent with static client overrides redirections in /support/nginx/peertube | ||
85 | const 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 | |||
102 | for (const staticClientOverride of staticClientOverrides) { | ||
103 | const overridePhysicalPath = join(CONFIG.STORAGE.CLIENT_OVERRIDES_DIR, staticClientOverride) | ||
104 | clientsRouter.use(`/client/${staticClientOverride}`, asyncMiddleware(serveClientOverride(overridePhysicalPath))) | ||
105 | } | ||
106 | |||
107 | clientsRouter.use('/client/locales/:locale/:file.json', serveServerTranslations) | ||
108 | clientsRouter.use('/client', express.static(distPath, { maxAge: STATIC_MAX_AGE.CLIENT })) | ||
109 | |||
110 | // 404 for static files not found | ||
111 | clientsRouter.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 | ||
117 | clientsRouter.use('/(:language)?', | ||
118 | clientsRateLimiter, | ||
119 | asyncMiddleware(serveIndexHTML) | ||
120 | ) | ||
121 | |||
122 | // --------------------------------------------------------------------------- | ||
123 | |||
124 | export { | ||
125 | clientsRouter | ||
126 | } | ||
127 | |||
128 | // --------------------------------------------------------------------------- | ||
129 | |||
130 | function 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 | |||
145 | async 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 | |||
169 | async 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 | |||
182 | async 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 | |||
188 | async 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 | |||
194 | async 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 | |||
200 | async 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 | |||
206 | async 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 | |||
218 | function 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 | |||
231 | type AllowedResult = { allowed: boolean, html?: string } | ||
232 | function 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 @@ | |||
1 | import cors from 'cors' | ||
2 | import express from 'express' | ||
3 | import { logger } from '@server/helpers/logger' | ||
4 | import { VideoTorrentsSimpleFileCache } from '@server/lib/files-cache' | ||
5 | import { generateHLSFilePresignedUrl, generateWebVideoPresignedUrl } from '@server/lib/object-storage' | ||
6 | import { Hooks } from '@server/lib/plugins/hooks' | ||
7 | import { VideoPathManager } from '@server/lib/video-path-manager' | ||
8 | import { MStreamingPlaylist, MStreamingPlaylistVideo, MVideo, MVideoFile, MVideoFullLight } from '@server/types/models' | ||
9 | import { forceNumber } from '@shared/core-utils' | ||
10 | import { HttpStatusCode, VideoStorage, VideoStreamingPlaylistType } from '@shared/models' | ||
11 | import { STATIC_DOWNLOAD_PATHS } from '../initializers/constants' | ||
12 | import { asyncMiddleware, optionalAuthenticate, videosDownloadValidator } from '../middlewares' | ||
13 | |||
14 | const downloadRouter = express.Router() | ||
15 | |||
16 | downloadRouter.use(cors()) | ||
17 | |||
18 | downloadRouter.use( | ||
19 | STATIC_DOWNLOAD_PATHS.TORRENTS + ':filename', | ||
20 | asyncMiddleware(downloadTorrent) | ||
21 | ) | ||
22 | |||
23 | downloadRouter.use( | ||
24 | STATIC_DOWNLOAD_PATHS.VIDEOS + ':id-:resolution([0-9]+).:extension', | ||
25 | optionalAuthenticate, | ||
26 | asyncMiddleware(videosDownloadValidator), | ||
27 | asyncMiddleware(downloadVideoFile) | ||
28 | ) | ||
29 | |||
30 | downloadRouter.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 | |||
39 | export { | ||
40 | downloadRouter | ||
41 | } | ||
42 | |||
43 | // --------------------------------------------------------------------------- | ||
44 | |||
45 | async 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 | |||
72 | async 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 | |||
111 | async 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 | |||
151 | function getVideoFile (req: express.Request, files: MVideoFile[]) { | ||
152 | const resolution = forceNumber(req.params.resolution) | ||
153 | return files.find(f => f.resolution === resolution) | ||
154 | } | ||
155 | |||
156 | function 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 | |||
163 | type AllowedResult = { | ||
164 | allowed: boolean | ||
165 | errorMessage?: string | ||
166 | } | ||
167 | |||
168 | function isTorrentDownloadAllowed (_object: { | ||
169 | torrentPath: string | ||
170 | }): AllowedResult { | ||
171 | return { allowed: true } | ||
172 | } | ||
173 | |||
174 | function isVideoDownloadAllowed (_object: { | ||
175 | video: MVideo | ||
176 | videoFile: MVideoFile | ||
177 | streamingPlaylist?: MStreamingPlaylist | ||
178 | }): AllowedResult { | ||
179 | return { allowed: true } | ||
180 | } | ||
181 | |||
182 | function 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 | |||
196 | async 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 @@ | |||
1 | import express from 'express' | ||
2 | import { toSafeHtml } from '@server/helpers/markdown' | ||
3 | import { cacheRouteFactory } from '@server/middlewares' | ||
4 | import { CONFIG } from '../../initializers/config' | ||
5 | import { ROUTE_CACHE_LIFETIME, WEBSERVER } from '../../initializers/constants' | ||
6 | import { | ||
7 | asyncMiddleware, | ||
8 | feedsFormatValidator, | ||
9 | setFeedFormatContentType, | ||
10 | videoCommentsFeedsValidator, | ||
11 | feedsAccountOrChannelFiltersValidator | ||
12 | } from '../../middlewares' | ||
13 | import { VideoCommentModel } from '../../models/video/video-comment' | ||
14 | import { buildFeedMetadata, initFeed, sendFeed } from './shared' | ||
15 | |||
16 | const commentFeedsRouter = express.Router() | ||
17 | |||
18 | // --------------------------------------------------------------------------- | ||
19 | |||
20 | const { middleware: cacheRouteMiddleware } = cacheRouteFactory({ | ||
21 | headerBlacklist: [ 'Content-Type' ] | ||
22 | }) | ||
23 | |||
24 | // --------------------------------------------------------------------------- | ||
25 | |||
26 | commentFeedsRouter.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 | |||
37 | export { | ||
38 | commentFeedsRouter | ||
39 | } | ||
40 | |||
41 | // --------------------------------------------------------------------------- | ||
42 | |||
43 | async 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 @@ | |||
1 | import express from 'express' | ||
2 | import { CONFIG } from '@server/initializers/config' | ||
3 | import { buildRateLimiter } from '@server/middlewares' | ||
4 | import { commentFeedsRouter } from './comment-feeds' | ||
5 | import { videoFeedsRouter } from './video-feeds' | ||
6 | import { videoPodcastFeedsRouter } from './video-podcast-feeds' | ||
7 | |||
8 | const feedsRouter = express.Router() | ||
9 | |||
10 | const feedsRateLimiter = buildRateLimiter({ | ||
11 | windowMs: CONFIG.RATES_LIMIT.FEEDS.WINDOW_MS, | ||
12 | max: CONFIG.RATES_LIMIT.FEEDS.MAX | ||
13 | }) | ||
14 | |||
15 | feedsRouter.use('/feeds', feedsRateLimiter) | ||
16 | |||
17 | feedsRouter.use('/feeds', commentFeedsRouter) | ||
18 | feedsRouter.use('/feeds', videoFeedsRouter) | ||
19 | feedsRouter.use('/feeds', videoPodcastFeedsRouter) | ||
20 | |||
21 | // --------------------------------------------------------------------------- | ||
22 | |||
23 | export { | ||
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 @@ | |||
1 | import express from 'express' | ||
2 | import { Feed } from '@peertube/feed' | ||
3 | import { CustomTag, CustomXMLNS, Person } from '@peertube/feed/lib/typings' | ||
4 | import { mdToOneLinePlainText } from '@server/helpers/markdown' | ||
5 | import { CONFIG } from '@server/initializers/config' | ||
6 | import { WEBSERVER } from '@server/initializers/constants' | ||
7 | import { getBiggestActorImage } from '@server/lib/actor-image' | ||
8 | import { UserModel } from '@server/models/user/user' | ||
9 | import { MAccountDefault, MChannelBannerAccountDefault, MUser, MVideoFullLight } from '@server/types/models' | ||
10 | import { pick } from '@shared/core-utils' | ||
11 | import { ActorImageType } from '@shared/models' | ||
12 | |||
13 | export 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 | |||
61 | export 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 | |||
84 | export 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 @@ | |||
1 | export * from './video-feed-utils' | ||
2 | export * 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 @@ | |||
1 | import { mdToOneLinePlainText, toSafeHtml } from '@server/helpers/markdown' | ||
2 | import { CONFIG } from '@server/initializers/config' | ||
3 | import { WEBSERVER } from '@server/initializers/constants' | ||
4 | import { getServerActor } from '@server/models/application/application' | ||
5 | import { getCategoryLabel } from '@server/models/video/formatter' | ||
6 | import { DisplayOnlyForFollowerOptions } from '@server/models/video/sql/video' | ||
7 | import { VideoModel } from '@server/models/video/video' | ||
8 | import { MThumbnail, MUserDefault } from '@server/types/models' | ||
9 | import { VideoInclude } from '@shared/models' | ||
10 | |||
11 | export 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 | |||
40 | export 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 @@ | |||
1 | import express from 'express' | ||
2 | import { extname } from 'path' | ||
3 | import { Feed } from '@peertube/feed' | ||
4 | import { cacheRouteFactory } from '@server/middlewares' | ||
5 | import { VideoModel } from '@server/models/video/video' | ||
6 | import { VideoInclude } from '@shared/models' | ||
7 | import { buildNSFWFilter } from '../../helpers/express-utils' | ||
8 | import { MIMETYPES, ROUTE_CACHE_LIFETIME, WEBSERVER } from '../../initializers/constants' | ||
9 | import { | ||
10 | asyncMiddleware, | ||
11 | commonVideosFiltersValidator, | ||
12 | feedsFormatValidator, | ||
13 | setDefaultVideosSort, | ||
14 | setFeedFormatContentType, | ||
15 | feedsAccountOrChannelFiltersValidator, | ||
16 | videosSortValidator, | ||
17 | videoSubscriptionFeedsValidator | ||
18 | } from '../../middlewares' | ||
19 | import { buildFeedMetadata, getCommonVideoFeedAttributes, getVideosForFeeds, initFeed, sendFeed } from './shared' | ||
20 | |||
21 | const videoFeedsRouter = express.Router() | ||
22 | |||
23 | const { middleware: cacheRouteMiddleware } = cacheRouteFactory({ | ||
24 | headerBlacklist: [ 'Content-Type' ] | ||
25 | }) | ||
26 | |||
27 | // --------------------------------------------------------------------------- | ||
28 | |||
29 | videoFeedsRouter.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 | |||
40 | videoFeedsRouter.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 | |||
53 | export { | ||
54 | videoFeedsRouter | ||
55 | } | ||
56 | |||
57 | // --------------------------------------------------------------------------- | ||
58 | |||
59 | async 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 | |||
91 | async 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 | |||
125 | function 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 @@ | |||
1 | import express from 'express' | ||
2 | import { extname } from 'path' | ||
3 | import { Feed } from '@peertube/feed' | ||
4 | import { CustomTag, CustomXMLNS, LiveItemStatus } from '@peertube/feed/lib/typings' | ||
5 | import { getBiggestActorImage } from '@server/lib/actor-image' | ||
6 | import { InternalEventEmitter } from '@server/lib/internal-event-emitter' | ||
7 | import { Hooks } from '@server/lib/plugins/hooks' | ||
8 | import { buildPodcastGroupsCache, cacheRouteFactory, videoFeedsPodcastSetCacheKey } from '@server/middlewares' | ||
9 | import { MVideo, MVideoCaptionVideo, MVideoFullLight } from '@server/types/models' | ||
10 | import { sortObjectComparator } from '@shared/core-utils' | ||
11 | import { ActorImageType, VideoFile, VideoInclude, VideoResolution, VideoState } from '@shared/models' | ||
12 | import { buildNSFWFilter } from '../../helpers/express-utils' | ||
13 | import { MIMETYPES, ROUTE_CACHE_LIFETIME, WEBSERVER } from '../../initializers/constants' | ||
14 | import { asyncMiddleware, setFeedPodcastContentType, videoFeedsPodcastValidator } from '../../middlewares' | ||
15 | import { VideoModel } from '../../models/video/video' | ||
16 | import { VideoCaptionModel } from '../../models/video/video-caption' | ||
17 | import { buildFeedMetadata, getCommonVideoFeedAttributes, getVideosForFeeds, initFeed } from './shared' | ||
18 | |||
19 | const videoPodcastFeedsRouter = express.Router() | ||
20 | |||
21 | // --------------------------------------------------------------------------- | ||
22 | |||
23 | const { middleware: podcastCacheRouteMiddleware, instance: podcastApiCache } = cacheRouteFactory({ | ||
24 | headerBlacklist: [ 'Content-Type' ] | ||
25 | }) | ||
26 | |||
27 | for (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 | |||
35 | for (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 | |||
43 | videoPodcastFeedsRouter.get('/podcast/videos.xml', | ||
44 | setFeedPodcastContentType, | ||
45 | videoFeedsPodcastSetCacheKey, | ||
46 | podcastCacheRouteMiddleware(ROUTE_CACHE_LIFETIME.FEEDS), | ||
47 | asyncMiddleware(videoFeedsPodcastValidator), | ||
48 | asyncMiddleware(generateVideoPodcastFeed) | ||
49 | ) | ||
50 | |||
51 | // --------------------------------------------------------------------------- | ||
52 | |||
53 | export { | ||
54 | videoPodcastFeedsRouter | ||
55 | } | ||
56 | |||
57 | // --------------------------------------------------------------------------- | ||
58 | |||
59 | async 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 | |||
110 | type 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 | |||
125 | async 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 | |||
186 | async 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 | |||
198 | async 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 | |||
221 | async 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 | |||
245 | function 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 | |||
270 | function 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 | |||
286 | function 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 | |||
301 | function 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 @@ | |||
1 | export * from './activitypub' | ||
2 | export * from './api' | ||
3 | export * from './sitemap' | ||
4 | export * from './client' | ||
5 | export * from './download' | ||
6 | export * from './feeds' | ||
7 | export * from './lazy-static' | ||
8 | export * from './misc' | ||
9 | export * from './object-storage-proxy' | ||
10 | export * from './plugins' | ||
11 | export * from './services' | ||
12 | export * from './static' | ||
13 | export * from './tracker' | ||
14 | export * 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 @@ | |||
1 | import cors from 'cors' | ||
2 | import express from 'express' | ||
3 | import { CONFIG } from '@server/initializers/config' | ||
4 | import { HttpStatusCode } from '../../shared/models/http/http-error-codes' | ||
5 | import { FILES_CACHE, LAZY_STATIC_PATHS, STATIC_MAX_AGE } from '../initializers/constants' | ||
6 | import { | ||
7 | AvatarPermanentFileCache, | ||
8 | VideoCaptionsSimpleFileCache, | ||
9 | VideoMiniaturePermanentFileCache, | ||
10 | VideoPreviewsSimpleFileCache, | ||
11 | VideoStoryboardsSimpleFileCache, | ||
12 | VideoTorrentsSimpleFileCache | ||
13 | } from '../lib/files-cache' | ||
14 | import { asyncMiddleware, handleStaticError } from '../middlewares' | ||
15 | |||
16 | // --------------------------------------------------------------------------- | ||
17 | // Cache initializations | ||
18 | // --------------------------------------------------------------------------- | ||
19 | |||
20 | VideoPreviewsSimpleFileCache.Instance.init(CONFIG.CACHE.PREVIEWS.SIZE, FILES_CACHE.PREVIEWS.MAX_AGE) | ||
21 | VideoCaptionsSimpleFileCache.Instance.init(CONFIG.CACHE.VIDEO_CAPTIONS.SIZE, FILES_CACHE.VIDEO_CAPTIONS.MAX_AGE) | ||
22 | VideoTorrentsSimpleFileCache.Instance.init(CONFIG.CACHE.TORRENTS.SIZE, FILES_CACHE.TORRENTS.MAX_AGE) | ||
23 | VideoStoryboardsSimpleFileCache.Instance.init(CONFIG.CACHE.STORYBOARDS.SIZE, FILES_CACHE.STORYBOARDS.MAX_AGE) | ||
24 | |||
25 | // --------------------------------------------------------------------------- | ||
26 | |||
27 | const lazyStaticRouter = express.Router() | ||
28 | |||
29 | lazyStaticRouter.use(cors()) | ||
30 | |||
31 | lazyStaticRouter.use( | ||
32 | LAZY_STATIC_PATHS.AVATARS + ':filename', | ||
33 | asyncMiddleware(getActorImage), | ||
34 | handleStaticError | ||
35 | ) | ||
36 | |||
37 | lazyStaticRouter.use( | ||
38 | LAZY_STATIC_PATHS.BANNERS + ':filename', | ||
39 | asyncMiddleware(getActorImage), | ||
40 | handleStaticError | ||
41 | ) | ||
42 | |||
43 | lazyStaticRouter.use( | ||
44 | LAZY_STATIC_PATHS.THUMBNAILS + ':filename', | ||
45 | asyncMiddleware(getThumbnail), | ||
46 | handleStaticError | ||
47 | ) | ||
48 | |||
49 | lazyStaticRouter.use( | ||
50 | LAZY_STATIC_PATHS.PREVIEWS + ':filename', | ||
51 | asyncMiddleware(getPreview), | ||
52 | handleStaticError | ||
53 | ) | ||
54 | |||
55 | lazyStaticRouter.use( | ||
56 | LAZY_STATIC_PATHS.STORYBOARDS + ':filename', | ||
57 | asyncMiddleware(getStoryboard), | ||
58 | handleStaticError | ||
59 | ) | ||
60 | |||
61 | lazyStaticRouter.use( | ||
62 | LAZY_STATIC_PATHS.VIDEO_CAPTIONS + ':filename', | ||
63 | asyncMiddleware(getVideoCaption), | ||
64 | handleStaticError | ||
65 | ) | ||
66 | |||
67 | lazyStaticRouter.use( | ||
68 | LAZY_STATIC_PATHS.TORRENTS + ':filename', | ||
69 | asyncMiddleware(getTorrent), | ||
70 | handleStaticError | ||
71 | ) | ||
72 | |||
73 | // --------------------------------------------------------------------------- | ||
74 | |||
75 | export { | ||
76 | lazyStaticRouter, | ||
77 | getPreview, | ||
78 | getVideoCaption | ||
79 | } | ||
80 | |||
81 | // --------------------------------------------------------------------------- | ||
82 | const avatarPermanentFileCache = new AvatarPermanentFileCache() | ||
83 | |||
84 | function 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 | // --------------------------------------------------------------------------- | ||
91 | const videoMiniaturePermanentFileCache = new VideoMiniaturePermanentFileCache() | ||
92 | |||
93 | function 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 | |||
101 | async 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 | |||
108 | async 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 | |||
115 | async 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 | |||
122 | async 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 @@ | |||
1 | import cors from 'cors' | ||
2 | import express from 'express' | ||
3 | import { CONFIG, isEmailEnabled } from '@server/initializers/config' | ||
4 | import { serveIndexHTML } from '@server/lib/client-html' | ||
5 | import { ServerConfigManager } from '@server/lib/server-config-manager' | ||
6 | import { HttpStatusCode } from '@shared/models' | ||
7 | import { HttpNodeinfoDiasporaSoftwareNsSchema20 } from '../../shared/models/nodeinfo/nodeinfo.model' | ||
8 | import { CONSTRAINTS_FIELDS, DEFAULT_THEME_NAME, PEERTUBE_VERSION, ROUTE_CACHE_LIFETIME } from '../initializers/constants' | ||
9 | import { getThemeOrDefault } from '../lib/plugins/theme-utils' | ||
10 | import { apiRateLimiter, asyncMiddleware } from '../middlewares' | ||
11 | import { cacheRoute } from '../middlewares/cache/cache' | ||
12 | import { UserModel } from '../models/user/user' | ||
13 | import { VideoModel } from '../models/video/video' | ||
14 | import { VideoCommentModel } from '../models/video/video-comment' | ||
15 | |||
16 | const miscRouter = express.Router() | ||
17 | |||
18 | miscRouter.use(cors()) | ||
19 | |||
20 | miscRouter.use('/nodeinfo/:version.json', | ||
21 | apiRateLimiter, | ||
22 | cacheRoute(ROUTE_CACHE_LIFETIME.NODEINFO), | ||
23 | asyncMiddleware(generateNodeinfo) | ||
24 | ) | ||
25 | |||
26 | // robots.txt service | ||
27 | miscRouter.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 | |||
37 | miscRouter.all('/teapot', | ||
38 | apiRateLimiter, | ||
39 | getCup, | ||
40 | asyncMiddleware(serveIndexHTML) | ||
41 | ) | ||
42 | |||
43 | // security.txt service | ||
44 | miscRouter.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 | |||
53 | export { | ||
54 | miscRouter | ||
55 | } | ||
56 | |||
57 | // --------------------------------------------------------------------------- | ||
58 | |||
59 | async 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 | |||
204 | function 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 @@ | |||
1 | import cors from 'cors' | ||
2 | import express from 'express' | ||
3 | import { OBJECT_STORAGE_PROXY_PATHS } from '@server/initializers/constants' | ||
4 | import { proxifyHLS, proxifyWebVideoFile } from '@server/lib/object-storage' | ||
5 | import { | ||
6 | asyncMiddleware, | ||
7 | ensureCanAccessPrivateVideoHLSFiles, | ||
8 | ensureCanAccessVideoPrivateWebVideoFiles, | ||
9 | ensurePrivateObjectStorageProxyIsEnabled, | ||
10 | optionalAuthenticate | ||
11 | } from '@server/middlewares' | ||
12 | import { doReinjectVideoFileToken } from './shared/m3u8-playlist' | ||
13 | |||
14 | const objectStorageProxyRouter = express.Router() | ||
15 | |||
16 | objectStorageProxyRouter.use(cors()) | ||
17 | |||
18 | objectStorageProxyRouter.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 | |||
26 | objectStorageProxyRouter.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 | |||
35 | export { | ||
36 | objectStorageProxyRouter | ||
37 | } | ||
38 | |||
39 | function proxifyWebVideoController (req: express.Request, res: express.Response) { | ||
40 | const filename = req.params.filename | ||
41 | |||
42 | return proxifyWebVideoFile({ req, res, filename }) | ||
43 | } | ||
44 | |||
45 | function 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 @@ | |||
1 | import express from 'express' | ||
2 | import { join } from 'path' | ||
3 | import { logger } from '@server/helpers/logger' | ||
4 | import { CONFIG } from '@server/initializers/config' | ||
5 | import { buildRateLimiter } from '@server/middlewares' | ||
6 | import { optionalAuthenticate } from '@server/middlewares/auth' | ||
7 | import { getCompleteLocale, is18nLocale } from '../../shared/core-utils/i18n' | ||
8 | import { HttpStatusCode } from '../../shared/models/http/http-error-codes' | ||
9 | import { PluginType } from '../../shared/models/plugins/plugin.type' | ||
10 | import { isProdInstance } from '../helpers/core-utils' | ||
11 | import { PLUGIN_GLOBAL_CSS_PATH } from '../initializers/constants' | ||
12 | import { PluginManager, RegisteredPlugin } from '../lib/plugins/plugin-manager' | ||
13 | import { getExternalAuthValidator, getPluginValidator, pluginStaticDirectoryValidator } from '../middlewares/validators/plugins' | ||
14 | import { serveThemeCSSValidator } from '../middlewares/validators/themes' | ||
15 | |||
16 | const sendFileOptions = { | ||
17 | maxAge: '30 days', | ||
18 | immutable: isProdInstance() | ||
19 | } | ||
20 | |||
21 | const pluginsRouter = express.Router() | ||
22 | |||
23 | const pluginsRateLimiter = buildRateLimiter({ | ||
24 | windowMs: CONFIG.RATES_LIMIT.PLUGINS.WINDOW_MS, | ||
25 | max: CONFIG.RATES_LIMIT.PLUGINS.MAX | ||
26 | }) | ||
27 | |||
28 | pluginsRouter.get('/plugins/global.css', | ||
29 | pluginsRateLimiter, | ||
30 | servePluginGlobalCSS | ||
31 | ) | ||
32 | |||
33 | pluginsRouter.get('/plugins/translations/:locale.json', | ||
34 | pluginsRateLimiter, | ||
35 | getPluginTranslations | ||
36 | ) | ||
37 | |||
38 | pluginsRouter.get('/plugins/:pluginName/:pluginVersion/auth/:authName', | ||
39 | pluginsRateLimiter, | ||
40 | getPluginValidator(PluginType.PLUGIN), | ||
41 | getExternalAuthValidator, | ||
42 | handleAuthInPlugin | ||
43 | ) | ||
44 | |||
45 | pluginsRouter.get('/plugins/:pluginName/:pluginVersion/static/:staticEndpoint(*)', | ||
46 | pluginsRateLimiter, | ||
47 | getPluginValidator(PluginType.PLUGIN), | ||
48 | pluginStaticDirectoryValidator, | ||
49 | servePluginStaticDirectory | ||
50 | ) | ||
51 | |||
52 | pluginsRouter.get('/plugins/:pluginName/:pluginVersion/client-scripts/:staticEndpoint(*)', | ||
53 | pluginsRateLimiter, | ||
54 | getPluginValidator(PluginType.PLUGIN), | ||
55 | pluginStaticDirectoryValidator, | ||
56 | servePluginClientScripts | ||
57 | ) | ||
58 | |||
59 | pluginsRouter.use('/plugins/:pluginName/router', | ||
60 | pluginsRateLimiter, | ||
61 | getPluginValidator(PluginType.PLUGIN, false), | ||
62 | optionalAuthenticate, | ||
63 | servePluginCustomRoutes | ||
64 | ) | ||
65 | |||
66 | pluginsRouter.use('/plugins/:pluginName/:pluginVersion/router', | ||
67 | pluginsRateLimiter, | ||
68 | getPluginValidator(PluginType.PLUGIN), | ||
69 | optionalAuthenticate, | ||
70 | servePluginCustomRoutes | ||
71 | ) | ||
72 | |||
73 | pluginsRouter.get('/themes/:pluginName/:pluginVersion/static/:staticEndpoint(*)', | ||
74 | pluginsRateLimiter, | ||
75 | getPluginValidator(PluginType.THEME), | ||
76 | pluginStaticDirectoryValidator, | ||
77 | servePluginStaticDirectory | ||
78 | ) | ||
79 | |||
80 | pluginsRouter.get('/themes/:pluginName/:pluginVersion/client-scripts/:staticEndpoint(*)', | ||
81 | pluginsRateLimiter, | ||
82 | getPluginValidator(PluginType.THEME), | ||
83 | pluginStaticDirectoryValidator, | ||
84 | servePluginClientScripts | ||
85 | ) | ||
86 | |||
87 | pluginsRouter.get('/themes/:themeName/:themeVersion/css/:staticEndpoint(*)', | ||
88 | pluginsRateLimiter, | ||
89 | serveThemeCSSValidator, | ||
90 | serveThemeCSSDirectory | ||
91 | ) | ||
92 | |||
93 | // --------------------------------------------------------------------------- | ||
94 | |||
95 | export { | ||
96 | pluginsRouter | ||
97 | } | ||
98 | |||
99 | // --------------------------------------------------------------------------- | ||
100 | |||
101 | function 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 | |||
110 | function 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 | |||
123 | function 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 | |||
136 | function 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 | |||
145 | function 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 | |||
155 | function 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 | |||
166 | function 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 @@ | |||
1 | import express from 'express' | ||
2 | import { MChannelSummary } from '@server/types/models' | ||
3 | import { escapeHTML } from '@shared/core-utils/renderer' | ||
4 | import { EMBED_SIZE, PREVIEWS_SIZE, THUMBNAILS_SIZE, WEBSERVER } from '../initializers/constants' | ||
5 | import { apiRateLimiter, asyncMiddleware, oembedValidator } from '../middlewares' | ||
6 | import { accountNameWithHostGetValidator } from '../middlewares/validators' | ||
7 | import { forceNumber } from '@shared/core-utils' | ||
8 | |||
9 | const servicesRouter = express.Router() | ||
10 | |||
11 | servicesRouter.use('/oembed', | ||
12 | apiRateLimiter, | ||
13 | asyncMiddleware(oembedValidator), | ||
14 | generateOEmbed | ||
15 | ) | ||
16 | servicesRouter.use('/redirect/accounts/:accountName', | ||
17 | apiRateLimiter, | ||
18 | asyncMiddleware(accountNameWithHostGetValidator), | ||
19 | redirectToAccountUrl | ||
20 | ) | ||
21 | |||
22 | // --------------------------------------------------------------------------- | ||
23 | |||
24 | export { | ||
25 | servicesRouter | ||
26 | } | ||
27 | |||
28 | // --------------------------------------------------------------------------- | ||
29 | |||
30 | function generateOEmbed (req: express.Request, res: express.Response) { | ||
31 | if (res.locals.videoAll) return generateVideoOEmbed(req, res) | ||
32 | |||
33 | return generatePlaylistOEmbed(req, res) | ||
34 | } | ||
35 | |||
36 | function 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 | |||
51 | function 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 | |||
66 | function 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 | |||
100 | function 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 | |||
163 | function 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 @@ | |||
1 | import express from 'express' | ||
2 | |||
3 | function doReinjectVideoFileToken (req: express.Request) { | ||
4 | return req.query.videoFileToken && req.query.reinjectVideoFileToken | ||
5 | } | ||
6 | |||
7 | function 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 | |||
15 | export { | ||
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 @@ | |||
1 | import express from 'express' | ||
2 | import { truncate } from 'lodash' | ||
3 | import { ErrorLevel, SitemapStream, streamToPromise } from 'sitemap' | ||
4 | import { logger } from '@server/helpers/logger' | ||
5 | import { getServerActor } from '@server/models/application/application' | ||
6 | import { buildNSFWFilter } from '../helpers/express-utils' | ||
7 | import { ROUTE_CACHE_LIFETIME, WEBSERVER } from '../initializers/constants' | ||
8 | import { apiRateLimiter, asyncMiddleware } from '../middlewares' | ||
9 | import { cacheRoute } from '../middlewares/cache/cache' | ||
10 | import { AccountModel } from '../models/account/account' | ||
11 | import { VideoModel } from '../models/video/video' | ||
12 | import { VideoChannelModel } from '../models/video/video-channel' | ||
13 | |||
14 | const sitemapRouter = express.Router() | ||
15 | |||
16 | sitemapRouter.use('/sitemap.xml', | ||
17 | apiRateLimiter, | ||
18 | cacheRoute(ROUTE_CACHE_LIFETIME.SITEMAP), | ||
19 | asyncMiddleware(getSitemap) | ||
20 | ) | ||
21 | |||
22 | // --------------------------------------------------------------------------- | ||
23 | |||
24 | export { | ||
25 | sitemapRouter | ||
26 | } | ||
27 | |||
28 | // --------------------------------------------------------------------------- | ||
29 | |||
30 | async 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 | |||
61 | async 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 | |||
69 | async 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 | |||
77 | async 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 | |||
108 | function 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 @@ | |||
1 | import cors from 'cors' | ||
2 | import express from 'express' | ||
3 | import { readFile } from 'fs-extra' | ||
4 | import { join } from 'path' | ||
5 | import { injectQueryToPlaylistUrls } from '@server/lib/hls' | ||
6 | import { | ||
7 | asyncMiddleware, | ||
8 | ensureCanAccessPrivateVideoHLSFiles, | ||
9 | ensureCanAccessVideoPrivateWebVideoFiles, | ||
10 | handleStaticError, | ||
11 | optionalAuthenticate | ||
12 | } from '@server/middlewares' | ||
13 | import { HttpStatusCode } from '@shared/models' | ||
14 | import { CONFIG } from '../initializers/config' | ||
15 | import { DIRECTORIES, STATIC_MAX_AGE, STATIC_PATHS } from '../initializers/constants' | ||
16 | import { buildReinjectVideoFileTokenQuery, doReinjectVideoFileToken } from './shared/m3u8-playlist' | ||
17 | |||
18 | const staticRouter = express.Router() | ||
19 | |||
20 | // Cors is very important to let other servers access torrent and video files | ||
21 | staticRouter.use(cors()) | ||
22 | |||
23 | // --------------------------------------------------------------------------- | ||
24 | // Web videos/Classic videos | ||
25 | // --------------------------------------------------------------------------- | ||
26 | |||
27 | const privateWebVideoStaticMiddlewares = CONFIG.STATIC_FILES.PRIVATE_FILES_REQUIRE_AUTH === true | ||
28 | ? [ optionalAuthenticate, asyncMiddleware(ensureCanAccessVideoPrivateWebVideoFiles) ] | ||
29 | : [] | ||
30 | |||
31 | staticRouter.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 | ) | ||
37 | staticRouter.use( | ||
38 | [ STATIC_PATHS.WEB_VIDEOS, STATIC_PATHS.LEGACY_WEB_VIDEOS ], | ||
39 | express.static(DIRECTORIES.VIDEOS.PUBLIC, { fallthrough: false }), | ||
40 | handleStaticError | ||
41 | ) | ||
42 | |||
43 | staticRouter.use( | ||
44 | STATIC_PATHS.REDUNDANCY, | ||
45 | express.static(CONFIG.STORAGE.REDUNDANCY_DIR, { fallthrough: false }), | ||
46 | handleStaticError | ||
47 | ) | ||
48 | |||
49 | // --------------------------------------------------------------------------- | ||
50 | // HLS | ||
51 | // --------------------------------------------------------------------------- | ||
52 | |||
53 | const privateHLSStaticMiddlewares = CONFIG.STATIC_FILES.PRIVATE_FILES_REQUIRE_AUTH === true | ||
54 | ? [ optionalAuthenticate, asyncMiddleware(ensureCanAccessPrivateVideoHLSFiles) ] | ||
55 | : [] | ||
56 | |||
57 | staticRouter.use( | ||
58 | STATIC_PATHS.STREAMING_PLAYLISTS.PRIVATE_HLS + ':videoUUID/:playlistName.m3u8', | ||
59 | ...privateHLSStaticMiddlewares, | ||
60 | asyncMiddleware(servePrivateM3U8) | ||
61 | ) | ||
62 | |||
63 | staticRouter.use( | ||
64 | STATIC_PATHS.STREAMING_PLAYLISTS.PRIVATE_HLS, | ||
65 | ...privateHLSStaticMiddlewares, | ||
66 | express.static(DIRECTORIES.HLS_STREAMING_PLAYLIST.PRIVATE, { fallthrough: false }), | ||
67 | handleStaticError | ||
68 | ) | ||
69 | staticRouter.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 | ||
76 | const thumbnailsPhysicalPath = CONFIG.STORAGE.THUMBNAILS_DIR | ||
77 | staticRouter.use( | ||
78 | STATIC_PATHS.THUMBNAILS, | ||
79 | express.static(thumbnailsPhysicalPath, { maxAge: STATIC_MAX_AGE.SERVER, fallthrough: false }), | ||
80 | handleStaticError | ||
81 | ) | ||
82 | |||
83 | // --------------------------------------------------------------------------- | ||
84 | |||
85 | export { | ||
86 | staticRouter | ||
87 | } | ||
88 | |||
89 | // --------------------------------------------------------------------------- | ||
90 | |||
91 | async 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 @@ | |||
1 | import { Server as TrackerServer } from 'bittorrent-tracker' | ||
2 | import express from 'express' | ||
3 | import { createServer } from 'http' | ||
4 | import { LRUCache } from 'lru-cache' | ||
5 | import proxyAddr from 'proxy-addr' | ||
6 | import { WebSocketServer } from 'ws' | ||
7 | import { logger } from '../helpers/logger' | ||
8 | import { CONFIG } from '../initializers/config' | ||
9 | import { LRU_CACHE, TRACKER_RATE_LIMITS } from '../initializers/constants' | ||
10 | import { VideoFileModel } from '../models/video/video-file' | ||
11 | import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist' | ||
12 | |||
13 | const trackerRouter = express.Router() | ||
14 | |||
15 | const blockedIPs = new LRUCache<string, boolean>({ | ||
16 | max: LRU_CACHE.TRACKER_IPS.MAX_SIZE, | ||
17 | ttl: TRACKER_RATE_LIMITS.BLOCK_IP_LIFETIME | ||
18 | }) | ||
19 | |||
20 | let peersIps = {} | ||
21 | let peersIpInfoHash = {} | ||
22 | runPeersChecker() | ||
23 | |||
24 | const 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 | |||
75 | if (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 | |||
91 | const onHttpRequest = trackerServer.onHttpRequest.bind(trackerServer) | ||
92 | trackerRouter.get('/tracker/announce', (req, res) => onHttpRequest(req, res, { action: 'announce' })) | ||
93 | trackerRouter.get('/tracker/scrape', (req, res) => onHttpRequest(req, res, { action: 'scrape' })) | ||
94 | |||
95 | function 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 | |||
128 | export { | ||
129 | trackerRouter, | ||
130 | createWebsocketTrackerServer | ||
131 | } | ||
132 | |||
133 | // --------------------------------------------------------------------------- | ||
134 | |||
135 | function 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 @@ | |||
1 | import cors from 'cors' | ||
2 | import express from 'express' | ||
3 | import { join } from 'path' | ||
4 | import { asyncMiddleware, buildRateLimiter, handleStaticError, webfingerValidator } from '@server/middlewares' | ||
5 | import { root } from '@shared/core-utils' | ||
6 | import { CONFIG } from '../initializers/config' | ||
7 | import { ROUTE_CACHE_LIFETIME, WEBSERVER } from '../initializers/constants' | ||
8 | import { cacheRoute } from '../middlewares/cache/cache' | ||
9 | |||
10 | const wellKnownRouter = express.Router() | ||
11 | |||
12 | const wellKnownRateLimiter = buildRateLimiter({ | ||
13 | windowMs: CONFIG.RATES_LIMIT.WELL_KNOWN.WINDOW_MS, | ||
14 | max: CONFIG.RATES_LIMIT.WELL_KNOWN.MAX | ||
15 | }) | ||
16 | |||
17 | wellKnownRouter.use(cors()) | ||
18 | |||
19 | wellKnownRouter.get('/.well-known/webfinger', | ||
20 | wellKnownRateLimiter, | ||
21 | asyncMiddleware(webfingerValidator), | ||
22 | webfingerController | ||
23 | ) | ||
24 | |||
25 | wellKnownRouter.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 | ||
35 | wellKnownRouter.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) | ||
51 | wellKnownRouter.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) | ||
62 | wellKnownRouter.use('/.well-known/dnt/', | ||
63 | wellKnownRateLimiter, | ||
64 | (_, res: express.Response) => { | ||
65 | res.json({ tracking: 'N' }) | ||
66 | } | ||
67 | ) | ||
68 | |||
69 | wellKnownRouter.use('/.well-known/change-password', | ||
70 | wellKnownRateLimiter, | ||
71 | (_, res: express.Response) => { | ||
72 | res.redirect('/my-account/settings') | ||
73 | } | ||
74 | ) | ||
75 | |||
76 | wellKnownRouter.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 | |||
90 | wellKnownRouter.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 | |||
99 | export { | ||
100 | wellKnownRouter | ||
101 | } | ||
102 | |||
103 | // --------------------------------------------------------------------------- | ||
104 | |||
105 | function 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 | } | ||