aboutsummaryrefslogtreecommitdiffhomepage
path: root/server
diff options
context:
space:
mode:
Diffstat (limited to 'server')
-rw-r--r--server/controllers/api/blocklist.ts108
-rw-r--r--server/controllers/api/config.ts12
-rw-r--r--server/controllers/api/index.ts2
-rw-r--r--server/controllers/api/plugins.ts7
-rw-r--r--server/controllers/api/users/my-subscriptions.ts21
-rw-r--r--server/controllers/api/videos/update.ts32
-rw-r--r--server/controllers/api/videos/upload.ts9
-rw-r--r--server/helpers/actors.ts17
-rw-r--r--server/helpers/decache.ts78
-rw-r--r--server/helpers/webtorrent.ts18
-rw-r--r--server/initializers/checker-before-init.ts1
-rw-r--r--server/initializers/config.ts9
-rw-r--r--server/initializers/constants.ts2
-rw-r--r--server/lib/blocklist.ts4
-rw-r--r--server/lib/client-html.ts4
-rw-r--r--server/lib/job-queue/handlers/move-to-object-storage.ts4
-rw-r--r--server/lib/notifier/shared/comment/comment-mention.ts4
-rw-r--r--server/lib/plugins/plugin-manager.ts4
-rw-r--r--server/lib/schedulers/remove-dangling-resumable-uploads-scheduler.ts35
-rw-r--r--server/lib/server-config-manager.ts5
-rw-r--r--server/lib/uploadx.ts10
-rw-r--r--server/middlewares/error.ts10
-rw-r--r--server/middlewares/validators/blocklist.ts29
-rw-r--r--server/middlewares/validators/plugins.ts6
-rw-r--r--server/models/account/account-blocklist.ts40
-rw-r--r--server/models/server/server-blocklist.ts19
-rw-r--r--server/models/video/video-playlist-element.ts2
-rw-r--r--server/tests/api/check-params/blocklist.ts72
-rw-r--r--server/tests/api/check-params/config.ts12
-rw-r--r--server/tests/api/check-params/plugins.ts2
-rw-r--r--server/tests/api/moderation/blocklist.ts107
-rw-r--r--server/tests/api/notifications/user-notifications.ts2
-rw-r--r--server/tests/api/server/config.ts18
-rw-r--r--server/tests/cli/peertube.ts19
-rw-r--r--server/tools/peertube-plugins.ts3
35 files changed, 637 insertions, 90 deletions
diff --git a/server/controllers/api/blocklist.ts b/server/controllers/api/blocklist.ts
new file mode 100644
index 000000000..1e936ad10
--- /dev/null
+++ b/server/controllers/api/blocklist.ts
@@ -0,0 +1,108 @@
1import express from 'express'
2import { handleToNameAndHost } from '@server/helpers/actors'
3import { AccountBlocklistModel } from '@server/models/account/account-blocklist'
4import { getServerActor } from '@server/models/application/application'
5import { ServerBlocklistModel } from '@server/models/server/server-blocklist'
6import { MActorAccountId, MUserAccountId } from '@server/types/models'
7import { BlockStatus } from '@shared/models'
8import { asyncMiddleware, blocklistStatusValidator, optionalAuthenticate } from '../../middlewares'
9import { logger } from '@server/helpers/logger'
10
11const blocklistRouter = express.Router()
12
13blocklistRouter.get('/status',
14 optionalAuthenticate,
15 blocklistStatusValidator,
16 asyncMiddleware(getBlocklistStatus)
17)
18
19// ---------------------------------------------------------------------------
20
21export {
22 blocklistRouter
23}
24
25// ---------------------------------------------------------------------------
26
27async function getBlocklistStatus (req: express.Request, res: express.Response) {
28 const hosts = req.query.hosts as string[]
29 const accounts = req.query.accounts as string[]
30 const user = res.locals.oauth?.token.User
31
32 const serverActor = await getServerActor()
33
34 const byAccountIds = [ serverActor.Account.id ]
35 if (user) byAccountIds.push(user.Account.id)
36
37 const status: BlockStatus = {
38 accounts: {},
39 hosts: {}
40 }
41
42 const baseOptions = {
43 byAccountIds,
44 user,
45 serverActor,
46 status
47 }
48
49 await Promise.all([
50 populateServerBlocklistStatus({ ...baseOptions, hosts }),
51 populateAccountBlocklistStatus({ ...baseOptions, accounts })
52 ])
53
54 return res.json(status)
55}
56
57async function populateServerBlocklistStatus (options: {
58 byAccountIds: number[]
59 user?: MUserAccountId
60 serverActor: MActorAccountId
61 hosts: string[]
62 status: BlockStatus
63}) {
64 const { byAccountIds, user, serverActor, hosts, status } = options
65
66 if (!hosts || hosts.length === 0) return
67
68 const serverBlocklistStatus = await ServerBlocklistModel.getBlockStatus(byAccountIds, hosts)
69
70 logger.debug('Got server blocklist status.', { serverBlocklistStatus, byAccountIds, hosts })
71
72 for (const host of hosts) {
73 const block = serverBlocklistStatus.find(b => b.host === host)
74
75 status.hosts[host] = getStatus(block, serverActor, user)
76 }
77}
78
79async function populateAccountBlocklistStatus (options: {
80 byAccountIds: number[]
81 user?: MUserAccountId
82 serverActor: MActorAccountId
83 accounts: string[]
84 status: BlockStatus
85}) {
86 const { byAccountIds, user, serverActor, accounts, status } = options
87
88 if (!accounts || accounts.length === 0) return
89
90 const accountBlocklistStatus = await AccountBlocklistModel.getBlockStatus(byAccountIds, accounts)
91
92 logger.debug('Got account blocklist status.', { accountBlocklistStatus, byAccountIds, accounts })
93
94 for (const account of accounts) {
95 const sanitizedHandle = handleToNameAndHost(account)
96
97 const block = accountBlocklistStatus.find(b => b.name === sanitizedHandle.name && b.host === sanitizedHandle.host)
98
99 status.accounts[sanitizedHandle.handle] = getStatus(block, serverActor, user)
100 }
101}
102
103function getStatus (block: { accountId: number }, serverActor: MActorAccountId, user?: MUserAccountId) {
104 return {
105 blockedByServer: !!(block && block.accountId === serverActor.Account.id),
106 blockedByUser: !!(block && user && block.accountId === user.Account.id)
107 }
108}
diff --git a/server/controllers/api/config.ts b/server/controllers/api/config.ts
index 805ad99c7..b253db397 100644
--- a/server/controllers/api/config.ts
+++ b/server/controllers/api/config.ts
@@ -169,6 +169,18 @@ function customConfig (): CustomConfig {
169 whitelisted: CONFIG.SERVICES.TWITTER.WHITELISTED 169 whitelisted: CONFIG.SERVICES.TWITTER.WHITELISTED
170 } 170 }
171 }, 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 },
172 cache: { 184 cache: {
173 previews: { 185 previews: {
174 size: CONFIG.CACHE.PREVIEWS.SIZE 186 size: CONFIG.CACHE.PREVIEWS.SIZE
diff --git a/server/controllers/api/index.ts b/server/controllers/api/index.ts
index 9949b378a..5f49336b1 100644
--- a/server/controllers/api/index.ts
+++ b/server/controllers/api/index.ts
@@ -6,6 +6,7 @@ import { badRequest } from '../../helpers/express-utils'
6import { CONFIG } from '../../initializers/config' 6import { CONFIG } from '../../initializers/config'
7import { abuseRouter } from './abuse' 7import { abuseRouter } from './abuse'
8import { accountsRouter } from './accounts' 8import { accountsRouter } from './accounts'
9import { blocklistRouter } from './blocklist'
9import { bulkRouter } from './bulk' 10import { bulkRouter } from './bulk'
10import { configRouter } from './config' 11import { configRouter } from './config'
11import { customPageRouter } from './custom-page' 12import { customPageRouter } from './custom-page'
@@ -49,6 +50,7 @@ apiRouter.use('/search', searchRouter)
49apiRouter.use('/overviews', overviewsRouter) 50apiRouter.use('/overviews', overviewsRouter)
50apiRouter.use('/plugins', pluginRouter) 51apiRouter.use('/plugins', pluginRouter)
51apiRouter.use('/custom-pages', customPageRouter) 52apiRouter.use('/custom-pages', customPageRouter)
53apiRouter.use('/blocklist', blocklistRouter)
52apiRouter.use('/ping', pong) 54apiRouter.use('/ping', pong)
53apiRouter.use('/*', badRequest) 55apiRouter.use('/*', badRequest)
54 56
diff --git a/server/controllers/api/plugins.ts b/server/controllers/api/plugins.ts
index 2de7fe41f..de9e055dc 100644
--- a/server/controllers/api/plugins.ts
+++ b/server/controllers/api/plugins.ts
@@ -144,8 +144,13 @@ async function installPlugin (req: express.Request, res: express.Response) {
144 144
145 const fromDisk = !!body.path 145 const fromDisk = !!body.path
146 const toInstall = body.npmName || body.path 146 const toInstall = body.npmName || body.path
147
148 const pluginVersion = body.pluginVersion && body.npmName
149 ? body.pluginVersion
150 : undefined
151
147 try { 152 try {
148 const plugin = await PluginManager.Instance.install(toInstall, undefined, fromDisk) 153 const plugin = await PluginManager.Instance.install(toInstall, pluginVersion, fromDisk)
149 154
150 return res.json(plugin.toFormattedJSON()) 155 return res.json(plugin.toFormattedJSON())
151 } catch (err) { 156 } catch (err) {
diff --git a/server/controllers/api/users/my-subscriptions.ts b/server/controllers/api/users/my-subscriptions.ts
index 6799ca8c5..fb1f68635 100644
--- a/server/controllers/api/users/my-subscriptions.ts
+++ b/server/controllers/api/users/my-subscriptions.ts
@@ -1,5 +1,6 @@
1import 'multer' 1import 'multer'
2import express from 'express' 2import express from 'express'
3import { handlesToNameAndHost } from '@server/helpers/actors'
3import { pickCommonVideoQuery } from '@server/helpers/query' 4import { pickCommonVideoQuery } from '@server/helpers/query'
4import { sendUndoFollow } from '@server/lib/activitypub/send' 5import { sendUndoFollow } from '@server/lib/activitypub/send'
5import { guessAdditionalAttributesFromQuery } from '@server/models/video/formatter/video-format-utils' 6import { guessAdditionalAttributesFromQuery } from '@server/models/video/formatter/video-format-utils'
@@ -7,7 +8,6 @@ import { VideoChannelModel } from '@server/models/video/video-channel'
7import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes' 8import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes'
8import { buildNSFWFilter, getCountVideos } from '../../../helpers/express-utils' 9import { buildNSFWFilter, getCountVideos } from '../../../helpers/express-utils'
9import { getFormattedObjects } from '../../../helpers/utils' 10import { getFormattedObjects } from '../../../helpers/utils'
10import { WEBSERVER } from '../../../initializers/constants'
11import { sequelizeTypescript } from '../../../initializers/database' 11import { sequelizeTypescript } from '../../../initializers/database'
12import { JobQueue } from '../../../lib/job-queue' 12import { JobQueue } from '../../../lib/job-queue'
13import { 13import {
@@ -89,28 +89,23 @@ async function areSubscriptionsExist (req: express.Request, res: express.Respons
89 const uris = req.query.uris as string[] 89 const uris = req.query.uris as string[]
90 const user = res.locals.oauth.token.User 90 const user = res.locals.oauth.token.User
91 91
92 const handles = uris.map(u => { 92 const sanitizedHandles = handlesToNameAndHost(uris)
93 let [ name, host ] = u.split('@')
94 if (host === WEBSERVER.HOST) host = null
95 93
96 return { name, host, uri: u } 94 const results = await ActorFollowModel.listSubscriptionsOf(user.Account.Actor.id, sanitizedHandles)
97 })
98
99 const results = await ActorFollowModel.listSubscriptionsOf(user.Account.Actor.id, handles)
100 95
101 const existObject: { [id: string ]: boolean } = {} 96 const existObject: { [id: string ]: boolean } = {}
102 for (const handle of handles) { 97 for (const sanitizedHandle of sanitizedHandles) {
103 const obj = results.find(r => { 98 const obj = results.find(r => {
104 const server = r.ActorFollowing.Server 99 const server = r.ActorFollowing.Server
105 100
106 return r.ActorFollowing.preferredUsername === handle.name && 101 return r.ActorFollowing.preferredUsername === sanitizedHandle.name &&
107 ( 102 (
108 (!server && !handle.host) || 103 (!server && !sanitizedHandle.host) ||
109 (server.host === handle.host) 104 (server.host === sanitizedHandle.host)
110 ) 105 )
111 }) 106 })
112 107
113 existObject[handle.uri] = obj !== undefined 108 existObject[sanitizedHandle.handle] = obj !== undefined
114 } 109 }
115 110
116 return res.json(existObject) 111 return res.json(existObject)
diff --git a/server/controllers/api/videos/update.ts b/server/controllers/api/videos/update.ts
index 3fcff3e86..e397127f3 100644
--- a/server/controllers/api/videos/update.ts
+++ b/server/controllers/api/videos/update.ts
@@ -1,5 +1,6 @@
1import express from 'express' 1import express from 'express'
2import { Transaction } from 'sequelize/types' 2import { Transaction } from 'sequelize/types'
3import { updateTorrentMetadata } from '@server/helpers/webtorrent'
3import { changeVideoChannelShare } from '@server/lib/activitypub/share' 4import { changeVideoChannelShare } from '@server/lib/activitypub/share'
4import { buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video' 5import { buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video'
5import { openapiOperationDoc } from '@server/middlewares/doc' 6import { openapiOperationDoc } from '@server/middlewares/doc'
@@ -68,7 +69,7 @@ async function updateVideo (req: express.Request, res: express.Response) {
68 }) 69 })
69 70
70 try { 71 try {
71 const videoInstanceUpdated = await sequelizeTypescript.transaction(async t => { 72 const { videoInstanceUpdated, isNewVideo } = await sequelizeTypescript.transaction(async t => {
72 // Refresh video since thumbnails to prevent concurrent updates 73 // Refresh video since thumbnails to prevent concurrent updates
73 const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoFromReq.id, t) 74 const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoFromReq.id, t)
74 75
@@ -137,8 +138,6 @@ async function updateVideo (req: express.Request, res: express.Response) {
137 transaction: t 138 transaction: t
138 }) 139 })
139 140
140 await federateVideoIfNeeded(videoInstanceUpdated, isNewVideo, t)
141
142 auditLogger.update( 141 auditLogger.update(
143 getAuditIdFromRes(res), 142 getAuditIdFromRes(res),
144 new VideoAuditView(videoInstanceUpdated.toFormattedDetailsJSON()), 143 new VideoAuditView(videoInstanceUpdated.toFormattedDetailsJSON()),
@@ -146,12 +145,14 @@ async function updateVideo (req: express.Request, res: express.Response) {
146 ) 145 )
147 logger.info('Video with name %s and uuid %s updated.', video.name, video.uuid, lTags(video.uuid)) 146 logger.info('Video with name %s and uuid %s updated.', video.name, video.uuid, lTags(video.uuid))
148 147
149 return videoInstanceUpdated 148 return { videoInstanceUpdated, isNewVideo }
150 }) 149 })
151 150
152 if (wasConfidentialVideo) { 151 if (videoInfoToUpdate.name) await updateTorrentsMetadata(videoInstanceUpdated)
153 Notifier.Instance.notifyOnNewVideoIfNeeded(videoInstanceUpdated) 152
154 } 153 await federateVideoIfNeeded(videoInstanceUpdated, isNewVideo, undefined)
154
155 if (wasConfidentialVideo) Notifier.Instance.notifyOnNewVideoIfNeeded(videoInstanceUpdated)
155 156
156 Hooks.runAction('action:api.video.updated', { video: videoInstanceUpdated, body: req.body, req, res }) 157 Hooks.runAction('action:api.video.updated', { video: videoInstanceUpdated, body: req.body, req, res })
157 } catch (err) { 158 } catch (err) {
@@ -199,3 +200,20 @@ function updateSchedule (videoInstance: MVideoFullLight, videoInfoToUpdate: Vide
199 return ScheduleVideoUpdateModel.deleteByVideoId(videoInstance.id, transaction) 200 return ScheduleVideoUpdateModel.deleteByVideoId(videoInstance.id, transaction)
200 } 201 }
201} 202}
203
204async function updateTorrentsMetadata (video: MVideoFullLight) {
205 for (const file of (video.VideoFiles || [])) {
206 await updateTorrentMetadata(video, file)
207
208 await file.save()
209 }
210
211 const hls = video.getHLSPlaylist()
212 if (!hls) return
213
214 for (const file of (hls.VideoFiles || [])) {
215 await updateTorrentMetadata(hls, file)
216
217 await file.save()
218 }
219}
diff --git a/server/controllers/api/videos/upload.ts b/server/controllers/api/videos/upload.ts
index 6773b500f..c827f6bf0 100644
--- a/server/controllers/api/videos/upload.ts
+++ b/server/controllers/api/videos/upload.ts
@@ -8,6 +8,7 @@ import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent'
8import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url' 8import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url'
9import { generateWebTorrentVideoFilename } from '@server/lib/paths' 9import { generateWebTorrentVideoFilename } from '@server/lib/paths'
10import { Redis } from '@server/lib/redis' 10import { Redis } from '@server/lib/redis'
11import { uploadx } from '@server/lib/uploadx'
11import { 12import {
12 addMoveToObjectStorageJob, 13 addMoveToObjectStorageJob,
13 addOptimizeOrMergeAudioJob, 14 addOptimizeOrMergeAudioJob,
@@ -19,7 +20,6 @@ import { VideoPathManager } from '@server/lib/video-path-manager'
19import { buildNextVideoState } from '@server/lib/video-state' 20import { buildNextVideoState } from '@server/lib/video-state'
20import { openapiOperationDoc } from '@server/middlewares/doc' 21import { openapiOperationDoc } from '@server/middlewares/doc'
21import { MVideo, MVideoFile, MVideoFullLight } from '@server/types/models' 22import { MVideo, MVideoFile, MVideoFullLight } from '@server/types/models'
22import { Uploadx } from '@uploadx/core'
23import { VideoCreate, VideoState } from '../../../../shared' 23import { VideoCreate, VideoState } from '../../../../shared'
24import { HttpStatusCode } from '../../../../shared/models' 24import { HttpStatusCode } from '../../../../shared/models'
25import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' 25import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
@@ -41,8 +41,8 @@ import {
41 authenticate, 41 authenticate,
42 videosAddLegacyValidator, 42 videosAddLegacyValidator,
43 videosAddResumableInitValidator, 43 videosAddResumableInitValidator,
44 videosResumableUploadIdValidator, 44 videosAddResumableValidator,
45 videosAddResumableValidator 45 videosResumableUploadIdValidator
46} from '../../../middlewares' 46} from '../../../middlewares'
47import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update' 47import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
48import { VideoModel } from '../../../models/video/video' 48import { VideoModel } from '../../../models/video/video'
@@ -52,9 +52,6 @@ const lTags = loggerTagsFactory('api', 'video')
52const auditLogger = auditLoggerFactory('videos') 52const auditLogger = auditLoggerFactory('videos')
53const uploadRouter = express.Router() 53const uploadRouter = express.Router()
54 54
55const uploadx = new Uploadx({ directory: getResumableUploadPath() })
56uploadx.getUserId = (_, res: express.Response) => res.locals.oauth?.token.user.id
57
58const reqVideoFileAdd = createReqFiles( 55const reqVideoFileAdd = createReqFiles(
59 [ 'videofile', 'thumbnailfile', 'previewfile' ], 56 [ 'videofile', 'thumbnailfile', 'previewfile' ],
60 Object.assign({}, MIMETYPES.VIDEO.MIMETYPE_EXT, MIMETYPES.IMAGE.MIMETYPE_EXT), 57 Object.assign({}, MIMETYPES.VIDEO.MIMETYPE_EXT, MIMETYPES.IMAGE.MIMETYPE_EXT),
diff --git a/server/helpers/actors.ts b/server/helpers/actors.ts
new file mode 100644
index 000000000..c31fe6f8e
--- /dev/null
+++ b/server/helpers/actors.ts
@@ -0,0 +1,17 @@
1import { WEBSERVER } from '@server/initializers/constants'
2
3function handleToNameAndHost (handle: string) {
4 let [ name, host ] = handle.split('@')
5 if (host === WEBSERVER.HOST) host = null
6
7 return { name, host, handle }
8}
9
10function handlesToNameAndHost (handles: string[]) {
11 return handles.map(h => handleToNameAndHost(h))
12}
13
14export {
15 handleToNameAndHost,
16 handlesToNameAndHost
17}
diff --git a/server/helpers/decache.ts b/server/helpers/decache.ts
new file mode 100644
index 000000000..e31973b7a
--- /dev/null
+++ b/server/helpers/decache.ts
@@ -0,0 +1,78 @@
1// Thanks: https://github.com/dwyl/decache
2// We reuse this file to also uncache plugin base path
3
4import { extname } from 'path'
5
6function decachePlugin (pluginPath: string, libraryPath: string) {
7 const moduleName = find(libraryPath)
8
9 if (!moduleName) return
10
11 searchCache(moduleName, function (mod) {
12 delete require.cache[mod.id]
13 })
14
15 removeCachedPath(pluginPath)
16}
17
18function decacheModule (name: string) {
19 const moduleName = find(name)
20
21 if (!moduleName) return
22
23 searchCache(moduleName, function (mod) {
24 delete require.cache[mod.id]
25 })
26
27 removeCachedPath(moduleName)
28}
29
30// ---------------------------------------------------------------------------
31
32export {
33 decacheModule,
34 decachePlugin
35}
36
37// ---------------------------------------------------------------------------
38
39function find (moduleName: string) {
40 try {
41 return require.resolve(moduleName)
42 } catch {
43 return ''
44 }
45}
46
47function searchCache (moduleName: string, callback: (current: NodeModule) => void) {
48 const resolvedModule = require.resolve(moduleName)
49 let mod: NodeModule
50 const visited = {}
51
52 if (resolvedModule && ((mod = require.cache[resolvedModule]) !== undefined)) {
53 // Recursively go over the results
54 (function run (current) {
55 visited[current.id] = true
56
57 current.children.forEach(function (child) {
58 if (extname(child.filename) !== '.node' && !visited[child.id]) {
59 run(child)
60 }
61 })
62
63 // Call the specified callback providing the
64 // found module
65 callback(current)
66 })(mod)
67 }
68};
69
70function removeCachedPath (pluginPath: string) {
71 const pathCache = (module.constructor as any)._pathCache
72
73 Object.keys(pathCache).forEach(function (cacheKey) {
74 if (cacheKey.includes(pluginPath)) {
75 delete pathCache[cacheKey]
76 }
77 })
78}
diff --git a/server/helpers/webtorrent.ts b/server/helpers/webtorrent.ts
index c75c058e4..ecc703646 100644
--- a/server/helpers/webtorrent.ts
+++ b/server/helpers/webtorrent.ts
@@ -14,7 +14,7 @@ import { MVideo } from '@server/types/models/video/video'
14import { MVideoFile, MVideoFileRedundanciesOpt } from '@server/types/models/video/video-file' 14import { MVideoFile, MVideoFileRedundanciesOpt } from '@server/types/models/video/video-file'
15import { MStreamingPlaylistVideo } from '@server/types/models/video/video-streaming-playlist' 15import { MStreamingPlaylistVideo } from '@server/types/models/video/video-streaming-playlist'
16import { CONFIG } from '../initializers/config' 16import { CONFIG } from '../initializers/config'
17import { promisify2 } from './core-utils' 17import { promisify2, sha1 } from './core-utils'
18import { logger } from './logger' 18import { logger } from './logger'
19import { generateVideoImportTmpPath } from './utils' 19import { generateVideoImportTmpPath } from './utils'
20import { extractVideo } from './video' 20import { extractVideo } from './video'
@@ -94,7 +94,7 @@ function createTorrentAndSetInfoHash (videoOrPlaylist: MVideo | MStreamingPlayli
94 94
95 const options = { 95 const options = {
96 // Keep the extname, it's used by the client to stream the file inside a web browser 96 // Keep the extname, it's used by the client to stream the file inside a web browser
97 name: `${video.name} ${videoFile.resolution}p${videoFile.extname}`, 97 name: buildInfoName(video, videoFile),
98 createdBy: 'PeerTube', 98 createdBy: 'PeerTube',
99 announceList: buildAnnounceList(), 99 announceList: buildAnnounceList(),
100 urlList: buildUrlList(video, videoFile) 100 urlList: buildUrlList(video, videoFile)
@@ -120,7 +120,7 @@ function createTorrentAndSetInfoHash (videoOrPlaylist: MVideo | MStreamingPlayli
120 }) 120 })
121} 121}
122 122
123async function updateTorrentUrls (videoOrPlaylist: MVideo | MStreamingPlaylistVideo, videoFile: MVideoFile) { 123async function updateTorrentMetadata (videoOrPlaylist: MVideo | MStreamingPlaylistVideo, videoFile: MVideoFile) {
124 const video = extractVideo(videoOrPlaylist) 124 const video = extractVideo(videoOrPlaylist)
125 125
126 const oldTorrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, videoFile.torrentFilename) 126 const oldTorrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, videoFile.torrentFilename)
@@ -133,15 +133,19 @@ async function updateTorrentUrls (videoOrPlaylist: MVideo | MStreamingPlaylistVi
133 133
134 decoded['url-list'] = buildUrlList(video, videoFile) 134 decoded['url-list'] = buildUrlList(video, videoFile)
135 135
136 decoded.info.name = buildInfoName(video, videoFile)
137 decoded['creation date'] = Math.ceil(Date.now() / 1000)
138
136 const newTorrentFilename = generateTorrentFileName(videoOrPlaylist, videoFile.resolution) 139 const newTorrentFilename = generateTorrentFileName(videoOrPlaylist, videoFile.resolution)
137 const newTorrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, newTorrentFilename) 140 const newTorrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, newTorrentFilename)
138 141
139 logger.info('Updating torrent URLs %s -> %s.', oldTorrentPath, newTorrentPath) 142 logger.info('Updating torrent metadata %s -> %s.', oldTorrentPath, newTorrentPath)
140 143
141 await writeFile(newTorrentPath, encode(decoded)) 144 await writeFile(newTorrentPath, encode(decoded))
142 await remove(join(CONFIG.STORAGE.TORRENTS_DIR, videoFile.torrentFilename)) 145 await remove(join(CONFIG.STORAGE.TORRENTS_DIR, videoFile.torrentFilename))
143 146
144 videoFile.torrentFilename = newTorrentFilename 147 videoFile.torrentFilename = newTorrentFilename
148 videoFile.infoHash = sha1(encode(decoded.info))
145} 149}
146 150
147function generateMagnetUri ( 151function generateMagnetUri (
@@ -171,7 +175,7 @@ function generateMagnetUri (
171 175
172export { 176export {
173 createTorrentPromise, 177 createTorrentPromise,
174 updateTorrentUrls, 178 updateTorrentMetadata,
175 createTorrentAndSetInfoHash, 179 createTorrentAndSetInfoHash,
176 generateMagnetUri, 180 generateMagnetUri,
177 downloadWebTorrentVideo 181 downloadWebTorrentVideo
@@ -226,3 +230,7 @@ function buildAnnounceList () {
226function buildUrlList (video: MVideo, videoFile: MVideoFile) { 230function buildUrlList (video: MVideo, videoFile: MVideoFile) {
227 return [ videoFile.getFileUrl(video) ] 231 return [ videoFile.getFileUrl(video) ]
228} 232}
233
234function buildInfoName (video: MVideo, videoFile: MVideoFile) {
235 return `${video.name} ${videoFile.resolution}p${videoFile.extname}`
236}
diff --git a/server/initializers/checker-before-init.ts b/server/initializers/checker-before-init.ts
index 51c396548..c85c389cd 100644
--- a/server/initializers/checker-before-init.ts
+++ b/server/initializers/checker-before-init.ts
@@ -33,6 +33,7 @@ function checkMissedConfig () {
33 'transcoding.resolutions.2160p', 33 'transcoding.resolutions.2160p',
34 'import.videos.http.enabled', 'import.videos.torrent.enabled', 'import.videos.concurrency', 'auto_blacklist.videos.of_users.enabled', 34 'import.videos.http.enabled', 'import.videos.torrent.enabled', 'import.videos.concurrency', 'auto_blacklist.videos.of_users.enabled',
35 'trending.videos.interval_days', 35 'trending.videos.interval_days',
36 'client.videos.miniature.prefer_author_display_name', 'client.menu.login.redirect_on_single_external_auth',
36 'instance.name', 'instance.short_description', 'instance.description', 'instance.terms', 'instance.default_client_route', 37 'instance.name', 'instance.short_description', 'instance.description', 'instance.terms', 'instance.default_client_route',
37 'instance.is_nsfw', 'instance.default_nsfw_policy', 'instance.robots', 'instance.securitytxt', 38 'instance.is_nsfw', 'instance.default_nsfw_policy', 'instance.robots', 'instance.securitytxt',
38 'services.twitter.username', 'services.twitter.whitelisted', 39 'services.twitter.username', 'services.twitter.whitelisted',
diff --git a/server/initializers/config.ts b/server/initializers/config.ts
index dadda2a77..eb848be6b 100644
--- a/server/initializers/config.ts
+++ b/server/initializers/config.ts
@@ -1,7 +1,7 @@
1import bytes from 'bytes' 1import bytes from 'bytes'
2import { IConfig } from 'config' 2import { IConfig } from 'config'
3import decache from 'decache'
4import { dirname, join } from 'path' 3import { dirname, join } from 'path'
4import { decacheModule } from '@server/helpers/decache'
5import { VideoRedundancyConfigFilter } from '@shared/models/redundancy/video-redundancy-config-filter.type' 5import { VideoRedundancyConfigFilter } from '@shared/models/redundancy/video-redundancy-config-filter.type'
6import { BroadcastMessageLevel } from '@shared/models/server' 6import { BroadcastMessageLevel } from '@shared/models/server'
7import { VideosRedundancyStrategy } from '../../shared/models' 7import { VideosRedundancyStrategy } from '../../shared/models'
@@ -63,6 +63,11 @@ const CONFIG = {
63 MINIATURE: { 63 MINIATURE: {
64 get PREFER_AUTHOR_DISPLAY_NAME () { return config.get<boolean>('client.videos.miniature.prefer_author_display_name') } 64 get PREFER_AUTHOR_DISPLAY_NAME () { return config.get<boolean>('client.videos.miniature.prefer_author_display_name') }
65 } 65 }
66 },
67 MENU: {
68 LOGIN: {
69 get REDIRECT_ON_SINGLE_EXTERNAL_AUTH () { return config.get<boolean>('client.menu.login.redirect_on_single_external_auth') }
70 }
66 } 71 }
67 }, 72 },
68 73
@@ -497,7 +502,7 @@ export function reloadConfig () {
497 delete require.cache[fileName] 502 delete require.cache[fileName]
498 } 503 }
499 504
500 decache('config') 505 decacheModule('config')
501 } 506 }
502 507
503 purge() 508 purge()
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index b8633e83e..c61c01d62 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -223,7 +223,7 @@ const SCHEDULER_INTERVALS_MS = {
223 REMOVE_OLD_VIEWS: 60000 * 60 * 24, // 1 day 223 REMOVE_OLD_VIEWS: 60000 * 60 * 24, // 1 day
224 REMOVE_OLD_HISTORY: 60000 * 60 * 24, // 1 day 224 REMOVE_OLD_HISTORY: 60000 * 60 * 24, // 1 day
225 UPDATE_INBOX_STATS: 1000 * 60, // 1 minute 225 UPDATE_INBOX_STATS: 1000 * 60, // 1 minute
226 REMOVE_DANGLING_RESUMABLE_UPLOADS: 60000 * 60 * 16 // 16 hours 226 REMOVE_DANGLING_RESUMABLE_UPLOADS: 60000 * 60 // 1 hour
227} 227}
228 228
229// --------------------------------------------------------------------------- 229// ---------------------------------------------------------------------------
diff --git a/server/lib/blocklist.ts b/server/lib/blocklist.ts
index d6b684015..98273a6ea 100644
--- a/server/lib/blocklist.ts
+++ b/server/lib/blocklist.ts
@@ -40,12 +40,12 @@ async function isBlockedByServerOrAccount (targetAccount: MAccountServer, userAc
40 40
41 if (userAccount) sourceAccounts.push(userAccount.id) 41 if (userAccount) sourceAccounts.push(userAccount.id)
42 42
43 const accountMutedHash = await AccountBlocklistModel.isAccountMutedByMulti(sourceAccounts, targetAccount.id) 43 const accountMutedHash = await AccountBlocklistModel.isAccountMutedByAccounts(sourceAccounts, targetAccount.id)
44 if (accountMutedHash[serverAccountId] || (userAccount && accountMutedHash[userAccount.id])) { 44 if (accountMutedHash[serverAccountId] || (userAccount && accountMutedHash[userAccount.id])) {
45 return true 45 return true
46 } 46 }
47 47
48 const instanceMutedHash = await ServerBlocklistModel.isServerMutedByMulti(sourceAccounts, targetAccount.Actor.serverId) 48 const instanceMutedHash = await ServerBlocklistModel.isServerMutedByAccounts(sourceAccounts, targetAccount.Actor.serverId)
49 if (instanceMutedHash[serverAccountId] || (userAccount && instanceMutedHash[userAccount.id])) { 49 if (instanceMutedHash[serverAccountId] || (userAccount && instanceMutedHash[userAccount.id])) {
50 return true 50 return true
51 } 51 }
diff --git a/server/lib/client-html.ts b/server/lib/client-html.ts
index 360b4667f..adc3d712e 100644
--- a/server/lib/client-html.ts
+++ b/server/lib/client-html.ts
@@ -350,10 +350,6 @@ class ClientHtml {
350 return join(__dirname, '../../../client/dist/standalone/videos/embed.html') 350 return join(__dirname, '../../../client/dist/standalone/videos/embed.html')
351 } 351 }
352 352
353 private static addHtmlLang (htmlStringPage: string, paramLang: string) {
354 return htmlStringPage.replace('<html>', `<html lang="${paramLang}">`)
355 }
356
357 private static addManifestContentHash (htmlStringPage: string) { 353 private static addManifestContentHash (htmlStringPage: string) {
358 return htmlStringPage.replace('[manifestContentHash]', FILES_CONTENT_HASH.MANIFEST) 354 return htmlStringPage.replace('[manifestContentHash]', FILES_CONTENT_HASH.MANIFEST)
359 } 355 }
diff --git a/server/lib/job-queue/handlers/move-to-object-storage.ts b/server/lib/job-queue/handlers/move-to-object-storage.ts
index 54a7c566b..b5eea0184 100644
--- a/server/lib/job-queue/handlers/move-to-object-storage.ts
+++ b/server/lib/job-queue/handlers/move-to-object-storage.ts
@@ -2,7 +2,7 @@ import { Job } from 'bull'
2import { remove } from 'fs-extra' 2import { remove } from 'fs-extra'
3import { join } from 'path' 3import { join } from 'path'
4import { logger } from '@server/helpers/logger' 4import { logger } from '@server/helpers/logger'
5import { updateTorrentUrls } from '@server/helpers/webtorrent' 5import { updateTorrentMetadata } from '@server/helpers/webtorrent'
6import { CONFIG } from '@server/initializers/config' 6import { CONFIG } from '@server/initializers/config'
7import { P2P_MEDIA_LOADER_PEER_VERSION } from '@server/initializers/constants' 7import { P2P_MEDIA_LOADER_PEER_VERSION } from '@server/initializers/constants'
8import { storeHLSFile, storeWebTorrentFile } from '@server/lib/object-storage' 8import { storeHLSFile, storeWebTorrentFile } from '@server/lib/object-storage'
@@ -113,7 +113,7 @@ async function onFileMoved (options: {
113 file.fileUrl = fileUrl 113 file.fileUrl = fileUrl
114 file.storage = VideoStorage.OBJECT_STORAGE 114 file.storage = VideoStorage.OBJECT_STORAGE
115 115
116 await updateTorrentUrls(videoOrPlaylist, file) 116 await updateTorrentMetadata(videoOrPlaylist, file)
117 await file.save() 117 await file.save()
118 118
119 logger.debug('Removing %s because it\'s now on object storage', oldPath) 119 logger.debug('Removing %s because it\'s now on object storage', oldPath)
diff --git a/server/lib/notifier/shared/comment/comment-mention.ts b/server/lib/notifier/shared/comment/comment-mention.ts
index 4f84d8dea..765cbaad9 100644
--- a/server/lib/notifier/shared/comment/comment-mention.ts
+++ b/server/lib/notifier/shared/comment/comment-mention.ts
@@ -47,8 +47,8 @@ export class CommentMention extends AbstractNotification <MCommentOwnerVideo, MU
47 47
48 const sourceAccounts = this.users.map(u => u.Account.id).concat([ this.serverAccountId ]) 48 const sourceAccounts = this.users.map(u => u.Account.id).concat([ this.serverAccountId ])
49 49
50 this.accountMutedHash = await AccountBlocklistModel.isAccountMutedByMulti(sourceAccounts, this.payload.accountId) 50 this.accountMutedHash = await AccountBlocklistModel.isAccountMutedByAccounts(sourceAccounts, this.payload.accountId)
51 this.instanceMutedHash = await ServerBlocklistModel.isServerMutedByMulti(sourceAccounts, this.payload.Account.Actor.serverId) 51 this.instanceMutedHash = await ServerBlocklistModel.isServerMutedByAccounts(sourceAccounts, this.payload.Account.Actor.serverId)
52 } 52 }
53 53
54 log () { 54 log () {
diff --git a/server/lib/plugins/plugin-manager.ts b/server/lib/plugins/plugin-manager.ts
index d4d2a7edc..6c2f4764e 100644
--- a/server/lib/plugins/plugin-manager.ts
+++ b/server/lib/plugins/plugin-manager.ts
@@ -1,8 +1,8 @@
1import decache from 'decache'
2import express from 'express' 1import express from 'express'
3import { createReadStream, createWriteStream } from 'fs' 2import { createReadStream, createWriteStream } from 'fs'
4import { ensureDir, outputFile, readJSON } from 'fs-extra' 3import { ensureDir, outputFile, readJSON } from 'fs-extra'
5import { basename, join } from 'path' 4import { basename, join } from 'path'
5import { decachePlugin } from '@server/helpers/decache'
6import { MOAuthTokenUser, MUser } from '@server/types/models' 6import { MOAuthTokenUser, MUser } from '@server/types/models'
7import { getCompleteLocale } from '@shared/core-utils' 7import { getCompleteLocale } from '@shared/core-utils'
8import { ClientScript, PluginPackageJson, PluginTranslation, PluginTranslationPaths, RegisterServerHookOptions } from '@shared/models' 8import { ClientScript, PluginPackageJson, PluginTranslation, PluginTranslationPaths, RegisterServerHookOptions } from '@shared/models'
@@ -420,7 +420,7 @@ export class PluginManager implements ServerHook {
420 420
421 // Delete cache if needed 421 // Delete cache if needed
422 const modulePath = join(pluginPath, packageJSON.library) 422 const modulePath = join(pluginPath, packageJSON.library)
423 decache(modulePath) 423 decachePlugin(pluginPath, modulePath)
424 const library: PluginLibrary = require(modulePath) 424 const library: PluginLibrary = require(modulePath)
425 425
426 if (!isLibraryCodeValid(library)) { 426 if (!isLibraryCodeValid(library)) {
diff --git a/server/lib/schedulers/remove-dangling-resumable-uploads-scheduler.ts b/server/lib/schedulers/remove-dangling-resumable-uploads-scheduler.ts
index d6e561cad..61e93eafa 100644
--- a/server/lib/schedulers/remove-dangling-resumable-uploads-scheduler.ts
+++ b/server/lib/schedulers/remove-dangling-resumable-uploads-scheduler.ts
@@ -1,9 +1,7 @@
1import { map } from 'bluebird' 1
2import { readdir, remove, stat } from 'fs-extra'
3import { logger, loggerTagsFactory } from '@server/helpers/logger' 2import { logger, loggerTagsFactory } from '@server/helpers/logger'
4import { getResumableUploadPath } from '@server/helpers/upload'
5import { SCHEDULER_INTERVALS_MS } from '@server/initializers/constants' 3import { SCHEDULER_INTERVALS_MS } from '@server/initializers/constants'
6import { METAFILE_EXTNAME } from '@uploadx/core' 4import { uploadx } from '../uploadx'
7import { AbstractScheduler } from './abstract-scheduler' 5import { AbstractScheduler } from './abstract-scheduler'
8 6
9const lTags = loggerTagsFactory('scheduler', 'resumable-upload', 'cleaner') 7const lTags = loggerTagsFactory('scheduler', 'resumable-upload', 'cleaner')
@@ -22,36 +20,17 @@ export class RemoveDanglingResumableUploadsScheduler extends AbstractScheduler {
22 } 20 }
23 21
24 protected async internalExecute () { 22 protected async internalExecute () {
25 const path = getResumableUploadPath() 23 logger.debug('Removing dangling resumable uploads', lTags())
26 const files = await readdir(path)
27
28 const metafiles = files.filter(f => f.endsWith(METAFILE_EXTNAME))
29 24
30 if (metafiles.length === 0) return 25 const now = new Date().getTime()
31
32 logger.debug('Reading resumable video upload folder %s with %d files', path, metafiles.length, lTags())
33 26
34 try { 27 try {
35 await map(metafiles, metafile => { 28 // Remove files that were not updated since the last execution
36 return this.deleteIfOlderThan(metafile, this.lastExecutionTimeMs) 29 await uploadx.storage.purge(now - this.lastExecutionTimeMs)
37 }, { concurrency: 5 })
38 } catch (error) { 30 } catch (error) {
39 logger.error('Failed to handle file during resumable video upload folder cleanup', { error, ...lTags() }) 31 logger.error('Failed to handle file during resumable video upload folder cleanup', { error, ...lTags() })
40 } finally { 32 } finally {
41 this.lastExecutionTimeMs = new Date().getTime() 33 this.lastExecutionTimeMs = now
42 }
43 }
44
45 private async deleteIfOlderThan (metafile: string, olderThan: number) {
46 const metafilePath = getResumableUploadPath(metafile)
47 const statResult = await stat(metafilePath)
48
49 // Delete uploads that started since a long time
50 if (statResult.ctimeMs < olderThan) {
51 await remove(metafilePath)
52
53 const datafile = metafilePath.replace(new RegExp(`${METAFILE_EXTNAME}$`), '')
54 await remove(datafile)
55 } 34 }
56 } 35 }
57 36
diff --git a/server/lib/server-config-manager.ts b/server/lib/server-config-manager.ts
index bdf6492f9..6aa459f82 100644
--- a/server/lib/server-config-manager.ts
+++ b/server/lib/server-config-manager.ts
@@ -47,6 +47,11 @@ class ServerConfigManager {
47 miniature: { 47 miniature: {
48 preferAuthorDisplayName: CONFIG.CLIENT.VIDEOS.MINIATURE.PREFER_AUTHOR_DISPLAY_NAME 48 preferAuthorDisplayName: CONFIG.CLIENT.VIDEOS.MINIATURE.PREFER_AUTHOR_DISPLAY_NAME
49 } 49 }
50 },
51 menu: {
52 login: {
53 redirectOnSingleExternalAuth: CONFIG.CLIENT.MENU.LOGIN.REDIRECT_ON_SINGLE_EXTERNAL_AUTH
54 }
50 } 55 }
51 }, 56 },
52 57
diff --git a/server/lib/uploadx.ts b/server/lib/uploadx.ts
new file mode 100644
index 000000000..11b1044db
--- /dev/null
+++ b/server/lib/uploadx.ts
@@ -0,0 +1,10 @@
1import express from 'express'
2import { getResumableUploadPath } from '@server/helpers/upload'
3import { Uploadx } from '@uploadx/core'
4
5const uploadx = new Uploadx({ directory: getResumableUploadPath() })
6uploadx.getUserId = (_, res: express.Response) => res.locals.oauth?.token.user.id
7
8export {
9 uploadx
10}
diff --git a/server/middlewares/error.ts b/server/middlewares/error.ts
index 6c52ce7bd..34c87a26d 100644
--- a/server/middlewares/error.ts
+++ b/server/middlewares/error.ts
@@ -1,5 +1,6 @@
1import express from 'express' 1import express from 'express'
2import { ProblemDocument, ProblemDocumentExtension } from 'http-problem-details' 2import { ProblemDocument, ProblemDocumentExtension } from 'http-problem-details'
3import { logger } from '@server/helpers/logger'
3import { HttpStatusCode } from '@shared/models' 4import { HttpStatusCode } from '@shared/models'
4 5
5function apiFailMiddleware (req: express.Request, res: express.Response, next: express.NextFunction) { 6function apiFailMiddleware (req: express.Request, res: express.Response, next: express.NextFunction) {
@@ -18,7 +19,8 @@ function apiFailMiddleware (req: express.Request, res: express.Response, next: e
18 19
19 res.status(status) 20 res.status(status)
20 res.setHeader('Content-Type', 'application/problem+json') 21 res.setHeader('Content-Type', 'application/problem+json')
21 res.json(new ProblemDocument({ 22
23 const json = new ProblemDocument({
22 status, 24 status,
23 title, 25 title,
24 instance, 26 instance,
@@ -28,7 +30,11 @@ function apiFailMiddleware (req: express.Request, res: express.Response, next: e
28 type: type 30 type: type
29 ? `https://docs.joinpeertube.org/api-rest-reference.html#section/Errors/${type}` 31 ? `https://docs.joinpeertube.org/api-rest-reference.html#section/Errors/${type}`
30 : undefined 32 : undefined
31 }, extension)) 33 }, extension)
34
35 logger.debug('Bad HTTP request.', { json })
36
37 res.json(json)
32 } 38 }
33 39
34 if (next) next() 40 if (next) next()
diff --git a/server/middlewares/validators/blocklist.ts b/server/middlewares/validators/blocklist.ts
index b7749e204..12980ced4 100644
--- a/server/middlewares/validators/blocklist.ts
+++ b/server/middlewares/validators/blocklist.ts
@@ -1,8 +1,10 @@
1import express from 'express' 1import express from 'express'
2import { body, param } from 'express-validator' 2import { body, param, query } from 'express-validator'
3import { areValidActorHandles } from '@server/helpers/custom-validators/activitypub/actor'
4import { toArray } from '@server/helpers/custom-validators/misc'
3import { getServerActor } from '@server/models/application/application' 5import { getServerActor } from '@server/models/application/application'
4import { HttpStatusCode } from '../../../shared/models/http/http-error-codes' 6import { HttpStatusCode } from '../../../shared/models/http/http-error-codes'
5import { isHostValid } from '../../helpers/custom-validators/servers' 7import { isEachUniqueHostValid, isHostValid } from '../../helpers/custom-validators/servers'
6import { logger } from '../../helpers/logger' 8import { logger } from '../../helpers/logger'
7import { WEBSERVER } from '../../initializers/constants' 9import { WEBSERVER } from '../../initializers/constants'
8import { AccountBlocklistModel } from '../../models/account/account-blocklist' 10import { AccountBlocklistModel } from '../../models/account/account-blocklist'
@@ -123,6 +125,26 @@ const unblockServerByServerValidator = [
123 } 125 }
124] 126]
125 127
128const blocklistStatusValidator = [
129 query('hosts')
130 .optional()
131 .customSanitizer(toArray)
132 .custom(isEachUniqueHostValid).withMessage('Should have a valid hosts array'),
133
134 query('accounts')
135 .optional()
136 .customSanitizer(toArray)
137 .custom(areValidActorHandles).withMessage('Should have a valid accounts array'),
138
139 (req: express.Request, res: express.Response, next: express.NextFunction) => {
140 logger.debug('Checking blocklistStatusValidator parameters', { query: req.query })
141
142 if (areValidationErrors(req, res)) return
143
144 return next()
145 }
146]
147
126// --------------------------------------------------------------------------- 148// ---------------------------------------------------------------------------
127 149
128export { 150export {
@@ -131,7 +153,8 @@ export {
131 unblockAccountByAccountValidator, 153 unblockAccountByAccountValidator,
132 unblockServerByAccountValidator, 154 unblockServerByAccountValidator,
133 unblockAccountByServerValidator, 155 unblockAccountByServerValidator,
134 unblockServerByServerValidator 156 unblockServerByServerValidator,
157 blocklistStatusValidator
135} 158}
136 159
137// --------------------------------------------------------------------------- 160// ---------------------------------------------------------------------------
diff --git a/server/middlewares/validators/plugins.ts b/server/middlewares/validators/plugins.ts
index 21171af23..c1e9ebefb 100644
--- a/server/middlewares/validators/plugins.ts
+++ b/server/middlewares/validators/plugins.ts
@@ -116,6 +116,9 @@ const installOrUpdatePluginValidator = [
116 body('npmName') 116 body('npmName')
117 .optional() 117 .optional()
118 .custom(isNpmPluginNameValid).withMessage('Should have a valid npm name'), 118 .custom(isNpmPluginNameValid).withMessage('Should have a valid npm name'),
119 body('pluginVersion')
120 .optional()
121 .custom(isPluginVersionValid).withMessage('Should have a valid plugin version'),
119 body('path') 122 body('path')
120 .optional() 123 .optional()
121 .custom(isSafePath).withMessage('Should have a valid safe path'), 124 .custom(isSafePath).withMessage('Should have a valid safe path'),
@@ -129,6 +132,9 @@ const installOrUpdatePluginValidator = [
129 if (!body.path && !body.npmName) { 132 if (!body.path && !body.npmName) {
130 return res.fail({ message: 'Should have either a npmName or a path' }) 133 return res.fail({ message: 'Should have either a npmName or a path' })
131 } 134 }
135 if (body.pluginVersion && !body.npmName) {
136 return res.fail({ message: 'Should have a npmName when specifying a pluginVersion' })
137 }
132 138
133 return next() 139 return next()
134 } 140 }
diff --git a/server/models/account/account-blocklist.ts b/server/models/account/account-blocklist.ts
index b2375b006..21983428a 100644
--- a/server/models/account/account-blocklist.ts
+++ b/server/models/account/account-blocklist.ts
@@ -1,11 +1,12 @@
1import { Op } from 'sequelize' 1import { Op, QueryTypes } from 'sequelize'
2import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' 2import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
3import { handlesToNameAndHost } from '@server/helpers/actors'
3import { MAccountBlocklist, MAccountBlocklistAccounts, MAccountBlocklistFormattable } from '@server/types/models' 4import { MAccountBlocklist, MAccountBlocklistAccounts, MAccountBlocklistFormattable } from '@server/types/models'
4import { AttributesOnly } from '@shared/core-utils' 5import { AttributesOnly } from '@shared/core-utils'
5import { AccountBlock } from '../../../shared/models' 6import { AccountBlock } from '../../../shared/models'
6import { ActorModel } from '../actor/actor' 7import { ActorModel } from '../actor/actor'
7import { ServerModel } from '../server/server' 8import { ServerModel } from '../server/server'
8import { getSort, searchAttribute } from '../utils' 9import { createSafeIn, getSort, searchAttribute } from '../utils'
9import { AccountModel } from './account' 10import { AccountModel } from './account'
10 11
11enum ScopeNames { 12enum ScopeNames {
@@ -77,7 +78,7 @@ export class AccountBlocklistModel extends Model<Partial<AttributesOnly<AccountB
77 }) 78 })
78 BlockedAccount: AccountModel 79 BlockedAccount: AccountModel
79 80
80 static isAccountMutedByMulti (accountIds: number[], targetAccountId: number) { 81 static isAccountMutedByAccounts (accountIds: number[], targetAccountId: number) {
81 const query = { 82 const query = {
82 attributes: [ 'accountId', 'id' ], 83 attributes: [ 'accountId', 'id' ],
83 where: { 84 where: {
@@ -187,6 +188,39 @@ export class AccountBlocklistModel extends Model<Partial<AttributesOnly<AccountB
187 .then(entries => entries.map(e => `${e.BlockedAccount.Actor.preferredUsername}@${e.BlockedAccount.Actor.Server.host}`)) 188 .then(entries => entries.map(e => `${e.BlockedAccount.Actor.preferredUsername}@${e.BlockedAccount.Actor.Server.host}`))
188 } 189 }
189 190
191 static getBlockStatus (byAccountIds: number[], handles: string[]): Promise<{ name: string, host: string, accountId: number }[]> {
192 const sanitizedHandles = handlesToNameAndHost(handles)
193
194 const localHandles = sanitizedHandles.filter(h => !h.host)
195 .map(h => h.name)
196
197 const remoteHandles = sanitizedHandles.filter(h => !!h.host)
198 .map(h => ([ h.name, h.host ]))
199
200 const handlesWhere: string[] = []
201
202 if (localHandles.length !== 0) {
203 handlesWhere.push(`("actor"."preferredUsername" IN (:localHandles) AND "server"."id" IS NULL)`)
204 }
205
206 if (remoteHandles.length !== 0) {
207 handlesWhere.push(`(("actor"."preferredUsername", "server"."host") IN (:remoteHandles))`)
208 }
209
210 const rawQuery = `SELECT "accountBlocklist"."accountId", "actor"."preferredUsername" AS "name", "server"."host" ` +
211 `FROM "accountBlocklist" ` +
212 `INNER JOIN "account" ON "account"."id" = "accountBlocklist"."targetAccountId" ` +
213 `INNER JOIN "actor" ON "actor"."id" = "account"."actorId" ` +
214 `LEFT JOIN "server" ON "server"."id" = "actor"."serverId" ` +
215 `WHERE "accountBlocklist"."accountId" IN (${createSafeIn(AccountBlocklistModel.sequelize, byAccountIds)}) ` +
216 `AND (${handlesWhere.join(' OR ')})`
217
218 return AccountBlocklistModel.sequelize.query(rawQuery, {
219 type: QueryTypes.SELECT as QueryTypes.SELECT,
220 replacements: { byAccountIds, localHandles, remoteHandles }
221 })
222 }
223
190 toFormattedJSON (this: MAccountBlocklistFormattable): AccountBlock { 224 toFormattedJSON (this: MAccountBlocklistFormattable): AccountBlock {
191 return { 225 return {
192 byAccount: this.ByAccount.toFormattedJSON(), 226 byAccount: this.ByAccount.toFormattedJSON(),
diff --git a/server/models/server/server-blocklist.ts b/server/models/server/server-blocklist.ts
index b3579d589..092998db3 100644
--- a/server/models/server/server-blocklist.ts
+++ b/server/models/server/server-blocklist.ts
@@ -1,10 +1,10 @@
1import { Op } from 'sequelize' 1import { Op, QueryTypes } from 'sequelize'
2import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' 2import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
3import { MServerBlocklist, MServerBlocklistAccountServer, MServerBlocklistFormattable } from '@server/types/models' 3import { MServerBlocklist, MServerBlocklistAccountServer, MServerBlocklistFormattable } from '@server/types/models'
4import { AttributesOnly } from '@shared/core-utils' 4import { AttributesOnly } from '@shared/core-utils'
5import { ServerBlock } from '@shared/models' 5import { ServerBlock } from '@shared/models'
6import { AccountModel } from '../account/account' 6import { AccountModel } from '../account/account'
7import { getSort, searchAttribute } from '../utils' 7import { createSafeIn, getSort, searchAttribute } from '../utils'
8import { ServerModel } from './server' 8import { ServerModel } from './server'
9 9
10enum ScopeNames { 10enum ScopeNames {
@@ -76,7 +76,7 @@ export class ServerBlocklistModel extends Model<Partial<AttributesOnly<ServerBlo
76 }) 76 })
77 BlockedServer: ServerModel 77 BlockedServer: ServerModel
78 78
79 static isServerMutedByMulti (accountIds: number[], targetServerId: number) { 79 static isServerMutedByAccounts (accountIds: number[], targetServerId: number) {
80 const query = { 80 const query = {
81 attributes: [ 'accountId', 'id' ], 81 attributes: [ 'accountId', 'id' ],
82 where: { 82 where: {
@@ -141,6 +141,19 @@ export class ServerBlocklistModel extends Model<Partial<AttributesOnly<ServerBlo
141 .then(entries => entries.map(e => e.BlockedServer.host)) 141 .then(entries => entries.map(e => e.BlockedServer.host))
142 } 142 }
143 143
144 static getBlockStatus (byAccountIds: number[], hosts: string[]): Promise<{ host: string, accountId: number }[]> {
145 const rawQuery = `SELECT "server"."host", "serverBlocklist"."accountId" ` +
146 `FROM "serverBlocklist" ` +
147 `INNER JOIN "server" ON "server"."id" = "serverBlocklist"."targetServerId" ` +
148 `WHERE "server"."host" IN (:hosts) ` +
149 `AND "serverBlocklist"."accountId" IN (${createSafeIn(ServerBlocklistModel.sequelize, byAccountIds)})`
150
151 return ServerBlocklistModel.sequelize.query(rawQuery, {
152 type: QueryTypes.SELECT as QueryTypes.SELECT,
153 replacements: { hosts }
154 })
155 }
156
144 static listForApi (parameters: { 157 static listForApi (parameters: {
145 start: number 158 start: number
146 count: number 159 count: number
diff --git a/server/models/video/video-playlist-element.ts b/server/models/video/video-playlist-element.ts
index 82c832188..a87b2bcae 100644
--- a/server/models/video/video-playlist-element.ts
+++ b/server/models/video/video-playlist-element.ts
@@ -276,7 +276,7 @@ export class VideoPlaylistElementModel extends Model<Partial<AttributesOnly<Vide
276 } 276 }
277 277
278 const positionQuery = Sequelize.literal(`${newPosition} + "position" - ${firstPosition}`) 278 const positionQuery = Sequelize.literal(`${newPosition} + "position" - ${firstPosition}`)
279 return VideoPlaylistElementModel.update({ position: positionQuery as any }, query) 279 return VideoPlaylistElementModel.update({ position: positionQuery }, query)
280 } 280 }
281 281
282 static increasePositionOf ( 282 static increasePositionOf (
diff --git a/server/tests/api/check-params/blocklist.ts b/server/tests/api/check-params/blocklist.ts
index 7d5fae5cf..f72a892e2 100644
--- a/server/tests/api/check-params/blocklist.ts
+++ b/server/tests/api/check-params/blocklist.ts
@@ -481,6 +481,78 @@ describe('Test blocklist API validators', function () {
481 }) 481 })
482 }) 482 })
483 483
484 describe('When getting blocklist status', function () {
485 const path = '/api/v1/blocklist/status'
486
487 it('Should fail with a bad token', async function () {
488 await makeGetRequest({
489 url: server.url,
490 path,
491 token: 'false',
492 expectedStatus: HttpStatusCode.UNAUTHORIZED_401
493 })
494 })
495
496 it('Should fail with a bad accounts field', async function () {
497 await makeGetRequest({
498 url: server.url,
499 path,
500 query: {
501 accounts: 1
502 },
503 expectedStatus: HttpStatusCode.BAD_REQUEST_400
504 })
505
506 await makeGetRequest({
507 url: server.url,
508 path,
509 query: {
510 accounts: [ 1 ]
511 },
512 expectedStatus: HttpStatusCode.BAD_REQUEST_400
513 })
514 })
515
516 it('Should fail with a bad hosts field', async function () {
517 await makeGetRequest({
518 url: server.url,
519 path,
520 query: {
521 hosts: 1
522 },
523 expectedStatus: HttpStatusCode.BAD_REQUEST_400
524 })
525
526 await makeGetRequest({
527 url: server.url,
528 path,
529 query: {
530 hosts: [ 1 ]
531 },
532 expectedStatus: HttpStatusCode.BAD_REQUEST_400
533 })
534 })
535
536 it('Should succeed with the correct parameters', async function () {
537 await makeGetRequest({
538 url: server.url,
539 path,
540 query: {},
541 expectedStatus: HttpStatusCode.OK_200
542 })
543
544 await makeGetRequest({
545 url: server.url,
546 path,
547 query: {
548 hosts: [ 'example.com' ],
549 accounts: [ 'john@example.com' ]
550 },
551 expectedStatus: HttpStatusCode.OK_200
552 })
553 })
554 })
555
484 after(async function () { 556 after(async function () {
485 await cleanupTests(servers) 557 await cleanupTests(servers)
486 }) 558 })
diff --git a/server/tests/api/check-params/config.ts b/server/tests/api/check-params/config.ts
index d0cd7722b..a6e87730a 100644
--- a/server/tests/api/check-params/config.ts
+++ b/server/tests/api/check-params/config.ts
@@ -54,6 +54,18 @@ describe('Test config API validators', function () {
54 whitelisted: true 54 whitelisted: true
55 } 55 }
56 }, 56 },
57 client: {
58 videos: {
59 miniature: {
60 preferAuthorDisplayName: false
61 }
62 },
63 menu: {
64 login: {
65 redirectOnSingleExternalAuth: false
66 }
67 }
68 },
57 cache: { 69 cache: {
58 previews: { 70 previews: {
59 size: 2 71 size: 2
diff --git a/server/tests/api/check-params/plugins.ts b/server/tests/api/check-params/plugins.ts
index 33f84ecbc..2c436376c 100644
--- a/server/tests/api/check-params/plugins.ts
+++ b/server/tests/api/check-params/plugins.ts
@@ -30,7 +30,7 @@ describe('Test server plugins API validators', function () {
30 // --------------------------------------------------------------- 30 // ---------------------------------------------------------------
31 31
32 before(async function () { 32 before(async function () {
33 this.timeout(30000) 33 this.timeout(60000)
34 34
35 server = await createSingleServer(1) 35 server = await createSingleServer(1)
36 36
diff --git a/server/tests/api/moderation/blocklist.ts b/server/tests/api/moderation/blocklist.ts
index 089af8b15..b3fd8ecac 100644
--- a/server/tests/api/moderation/blocklist.ts
+++ b/server/tests/api/moderation/blocklist.ts
@@ -254,6 +254,45 @@ describe('Test blocklist', function () {
254 } 254 }
255 }) 255 })
256 256
257 it('Should get blocked status', async function () {
258 const remoteHandle = 'user2@' + servers[1].host
259 const localHandle = 'user1@' + servers[0].host
260 const unknownHandle = 'user5@' + servers[0].host
261
262 {
263 const status = await command.getStatus({ accounts: [ remoteHandle ] })
264 expect(Object.keys(status.accounts)).to.have.lengthOf(1)
265 expect(status.accounts[remoteHandle].blockedByUser).to.be.false
266 expect(status.accounts[remoteHandle].blockedByServer).to.be.false
267
268 expect(Object.keys(status.hosts)).to.have.lengthOf(0)
269 }
270
271 {
272 const status = await command.getStatus({ token: servers[0].accessToken, accounts: [ remoteHandle ] })
273 expect(Object.keys(status.accounts)).to.have.lengthOf(1)
274 expect(status.accounts[remoteHandle].blockedByUser).to.be.true
275 expect(status.accounts[remoteHandle].blockedByServer).to.be.false
276
277 expect(Object.keys(status.hosts)).to.have.lengthOf(0)
278 }
279
280 {
281 const status = await command.getStatus({ token: servers[0].accessToken, accounts: [ localHandle, remoteHandle, unknownHandle ] })
282 expect(Object.keys(status.accounts)).to.have.lengthOf(3)
283
284 for (const handle of [ localHandle, remoteHandle ]) {
285 expect(status.accounts[handle].blockedByUser).to.be.true
286 expect(status.accounts[handle].blockedByServer).to.be.false
287 }
288
289 expect(status.accounts[unknownHandle].blockedByUser).to.be.false
290 expect(status.accounts[unknownHandle].blockedByServer).to.be.false
291
292 expect(Object.keys(status.hosts)).to.have.lengthOf(0)
293 }
294 })
295
257 it('Should not allow a remote blocked user to comment my videos', async function () { 296 it('Should not allow a remote blocked user to comment my videos', async function () {
258 this.timeout(60000) 297 this.timeout(60000)
259 298
@@ -434,6 +473,35 @@ describe('Test blocklist', function () {
434 expect(block.blockedServer.host).to.equal('localhost:' + servers[1].port) 473 expect(block.blockedServer.host).to.equal('localhost:' + servers[1].port)
435 }) 474 })
436 475
476 it('Should get blocklist status', async function () {
477 const blockedServer = servers[1].host
478 const notBlockedServer = 'example.com'
479
480 {
481 const status = await command.getStatus({ hosts: [ blockedServer, notBlockedServer ] })
482 expect(Object.keys(status.accounts)).to.have.lengthOf(0)
483
484 expect(Object.keys(status.hosts)).to.have.lengthOf(2)
485 expect(status.hosts[blockedServer].blockedByUser).to.be.false
486 expect(status.hosts[blockedServer].blockedByServer).to.be.false
487
488 expect(status.hosts[notBlockedServer].blockedByUser).to.be.false
489 expect(status.hosts[notBlockedServer].blockedByServer).to.be.false
490 }
491
492 {
493 const status = await command.getStatus({ token: servers[0].accessToken, hosts: [ blockedServer, notBlockedServer ] })
494 expect(Object.keys(status.accounts)).to.have.lengthOf(0)
495
496 expect(Object.keys(status.hosts)).to.have.lengthOf(2)
497 expect(status.hosts[blockedServer].blockedByUser).to.be.true
498 expect(status.hosts[blockedServer].blockedByServer).to.be.false
499
500 expect(status.hosts[notBlockedServer].blockedByUser).to.be.false
501 expect(status.hosts[notBlockedServer].blockedByServer).to.be.false
502 }
503 })
504
437 it('Should unblock the remote server', async function () { 505 it('Should unblock the remote server', async function () {
438 await command.removeFromMyBlocklist({ server: 'localhost:' + servers[1].port }) 506 await command.removeFromMyBlocklist({ server: 'localhost:' + servers[1].port })
439 }) 507 })
@@ -575,6 +643,27 @@ describe('Test blocklist', function () {
575 } 643 }
576 }) 644 })
577 645
646 it('Should get blocked status', async function () {
647 const remoteHandle = 'user2@' + servers[1].host
648 const localHandle = 'user1@' + servers[0].host
649 const unknownHandle = 'user5@' + servers[0].host
650
651 for (const token of [ undefined, servers[0].accessToken ]) {
652 const status = await command.getStatus({ token, accounts: [ localHandle, remoteHandle, unknownHandle ] })
653 expect(Object.keys(status.accounts)).to.have.lengthOf(3)
654
655 for (const handle of [ localHandle, remoteHandle ]) {
656 expect(status.accounts[handle].blockedByUser).to.be.false
657 expect(status.accounts[handle].blockedByServer).to.be.true
658 }
659
660 expect(status.accounts[unknownHandle].blockedByUser).to.be.false
661 expect(status.accounts[unknownHandle].blockedByServer).to.be.false
662
663 expect(Object.keys(status.hosts)).to.have.lengthOf(0)
664 }
665 })
666
578 it('Should unblock the remote account', async function () { 667 it('Should unblock the remote account', async function () {
579 await command.removeFromServerBlocklist({ account: 'user2@localhost:' + servers[1].port }) 668 await command.removeFromServerBlocklist({ account: 'user2@localhost:' + servers[1].port })
580 }) 669 })
@@ -620,6 +709,7 @@ describe('Test blocklist', function () {
620 }) 709 })
621 710
622 describe('When managing server blocklist', function () { 711 describe('When managing server blocklist', function () {
712
623 it('Should list all videos', async function () { 713 it('Should list all videos', async function () {
624 for (const token of [ userModeratorToken, servers[0].accessToken ]) { 714 for (const token of [ userModeratorToken, servers[0].accessToken ]) {
625 await checkAllVideos(servers[0], token) 715 await checkAllVideos(servers[0], token)
@@ -713,6 +803,23 @@ describe('Test blocklist', function () {
713 expect(block.blockedServer.host).to.equal('localhost:' + servers[1].port) 803 expect(block.blockedServer.host).to.equal('localhost:' + servers[1].port)
714 }) 804 })
715 805
806 it('Should get blocklist status', async function () {
807 const blockedServer = servers[1].host
808 const notBlockedServer = 'example.com'
809
810 for (const token of [ undefined, servers[0].accessToken ]) {
811 const status = await command.getStatus({ token, hosts: [ blockedServer, notBlockedServer ] })
812 expect(Object.keys(status.accounts)).to.have.lengthOf(0)
813
814 expect(Object.keys(status.hosts)).to.have.lengthOf(2)
815 expect(status.hosts[blockedServer].blockedByUser).to.be.false
816 expect(status.hosts[blockedServer].blockedByServer).to.be.true
817
818 expect(status.hosts[notBlockedServer].blockedByUser).to.be.false
819 expect(status.hosts[notBlockedServer].blockedByServer).to.be.false
820 }
821 })
822
716 it('Should unblock the remote server', async function () { 823 it('Should unblock the remote server', async function () {
717 await command.removeFromServerBlocklist({ server: 'localhost:' + servers[1].port }) 824 await command.removeFromServerBlocklist({ server: 'localhost:' + servers[1].port })
718 }) 825 })
diff --git a/server/tests/api/notifications/user-notifications.ts b/server/tests/api/notifications/user-notifications.ts
index 468efdf35..9af20843e 100644
--- a/server/tests/api/notifications/user-notifications.ts
+++ b/server/tests/api/notifications/user-notifications.ts
@@ -267,7 +267,7 @@ describe('Test user notifications', function () {
267 }) 267 })
268 268
269 it('Should send a notification when an imported video is transcoded', async function () { 269 it('Should send a notification when an imported video is transcoded', async function () {
270 this.timeout(50000) 270 this.timeout(120000)
271 271
272 const name = 'video import ' + buildUUID() 272 const name = 'video import ' + buildUUID()
273 273
diff --git a/server/tests/api/server/config.ts b/server/tests/api/server/config.ts
index ea524723c..96ec17b0f 100644
--- a/server/tests/api/server/config.ts
+++ b/server/tests/api/server/config.ts
@@ -43,6 +43,9 @@ function checkInitialConfig (server: PeerTubeServer, data: CustomConfig) {
43 expect(data.services.twitter.username).to.equal('@Chocobozzz') 43 expect(data.services.twitter.username).to.equal('@Chocobozzz')
44 expect(data.services.twitter.whitelisted).to.be.false 44 expect(data.services.twitter.whitelisted).to.be.false
45 45
46 expect(data.client.videos.miniature.preferAuthorDisplayName).to.be.false
47 expect(data.client.menu.login.redirectOnSingleExternalAuth).to.be.false
48
46 expect(data.cache.previews.size).to.equal(1) 49 expect(data.cache.previews.size).to.equal(1)
47 expect(data.cache.captions.size).to.equal(1) 50 expect(data.cache.captions.size).to.equal(1)
48 expect(data.cache.torrents.size).to.equal(1) 51 expect(data.cache.torrents.size).to.equal(1)
@@ -138,6 +141,9 @@ function checkUpdatedConfig (data: CustomConfig) {
138 expect(data.services.twitter.username).to.equal('@Kuja') 141 expect(data.services.twitter.username).to.equal('@Kuja')
139 expect(data.services.twitter.whitelisted).to.be.true 142 expect(data.services.twitter.whitelisted).to.be.true
140 143
144 expect(data.client.videos.miniature.preferAuthorDisplayName).to.be.true
145 expect(data.client.menu.login.redirectOnSingleExternalAuth).to.be.true
146
141 expect(data.cache.previews.size).to.equal(2) 147 expect(data.cache.previews.size).to.equal(2)
142 expect(data.cache.captions.size).to.equal(3) 148 expect(data.cache.captions.size).to.equal(3)
143 expect(data.cache.torrents.size).to.equal(4) 149 expect(data.cache.torrents.size).to.equal(4)
@@ -246,6 +252,18 @@ const newCustomConfig: CustomConfig = {
246 whitelisted: true 252 whitelisted: true
247 } 253 }
248 }, 254 },
255 client: {
256 videos: {
257 miniature: {
258 preferAuthorDisplayName: true
259 }
260 },
261 menu: {
262 login: {
263 redirectOnSingleExternalAuth: true
264 }
265 }
266 },
249 cache: { 267 cache: {
250 previews: { 268 previews: {
251 size: 2 269 size: 2
diff --git a/server/tests/cli/peertube.ts b/server/tests/cli/peertube.ts
index f2a984962..3ac440f84 100644
--- a/server/tests/cli/peertube.ts
+++ b/server/tests/cli/peertube.ts
@@ -207,6 +207,25 @@ describe('Test CLI wrapper', function () {
207 207
208 expect(res).to.not.contain('peertube-plugin-hello-world') 208 expect(res).to.not.contain('peertube-plugin-hello-world')
209 }) 209 })
210
211 it('Should install a plugin in requested version', async function () {
212 this.timeout(60000)
213
214 await cliCommand.execWithEnv(`${cmd} plugins install --npm-name peertube-plugin-hello-world --plugin-version 0.0.17`)
215 })
216
217 it('Should list installed plugins, in correct version', async function () {
218 const res = await cliCommand.execWithEnv(`${cmd} plugins list`)
219
220 expect(res).to.contain('peertube-plugin-hello-world')
221 expect(res).to.contain('0.0.17')
222 })
223
224 it('Should uninstall the plugin again', async function () {
225 const res = await cliCommand.execWithEnv(`${cmd} plugins uninstall --npm-name peertube-plugin-hello-world`)
226
227 expect(res).to.not.contain('peertube-plugin-hello-world')
228 })
210 }) 229 })
211 230
212 describe('Manage video redundancies', function () { 231 describe('Manage video redundancies', function () {
diff --git a/server/tools/peertube-plugins.ts b/server/tools/peertube-plugins.ts
index ae625114d..9dd3f08c9 100644
--- a/server/tools/peertube-plugins.ts
+++ b/server/tools/peertube-plugins.ts
@@ -31,6 +31,7 @@ program
31 .option('-p, --password <token>', 'Password') 31 .option('-p, --password <token>', 'Password')
32 .option('-P --path <path>', 'Install from a path') 32 .option('-P --path <path>', 'Install from a path')
33 .option('-n, --npm-name <npmName>', 'Install from npm') 33 .option('-n, --npm-name <npmName>', 'Install from npm')
34 .option('--plugin-version <pluginVersion>', 'Specify the plugin version to install (only available when installing from npm)')
34 .action((options, command) => installPluginCLI(command, options)) 35 .action((options, command) => installPluginCLI(command, options))
35 36
36program 37program
@@ -109,7 +110,7 @@ async function installPluginCLI (command: Command, options: OptionValues) {
109 await assignToken(server, username, password) 110 await assignToken(server, username, password)
110 111
111 try { 112 try {
112 await server.plugins.install({ npmName: options.npmName, path: options.path }) 113 await server.plugins.install({ npmName: options.npmName, path: options.path, pluginVersion: options.pluginVersion })
113 } catch (err) { 114 } catch (err) {
114 console.error('Cannot install plugin.', err) 115 console.error('Cannot install plugin.', err)
115 process.exit(-1) 116 process.exit(-1)